El montacargas de la despensa: del disco a la HBM, o por qué la cocina abre tarde

Esta es una bajada al sótano. La serie por debajo del motor optimizó la ruta caliente —lo que pasa con cada token ya en servicio. Este post mira el trayecto de antes de servir: cómo los pesos suben del disco a la HBM. Es el primero de un par sobre las dos cosas que pasan fuera de la API y casi nadie cronometra: la carga del modelo (este) y la ejecución en el silicio (el siguiente).

TL;DR

Antes de que un pod de inferencia genere su primer token, tiene que subir el modelo entero a la HBM. Un Llama-70B en FP16 son 140 GB que viajan por un camino que nadie dibuja: disco → page cache → buffer de host → PCIe → HBM. La intuición falla aquí: la HBM no es el cuello —mueve 3,35 TB/s y traga 140 GB en 42 ms—; el cuello es la cadena de suministro. El disco NVMe Gen5 lee a ~14 GB/s (10 s para 140 GB); el PCIe Gen5 copia host→GPU a ~50 GB/s (2,8 s); y el loader de safetensors por defecto, que deserializa tensor a tensor y rebota cada byte por un buffer de CPU, infla todo eso hasta 30-60 s. Ese tiempo es el cold start, y es el impuesto oculto que pagan el autoscaling (scale-from-zero), el canary/blue-green y el disaggregated serving cada vez que nace un pod. Hay tres familias de solución —GPUDirect Storage (DMA directo disco→HBM, sin rebote por CPU), fastsafetensors (4,8-7,5× sobre el loader por defecto) y el Run:ai Model Streamer (lectura concurrente que satura el disco)— más la palanca más simple de todas: mover menos bytes (FP8 es la mitad que FP16). Este post explica el camino, las matemáticas, los 10 knobs, y la trampa más cruel: “la segunda vez cargó rápido” no es tu loader siendo bueno, es la page cache mintiéndote. Sobre el cluster genérico 4×H100 SXM.

Dónde estás: el sótano, antes de abrir

El camino de carga · antes del primer tokenHBM · 80 GB, 3,35 TB/s — el destino, nunca el cuelloPCIe Gen5 x16 · ~50 GB/s host→GPU (H2D)ESTÁS AQUÍ · loader + page cache + buffer hostdeserializar, rebotar por CPU o DMA directo (GDS)NVMe Gen5 · ~14 GB/s por disco — el origenRed / Ceph RGW · pesos compartidos (más lento aún)La ruta caliente del token vive arriba; este post abre lo de abajoel trayecto que decide si la cocina abre en 10 s o en 60 s

La analogía: el montacargas de la despensa

Sigamos en el restaurante de la serie. La mesa compartida era el NVSwitch, la planta de al lado era el NUMA, el maître era el kubelet. Todo eso describe el restaurante funcionando, con comensales sentados. Pero hay un momento que ningún post miró: antes de abrir, alguien tiene que subir toda la despensa desde el almacén del sótano hasta la cocina.

Los pesos del modelo son los ingredientes. Viven en el almacén del sótano (el disco). La cocina —la línea caliente donde se emplatan los tokens— es la HBM de la GPU. Y entre uno y otro hay un montacargas: el camino disco → host → PCIe → HBM. La cocina no puede servir el primer plato hasta que la despensa esté arriba y colocada. Ese tiempo de reposición es el cold start.

La trampa de la intuición: la cocina (HBM) es enorme y rapidísima, coloca ingredientes a 3,35 TB/s. Así que culpamos a la cocina cuando el restaurante abre tarde. Pero la cocina está parada esperando el montacargas. El cuello nunca es la línea caliente: es el montacargas y el almacén. Y, peor todavía, hay un mozo (el loader por defecto) que en vez de cargar cajas enteras, saca los ingredientes uno a uno, los apunta en una libreta y los vuelve a empaquetar antes de subirlos. Ese mozo —no el montacargas— es la mitad del problema.

El mecanismo: qué pasa de verdad al cargar un modelo

Cuando vLLM arranca con un modelo en safetensors, los 140 GB del Llama-70B FP16 hacen este viaje:

  1. Disco → page cache. El kernel lee los ficheros .safetensors del NVMe a la page cache (RAM de host). Si es la primera vez tras un reinicio, es lectura física del disco (~14 GB/s en Gen5). Si los ficheros ya están en page cache de un arranque anterior, esto es casi gratis —y aquí nace la trampa que veremos.
  2. Deserializar. El loader de Hugging Face por defecto hace mmap del fichero y construye los tensores uno a uno, copiándolos a un tensor de CPU antes de moverlos. Es trabajo de CPU monohilo que no satura ni el disco ni el PCIe: la mayoría del tiempo de carga “lenta” se va aquí, no en mover bytes.
  3. Host → HBM (H2D). Cada tensor se copia del buffer de host a la HBM por PCIe Gen5 x16 (~50 GB/s prácticos). Para que el DMA sea eficiente, el buffer de host debería ser pinned —lo que conecta directamente con las hugepages y la memoria fijada del post de NUMA.
  4. Colocar en HBM. La HBM recibe los 140 GB. A 3,35 TB/s, esto tarda 42 ms. Nunca es el cuello.

