wandres.dev
MEMOIZACIÓN · Cachés que se invalidan solas

El memo como cortacircuitos del grafo

El mecanismo exacto por el que un memo detiene la propagación: sus descendientes quedan en quizá sucio, y si el memo no cambia de valor nunca ascienden a sucio. Con la medida del ahorro.

⏱ 17 min

La función más valiosa de un memo no es cachear: es cortar. Un memo bien colocado convierte una cascada que afectaría a doscientos nodos en una que se detiene en el primero. El mecanismo exacto por el que ocurre está en el tercer estado del nivel 4, y verlo desde este lado es lo que convierte la memoización de un truco de rendimiento en una herramienta de diseño del grafo.

🎯 Al terminar esta lección sabrás
  • Explicar el corte a partir de los estados quizá sucio y sucio.
  • Medir el ahorro de un corte en función de los descendientes.
  • Colocar memos en los puntos donde el corte es más probable.
  • Reconocer los tipos de valor que cortan bien y los que no cortan nunca.

El mecanismo del corte

Recupera la mecánica del nivel 4 y míralo desde el memo.

Cuando una fuente cambia, sus observadores directos pasan a SUCIO y todos los descendientes indirectos a QUIZA. Esa distinción es lo que hace posible el corte: un nodo en QUIZA todavía tiene permiso para no ejecutarse.

Cuando el memo se resuelve, hay dos desenlaces posibles.

Si su valor cambia, asciende a sus observadores directos de QUIZA a SUCIO. Ahora están obligados a ejecutarse, y al hacerlo ascenderán a los suyos si también cambian. La cascada continúa.

Si su valor no cambia, no asciende a nadie. Sus observadores siguen en QUIZA, y cuando les toque resolverse recorrerán sus fuentes, no encontrarán ninguna sucia, y pasarán a LIMPIO sin ejecutar su cuerpo. Y como ellos no cambian, sus propios observadores hacen lo mismo. La cascada muere en seco.

// En ejecutar(), la unica linea que decide si la cascada sigue o se corta
if (!nodo.iguales(nodo.valor, v)) {
  nodo.valor = v;
  for (const o of nodo.observadores) {
    if (o.estado >= SUCIO) continue;
    o.estado = SUCIO;                  // ascenso: la cascada continua
    if (o.efecto) planificar(o);
  }
}
// si son iguales, no se hace nada: la cascada muere aqui
flowchart TB
F[fuente cambia] --> M[memo pasa a SUCIO]
M --> D1[descendientes a QUIZA]
M --> R{el memo produce el mismo valor}
R -->|no| A[ascender descendientes a SUCIO]
A --> E1[toda la cascada se ejecuta]
R -->|si| N[no ascender a nadie]
N --> E2[los descendientes pasan a LIMPIO sin ejecutarse]
style F fill:#89b4fa,color:#11111b
style M fill:#cba6f7,color:#11111b
style D1 fill:#f9e2af,color:#11111b
style R fill:#f9e2af,color:#11111b
style A fill:#f38ba8,color:#11111b
style E1 fill:#f38ba8,color:#11111b
style N fill:#a6e3a1,color:#11111b
style E2 fill:#a6e3a1,color:#11111b

La medida del ahorro

El ahorro de un corte es el trabajo de todo el subgrafo que cuelga del memo, multiplicado por la probabilidad de que el valor no cambie.

ahorro = descendientes_transitivos * coste_medio * (1 - probabilidad_de_cambio)
coste  = coste_del_cuerpo + coste_de_comparar + contabilidad_de_aristas

Los dos números que gobiernan son descendientes transitivos y probabilidad de que no cambie. Y es útil verlos como un producto porque explica por qué la intuición habitual falla: la gente memoiza lo que le parece caro de calcular, cuando lo que hay que memoizar es lo que tiene muchos descendientes y cambia poco.

// Mal candidato: cuerpo carisimo, un solo consumidor, cambia siempre
const transformado = memo(() => datos().map(transformacionPesada));
// El memo no ahorra nada: el consumidor lo iba a pedir igual y el valor
// siempre es distinto. Solo evita recalculos si se lee dos veces.

// Buen candidato: cuerpo trivial, ochenta consumidores, casi nunca cambia
const modoCompacto = memo(() => anchoVentana() < 720);
// El ancho cambia en cada pixel de un redimensionado; el booleano
// cambia dos veces en toda la operacion. Corta ochenta subarboles.
💡
Los booleanos son los mejores cortacircuitos que existen

