Un solo chip Google Cloud TPU v5e — 16 GB de HBM, alrededor de $0.58/hora en spot — sirve google/gemma-4-E2B-it bajo vLLM a 1.496 tokens de salida/seg agregados, con 8.02 ms de latencia por token en un solo stream y tool-calling nativo. Eso es suficiente para respaldar una flota de 8-16 agentes “lite” concurrentes por aproximadamente $0.107 por millón de tokens de salida.
Este es un build log con números. Todo lo que aparece aquí fue medido en el hardware, y las secciones que dicen “me equivoqué en esto” son las que valen tu tiempo — cuatro de mis predicciones más confiadas fueron refutadas por el benchmark, y cada refutación fue más útil que la suposición.
Setup bajo prueba: v5litepod-1 (un chip v5e), us-west4-a, vllm/vllm-tpu:nightly, vLLM 0.26.1rc1.dev125+ga7a204cc6, backend JAX tpu-inference, google/gemma-4-E2B-it en bf16.
Parte 1 — Scaffold y ejecución
1.1 Prerrequisitos
gcloud auth login # para llamadas de subproceso gcloud
gcloud auth application-default login # ADC, para el cliente de Secret Manager
Pon tu token de Hugging Face en Secret Manager, no en un script ni en un archivo env — el startup script de la VM TPU se almacena como metadata de la instancia, y cualquier cosa que hornees en él es legible desde la instancia:
printf '%s' "***" | gcloud secrets create hf-token --data-file=- --project=YOUR_PROJECT
Restricción de zona que te hará perder la tarde si no la ves: flex-start v5litepod-1 solo se acepta en us-west4-a. europe-west4-a y -b lo rechazan en la API con FLEX_START provisioning model is not supported for accelerator type "v5litepod-1", sin importar la cuota. Tener cuota no-cero en una zona no te dice nada — el modelo de aprovisionamiento es el bloqueador.
1.2 Provisionar el chip
Tres modelos de aprovisionamiento, tres comandos distintos. Nota: v5e se escribe v5litepod para gcloud — “v5e-1” está bien en prosa y nunca es válido en un argumento de CLI.
# Spot — el más barato, preemtible con ~30s de aviso, SIN límite de duración (factura hasta que lo borres)
gcloud alpha compute tpus tpu-vm create gemma4-v5e \
--zone=us-west4-a --type=v5litepod --topology=1x1 \
--provisioning-model=spot --version=v2-alpha-tpuv5-lite
# On-demand — precio completo, sin preemptions, también sin límite
gcloud alpha compute tpus tpu-vm create gemma4-v5e \
--zone=us-west4-a --type=v5litepod --topology=1x1 \
--version=v2-alpha-tpuv5-lite
Flex-start pasa por la API de Queued Resources y es el único modelo que acepta --max-run-duration, es decir, el único que deja de facturar solo:
gcloud alpha compute tpus queued-resources create gemma4-qr \
--node-id=gemma4-qr-node --zone=us-west4-a \
--accelerator-type=v5litepod-1 --runtime-version=v2-alpha-tpuv5-lite \
--provisioning-model=flex-start --max-run-duration=4h
Verificado 2026-08-09: este comando exacto se ejecutó — el QR llegó a
ACTIVEconprovisioningModel: FLEX_STARTymaxRunDuration: 14400s, y luego se eliminó limpiamente. Nota que no hay dry-run: un create o hace queue o provisiona, y el estadoPROVISIONINGno se puede borrar, así que pagarás al menos unos minutos si hay capacidad disponible de inmediato.
Spot y on-demand no tienen stop automático. Facturan hasta que son preemtidos o borrados. Pon un recordatorio en el calendario, o usa flex-start. Ver la sección de costos — flex-start es solo 3.8% más caro que spot.
Spot usa una cuota separada (TPUV5sPreemptibleLitepodPerProjectPerZoneForTPUAPI), no la cuota TPU estándar. Una zona con mucha cuota on-demand puede igual rechazar spot.
1.3 Llevar el token al nodo y arrancar el servidor
gcloud compute tpus tpu-vm ssh falla con ConnectionResetError en algunos entornos sandbox (falla dentro de su propia llamada API interna, mientras que las llamadas gcloud planas funcionan bien). SSH directo siempre funciona:
IP=$(gcloud compute tpus tpu-vm describe gemma4-v5e --zone=us-west4-a \
--format='value(networkEndpoints[0].accessConfig.externalIp)')
# Pasa el secreto directo — nunca por una variable de shell ni una línea de log
gcloud secrets versions access latest --secret=hf-token \
| ssh -i ~/.ssh/google_compute_engine xbill@$IP 'umask 077; cat > ~/.hf_token'
ssh -i ~/.ssh/google_compute_engine xbill@$IP 'sudo docker pull vllm/vllm-tpu:nightly'
Luego arráncalo. Esta es la configuración que el resto del artículo defiende:
sudo docker run -d --name vllm-gemma4 --privileged --net=host \
-v /dev/shm:/dev/shm --shm-size 10gb \
-v ~/.cache/vllm:/root/.cache/vllm \
-e HF_HOME=/dev/shm -e HF_TOKEN="$(cat ~/.hf_token)" \
vllm/vllm-tpu:nightly \
vllm serve google/gemma-4-E2B-it \
--dtype bfloat16 \
--kv-cache-dtype auto \
--max-model-len 32768 \
--max-num-batched-tokens 4096 \
--tensor-parallel-size 1 \
--gpu-memory-utilization 0.92 \
--enable-prefix-caching \
--disable-chunked-mm-input \
--limit-mm-per-prompt '{"image":4,"audio":1}' \
--enable-auto-tool-choice --tool-call-parser gemma4 --reasoning-parser gemma4
Esa línea -v ~/.cache/vllm:/root/.cache/vllm es lo más valioso de este artículo. El caché de compilación JAX vive ahí (197 MB medidos) y de otra forma es local al contenedor, destruido en cada docker rm. La compilación son 685 s de los 857 s del cold start. Medido: montarlo reduce un restart a 497 s, un ahorro del 42%. Sin el mount, cada restart, cambio de flag y preemption de spot te cobra la compilación completa de nuevo.
1.4 Verificar
El cold start es 857 s (14 min) y el 80% es compilación XLA, no carga de pesos — el checkpoint de 9.54 GiB se descarga en unos 10 segundos. Sé paciente y mira el log, no el reloj:
sudo docker logs -f vllm-gemma4 2>&1 | grep -E "Memory statistics|Init kv-cache|startup complete"
Quieres ver esto, que es todo el presupuesto de memoria en una línea:
Memory statistics | total_hbm_limit_gb=15.75GiB | total_hbm_limit_cap_gb=14.49GiB
| total_hbm_used_gb=8.97GiB | total_hbm_avail_gb=5.52GiB
Luego haz un smoke-test. Usa /v1/chat/completions, no /v1/completions — los completions crudos devuelven string vacío en modelos -it, lo que parece un deploy roto y no lo es:
curl -s localhost:8000/v1/chat/completions -H 'Content-Type: application/json' -d '{
"model":"google/gemma-4-E2B-it",
"messages":[{"role":"user","content":"Say hi in five words."}]}' | jq -r '.choices[0].message.content'
1.5 Teardown
gcloud compute tpus tpu-vm delete gemma4-v5e --zone=us-west4-a --quiet
Parte 2 — Los flags y qué hacen realmente
El chip: qué te da realmente un v5e
| spec | v5e, un chip | fuente |
|---|---|---|
| Capacidad HBM | 16 GB nominal · 15.75 GiB visible al runtime | vendor · medido |
Usable para pesos + KV a 0.92 | 14.49 GiB | medido |
| Ancho de banda HBM | 800 GiBps | vendor |
| Pico bf16 | 197 TFLOPS | vendor |
| Pico int8 | 393 TOPS (exactamente 2x bf16) | vendor |
| TensorCore | 1, con 4 MXUs (128x128) | vendor |
| ICI | 400 GBps bidireccional, 4 puertos | vendor |
| Machine type | ct5lp-hightpu-1t | |
| Ortografía gcloud | v5litepod-1, runtime v2-alpha-tpuv5-lite | medido |
Dos trampas de unidades que debes conocer antes de comparar nada. Google cotiza el ancho de banda v5e en GiBps y el v6e en GBps — normaliza antes de dividir, la ratio generacional real es ~1.9x, no un 2x limpio. Y la cifra de capacidad “16 GB” se sienta incómoda junto a los 15.75 GiB que reporta el runtime (15.75 GiB son 16.9 GB), así que el número del vendor casi seguro es 16 GiB escrito a la ligera. Dimensiona contra los 15.75 GiB medidos, nunca contra la cifra de marketing.
Tipos de datos: en qué puede computar realmente la matriz de unidades
Esta única tabla decide toda pregunta de cuantización en este chip.
| formato | ¿nativo en la MXU? | qué compra en v5e |
|---|---|---|
| bf16 | ✅ | el baseline — todo corre en esto |
| int8 | ✅ 2x throughput de bf16 | la única ganancia de cómputo low-precision |
| fp8 | ❌ | solo almacenamiento y ancho de banda — los valores se expanden de vuelta a bf16 antes del matmul |
| int4 / fp4 | ❌ | solo footprint y ancho de banda, luego se desempacan a bf16 |
Google publica picos bf16 e Int8 para v5e y ninguna cifra fp8 en absoluto, lo cual es la pista. La consecuencia práctica: un benchmark que no muestra speedup con fp8 en este chip es el resultado correcto, no una mala configuración. El v7/Ironwood es el primer TPU con fp8 en la MXU — no traslades ninguna conclusión de aquí hacia allá.
Cuantización: qué es realmente alcanzable
Gemma 4 existe solo como implementación JAX en este stack, así que cualquier cosa en el path de torch es inalcanzable sin importar lo que la plataforma anuncie. Estado medido:
| ruta | estado en este build |
|---|---|
KV cache, bf16 (auto) | ✅ el único que vale la pena correr |
KV cache fp8_e4m3 / fp8_e5m2 | alcanzable, 1.000x capacidad — el layout de bloques está word-aligned, así que estrechar el elemento compra padding, no espacio. ~2% más lento |
KV cache int8 | ❌ rechazado por el enum de la CLI — nunca llega al engine |
KV cache int8_per_token_head, turboquant_*, nvfp4, fp8_inc, fp8_ds_mla | aceptados por la CLI, luego matan el servidor al boot |
| Pesos, compressed-tensors w4a16 (formato QAT de Google) | ❌ NotImplementedError en el path JAX |
| Pesos, mxfp4 | ❌ solo MoE; E2B es denso, así que no hay dónde fijarlo |
| Pesos, qwix PTQ int8/int4 | ❌ no bootea — el path concreto OOM en temporales de cuantización, el path abstracto lanza binding weights |
| Pesos, AWQ / GGUF / q4_0 | ❌ path torch o ausente |
Así que bf16 no es una elección aquí, es lo único que corre — y esa es la restricción más grande del chip. Los pesos son 8.97 de los 14.49 GiB del presupuesto (62%), y nada de eso se puede comprimir hoy. Pesos int8 funcionales duplicarían aproximadamente el pool de KV y comprarían FLOPS reales, ya que int8 es el único formato con path MXU nativo. Está bloqueado upstream, no por configuración, así que vale la pena re-testear en cada bump de imagen.
Una consecuencia medible de todo esto: decode mueve ~3.15 GiB por step contra un floor de ancho de banda de 3.94 ms, y mide 8.02 ms — alrededor del 49% del ancho de banda pico. (Los 3.15 GiB se derivan de la geometría de capas del modelo; los 8.02 ms son medidos.) El chip no es el cuello de botella en ningún punto de este artículo.
El modelo: seis cosas de Gemma 4 E2B que te van a atrapar
E2B es un checkpoint extraño. Casi toda intuición de un decoder convencional es incorrecta aquí, y la aritmética de memoria del resto del artículo solo tiene sentido una vez que estas están sobre la mesa.
| campo | valor |
|---|---|
num_hidden_layers | 35 (28 sliding / 7 full attention, i % 5 == 4 es full) |
num_kv_shared_layers | 20 — así que solo 15 capas poseen un caché |
num_attention_heads / num_key_value_heads | 8 / 1 |
head_dim / global_head_dim | 256 / 512 |
hidden_size / intermediate_size | 1536 / 6144 |
vocab_size | 262,144 (embeddings atados) |
sliding_window | 512 |
| residente en bf16 | 8.97 GiB |
1. “E2B” no es un modelo de 2B. Es ~2B efectivos contra ~5B totales, y aterriza en 8.97 GiB residentes. El prefijo E es significativo — leer E4B como “un modelo de 4B” subestima sus pesos por ~2x, que es exactamente la diferencia entre caber en un chip de 16 GB y no caber.
2. Hay dos geometrías de atención, no una. Las capas sliding corren a head_dim 256; las siete capas full-attention corren a 512, y eso aplica a K y V, no solo a Q. Un único head_dim no describe este modelo — cualquier cosa que lea un valor y lo aplique a las 35 capas subestima las capas full por 2x. Eso es un error de dimensionado KV del 17%, y es uno que la gente realmente comete.
3. Veinte de las treinta y cinco capas leen el caché de otro. first_shared = 35 − 20 = 15, así que las capas 0–14 poseen KV y las 15–34 comparten. El mapeo es “última capa precedente del mismo tipo de atención”, y dentro de 0–14 eso significa que las veinte capas compartidas resuelven a solo dos cachés fuente — la capa 13 para las sliding, la capa 14 para las full. Veinte capas, dos tensores.
4. KV cuesta 18 KiB/token, y el log de boot te mentirá sobre por qué.
12 sliding cached layers x 1 KV head x 2 (K,V) x 256 x 2 B = 12,288 B
3 full cached layers x 1 KV head x 2 (K,V) x 512 x 2 B = 6,144 B
total = 18,432 B = 18 KiB/token
Multiplica por los 321,344 tokens residentes medidos y obtienes 5.52 GiB — exactamente lo que reporta el engine. Pero la línea del log que describe el caché, regular_attn_shape=(num_blocks, (64, 1, 2, 256)), es una muestra first-wins tomada de la capa 0 — que es sliding, por eso 256. No dice nada de las capas 4, 9 y 14. En cualquier modelo híbrido sub-reporta. Dimensiona el KV desde la geometría de la config y compruébalo contra total_hbm_avail_gb; nunca lo leas de esa línea.
5. Una sola cabeza KV significa que más chips empeoran las cosas, no las mejoran. num_key_value_heads = 1 es MQA completo, y una sola cabeza no se puede shardear. Los runtimes rellenan num_kv_heads hasta un múltiplo del tamaño tensor-parallel, así que con TP=4 pagas 4x la memoria KV para almacenar la misma cabeza replicada. En este modelo una topología más grande multiplica el costo KV en lugar de dividirlo. Revisa num_key_value_heads antes de asumir que más chips resuelven un problema de memoria.
6. Las cabezas no teselan el hidden size. 8 x 256 = 2048 contra hidden_size = 1536, así que la proyección Q es rectangular. Cualquier código que calcule head_dim = hidden_size / num_heads obtiene 192 y está silenciosamente mal.
Y una que explica el rendimiento más que la memoria: 4.38 GiB de los 8.97 GiB residentes son tablas de embedding por capa (262,144 x 256 x 35), que se recogen por token, no se streamean. Solo ~3.15 GiB se mueven realmente por step de decode — el transformer denso más los 0.75 GiB de embedding atado que lm_head lee completo. Por eso un modelo de 8.97 GiB decodifica tan rápido como lo hace.
Dos trampas más si vas a hurgar en el checkpoint: el archivo también contiene audio_tower y capas de visión con su propia numeración de capas independiente, así que un regex que matchee layers\.(\d+)\. colisiona silenciosamente con ellas — ancla siempre en model.language_model.. Y los exports QAT (-qat-w4a16-ct, -qat-q4_0-unquantized) no cargan en este stack en absoluto, en parte porque legítimamente no traen k_norm para las capas KV-shared y el loader lo exige de todas formas.
El modelo mental: un presupuesto HBM, y una parte que nadie gobierna
Todo lo demás se sigue de esto:
15.75 GiB HBM total en el chip
× 0.92 --gpu-memory-utilization
─────────
14.49 GiB el tope que el engine asignará adentro
− 8.97 GiB pesos del modelo (bf16)
─────────
5.52 GiB KV cache → 321,376 tokens a 18 KiB/token
1.26 GiB lo que queda FUERA del tope — los programas XLA compilados
viven aquí, y gpu_memory_utilization no los gobierna
Esa última línea es la trampa. Probé --gpu-memory-utilization 0.95 esperando un +8% gratis de KV. El pool de KV se dimensionó exactamente como predecía la aritmética — tope 14.96 GiB, KV 5.99 GiB, 348,864 tokens, +8.6% — y luego XLA murió 691 s después, cargando jit_structured_decode_fn:
RuntimeProgramAllocationFailure: Attempting to reserve 384.11M at the bottom of memory.
That was not possible. There are 347.33M free, 0B reserved, and 347.33M reservable.
El knob gobierna pesos + KV solamente; las imágenes de programas compilados salen del remanente que deja atrás. He medido esta falla dos veces, con meses de diferencia, en tamaños de página distintos (32 y 64) — 384.11 M pedidos contra 346.77 M y 347.33 M libres. Es determinista, no una race condition.
0.92 es un techo, no un default conservador. Y nota la forma de esa falla: cuesta una compilación completa descubrirla — 691 s antes de que te lo diga.
--gpu-memory-utilization 0.92
Fracción del HBM total que el engine puede asignar para pesos + KV cache. No activaciones, no programas compilados. 0.95 no bootea en este modelo/chip/build. 0.93 y 0.94 no están probados; los márgenes estimados son ~291 MB y ~127 MB contra una falla que quedó corta por 37 MB, así que la relación riesgo/recompensa es mala.
También existe --kv-cache-memory (campo de config kv_cache_memory_bytes), que fija el pool en bytes en lugar de como fracción, y se saltea el memory profiling en boots posteriores. Valor medido-bueno en este setup: 5923602432.
--max-model-len 32768
El contexto máximo. Lo sorprendente es que no cuesta capacidad KV:
max-model-len | block_size | bloques/request | KV blocks | tokens KV |
|---|---|---|---|---|
| 16,384 | 32 | 512 | 10,043 | 321,376 |
| 32,768 | 64 | 512 | 5,021 | 321,344 |
El backend Pallas escala block_size con max_model_len para mantener bloques-por-request constantes en 512. Duplicar el contexto duplica el tamaño de página, reduce a la mitad el conteo de bloques, y aterriza en la misma capacidad de tokens. Medido, en ambos brazos.
Y bloques-por-request resulta predecir la velocidad de decode:
| bloques/request | c=1 TPOT |
|---|---|
512 (max-model-len 16384) | 8.05 ms |
512 (max-model-len 32768) | 8.02 ms |
| 2048 (un brazo mal configurado) | 8.32 ms |
Corolario: no pongas --block-size. El backend lo elige, y fijar 32 pelearía contra el escalado que mantiene el contexto largo gratis.
--max-num-batched-tokens 4096
El presupuesto de tokens por step del scheduler — el tamaño de chunk para chunked prefill. Más pequeño favorece la latencia inter-token (un chunk de prefill grande detiene cada decode en vuelo); más grande favorece el time-to-first-token. Este es el flag más malentendido en TPU, por dos razones.
Razón 1: los buckets son potencias de dos. TPU necesita un grafo compilado por forma de tensor, así que vLLM precompila una escalera — 16, 32, 64, … 4096 — y redondea tu valor hacia arriba al siguiente bucket. Poner 2496 compila exactamente la misma escalera que 4096. Cada chunk cuesta entonces un kernel con forma 4096 mientras lleva 2496 tokens de trabajo. Lo probé: −24.7% de throughput a 8k/64 con la cola de ITL completamente sin cambios (173.9 → 174.2 ms).
Razón 2: multimodal pone un piso duro. Con --disable-chunked-mm-input, un item multimodal debe caber en un solo batch. Bájate de eso y el servidor se niega a arrancar:
ValueError: Chunked MM input disabled but max_tokens_per_mm_item (2496)
is larger than max_num_batched_tokens (2048). Please increase max_num_batched_tokens.
Así que para este modelo con {"image":4,"audio":1}: piso 2496, siguiente bucket 4096. Cada valor en (2048, 4096] compila idénticamente, así que gana el más grande. 4096 es óptimo en ese intervalo — no por gusto, por construcción.
--max-num-seqs (déjalo tranquilo)
Máximo de secuencias concurrentes. El default es 256, y saber de dónde viene vale la pena, porque no es donde buscarías:
SchedulerConfig.DEFAULT_MAX_NUM_SEQS = 128es código muerto en el path de serve.EngineArgs.get_batch_defaults()lo sobreescribe desde un dict claveado por contexto de uso y condicionado a la memoria del device:>= 70 GiB→ 1024, si no → 256.- vLLM trae tuning TPU por chip (V6E 1024 / V5E 512 / V5P 256 para
max_num_batched_tokens)… y nunca se dispara, porqueget_device_name()devuelve'TPU V5E'mientras el código testeachip_name == "V5E". También llama aget_device_total_memory(), que lanzaNotImplementedErroren tpu-inference y es tragado por unexceptpelado, así que el gate de memoria lee 0.
Así que en un v5e obtienes silenciosamente defaults genéricos no-TPU. Intenté limitarlo a 64 con la teoría de que 256 sobre-admite (256 × 16384 = 4.19M tokens KV contra 321,376 residentes). Lo empeoró, y de todos modos el pico de carga ofrecida de mi benchmark era 64, así que el tope nunca llegó a limitar. Déjalo en 256 a menos que veas preemption real en los logs.
--kv-cache-dtype auto
Déjalo, y desconfía de cualquiera que te diga lo contrario. --kv-cache-dtype fp8_e4m3 da una ratio de capacidad 1.000x en este stack. El layout de bloques KV está word-aligned:
bf16: regular_attn_shape=(num_blocks, (32, 1, 2, 256)) → 32,768 bytes/block/layer
fp8: regular_attn_shape=(num_blocks, (32, 1, 4, 256)) → 32,768 bytes/block/layer
La tercera dimensión se duplica exactamente cuando el ancho del elemento se reduce a la mitad. Estrechar el elemento compra padding, no capacidad — y cuesta ~2% de throughput. El flag se acepta en la CLI, se repite en non-default args, se elogia en una línea del log, se reporta en /metrics, y asigna un tensor genuinamente float8_e4m3fn. Cinco señales independientes de que funcionó, y no hizo nada.
Esperaba que --kv-cache-dtype int8 fuera peor todavía — alcanzable, redondeando silenciosamente el caché a enteros porque el modelo fija sus escalas K/V a 1.0. Lo probé y eso es incorrecto en este build: int8 no está en el enum de la CLI de vLLM y es rechazado antes de llegar a nada de eso (invalid choice: 'int8'). Los 16 valores aceptados son auto, bfloat16, float16, fp8, fp8_ds_mla, fp8_e4m3, fp8_e5m2, fp8_inc, fp8_per_token_head, int4_per_token_head, int8_per_token_head, nvfp4 y cuatro turboquant_*. Los que sí se aceptan pero no se soportan (int8_per_token_head, turboquant_*, nvfp4, fp8_inc, fp8_ds_mla) fallan ruidosamente al boot en lugar de silenciosamente — así que en este build el resumen honesto es: fp8 es la trampa, porque es el que aparenta funcionar.
auto no es vago aquí — significa “heredar el dtype del modelo”, que --dtype bfloat16 fija.
--tensor-parallel-size 1
Un chip, así que es forzado. Pero vale la pena saber por qué más chips no ayudarían a este modelo: E2B tiene num_key_value_heads=1 — MQA completo. Una sola cabeza KV no se puede shardear; los runtimes la rellenan hasta un múltiplo del tamaño TP, así que con TP=4 pagas 4× la memoria KV para almacenar la misma cabeza replicada. Para este modelo, más chips multiplican el costo KV en lugar de dividirlo.
--disable-chunked-mm-input y --limit-mm-per-prompt
Estos son el contrato multimodal, y juntos fijan el piso de 2496 tokens discutido arriba. Si no necesitas imágenes y audio, quitarlos deja que max-num-batched-tokens llegue al bucket de 2048, que es la única ruta a un piso de latencia más bajo en este stack. Ese es un trade real que evaluaría antes de copiar esta config a un deployment solo-texto.
--enable-auto-tool-choice --tool-call-parser gemma4 --reasoning-parser gemma4
Lo que convierte esto en un backend de agente en lugar de una caja de texto: tool calling compatible con OpenAI, parseado nativamente. Vale la pena saber que esto trae maquinaria de structured-decoding — el programa que se quedó sin memoria en el experimento de 0.95 era jit_structured_decode_fn — así que no son gratis contra ese remanente no-gobernado de 1.26 GiB.
Variables de entorno que vale la pena conocer
Se fijan con -e en docker run; vienen de tpu-inference, no de vLLM:
| var | default | qué hace |
|---|---|---|
ATTN_BUCKETIZED_NUM_REQS | False | precompilar atención en buckets de requests power-of-two en lugar de una sola forma a max_num_seqs |
SLICE_ROPE_CACHE | False | trocear el rotary cache a max_model_len al cargar — HBM gratis |
NUM_PRECOMPILE_WORKERS | 1 | precompilación XLA paralela; compilar son 685 s de un boot de 857 s |
VLLM_TPU_BUCKET_PADDING_GAP | 0 | incrementos de bucket lineales (usa 128) en lugar de potencias de dos — el fix del problema 2496-pads-to-4096 |
VLLM_XLA_CHECK_RECOMPILATION | False | error ante una recompilación en runtime; actívalo para un boot de validación |
Parte 3 — Resultados: cuántos clientes, y para qué
Throughput y latencia, medidos
Proveniencia. Las 12 celdas se midieron en la configuración de arriba, 3 repeticiones cada una (36 runs). El throughput es estable — coeficiente de variación ≤3.4%, la mayoría ≤1%. La única excepción está marcada: el TTFT de 128-ctx/16-client tiene cv 56% entre reps, así que ningún valor puntual es confiable ahí. Una config anterior se re-corrió como control y reprodujo un sweep previo al dígito (TPOT 8.05/8.08/8.43 ms, KV 5.52 GiB, 10,043 bloques), así que el rig es estable.
Tokens de salida agregados por segundo:
| contexto ↓ / clientes → | 1 | 4 | 16 | 64 |
|---|---|---|---|---|
| 128 | 123.7 | 433.9 | 1,152.5 | 1,496.5 |
| 1,024 | 120.4 | 415.4 | 991.0 | 1,258.7 |
| 8,192 | 94.2 | 254.4 | 399.6 | 324.3 |
Time-to-first-token mediano (ms):
| contexto ↓ / clientes → | 1 | 4 | 16 | 64 |
|---|---|---|---|---|
| 128 | 15.9 | 31.8 | 98.8* | 247.3 |
| 1,024 | 40.3 | 51.5 | 170.1 | 421.7 |
| 8,192 | 289.4 | 304.2 | 594.4 | 11,734 |
Tokens por segundo por stream (lo que siente un usuario):
| contexto ↓ / clientes → | 1 | 4 | 16 | 64 |
|---|---|---|---|---|
| 128 | 125 | 112 | 76 | 25 |
| 1,024 | 124 | 109 | 69 | 21 |
| 8,192 | 119 | 71 | 27 | 10 |
Los tres regímenes
Contexto corto (≤1K) escala limpiamente a 64 clientes. 12.1× el throughput de un solo stream. Este es el régimen donde vive la mayoría del tráfico de agentes.
Contexto largo (8K) alcanza su pico a 16 clientes y luego retrocede. 400 → 324 tok/s de 16 a 64, con TTFT mediano explotando a 11.7 segundos. La razón es aritmética: 64 streams × 8,192 tokens = 524,288 tokens KV pedidos contra 321,376 residentes. Pasado el muro de KV, más clientes compran colas, no throughput.
Un solo stream está limitado por ancho de banda a aproximadamente la mitad del pico. El decode mueve ~3.15 GiB por step (las tablas de embedding por capa del modelo, 4.38 GiB, se recogen, no se streamean), que a los 800 GiBps del v5e es un piso de 3.94 ms contra 8.02 ms medidos — 49% del pico. (Esos 3.15 GiB se derivan de la geometría de capas del modelo, no de una lectura de instrumento; los 8.02 ms son medidos.) Hay aproximadamente 2× de headroom sentado en costo fijo por step, no en ancho de banda de memoria.
Conteos de clientes recomendados
| workload | contexto | clientes | esperado |
|---|---|---|---|
| Chat interactivo / turnos de agente | ≤1K | 16 | 991–1,152 tok/s, 99–170 ms TTFT, ~14 ms TPOT |
| Máximo throughput, batch/offline | ≤1K | 64 | 1,259–1,496 tok/s, 247–422 ms TTFT |
| RAG / documentos largos | 8K | 16 | 399.6 tok/s, 594 ms TTFT |
| Interactivo de contexto largo | 8K | ≤4 | 254 tok/s, ~304 ms TTFT |
Regla de oro: mantén clientes × contexto por debajo de los 321K tokens de KV vivo.
Parte 4 — Costo: spot vs flex-start vs reserved
Tarifas en vivo del Cloud Billing Catalog para us-west4, por chip-hora:
| modelo | $/chip-hr | vs spot | ¿se detiene solo? | ¿preemtible? |
|---|---|---|---|---|
| Compromiso de 3 años | 0.5400 | −6.6% | n/a | no |
| Spot | 0.5779 | — | no | sí, ~30 s de aviso |
| Flex-start (DWS) | 0.6000 | +3.8% | sí (--max-run-duration) | no, una vez corriendo |
| Commit 1 año / Reserved | 0.8400 | +45% | n/a | no |
| On-demand | 1.2000 | +108% | no | no |
El resultado que me sorprendió: flex-start es la opción por defecto, no spot
Spot es solo 3.8% más barato que flex-start, y ese descuento es frágil. Una preemption cuesta un cold start completo — 857 s, de los cuales 685 s son recompilación — que son $0.138 de gasto desperdiciado a la tarifa spot. La prima de flex-start es $0.0221/hora. Así que:
Spot solo gana a flex-start mientras las preemptions sean menos frecuentes que cada 6.2 h sin el mount del caché, o cada 3.6 h con él. Costo de rebuild medido: $0.1376 frío, $0.0798 tibio.
Y flex-start deja de facturar solo vía --max-run-duration, mientras que spot y on-demand corren hasta que te acuerdes de borrarlos. Un fin de semana olvidado en un nodo spot ($0.58 × 60 h ≈ $35) borra meses del ahorro del 3.8%.
Monta el caché de compilación y el cálculo cambia de nuevo — un caché tibio convierte una preemption de ~18 minutos en ~6, que es exactamente por qué ese único flag -v importa más que cualquier flag de tuning aquí.
Costo por millón de tokens de salida
| workload | tok/s | spot | flex-start | on-demand |
|---|---|---|---|---|
| 128 ctx, 64 clientes | 1,496.5 | $0.107 | $0.111 | $0.223 |
| 1K ctx, 64 clientes | 1,258.7 | $0.128 | $0.132 | $0.265 |
| 1K ctx, 16 clientes | 991.0 | $0.162 | $0.168 | $0.336 |
| 8K ctx, 16 clientes | 399.6 | $0.402 | $0.417 | $0.834 |
| 1 cliente (cualquier ctx) | 123.7 | $1.298 | $1.347 | $2.695 |
Corriendo un chip 24/7: $422/mes en spot, $438 en flex-start, $876 on-demand.
El titular: el batching vale más que cualquier decisión de precios. Pasar de 1 cliente a 64 en contexto corto es una reducción de costo de 12.1× por token — mucho mayor que los 2.08× entre on-demand y spot. Ajusta tu concurrencia antes de buscar descuentos.
Parte 5 — Cuatro cosas en las que estaba confiadamente equivocado
Las predicciones refutadas fueron la salida más valiosa de este ejercicio.
1. “El KV cache fp8 duplicará la capacidad.” Dio 1.000x, porque el layout está word-aligned. Cinco señales independientes dijeron que el flag había funcionado. Solo la línea de block-shape reveló la verdad. Lección: verifica la cuantización desde el log de asignación del boot, nunca desde que el flag sea aceptado.
2. “0.95 de utilización de memoria es un 8% gratis de KV.” La matemática KV acertó al 0.03% y el engine murió igual, 13 minutos después, porque los programas compilados viven fuera del control del knob. Lección: un knob de memoria que no gobierna toda la memoria te va a mentir.
3. “Bajar max-num-batched-tokens exactamente al piso multimodal reducirá la latencia.” Costó 24.7% de throughput y movió la cola de latencia en 0.2%, porque 2496 y 4096 redondean al mismo bucket compilado. Lección: en TPU, la forma que obtienes no es el número que escribiste.
4. “Limitar max-num-seqs a 64 acelerará el decode.” No lo hizo, y el brazo que lo llevaba fue peor en cada celda. Lección: si la carga ofrecida de tu benchmark nunca alcanza el tope, el tope no está probado — dilo en lugar de reclamar la victoria.
Hay una quinta que no he podido arreglar. El log de boot reporta Hybrid KV cache layout: num_kv_cache_groups=1 — cada una de las 15 capas cacheadas recibe asignación KV de longitud completa, aunque 12 de ellas son capas sliding-attention con ventana de 512 tokens. Ponerles ventana valdría 2.8× la capacidad KV a contexto 16K, con costo de calidad cero. Es inalcanzable: tpu-inference deshabilita sliding windows para cualquier modelo con más de un head dim, y Gemma 4 tiene dos (256 en capas sliding, 512 en full). El código fuente lleva un TODO: enable sliding windows once mixed dims support. Vale la pena re-chequear en cada bump de imagen — es la ganancia individual más grande que sigue sobre la mesa.
Todo lo anterior fue validado end-to-end
La configuración final se booteó desde cero y se ejercitó, no se ensambló a partir de fragmentos ganadores:
| check | resultado |
|---|---|
| cold boot | 857 s (compilar 685 s = 80%) |
| warm boot, caché de compilación montado | 497 s (−42%) |
| memoria | 14.49 GiB tope / 8.97 pesos / 5.52 KV — coincide con todos los demás brazos |
| capacidad KV | block_size 64 x 5,021 bloques = 321,344 tokens |
| chat completion | ✅ |
| tool calling | ✅ {"name":"get_weather","arguments":"{\"city\": \"Paris\"}"} |
| imagen multimodal | ✅ describió correctamente un PNG de gradiente sintético |
| contexto largo | ✅ 26,016 tokens de prompt aceptados |
| throughput | 12 celdas x 3 reps, cv ≤3.4% |
Los tres flags que reafirman un default (--dtype, --kv-cache-dtype, --gpu-memory-utilization) están confirmados como no-ops por el propio vLLM — no aparecen en los non-default args del engine cuando se pasan con esos valores. Están en el comando por auditabilidad, ya que los defaults reales se calculan varias capas más abajo de donde parecen declarados.
fp8 KV fue re-verificado en este build exacto, porque el resultado original precedía a un rebuild del contenedor. Mismos 5,021 bloques, mismos 5.52 GiB, mismos 321,344 tokens que bf16 — mientras la forma va de (64,1,2,256) → (64,1,4,256) y el dtype realmente es float8_e4m3fn. La primera vez que medí esto el tamaño de página era 32; se reproduce a 64, así que el mecanismo de word-alignment no es un artefacto del block size.
Apéndice: gotchas que me costaron tiempo real
/v1/completionsdevuelve un string vacío en modelos-it. Usa/v1/chat/completions. Un resultado vacío de benchmark es esperado ahí, no un deploy roto.v5eesv5litepodpara gcloud. Tipo de aceleradorv5litepod-1, runtimev2-alpha-tpuv5-lite,--type=v5litepod --topology=1x1.- No hardcodees el endpoint. La IP externa cambia cada vez que se recrea el nodo.
- No confíes en un “ID” de imagen como target de pull.
sha256:2a4a1f82…dedocker imageses un ID de config-blob, no un digest de manifest;docker pullpor él falla conunexpected media type. El string de versión es mejor handle —0.26.1rc1.dev125+g**a7a204cc6**embebe el git SHA de vLLM, así que puedes leer la fuente exacta que tu build envió. - Upstream marca los tests de correctness de E2B como fallando. La propia tabla de soporte de tpu-inference marca
gemma-4-E2B-it✅ unit / ❌ correctness / ❓ performance, mientras que los 26B y 31B pasan los tres. Mis propias sondas de calidad estuvieron limpias (8/9 outputs byte-idénticos, 3/3 needle retrievals a 2K/8K/14K), pero deberías saber que la bandera existe. - Los docs de config TPU en
docs.vllm.ai/en/v0.11.1/configuration/tpu/son excelentes y parcialmente stale. Su recomendación titularVLLM_TPU_MOST_MODEL_LENya no existe ni en vLLM ni en tpu-inference. Revisa la versión antes de copiar.
Qué haría distinto la próxima vez
Probar la configuración completa, no un cambio sobre ella. Mi recomendación final es una config que nunca se corrió end-to-end — es el brazo ganador más tres pines que deberían ser no-ops. Cada vez que asumí que algo era un no-op en este proyecto, eventualmente me demostraron que estaba equivocado.
Sin probar y plausiblemente mejor: max-model-len 65536 (block_size iría a 128, bloques-por-request se queda en 512, así que puede ser gratis también), max-num-batched-tokens 8192, VLLM_TPU_BUCKET_PADDING_GAP=128, y decodificación especulativa n-gram — que no necesita checkpoint draft y está marcada como completamente aprobada en TPU, contra un workload sentado al 49% del ancho de banda de memoria.
¿Quieres llevar tu stack de agentes a producción con infraestructura propia? Sigue explorando estos temas en el blog de DojoFullStack: self-hosting de LLMs, optimización de costos de inferencia y arquitecturas de agentes de código.