wandres.dev
RENDIMIENTO Y ESCALA · máquinas grandes

El coste real de interpretar

La sospecha de que una máquina de estados es cara nace de la palabra intérprete, pero el trabajo que un actor ejecuta ante un evento es enumerable y diminuto: indexar el tipo del evento, evaluar unas guardas, calcular los conjuntos de salida y entrada, correr las acciones y construir un snapshot inmutable. Esta lección desmonta ese macrostep paso a paso, lo sitúa en su orden de magnitud real frente al render, el layout y la red, identifica los tres escenarios donde la interpretación sí llega a aparecer en un perfil, y propone una forma de medirla en lugar de estimarla.

⏱ 18 min

La palabra intérprete arrastra una sospecha de lentitud heredada de otros contextos —intérpretes de lenguajes, máquinas virtuales, capas de indirección— y esa sospecha se traslada sin examen al actor de XState: si cada clic atraviesa un motor genérico en vez de un if, algo tendrá que costar. La objeción merece respuesta empírica, no retórica. El trabajo que un actor realiza ante un evento es enumerable, acotado y sorprendentemente pequeño: comparar el tipo del evento contra un índice, evaluar un puñado de guardas, calcular dos conjuntos de nodos, ejecutar las acciones asociadas y construir un objeto nuevo. Nada de eso escala con el volumen de tus datos ni con el número de componentes montados; escala con la profundidad del nodo activo y con cuántas transiciones compiten por el mismo evento. Conocer ese algoritmo con precisión es lo que permite dejar de temerlo y empezar a medirlo, que es la única forma honesta de hablar de rendimiento.

🎯 Al terminar esta lección sabrás
  • Enumerar el trabajo exacto que ejecuta un actor desde que recibe un evento hasta que emite el snapshot.
  • Situar ese coste en su orden de magnitud real frente al render, el layout y la latencia de red.
  • Reconocer los tres patrones donde la interpretación sí puede llegar a dominar un perfil.
  • Medir el coste de una transición con instrumentación reproducible en vez de estimarlo por intuición.

El trabajo exacto de un macrostep

Conviene deshacer primero un malentendido de vocabulario: el actor no interpreta la definición en el sentido de recorrer una estructura sintáctica en cada evento. La normalización ocurre una sola vez, cuando setup y createMachine construyen el grafo: los estados se resuelven a nodos con identificador y padre, las transiciones se agrupan por tipo de evento, las acciones nombradas se ligan a sus implementaciones y los descriptores comodín se ordenan. Lo que queda en tiempo de ejecución es una estructura de datos ya cocinada sobre la que el macrostep hace consultas baratas.

flowchart TD
A[evento en el buzon] --> B[buscar transiciones por tipo]
B --> C[evaluar guardas en orden]
C --> D[calcular conjunto de salida]
D --> E[acciones de salida]
E --> F[acciones de la transicion y assigns]
F --> G[acciones de entrada]
G --> H[snapshot nuevo e inmutable]
H --> I[notificar a los observadores]

El esqueleto del algoritmo cabe en una veintena de líneas, y escribirlo así aclara dónde puede haber coste y dónde no lo hay por construcción.

// Esqueleto conceptual del macrostep que ejecuta el actor ante cada evento.
function macrostep(activos: Nodo[], evento: Evento, ctx: Ctx): Snapshot {
  const candidatas = porTipo(activos, evento.type)          // consulta indexada
  const elegida = candidatas.find((t) => t.guards.every((g) => g(ctx, evento)))
  if (!elegida) return sinCambios(activos, ctx)             // el caso mas comun
  const salida = nodosAbandonados(activos, elegida)         // profundidad, no tamano
  const entrada = nodosOcupados(activos, elegida)
  ejecutar(salida.flatMap((n) => n.exit), ctx, evento)
  const ctx2 = aplicarAssigns(elegida.actions, ctx, evento) // objeto nuevo, campos compartidos
  ejecutar(entrada.flatMap((n) => n.entry), ctx2, evento)
  return construirSnapshot(entrada, ctx2)
}

