Fundamentos de ML

Servir un modelo es un problema de memoria: prefill, decodificación y la caché KV

La decodificación lee todos los pesos en cada token, así que al servir un modelo te quedas sin ancho de banda de memoria mucho antes que sin cómputo. En mi portátil, leer los pesos se llevó el 84 % de cada paso con una sola secuencia, y un modelo híbrido pagó 5,5 ms por cada secuencia de más, cuando la aritmética predecía menos de 0,5. Cerca de la capacidad, el primer token tardaba segundos en llegar, y a media carga el percentil 99 de esa espera fue más de diez veces mayor en unas ejecuciones que en otras con casi la misma tasa.

¿Acabas de llegar a la serie? Empieza aquí: qué da por sabido y qué no.

La parte 10 acabó con un modelo entrenado, y al servirlo aparece un límite que la mayoría de los ingenieros de software nunca ha mirado con un profiler: la velocidad a la que la máquina puede leer la memoria.

Lo he medido en mi portátil, un Apple M5 Pro, sirviendo Qwen3.5-9B en 4 bits con llama-server (el servidor HTTP de llama.cpp). También he leído cómo decide el scheduler de vLLM a qué petición le toca el turno. La mayor parte de lo que sigue es aritmética que puedes comprobar con un cronómetro. Hay una parte que no, y esa es la que obliga a medir tu propio stack.

El prefill lee los pesos una vez y la decodificación, en cada token

Cada petición pasa por dos fases. El prefill mete el prompt entero en el modelo en una sola pasada, con todas las posiciones en paralelo, y llena la caché KV de la parte 6. Después viene la decodificación, que produce un token por paso, y cada paso vuelve a ejecutar el modelo completo para esa única posición.

En esta máquina, el prefill procesaba 1143 tokens por segundo con un prompt de 1024 tokens y la decodificación se quedaba en 42,6. Con los mismos pesos, decodificar era veintisiete veces más lento por token.

La diferencia la explican los pesos. Cada paso de decodificación tiene que leerlos todos una vez, y en este fichero son 5,38 GB sin contar la tabla de embeddings, de la que un paso solo lee una fila. Un benchmark de lectura en el mismo chip alcanzó 273 GB/s y, si divides, el suelo queda en 19,7 ms por token. El benchmark de llama.cpp midió el paso en 23,5 ms, de modo que leer los pesos a toda velocidad supone el 84 % de ese tiempo. La misma división para Llama-3.1-8B en bf16 en una H100 SXM, con 15 GB por token a 3,35 TB/s, da 4,5 ms sin contar sobrecostes.

El prefill lee esos mismos 5,38 GB una vez y los amortiza entre cientos de posiciones, así que su cuello de botella es la aritmética. Al número de operaciones por byte leído se le llama intensidad aritmética. Si se deja fuera la tabla de embeddings en las dos cuentas, un paso de decodificación con una secuencia hace unas 3 operaciones por byte. Los 1143 tokens por segundo del prefill equivalen a unos 18 billones de operaciones por segundo y, divididos entre 273 GB/s, a unas 66 por byte, el punto de equilibrio de este chip. Por debajo de esa cifra el ritmo lo marca la memoria, y por encima, la aritmética. Entre las 3 operaciones por byte de la decodificación y ese 66 hay un factor de más de veinte.

La caché KV es la otra mitad de la memoria

La decodificación también lee la caché KV, los vectores key y value que ha ido dejando cada posición anterior. Su tamaño por token sale de multiplicar dos tensores por el número de capas, el de cabezas key-value, el tamaño de cabeza y los bytes. En Llama-3.1-8B en bf16 son 2 × 32 × 8 × 128 × 2, es decir, 128 KiB por token, y eso que la grouped-query attention de la parte 7 ya la había dividido entre cuatro. Todos esos factores vienen en la configuración de tu modelo.

