Mudar el anillo a p4
Siete cambios de contrato entre un motor y quien lo llama. Cada uno falló de forma distinta, y solo uno de ellos parecía un error.
Fusionamos una nueva release del motor p4 y el anillo dejó de servir. No con un crash — el cargador reportó éxito, los agentes reportaron ready, y no pasó nada. Remontar desde ese silencio costó un día y sacó a la luz siete puntos en los que quien llama y el motor se habían separado. Lo que los hace dignos de anotar no es la cuenta. Es que seis de los siete no produjeron ningún error.
Un evento para un nodo que no existe se reenvía, no se rechaza
Nuestro cargador dirigía el comando LOAD al nodo que quería crear. Pero un nodo no existe hasta que LOAD lo crea, y la regla del broker ante un evento que nombra un nodo desconocido es reenviarlo hacia fuera en vez de rechazarlo. El comando salió del agente buscando otro sitio al que ir, no encontró ninguno y se descartó. Ni una línea de log, porque desde el punto de vista del broker no había pasado nada malo.
El arreglo fue dirigir LOAD al *agente*, envuelto en el sobre de ciclo de vida neutral al backend del motor, con el comando propio del adaptador como cuerpo opaco. Obvio a posteriori; invisible desde fuera.
Un número que tiene que ser igual a otro número
Un modelo se carga bajo una load generation, y cada nodo se registra con una generación de nodo. Las habíamos tratado como independientes — un timestamp para una, 1 para la otra — y todo funcionaba. El anillo cargaba. Respondía correctamente a una petición. Y entonces moría el nodo de cabeza.
La comprobación, en la contabilidad de release del adaptador.
let Endpoint::Node { generation, .. } = &event.envelope.source;
if *generation != receipt.load_generation {
return Err("release owner census generation differs from source");
}El recibo que cierra una petición terminada lleva la load generation, y el nodo que lo envía lleva la suya. Cuando difieren, el nodo se detiene. Así que la forma del bug es: la carga funciona, la primera petición funciona, la cabeza muere, y todas las sesiones posteriores se quedan colgadas a medio cargar. Se lee exactamente como un crash bajo carga y en absoluto como un desajuste. Nuestro cargador ahora rechaza un plan cuyos dos números no coinciden, antes de cargar nada.
Una cota que nunca podría cumplirse
El adaptador compara el resultado más grande que un stage puede devolver con la cifra que el stage server reporta al arrancar, por igualdad exacta. No podíamos conocer esa cifra sin cargar el modelo — así que cargamos con una estimación, y el fallo nos dio de golpe los números reales de los cuatro stages:
Una ejecución, cuatro respuestas.
step37-s0: profile=33554432, READY=34419218444 step37-s1: profile=33554432, READY=34419218444 step37-s2: profile=33554432, READY=34419218444 step37-s3: profile=33554432, READY=59136012
34 GB. Los almacenes retenidos del agente son de 256 MiB, así que ese anillo nunca podría haber sido admitido. Leer la derivación dentro del stage server explicó por qué: la cota escala con n_batch × n_ubatch, y habíamos heredado un ancho de batch de 2048 de una configuración anterior a esta comprobación. Con 128 filas — el ancho que usa el layout de producción — la cota es de 138 MB y cabe. Ahora la derivamos en el plan a partir de la misma fórmula en vez de arrastrar una constante recordada, y la cifra predicha para el stage de cola salió exactamente igual a la que otro despliegue había registrado, que es la clase de acuerdo que conviene tener antes de gastar veinte minutos en una carga.
En breve
- Direcciones de loopback en un anillo de dos hosts. Un stage marca al agente del siguiente stage en la dirección que ese agente anuncia. Anuncia
127.0.0.1y el host A se marca a sí mismo. La configuración registrada del anillo había sido loopback todo el tiempo — nunca podría haber funcionado entre hosts. - El diario es obligatorio. Un modelo no se carga sin el diario operativo del agente. Lo desactivamos mientras perseguíamos otro error y empeoramos el síntoma de una forma que parecía progreso.
- El nombre del dispositivo depende del backend. El constructor de planes de referencia apunta a CUDA y emite
--device CUDA0. La build de HIP llama a sus dispositivosROCm0. Ese plan carga el modelo entero y *después* no encuentra el dispositivo. - El servidor nativo es parte de la release. Un agente compilado desde un árbol más nuevo quiere capacidades que el stage server instalado no reporta. También descubierto después de una carga completa del modelo.
El silencio es el modo de fallo caro
Cada uno de estos fue barato de arreglar y caro de encontrar, y el patrón es consistente: los fallos costosos fueron aquellos en los que un sistema con buena pinta no hacía nada, o lo hacía una sola vez. Las guardas que añadimos tienen todas la misma forma — rechazar pronto, en el punto donde el error todavía es legible. El cargador escribe la load generation a disco *antes* de que salga el primer comando, porque de otro modo es irrecuperable. Rechaza un desajuste de generaciones en vez de descubrirlo tras la primera petición. Deriva la cota de resultado en vez de recordarla.
El anillo está sirviendo. Cuatro stages repartidos en dos máquinas, 113 GiB de pesos residentes, primer token en 1.4 s en frío y ~0.3 s en caliente, y la contribución por nodo fluyendo hasta el libro de liquidación por primera vez.