El camino tiene un atajo: GPUDirect Storage (GDS). En vez de rebotar por el buffer de CPU (paso 2-3), un motor DMA cerca del controlador NVMe escribe directamente del disco a la HBM, sin involucrar a la CPU. Es el mismo principio que el GPUDirect RDMA de red: sacar a la CPU del medio. fastsafetensors usa GDS y alcanza 26,4 GB/s leyendo un Llama-70B desde NVMe sobre 4 GPUs.

Dos caminos del disco a la HBMPor defecto: rebote por CPUNVMebuffer CPU+ deserializarHBMla CPU toca cada byte · monohilo · lentoGPUDirect Storage: DMA directoNVMeHBMla CPU no toca los datos · ~26 GB/s NVMe→HBM140 GB FP16HBM: 42 msPCIe: 2,8 sNVMe: 10 sdefecto: 30-60 s

Por qué existe el problema: la economía de bytes

El tamaño del modelo en bytes lo decide la cuantización, y eso fija el suelo del cold start —porque hay que mover todos esos bytes antes del primer token. Para un modelo de 70B parámetros:

FormatoBytes/parámTamaño 70BLeer 1 NVMe @14 GB/sH2D PCIe @50 GB/sHBM @3,35 TB/s
FP16 / BF162140 GB10,0 s2,8 s42 ms
FP8170 GB5,0 s1,4 s21 ms
INT4 (GPTQ/AWQ)0,5~35 GB2,5 s0,7 s10 ms

Tres lecturas de esta tabla:

La HBM nunca aparece como problema. La última columna es siempre milisegundos. Quien diga “la GPU tarda en cargar” está culpando al sitio equivocado.

Cuantizar es la palanca de cold start más infravalorada. Pasar de FP16 a FP8 no solo dobla el throughput de inferencia (menos ancho de banda HBM por token, como vimos en quantization): también parte por la mitad el cold start, porque hay la mitad de bytes que subir. Es un dos por uno que el dimensionado suele ignorar.

El disco es el cuello de los bytes; el loader es el cuello del tiempo. Las cifras de la tabla son el suelo teórico —solo mover bytes. El loader por defecto añade el deserializado monohilo encima, que es la diferencia entre los 10 s teóricos y los 30-60 s reales. Por eso las soluciones atacan en dos frentes: menos bytes (cuantización) y mejor mozo (loaders concurrentes / GDS).

Las matemáticas que importan: el cold start como impuesto del autoscaling

El cold start no se paga una vez. Se paga cada vez que nace un pod. Y en una plataforma elástica, los pods nacen continuamente.

Pongamos un autoscaling con KEDA que escala de 2 a 6 réplicas cuando sube la cola. Las 4 réplicas nuevas tardan en estar listas:

$$ T_{\text{ready}} = T_{\text{schedule}} + T_{\text{pull-image}} + T_{\text{load-weights}} + T_{\text{cuda-graphs}} $$

Con un Llama-70B FP16 y el loader por defecto, $T_{\text{load-weights}}$ domina: puede ser 40 s de los ~60 s totales. Durante esos 40 s, la cola que disparó el autoescalado sigue creciendo —las réplicas nuevas no absorben tráfico hasta que cargan. El número real de la fórmula no es “cuántas réplicas”, es cuánto tarda cada una en empezar a servir, y ese número lo escribe el camino de carga.

Esto tiene una consecuencia operativa dura: scale-to-zero es inviable para cargas con SLO de latencia si el cold start es de 40 s. Nadie espera 40 s al primer token. La elasticidad real de una plataforma de inferencia no la limita la GPU disponible —la limita cuánto tarda esa GPU en tener el modelo dentro. Bajar el cold start de 40 s a 8 s (con streamer + FP8) es lo que convierte “scale-to-zero teórico” en “scale-to-zero usable”.

Los números publicados dan el orden de magnitud de la mejora: fastsafetensors reduce el arranque de 12,39 s a 4,74 s en un Llama-2-13B sobre 4×L40S, y de 16,04 s a 6,88 s en 1×A100 —4,8-7,5× sobre el deserializador por defecto. El Run:ai Model Streamer carga en 4,88 s desde S3 a concurrencia 32 y 7,53 s desde SSD IO2 a concurrencia 8; integrado en vLLM, el tiempo total hasta ready baja a ~23 s desde S3. No son magias: es sacar a la CPU del bucle (GDS) y leer en paralelo (concurrencia) para saturar el disco en vez de dejarlo medio ocioso mientras un hilo deserializa.