Antes de leer el esqueleto conviene fijar una distinción temporal que ahorra discusiones enteras: hay un coste de construcción y un coste de operación, y solo el segundo se paga por evento. Normalizar la definición, resolver los identificadores y ligar las implementaciones ocurre una vez por máquina, no una vez por transición ni una vez por actor, porque todas las instancias comparten el mismo grafo ya cocinado. Ese reparto es también la razón por la que crear la máquina dentro del cuerpo de un componente es un error de rendimiento real y no teórico: convierte un coste de construcción en un coste de render, que es justamente el intercambio contrario al que interesa.

Tres observaciones se derivan directamente de esa forma. La primera es que la selección de transiciones no barre la máquina entera: parte de los nodos activos y sube por sus ancestros, de modo que su coste depende de la profundidad de la jerarquía y no del número total de estados definidos. La segunda es que el conjunto de salida y el de entrada se calculan sobre esa misma cadena de ancestros, con lo que un statechart de cuatro niveles y trescientos nodos hace exactamente el mismo trabajo por evento que uno de cuatro niveles y treinta. La tercera, y la más olvidada, es que el camino dominante en cualquier aplicación real es el del evento que no dispara nada: se consulta el índice, no hay candidata, se devuelve el snapshot anterior sin asignar un objeto nuevo.

ℹ️
El snapshot nuevo no clona tu contexto

Cada transición produce un snapshot nuevo, y de ahí nace la segunda sospecha habitual: que se copia el estado entero en cada evento. Lo que se copia es la capa superficial. assign construye un objeto de contexto nuevo cuyos campos no modificados apuntan a las mismas referencias que antes, igual que un reducer bien escrito; no hay recorrido profundo ni serialización. El coste de una transición sobre un contexto con un array de diez mil elementos que no se toca es idéntico al de uno con un array vacío, porque lo que cambia de identidad es el envoltorio, no el contenido.

El orden de magnitud

La única comparación que importa es la que sitúa la transición junto a lo demás que ocurre en el mismo instante. Los valores siguientes son órdenes de magnitud típicos en un portátil de gama media, no promesas: sirven para razonar sobre proporciones, y la proporción es abrumadora.

Operación Orden de magnitud Quién la paga
Transición simple con una guarda unidades de microsegundo el actor
Transición con entrada y salida en tres niveles decenas de microsegundo el actor
Render de un componente React mediano cientos de microsegundo React
Reflow y pintura de una lista visible milisegundos el navegador
Lectura de una capa de almacenamiento local milisegundos el navegador
Ida y vuelta a la red en buenas condiciones decenas o cientos de milisegundos la red

La lectura correcta de esa tabla no es que la interpretación sea gratis, sino que vive entre dos y cuatro órdenes de magnitud por debajo de la operación que la acompaña. Un evento que dispara una transición y provoca un render paga en el actor una fracción de porcentaje del coste total del ciclo. Optimizar ahí es, en el sentido literal del término, ruido: aunque consiguieras que la transición costara cero, el usuario no percibiría diferencia alguna porque el presupuesto se gasta después, en el árbol de React y en el compositor del navegador.

Hay, sin embargo, una dimensión que la tabla no captura y que conviene incorporar al razonamiento: el buzón. Un actor procesa sus eventos en serie, y las acciones que uno de ellos programa —enviar a un hijo, notificar al padre, elevar un evento interno— se encolan para macrosteps posteriores en lugar de ejecutarse dentro del actual. De ahí se sigue que un evento de usuario no cuesta una transición aislada sino la cascada completa que desencadena, y que la cifra honesta a medir no es el coste de un macrostep sino el del turno entero hasta que el buzón vuelve a quedar vacío. En una máquina plana ambas cifras coinciden porque la cascada tiene longitud uno; en un sistema de varios actores encadenados pueden separarse un orden de magnitud, y solo la segunda predice lo que el usuario percibe.