Qwen3.5 recorta la caché todavía más, porque solo 8 de sus 32 capas son de atención completa. Las otras 24 son de atención lineal y cada una lleva un estado recurrente de tamaño fijo por secuencia, sin caché por token. La caché se queda en 32 KiB por token, y en este portátil la decodificación apenas notaba el contexto: 42,6 tokens por segundo con la caché vacía y 41,3 con 4096 tokens ya en ella. Ese estado fijo tampoco sale gratis. En llama.cpp va en f32, y su parte principal ocupa 48 MiB por secuencia, tanto como 1536 tokens de caché. Una petición lo paga igual si tiene diez tokens que si tiene diez mil.

En una GPU, la concurrencia la limita la caché. Una H100 que se vende como de 80 GB declara unos 85,5 GB, y vLLM se queda por defecto con el 92 %. Descontados los 16 GB de pesos de Llama, quedan como mucho unos 62 GB para la caché, que dan para unas 116 peticiones de 4096 tokens cada una, y para menos en cuanto las activaciones se llevan su parte. Si una petición nueva no cabe, espera (scheduler.py:1223), y si una que ya está en marcha necesita otro bloque, desaloja a otra. Este es el código del desalojo, condensado de vllm/v1/core/sched/scheduler.py:564-810 en la v0.31.0:

# There's no "decoding phase" nor "prefill phase" in the scheduler.
token_budget = self.max_num_scheduled_tokens
while True:
new_blocks = self.kv_cache_manager.allocate_slots(request, num_new_tokens)
if new_blocks is not None:
break # the cache had room
preempted_req = self.running.pop() # it didn't: evict the newest running request
self._preempt_request(preempted_req, scheduled_timestamp)

La primera línea es un comentario del propio scheduler y describe el diseño. Cada paso tiene un presupuesto de tokens que comparten los trozos de prefill y los tokens de decodificación, así que un prompt largo se trocea y se ejecuta junto a la decodificación de otras peticiones.

La caché se reparte por defecto en bloques de 16 tokens. Cuando allocate_slots no encuentra bastantes bloques libres, devuelve None, y con la política por defecto (por orden de llegada) el bucle saca de la GPU la última petición admitida entre las que están en marcha. Después, _preempt_request libera sus bloques y pone num_computed_tokens = 0 (scheduler.py:1560). Al volver, la petición recalcula el prompt y lo que ya había generado, salvo lo que siga en la caché de prefijos, activada por defecto (config/cache.py:142). Quedarse sin caché cuesta cómputo, y quien lo paga es la petición más reciente.

Sobre el papel, el batching sale gratis

La diferencia entre 3 operaciones por byte y un punto de equilibrio de 66 es lo que justifica el batching. Si de todos modos un paso tiene que leer todos los pesos, lo lógico es que decodificar muchas secuencias en ese paso cueste poco más que decodificar una, hasta que la aritmética alcance a la memoria.

Para sacar la cifra teórica he montado un modelo roofline de este portátil. En él, un paso dura lo que tarde la más lenta de dos cosas (leer sus bytes al ancho de banda medido o hacer su aritmética al ritmo del prefill), más un sobrecoste fijo ajustado al paso con una sola secuencia. Según ese modelo, 32 secuencias a la vez tardan 37 ms por paso, frente a los 24 ms que midió llama-server con una. Después he lanzado contra llama-server de 1 a 32 peticiones a la vez y he sacado el tiempo de paso de las mediciones del propio servidor.

El tiempo medido de un paso de decodificación en este portátil, frente a lo que debería costar según la aritmética. Elige un modelo y arrastra el deslizador para cambiar cuántas secuencias se decodifican a la vez. La línea continua es lo medido, y la discontinua, la aritmética calibrada con una secuencia. El número de secuencias se duplica de una marca a la siguiente y cada modelo tiene su propia escala vertical.
23 ms195 ms12481632
paso, medido
4,9 ms
paso, previsto
4,5 ms
tokens por segundo
821
throughput frente a una
3,3×