Los 10 knobs donde tocar

Knob 1 — Medir dónde se va el tiempo (read vs deserializar vs H2D)

Antes de tocar nada: cronometrar. ¿El tiempo está en leer del disco, en deserializar, o en el H2D? iostat -x 1 durante la carga dice si el NVMe está saturado (cuello de disco) o casi ocioso (cuello de loader/CPU). Si el disco va al 20%, el problema no es el disco: es el mozo. Cambiar de disco no arreglaría nada; cambiar de loader, sí.

Knob 2 — --load-format: elegir el mozo

vLLM expone varios cargadores vía --load-format: safetensors (defecto), runai_streamer, fastsafetensors, tensorizer. El defecto es el más lento. El cambio de una bandera puede ser el 4-7× más barato que existe.

# Run:ai Model Streamer (lectura concurrente, satura el disco)
vllm serve meta-llama/Llama-3.1-70B --load-format runai_streamer
# fastsafetensors (GPUDirect Storage, DMA directo disco→HBM)
vllm serve meta-llama/Llama-3.1-70B --load-format fastsafetensors

Knob 3 — Concurrencia del streamer

El Run:ai Model Streamer reparte la lectura en N hilos según el tamaño de cada tensor para saturar el ancho de banda del almacenamiento. La concurrencia es el parámetro clave: 16 suele bastar para NVMe local; 32 (a veces 64) para almacenamiento de red de alto throughput. Un hilo no satura un NVMe Gen5; 32 sí.

vllm serve <model> --load-format runai_streamer \
  --model-loader-extra-config '{"concurrency": 32}'

Knob 4 — GPUDirect Storage (GDS) + fastsafetensors

Si hay driver nvidia-fs, filesystem soportado y NVMe local, GDS escribe directo disco→HBM sin rebote por CPU. Es la diferencia entre los dos caminos del diagrama. Pero: requiere el stack montado y solo gana si el cuello es el rebote por CPU, no el propio disco. Verificar con gdscheck.

Knob 5 — NVMe local para los pesos, no red

Servir los pesos desde Ceph RGW / NFS es cómodo (un sitio compartido) pero mete la red en el camino de carga. Para el cold start, pesos en NVMe local del nodo (o cache local). El almacenamiento de red es para el repositorio de modelos; el nodo de inferencia debería tener una copia local caliente.

Knob 6 — Pre-pull / cache local del modelo

Un initContainer que descarga el modelo a un volumen local o hostPath NVMe antes de arrancar vLLM convierte un cold start “desde la red” en uno “desde NVMe local”. Combinado con un DaemonSet de cache por nodo, los pods nuevos en un nodo ya caliente leen del disco local, no de la red.

Knob 7 — Cuantización: mover menos bytes

Pesos ya en FP8 o INT4 en el disco = la mitad o un cuarto del cold start. Es el knob de la tabla de arriba. Y se compone con todos los demás: FP8 + streamer + GDS es multiplicativo.

Knob 8 — Carga paralela entre GPUs (vLLM V1)

El engine V1 de vLLM (defecto desde 0.19) carga los shards de pesos en paralelo entre las GPUs de un TP, en vez de secuencialmente. En TP=4, cada GPU carga su cuarto a la vez. Verificar que está activo; en versiones viejas la carga era serial y el cold start de TP=4 era casi 4× el de TP=1.

Knob 9 — Localidad NUMA del NVMe

El NVMe cuelga de un PCIe root bajo un socket concreto —exactamente el mismo mapa NUMA del post del host. Si el buffer de host de la carga cae en el socket equivocado, el H2D cruza la UPI. La quinta lista a alinear, junto a isolcpus, reserved-cpus e IRQ de la NIC: qué socket es local al NVMe y a la GPU destino. nvidia-smi topo -m lo muestra.

Knob 10 — No pagar el cold start: mantener pods calientes

A veces la respuesta no es cargar más rápido, sino no descargar. Un suelo de réplicas siempre vivas (no scale-to-zero), o un pool de warm standby precargado, cambia “esperar 40 s” por “0 s”. Es coste de GPU ociosa a cambio de latencia de arranque: una decisión de capacity planning, no técnica.

Tabla resumen

#KnobQué atacaRiesgo / coste
1iostat al cargarsaber si el cuello es disco o loaderninguno; hazlo siempre primero
2--load-formatel deserializado monohilocompatibilidad del formato
3concurrencia streamerdisco infrautilizadoRAM de host por buffers
4GPUDirect Storagerebote por CPUrequiere nvidia-fs + FS soportado
5NVMe local vs redla red en el caminoduplicar pesos por nodo
6pre-pull / cache nodored en cada arranqueespacio en disco local
7cuantización FP8/INT4bytes a movercalidad (medir, no asumir)
8carga paralela V1carga serial entre GPUsninguno si V1 activo
9NUMA del NVMeH2D cruzando UPIalinear con el resto de listas NUMA
10warm pods / no zeroel cold start enteroGPU ociosa pagada

