wandres.dev
RENDIMIENTO Y ESCALA · máquinas grandes

Optimizar sin romper el modelo

Toda optimización sobre una máquina de estados cae en una de tres categorías: las que no tocan el modelo y son casi siempre correctas, las que lo simplifican legítimamente porque eliminan distinciones que nunca existieron en el dominio, y las que lo desmontan a cambio de una ganancia ilusoria. Esta lección clasifica las tres con ejemplos, explica por qué reintroducir booleanos o mutar el contexto no es una optimización sino una regresión de garantías, y propone un protocolo de cinco pasos que impide que una mejora de rendimiento se convierta en un estado imposible.

⏱ 20 min

El momento peligroso de cualquier proyecto gobernado por máquinas llega cuando alguien mide algo lento y propone quitar el modelo de en medio. La propuesta suele venir bien intencionada y con un razonamiento que suena impecable: si tres estados producen la misma pantalla, fusionémoslos; si el actor manda un mensaje que tarda un turno, escribamos directamente en la variable; si el contexto se copia, mutémoslo. Cada una de esas frases cambia el rendimiento en una magnitud despreciable y cambia las garantías del sistema en una magnitud enorme, y lo hace sin dejar rastro visible, porque el código sigue funcionando exactamente igual hasta el día en que dos eventos llegan en un orden que nadie probó. Optimizar bien un sistema de estados exige, por tanto, una taxonomía previa: saber de antemano qué categoría de cambio se está proponiendo antes de discutir si es rápido.

🎯 Al terminar esta lección sabrás
  • Clasificar cualquier optimización propuesta en una de las tres categorías según lo que toca del modelo.
  • Aplicar las mejoras que no alteran el modelo, que cubren la inmensa mayoría de los casos reales.
  • Reconocer las simplificaciones legítimas del modelo y distinguirlas de las que borran distinciones reales.
  • Seguir un protocolo de cinco pasos que impida que una mejora medida introduzca estados imposibles.

Lo que se optimiza sin tocar el modelo

Aquí vive la práctica totalidad del beneficio real, y conviene agotar esta categoría entera antes de considerar siquiera las otras dos. Son cambios en cómo se observa la máquina, en cómo se alimenta y en cómo se implementan sus piezas, pero el grafo permanece intacto: la lista de estados, las transiciones y las guardas siguen diciendo exactamente lo mismo.

Técnica Qué toca Riesgo para el modelo
Suscribirse a una rebanada con selector el observador ninguno
Amortiguar el evento en el borde antes de enviarlo el productor ninguno si la semántica lo admite
Nombrar acciones y guardas para poder perfilarlas la implementación ninguno
Mover un cálculo caro de una guarda a datos precalculados la implementación ninguno si el resultado es idéntico
Crear actores hijos bajo demanda en vez de por adelantado el ciclo de vida bajo, con parada simétrica
Virtualizar la lista que pinta el estado la vista ninguno
Desactivar la inspección en producción la instrumentación ninguno

El caso de la amortiguación merece detalle porque es la que más rendimiento devuelve por línea escrita y también la única de la lista que puede alterar la semántica si se aplica sin pensar. Amortiguar en el borde significa que el productor decide cuántos eventos merecen existir, en vez de convertir cada muestra de hardware en un macrostep.

// Antes: un evento por muestra, decenas por segundo, cada uno con su macrostep.
elemento.addEventListener('pointermove', (e) => {
  actor.send({ type: 'MOVER', x: e.clientX, y: e.clientY })
})

// Despues: una muestra por fotograma, con la ultima posicion conocida.
let pendiente: PointerEvent | null = null
elemento.addEventListener('pointermove', (e) => {
  const habiaPendiente = pendiente !== null
  pendiente = e
  if (habiaPendiente) return
  requestAnimationFrame(() => {
    const ultimo = pendiente
    pendiente = null
    if (ultimo) actor.send({ type: 'MOVER', x: ultimo.clientX, y: ultimo.clientY })
  })
})

Nótese que el grafo no se ha enterado de nada. La máquina sigue teniendo el mismo estado de arrastre, la misma transición ante el evento de movimiento y las mismas guardas; lo único que cambió es cuántas veces el mundo exterior decide hablarle. Esa es la firma de una optimización de primera categoría y también la razón de su seguridad: si mañana el perfil demuestra que la amortiguación era innecesaria, se retira la envoltura y no queda cicatriz alguna en el modelo.

La pregunta que decide si esa transformación es lícita no es de rendimiento sino de dominio: ¿le importa a la máquina el camino recorrido o solo la posición final? Si dibuja un trazo, descartar muestras intermedias cambia el resultado y la optimización es incorrecta. Si arrastra un elemento, solo importa dónde está ahora y descartar es exactamente lo correcto. La misma técnica es impecable o defectuosa según lo que el modelo afirme, que es la moraleja de todo este nivel.

