<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Safetensors on lo0 — Blog Técnico</title><link>https://blog.lo0.es/tags/safetensors/</link><description>Recent content in Safetensors on lo0 — Blog Técnico</description><generator>Hugo -- gohugo.io</generator><language>es</language><lastBuildDate>Sun, 07 Jun 2026 08:30:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/tags/safetensors/index.xml" rel="self" type="application/rss+xml"/><item><title>El montacargas de la despensa: del disco a la HBM, o por qué la cocina abre tarde</title><link>https://blog.lo0.es/posts/del-disco-a-la-hbm-cold-start-carga-modelo/</link><pubDate>Sun, 07 Jun 2026 08:30:00 +0200</pubDate><guid>https://blog.lo0.es/posts/del-disco-a-la-hbm-cold-start-carga-modelo/</guid><description>&lt;blockquote>
&lt;p>Esta es una bajada al sótano. La serie &lt;a href="https://blog.lo0.es/posts/nvlink-nvswitch-nccl-tensor-parallel/">por debajo del motor&lt;/a> optimizó la &lt;strong>ruta caliente&lt;/strong> —lo que pasa con cada token ya en servicio. Este post mira el trayecto de &lt;strong>antes&lt;/strong> 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 &lt;strong>carga del modelo&lt;/strong> (este) y la &lt;a href="https://blog.lo0.es/posts/sm-cuda-streams-cuda-graphs-inferencia/">ejecución en el silicio&lt;/a> (el siguiente).&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Antes de que un pod de inferencia genere su primer token, tiene que &lt;strong>subir el modelo entero a la HBM&lt;/strong>. Un Llama-70B en FP16 son &lt;strong>140 GB&lt;/strong> que viajan por un camino que nadie dibuja: &lt;strong>disco → page cache → buffer de host → PCIe → HBM&lt;/strong>. La intuición falla aquí: la HBM no es el cuello —mueve 3,35 TB/s y traga 140 GB en &lt;strong>42 ms&lt;/strong>—; el cuello es la &lt;strong>cadena de suministro&lt;/strong>. 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 &lt;strong>loader de safetensors por defecto&lt;/strong>, que deserializa tensor a tensor y rebota cada byte por un buffer de CPU, infla todo eso hasta &lt;strong>30-60 s&lt;/strong>. Ese tiempo es el &lt;strong>cold start&lt;/strong>, y es el impuesto oculto que pagan el &lt;a href="https://blog.lo0.es/posts/autoscaling-llm-kubernetes-keda/">autoscaling&lt;/a> (scale-from-zero), el &lt;a href="https://blog.lo0.es/posts/canary-blue-green-shadow-modelos-llm/">canary/blue-green&lt;/a> y el &lt;a href="https://blog.lo0.es/posts/disaggregated-serving-prefill-decode/">disaggregated serving&lt;/a> cada vez que nace un pod. Hay tres familias de solución —&lt;strong>GPUDirect Storage&lt;/strong> (DMA directo disco→HBM, sin rebote por CPU), &lt;strong>fastsafetensors&lt;/strong> (4,8-7,5× sobre el loader por defecto) y el &lt;strong>Run:ai Model Streamer&lt;/strong> (lectura concurrente que satura el disco)— más la palanca más simple de todas: &lt;strong>mover menos bytes&lt;/strong> (FP8 es la mitad que FP16). Este post explica el camino, las matemáticas, los 10 knobs, y la trampa más cruel: &lt;em>&amp;ldquo;la segunda vez cargó rápido&amp;rdquo;&lt;/em> no es tu loader siendo bueno, es la &lt;strong>page cache&lt;/strong> mintiéndote. Sobre el cluster genérico 4×H100 SXM.&lt;/p>
&lt;h2 id="dónde-estás-el-sótano-antes-de-abrir">Dónde estás: el sótano, antes de abrir&lt;/h2>
&lt;div class="diagram" style="max-width:560px;margin:1.2rem auto;">
&lt;svg viewBox="0 0 560 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="El camino de carga: del disco a la HBM, antes de la ruta caliente del token">
&lt;text x="280" y="24" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">El camino de carga · antes del primer token&lt;/text>
&lt;rect x="120" y="40" width="320" height="40" rx="6" fill="currentColor" fill-opacity="0.05" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="65" text-anchor="middle" font-family="sans-serif" font-size="12" fill="currentColor">HBM · 80 GB, 3,35 TB/s — el destino, nunca el cuello&lt;/text>
&lt;rect x="120" y="86" width="320" height="40" rx="6" fill="currentColor" fill-opacity="0.05" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="111" text-anchor="middle" font-family="sans-serif" font-size="12" fill="currentColor">PCIe Gen5 x16 · ~50 GB/s host→GPU (H2D)&lt;/text>
&lt;rect x="120" y="132" width="320" height="58" rx="6" fill="#dceede" stroke="#3c8c54" stroke-width="3"/>
&lt;text x="280" y="156" text-anchor="middle" font-family="sans-serif" font-size="12.5" font-weight="700" fill="#1f5c34">ESTÁS AQUÍ · loader + page cache + buffer host&lt;/text>
&lt;text x="280" y="174" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="#2c6b42">deserializar, rebotar por CPU o DMA directo (GDS)&lt;/text>
&lt;rect x="120" y="196" width="320" height="40" rx="6" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.4"/>
&lt;text x="280" y="221" text-anchor="middle" font-family="sans-serif" font-size="12" fill="currentColor">NVMe Gen5 · ~14 GB/s por disco — el origen&lt;/text>
&lt;rect x="120" y="248" width="320" height="40" rx="6" fill="currentColor" fill-opacity="0.05" stroke="#888" stroke-width="1.3"/>
&lt;text x="280" y="273" text-anchor="middle" font-family="sans-serif" font-size="12" fill="currentColor">Red / Ceph RGW · pesos compartidos (más lento aún)&lt;/text>
&lt;text x="280" y="312" text-anchor="middle" font-family="sans-serif" font-size="11" fill="currentColor" opacity="0.75">La ruta caliente del token vive arriba; este post abre lo de abajo&lt;/text>
&lt;text x="280" y="330" text-anchor="middle" font-family="sans-serif" font-size="11" fill="currentColor" opacity="0.75">el trayecto que decide si la cocina abre en 10 s o en 60 s&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="la-analogía-el-montacargas-de-la-despensa">La analogía: el montacargas de la despensa&lt;/h2>
&lt;p>Sigamos en el restaurante de la serie. La &lt;a href="https://blog.lo0.es/posts/nvlink-nvswitch-nccl-tensor-parallel/">mesa compartida&lt;/a> era el NVSwitch, la &lt;a href="https://blog.lo0.es/posts/numa-hugepages-aislamiento-cpu-inferencia/">planta de al lado&lt;/a> era el NUMA, el &lt;a href="https://blog.lo0.es/posts/kubelet-resource-managers-rke2-numa/">maître&lt;/a> era el kubelet. Todo eso describe el restaurante &lt;strong>funcionando&lt;/strong>, con comensales sentados. Pero hay un momento que ningún post miró: &lt;strong>antes de abrir&lt;/strong>, alguien tiene que subir toda la despensa desde el almacén del sótano hasta la cocina.&lt;/p>
&lt;p>Los pesos del modelo son los ingredientes. Viven en el &lt;strong>almacén del sótano&lt;/strong> (el disco). La cocina —la &lt;strong>línea caliente&lt;/strong> donde se emplatan los tokens— es la HBM de la GPU. Y entre uno y otro hay un &lt;strong>montacargas&lt;/strong>: 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 &lt;strong>cold start&lt;/strong>.&lt;/p>
&lt;p>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 &lt;strong>esperando el montacargas&lt;/strong>. El cuello nunca es la línea caliente: es el &lt;strong>montacargas y el almacén&lt;/strong>. Y, peor todavía, hay un mozo (el loader por defecto) que en vez de cargar cajas enteras, &lt;strong>saca los ingredientes uno a uno, los apunta en una libreta y los vuelve a empaquetar&lt;/strong> antes de subirlos. Ese mozo —no el montacargas— es la mitad del problema.&lt;/p>
&lt;h2 id="el-mecanismo-qué-pasa-de-verdad-al-cargar-un-modelo">El mecanismo: qué pasa de verdad al cargar un modelo&lt;/h2>
&lt;p>Cuando vLLM arranca con un modelo en &lt;code>safetensors&lt;/code>, los 140 GB del Llama-70B FP16 hacen este viaje:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Disco → page cache.&lt;/strong> El kernel lee los ficheros &lt;code>.safetensors&lt;/code> del NVMe a la &lt;strong>page cache&lt;/strong> (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.&lt;/li>
&lt;li>&lt;strong>Deserializar.&lt;/strong> El loader de Hugging Face por defecto hace &lt;code>mmap&lt;/code> del fichero y construye los tensores &lt;strong>uno a uno&lt;/strong>, 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 &amp;ldquo;lenta&amp;rdquo; se va aquí, no en mover bytes.&lt;/li>
&lt;li>&lt;strong>Host → HBM (H2D).&lt;/strong> Cada tensor se copia del buffer de host a la HBM por &lt;strong>PCIe Gen5 x16&lt;/strong> (~50 GB/s prácticos). Para que el DMA sea eficiente, el buffer de host debería ser &lt;strong>pinned&lt;/strong> —lo que conecta directamente con las &lt;a href="https://blog.lo0.es/posts/numa-hugepages-aislamiento-cpu-inferencia/">hugepages y la memoria fijada&lt;/a> del post de NUMA.&lt;/li>
&lt;li>&lt;strong>Colocar en HBM.&lt;/strong> La HBM recibe los 140 GB. A 3,35 TB/s, &lt;strong>esto tarda 42 ms&lt;/strong>. Nunca es el cuello.&lt;/li>
&lt;/ol>
&lt;p>El camino tiene un &lt;strong>atajo&lt;/strong>: &lt;strong>GPUDirect Storage (GDS)&lt;/strong>. En vez de rebotar por el buffer de CPU (paso 2-3), un motor DMA cerca del controlador NVMe escribe &lt;strong>directamente del disco a la HBM&lt;/strong>, sin involucrar a la CPU. Es el mismo principio que el &lt;a href="https://blog.lo0.es/posts/cilium-ebpf-dranet-numa-de-red-inferencia/">GPUDirect RDMA de red&lt;/a>: sacar a la CPU del medio. &lt;code>fastsafetensors&lt;/code> usa GDS y alcanza &lt;strong>26,4 GB/s&lt;/strong> leyendo un Llama-70B desde NVMe sobre 4 GPUs.&lt;/p>
&lt;div class="diagram" style="max-width:680px;margin:1.4rem auto;">
&lt;svg viewBox="0 0 680 250" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Dos caminos: loader por defecto rebotando por CPU vs GPUDirect Storage directo">
&lt;text x="340" y="22" text-anchor="middle" font-family="sans-serif" font-size="13" font-weight="700" fill="currentColor">Dos caminos del disco a la HBM&lt;/text>
&lt;text x="170" y="48" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="600" fill="#c1121f">Por defecto: rebote por CPU&lt;/text>
&lt;rect x="60" y="60" width="90" height="40" rx="5" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.3"/>
&lt;text x="105" y="84" text-anchor="middle" font-family="sans-serif" font-size="11" fill="currentColor">NVMe&lt;/text>
&lt;rect x="190" y="60" width="90" height="40" rx="5" fill="currentColor" fill-opacity="0.06" stroke="#888" stroke-width="1.3"/>
&lt;text x="235" y="78" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor">buffer CPU&lt;/text>
&lt;text x="235" y="92" text-anchor="middle" font-family="sans-serif" font-size="9" fill="currentColor" opacity="0.7">+ deserializar&lt;/text>
&lt;line x1="150" y1="80" x2="188" y2="80" stroke="#c1121f" stroke-width="2" marker-end="url(#dha)"/>
&lt;defs>&lt;marker id="dha" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#c1121f"/>&lt;/marker>&lt;/defs>
&lt;rect x="320" y="60" width="90" height="40" rx="5" fill="#dceede" stroke="#3c8c54" stroke-width="1.5"/>
&lt;text x="365" y="84" text-anchor="middle" font-family="sans-serif" font-size="11" fill="#1f5c34">HBM&lt;/text>
&lt;line x1="280" y1="80" x2="318" y2="80" stroke="#c1121f" stroke-width="2" marker-end="url(#dha)"/>
&lt;text x="235" y="120" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="currentColor" opacity="0.7">la CPU toca cada byte · monohilo · lento&lt;/text>
&lt;text x="170" y="168" text-anchor="middle" font-family="sans-serif" font-size="11.5" font-weight="600" fill="#2a9d8f">GPUDirect Storage: DMA directo&lt;/text>
&lt;rect x="60" y="180" width="90" height="40" rx="5" fill="#eef3fb" stroke="#4a6fa5" stroke-width="1.3"/>
&lt;text x="105" y="204" text-anchor="middle" font-family="sans-serif" font-size="11" fill="currentColor">NVMe&lt;/text>
&lt;rect x="320" y="180" width="90" height="40" rx="5" fill="#dceede" stroke="#3c8c54" stroke-width="1.5"/>
&lt;text x="365" y="204" text-anchor="middle" font-family="sans-serif" font-size="11" fill="#1f5c34">HBM&lt;/text>
&lt;path d="M150,200 C230,200 240,200 318,200" stroke="#2a9d8f" stroke-width="2.4" fill="none" marker-end="url(#dhb)"/>
&lt;defs>&lt;marker id="dhb" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#2a9d8f"/>&lt;/marker>&lt;/defs>
&lt;text x="235" y="240" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="currentColor" opacity="0.7">la CPU no toca los datos · ~26 GB/s NVMe→HBM&lt;/text>
&lt;line x1="470" y1="55" x2="470" y2="240" stroke="currentColor" stroke-width="1" opacity="0.25"/>
&lt;text x="575" y="90" text-anchor="middle" font-family="sans-serif" font-size="10.5" font-weight="600" fill="currentColor">140 GB FP16&lt;/text>
&lt;text x="575" y="112" text-anchor="middle" font-family="sans-serif" font-size="10" fill="currentColor" opacity="0.8">HBM: 42 ms&lt;/text>
&lt;text x="575" y="130" text-anchor="middle" font-family="sans-serif" font-size="10" fill="currentColor" opacity="0.8">PCIe: 2,8 s&lt;/text>
&lt;text x="575" y="148" text-anchor="middle" font-family="sans-serif" font-size="10" fill="currentColor" opacity="0.8">NVMe: 10 s&lt;/text>
&lt;text x="575" y="170" text-anchor="middle" font-family="sans-serif" font-size="10" fill="#c1121f" opacity="0.9">defecto: 30-60 s&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="por-qué-existe-el-problema-la-economía-de-bytes">Por qué existe el problema: la economía de bytes&lt;/h2>
&lt;p>El tamaño del modelo en bytes lo decide la cuantización, y eso fija el suelo del cold start —porque hay que mover &lt;strong>todos&lt;/strong> esos bytes antes del primer token. Para un modelo de 70B parámetros:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Formato&lt;/th>
&lt;th>Bytes/parám&lt;/th>
&lt;th>Tamaño 70B&lt;/th>
&lt;th>Leer 1 NVMe @14 GB/s&lt;/th>
&lt;th>H2D PCIe @50 GB/s&lt;/th>
&lt;th>HBM @3,35 TB/s&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>FP16 / BF16&lt;/td>
&lt;td>2&lt;/td>
&lt;td>140 GB&lt;/td>
&lt;td>10,0 s&lt;/td>
&lt;td>2,8 s&lt;/td>
&lt;td>42 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>FP8&lt;/td>
&lt;td>1&lt;/td>
&lt;td>70 GB&lt;/td>
&lt;td>5,0 s&lt;/td>
&lt;td>1,4 s&lt;/td>
&lt;td>21 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>INT4 (GPTQ/AWQ)&lt;/td>
&lt;td>0,5&lt;/td>
&lt;td>~35 GB&lt;/td>
&lt;td>2,5 s&lt;/td>
&lt;td>0,7 s&lt;/td>
&lt;td>10 ms&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Tres lecturas de esta tabla:&lt;/p>
&lt;p>&lt;strong>La HBM nunca aparece como problema.&lt;/strong> La última columna es siempre milisegundos. Quien diga &amp;ldquo;la GPU tarda en cargar&amp;rdquo; está culpando al sitio equivocado.&lt;/p>
&lt;p>&lt;strong>Cuantizar es la palanca de cold start más infravalorada.&lt;/strong> Pasar de FP16 a FP8 no solo dobla el throughput de inferencia (menos ancho de banda HBM por token, como vimos en &lt;a href="https://blog.lo0.es/posts/quantization-fundamentos-inferencia/">quantization&lt;/a>): &lt;strong>también parte por la mitad el cold start&lt;/strong>, porque hay la mitad de bytes que subir. Es un dos por uno que el dimensionado suele ignorar.&lt;/p>
&lt;p>&lt;strong>El disco es el cuello de los bytes; el loader es el cuello del tiempo.&lt;/strong> Las cifras de la tabla son el &lt;strong>suelo teórico&lt;/strong> —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: &lt;strong>menos bytes&lt;/strong> (cuantización) y &lt;strong>mejor mozo&lt;/strong> (loaders concurrentes / GDS).&lt;/p>
&lt;h2 id="las-matemáticas-que-importan-el-cold-start-como-impuesto-del-autoscaling">Las matemáticas que importan: el cold start como impuesto del autoscaling&lt;/h2>
&lt;p>El cold start no se paga una vez. Se paga &lt;strong>cada vez que nace un pod&lt;/strong>. Y en una plataforma elástica, los pods nacen continuamente.&lt;/p>
&lt;p>Pongamos un &lt;a href="https://blog.lo0.es/posts/autoscaling-llm-kubernetes-keda/">autoscaling con KEDA&lt;/a> que escala de 2 a 6 réplicas cuando sube la cola. Las 4 réplicas nuevas tardan en estar listas:&lt;/p>
&lt;p>$$ T_{\text{ready}} = T_{\text{schedule}} + T_{\text{pull-image}} + T_{\text{load-weights}} + T_{\text{cuda-graphs}} $$&lt;/p>
&lt;p>Con un Llama-70B FP16 y el loader por defecto, $T_{\text{load-weights}}$ domina: puede ser &lt;strong>40 s&lt;/strong> de los ~60 s totales. Durante esos 40 s, la cola que disparó el autoescalado &lt;strong>sigue creciendo&lt;/strong> —las réplicas nuevas no absorben tráfico hasta que cargan. El número real de la fórmula no es &amp;ldquo;cuántas réplicas&amp;rdquo;, es &lt;strong>cuánto tarda cada una en empezar a servir&lt;/strong>, y ese número lo escribe el camino de carga.&lt;/p>
&lt;p>Esto tiene una consecuencia operativa dura: &lt;strong>scale-to-zero es inviable para cargas con SLO de latencia si el cold start es de 40 s.&lt;/strong> Nadie espera 40 s al primer token. La elasticidad real de una plataforma de inferencia no la limita la GPU disponible —la limita &lt;strong>cuánto tarda esa GPU en tener el modelo dentro&lt;/strong>. Bajar el cold start de 40 s a 8 s (con streamer + FP8) es lo que convierte &amp;ldquo;scale-to-zero teórico&amp;rdquo; en &amp;ldquo;scale-to-zero usable&amp;rdquo;.&lt;/p>
&lt;p>Los números publicados dan el orden de magnitud de la mejora: &lt;code>fastsafetensors&lt;/code> reduce el arranque de &lt;strong>12,39 s a 4,74 s&lt;/strong> en un Llama-2-13B sobre 4×L40S, y de &lt;strong>16,04 s a 6,88 s&lt;/strong> en 1×A100 —&lt;strong>4,8-7,5×&lt;/strong> sobre el deserializador por defecto. El &lt;strong>Run:ai Model Streamer&lt;/strong> carga en &lt;strong>4,88 s desde S3 a concurrencia 32&lt;/strong> y &lt;strong>7,53 s desde SSD IO2 a concurrencia 8&lt;/strong>; integrado en vLLM, el tiempo total hasta &lt;em>ready&lt;/em> baja a ~23 s desde S3. No son magias: es &lt;strong>sacar a la CPU del bucle&lt;/strong> (GDS) y &lt;strong>leer en paralelo&lt;/strong> (concurrencia) para saturar el disco en vez de dejarlo medio ocioso mientras un hilo deserializa.&lt;/p>
&lt;h2 id="los-10-knobs-donde-tocar">Los 10 knobs donde tocar&lt;/h2>
&lt;h3 id="knob-1--medir-dónde-se-va-el-tiempo-read-vs-deserializar-vs-h2d">Knob 1 — Medir dónde se va el tiempo (read vs deserializar vs H2D)&lt;/h3>
&lt;p>Antes de tocar nada: cronometrar. ¿El tiempo está en leer del disco, en deserializar, o en el H2D? &lt;code>iostat -x 1&lt;/code> 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 &lt;strong>no&lt;/strong> es el disco: es el mozo. Cambiar de disco no arreglaría nada; cambiar de loader, sí.&lt;/p>
&lt;h3 id="knob-2----load-format-elegir-el-mozo">Knob 2 — &lt;code>--load-format&lt;/code>: elegir el mozo&lt;/h3>
&lt;p>vLLM expone varios cargadores vía &lt;code>--load-format&lt;/code>: &lt;code>safetensors&lt;/code> (defecto), &lt;code>runai_streamer&lt;/code>, &lt;code>fastsafetensors&lt;/code>, &lt;code>tensorizer&lt;/code>. El defecto es el más lento. El cambio de una bandera puede ser el 4-7× más barato que existe.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Run:ai Model Streamer (lectura concurrente, satura el disco)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve meta-llama/Llama-3.1-70B --load-format runai_streamer
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># fastsafetensors (GPUDirect Storage, DMA directo disco→HBM)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve meta-llama/Llama-3.1-70B --load-format fastsafetensors
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="knob-3--concurrencia-del-streamer">Knob 3 — Concurrencia del streamer&lt;/h3>
&lt;p>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: &lt;strong>16 suele bastar para NVMe local; 32 (a veces 64)&lt;/strong> para almacenamiento de red de alto throughput. Un hilo no satura un NVMe Gen5; 32 sí.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">vllm serve &amp;lt;model&amp;gt; --load-format runai_streamer &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --model-loader-extra-config &lt;span class="s1">&amp;#39;{&amp;#34;concurrency&amp;#34;: 32}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="knob-4--gpudirect-storage-gds--fastsafetensors">Knob 4 — GPUDirect Storage (GDS) + fastsafetensors&lt;/h3>
&lt;p>Si hay driver &lt;code>nvidia-fs&lt;/code>, filesystem soportado y NVMe local, GDS escribe directo disco→HBM sin rebote por CPU. Es la diferencia entre los dos caminos del diagrama. &lt;strong>Pero&lt;/strong>: requiere el stack montado y solo gana si el cuello es el rebote por CPU, no el propio disco. Verificar con &lt;code>gdscheck&lt;/code>.&lt;/p>
&lt;h3 id="knob-5--nvme-local-para-los-pesos-no-red">Knob 5 — NVMe local para los pesos, no red&lt;/h3>
&lt;p>Servir los pesos desde Ceph RGW / NFS es cómodo (un sitio compartido) pero mete la &lt;strong>red&lt;/strong> en el camino de carga. Para el cold start, &lt;strong>pesos en NVMe local del nodo&lt;/strong> (o cache local). El almacenamiento de red es para el repositorio de modelos; el nodo de inferencia debería tener una copia local caliente.&lt;/p>
&lt;h3 id="knob-6--pre-pull--cache-local-del-modelo">Knob 6 — Pre-pull / cache local del modelo&lt;/h3>
&lt;p>Un &lt;code>initContainer&lt;/code> que descarga el modelo a un volumen &lt;code>local&lt;/code> o &lt;code>hostPath&lt;/code> NVMe antes de arrancar vLLM convierte un cold start &amp;ldquo;desde la red&amp;rdquo; en uno &amp;ldquo;desde NVMe local&amp;rdquo;. Combinado con un DaemonSet de cache por nodo, los pods nuevos en un nodo ya caliente leen del disco local, no de la red.&lt;/p>
&lt;h3 id="knob-7--cuantización-mover-menos-bytes">Knob 7 — Cuantización: mover menos bytes&lt;/h3>
&lt;p>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.&lt;/p>
&lt;h3 id="knob-8--carga-paralela-entre-gpus-vllm-v1">Knob 8 — Carga paralela entre GPUs (vLLM V1)&lt;/h3>
&lt;p>El engine V1 de vLLM (defecto desde 0.19) carga los shards de pesos &lt;strong>en paralelo&lt;/strong> 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.&lt;/p>
&lt;h3 id="knob-9--localidad-numa-del-nvme">Knob 9 — Localidad NUMA del NVMe&lt;/h3>
&lt;p>El NVMe cuelga de un &lt;strong>PCIe root bajo un socket&lt;/strong> concreto —exactamente el mismo mapa NUMA del &lt;a href="https://blog.lo0.es/posts/numa-hugepages-aislamiento-cpu-inferencia/">post del host&lt;/a>. 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 &lt;code>isolcpus&lt;/code>, &lt;code>reserved-cpus&lt;/code> e IRQ de la NIC: &lt;strong>qué socket es local al NVMe y a la GPU destino&lt;/strong>. &lt;code>nvidia-smi topo -m&lt;/code> lo muestra.&lt;/p>
&lt;h3 id="knob-10--no-pagar-el-cold-start-mantener-pods-calientes">Knob 10 — No pagar el cold start: mantener pods calientes&lt;/h3>
&lt;p>A veces la respuesta no es cargar más rápido, sino &lt;strong>no descargar&lt;/strong>. Un suelo de réplicas siempre vivas (no scale-to-zero), o un pool de warm standby precargado, cambia &amp;ldquo;esperar 40 s&amp;rdquo; por &amp;ldquo;0 s&amp;rdquo;. Es coste de GPU ociosa a cambio de latencia de arranque: una decisión de &lt;a href="https://blog.lo0.es/posts/capacity-planning-inferencia-llm-on-premise/">capacity planning&lt;/a>, no técnica.&lt;/p>
&lt;h3 id="tabla-resumen">Tabla resumen&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Knob&lt;/th>
&lt;th>Qué ataca&lt;/th>
&lt;th>Riesgo / coste&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>&lt;code>iostat&lt;/code> al cargar&lt;/td>
&lt;td>saber si el cuello es disco o loader&lt;/td>
&lt;td>ninguno; hazlo siempre primero&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>&lt;code>--load-format&lt;/code>&lt;/td>
&lt;td>el deserializado monohilo&lt;/td>
&lt;td>compatibilidad del formato&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>concurrencia streamer&lt;/td>
&lt;td>disco infrautilizado&lt;/td>
&lt;td>RAM de host por buffers&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>GPUDirect Storage&lt;/td>
&lt;td>rebote por CPU&lt;/td>
&lt;td>requiere nvidia-fs + FS soportado&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>NVMe local vs red&lt;/td>
&lt;td>la red en el camino&lt;/td>
&lt;td>duplicar pesos por nodo&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>6&lt;/td>
&lt;td>pre-pull / cache nodo&lt;/td>
&lt;td>red en cada arranque&lt;/td>
&lt;td>espacio en disco local&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>7&lt;/td>
&lt;td>cuantización FP8/INT4&lt;/td>
&lt;td>bytes a mover&lt;/td>
&lt;td>calidad (medir, no asumir)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>carga paralela V1&lt;/td>
&lt;td>carga serial entre GPUs&lt;/td>
&lt;td>ninguno si V1 activo&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>9&lt;/td>
&lt;td>NUMA del NVMe&lt;/td>
&lt;td>H2D cruzando UPI&lt;/td>
&lt;td>alinear con el resto de listas NUMA&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>10&lt;/td>
&lt;td>warm pods / no zero&lt;/td>
&lt;td>el cold start entero&lt;/td>
&lt;td>GPU ociosa pagada&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="cómo-se-conecta-con-el-resto-del-stack">Cómo se conecta con el resto del stack&lt;/h2>
&lt;p>&lt;strong>Con el autoscaling.&lt;/strong> Todo el &lt;a href="https://blog.lo0.es/posts/autoscaling-llm-kubernetes-keda/">scale-from-zero con KEDA&lt;/a> 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.&lt;/p>
&lt;p>&lt;strong>Con el disaggregated serving.&lt;/strong> En &lt;a href="https://blog.lo0.es/posts/disaggregated-serving-prefill-decode/">prefill/decode desagregado&lt;/a>, 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.&lt;/p>
&lt;p>&lt;strong>Con canary/blue-green.&lt;/strong> Cada &lt;a href="https://blog.lo0.es/posts/canary-blue-green-shadow-modelos-llm/">despliegue canary&lt;/a> 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.&lt;/p>
&lt;p>&lt;strong>Con NUMA y hugepages.&lt;/strong> El buffer de host de la carga quiere ser &lt;strong>pinned&lt;/strong> y &lt;strong>NUMA-local&lt;/strong> —lo mismo que pedía el &lt;a href="https://blog.lo0.es/posts/numa-hugepages-aislamiento-cpu-inferencia/">post del host&lt;/a> para la ruta caliente. El camino de carga es otro cliente del mismo mapa NUMA.&lt;/p>
&lt;p>&lt;strong>Con la cuantización.&lt;/strong> &lt;a href="https://blog.lo0.es/posts/quantization-fundamentos-inferencia/">FP8/INT4&lt;/a> no es solo throughput de inferencia: es la palanca directa sobre los bytes del cold start.&lt;/p>
&lt;p>&lt;strong>Con capacity planning.&lt;/strong> El &lt;a href="https://blog.lo0.es/posts/capacity-planning-inferencia-llm-on-premise/">dimensionado&lt;/a> 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.&lt;/p>
&lt;h2 id="trampas-y-cosas-que-no-son-lo-que-parecen">Trampas y cosas que no son lo que parecen&lt;/h2>
&lt;p>&lt;strong>&amp;ldquo;La segunda vez cargó rapidísimo.&amp;rdquo;&lt;/strong> Es la trampa estrella. La primera carga llenó la &lt;strong>page cache&lt;/strong> (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 &lt;strong>frío&lt;/strong>, 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).&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;GDS siempre acelera.&amp;rdquo;&lt;/strong> 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 &lt;code>nvidia-fs&lt;/code>, un filesystem soportado y a veces no funciona sobre el almacenamiento de red que tengas.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;mmap hace la carga instantánea.&amp;rdquo;&lt;/strong> &lt;code>mmap&lt;/code> mapea el fichero pero &lt;strong>no lee nada todavía&lt;/strong>: el coste se difiere al primer acceso a cada página. El tiempo no desaparece, se mueve —el &lt;strong>primer token&lt;/strong> 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.&lt;/p>
&lt;p>&lt;strong>Pesos en almacenamiento de red &amp;ldquo;porque es más limpio&amp;rdquo;.&lt;/strong> 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.&lt;/p>
&lt;p>&lt;strong>Cargar FP16 y cuantizar en el arranque.&lt;/strong> Cuantizar al vuelo durante la carga (p. ej. FP16→FP8 en GPU) puede ser &lt;strong>más lento&lt;/strong> 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.&lt;/p>
&lt;p>&lt;strong>Optimizar la carga e ignorar &lt;code>T_cuda-graphs&lt;/code>.&lt;/strong> 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 &lt;a href="https://blog.lo0.es/posts/sm-cuda-streams-cuda-graphs-inferencia/">post siguiente&lt;/a>.&lt;/p>
&lt;h2 id="conclusión">Conclusión&lt;/h2>
&lt;p>Toda la serie optimizó lo que pasa &lt;strong>con cada token&lt;/strong>: 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 &lt;strong>cadena de suministro&lt;/strong>: 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 —&lt;strong>menos bytes&lt;/strong> (cuantización) y &lt;strong>mejor transporte&lt;/strong> (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 &lt;strong>no es un detalle de arranque, es el techo de la elasticidad&lt;/strong>. 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.&lt;/p>
&lt;h2 id="ver-también">Ver también&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/posts/nvlink-nvswitch-nccl-tensor-parallel/">La mesa compartida: NVLink, NVSwitch y NCCL&lt;/a> — el primero de &amp;ldquo;por debajo del motor&amp;rdquo;; este post baja al sótano que aquella serie daba por lleno.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/numa-hugepages-aislamiento-cpu-inferencia/">La planta de al lado: NUMA, hugepages y aislamiento de CPU&lt;/a> — la memoria pinned y NUMA-local que el camino de carga necesita para un H2D eficiente.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/cilium-ebpf-dranet-numa-de-red-inferencia/">La puerta de la cocina: NUMA de red, Cilium eBPF y DRANET&lt;/a> — el mismo principio de &amp;ldquo;saca a la CPU del medio&amp;rdquo; (GPUDirect) que aquí aplica GDS al disco.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/sm-cuda-streams-cuda-graphs-inferencia/">SM, CUDA streams y CUDA graphs&lt;/a> — la segunda mitad del cold start (captura de graphs) y lo que pasa en el silicio una vez los pesos están dentro.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/quantization-fundamentos-inferencia/">Quantization para inferencia&lt;/a> — la palanca que parte por la mitad los bytes del cold start, no solo el ancho de banda HBM por token.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/autoscaling-llm-kubernetes-keda/">Autoscaling de LLM en Kubernetes con KEDA&lt;/a> — por qué el cold start es el techo real de la elasticidad y mata el scale-to-zero.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/disaggregated-serving-prefill-decode/">Disaggregated serving: prefill y decode separados&lt;/a> — levantar pools bajo demanda paga el cold start en cada pod nuevo.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/canary-blue-green-shadow-modelos-llm/">Canary, blue-green y shadow para modelos&lt;/a> — el tiempo de validación de un canary incluye su carga del modelo.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/posts/capacity-planning-inferencia-llm-on-premise/">Capacity planning de inferencia on-premise&lt;/a> — el cold start como parámetro del colchón de réplicas y de la decisión warm-vs-zero.&lt;/li>
&lt;/ul>
&lt;h2 id="referencias">Referencias&lt;/h2>
&lt;ul>
&lt;li>NVIDIA, &lt;em>Reducing Cold Start Latency for LLM Inference with NVIDIA Run:ai Model Streamer&lt;/em>: &lt;a href="https://developer.nvidia.com/blog/reducing-cold-start-latency-for-llm-inference-with-nvidia-runai-model-streamer/">https://developer.nvidia.com/blog/reducing-cold-start-latency-for-llm-inference-with-nvidia-runai-model-streamer/&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Loading models with Run:ai Model Streamer&lt;/em>: &lt;a href="https://docs.vllm.ai/en/stable/models/extensions/runai_model_streamer/">https://docs.vllm.ai/en/stable/models/extensions/runai_model_streamer/&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>Loading model weights with fastsafetensors&lt;/em>: &lt;a href="https://docs.vllm.ai/en/stable/models/extensions/fastsafetensor/">https://docs.vllm.ai/en/stable/models/extensions/fastsafetensor/&lt;/a>.&lt;/li>
&lt;li>foundation-model-stack, &lt;em>fastsafetensors&lt;/em> (loader de alto rendimiento, GDS): &lt;a href="https://github.com/foundation-model-stack/fastsafetensors">https://github.com/foundation-model-stack/fastsafetensors&lt;/a>.&lt;/li>
&lt;li>&lt;em>Speeding up Model Loading with fastsafetensors&lt;/em> (arXiv 2505.23072): &lt;a href="https://arxiv.org/html/2505.23072v1">https://arxiv.org/html/2505.23072v1&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>GPUDirect Storage: A Direct Path Between Storage and GPU Memory&lt;/em>: &lt;a href="https://developer.nvidia.com/blog/gpudirect-storage/">https://developer.nvidia.com/blog/gpudirect-storage/&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>Magnum IO GPUDirect Storage&lt;/em> (benchmarking y configuración): &lt;a href="https://developer.nvidia.com/gpudirect-storage">https://developer.nvidia.com/gpudirect-storage&lt;/a>.&lt;/li>
&lt;/ul></description></item></channel></rss>