La demo arranca con un modelo de control, que está ahí para comprobar el método. Es Qwen3-Reranker-0.6B en 8 bits, construido sobre Qwen3-0.6B y con atención normal en todas las capas. Hasta cuatro secuencias, la aritmética no se desvía más de un 7 %. Con ocho, el paso medido es de 7,1 ms frente a 5,1 previstos, y a partir de ahí la línea medida se despega.

Pasa ahora al 9B. Con dos secuencias, el paso ya es de 29,3 ms frente a 24,8 previstos, un 18 % más. Con treinta y dos tarda 195 ms, y un pequeño porcentaje de ese tiempo se va en el prefill de los últimos prompts. Eso son unos 5,5 ms de media por cada secuencia de más, cuando el ancho de banda y el estado recurrente predicen menos de medio milisegundo. La aritmética prometía 864 tokens por segundo y el servidor dio 164, menos de una quinta parte.

Las cifras no dicen en qué kernel se van esos milisegundos, y el control no ayuda mucho a acotarlo. Es quince veces más pequeño, está cuantizado de otra forma y su sobrecoste fijo es el 42 % del paso con una secuencia. Hasta cuatro secuencias, el batching le saldría barato casi con cualquier tipo de capas. El control está ahí por el método, y el método se sostiene: en el mismo runtime y en la misma máquina, medido igual, un modelo hace batching como predice la aritmética hasta cuatro secuencias. El exceso es cosa de este modelo en este runtime.

El p99 se juega en la cola

Hasta aquí, las peticiones salían todas a la vez. Los usuarios, en cambio, llegan al azar. He configurado llama-server con ocho slots, el nombre que da a las secuencias que decodifica en paralelo, y le he enviado 60 peticiones a intervalos de Poisson. Es el modelo habitual para usuarios que no tienen nada que ver entre sí: cada llegada es independiente de la anterior. Cada petición llevaba 256 tokens de prompt y 128 de salida, y en todas he cronometrado el primer token, la espera que ve el usuario antes de que aparezca nada.

Empecemos por la capacidad. Ocho clientes en bucle cerrado, que mandaban la siguiente petición en cuanto recibían la respuesta de la anterior, sacaron del servidor 87,2 tokens por segundo, unas 0,68 peticiones por segundo. Con peticiones idénticas, un bucle cerrado así va casi al compás, con ocho prefills muy seguidos y luego ocho decodificaciones, apenas interrumpidas, hasta que terminan todas y los siguientes prompts llegan casi a la vez.

Con las dos tasas más altas que he probado, de unas 0,9 y 2 peticiones por segundo, la cola no llegó a vaciarse entre la 10.ª y la 50.ª petición completada, y en ese tramo el servidor sostuvo 85,7 y 85,9 tokens por segundo. Al haber siempre cola, los prefills caen en mitad de la decodificación de otras peticiones, la misma mezcla que vLLM hace a propósito con su presupuesto compartido. El tiempo por token de salida subió de 76 ms en bucle cerrado a 89. De esos 13 ms de más, unos 12 se deben a los prefills de las otras peticiones. El bucle cerrado pagaba esos mismos prefills como una pausa entre rondas, así que el coste solo había cambiado de sitio. De ahí que sus 87,2 tokens por segundo quedaran a menos de un 2 % de lo que sostuvo el servidor con esas dos tasas.

Sesenta llegadas son una muestra corta, y su tasa se apartó de la que había fijado. De tres ejecuciones configuradas a 0,5 por segundo, una salió a 0,50 y las otras dos, a 0,37 y a 0,39. Por eso la demo ordena las once ejecuciones por su tasa real.

Todas las ejecuciones del mismo servidor con llegadas aleatorias, ordenadas por la tasa real de sus 60 llegadas. Arrastra para recorrerlas. La barra muestra los tokens ofrecidos frente a la cifra del bucle cerrado, y la marca, lo que sostuvo el servidor con las dos tasas más altas, sin contar el arranque ni el vaciado. Debajo está el tiempo hasta el primer token en escala logarítmica, con la mediana en línea continua y el percentil 99 en discontinua. En el eje, las ejecuciones están igual de separadas, sea cual sea su tasa.