ℹ️
Precalcular no es lo mismo que memoizar dentro de la guarda

Cuando una guarda resulta cara, la tentación es cachear su resultado dentro de la propia función. Es mala idea: la guarda debe ser pura y determinista para que el modelo siga siendo razonable y verificable, y una caché interna introduce estado oculto que puede desincronizarse del contexto. La forma correcta es precalcular el dato en el assign que lo produce y dejar que la guarda haga una comparación trivial. El coste se paga una vez, en la escritura, y no en cada evaluación.

Simplificaciones legítimas del modelo

Hay cambios que sí tocan el grafo y aun así son correctos, porque eliminan una distinción que el dominio nunca hizo. La prueba para reconocerlos es una sola pregunta: ¿existe algún evento que se comporte de forma distinta en los dos estados que quiero fusionar? Si la respuesta es no, los dos estados eran el mismo estado con dos nombres y fusionarlos es corregir el modelo, no degradarlo.

Tres simplificaciones caen habitualmente en esta categoría. La primera es la fusión de estados que solo se distinguían por un texto de la interfaz: si cargandoPrimeraVez y recargando aceptan los mismos eventos, tienen las mismas transiciones y las mismas invocaciones, la diferencia es un dato y pertenece al contexto, no al grafo. La segunda es la expulsión del contexto de valores derivables: guardar totalConImpuestos junto a total obliga a mantener ambos sincronizados y añade un assign por transición, cuando la derivación en el selector es más barata y no puede desincronizarse. La tercera es la sustitución de actores hijos por datos planos cuando esos hijos no tenían ciclo de vida propio, que es el corolario natural de la lección anterior.

// Antes: dos estados que ningun evento distingue.
cargandoPrimeraVez: { invoke: { src: 'leer', onDone: 'listo' } },
recargando:         { invoke: { src: 'leer', onDone: 'listo' } },

// Despues: un estado, y la distincion donde siempre debio estar.
cargando: {
  entry: assign({ esPrimera: ({ context }) => context.datos === null }),
  invoke: { src: 'leer', onDone: 'listo' },
},

La segunda de las tres, la expulsión de valores derivados, tiene además un efecto sobre la corrección que suele pasar desapercibido. Un campo derivado que vive en el contexto es una invariante que alguien debe mantener a mano en cada transición que toque sus fuentes, y basta con olvidar una para que el sistema entre en un estado internamente contradictorio del que ninguna guarda avisa. Sacarlo del contexto no solo ahorra asignaciones: elimina por construcción una clase de incoherencia. Es el caso feliz en el que la versión más rápida es también la más segura, y conviene reconocerlo porque no abunda.

Simplificación Prueba que la autoriza Ganancia real
Fusionar dos estados ningún evento se comporta distinto en ambos menos nodos, menos ramas que probar
Sacar un derivado del contexto se puede recalcular desde otros campos menos asignaciones y una invariante menos
Sustituir hijos por datos planos el hijo no tenía ciclo de vida propio menos actores vivos y menos paradas que recordar
⚠️
Cuidado con fusionar estados que sí se distinguen mañana

La fusión es correcta cuando ningún evento diferencia los dos estados hoy, pero conviene comprobar también si algo los diferencia en el horizonte inmediato del producto. Si el equipo ya sabe que la recarga deberá poder cancelarse y la carga inicial no, los estados sí eran distintos y fusionarlos obliga a deshacer el trabajo dentro de dos semanas, además de pasar por un periodo intermedio con una bandera booleana haciendo de estado encubierto. Fusiona lo que nunca se distinguió, no lo que todavía no se distingue.

Lo que no se toca

La tercera categoría reúne los cambios que se presentan como optimizaciones y son, en realidad, renuncias a las garantías que motivaron el modelo. Comparten una estructura común: todos convierten una propiedad verificada por construcción en una propiedad que depende de que nadie se equivoque después.

🚫

Volver a los booleanos sueltos

Sustituir estados por banderas independientes reintroduce el producto cartesiano completo de combinaciones, incluidas las imposibles. El ahorro es de nanosegundos y el coste es la clase entera de errores que el modelo eliminaba.

🚫

Mutar el contexto en el sitio

Modificar el objeto de contexto en lugar de producir uno nuevo rompe la comparación por identidad de la que dependen los selectores, el historial de inspección y la reproducibilidad de las trazas. Ahorra una asignación y desactiva tres mecanismos.

🚫

Escribir por fuera del actor

Tocar un valor directamente porque enviar un evento tarda un turno del buzón convierte al actor en una fuente de verdad opcional. A partir de ahí ninguna traza es completa y ningún estado es reconstruible.

