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

Cuándo un memo cuesta más de lo que ahorra

La factura completa de un nodo memoizado — memoria, aristas, comparación, indirección — y el criterio numérico para decidir si compensa, con los cuatro casos donde nunca compensa.

⏱ 16 min

La memoización tiene fama de gratis, y no lo es. Un memo añade un objeto, dos listas de aristas, una comparación por evaluación y un nivel de indirección en cada lectura. En cómputos triviales esa factura supera con holgura al ahorro, y como el ahorro es invisible mientras que el coste está repartido, casi nadie lo detecta. Esta lección pone la factura sobre la mesa y saca un criterio con números.

🎯 Al terminar esta lección sabrás
  • Enumerar los cuatro componentes del coste de un memo.
  • Aplicar el criterio numérico para decidir si compensa.
  • Reconocer los cuatro casos donde un memo nunca compensa.
  • Medir el efecto de eliminar memos innecesarios de un grafo real.

La factura, componente a componente

Memoria. Un objeto nodo con seis o siete campos, más dos colecciones para las aristas. En un motor con conjuntos hash, dos objetos adicionales por nodo aunque estén vacíos. Un grafo con diez mil memos paga treinta mil objetos de vida larga que el recolector tiene que trazar en cada marcado.

Aristas. Un memo se interpone entre sus fuentes y sus consumidores, y eso duplica el número de aristas del camino. Donde antes el consumidor leía la fuente directamente —una arista—, ahora la fuente apunta al memo y el memo al consumidor —dos aristas. Cada una cuesta memoria y cuesta contabilidad en cada reejecución.

Comparación. Una llamada a la función de igualdad por evaluación. Con Object.is es despreciable; con una comparación estructural puede ser lo más caro del nodo.

Indirección en la lectura. Cada lectura de un memo comprueba el estado, posiblemente recorre las fuentes resolviéndolas, teje la arista y devuelve. Comparado con leer una señal, son varias operaciones más. En un bucle que lee el mismo memo mil veces, esa diferencia se nota.

// Sin memo: una arista, una lectura directa
const consumidor = efecto(() => usar(precio() * 1.21));

// Con memo: dos aristas, dos nodos, una comparacion y dos lecturas
const conIva = memo(() => precio() * 1.21);
const consumidor = efecto(() => usar(conIva()));

En el segundo caso, el memo ahorra recalcular una multiplicación. Si hay un solo consumidor, el memo es puro coste: la multiplicación cuesta menos que la comparación y el salto.

El criterio numérico

Compensa cuando el ahorro esperado supera al coste. Con C el número de consumidores, T el coste del cuerpo, p la probabilidad de que el valor cambie ante un cambio de entrada, y D el número de descendientes transitivos:

ahorro = (C - 1) * T          // no recalcular por cada consumidor
       + (1 - p) * D * T_medio  // cortar la cascada cuando no cambia

coste  = T                     // el cuerpo se ejecuta igualmente al menos una vez
       + comparacion
       + contabilidad de aristas por reejecucion
       + memoria del nodo

De aquí salen las dos vías por las que un memo puede ganarse el sueldo, y merece la pena verlas separadas porque suelen confundirse.

La primera es evitar el recálculo por consumidor: solo importa si C es mayor que uno. Con un solo consumidor esta vía aporta cero.

La segunda es cortar la cascada: solo importa si p es baja y D es alto. Un memo cuyo valor cambia siempre aporta cero por esta vía, tenga los descendientes que tenga.

Un memo que falla en las dos —un consumidor, valor que siempre cambia— es puro coste. Y es, con mucha diferencia, el memo más frecuente en las aplicaciones reales.

flowchart TB
Q{el memo tiene mas de un consumidor} -->|no| Q2{su valor cambia casi siempre}
Q -->|si| G[gana por evitar recalculos]
Q2 -->|si| P[puro coste, quitalo]
Q2 -->|no| Q3{tiene muchos descendientes}
Q3 -->|si| G2[gana por cortar la cascada]
Q3 -->|no| P2[coste marginal, probablemente sobra]
style Q fill:#f9e2af,color:#11111b
style Q2 fill:#f9e2af,color:#11111b
style Q3 fill:#f9e2af,color:#11111b
style G fill:#a6e3a1,color:#11111b
style G2 fill:#a6e3a1,color:#11111b
style P fill:#f38ba8,color:#11111b
style P2 fill:#fab387,color:#11111b

Los cuatro casos donde nunca compensa