💡
Cuenta cascadas, no eventos

Instrumenta el flujo de inspección para contar cuántos macrosteps produce un único gesto del usuario en tu sistema real. La cifra sorprende a menudo: un clic que parecía costar una transición desata siete porque cada actor implicado reenvía, eleva o notifica a otro. Ese número es la métrica adecuada cuando hay varios actores en juego, y de propina es el mejor detector de bucles de mensajería que se realimentan sin que nadie lo haya advertido, porque una cascada que no converge se manifiesta como una cuenta que crece sin techo.

Los tres lugares donde sí aparece

Decir que casi nunca es el cuello de botella no es decir que nunca lo sea. Hay tres patrones concretos donde la interpretación se vuelve visible en un perfil, y ninguno de ellos se debe al algoritmo en sí, sino a lo que el desarrollador colgó de él.

📈

Frecuencia, no coste unitario

Un evento por movimiento de ratón o por muestra de un sensor multiplica un coste diminuto por miles. El remedio no es hacer la transición más rápida sino no enviarla: amortigua en el borde y envía muestras agregadas.

🧮

Guardas y assigns caros

La guarda que ordena un array o el assign que reconstruye una estructura grande no son coste del intérprete: son tu código, ejecutado en el camino crítico del evento. El perfil las atribuye a la transición y la culpa recae en el sitio equivocado.

🔍

Inspección activa en producción

El flujo de inspección serializa eventos, snapshots y referencias de actor. Es la herramienta correcta en desarrollo y un impuesto permanente si se cuela en el paquete de producción sin condición de entorno.

Los tres comparten una propiedad diagnóstica útil: si eliminas la máquina y reescribes el mismo comportamiento a mano, el coste no desaparece, porque el trabajo caro seguía siendo tuyo. Esa prueba mental separa el coste del modelo del coste de lo que el modelo transporta, y evita la conclusión precipitada de que había que quitar la máquina.

El primero de los tres merece un comentario aparte porque su remedio vive fuera del grafo. Cuando el problema es la frecuencia, ninguna reescritura de estados ayuda: hay que reducir el número de mensajes en el productor decidiendo cuántas muestras merecen convertirse en eventos. Y esa decisión no es técnica sino semántica, porque depende de si el modelo necesita el camino recorrido o solo la última posición conocida. Equivocarla produce una interfaz veloz que dibuja mal, que es una forma particularmente incómoda de fallar. La regla general que se deduce es que el coste de un evento se gobierna mejor en el punto donde el evento se decide que en el punto donde se procesa.

Medir antes de creer

Una medición reproducible cuesta cinco minutos y sustituye una discusión entera. La forma más honesta de tomarla es aislar el actor de la interfaz, porque mezclarlos garantiza que el render enmascare todo lo demás.

import { createActor } from 'xstate'
import { maquina } from './maquina'

const actor = createActor(maquina).start()
const N = 100_000
const inicio = performance.now()
for (let i = 0; i < N; i++) {
  actor.send({ type: 'TICK', valor: i })
}
const total = performance.now() - inicio
console.log(`${(total / N * 1000).toFixed(2)} microsegundos por evento`)

Ese bucle mide el intérprete y solo el intérprete: sin React, sin capa de red, sin observadores. Ejecútalo sobre tu máquina real, no sobre un ejemplo de juguete, y anota el número. A partir de ahí la conversación cambia de naturaleza: en lugar de discutir si las máquinas son lentas, tienes un presupuesto por evento y puedes contrastarlo con la frecuencia real de eventos de tu aplicación. Si el producto de ambos es una fracción despreciable de tu presupuesto de fotograma, el asunto está cerrado con datos.

La medida complementaria es la de la cascada, y se toma con el flujo de inspección en vez de con un cronómetro. Contar macrosteps por gesto revela la estructura de coordinación real del sistema, que casi nunca coincide con la que uno creía tener.