Hay una cuarta, más sutil, que aparece con frecuencia en revisiones de código: saltarse una guarda porque su condición ya se comprobó en la interfaz. El argumento es tentador —el botón está deshabilitado, luego el evento no puede llegar en ese estado— y es falso por la misma razón por la que existe la validación en el servidor: la interfaz es una de las fuentes de eventos, no la única. Una prueba, una traza reproducida, un actor hermano o una versión futura del componente pueden enviar ese evento, y la guarda es lo que garantiza que el sistema se comporte bien cuando ocurra. Quitarla no acelera nada medible y traslada una invariante del modelo a una convención social.

💡
Cuando el snapshot sí se puede cachear y cuando no

Conservar la última proyección calculada de un snapshot es legítimo y útil, porque es una derivación. Conservar el snapshot mismo en una variable externa para evitar preguntar al actor no lo es, porque esa variable envejece: el actor puede haber transicionado y quien lee la copia toma decisiones sobre un pasado que ya no existe. La distinción práctica es sencilla: cachea funciones del estado, nunca el estado.

Un protocolo de cinco pasos

Todo lo anterior se convierte en práctica cuando existe un procedimiento que impide saltarse los pasos incómodos, que son precisamente los que evitan las regresiones.

flowchart TD
A[medir con un guion repetible] --> B[formular una hipotesis concreta]
B --> C{la mejora toca el grafo}
C -->|no| D[aplicar el cambio minimo]
C -->|si| E[justificar que ningun evento distingue lo que se fusiona]
E --> D
D --> F[pasar las pruebas de modelo y de recorrido]
F --> G[volver a medir con el mismo guion]
G --> H{la ganancia justifica la complejidad}
H -->|no| I[revertir sin remordimiento]
H -->|si| J[documentar el motivo junto al codigo]

Dos pasos de ese circuito son los que la prisa suele eliminar y los únicos que realmente protegen. El primero es la bifurcación que pregunta si el cambio toca el grafo, porque obliga a decir en voz alta a qué categoría pertenece la propuesta antes de escribirla. El segundo es la reversión sin remordimiento cuando la ganancia no justifica la complejidad: una optimización que mejora un dos por ciento y añade un comparador, una memoización y una nota explicativa es una pérdida neta, y admitirlo requiere haber fijado el criterio antes de medir, no después.

El modelo es la optimización, y por eso se defiende antes que la velocidad

Hay una inversión que conviene hacer explícita porque cambia por completo la conversación sobre rendimiento en sistemas de estados. Lo que llamamos modelo —estados excluyentes, transiciones declaradas, contexto inmutable, guardas puras— no es una capa de ceremonia que se paga a cambio de claridad y que, por tanto, podría venderse cuando hace falta velocidad. Es la estructura que hace posibles las únicas optimizaciones que de verdad importan. Puedes suscribirte a una rebanada porque el estado es un valor comparable y no un conjunto de variables mutables desperdigadas. Puedes amortiguar eventos en el borde porque el buzón es serial y sabes exactamente qué significa descartar una muestra. Puedes cortar un subárbol en un actor porque las fronteras están declaradas y no hay que adivinarlas leyendo doce manejadores. Puedes reproducir una traza para comparar dos versiones porque el comportamiento es determinista dado un contexto y una secuencia de eventos. Cada una de esas capacidades desaparece en el instante en que alguien mete un booleano suelto, muta el contexto o escribe por fuera del actor para ahorrarse un turno del buzón. La operación que se presentaba como optimización es en realidad una amputación de la capacidad de optimizar, y su efecto no se nota el día que se hace sino tres meses después, cuando el perfil señala algo y ya no hay ningún punto firme desde el que razonar sobre el cambio. De ahí la regla que resume el nivel entero: optimiza la observación todo lo que quieras, optimiza la implementación cuanto haga falta, simplifica el grafo cuando descubras que hacía distinciones que el dominio no hace, y no negocies jamás las garantías, porque las garantías son el activo desde el cual todo lo demás se puede tocar con seguridad. Un sistema rápido que ya no se puede razonar no es rápido: es simplemente un sistema al que todavía no le ha llegado su error de concurrencia.

⚔️ Optimiza con protocolo
  1. Escribe un guion repetible de uso real y toma la medición base de renders, tiempo de fotograma y memoria retenida.
  2. Formula tres hipótesis concretas de mejora y clasifica cada una en su categoría antes de tocar una sola línea.
  3. Aplica solo las que no tocan el grafo, vuelve a medir y anota la ganancia de cada una por separado.
  4. Busca en tu máquina dos estados que ningún evento distinga; justifica la fusión por escrito y comprueba que ninguna prueba de comportamiento cambia.
  5. Toma una propuesta de la tercera categoría, impleméntala en una rama y escribe la secuencia de eventos exacta que produce un estado imposible con ella.
  6. Revierte toda mejora cuya ganancia medida no justifique su complejidad y documenta junto al código las que sobrevivieron y por qué.