Cómo se conecta con el resto del stack

Con el autoscaling. Todo el scale-from-zero con KEDA descansa sobre esto: la elasticidad real la limita el cold start, no la GPU disponible. Un autoescalado con 40 s de carga reacciona tarde a cada pico.

Con el disaggregated serving. En prefill/decode desagregado, levantar un pool de decode bajo demanda paga el cold start de cargar el modelo en cada pod nuevo. La elasticidad del patrón depende de cuán rápido arrancan esos pods.

Con canary/blue-green. Cada despliegue canary carga una versión nueva del modelo en paralelo a la vieja. El tiempo de validación de un canary incluye su cold start; modelos grandes hacen los despliegues más lentos y caros.

Con NUMA y hugepages. El buffer de host de la carga quiere ser pinned y NUMA-local —lo mismo que pedía el post del host para la ruta caliente. El camino de carga es otro cliente del mismo mapa NUMA.

Con la cuantización. FP8/INT4 no es solo throughput de inferencia: es la palanca directa sobre los bytes del cold start.

Con capacity planning. El dimensionado que ignora el cold start subestima cuántas réplicas hacen falta para absorber un pico: si tardan 40 s en arrancar, necesitas más colchón permanente.

Trampas y cosas que no son lo que parecen

“La segunda vez cargó rapidísimo.” Es la trampa estrella. La primera carga llenó la page cache (RAM de host); la segunda lee de RAM, no del disco, y vuela. Pero en producción los pods son efímeros y nacen en nodos distintos: el arranque que cuenta es el frío, en un nodo donde esos ficheros no están en page cache. Benchmarquear la segunda carga es medir una situación que casi nunca ocurre en el momento que importa (el pico que dispara el autoescalado).

“GDS siempre acelera.” No. GDS elimina el rebote por CPU; si tu cuello es el propio disco (NVMe saturado) o el deserializado, GDS no toca esa parte. Mide primero (knob 1). Además exige nvidia-fs, un filesystem soportado y a veces no funciona sobre el almacenamiento de red que tengas.

“mmap hace la carga instantánea.” mmap mapea el fichero pero no lee nada todavía: el coste se difiere al primer acceso a cada página. El tiempo no desaparece, se mueve —el primer token paga los page faults que el arranque no pagó. Has movido el cold start a la latencia del primer request, que probablemente es peor sitio para tenerlo.

Pesos en almacenamiento de red “porque es más limpio”. Compartir un repositorio de modelos en Ceph RGW está bien para almacenarlos; servir el cold start desde ahí mete la red (y su latencia y su contención) en el camino crítico. Cache local NVMe en el nodo de inferencia.

Cargar FP16 y cuantizar en el arranque. Cuantizar al vuelo durante la carga (p. ej. FP16→FP8 en GPU) puede ser más lento que tener los pesos ya cuantizados en disco: mueves el doble de bytes y encima haces trabajo de conversión. Si vas a servir en FP8, guarda los pesos en FP8.

Optimizar la carga e ignorar T_cuda-graphs. Bajar la carga de pesos a 8 s y olvidar que la captura de CUDA graphs añade varios segundos más deja el cold start a medias. Esa segunda mitad del arranque es el tema del post siguiente.

Conclusión

Toda la serie optimizó lo que pasa con cada token: el cable, el host, la red, el silicio. Pero antes del primer token hay un trayecto que casi nadie cronometra y que decide si un pod de inferencia abre en 10 s o en 60 s: subir el modelo del disco a la HBM. La intuición culpa a la GPU, y la GPU es inocente —la HBM traga 140 GB en 42 ms. El cuello es la cadena de suministro: un disco que lee a 14 GB/s, un PCIe que copia a 50, y sobre todo un loader por defecto que deserializa tensor a tensor con un solo hilo y convierte 10 s de bytes en 60 s de espera. Las soluciones atacan los dos frentes correctos —menos bytes (cuantización) y mejor transporte (GDS, streamers concurrentes, NVMe local)— y dan 4-7× casi gratis. Y por encima de la técnica, una idea que reordena la prioridad: en una plataforma elástica, el cold start no es un detalle de arranque, es el techo de la elasticidad. La GPU más rápida del mundo no escala si tarda 40 s en tener el modelo dentro. El montacargas de la despensa, ese que nadie cronometró, es lo que decide a qué hora abre de verdad la cocina.

Ver también

Referencias