---
title: "Acelerar el cold start de modelos: de minutos a segundos"
url: https://blog.lo0.es/posts/acelerar-cold-start-carga-modelos-tensorizer/
markdown: https://blog.lo0.es/posts/acelerar-cold-start-carga-modelos-tensorizer/index.md
lang: es
date: 2026-06-11
lastmod: 2026-07-28
author: "José Manuel Román"
description: "El cold start de un servidor de modelos no es una sola cosa: son cinco partidas —init del proceso, contexto CUDA, allocator, carga de pesos disco→HBM y captura de CUDA graphs + JIT de kernels—. Este runbook ataca las cinco con números: safetensors (mmap/zero-copy), Tensorizer y Run:ai Model Streamer para los pesos, caché de compilación y warm pools para lo demás, y la economía de cuándo merece scale-to-zero frente a tener una réplica caliente."
tags: ["cold-start","tensorizer","model-streamer","safetensors","vllm","scale-to-zero","cuda-graphs","inferencia","fundamentos"]
translations:
  en: https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/index.md
---

# Acelerar el cold start de modelos: de minutos a segundos


> Esta es la **tercera tanda** de una serie operativa sobre exprimir un cluster LLM on-premise genérico de **4×H100 SXM 80 GB con NVLink**. Las hermanas de esta tanda son [Multimodal: servir un VLM on-premise con vLLM](https://blog.lo0.es/posts/multimodal-vlm-on-premise-vllm/index.md) —añadir visión al mismo motor— y [FinOps y multi-tenancy de GPU con LiteLLM](https://blog.lo0.es/posts/finops-multi-tenancy-gpu-litellm/index.md) —repartir y cobrar la GPU entre equipos—. Este post toma el problema que [Del disco a la HBM](https://blog.lo0.es/posts/del-disco-a-la-hbm-cold-start-carga-modelo/index.md) dejó planteado en lo conceptual y lo convierte en runbook: cómo se baja de verdad el cold start, knob a knob, para que la elasticidad sea usable.

## TL;DR

El post [Del disco a la HBM](https://blog.lo0.es/posts/del-disco-a-la-hbm-cold-start-carga-modelo/index.md) dejó una idea incómoda: **mover los pesos del disco a la HBM es solo una de las cinco partidas del cold start**. Las otras cuatro —arrancar el proceso, crear el contexto CUDA, montar el allocator y, la más cara que casi nadie mira, **capturar los CUDA graphs y compilar los kernels JIT**— no las toca ningún loader rápido. Por eso un servidor que carga los pesos en 6 s puede seguir tardando **90 s** en estar listo. Este runbook ataca las cinco. Para los pesos: **safetensors** (mmap, zero-copy, sin rebote por un buffer FP32 de host) frente al `pickle` de `torch.load`; **Tensorizer** (CoreWeave) que serializa el modelo en un fichero y lo **streamea tensor a tensor directo a la GPU** desde objetos/HTTP/S3; y el **Run:ai Model Streamer** (de NVIDIA), que **lee en concurrencia y solapa la lectura con la copia H2D** —los tres se activan con una bandera, `--load-format`, en vLLM—. Para lo no-pesos: **caché de compilación de torch.compile/Inductor** (que se persiste y se reusa entre arranques), **acotar la captura de CUDA graphs** a los batch sizes que de verdad usas (la captura por defecto puede comerse ~54 s), y el **sleep mode** como atajo que se salta las cinco partidas. Las matemáticas: $t = W/B$ para los pesos, el ahorro de **solapar** lectura y H2D frente a hacerlo en serie, y un cold start de 70B descompuesto partida a partida. Y la economía: cuándo **scale-to-zero con pre-warm** gana a **mantener una réplica caliente**. Sobre el cluster genérico 4×H100 SXM 80 GB.

## La analogía: abrir la cocina por la mañana

Un restaurante no abre con tocar un interruptor. Quien haya trabajado en uno lo sabe: la hora de apertura la decide **todo lo que pasa antes**, y casi nada de ello es "traer la comida".

El primer cocinero llega a un local frío y a oscuras. Tiene que: **encender las luces y arrancar los sistemas** (el init del proceso); **encender los hornos y dejar que cojan temperatura** (el contexto CUDA, que tarda lo suyo en estar operativo); **sacar las tablas, ordenar la cámara, organizar el espacio de trabajo** (el allocator de memoria, que reserva y estructura la HBM); **bajar al almacén y subir el género** —las cajas de verdura, la carne, el pescado— **y colocarlo en su sitio** (la carga de pesos del disco a la HBM, el tema entero de [Del disco a la HBM](https://blog.lo0.es/posts/del-disco-a-la-hbm-cold-start-carga-modelo/index.md)); y, lo que más se olvida, **afilar todos los cuchillos, montar el mise en place, hacer los fondos, precalentar las salsas madre** —toda la preparación que no es un ingrediente pero sin la cual no sale un solo plato— (la captura de CUDA graphs y la compilación JIT de los kernels).

Aquí está la trampa que [Del disco a la HBM](https://blog.lo0.es/posts/del-disco-a-la-hbm-cold-start-carga-modelo/index.md) ya anticipó: **si solo optimizas el género, sigues abriendo tarde**. Puedes contratar el mejor servicio de reparto del mundo, montacargas rapidísimo, cajas que se colocan solas. Pero si el cocinero todavía está afilando cuchillos y esperando a que los hornos cojan temperatura, la cocina **no sirve el primer plato**. El género llegó pronto y el restaurante sigue cerrado.

El runbook de hoy es, literalmente, la lista de todo lo que hay que hacer para que la cocina abra a su hora —y cuál de esas tareas se puede acelerar, cuál se puede dejar hecha de la noche anterior (cachear), y cuándo sale más a cuenta no apagar nunca la cocina (réplica caliente) que volver a encenderla cada mañana (scale-to-zero).

## Las cinco partidas del cold start

Cuando un pod de inferencia nace y hasta que devuelve su primer token, el reloj corre por cinco sitios distintos. Conviene nombrarlos porque **cada optimización ataca a uno o dos, nunca a todos**:

1. **Init del proceso.** Arrancar el intérprete de Python, importar el motor (vLLM, sus dependencias), parsear la configuración. Segundos, y crece con el tamaño del entorno y los `import` de CUDA/PyTorch.
2. **Contexto CUDA.** El primer `cudaSetDevice` / `cudaFree(0)` inicializa el runtime de CUDA contra el driver: carga el contexto, mapea la GPU. No es instantáneo —del orden de uno a varios segundos por GPU según driver y número de dispositivos.
3. **Allocator.** vLLM monta su gestor de memoria sobre la HBM (caching allocator de PyTorch más el reparto del KV-cache). Reservar y estructurar decenas de GB tiene su coste.
4. **Carga de pesos disco→HBM.** El trayecto que [Del disco a la HBM](https://blog.lo0.es/posts/del-disco-a-la-hbm-cold-start-carga-modelo/index.md) diseccionó: disco → page cache → buffer de host → PCIe → HBM. Lo que casi todo el mundo cree que **es** el cold start, y es solo una de las cinco.
5. **Captura de CUDA graphs + JIT de kernels.** vLLM captura grafos de CUDA para las distintas formas de batch y compila kernels con `torch.compile`/Inductor (y backends como DeepGEMM o FlashInfer). Esta partida es la gran ignorada: **la captura de graphs por defecto en vLLM tarda del orden de 54 s** porque cubre un abanico amplio de batch sizes ([vLLM, *torch.compile integration*](https://docs.vllm.ai/en/latest/design/torch_compile/); [Red Hat, *vLLM with torch.compile*](https://developers.redhat.com/articles/2025/09/03/vllm-torchcompile-efficient-llm-inference-pytorch)).

> **Diagrama:** Las cinco partidas del cold start como barras de tiempo apiladas — [verlo en la versión HTML](https://blog.lo0.es/posts/acelerar-cold-start-carga-modelos-tensorizer/)

La lección que el diagrama grita: en un servidor moderno con `torch.compile` y CUDA graphs, la **partida 5 puede ser tan grande o mayor que la 4**. Acelerar la carga de pesos de 40 s a 6 s es un avance enorme, pero si dejas la captura de graphs en 50 s, el cold start cae de 104 s a ~70 s, no a 12 s. **Hay que atacar las dos mitades.**

## Acelerar la partida 4: la carga de los pesos

### safetensors frente a pickle: el formato manda

El primer knob es el formato en disco. El viejo `torch.load` usa **`pickle`**, que tiene dos problemas. El de seguridad es conocido: deserializar un pickle **ejecuta código Python arbitrario** —el protocolo `__reduce__` permite que el fichero invoque cualquier callable al cargar, así que cada descarga de un modelo era un vector de ejecución remota ([HuggingFace, *Safetensors*](https://huggingface.co/docs/safetensors/index)). El de rendimiento es el que nos ocupa: cargar un pickle reconstruye objetos Python y suele pasar por un **buffer de host** intermedio antes de llegar a la GPU.

**safetensors** —ahora bajo la PyTorch Foundation ([HuggingFace, *Safetensors joins PyTorch Foundation*](https://huggingface.co/blog/safetensors-joins-pytorch-foundation))— resuelve ambos. El fichero es solo una **cabecera JSON + bytes de tensor en crudo**: cargarlo no puede hacer nada salvo poblar buffers de tensores. Y la propiedad clave para nosotros es física: **la región de datos está alineada a frontera de página** (la cabecera se rellena para que el primer tensor empiece en un múltiplo del tamaño de página del SO). Eso es lo que hace posible el **zero-copy**: un loader puede hacer `mmap` del fichero, `cudaHostRegister` sobre la región mapeada y **DMA directo de la page cache a la VRAM**, sin deserialización de `torch.load` ni buffer FP32 temporal de host ([HuggingFace, *Safetensors*](https://huggingface.co/docs/safetensors/index)). Es el formato base; todo lo demás se construye encima.

> **Trampa del `mmap`** (ya señalada en [Del disco a la HBM](https://blog.lo0.es/posts/del-disco-a-la-hbm-cold-start-carga-modelo/index.md)): `mmap` no lee nada de inmediato, difiere el coste al primer acceso a cada página. Si no fuerzas la lectura, el cold start parece corto y el **primer token** paga los page faults. Y la "segunda carga rapidísima" es la page cache mintiéndote: en producción los pods nacen fríos, en nodos donde esos ficheros no están cacheados.

### Tensorizer: streamear tensor a tensor directo a la GPU

**Tensorizer**, de CoreWeave, serializa los pesos del modelo y sus tensores **en un único fichero** y, en vez de cargar el modelo entero a RAM antes de moverlo a la GPU, **streamea los datos tensor a tensor** desde disco, un endpoint HTTP/HTTPS o un bucket S3, **deserializándolos al vuelo directamente sobre la GPU** ([vLLM, *Loading Models with CoreWeave's Tensorizer*](https://docs.vllm.ai/en/stable/models/extensions/tensorizer/)). La ventaja operativa: **carga casi instantánea y bajo uso de RAM de host** durante la inicialización, lo que importa especialmente en escenarios serverless y de autoescalado —justo el caso de scale-to-zero—. Y desacopla los pesos de la imagen del contenedor: el modelo vive en almacenamiento de objetos, no inflando el image pull.

En vLLM se activa con `--load-format tensorizer` (o `load_format="tensorizer"` por API). Requiere serializar el modelo una vez al formato de Tensorizer; a partir de ahí, cualquier pod lo streamea.

### Run:ai Model Streamer: leer y copiar a la vez

El **Run:ai Model Streamer** (de NVIDIA) ataca el cuello desde otro ángulo: **lee los tensores en concurrencia** —N hilos del SO leyendo del almacenamiento al buffer de CPU— **mientras los streamea a la VRAM**, de modo que la **lectura del almacenamiento y la copia H2D se solapan** en lugar de hacerse en serie ([vLLM, *Loading models with Run:ai Model Streamer*](https://docs.vllm.ai/en/stable/models/extensions/runai_model_streamer/); [NVIDIA, *Reducing Cold Start Latency*](https://developer.nvidia.com/blog/reducing-cold-start-latency-for-llm-inference-with-nvidia-runai-model-streamer/)). Lee safetensors directamente, sin reconversión.

Los números publicados por NVIDIA dan el orden de magnitud: el streamer alcanza **4,88 s leyendo desde S3 a concurrencia 32** y **7,53 s desde un SSD IO2 a concurrencia 8**; integrado en vLLM, el tiempo total hasta *ready* baja a **23,18 s desde S3**, **28,28 s desde SSD IO2** y **35,08 s desde GP3** ([NVIDIA, *Reducing Cold Start Latency*](https://developer.nvidia.com/blog/reducing-cold-start-latency-for-llm-inference-with-nvidia-runai-model-streamer/)). Fíjate en la brecha entre el tiempo de **carga de pesos** (~5–8 s) y el total hasta *ready* (~23–35 s): esos ~18–27 s de diferencia son **las otras cuatro partidas**, sobre todo la 5. El streamer arregló la partida 4 y el resto sigue ahí —exactamente lo que advierte este post—.

La concurrencia es el parámetro de ajuste: controla cuántos hilos del SO leen tensores al buffer de CPU (y, para S3, cuántas conexiones cliente abre el host) ([vLLM, *Run:ai Model Streamer*](https://docs.vllm.ai/en/stable/models/extensions/runai_model_streamer/)). **16 suele bastar para NVMe local; 32 para almacenamiento de objetos de alto throughput.** Un hilo no satura un NVMe Gen5; el solapamiento sí.

```bash
# safetensors por defecto (el más lento de los rápidos)
vllm serve <model> --load-format safetensors

# Run:ai Model Streamer (lectura concurrente, solapa lectura + H2D)
vllm serve <model> --load-format runai_streamer \
  --model-loader-extra-config '{"concurrency": 32}'

# Tensorizer (stream tensor a tensor directo a GPU desde objetos/S3)
vllm serve <model> --load-format tensorizer
```

## Las matemáticas de los pesos: $t = W/B$ y el ahorro de solapar

El suelo de la partida 4 es simple: mover $W$ gigabytes a un ancho de banda $B$ tarda

$$t = \frac{W}{B}$$

Tomemos un modelo grande, un **70B**. En BF16 ($b=2$ bytes/parám) son $W = 70 \times 10^9 \cdot 2 = 140$ GB; en FP8 ($b=1$), $W = 70$ GB. El ancho de banda depende del tier (ver tabla más abajo): NVMe Gen5 local lee del orden de **~14 GB/s por disco**; el PCIe Gen5 x16 copia host→GPU a **~50 GB/s**; el almacenamiento de red, **~1–3 GB/s**. El suelo teórico de leer 140 GB de un NVMe es $140/14 = 10$ s; de la red a 2 GB/s, **70 s**. El cuello manda.

**El truco del solapamiento.** El loader por defecto hace los dos pasos —leer del disco y copiar H2D— **en serie**: primero llena un buffer, luego copia, luego el siguiente. El tiempo es la **suma**:

$$t_{\text{serie}} = t_{\text{lectura}} + t_{\text{H2D}}$$

Tensorizer y el Run:ai Model Streamer los **solapan**: mientras un hilo copia un chunk a la VRAM, otro ya está leyendo el siguiente del disco. Con suficiente concurrencia, el tiempo total tiende al **máximo** de los dos, no a la suma:

$$t_{\text{solapado}} \approx \max(t_{\text{lectura}},\, t_{\text{H2D}})$$

Con los 70 GB del 70B en FP8, desde NVMe a 14 GB/s y PCIe a 50 GB/s: $t_{\text{lectura}} = 70/14 = 5{,}0$ s, $t_{\text{H2D}} = 70/50 = 1{,}4$ s. En serie, $5{,}0 + 1{,}4 = 6{,}4$ s; solapado, $\max(5{,}0,\,1{,}4) = 5{,}0$ s. El ahorro aquí es modesto (~22%) porque el disco domina con holgura. **El solapamiento brilla cuando los dos pasos son comparables**: desde almacenamiento de red a 5 GB/s, $t_{\text{lectura}} = 70/5 = 14$ s y $t_{\text{H2D}} = 1{,}4$ s, serie 15,4 s vs solapado 14 s; pero con muchos flujos concurrentes que saturen un disco rápido y un H2D igualado, pasar de la suma al máximo puede **casi doblar** el throughput efectivo. La otra cara: el solapamiento solo te da lo que el **cuello físico** permite. Si el disco solo da 14 GB/s, ningún streamer te baja de 5 s para 70 GB —para eso está la otra palanca, **mover menos bytes** (FP8 frente a BF16 parte $W$ por la mitad)—.

### El tier de almacenamiento

| Tier | Ancho de banda típico | 140 GB (70B BF16) | 70 GB (70B FP8) | Notas |
|---|---|---|---|---|
| NVMe Gen5 local | ~14 GB/s por disco | ~10 s | ~5 s | el camino corto; un disco |
| NVMe Gen5 local (varios, RAID0) | ~28–50 GB/s | ~3–5 s | ~1,5–2,5 s | si el loader satura varios flujos |
| Almacenamiento de objetos (S3/RGW) | ~1–3 GB/s por flujo | ~47–140 s | ~23–70 s | la concurrencia del streamer ayuda mucho |
| NFS / red compartida | ~1–2 GB/s | ~70–140 s | ~35–70 s | mete la red y su contención en el camino |

La conclusión operativa es la misma que en [Del disco a la HBM](https://blog.lo0.es/posts/del-disco-a-la-hbm-cold-start-carga-modelo/index.md): **los pesos que sirven el cold start viven en NVMe local del nodo**, no en la red. El almacenamiento de red es el repositorio; el nodo de inferencia tiene una copia local caliente (pre-pull con un `initContainer` o un DaemonSet de cache por nodo). Tensorizer es la excepción interesante: streamea tan eficientemente desde objetos que a veces hace viable servir desde S3/RGW sin copia local, a cambio de depender del ancho de banda de la red de almacenamiento.

## Acelerar la partida 5: todo lo que no son pesos

Aquí está el medio cold start que los loaders rápidos no tocan. Tres frentes.

### Cachear la compilación de torch.compile/Inductor

`torch.compile` tiene un **sistema de caché incorporado**: los artefactos compilados se guardan tras el primer arranque y **se reusan entre arranques** —incluso entre máquinas si se configura bien—. En vLLM, el resultado de la compilación de Dynamo se almacena en `~/.cache/vllm/torch_compile_cache/` ([vLLM Blog, *Introduction to torch.compile*](https://blog.vllm.ai/2025/08/20/torch-compile.html); [vLLM, *torch.compile integration*](https://docs.vllm.ai/en/latest/design/torch_compile/)). La consecuencia para el runbook: si ese directorio es **un volumen persistente compartido entre pods** (un PVC, o un `hostPath` en NVMe del nodo poblado por el primer arranque), los pods siguientes se saltan la compilación y arrancan más rápido. El primer pod paga la compilación; los demás heredan la caché.

Es exactamente "afilar los cuchillos la noche anterior": la preparación cara se hace una vez y se reutiliza. El matiz: la caché es sensible a la versión de vLLM/PyTorch, la GPU, la cuantización y la configuración —cambiar cualquiera invalida la caché y vuelves a pagar la compilación una vez—.

### Acotar la captura de CUDA graphs

vLLM captura CUDA graphs para un **abanico amplio de batch sizes** por defecto, y eso es caro: la captura por defecto ronda los **54 s**, y en configuraciones grandes se ha medido hasta **294 s**. Limitando la captura a los batch sizes que tu carga **de verdad** usa (p. ej. `1, 2, 4, 8, 16, 24, 32, 64`) se reduce el cold start **más de un 70%**, de 294 s a ~82 s en el caso medido ([Red Hat, *vLLM with torch.compile*](https://developers.redhat.com/articles/2025/09/03/vllm-torchcompile-efficient-llm-inference-pytorch)). El knob es la configuración de compilación/CUDA graphs del engine; el principio es no capturar grafos para tamaños de batch que nunca verás.

> **Disyuntiva real:** menos batch sizes capturados = arranque más rápido pero menos cobertura. Un batch fuera del conjunto capturado cae al camino eager (más lento por petición). Acotas a los tamaños frecuentes de **tu** tráfico, no a cualquiera. Y siempre existe el extremo de **desactivar CUDA graphs** (`enforce_eager`): arranque casi instantáneo en la partida 5 a costa de throughput en estado caliente —válido para depurar o para servicios de muy bajo tráfico donde el cold start pesa más que el régimen permanente—.

### Warm pools: no pagar la partida 5 en el camino crítico

El atajo definitivo para las cinco partidas es **no ejecutarlas cuando el usuario espera**. Un *warm pool* —una réplica ya arrancada, con pesos cargados, graphs capturados y kernels compilados, esperando tráfico— convierte el cold start en cero para esa petición. Es coste de GPU (parcial o totalmente ociosa) a cambio de latencia de arranque; lo analizamos en la economía, más abajo.

### El sleep mode: el atajo que se salta las cinco partidas

Y existe un atajo a medio camino entre "arrancar de cero" y "tener una réplica entera caliente": el **sleep mode** de vLLM, el tema de [Servir varios modelos en una sola GPU](https://blog.lo0.es/posts/servir-varios-modelos-una-gpu-swap-sleep/index.md). En vez de matar el proceso, lo **duerme**: aparca los pesos en RAM del host (nivel 1) o los descarta (nivel 2), pero **mantiene el proceso vivo** —y con él el contexto CUDA, el allocator, **los CUDA graphs y los kernels JIT ya compilados**—. El *wake* no paga las partidas 1, 2, 3 ni 5; solo vuelve a poner los pesos en VRAM (partida 4, y desde RAM, no desde disco). Por eso un wake es **18–200× más rápido que un cold start completo**, e incluso el nivel 2 —que recarga los pesos del mismo disco— sigue siendo 23–45× más rápido, **porque se salta las otras cuatro partidas** ([vLLM Blog, *Sleep Mode*](https://blog.vllm.ai/2025/10/26/sleep-mode.html)). El sleep mode es la prueba viva de la tesis de este post: si preservar las partidas 1, 2, 3 y 5 da un 18–200×, es que **mover bytes nunca fue el coste entero**.

## Un cold start de ejemplo, partida a partida

Pongamos números a un cold start de **70B en FP8 (70 GB)** desde NVMe local y veamos qué optimización ataca cada partida:

| # | Partida | Por defecto | Optimización | Optimizado |
|---|---|---|---|---|
| 1 | Init proceso | ~4 s | (poco margen; entorno mínimo) | ~3 s |
| 2 | Contexto CUDA | ~6 s | (driver; fijo) | ~6 s |
| 3 | Allocator | ~4 s | (fijo) | ~4 s |
| 4 | Carga de pesos | ~25 s | Run:ai streamer / Tensorizer (concurrencia + solapado) | ~5 s |
| 5 | CUDA graphs + JIT | ~54 s | caché de torch.compile + captura acotada | ~10 s |
| | **Total** | **~93 s** | | **~28 s** |

Dos lecturas. Primera: optimizar **solo** la partida 4 (loader rápido y nada más) baja el total de 93 a ~73 s —una mejora real pero decepcionante, porque la 5 sigue intacta—. Optimizar **solo** la 5 (caché + captura acotada) baja a ~49 s. Atacar **las dos** baja a ~28 s, y es lo que convierte scale-to-zero teórico en usable. Segunda: las partidas 2 y 3 son prácticamente irreductibles —dependen del driver y del hardware—; constituyen el **suelo del cold start**, del orden de 10 s, que ninguna optimización de software cruza. Por debajo de ese suelo, la única respuesta es **no arrancar de cero**: sleep mode (wake sub-segundo a pocos segundos) o réplica caliente (cero).

## La economía del scale-to-zero

La pregunta de capacidad que todo esto sirve es: **¿apagar o mantener caliente?** Scale-to-zero ahorra GPU mientras no hay tráfico, pero paga el cold start cuando vuelve. La decisión es un balance entre coste de GPU ociosa y SLA de latencia.

El criterio básico: scale-to-zero solo es viable si el cold start **cabe dentro del SLA de arranque que tus usuarios toleran**, o si tienes un mecanismo de **pre-warming** que arranca la réplica **antes** de que llegue la petición (predicción de carga, señal adelantada de la cola). Sin pre-warm, un cold start de 90 s significa que el primer usuario tras un período ocioso espera 90 s —inaceptable para casi cualquier SLO interactivo—. Con el cold start bajado a ~28 s, sigue siendo mucho para una petición interactiva, pero ya es tolerable para cargas batch o para un pre-warm disparado por [autoscaling con KEDA](https://blog.lo0.es/posts/autoscaling-llm-kubernetes-keda/index.md) sobre la profundidad de cola.

La regla de decisión, en una frase: **mantén una réplica caliente cuando el coste de la GPU ociosa durante los valles es menor que el coste de incumplir el SLA en cada arranque; haz scale-to-zero (con pre-warm) cuando los valles son largos y profundos y el cold start optimizado cabe en tu tolerancia**. Para un servicio interactivo 24×7 con tráfico irregular pero continuo, el suelo de réplicas calientes casi siempre gana. Para un modelo grande que se invoca pocas veces al día (un 70B de tareas difíciles), scale-to-zero con cold start optimizado —o sleep mode si comparte GPU con otro modelo— es lo razonable. Es la misma frontera de [capacity planning](https://blog.lo0.es/posts/capacity-planning-inferencia-llm-on-premise/index.md): el cold start es un parámetro del colchón de réplicas, no un detalle de arranque.

## Aplicado al cluster genérico 4×H100

Bajemos el runbook a las **4 H100 SXM de 80 GB con NVLink**. Las decisiones concretas:

**Loader y formato.** Pesos en **safetensors** en disco (nunca pickle). Para el camino corto desde NVMe local, el **Run:ai Model Streamer** (`--load-format runai_streamer`, concurrencia 16–32) es la opción por defecto: lee concurrente, solapa lectura y H2D, y solo cambias una bandera. Reserva **Tensorizer** para el caso en que sirvas desde almacenamiento de objetos (RGW/S3) y quieras desacoplar los pesos de la imagen sin copia local —su streaming tensor-a-tensor directo a GPU es lo que hace viable ese patrón—. En ambos casos, FP8 frente a BF16 parte por la mitad la partida 4 casi gratis (mide la calidad, no la asumas).

**Tier de almacenamiento.** Repositorio de modelos en la red (Ceph RGW), copia caliente en **NVMe local del nodo** poblada por pre-pull. El cold start que cuenta es el frío, en un nodo donde los ficheros no están en page cache; servir ese arranque desde la red mete su latencia y contención en el camino crítico.

**Caché de la partida 5.** El directorio `torch_compile_cache` en un volumen persistente o `hostPath` NVMe compartido entre los pods del mismo modelo: el primer pod compila, los demás heredan. Y **acotar la captura de CUDA graphs** a los batch sizes reales del tráfico de cada servicio.

**Cuándo scale-to-zero con pre-warm vs réplica caliente.** El **LLM de agentes** (servicio principal, tráfico continuo) **nunca** hace scale-to-zero: un suelo de réplicas calientes en una o dos GPUs. El **70B ocasional** sí: scale-to-zero con cold start optimizado, o —si comparte GPU con un modelo mediano— **sleep mode** para que el wake sea sub-segundo en vez de un arranque de 28 s (la jugada exacta de [Servir varios modelos en una sola GPU](https://blog.lo0.es/posts/servir-varios-modelos-una-gpu-swap-sleep/index.md)). El cold start optimizado de este post es lo que hace que el **swap** y el **failover** entre réplicas sean tolerables: una réplica que cae y se reemplaza en 28 s degrada menos que una que tarda 90 s. Y conecta directo con la [multi-tenancy](https://blog.lo0.es/posts/finops-multi-tenancy-gpu-litellm/index.md): un cold start corto permite repartir GPUs entre equipos con scale-to-zero por tenant sin que el primer usuario de cada tenant pague un arranque eterno.

El principio transversal: **el cold start es el techo de la elasticidad**. Las cinco partidas, atacadas las dos mitades —loader rápido para los pesos, caché y captura acotada para los graphs—, lo bajan de minutos a segundos. Y por debajo del suelo irreductible de ~10 s (contexto CUDA + allocator), la única salida es no arrancar de cero: sleep mode o réplica caliente.

## Trampas y cosas que no son lo que parecen

**"Cambié a un loader rápido y el arranque casi no mejoró."** Probablemente tu cuello no era la partida 4. Si la captura de CUDA graphs se come 54 s, bajar la carga de pesos de 25 a 5 s te quita 20 s de 93 —se nota poco—. Mide **dónde** se va el tiempo antes de optimizar: los logs de arranque de vLLM desglosan carga de pesos frente a captura de graphs.

**"La segunda vez arrancó volando."** La page cache (para los pesos) y la caché de torch.compile (para los graphs) mintiendo a la vez. El arranque que importa es el **frío**, en un nodo limpio. Benchmarquear el segundo arranque mide una situación que casi nunca ocurre en el pico que dispara el autoescalado.

**"Tensorizer/streamer me dará el 18–200× del sleep mode."** No. Esos loaders aceleran **solo la partida 4**. El 18–200× del [sleep mode](https://blog.lo0.es/posts/servir-varios-modelos-una-gpu-swap-sleep/index.md) viene de **preservar las otras cuatro** manteniendo el proceso vivo. Son herramientas para problemas distintos: el loader rápido es para un cold start de verdad (proceso nuevo); el sleep mode es para alternar modelos sin matar el proceso.

**"`enforce_eager` arregla el cold start."** Arregla la partida 5 (sin captura de graphs, arranque casi instantáneo) pero **degrada el throughput en estado caliente** —los CUDA graphs existen porque aceleran el régimen permanente—. Es un intercambio: gana para servicios de bajísimo tráfico donde el arranque pesa más que el caliente; pierde para un servicio con carga sostenida.

**"Acoto los CUDA graphs a un solo batch size y listo."** Acotas a los tamaños **que tu tráfico usa**, no a uno. Un batch fuera del conjunto capturado cae al camino eager y va más lento. Demasiado agresivo y penalizas el régimen permanente para ganar unos segundos de arranque.

**"Mover los pesos a FP8 también acelera la partida 5."** No directamente. FP8 parte por la mitad los **bytes** (partida 4) y dobla el throughput de inferencia, pero la captura de graphs y la compilación de kernels no dependen del tamaño de los pesos sino de la **forma** del grafo y los batch sizes. La partida 5 se ataca con caché y captura acotada, no con cuantización.

## Conclusión

[Del disco a la HBM](https://blog.lo0.es/posts/del-disco-a-la-hbm-cold-start-carga-modelo/index.md) lo dejó dicho en lo conceptual: mover los pesos es una de las cinco partidas del cold start. Este runbook lo convierte en acción. Para los pesos —la partida 4— hay un menú claro: **safetensors** como base (mmap, zero-copy, sin pickle), **Run:ai Model Streamer** para el camino corto desde NVMe (lectura concurrente que solapa lectura y H2D), **Tensorizer** para servir streaming desde objetos sin copia local, y la palanca transversal de **mover menos bytes** con FP8. Pero la mitad olvidada del arranque es la **partida 5** —captura de CUDA graphs y compilación JIT—, que en un servidor moderno puede ser tan grande como la carga de pesos: se ataca **cacheando la compilación** entre pods y **acotando la captura de graphs** a los batch sizes reales. Atacar solo una mitad deja el cold start a medias; atacar las dos lo baja de ~90 s a ~28 s. Y por debajo del suelo irreductible de ~10 s que imponen el contexto CUDA y el allocator, la única salida es **no arrancar de cero**: el sleep mode (wake sub-segundo) o la réplica caliente (cero). La cocina abre a su hora cuando alguien se ocupó del género **y** de afilar los cuchillos la noche anterior. Optimizar solo el reparto del almacén deja los hornos fríos y el restaurante cerrado.

## Ver también

- [Multimodal: servir un VLM on-premise con vLLM](https://blog.lo0.es/posts/multimodal-vlm-on-premise-vllm/index.md) — pieza hermana de esta tanda: añadir visión al mismo motor de inferencia; un VLM tiene su propio cold start (pesos del encoder de imagen además del LLM).
- [FinOps y multi-tenancy de GPU con LiteLLM](https://blog.lo0.es/posts/finops-multi-tenancy-gpu-litellm/index.md) — pieza hermana: un cold start corto es lo que hace viable el scale-to-zero por tenant sin penalizar al primer usuario de cada equipo.
- [Del disco a la HBM: el cold start y la carga del modelo](https://blog.lo0.es/posts/del-disco-a-la-hbm-cold-start-carga-modelo/index.md) — el post conceptual que este runbook continúa: por qué mover bytes es solo una de cinco partidas y el camino disco→page cache→host→PCIe→HBM.
- [Servir varios modelos en una sola GPU: swap y sleep mode](https://blog.lo0.es/posts/servir-varios-modelos-una-gpu-swap-sleep/index.md) — el sleep mode como atajo que preserva las cuatro partidas no-pesos y da el wake 18–200×.
- [Autoscaling de LLM en Kubernetes con KEDA](https://blog.lo0.es/posts/autoscaling-llm-kubernetes-keda/index.md) — el cold start es el techo de la elasticidad; el pre-warming disparado por profundidad de cola es lo que hace usable el scale-to-zero.
- [Capacity planning de inferencia on-premise](https://blog.lo0.es/posts/capacity-planning-inferencia-llm-on-premise/index.md) — el cold start como parámetro de la decisión réplica caliente vs scale-to-zero y del colchón de réplicas.
- [Compartir una GPU: time-slicing, MPS y MIG](https://blog.lo0.es/posts/compartir-gpu-time-slicing-mps-mig/index.md) — repartir una GPU entre servicios; cada servicio co-residente tiene su propio coste de arranque a presupuestar.

- [Cadena de confianza del modelo (2/4): registro, artefactos OCI y distribución](https://blog.lo0.es/posts/registro-distribucion-modelos-oci-oras/index.md) — el eslabón anterior al cold start: de qué registro salen los bytes, cómo se cachean cerca del nodo y qué aporta montar el modelo como artefacto OCI.

## Referencias

- HuggingFace, *Safetensors* (formato, zero-copy, seguridad frente a pickle): <https://huggingface.co/docs/safetensors/index>
- HuggingFace, *Safetensors is Joining the PyTorch Foundation*: <https://huggingface.co/blog/safetensors-joins-pytorch-foundation>
- vLLM, *Loading Models with CoreWeave's Tensorizer*: <https://docs.vllm.ai/en/stable/models/extensions/tensorizer/>
- vLLM, *Loading models with Run:ai Model Streamer*: <https://docs.vllm.ai/en/stable/models/extensions/runai_model_streamer/>
- NVIDIA, *Reducing Cold Start Latency for LLM Inference with NVIDIA Run:ai Model Streamer* (benchmarks S3/SSD, concurrencia): <https://developer.nvidia.com/blog/reducing-cold-start-latency-for-llm-inference-with-nvidia-runai-model-streamer/>
- vLLM Blog, *Introduction to torch.compile and How It Works with vLLM* (caché de compilación): <https://blog.vllm.ai/2025/08/20/torch-compile.html>
- vLLM, *torch.compile integration* (caché en `~/.cache/vllm/torch_compile_cache/`): <https://docs.vllm.ai/en/latest/design/torch_compile/>
- Red Hat Developer, *vLLM with torch.compile: Efficient LLM inference on PyTorch* (captura de CUDA graphs ~54 s, acotar batch sizes −70%): <https://developers.redhat.com/articles/2025/09/03/vllm-torchcompile-efficient-llm-inference-pytorch>
- vLLM Blog, *Zero-Reload Model Switching with vLLM Sleep Mode* (wake 18–200×): <https://blog.vllm.ai/2025/10/26/sleep-mode.html>