fin de la barra: bucle cerrado, 87,2 tok/s · marca: 85,8 tok/s sostenidos con las dos tasas más altas

carga: 33 % de la capacidad en bucle cerrado

1 s10 s60 s0,2260,3270,3470,3540,3720,3940,4980,6490,7080,9352,007
primer token, p50
0,3 s
primer token, p99
0,5 s
ofrecidos
29 tok/s
por token, tras el primero
33 ms
entregados
28,6 tok/s

Lo sorprendente ocurre por debajo de la capacidad, y basta con recorrer las ejecuciones para verlo. Hasta 0,50 peticiones por segundo (64 tokens por segundo ofrecidos, unas tres cuartas partes de la cifra del bucle cerrado), la mediana de la espera hasta el primer token se quedó siempre entre 0,3 y 0,4 s. A 0,65 peticiones por segundo, el 95 % de la capacidad, la mediana fue de 1,6 s y el p99, de 9,4 s. Justo por encima, a 0,71 la mediana y el p99 se quedaron en 0,6 y 5,5 s, aunque, con una sola ejecución por tasa, el orden entre 0,65 y 0,71 dice poco. A 0,94 la mediana fue de 12 s, y a 2, de 28 s. De 0,65 en adelante, una petición que ya ha empezado recibe un token cada 89 ms más o menos, sea cual sea la tasa. El resto es espera en la cola, y por encima de la capacidad la cola crece mientras sigan llegando peticiones.

El percentil 99 es todavía más ruidoso. Tres ejecuciones de 0,33 a 0,35 por segundo, todas cerca de media carga, dieron un p99 de 0,4 s, de 5,2 s y de 0,6 s. Con 60 peticiones, el p99 es lo que tarda la segunda más lenta, y eso depende tanto de cómo se hayan agrupado por azar las llegadas como del modelo. Una sola prueba de carga corta no basta para fijarlo, y en producción te tocan las tres ejecuciones, en días distintos.

Qué vigila el ingeniero de ML

Son cuatro hábitos, y ninguno necesita un profiler para empezar.

Cuenta bytes por token antes que FLOPs. Los pesos que se leen en cada paso, divididos entre el ancho de banda medido, marcan el suelo de tu decodificación. Ese suelo, dividido entre el paso medido, es tu eficiencia, y aquí fue del 84 %. Un fichero cuantizado mueve el suelo en proporción a su tamaño, y esa es la razón principal de que los pesos en 4 bits se sirvan más rápido.

Presupuesta la caché como memoria, porque lo es. Los bytes por token, multiplicados por el contexto y por las peticiones concurrentes, tienen que caber en lo que dejan libre los pesos. Antes de fiarte de una regla práctica, mira la arquitectura: un híbrido como Qwen3.5 renuncia a tres cuartas partes de la caché a cambio de un estado fijo por secuencia.

Mide tu curva de batching. Saca el tiempo de paso frente a las secuencias en curso, con tu modelo y en tu runtime, desde 1 hasta donde la curva se tuerza. En este portátil, el mismo servidor cuadró con la aritmética hasta cuatro secuencias en uno de los modelos y al otro le cobró unos 5,5 ms de media por secuencia.

Haz la prueba de carga simulando llegadas y mira el p99 del tiempo hasta el primer token. Aquí, un bucle cerrado de clientes idénticos dio la capacidad con menos de un 2 % de error, pero no dice nada de la espera al 95 % de esa capacidad. Las llegadas aleatorias enseñan lo que ve un usuario, y con unas pocas decenas de ellas el p99 cambia de una ejecución a otra, así que repite la prueba.

El suelo sale de una división y el presupuesto de caché, de una multiplicación. El 18 % de más con dos secuencias, en cambio, solo apareció al cronometrar.

Fundamentos de MLFrom code to weights