Cuerpo trivial con un consumidor. Una suma, una concatenación, una comparación. El cuerpo cuesta menos que la maquinaria del memo. Escríbelo en línea.

Valor que siempre cambia. Un memo que devuelve un objeto literal, un array derivado o una marca de tiempo, con la igualdad por defecto. No corta nunca. Si además tiene un solo consumidor, no aporta absolutamente nada.

Memo que envuelve otro memo sin transformar. Aparece al refactorizar: una capa que solo reenvía. Añade un nodo, dos aristas y una comparación para nada.

const total = memo(() => calcularTotal(elementos()));
const totalFormateado = memo(() => total());   // no hace nada, quitalo

Memo dentro de un bucle o de una lista. Un memo por fila en una lista de diez mil filas son diez mil nodos con sus aristas. Si el cuerpo es trivial, el coste de construir el grafo supera al de recalcular. La alternativa es un memo para la lista entera, aceptando un grano más grueso a cambio de menos nodos.

⚠️
Memoizar por defecto es un antipatron

Existe una escuela que dice memoizar todo por si acaso. En un motor de reconciliación ese consejo tenía cierto sentido, porque el coste de una memoización de más era bajo comparado con el de un re-render de más. En un motor de grano fino no lo tiene: el memo añade un nodo permanente al grafo, y los nodos permanentes cuestan memoria, aristas y recorridos en cada propagación. La postura sana es la inversa: no memoices hasta tener una razón, y que la razón sea uno de los dos mecanismos de ahorro, medido.

Medirlo, y cierre del nivel

Medir la eliminación

La forma honesta de saberlo es quitar memos y medir. Instrumenta el motor con dos contadores globales —cuerpos ejecutados y aristas tejidas— y compara.

let cuerpos = 0, aristas = 0;
// incrementar cuerpos en ejecutar(), aristas en seguir()

function medir(escenario) {
  cuerpos = 0; aristas = 0;
  escenario();
  return { cuerpos, aristas };
}

console.log('con memos:', medir(escenarioConMemos));
console.log('sin memos:', medir(escenarioSinMemos));

Si al quitar un memo los cuerpos ejecutados suben poco y las aristas bajan mucho, el memo sobraba. Si los cuerpos se disparan, estaba cortando y merece quedarse.

Cada memo es una apuesta sobre la estabilidad de un valor

La manera más útil de pensar un memo es como una apuesta: apuestas a que ese valor va a repetirse más veces de las que va a cambiar. Si ganas, cortas cascadas y ahorras recálculos. Si pierdes, has añadido un nodo, dos aristas y una comparación por evaluación a cambio de nada. Y como toda apuesta, tiene un valor esperado que se puede calcular y que casi nadie calcula, porque la memoización se ha convertido en un reflejo en vez de en una decisión. Hay tres consecuencias prácticas que salen de tomárselo así. Primera: la apuesta se puede medir a posteriori, con la tasa de corte de la lección 3, y quien no la mide está apostando a ciegas indefinidamente. Segunda: la apuesta cambia con el tiempo; un memo que ganaba cuando se escribió puede estar perdiendo hoy porque el patrón de uso cambió, y nadie vuelve a mirarlo. Tercera, y la más importante: en un motor de grano fino el ahorro por defecto ya es alto, así que el margen que le queda a la memoización es más estrecho que en un motor de reconciliación. Transportar el reflejo de memoizar todo desde un modelo al otro es uno de los errores más comunes al cambiar de framework, y produce grafos inflados con centenares de nodos que no cortan nada y que, medidos, resultan costar más que el problema que pretendían resolver.

Cierre del nivel

Un memo es un nodo del grafo con dos caras, evaluación perezosa, caché de un hueco e invalidación exacta. Su valor no está en cachear sino en cortar, y corta gracias al estado intermedio del nivel 4. Compensa cuando tiene varios consumidores o cuando su valor es estable y tiene muchos descendientes; en los demás casos es coste.

Lo que viene ahora es el mecanismo que distingue a esta familia de motores de todas las demás: quién posee a quién, y quién limpia cuando algo desaparece.

⚔️ Audita los memos de una pantalla real
  1. Instrumenta cuerpos ejecutados y aristas tejidas en tu motor.
  2. Lista todos los memos de una pantalla con su número de consumidores y su tasa de corte.
  3. Elimina los que tengan un consumidor y tasa de corte por debajo del diez por ciento.
  4. Vuelve a medir y comprueba cuánto bajaron las aristas y cuánto subieron los cuerpos.