let macrosteps = 0
const observado = createActor(maquina, {
  inspect: (ev) => {
    if (ev.type === '@xstate.microstep') macrosteps++
  },
}).start()

macrosteps = 0
observado.send({ type: 'CONFIRMAR_PEDIDO' })
queueMicrotask(() => console.log('macrosteps por gesto', macrosteps))

Con esas dos cifras en la mano —microsegundos por macrostep y macrosteps por gesto— el coste de interpretación de tu sistema queda completamente caracterizado, y su producto es el número que puedes llevar a una discusión de arquitectura sin que nadie tenga que creerte por fe.

⚠️
Un perfil sin nombres no sirve de nada

Cuando el perfil sí acusa a la transición, exige que las acciones y guardas tengan nombre en setup en lugar de estar escritas en línea. Las funciones anónimas aparecen en el perfil como entradas indistinguibles y te obligan a adivinar cuál de las once acciones de entrada es la costosa. Nombrarlas convierte una llamarada opaca en un desglose legible, y esa legibilidad es la diferencia entre optimizar la línea correcta y reescribir la máquina entera por si acaso.

El coste de un modelo se mide contra lo que evita, no contra el vacío

La pregunta de si interpretar una máquina sale caro está mal planteada mientras se compare contra la nada. La alternativa a la máquina no es ausencia de trabajo: es el mismo trabajo escrito a mano en forma de condicionales dispersos, banderas booleanas, comprobaciones defensivas repetidas en cada manejador y reconciliaciones ad hoc entre variables que deberían haber sido excluyentes. Ese código también ejecuta comparaciones, también asigna objetos y también decide caminos; simplemente lo hace sin estructura, sin índice y sin garantías, repartido por doce archivos donde nadie puede medirlo como una unidad. El intérprete concentra ese trabajo disperso en un algoritmo único, acotado y perfilable, y ese es su primer regalo de rendimiento antes incluso de hablar de microsegundos: hace que el coste del comportamiento sea localizable. El segundo regalo es más profundo. Un modelo explícito permite optimizaciones que el código imperativo prohíbe, porque solo se puede simplificar con seguridad aquello cuya semántica está declarada: puedes suscribirte a una proyección porque sabes qué es un snapshot, puedes cortar un subárbol en un actor porque sabes dónde termina su frontera, puedes amortiguar un evento porque sabes que el buzón es serial. Nada de eso está disponible cuando el estado vive en quince variables reactivas cuyas relaciones solo existen en la cabeza de quien las escribió. La conclusión práctica es una inversión de la pregunta habitual: no se trata de cuánto cuesta interpretar, sino de cuánto ahorra tener un modelo lo bastante explícito como para saber qué se puede tocar sin romper nada. El rendimiento, a esta escala, es una consecuencia de la claridad, y la claridad es exactamente lo que compraste al elegir estados en lugar de banderas.

⚔️ Cuantifica tu intérprete
  1. Ejecuta el bucle de medición sobre tu máquina más grande y anota los microsegundos por evento; repítelo con la máquina más pequeña y compara.
  2. Multiplica ese número por la frecuencia real de eventos de tu aplicación en su minuto más intenso y expresa el resultado como porcentaje de un presupuesto de fotograma de dieciséis milisegundos.
  3. Añade a una guarda un ordenamiento sobre un array de diez mil elementos y vuelve a medir; identifica en el perfil si la entrada acusada es la guarda o el intérprete.
  4. Duplica el número de estados de la máquina sin aumentar su profundidad y comprueba experimentalmente que el coste por evento no cambia de forma apreciable.
  5. Envía mil eventos que no disparan ninguna transición y compara el coste con mil eventos que sí la disparan; explica la diferencia a partir del esqueleto del macrostep.
  6. Activa la inspección, repite la medición y calcula el sobrecoste; escribe la condición de entorno que garantiza que ese sobrecoste nunca llega a producción.