Un memo que devuelve un booleano tiene la mejor propiedad posible: su espacio de valores tiene dos elementos, así que la probabilidad de que una entrada cambiada produzca un valor distinto es bajísima. Un ancho de ventana que recorre trescientos píxeles produce un solo cambio en ancho < 720. Lo mismo vale para valores enumerados pequeños: estados, modos, categorías. Si buscas dónde colocar memos, empieza por convertir magnitudes continuas en categorías discretas lo antes posible en el grafo.

Los tipos que no cortan nunca

Por el contrario, hay valores que con la igualdad por defecto no cortan jamás, y conviene reconocerlos porque producen memos que solo cuestan.

Objetos literales. Cada ejecución produce una referencia nueva, y Object.is dice que es distinta. Este memo propaga siempre.

const filtros = memo(() => ({ texto: consulta(), pagina: pagina() }));

Arrays derivados. Igual: map, filter y slice devuelven arrays nuevos.

const visibles = memo(() => todos().filter(t => !t.hecha));

Funciones y closures. Una función creada dentro del cuerpo es una referencia nueva en cada ejecución.

Los tres tienen la misma solución: o una función de igualdad a medida —con el cuidado que exige la lección anterior—, o partir el nodo en varios que devuelvan primitivos.

// En vez de un memo con un objeto, dos memos con primitivos.
// Cada uno corta por su cuenta.
const textoFiltro = memo(() => consulta().trim().toLowerCase());
const paginaActual = memo(() => Math.max(1, pagina()));

Esta última técnica es más eficaz de lo que parece y merece hacerse costumbre: descomponer un memo de objeto en memos de primitivos multiplica los puntos de corte. Cada consumidor se suscribe solo a la parte que le importa, y cada parte corta por separado.

Coloca los memos donde el grafo se estrecha, no donde el codigo es caro

La regla de colocación que sale de todo esto contradice la intuición y merece enunciarse sin rodeos: pon un memo donde el grafo se estrecha, no donde el cálculo es caro. Los puntos de estrechamiento son aquellos donde muchos consumidores dependen de un valor que se puede resumir en pocos bits. Piensa en el grafo como en una red de tuberías: un memo es una válvula, y una válvula solo tiene sentido en un punto donde de verdad puedas cerrar el paso. Colocarla en una tubería por la que siempre pasa agua no ahorra nada y añade una junta más que puede gotear. Hay un corolario que ayuda a decidir en la práctica: si un memo lleva ahí un tiempo, instruméntalo y mide su tasa de corte, es decir, qué fracción de sus evaluaciones acaban devolviendo el mismo valor. Si esa tasa está por debajo de un veinte por ciento, el memo casi seguro cuesta más de lo que ahorra y estarías mejor sin él. Es un número trivial de medir —dos contadores— y prácticamente nadie lo mide, con el resultado de que muchas aplicaciones arrastran decenas de memos que solo añaden nodos, aristas y comparaciones al grafo sin cortar nunca nada.

Instrumentar la tasa de corte

Medirlo es sencillo y vale la pena tenerlo en el modo de desarrollo del motor.

function memoMedido(nombre, fn, opciones) {
  let evaluaciones = 0, cortes = 0;
  const envuelta = (prev) => {
    evaluaciones++;
    const v = fn(prev);
    const iguales = opciones?.iguales ?? Object.is;
    if (iguales(prev, v)) cortes++;
    return v;
  };
  const m = memo(envuelta, opciones);
  m.estadisticas = () => ({ nombre, evaluaciones, cortes, tasa: cortes / evaluaciones });
  return m;
}

Ordena tus memos por tasa de corte y por número de descendientes. Los que tengan tasa alta y muchos descendientes están haciendo su trabajo. Los que tengan tasa cercana a cero son candidatos a eliminarse o a reescribirse para que devuelvan algo que sí pueda repetirse.

⚔️ Mide la tasa de corte de tus memos
  1. Envuelve tus memos con el medidor de esta lección en una pantalla real.
  2. Interactúa durante un minuto y vuelca las estadísticas ordenadas por tasa de corte.
  3. Elimina los memos con tasa cercana a cero y comprueba que el trabajo total no aumenta.
  4. Coge el memo con más descendientes y tasa baja, pártelo en primitivos y vuelve a medir.