<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Inference on lo0 — Tech Blog</title><link>https://blog.lo0.es/en/tags/inference/</link><description>Recent content in Inference on lo0 — Tech Blog</description><generator>Hugo -- gohugo.io</generator><language>en</language><lastBuildDate>Sat, 29 Aug 2026 10:00:00 +0200</lastBuildDate><atom:link href="https://blog.lo0.es/en/tags/inference/index.xml" rel="self" type="application/rss+xml"/><item><title>The blog map: from the utility feed to the token served</title><link>https://blog.lo0.es/en/posts/blog-map-from-utility-feed-to-token-served/</link><pubDate>Sat, 29 Aug 2026 10:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/blog-map-from-utility-feed-to-token-served/</guid><description>&lt;p>This blog started in March 2026 with a handful of articles about Kubernetes and networking, and by now it has piled up more than 150 pieces that, read one at a time, look like unconnected topics: PagedAttention, diesel generators, OpenCost, SPIFFE, immersion cooling, RAGAS. They are not. All of them describe the same object from different altitudes: &lt;strong>a sovereign inference factory&lt;/strong>, that is, the complete machine that turns megawatts from the grid into tokens served to a user, with ownership and control of the data kept in house.&lt;/p>
&lt;p>This article is the index of that machine. It orders what has been published into ten layers, bottom up, and links what is written in each one. It is good for three things: finding a specific topic, seeing which layers are covered in depth and which are half done, and choosing a reading route according to the problem in front of you.&lt;/p>
&lt;h2 id="the-map">The map&lt;/h2>
&lt;div class="diagram" style="max-width:880px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 880 620" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Layered map of the sovereign inference stack, from the utility feed to the token served">&lt;style>
.bx{fill:none;stroke:currentColor;stroke-width:1.3;rx:6}
.bxs{fill:none;stroke:currentColor;stroke-width:1;stroke-dasharray:4 3;rx:6}
.t{font:600 13px sans-serif;fill:currentColor}
.s{font:11px sans-serif;fill:currentColor}
.n{font:600 11px sans-serif;fill:currentColor}
.h{font:600 12px sans-serif;fill:currentColor}
.ar{stroke:currentColor;stroke-width:1.2;fill:none;marker-end:url(#ah)}
&lt;/style>
&lt;defs>&lt;marker id="ah" 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="currentColor"/>&lt;/marker>&lt;/defs>
&lt;text x="440" y="20" text-anchor="middle" class="t">From the utility feed to the token: the ten layers of an inference factory&lt;/text>
&lt;rect x="30" y="34" width="600" height="44" class="bx"/>
&lt;text x="46" y="53" class="h">10 · Cost, performance and energy&lt;/text>
&lt;text x="46" y="69" class="s">FinOps · benchmarking · Wh per token · TCO · the on-premise case with data&lt;/text>
&lt;text x="600" y="60" text-anchor="end" class="n">28&lt;/text>
&lt;rect x="30" y="86" width="290" height="44" class="bx"/>
&lt;text x="46" y="105" class="h">9 · Security and compliance&lt;/text>
&lt;text x="46" y="121" class="s">guardrails · isolation · signing · ENS, 42001, AI Act&lt;/text>
&lt;text x="304" y="112" text-anchor="end" class="n">13&lt;/text>
&lt;rect x="340" y="86" width="290" height="44" class="bx"/>
&lt;text x="356" y="105" class="h">8 · Observability and evals&lt;/text>
&lt;text x="356" y="121" class="s">OTel GenAI · Langfuse · DCGM · RAGAS · runbooks&lt;/text>
&lt;text x="614" y="112" text-anchor="end" class="n">14&lt;/text>
&lt;rect x="30" y="138" width="600" height="44" class="bx"/>
&lt;text x="46" y="157" class="h">7 · Control plane and delivery&lt;/text>
&lt;text x="46" y="173" class="s">gateway and L7 router · KServe and OIP · OCI registry · GitOps · canary and shadow&lt;/text>
&lt;text x="600" y="164" text-anchor="end" class="n">14&lt;/text>
&lt;rect x="30" y="190" width="600" height="44" class="bx"/>
&lt;text x="46" y="209" class="h">6 · Data and RAG&lt;/text>
&lt;text x="46" y="225" class="s">ingestion · embeddings · reranker · vector store · CDC · semantic cache&lt;/text>
&lt;text x="600" y="216" text-anchor="end" class="n">17&lt;/text>
&lt;rect x="30" y="242" width="600" height="44" class="bx"/>
&lt;text x="46" y="261" class="h">5 · The model&lt;/text>
&lt;text x="46" y="277" class="s">quantisation · pruning · distillation · MoE · LoRA · speculative decoding&lt;/text>
&lt;text x="600" y="268" text-anchor="end" class="n">20&lt;/text>
&lt;rect x="30" y="294" width="600" height="44" class="bx"/>
&lt;text x="46" y="313" class="h">4 · Inference engine&lt;/text>
&lt;text x="46" y="329" class="s">vLLM from the inside · KV cache · prefill and decode · batching · scheduler&lt;/text>
&lt;text x="600" y="320" text-anchor="end" class="n">22&lt;/text>
&lt;rect x="30" y="346" width="600" height="44" class="bx"/>
&lt;text x="46" y="365" class="h">3 · Cluster&lt;/text>
&lt;text x="46" y="381" class="s">Kubernetes · GPU sharing · queues and quotas · autoscaling · operators&lt;/text>
&lt;text x="600" y="372" text-anchor="end" class="n">13&lt;/text>
&lt;rect x="30" y="398" width="290" height="44" class="bx"/>
&lt;text x="46" y="417" class="h">2 · Storage&lt;/text>
&lt;text x="46" y="433" class="s">weights, corpus and checkpoints · cold start&lt;/text>
&lt;text x="304" y="424" text-anchor="end" class="n">7&lt;/text>
&lt;rect x="340" y="398" width="290" height="44" class="bx"/>
&lt;text x="356" y="417" class="h">1 · Node&lt;/text>
&lt;text x="356" y="433" class="s">PCIe · NUMA · NVLink · HBM · CPU&lt;/text>
&lt;text x="614" y="424" text-anchor="end" class="n">8&lt;/text>
&lt;rect x="30" y="450" width="600" height="44" class="bx"/>
&lt;text x="46" y="469" class="h">0 · Physical infrastructure&lt;/text>
&lt;text x="46" y="485" class="s">utility feed · generators · switching · UPS · air · liquid · immersion&lt;/text>
&lt;text x="600" y="476" text-anchor="end" class="n">8&lt;/text>
&lt;path d="M660 472 L660 56" class="ar"/>
&lt;text x="672" y="270" class="s">each layer only exists&lt;/text>
&lt;text x="672" y="286" class="s">if the one below holds it&lt;/text>
&lt;rect x="660" y="330" width="200" height="120" class="bxs"/>
&lt;text x="676" y="352" class="h">Master maps&lt;/text>
&lt;text x="676" y="372" class="s">6-stage LLMOps pipeline&lt;/text>
&lt;text x="676" y="390" class="s">the 7 layers of the stack&lt;/text>
&lt;text x="676" y="408" class="s">anatomy of a request&lt;/text>
&lt;text x="676" y="426" class="s">5 maturity levels&lt;/text>
&lt;text x="676" y="444" class="s">7 deployment phases&lt;/text>
&lt;rect x="660" y="86" width="200" height="96" class="bxs"/>
&lt;text x="676" y="108" class="h">Networking vertical&lt;/text>
&lt;text x="676" y="128" class="s">EVPN-VXLAN Huawei&lt;/text>
&lt;text x="676" y="146" class="s">MikroTik RouterOS 7&lt;/text>
&lt;text x="676" y="164" class="s">UniFi and HCIA-Datacom&lt;/text>
&lt;text x="440" y="520" text-anchor="middle" class="s">Layer 10 rests on none of the others: it cuts through them all, because it measures what the nine below cost.&lt;/text>
&lt;text x="440" y="540" text-anchor="middle" class="s">Layers 0 to 3 belong to the infrastructure operator; 4 to 7, to the platform; 8 and 9, to everyone.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The map reads bottom up, and the criterion for ordering it is physical dependency: there is no inference engine without a powered and cooled GPU, no RAG without an engine serving embeddings, no demonstrable regulatory compliance without observability recording what happened. Layer 10 is the exception, and that is why it appears cutting across everything: cost, performance and energy are not a floor of the building, they are the measure of the whole building.&lt;/p>
&lt;h2 id="layer-0--physical-infrastructure">Layer 0 · Physical infrastructure&lt;/h2>
&lt;p>The most recent vertical, and the one that closes the circle: the path of the watt from the utility feed to the chip, and the path of the heat from the chip to the atmosphere.&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/datacenter-physical-infrastructure-power-chain/">The power chain&lt;/a>: redundancy, Tier, kW against kVA, PUE, AI density and the limit of the grid.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/datacenter-physical-infrastructure-generators/">Generators&lt;/a>: NFPA 110, transients, sizing, diesel against gas, fuel and paralleling.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/datacenter-physical-infrastructure-switching/">Switching&lt;/a>: ATS against STS, the ITIC window, open and closed transition, and the A and B paths.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/datacenter-physical-infrastructure-ups-and-ride-through/">The UPS and ride-through&lt;/a>: IEC 62040-3 topologies, eco-mode, flywheel, lithium against lead and the pulsing loads of AI.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/ai-datacenter-cooling-thermal-challenge/">The thermal challenge&lt;/a>: why AI density broke air, and the spectrum of solutions.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/ai-datacenter-cooling-air-and-its-limits/">Air and its limits&lt;/a>: CRAC and CRAH, the geometry of the flow, the cube law, containment, ASHRAE and rear doors.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/ai-datacenter-cooling-direct-liquid-to-chip/">Direct liquid to the chip&lt;/a>: cold plate, CDU, TCS and FWS loops, coolant chemistry and leaks.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/ai-datacenter-cooling-immersion-megawatt-horizon/">Immersion and the megawatt horizon&lt;/a>: single-phase and two-phase, the PFAS wall and microfluidics in the silicon.&lt;/li>
&lt;/ul>
&lt;h2 id="layer-1--the-node">Layer 1 · The node&lt;/h2>
&lt;p>Inside the machine, before any process exists: the buses, the memory and the topology that decide how much performance actually reaches the model.&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pcie-topology-gpudirect-p2p-acs-security-guard/">PCIe topology, GPUDirect and ACS&lt;/a>: the corridors the tensors travel through.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">NUMA, hugepages and CPU isolation&lt;/a>: why the neighbour on your socket costs you latency.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">NVLink, NVSwitch and NCCL&lt;/a>: the cable a large model is split across.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/">Network NUMA with Cilium and DraNet&lt;/a>: the kitchen door nobody watches.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">CPU, Memory and Topology Manager in RKE2&lt;/a>: how to ask for a GPU with affinity and no surprises.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">SM, CUDA streams and CUDA graphs&lt;/a>: where time is lost inside the accelerator.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mixed-nvidia-intel-environments-llm-inference/">Mixed NVIDIA and Intel environments&lt;/a>: from the homogeneous cluster to the real estate.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/measuring-gpu-power-nvml-dcgm-sampling-errors/">Measuring the power of a GPU&lt;/a>: sensor metrology, which belongs to this layer and to layer 10 at the same time.&lt;/li>
&lt;/ul>
&lt;h2 id="layer-2--storage">Layer 2 · Storage&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/ai-era-storage-state-of-the-art/">State of the art&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/ai-era-storage-performance/">performance&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/storage-ai-era-security/">security&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/storage-ai-era-availability/">availability&lt;/a>: the set of four on the storage that holds weights, corpus and checkpoints.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM&lt;/a>: why starting a model takes as long as it takes.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/">Speeding up the cold start&lt;/a>: from minutes to seconds with serialisation and cache.&lt;/li>
&lt;/ul>
&lt;h2 id="layer-3--the-cluster">Layer 3 · The cluster&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a>: the inference piece that does scale.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM inference operators&lt;/a>: OME, vLLM Production Stack and company.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/">The GPU cluster as a multi-tenant platform&lt;/a>: from shared machine to service.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">Sharing a GPU: time-slicing, MPS and MIG&lt;/a>: the three ways to split an accelerator.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/volcano-kueue-gang-scheduling-gpu-quotas-kubernetes/">Volcano and Kueue&lt;/a>: gang scheduling, queues and quotas.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling with HPA and KEDA&lt;/a>: scaling on metrics that mean something.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/">Serving several models on one GPU&lt;/a>: co-residency, swap and sleep.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">On-premise capacity planning&lt;/a>: from the SLO to the number of nodes.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gitops-inference-stack-flux/">GitOps for the stack with Flux&lt;/a>: operating the platform as code.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kubernetes-cilium-bgp-services-without-ingress/">Cilium BGP on RKE2&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/ebpf-zero-to-cilium-kernel-tcp-ip-bypass/">eBPF from zero to Cilium&lt;/a>: the network underneath everything above.&lt;/li>
&lt;/ul>
&lt;h2 id="layer-4--the-inference-engine">Layer 4 · The inference engine&lt;/h2>
&lt;p>The densest block of the blog: vLLM cut open, piece by piece.&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention&lt;/a>: the working memory and its block manager.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">the scheduler step&lt;/a>: how each round of work is assembled.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">Optimising prefill&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">optimising decode&lt;/a>: the two phases and their controls.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-attention-backend-flashattention-flashinfer-prefill-decode-asymmetry/">The attention backend&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention&lt;/a>: the core of the computation.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">Prefix cache: hit rate engineering&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/vllm-batch-sizing-two-hour-grid-search/">batch sizing with grid search&lt;/a>: the two settings that give away the most performance.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">one big replica or many small ones&lt;/a>: the topology decisions.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/long-context-kv-offloading-notebook-desk/">Long context and KV offloading&lt;/a>: the KV memory hierarchy when the window does not fit in HBM.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">Structured output&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/function-calling-tool-augmented-retrieval-detective-knows-which-file/">function calling&lt;/a>: holding the model&amp;rsquo;s output in place.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/serving-reasoning-models-invisible-scratchpad-latency-bill/">Serving reasoning models&lt;/a>: the invisible draft you pay for all the same.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/multimodal-on-premise-serving-vlm-vllm/">Multimodal on-premise with vLLM&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/serving-embeddings-rerankers-tei-production/">serving embeddings and rerankers with TEI&lt;/a>: the other models in the stack.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">The inverted roofline&lt;/a>: why a small model is not optimised the same way as a large one.&lt;/li>
&lt;/ul>
&lt;h2 id="layer-5--the-model">Layer 5 · The model&lt;/h2>
&lt;p>Everything done to the model before serving it, or while it is being served.&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantization for inference&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8 end-to-end&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/aggressive-quantisation-4-bit-to-ternary/">aggressive sub-4-bit quantisation&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/test-time-quantization-without-calibration-dataset/">quantising on the fly&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-pruning-cutting-without-amputating/">Pruning&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/knowledge-distillation-teaching-small-model-think-like-big-one/">distillation&lt;/a>: reducing without amputating, and teaching a small model.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/">MoE in inference&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/device-native-architectures-fine-grained-moe-pre-attention-router/">device-native architectures&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/qlora-multi-lora-limit-small-models/">QLoRA and multi-LoRA at the limit&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/qlora-runbook-dataset-to-served-adapter/">the complete QLoRA runbook&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/self-speculative-decoding-model-gets-ahead-of-itself/">self-speculative with early exit&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/modern-alignment-dpo-kto-orpo-simpo/">Modern alignment&lt;/a>: DPO, KTO, ORPO and SimPO.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">Continuous fine-tuning&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">retrain: closing the loop&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h2 id="layer-6--data-and-rag">Layer 6 · Data and RAG&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/end-to-end-document-ingestion-pdf-to-indexed-chunk/">Document ingestion from the PDF to the indexed chunk&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">corpus curation&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/embeddings-2026-dense-sparse-multivector-families/">Embeddings in 2026&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">reranker with hybrid retrieval&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL and Qdrant in ingestion&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/debezium-cdc-notary-who-hears-changes/">Debezium and CDC&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/rag-on-kafka-reference-architecture-streaming-datalakes/">RAG over Kafka&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/semantic-cache-rag-receptionist-photographic-memory/">Semantic cache&lt;/a>: the receptionist with a memory.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/rag-cpu-data-plane-generation-plane/">RAG on CPU&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/aggressive-rag-small-models-trading-parameters-for-retrieval/">aggressive RAG on small models&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evaluating-rag-ragas-golden-dataset-metrics/">Evaluating a RAG without fooling yourself&lt;/a>: RAGAS and the golden dataset.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/ontologies-knowledge-graphs-llmops-linnaean-nomenclature/">Ontologies and knowledge graphs&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/data-versioning-llmops-dvc-lakefs-reproducible-golden-dataset/">data versioning with DVC and lakeFS&lt;/a>.&lt;/li>
&lt;/ul>
&lt;h2 id="layer-7--control-plane-and-delivery">Layer 7 · Control plane and delivery&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">Choosing the OSS gateway&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">the L7 inference router&lt;/a>: the switchboard in front of the engine.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kserve-open-inference-protocol-control-plane/">KServe and the Open Inference Protocol&lt;/a>: the standard control plane.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-where-the-bytes-live-registry-oci-artefacts/">Model registry and distribution with OCI and ORAS&lt;/a>: where the bytes live.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/canary-blue-green-shadow-llm-models/">Canary, blue-green and shadow&lt;/a>: deploying a model without breaking the service.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prompt-versioning-contract-five-word-change/">Prompt versioning&lt;/a>: the contract that avoids the scare.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mcp-deep-observability-opentelemetry/">MCP from the inside&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/mcp-grows-up-authentication-keycloak/">adding authentication with Keycloak&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Operating the control plane&lt;/strong> (September 2026 run): &lt;a href="https://blog.lo0.es/en/posts/litellm-langfuse-operational-pair/">the LiteLLM and Langfuse pair&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/litellm-day-2-high-availability/">day 2 of the proxy&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/litellm-virtual-keys-budgets-limits/">virtual keys, budgets and limits&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/humans-agents-same-gateway-priority-traffic-separation/">humans and agents on the same gateway&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/prefix-routing-what-litellm-does-not-do/">the prefix routing LiteLLM does not do&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/litellm-mcp-gateway-second-front-door/">the MCP gateway as a second front door&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/sizing-agents-decode-bottleneck/">sizing for agents&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/gateway-jwt-audience-trace-identity-seams/">the seams with identity and traces&lt;/a>: the gateway no longer as a piece you choose, but as a piece you operate.&lt;/li>
&lt;li>&lt;strong>Identity&lt;/strong>: &lt;a href="https://blog.lo0.es/en/posts/keycloak-identity-plane-ai-platform/">Keycloak in an AI platform&lt;/a>, with its position in the architecture and the MCP standards it does not implement, and &lt;a href="https://blog.lo0.es/en/posts/completing-keycloak-for-mcp-protected-resource/">how to complete it for MCP&lt;/a> by building the protected resource side.&lt;/li>
&lt;li>&lt;strong>Operating Langfuse&lt;/strong> (a run of eight, September 2026): &lt;a href="https://blog.lo0.es/en/posts/langfuse-v4-what-goes-into-a-trace/">what goes into a trace&lt;/a>, the first article about day 2 of version 4.&lt;/li>
&lt;li>Building an end-to-end sovereign assistant, with all of the above pieces working together, is the exercise that closes this layer and is pending publication.&lt;/li>
&lt;/ul>
&lt;p>Delivery to the teams that use it is its own family of pieces:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/backstage-self-service-portal-llm-platform/">Backstage as a self-service portal&lt;/a>: the catalogue, the templates and the golden path, with the real cost of operating it.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kubeflow-pieces-worth-it-on-premise-llm-platform/">Kubeflow in depth&lt;/a>: which pieces of the ML toolbox are worth it and which ones you already have.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/knative-scale-to-zero-llm-inference/">Knative and scale-to-zero&lt;/a>: when switching off the GPU saves money and when the cold start violates your SLO.&lt;/li>
&lt;/ul>
&lt;p>Rebuilding the cloud experience on your own iron is a run of three:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/public-cloud-to-private-what-you-lose-which-projects-fill-gap/">From public cloud to private&lt;/a>: what you lose when you leave the cloud and which projects fill each gap, with the business case and the sovereignty drivers.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/crossplane-control-plane-private-cloud-api/">Crossplane as a control plane&lt;/a>: the declarative provisioning API that on-premise does not give you, and the cost of operating your own control plane.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kubevela-score-platform-cli-without-teaching-kubernetes/">KubeVela and Score&lt;/a>: application-centric self-service by CLI, the friendly face Crossplane deliberately does not offer.&lt;/li>
&lt;/ul>
&lt;h2 id="layer-8--observability-and-evaluation">Layer 8 · Observability and evaluation&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">Tracing with OpenTelemetry GenAI&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/">instrumenting vLLM with OTel&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/langfuse-inside-sorting-centre-bottleneck/">Langfuse from the inside&lt;/a>: the sorting centre for traces.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability with DCGM&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/anatomy-dcgm-vllm-metrics-analogies-documented-anomalies/">the anatomy of DCGM and vLLM metrics&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">Evals: the layer after tracing&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/">LLM-as-judge&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/incident-response-runbooks-llm-inference-kafka-keep/">Incident response runbooks&lt;/a>: from the alert to the action.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/hubble-ebpf-network-observability-ai-agents/">Hubble&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/agentsight-llm-tracing-ebpf-zero-instrumentation/">AgentSight&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/ebpf-local-inference-statistical-drift-detection/">drift detection with eBPF&lt;/a>: observing without instrumenting.&lt;/li>
&lt;/ul>
&lt;h2 id="layer-9--security-trust-and-compliance">Layer 9 · Security, trust and compliance&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-guardrails-safety-four-lines-of-defence/">Guardrails and safety&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-guard-anatomy-scanners-langfuse-vllm-litellm/">LLM Guard&lt;/a>: the lines of defence around the model.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">Hardening and secrets of the sovereign stack&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/isolating-ai-agents-workstation-to-cluster/">Isolating AI agents from the workspace&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/runbook-caging-ai-agent-bubblewrap-tetragon/">the runbook with bubblewrap and Tetragon&lt;/a>, with &lt;a href="https://blog.lo0.es/en/posts/tetragon-cilium-security-syscalls-kernel/">Tetragon&lt;/a> as the base.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-signature-provenance-aibom-why-you-trust-those-bytes/">Signing, provenance and AIBOM&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-who-serves-the-model-which-machine-you-trust/">identity and isolation with SPIFFE and Confidential Containers&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/iso-42001-aims-on-premise-llm-platform/">ISO/IEC 42001&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/eu-ai-act-technical-dossier-on-premise-llm-architecture/">the EU AI Act article by article&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">the cross-mapping of ENS, 42001 and the AI Act&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/infrastructure-healthcare-ai-compliance/">Compliance in healthcare&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/infrastructure-defence-ai-compliance/">in defence&lt;/a>: the same infrastructure facing two sector frameworks.&lt;/li>
&lt;/ul>
&lt;h2 id="layer-10--cost-performance-and-energy">Layer 10 · Cost, performance and energy&lt;/h2>
&lt;p>The data track, written in a different format from the rest of the blog: tables, figures and a verifiable source, no analogies. These are the articles that hold up an investment decision.&lt;/p>
&lt;p>&lt;strong>FinOps&lt;/strong>: &lt;a href="https://blog.lo0.es/en/posts/gpu-llm-finops-frameworks-metrics-state-of-the-art/">cost model and state of the art&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/opencost-gpu-cost-allocation-kubernetes/">OpenCost in depth&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/kubecost-vs-opencost-vs-alternatives/">Kubecost against OpenCost&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/gpu-hour-to-cost-per-token-on-prem-vs-cloud/">from the GPU-hour to the cost per token&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/gpu-chargeback-showback-multi-tenancy/">chargeback and showback&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/gpu-idle-cost-not-on-any-invoice/">the cost of the idle GPU&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/cloud-gpu-on-demand-reserved-spot-neoclouds-2026-prices/">cloud GPU and neoclouds&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/full-tco-on-premise-gpu-cluster-capex-gpu-hour-break-even/">full on-premise TCO&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">multi-tenancy FinOps with LiteLLM&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Benchmarking&lt;/strong>: &lt;a href="https://blog.lo0.es/en/posts/llm-inference-benchmarking-frameworks-metrics-state-of-the-art/">frameworks and metrics&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/llm-benchmark-tool-catalogue-practical-deep-dive/">the tool catalogue&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/guidellm-validating-slo-under-load-sizing-knee/">GuideLLM and the SLO under load&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/genai-perf-llm-inference-profiling-in-depth/">GenAI-Perf in depth&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/mlperf-inference-reading-loadgen-comparability/">how to read MLPerf Inference&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/measurement-bias-reproducibility-llm-benchmarks/">measurement bias and reproducibility&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/llm-quality-benchmarks-contamination-trap-oss-tools/">quality benchmarks&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-serving-engines-pareto-frontier-comparison/">the Pareto frontier between engines&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Energy&lt;/strong>: &lt;a href="https://blog.lo0.es/en/posts/measuring-gpu-power-nvml-dcgm-sampling-errors/">measuring GPU power&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/energy-per-token-spain-methodology-electricity-market-speculation/">energy per token in Spain&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/ai-energy-benchmarking-frameworks-metrics-state-of-the-art/">energy benchmarking frameworks&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/measuring-energy-in-production-kepler-dcgm-stack/">measuring in production with Kepler and DCGM&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/mlperf-power-energy-efficiency-benchmark/">MLPerf Power&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/llm-energy-leaderboards-wh-per-token/">Wh per token leaderboards&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/watt-to-carbon-pue-grid-intensity-gco2-per-token/">from the watt to carbon&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/levers-fewer-watts-per-token-llm-inference/">efficiency levers&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/energy-in-tco-and-regulation-cost-pue-csrd-eu-datacenter-reporting/">energy in the TCO and in regulation&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Synthesis&lt;/strong>: &lt;a href="https://blog.lo0.es/en/posts/cost-performance-energy-three-axes-llm-inference/">the three axes and the identity that binds them&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/sovereign-on-premise-vs-hyperscalers-data/">sovereign on-premise against hyperscalers, with data&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/slo-to-number-of-gpus-sizing-justifying-inference-hardware/">from the SLO to the number of GPUs&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/reproducible-harness-cost-performance-energy-experiment/">the reproducible harness&lt;/a>.&lt;/p>
&lt;p>To that list you can add two cost pieces that are not about GPUs: &lt;a href="https://blog.lo0.es/en/posts/second-cost-vector-ai-agents-durable-execution-temporal/">the cost of agents with durable execution&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/gpu-hour-to-cost-per-token-on-prem-vs-cloud/">the gateway&amp;rsquo;s cost per request&lt;/a>.&lt;/p>
&lt;h2 id="the-master-maps">The master maps&lt;/h2>
&lt;p>Five earlier articles work as the general plan and are still the best way in for anyone arriving new:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a>: the master map of the life cycle.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/">The seven layers of the on-premise stack&lt;/a>: the architecture version of this same map.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production&lt;/a>: a real request crossing every layer.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">Five maturity levels of the platform&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/greenfield-deployment-phases-on-premise-llm-platform/">seven greenfield deployment phases&lt;/a>: where you are and what comes next.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">The OSS catalogue entry by entry&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">the parallel catalogue against the hyperscalers&lt;/a>: which tool for each stage.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mlops-for-llms-landscape-2026/">MLOps for LLMs in 2026&lt;/a>: the starting landscape.&lt;/li>
&lt;/ul>
&lt;h2 id="four-routes">Four routes&lt;/h2>
&lt;p>&lt;strong>I am about to build the first platform.&lt;/strong> Landscape and the six-stage pipeline, the seven layers of the stack, the seven deployment phases, capacity planning, vLLM on Kubernetes, gateway, observability with OTel and Langfuse, and guardrails before opening the door.&lt;/p>
&lt;p>&lt;strong>I have the cluster and the performance is not there.&lt;/strong> Anatomy of a request, KV cache and PagedAttention, prefill and decode, prefix cache, batch sizing, GuideLLM to find the saturation point, measurement bias so you do not fool yourself with the numbers, and the Pareto frontier between engines.&lt;/p>
&lt;p>&lt;strong>I have to justify the investment to management.&lt;/strong> The three axes, on-premise TCO, cost per token, idle GPU, on-premise against hyperscalers, sizing and justifying, and the reproducible harness so the figures survive an audit.&lt;/p>
&lt;p>&lt;strong>My job is the room, not the software.&lt;/strong> The power chain, the generators, the thermal challenge, air and its limits, direct liquid to the chip, immersion, and from there to storage and node topology.&lt;/p>
&lt;h2 id="what-is-missing">What is missing&lt;/h2>
&lt;p>The map also serves to show the gaps, and three are identified, plus a line that is half open:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>A comparison of vector databases&lt;/strong>: Milvus, Qdrant, pgvector and OpenSearch with data, beyond the scattered mentions.&lt;/li>
&lt;li>&lt;strong>Lakehouse for RAG&lt;/strong>: Iceberg and Trino as the substrate of the corpus.&lt;/li>
&lt;li>&lt;strong>Out-of-band management&lt;/strong>: OpenBMC, Redfish and DCIM, which is exactly the bridge between layer 0 and layer 3.&lt;/li>
&lt;li>&lt;strong>Sovereign substrate&lt;/strong>: the CLI self-service part is already covered by the layer 7 run (Crossplane, KubeVela and Score), and what remains is the IaaS-type substrate underneath, the sovereign alternatives to the hyperscaler (Spinifex, Harvester, OpenStack) on which everything else runs.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/start-here/">Start here&lt;/a>: the reading route by profile and by vertical, including the Kubernetes and networking ones this map leaves out.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production&lt;/a>: if you are only going to read one article on the blog, make it that one.&lt;/li>
&lt;/ul></description></item><item><title>MLPerf Inference: how to read it, what LoadGen enforces and what comparability it offers</title><link>https://blog.lo0.es/en/posts/mlperf-inference-reading-loadgen-comparability/</link><pubDate>Sat, 29 Aug 2026 09:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/mlperf-inference-reading-loadgen-comparability/</guid><description>&lt;blockquote>
&lt;p>Notation: amounts in &lt;strong>euros (N €)&lt;/strong>, decimals with a point. The dollar sign is not used
(on this site it is a formula delimiter).&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>MLPerf Inference is maintained by MLCommons on a six-monthly cadence. It imposes three things no home-made benchmark has: a &lt;strong>mandatory load generator&lt;/strong> (LoadGen, compiled from a tagged revision and unmodified), model-equivalence rules that bound what can be optimised, and an &lt;strong>audit process&lt;/strong> that reviews up to two submissions per round. The four scenarios (SingleStream, MultiStream, Server, Offline) have a minimum duration of 600 s and different metrics: Server reports the sustained Poisson rate and is the only one with a latency constraint. For the LLM benchmarks that constraint is not a request latency but two simultaneous SLOs at the 99th percentile: TTFT and TPOT, with per-model figures ranging from 2000 ms / 200 ms on Llama 2 70B to 450 ms / 40 ms on its Interactive variant. Quality is required relative to the reference model (99 % or 99.9 %) and the &lt;strong>generation length&lt;/strong> is audited as well (Llama 2 70B: more than 90 % of 294.45 tokens per sample). Three reading rules sum up the real comparability: results are only compared within the same version, division, category and scenario; per-accelerator is not an official MLCommons metric, it is derived; and neither cost nor energy enters the primary metric, to the point that round v5.1 had 2 power submissions against 27 participating organisations.&lt;/p>
&lt;hr>
&lt;h2 id="what-it-is-and-how-it-is-organised">What it is and how it is organised&lt;/h2>
&lt;p>MLPerf Inference is maintained by the &lt;strong>MLCommons Association&lt;/strong> through the MLPerf Inference Working Group, whose participation is limited to MLCommons members and affiliates (&lt;a href="https://mlcommons.org/working-groups/benchmarks/inference/">MLPerf Inference Working Group&lt;/a>). The declared cadence for publishing results is roughly six months.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Round&lt;/th>
&lt;th>Results published&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>v4.0&lt;/td>
&lt;td>27 March 2024&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>v4.1&lt;/td>
&lt;td>28 August 2024&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>v5.0&lt;/td>
&lt;td>2 April 2025&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>v5.1&lt;/td>
&lt;td>9 September 2025&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>v6.0&lt;/td>
&lt;td>1 April 2026&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>v6.1&lt;/td>
&lt;td>in preparation as of this data sheet&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The suite splits into two system categories: &lt;strong>Datacenter&lt;/strong> and &lt;strong>Edge&lt;/strong>, the latter defined in the rules as everything that is not datacenter (&lt;a href="https://github.com/mlcommons/inference_policies/blob/master/inference_rules.adoc">inference_rules.adoc&lt;/a>). A Datacenter system has two requirements that do not apply in Edge:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Mandatory ECC&lt;/strong> on DRAM and HBM, active during all performance and accuracy runs. There is no requirement on SRAM.&lt;/li>
&lt;li>&lt;strong>Mandatory networking&lt;/strong> since round v3.0, with a minimum bandwidth computed from the achieved throughput. For Llama 3.1 405B the minimum ingress is &lt;code>throughput × 20000 × dtype_size&lt;/code> bytes/s; for Llama 2 70B the minimum egress is &lt;code>throughput × 1024 × dtype_size&lt;/code>.&lt;/li>
&lt;/ul>
&lt;p>Edge additionally allows &lt;strong>inferred results&lt;/strong>: a MultiStream derived from SingleStream counts as 8 times the p99 latency; an Offline derived from MultiStream, 8000 divided by the mean latency in milliseconds.&lt;/p>
&lt;h2 id="the-four-scenarios">The four scenarios&lt;/h2>
&lt;p>This is the normative table, transcribed from the rules:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Scenario&lt;/th>
&lt;th>Query generation&lt;/th>
&lt;th>Duration&lt;/th>
&lt;th>Samples/query&lt;/th>
&lt;th>Latency constraint&lt;/th>
&lt;th>Tail latency&lt;/th>
&lt;th>Reported metric&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>SingleStream&lt;/td>
&lt;td>The next query is issued when the SUT completes the previous one&lt;/td>
&lt;td>600 s&lt;/td>
&lt;td>1&lt;/td>
&lt;td>None&lt;/td>
&lt;td>90 %&lt;/td>
&lt;td>p90 latency with early stopping&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Server / Interactive&lt;/td>
&lt;td>LoadGen issues queries following a &lt;strong>Poisson distribution&lt;/strong>&lt;/td>
&lt;td>600 s&lt;/td>
&lt;td>1&lt;/td>
&lt;td>Benchmark-specific&lt;/td>
&lt;td>99 %&lt;/td>
&lt;td>Maximum Poisson parameter supported&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Offline&lt;/td>
&lt;td>All samples are delivered at the start in &lt;strong>a single query&lt;/strong>&lt;/td>
&lt;td>1 query, 600 s&lt;/td>
&lt;td>At least 24,576&lt;/td>
&lt;td>None&lt;/td>
&lt;td>N/A&lt;/td>
&lt;td>Measured throughput&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>MultiStream&lt;/td>
&lt;td>The next query is issued when the SUT completes the previous one&lt;/td>
&lt;td>600 s&lt;/td>
&lt;td>&lt;strong>8&lt;/strong>&lt;/td>
&lt;td>None&lt;/td>
&lt;td>99 %&lt;/td>
&lt;td>p99 query latency with early stopping&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Four operational clarifications that change how each number is interpreted:&lt;/p>
&lt;ul>
&lt;li>In &lt;strong>Server&lt;/strong>, LoadGen does not measure a latency: it runs a binary search over the target rate. For a given value it generates queries at that QPS with Poisson arrivals; if the run fails the latency gate, it lowers the value and repeats. The final published metric is, per the official FAQ, &lt;strong>scheduled samples per second&lt;/strong>, not completed samples nor the target entered by the user.&lt;/li>
&lt;li>In &lt;strong>MultiStream&lt;/strong>, the latency of a query is the &lt;strong>maximum&lt;/strong> of the latencies of its samples, and the 8 samples of each query are contiguous in the order in which they were loaded. The value &lt;code>multi_stream_samples_per_query = 8&lt;/code> is fixed in the official configuration.&lt;/li>
&lt;li>In &lt;strong>Offline&lt;/strong> there is no latency constraint at all. The rules themselves forbid techniques that only work in fixed-length experiments &lt;em>except in the Offline scenario&lt;/em>, which is a normative acknowledgement that an Offline result says nothing about behaviour under an SLO.&lt;/li>
&lt;li>The percentiles in the table are the &lt;strong>lower theoretical bound&lt;/strong> for runs with very many queries. With early stopping, the percentile actually computed is somewhat above the nominal one, as a penalty for processing few queries.&lt;/li>
&lt;/ul>
&lt;p>The mandatory scenarios depend on the benchmark. In Datacenter, the summarisation, question answering, text generation and VLM tasks require &lt;code>(Server | Interactive), Offline&lt;/code>, that is, the submitter chooses between Server and Interactive. Reasoning and recommendation require &lt;code>Server, Offline&lt;/code>. Medical segmentation, node classification, speech-to-text and RAG require only &lt;code>Offline&lt;/code>.&lt;/p>
&lt;h2 id="loadgen-what-it-measures-and-what-it-does-not">LoadGen: what it measures and what it does not&lt;/h2>
&lt;p>LoadGen is the module that generates the load and computes the metrics. Its use is mandatory for all submissions, and it must be compiled from a tagged and approved revision of the repository, unaltered; the README is explicit that local modifications of the C++ library are not accepted.&lt;/p>
&lt;p>Its four declared responsibilities are generating the queries according to the scenario, tracking the latency of each query, validating the accuracy of the results and computing the final metrics. Latency is defined as the time from when LoadGen was scheduled to pass a query to the system under test until it receives the response.&lt;/p>
&lt;p>Equally important is what it declares &lt;strong>out of scope&lt;/strong>: LoadGen does not know the model, does not know the data formats, cannot score accuracy and does not know the scenario constraints of the MLPerf rules. The consequence is written in its own README: being model-agnostic, it cannot enforce the MLPerf requirements, for example the target percentiles and latencies. Those limits are injected from &lt;code>mlperf.conf&lt;/code> and &lt;code>user.conf&lt;/code>. The justification for making it mandatory appears in the founding ISCA 2020 paper: it establishes a clear boundary between the components that belong to the submitter and those that belong to MLPerf, and it measures the performance of the complete system instead of that of a single piece.&lt;/p>
&lt;h3 id="duration-query-count-and-early-stopping">Duration, query count and early stopping&lt;/h3>
&lt;p>The minimum duration is 600,000 ms in all four scenarios. The minimum number of queries comes from a confidence interval table that the rules publish:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Tail percentile&lt;/th>
&lt;th>Confidence&lt;/th>
&lt;th>Margin of error&lt;/th>
&lt;th>Inferences&lt;/th>
&lt;th>Rounded&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>90 %&lt;/td>
&lt;td>99 %&lt;/td>
&lt;td>0.50 %&lt;/td>
&lt;td>23,886&lt;/td>
&lt;td>24,576&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>95 %&lt;/td>
&lt;td>99 %&lt;/td>
&lt;td>0.25 %&lt;/td>
&lt;td>50,425&lt;/td>
&lt;td>57,344&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>97 %&lt;/td>
&lt;td>99 %&lt;/td>
&lt;td>0.15 %&lt;/td>
&lt;td>85,811&lt;/td>
&lt;td>90,112&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>99 %&lt;/td>
&lt;td>99 %&lt;/td>
&lt;td>0.05 %&lt;/td>
&lt;td>262,742&lt;/td>
&lt;td>270,336&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The submission checker applies &lt;code>Server: 270336&lt;/code>, &lt;code>SingleStream: 1024&lt;/code> and &lt;code>MultiStream: 662&lt;/code>. For Offline, the minimum is set per benchmark: 24,576 on Llama 2 70B, 15,000 on Mixtral 8x7B, 13,368 on Llama 3.1 8B, 8,313 on Llama 3.1 405B, 4,388 on DeepSeek-R1, 1,633 on Whisper and 43 on 3D UNet.&lt;/p>
&lt;p>The &lt;strong>early stopping&lt;/strong> mechanism allows runs to be shortened while keeping the statistical guarantee. With tolerance &lt;code>d = 0&lt;/code> and confidence &lt;code>c = 0.99&lt;/code>, the algorithm resolves by binary search the smallest number of queries below the latency threshold that satisfies the criterion, given the number of queries observed above it.&lt;/p>
&lt;p>LoadGen&amp;rsquo;s &lt;strong>modes&lt;/strong> are &lt;code>SubmissionRun&lt;/code> (accuracy followed by performance), &lt;code>AccuracyOnly&lt;/code>, &lt;code>PerformanceOnly&lt;/code> and &lt;code>FindPeakPerformance&lt;/code>. The last, applicable only to Server, takes &lt;code>target_qps&lt;/code> as a lower bound if it passes, estimates the upper bound at double and doubles it until failure, and then does a binary search. In performance mode LoadGen selects queries uniformly at random with replacement from a set of size QSL; in accuracy mode it uses a copy of the validation dataset, each sample exactly once. One accuracy run must be executed for every performance result submitted, and the same code in both modes.&lt;/p>
&lt;p>The &lt;strong>seeds&lt;/strong> are announced four weeks before the deadline and the mandatory generator is Mersenne Twister 19937. They change every round: in v5.1, &lt;code>qsl_rng_seed = 1780908523862526354&lt;/code>; in v5.0 it was &lt;code>6023615788873153749&lt;/code>.&lt;/p>
&lt;h3 id="the-compliance-tests">The compliance tests&lt;/h3>
&lt;p>They are activated by placing an &lt;code>audit.config&lt;/code> file in the working directory, whose parameters &lt;strong>override&lt;/strong> those of &lt;code>mlperf.conf&lt;/code> and &lt;code>user.conf&lt;/code>. Their logs are mandatory in the submission package. The declared purpose is to detect anomalies, not to diagnose their cause.&lt;/p>
&lt;p>For the LLM benchmarks the required test is &lt;strong>TEST06&lt;/strong>, specifically against the end-of-sequence token &lt;em>exploit&lt;/em>. It runs 100 samples and demands three conditions: that the first token reported separately matches the first token of the model&amp;rsquo;s output, that the output ends with zero or a single EOS token, and that the number of tokens reported matches the real length of the generated sequence.&lt;/p>
&lt;h2 id="divisions-categories-and-what-can-be-touched">Divisions, categories and what can be touched&lt;/h2>
&lt;h3 id="the-three-divisions">The three divisions&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Closed&lt;/strong>: requires preprocessing, postprocessing and model equivalent to the reference implementation. It allows calibration for quantisation and &lt;strong>does not allow retraining&lt;/strong>. It is the only one that can use the MLPerf name unqualified.&lt;/li>
&lt;li>&lt;strong>Open&lt;/strong>: allows arbitrary preprocessing, postprocessing and model, including retraining. The accuracy, latency and scenario constraints &lt;strong>do not apply&lt;/strong>: instead, the accuracy obtained and the latency constraints under which the performance was obtained must be reported. The model may have any origin, be quantised in any way and be sparsified in any way. It must use the same validation dataset as the corresponding Closed and use all of it.&lt;/li>
&lt;li>&lt;strong>Network&lt;/strong>: inherits all the Closed requirements, applies only to Datacenter, and separates the LoadGen node from the system under test through a &lt;em>fabric&lt;/em>. The submitter&amp;rsquo;s dispatch library cannot preprocess, postprocess, &lt;strong>batch&lt;/strong>, pad or &lt;strong>cache&lt;/strong>. Bus interconnects are forbidden by name (PCIe, CXL, CCIX, HyperTransport, NVLink, QPI, UPI, ICI) and only Ethernet, IEEE 802.11, InfiniBand and 3GPP are admitted, with a requirement to work chassis to chassis at more than ten metres.&lt;/li>
&lt;/ul>
&lt;h3 id="the-availability-categories">The availability categories&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Category&lt;/th>
&lt;th>Hardware&lt;/th>
&lt;th>Software&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Available in cloud&lt;/td>
&lt;td>Available for cloud rental&lt;/td>
&lt;td>Available&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Available on premise&lt;/td>
&lt;td>Available for purchase&lt;/td>
&lt;td>Available&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Preview&lt;/td>
&lt;td>Must be available for the next submission, or the one after 140 days, whichever is longer&lt;/td>
&lt;td>Available except what is needed for substantially new hardware&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>RDI&lt;/td>
&lt;td>Does not meet the above&lt;/td>
&lt;td>Does not meet the above&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Available demands four cumulative conditions: price available, having been rented or shipped to at least one third party, public evidence of availability and reasonable availability to additional third parties on the submission date. A Preview result obliges a resubmission as Available with equal or better performance, tolerating up to &lt;strong>2 % degradation&lt;/strong> from noise; if there is no resubmission, the Preview result is &lt;strong>marked invalid&lt;/strong>. RDI components cannot be submitted as Available until the cycle after the next one, or 221 days, whichever is longer.&lt;/p>
&lt;h3 id="what-closed-allows-and-forbids">What Closed allows and forbids&lt;/h3>
&lt;p>MLPerf delivers the weights in fp16 or fp32. The central rule is that the submitter may perform purely mathematical and reproducible quantisation, using only the calibration data and the tensors of the delivered model, to any numerical format that reaches the required quality, and that the method must be described publicly at a level that allows it to be reproduced. The test against back doors is elegant: the description of the quantisation method must be &lt;em>much smaller&lt;/em> than the non-zero weights it produces.&lt;/p>
&lt;p>&lt;strong>Allowed&lt;/strong>, among other things: any framework or runtime, arbitrary data layout, varying the matrix multiplication algorithm, mathematically equivalent transformations, polynomial approximations of transcendentals, processing queries out of order within what the scenario admits, replacing dense operations with mathematically equivalent sparse operations, hand-picking different precisions per operation, fusing and unfusing, dynamic batching, and mixtures of experts combining weights with different quantisation.&lt;/p>
&lt;p>&lt;strong>Forbidden&lt;/strong>: replacing or supplementing weights wholesale, discarding non-zero weights, including pruning, caching queries or responses, coalescing identical queries, modifying weights during the timed portion, quantisation algorithms of size comparable to the weights they produce, hardcoding the total number of queries, using knowledge of the LoadGen implementation to predict peaks or troughs in the Server scenario, changing the number of search beams, and incorporating statistics from the performance or accuracy sets.&lt;/p>
&lt;p>Two clarifications that matter to anyone serving LLMs in production: the &lt;strong>KV cache&lt;/strong> is allowed just as in the reference model provided it is not applied &lt;strong>across queries&lt;/strong>; PagedAttention is admitted if the block is reused only within the batch; continuous or dynamic batching is allowed; and KV cache entries are treated as activations for quantisation purposes, and cannot be pruned. &lt;strong>Speculative decoding&lt;/strong> is only admitted in the benchmark and scenario combinations explicitly listed, with the reference head at the same precision, and implementations that artificially manipulate the acceptance rate are forbidden.&lt;/p>
&lt;h2 id="the-slos-almost-nobody-cites-ttft-and-tpot">The SLOs almost nobody cites: TTFT and TPOT&lt;/h2>
&lt;p>This is the point where an MLPerf table is most often misread. In the LLM benchmarks of the Server scenario, the constraint is not a request latency: the rules set &lt;code>target_latency = 0&lt;/code> and activate &lt;code>use_token_latencies = 1&lt;/code>, so that the gate is formed by &lt;strong>two simultaneous metrics&lt;/strong>, both evaluated at the &lt;strong>99th percentile&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>TTFT&lt;/strong> (&lt;em>time to first token&lt;/em>): latency of the first token.&lt;/li>
&lt;li>&lt;strong>TPOT&lt;/strong> (&lt;em>time per output token&lt;/em>): mean interval between all generated tokens.&lt;/li>
&lt;/ul>
&lt;p>The figures are in &lt;code>mlperf.conf&lt;/code> and change per round and per benchmark:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Benchmark (Server scenario)&lt;/th>
&lt;th>TTFT&lt;/th>
&lt;th>TPOT&lt;/th>
&lt;th>Round&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>llama2-70b&lt;/code>&lt;/td>
&lt;td>2000 ms&lt;/td>
&lt;td>200 ms&lt;/td>
&lt;td>v5.0, v5.1, v6.x&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>llama2-70b-interactive&lt;/code>&lt;/td>
&lt;td>&lt;strong>450 ms&lt;/strong>&lt;/td>
&lt;td>&lt;strong>40 ms&lt;/strong>&lt;/td>
&lt;td>v5.1, v6.x&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>mixtral-8x7b&lt;/code>&lt;/td>
&lt;td>2000 ms&lt;/td>
&lt;td>200 ms&lt;/td>
&lt;td>v5.0, v5.1, v6.x&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>llama3_1-405b&lt;/code>&lt;/td>
&lt;td>6000 ms&lt;/td>
&lt;td>175 ms&lt;/td>
&lt;td>v5.0, v5.1, v6.x&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>llama3_1-405b-interactive&lt;/code>&lt;/td>
&lt;td>4500 ms&lt;/td>
&lt;td>80 ms&lt;/td>
&lt;td>v6.x&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>llama3_1-8b&lt;/code>&lt;/td>
&lt;td>2000 ms&lt;/td>
&lt;td>100 ms&lt;/td>
&lt;td>v5.1, v6.x&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>llama3_1-8b-interactive&lt;/code>&lt;/td>
&lt;td>500 ms&lt;/td>
&lt;td>30 ms&lt;/td>
&lt;td>v6.x&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>deepseek-r1&lt;/code>&lt;/td>
&lt;td>2000 ms&lt;/td>
&lt;td>80 ms&lt;/td>
&lt;td>v5.1, v6.x&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>deepseek-r1-interactive&lt;/code>&lt;/td>
&lt;td>1500 ms&lt;/td>
&lt;td>15 ms&lt;/td>
&lt;td>v6.x&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>gpt-oss-120b&lt;/code>&lt;/td>
&lt;td>3000 ms&lt;/td>
&lt;td>80 ms&lt;/td>
&lt;td>v6.x&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>A TPOT of 40 ms is equivalent to 25 tokens per second per user, which is the order of magnitude of a conversational interface; one of 200 ms is equivalent to 5 tokens per second, which is a batch use case dressed up as a service. Two results for the same model under the Server and Interactive labels are not the same measurement. Two documented discrepancies between primary sources remain: the &lt;code>llama2-70b-interactive&lt;/code> variant does not appear in the &lt;code>mlperf.conf&lt;/code> tagged as v5.0 despite having been announced in that round, and in the v6.x cycle the TPOT of &lt;code>gpt-oss-120b-interactive&lt;/code> appears as 15 ms in &lt;code>mlperf.conf&lt;/code> and as 20 ms in the text of the rules.&lt;/p>
&lt;p>For contrast, the constraints of the non-LLM benchmarks in the Server scenario are conventional request latencies: ResNet-50 15 ms, RetinaNet 100 ms, BERT 130 ms, DLRMv2 60 ms, DLRMv3 80 ms, RNN-T 1000 ms, GPT-J and Stable Diffusion XL 20,000 ms.&lt;/p>
&lt;h2 id="quality-the-99--and-the-token-count">Quality: the 99 % and the token count&lt;/h2>
&lt;p>The quality targets are expressed relative to the reference model, never in absolute terms, and each benchmark demands one variant, the other or both. In the current Datacenter suite, 3D UNet, Llama 3.1 8B and Whisper ask for 99 % and 99.9 % of FP32, which generates two distinct results; Llama 2 70B asks only for 99.9 %; Llama 3.1 405B, Mixtral, DeepSeek-R1, GPT-OSS-120B, Qwen3-VL, RGAT and WAN-2.2 ask only for 99 %; and E2E-RAG is the exception at 97 %. In the checker this materialises as separate benchmarks (&lt;code>bert-99&lt;/code> and &lt;code>bert-99.9&lt;/code>), each with its computed numerical threshold.&lt;/p>
&lt;p>On top of that metric there is a second LLM-specific constraint that is cited very rarely: the generation length is audited as well.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Benchmark&lt;/th>
&lt;th>Length constraint&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Llama 2 70B&lt;/td>
&lt;td>tokens per sample &amp;gt; 90 % of 294.45&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Llama 3.1 405B&lt;/td>
&lt;td>tokens per sample between 90 % and 110 % of 684.68&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Mixtral 8x7B&lt;/td>
&lt;td>tokens per sample between 90 % and 110 % of 144.84&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Llama 3.1 8B&lt;/td>
&lt;td>total generated length &amp;gt; 90 % of 8,167,644&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The FAQ settles the obvious shortcut: reducing the maximum output length below the reference is not allowed, and truncating tokens to improve performance or reach the accuracy target &lt;strong>is not permitted&lt;/strong>. The inference parameters are fixed in Closed (&lt;code>max_new_tokens = 1024&lt;/code> on Llama 2 70B, &lt;code>20000&lt;/code> on Llama 3.1 405B and DeepSeek-R1), as is the decoding algorithm: greedy search in the Llama family, Mixtral and DeepSeek-R1; sampling with &lt;code>temperature = 1.0&lt;/code> and &lt;code>top_p = 1.0&lt;/code> in GPT-OSS-120B and Qwen3-VL. Accuracy is reported to &lt;strong>five significant figures&lt;/strong> with round-half-to-even.&lt;/p>
&lt;h2 id="the-current-datacenter-suite">The current Datacenter suite&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Area&lt;/th>
&lt;th>Task&lt;/th>
&lt;th>Model&lt;/th>
&lt;th>Dataset&lt;/th>
&lt;th>QSL&lt;/th>
&lt;th>Required quality&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Vision&lt;/td>
&lt;td>Medical segmentation&lt;/td>
&lt;td>3D UNet&lt;/td>
&lt;td>KiTS 2019&lt;/td>
&lt;td>42&lt;/td>
&lt;td>99 % and 99.9 % of FP32 (DICE 0.86330)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Language&lt;/td>
&lt;td>Summarisation&lt;/td>
&lt;td>Llama 3.1 8B&lt;/td>
&lt;td>CNN DailyMail v3.0.0&lt;/td>
&lt;td>13,368&lt;/td>
&lt;td>99 % and 99.9 % of FP32&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Language&lt;/td>
&lt;td>Question answering&lt;/td>
&lt;td>Llama 2 70B&lt;/td>
&lt;td>OpenOrca&lt;/td>
&lt;td>24,576&lt;/td>
&lt;td>99.9 % of FP32&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Language&lt;/td>
&lt;td>Text generation&lt;/td>
&lt;td>Llama 3.1 405B&lt;/td>
&lt;td>LongBench, Ruler, GovReport&lt;/td>
&lt;td>8,313&lt;/td>
&lt;td>99 % of FP16&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Language&lt;/td>
&lt;td>QA, maths and code&lt;/td>
&lt;td>Mixtral 8x7B&lt;/td>
&lt;td>OpenOrca, GSM8K, MBXP&lt;/td>
&lt;td>15,000&lt;/td>
&lt;td>99 % of FP16&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Language&lt;/td>
&lt;td>Reasoning&lt;/td>
&lt;td>DeepSeek-R1&lt;/td>
&lt;td>&lt;code>mlperf_deepseek_r1&lt;/code>&lt;/td>
&lt;td>4,388&lt;/td>
&lt;td>99 % of FP16 (exact match 81.9132 %)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Language&lt;/td>
&lt;td>QA, maths and code&lt;/td>
&lt;td>GPT-OSS-120B&lt;/td>
&lt;td>AIME25, GPQA Diamond, LiveCodeBench v6&lt;/td>
&lt;td>6,396&lt;/td>
&lt;td>99 % of 83.13 %&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Vision&lt;/td>
&lt;td>Vision-language model&lt;/td>
&lt;td>Qwen3-VL-235B-A22B&lt;/td>
&lt;td>Shopify product catalogue&lt;/td>
&lt;td>48,289&lt;/td>
&lt;td>99 % of BF16 (hierarchical F1 0.7824)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Commerce&lt;/td>
&lt;td>Recommendation&lt;/td>
&lt;td>DLRMv3&lt;/td>
&lt;td>Synthetic Streaming 100B&lt;/td>
&lt;td>34,996&lt;/td>
&lt;td>99.9 % of FP32&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Generative&lt;/td>
&lt;td>Text to video&lt;/td>
&lt;td>WAN-2.2-T2V-A14B&lt;/td>
&lt;td>VBench prompts&lt;/td>
&lt;td>248&lt;/td>
&lt;td>99 % of BF16 (VBench 69.7752)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Graphs&lt;/td>
&lt;td>Node classification&lt;/td>
&lt;td>RGAT&lt;/td>
&lt;td>IGBH&lt;/td>
&lt;td>788,379&lt;/td>
&lt;td>99 % of FP32 (72.86 %)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Audio&lt;/td>
&lt;td>Speech to text&lt;/td>
&lt;td>Whisper&lt;/td>
&lt;td>LibriSpeech&lt;/td>
&lt;td>1,633&lt;/td>
&lt;td>99 % and 99.9 % of FP32 (WER 2.0671 %)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Language&lt;/td>
&lt;td>End-to-end RAG&lt;/td>
&lt;td>E2E-RAG&lt;/td>
&lt;td>FRAMES&lt;/td>
&lt;td>824&lt;/td>
&lt;td>97 % of FP32&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The &lt;strong>E2E-RAG&lt;/strong> benchmark deserves attention from anyone designing a RAG platform, because it fixes the whole pipeline: a FAISS HNSW index with mandatory parameters &lt;code>M = 32&lt;/code>, &lt;code>efConstruction = 200&lt;/code> and &lt;code>efSearch = 100&lt;/code>; e5-base-v2 embeddings; ColBERTv2 reranking; generation and query rewriting with GPT-OSS-120B; evaluation with Llama 3.1 8B as judge; and a maximum of 5 retrieval iterations, mandatory for all submitters. Model loading is not timed; building the vector database is.&lt;/p>
&lt;p>The definition of &lt;em>sample&lt;/em> changes per model and is a frequent source of confusion: in the LLMs it is a sequence, in DLRMv3 it is a request with a user history and &lt;strong>2048 candidates&lt;/strong>, in WAN-2.2 it is a pair of positive and negative prompts, and in PointPainting it is five images and a lidar point cloud.&lt;/p>
&lt;h2 id="how-to-read-a-results-row">How to read a results row&lt;/h2>
&lt;p>The columns of the official table in the Closed division are &lt;em>Submitter, Software, System, Benchmark Results, Processor/Count, Details, Accelerator/Count&lt;/em> and &lt;em>Code&lt;/em>. The Open division adds &lt;em>Model Used&lt;/em> and &lt;em>Notes&lt;/em>. Rows with power measurement add &lt;em>System Power&lt;/em> in Server and Offline, or &lt;em>Energy Per Stream&lt;/em> in the stream scenarios. Any citation must use the identifier in the format &lt;code>major-version.minor-version.entry.benchmark&lt;/code>, of the style &lt;code>5.1-0053&lt;/code>, and carry a footnote with suite, version, division, benchmark, scenario, date and source.&lt;/p>
&lt;h3 id="per-accelerator-is-not-an-official-metric">Per-accelerator is not an official metric&lt;/h3>
&lt;p>&lt;strong>There is no per-accelerator column.&lt;/strong> It is a derived metric, and the MLCommons messaging guidelines are blunt: any comparison based on a different or derived metric, such as power, cost, model size or accuracy, must make the basis of comparison clear in the text and in a footnote, and secondary and derived metrics cannot be presented as official or verified MLPerf metrics. NVIDIA itself states this with the standard formula in its round blogs: per-GPU performance is not a primary MLPerf Inference metric and is computed by dividing the reported throughput by the number of reported accelerators. The same applies to combining results from several benchmarks: MLCommons allows it but &lt;strong>does not endorse it&lt;/strong>, and the composite cannot be presented as an official result.&lt;/p>
&lt;h3 id="the-comparison-rules">The comparison rules&lt;/h3>
&lt;ul>
&lt;li>MLPerf results can only be compared &lt;strong>with compatible MLPerf results&lt;/strong>: same benchmark, same scenario and compatible versions per the normative compatibility table.&lt;/li>
&lt;li>MLPerf results cannot be compared with non-MLPerf results.&lt;/li>
&lt;li>When comparing, any difference in version, division, category, verification status, scenario or number of chips must be clearly identified. When comparing Open with Closed, it must be identified in what sense the Open result would not qualify as Closed.&lt;/li>
&lt;li>Submitters cannot publish results of a version before its official date; non-submitters must wait &lt;strong>two weeks&lt;/strong> from that date.&lt;/li>
&lt;li>The sanctions regime goes as far as barring an infringer from submitting results in the future and marking their results as non-compliant permanently in the database, with a deadline of &lt;strong>three working days&lt;/strong> to withdraw the infringing content.&lt;/li>
&lt;/ul>
&lt;h3 id="how-much-audit-sits-behind-a-row">How much audit sits behind a row&lt;/h3>
&lt;p>In each round up to two submissions are audited: one chosen at random from all of them and zero or one chosen by the review committee. And there is a constraint that conditions the reading of the whole table: only Available submissions in the Closed division are auditable. Preview, RDI and Open fall outside the process. There is an exemption from the random audit if the system is equivalent to another already audited and neither the aggregate performance nor the per-accelerator performance differ by more than 10 % from those of the previous audit. The deadlines are 28 days to select an auditor, 30 for the report after signing the confidentiality agreements and around 90 days for the complete process, with &lt;strong>two days&lt;/strong> of hardware access for the auditor. The rules close with three sentences that define the standard of proof: results that cannot be replicated are not valid results, benchmark detection is not allowed and input-based optimisation is not allowed either.&lt;/p>
&lt;h2 id="the-recent-rounds-in-figures">The recent rounds, in figures&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Round&lt;/th>
&lt;th>Organisations&lt;/th>
&lt;th>Performance results&lt;/th>
&lt;th>Power results&lt;/th>
&lt;th>New benchmarks&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>v4.0&lt;/td>
&lt;td>23&lt;/td>
&lt;td>more than 8,500&lt;/td>
&lt;td>900&lt;/td>
&lt;td>2&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>v4.1&lt;/td>
&lt;td>22&lt;/td>
&lt;td>964&lt;/td>
&lt;td>31&lt;/td>
&lt;td>1&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>v5.0&lt;/td>
&lt;td>23&lt;/td>
&lt;td>17,457&lt;/td>
&lt;td>n/a&lt;/td>
&lt;td>4&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>v5.1&lt;/td>
&lt;td>27&lt;/td>
&lt;td>n/a&lt;/td>
&lt;td>2&lt;/td>
&lt;td>3&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>v6.0&lt;/td>
&lt;td>24&lt;/td>
&lt;td>n/a&lt;/td>
&lt;td>n/a&lt;/td>
&lt;td>5 of 11 Datacenter tests&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>These counts do not form a time series: the counting criteria differ between press releases and none documents the criterion, to the point that v4.0 and v4.1 differ by a factor close to nine.&lt;/p>
&lt;p>The published performance jumps are comparable within each announcement. Between v4.0 and v5.0, twelve months apart, the number of Llama 2 70B submissions multiplied by 2.5, the &lt;strong>median&lt;/strong> score doubled and the best score was &lt;strong>3.3 times faster&lt;/strong>. Between v5.0 and v5.1, in six months, the best systems improved &lt;strong>by up to 50 %&lt;/strong> in some scenarios. Round v6.0 changed axis and its press release talks about scale rather than speed: 30 % more multi-node submissions, 10 % of the systems with more than ten nodes against 2 % in the previous round, and a system larger than 72 nodes and 288 accelerators, quadrupling the previous maximum node count.&lt;/p>
&lt;p>Hardware that debuted in each round: MI300X, TPU v6e, Xeon Granite Rapids and B200 in v4.1; MI325X, Xeon 6980P, GB200 and Jetson AGX Thor in v5.0; MI355X, Intel Arc Pro B60, GB300 and RTX Pro 6000 Blackwell Server Edition in v5.1.&lt;/p>
&lt;h2 id="what-mlperf-inference-does-not-measure">What MLPerf Inference does not measure&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Cost.&lt;/strong> There is no cost or TCO metric, and cost is explicitly classified as a derived metric that cannot be presented as official.&lt;/li>
&lt;li>&lt;strong>Energy, except as an optional extension.&lt;/strong> MLPerf Power is regulated in a separate document, requires measuring at the wall with the power group&amp;rsquo;s workflow and PTDaemon, and forbids any other method. Its real adoption is marginal: 2 power submissions in v5.1 against 27 participating organisations. In addition, MLCommons bounds what that figure means: the measured power is only valid for the benchmark it accompanies, and any other reference to power, such as a TDP setting or a power supply rating, is neither measured nor validated by MLCommons. The messaging rule forbids submitters from publishing per-watt normalised comparisons using any proxy other than the measured power.&lt;/li>
&lt;li>Configurations not optimised by the vendor. The bias is acknowledged in the audit rule itself, which contemplates submissions whose performance is not consistent with the known characteristics of the hardware, or where the committee lacks visibility into how it was achieved, or where hardware and software are not reasonably available to the public.&lt;/li>
&lt;li>&lt;strong>Barrier to entry.&lt;/strong> Only MLCommons members and &lt;em>test partners&lt;/em> can submit results for review. Anyone measuring with the same code without submitting must label every figure as &lt;strong>unverified&lt;/strong> with the corresponding legend.&lt;/li>
&lt;/ul>
&lt;h2 id="usage-flow-for-an-on-premise-platform">Usage flow for an on-premise platform&lt;/h2>
&lt;p>MLPerf Inference is useful in a purchasing decision if it is used for what it is, a test bench with written rules, and not as a ranking.&lt;/p>
&lt;ol>
&lt;li>Filter by division and category before looking at any number: keep Closed and Available. A Preview or RDI result describes hardware that cannot yet be bought, and neither of the two is auditable.&lt;/li>
&lt;li>Choose the scenario that matches the load pattern: Server or Interactive if there are users waiting, Offline if the work is batch. An Offline result does not authorise promising a latency.&lt;/li>
&lt;li>Read the SLO before the throughput: in any LLM benchmark, the number of queries per second only means something alongside its TTFT/TPOT pair. The same machine publishes very different figures under Server and under Interactive.&lt;/li>
&lt;li>Check the quality variant: 99 % and 99.9 % are different results for the same model, and the second usually costs performance.&lt;/li>
&lt;li>Translate to your own configuration carefully: dividing by the number of accelerators is a derived metric, not an official one, and it has to be declared as such.&lt;/li>
&lt;li>Close the gap MLPerf leaves open with your own measurement: cost, energy and behaviour under real traffic. That is exactly the function of the reproducible harness and of the track B bench tools, and the reason no platform decision stands on a public table alone.&lt;/li>
&lt;/ol>
&lt;h2 id="cross-links-from-the-benchmarking-track">Cross-links from the benchmarking track&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-benchmarking-frameworks-metrics-state-of-the-art/">B1/B2 — LLM inference benchmarking: frameworks, metrics and state of the art&lt;/a>: the TTFT, TPOT and goodput metrics outside the MLPerf corset, and which tool measures each one.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-benchmark-tool-catalogue-practical-deep-dive/">B2 — LLM benchmark tool catalogue&lt;/a>: the tools with which you reproduce at home what MLPerf standardises.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/guidellm-validating-slo-under-load-sizing-knee/">B3 — GuideLLM and SLO validation under load&lt;/a>: the practical equivalent of LoadGen&amp;rsquo;s binary search in the Server scenario.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/measurement-bias-reproducibility-llm-benchmarks/">B6 — Measurement bias and reproducibility&lt;/a>: why LoadGen exists, told from the side of what happens when there is none.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-serving-engines-pareto-frontier-comparison/">B8 — Serving engines compared on the Pareto frontier&lt;/a>: the comparison MLPerf does not make, with engines instead of systems.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mlperf-power-energy-efficiency-benchmark/">C4 — MLPerf Power&lt;/a>: the energy extension of this same machinery, with its certified measurement at the wall.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/reproducible-harness-cost-performance-energy-experiment/">The reproducible harness: cost, performance and energy in a single auditable experiment&lt;/a> — how to build at home the auditable equivalent of a submission, measuring on top of that what MLPerf leaves out.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/slo-to-number-of-gpus-sizing-justifying-inference-hardware/">Sizing and justifying the GPU investment&lt;/a> — the step from a throughput figure under an SLO to the number of accelerators and the return.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>MLCommons, &lt;em>MLPerf Inference Working Group&lt;/em> — &lt;a href="https://mlcommons.org/working-groups/benchmarks/inference/">https://mlcommons.org/working-groups/benchmarks/inference/&lt;/a>&lt;/li>
&lt;li>MLCommons, &lt;em>MLPerf Inference Rules (inference_rules.adoc)&lt;/em> — &lt;a href="https://github.com/mlcommons/inference_policies/blob/master/inference_rules.adoc">https://github.com/mlcommons/inference_policies/blob/master/inference_rules.adoc&lt;/a>&lt;/li>
&lt;li>MLCommons, &lt;em>MLPerf Inference Power Measurement (power_measurement.adoc)&lt;/em> — &lt;a href="https://github.com/mlcommons/inference_policies/blob/master/power_measurement.adoc">https://github.com/mlcommons/inference_policies/blob/master/power_measurement.adoc&lt;/a>&lt;/li>
&lt;li>MLCommons, &lt;em>MLPerf Submission Rules (submission_rules.adoc)&lt;/em> — &lt;a href="https://github.com/mlcommons/policies/blob/master/submission_rules.adoc">https://github.com/mlcommons/policies/blob/master/submission_rules.adoc&lt;/a>&lt;/li>
&lt;li>MLCommons, &lt;em>MLPerf Results Messaging Guidelines&lt;/em> — &lt;a href="https://github.com/mlcommons/policies/blob/master/MLPerf_Results_Messaging_Guidelines.adoc">https://github.com/mlcommons/policies/blob/master/MLPerf_Results_Messaging_Guidelines.adoc&lt;/a>&lt;/li>
&lt;li>MLCommons, &lt;em>LoadGen README&lt;/em> — &lt;a href="https://github.com/mlcommons/inference/blob/master/loadgen/README.md">https://github.com/mlcommons/inference/blob/master/loadgen/README.md&lt;/a>&lt;/li>
&lt;li>MLCommons, &lt;em>loadgen/test_settings.h&lt;/em> — &lt;a href="https://github.com/mlcommons/inference/blob/master/loadgen/test_settings.h">https://github.com/mlcommons/inference/blob/master/loadgen/test_settings.h&lt;/a>&lt;/li>
&lt;li>MLCommons, &lt;em>loadgen/mlperf.conf (master)&lt;/em> — &lt;a href="https://github.com/mlcommons/inference/blob/master/loadgen/mlperf.conf">https://github.com/mlcommons/inference/blob/master/loadgen/mlperf.conf&lt;/a>&lt;/li>
&lt;li>MLCommons, &lt;em>loadgen/mlperf.conf (tag v5.1)&lt;/em> — &lt;a href="https://github.com/mlcommons/inference/blob/v5.1/loadgen/mlperf.conf">https://github.com/mlcommons/inference/blob/v5.1/loadgen/mlperf.conf&lt;/a>&lt;/li>
&lt;li>MLCommons, &lt;em>loadgen/mlperf.conf (tag v5.0)&lt;/em> — &lt;a href="https://github.com/mlcommons/inference/blob/v5.0/loadgen/mlperf.conf">https://github.com/mlcommons/inference/blob/v5.0/loadgen/mlperf.conf&lt;/a>&lt;/li>
&lt;li>MLCommons, &lt;em>tools/submission/submission_checker.py (tag v5.0)&lt;/em> — &lt;a href="https://github.com/mlcommons/inference/blob/v5.0/tools/submission/submission_checker.py">https://github.com/mlcommons/inference/blob/v5.0/tools/submission/submission_checker.py&lt;/a>&lt;/li>
&lt;li>MLCommons, &lt;em>compliance/nvidia TEST06 README (tag v5.1)&lt;/em> — &lt;a href="https://github.com/mlcommons/inference/blob/v5.1/compliance/nvidia/TEST06/README.md">https://github.com/mlcommons/inference/blob/v5.1/compliance/nvidia/TEST06/README.md&lt;/a>&lt;/li>
&lt;li>MLCommons, &lt;em>Benchmark results: Inference Datacenter&lt;/em> — &lt;a href="https://mlcommons.org/benchmarks/inference-datacenter/">https://mlcommons.org/benchmarks/inference-datacenter/&lt;/a>&lt;/li>
&lt;li>Reddi et al., &lt;em>MLPerf Inference Benchmark&lt;/em>, ISCA 2020 — &lt;a href="https://arxiv.org/abs/1911.02549">https://arxiv.org/abs/1911.02549&lt;/a>&lt;/li>
&lt;li>MLCommons, &lt;em>MLPerf Inference v5.0 Benchmark Results&lt;/em> (abril 2025) — &lt;a href="https://www.businesswire.com/news/home/20250402313932/en/MLCommons-Releases-New-MLPerf-Inference-v5.0-Benchmark-Results">https://www.businesswire.com/news/home/20250402313932/en/MLCommons-Releases-New-MLPerf-Inference-v5.0-Benchmark-Results&lt;/a>&lt;/li>
&lt;li>MLCommons, &lt;em>MLPerf Inference v5.1 Benchmark Results&lt;/em> (septiembre 2025) — &lt;a href="https://www.globenewswire.com/news-release/2025/09/09/3147136/0/en/MLCommons-Releases-New-MLPerf-Inference-v5-1-Benchmark-Results.html">https://www.globenewswire.com/news-release/2025/09/09/3147136/0/en/MLCommons-Releases-New-MLPerf-Inference-v5-1-Benchmark-Results.html&lt;/a>&lt;/li>
&lt;li>MLCommons, &lt;em>MLPerf Inference v6.0 Benchmark Results&lt;/em> (abril 2026) — &lt;a href="https://www.globenewswire.com/news-release/2026/04/01/3266801/0/en/MLCommons-Releases-New-MLPerf-Inference-v6-0-Benchmark-Results.html">https://www.globenewswire.com/news-release/2026/04/01/3266801/0/en/MLCommons-Releases-New-MLPerf-Inference-v6-0-Benchmark-Results.html&lt;/a>&lt;/li>
&lt;li>MLCommons, &lt;em>MLPerf Inference v4.1 Benchmark Results&lt;/em> (agosto 2024) — &lt;a href="https://www.businesswire.com/news/home/20240828886616/en/New-MLPerf-Inference-v4.1-Benchmark-Results-Highlight-Rapid-Hardware-and-Software-Innovations-in-Generative-AI-Systems">https://www.businesswire.com/news/home/20240828886616/en/New-MLPerf-Inference-v4.1-Benchmark-Results-Highlight-Rapid-Hardware-and-Software-Innovations-in-Generative-AI-Systems&lt;/a>&lt;/li>
&lt;li>NVIDIA Developer Blog, &lt;em>NVIDIA Blackwell delivers massive performance leaps in MLPerf Inference v5.0&lt;/em> — &lt;a href="https://developer.nvidia.com/blog/nvidia-blackwell-delivers-massive-performance-leaps-in-mlperf-inference-v5-0/">https://developer.nvidia.com/blog/nvidia-blackwell-delivers-massive-performance-leaps-in-mlperf-inference-v5-0/&lt;/a>&lt;/li>
&lt;li>NVIDIA Developer Blog, &lt;em>NVIDIA Blackwell Ultra sets new inference records in MLPerf debut&lt;/em> — &lt;a href="https://developer.nvidia.com/blog/nvidia-blackwell-ultra-sets-new-inference-records-in-mlperf-debut/">https://developer.nvidia.com/blog/nvidia-blackwell-ultra-sets-new-inference-records-in-mlperf-debut/&lt;/a>&lt;/li>
&lt;li>MLCommons, &lt;em>MLPerf Automotive&lt;/em> (octubre 2025) — &lt;a href="https://arxiv.org/html/2510.27065v1">https://arxiv.org/html/2510.27065v1&lt;/a>&lt;/li>
&lt;li>Tschand et al., &lt;em>MLPerf Power: Benchmarking the Energy Efficiency of Machine Learning Systems from Microwatts to Megawatts&lt;/em> — &lt;a href="https://arxiv.org/abs/2410.12032">https://arxiv.org/abs/2410.12032&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Cost, performance and energy: the three axes of LLM inference and the identity that ties them together</title><link>https://blog.lo0.es/en/posts/cost-performance-energy-three-axes-llm-inference/</link><pubDate>Sat, 13 Jun 2026 03:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/cost-performance-energy-three-axes-llm-inference/</guid><description>&lt;blockquote>
&lt;p>Notation: amounts in &lt;strong>euros (N €)&lt;/strong>, decimals with a point. Cost and energy references are
European (France, Germany, Spain), because the aim is to defend a sovereign European
architecture; when a source quotes prices in dollars it is flagged as &amp;ldquo;USD&amp;rdquo;. The dollar
symbol is not used (on this site it is a formula delimiter).&lt;/p>
&lt;/blockquote>
&lt;h2 id="what-this-series-is">What this series is&lt;/h2>
&lt;p>This is the first instalment of a series of &lt;strong>28 data articles&lt;/strong> (not opinion pieces)
about the three dimensions that decide whether a sovereign European LLM platform is
sustainable: &lt;strong>cost (FinOps)&lt;/strong>, &lt;strong>performance (benchmarking)&lt;/strong> and &lt;strong>energy&lt;/strong>. The aim
is to have a body of verifiable figures, with their methodology, to support architecture
decisions with numbers rather than intuition. This first article sets the
frame: what each axis measures, &lt;strong>how they link to one another&lt;/strong>, an end-to-end worked
example with European data and the 2026 references that will be used as a base.&lt;/p>
&lt;p>The quantitative thesis, in one sentence: the three axes &lt;strong>are not independent&lt;/strong>. Throughput
(tokens per second) is the common denominator of cost per token and energy per token, and
energy is, on top of that, between &lt;strong>30 % and 50 %&lt;/strong> of the cost itself. Optimising one moves
the other two. And in Europe, where the cluster sits (France, Germany, Spain) changes
both the electricity cost and the carbon per token at once.&lt;/p>
&lt;hr>
&lt;h2 id="the-three-axes-and-their-metrics">The three axes and their metrics&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Axis&lt;/th>
&lt;th>Question&lt;/th>
&lt;th>Key metrics&lt;/th>
&lt;th>Unit&lt;/th>
&lt;th>Tool family&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Cost (FinOps)&lt;/td>
&lt;td>How much does each unit of work cost?&lt;/td>
&lt;td>cost/1M tokens (CPM), cost/request, €/GPU-hour, utilisation&lt;/td>
&lt;td>€/1M tok, €/h&lt;/td>
&lt;td>OpenCost, Kubecost, CloudZero&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Performance&lt;/td>
&lt;td>How much work per second and at what latency?&lt;/td>
&lt;td>TTFT, TPOT/ITL, throughput, goodput, P50/P99&lt;/td>
&lt;td>tok/s, ms&lt;/td>
&lt;td>GuideLLM, AIPerf, LLMPerf, MLPerf Inference&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Energy&lt;/td>
&lt;td>How many watts per unit of work?&lt;/td>
&lt;td>energy/token, average power, PUE, carbon&lt;/td>
&lt;td>J/tok, Wh, kWh, gCO₂&lt;/td>
&lt;td>Zeus, CodeCarbon, MLPerf Power, Kepler&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Each axis has its own in-depth introductory article in this same batch; here only how they
fit together matters. It is worth breaking each one down a little more before crossing them.&lt;/p>
&lt;h3 id="cost-axis-in-detail">Cost axis in detail&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>Definition&lt;/th>
&lt;th>Why it matters&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>CPM (cost/1M tokens)&lt;/td>
&lt;td>cluster cost ÷ tokens produced&lt;/td>
&lt;td>the comparable unit on-prem vs cloud&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cost per request&lt;/td>
&lt;td>cost attributed to one complete request&lt;/td>
&lt;td>billing by real usage&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>€/GPU-hour&lt;/td>
&lt;td>hourly cost of a GPU (amortised or rented)&lt;/td>
&lt;td>the basis of any calculation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Utilisation&lt;/td>
&lt;td>fraction of the GPU actually used&lt;/td>
&lt;td>the lever that moves everything else&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Fixed vs variable cost&lt;/td>
&lt;td>amortised capex vs energy/operations&lt;/td>
&lt;td>defines the break-even point&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="performance-axis-in-detail">Performance axis in detail&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>Definition&lt;/th>
&lt;th>Dominant phase&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>TTFT&lt;/td>
&lt;td>latency to the first token&lt;/td>
&lt;td>prefill&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TPOT / ITL&lt;/td>
&lt;td>time between output tokens&lt;/td>
&lt;td>decode&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Throughput&lt;/td>
&lt;td>tokens or requests per second of the system&lt;/td>
&lt;td>both&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Goodput&lt;/td>
&lt;td>throughput that meets the SLO (not the raw figure)&lt;/td>
&lt;td>the honest number&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>P95 / P99&lt;/td>
&lt;td>latency percentiles (not the mean)&lt;/td>
&lt;td>the real experience&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="energy-axis-in-detail">Energy axis in detail&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>Definition&lt;/th>
&lt;th>Unit&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Average power&lt;/td>
&lt;td>average instantaneous consumption&lt;/td>
&lt;td>W&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Energy per token&lt;/td>
&lt;td>energy per generated token&lt;/td>
&lt;td>J/token&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Total energy&lt;/td>
&lt;td>integral of power over time&lt;/td>
&lt;td>Wh, kWh&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>PUE&lt;/td>
&lt;td>datacenter overhead (cooling, losses)&lt;/td>
&lt;td>ratio ≥ 1&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Carbon&lt;/td>
&lt;td>energy × grid intensity&lt;/td>
&lt;td>gCO₂eq&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="the-identity-that-ties-the-three-axes-together-throughput">The identity that ties the three axes together: throughput&lt;/h2>
&lt;p>The &lt;strong>cost per million tokens (CPM)&lt;/strong> is the cluster&amp;rsquo;s cost divided by what it
produces:&lt;/p>
$$\text{CPM} = \frac{\text{cluster cost per hour}}{\text{throughput (tok/s)} \times 3600 / 10^6}$$
&lt;p>&lt;strong>Energy per token&lt;/strong> has exactly the same form, with power in the
numerator:&lt;/p>
$$\text{energy per token (J)} = \frac{\text{average power (W)}}{\text{throughput (tok/s)}}$$
&lt;p>The two share the &lt;strong>same denominator&lt;/strong>: throughput. Hence the key operational consequence:
&lt;strong>every performance improvement that raises tokens/s lowers both cost per token and
energy per token at the same time&lt;/strong>. These are not three separate optimisations; throughput moves them together.
That is why performance benchmarking (axis 2) is, at bottom, also a FinOps tool and an
energy efficiency tool.&lt;/p>
&lt;p>The CPM↔throughput relationship is direct: the higher the throughput, the lower the CPM. It is the identity on
which the whole cost analysis of the series is built.&lt;/p>
&lt;hr>
&lt;h2 id="worked-example-unit-economics-of-an-8h100-node-in-europe">Worked example: unit economics of an 8×H100 node in Europe&lt;/h2>
&lt;p>To see the identity in action, an end-to-end calculation on an example node
(8×H100 SXM, Llama 3.1 70B FP16 served with vLLM), with European prices. Order-of-magnitude
figures; the methodology is what matters.&lt;/p>
&lt;p>&lt;strong>Starting data:&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Parameter&lt;/th>
&lt;th>Value&lt;/th>
&lt;th>Source&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>European cloud rental (Scaleway, per H100)&lt;/td>
&lt;td>2.73 €/h&lt;/td>
&lt;td>&lt;a href="https://www.scaleway.com/en/h100/">Scaleway&lt;/a>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>→ 8×H100 node on-demand&lt;/td>
&lt;td>~21.84 €/h&lt;/td>
&lt;td>8 × 2.73&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Amortised on-prem cost (estimate)&lt;/td>
&lt;td>~10–13 €/h&lt;/td>
&lt;td>capex 24–36 months + opex&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Throughput&lt;/td>
&lt;td>2,800 tok/s&lt;/td>
&lt;td>reference benchmark&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Power per H100 (TDP)&lt;/td>
&lt;td>up to 700 W&lt;/td>
&lt;td>&lt;a href="https://www.nvidia.com/en-us/data-center/h100/">NVIDIA&lt;/a>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reference PUE&lt;/td>
&lt;td>1.4&lt;/td>
&lt;td>typical of a datacenter&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>Cost per million tokens.&lt;/strong> At 2,800 tok/s, hourly throughput is
2,800 × 3,600 / 10⁶ = 10.08 million tokens/hour. Therefore:&lt;/p>
$$\text{CPM (cloud)} = \frac{21{.}84}{10{.}08} \approx 2{.}17 \text{ €/1M tok}$$
&lt;p>With the amortised on-prem cost (~11 €/h) it drops to &lt;strong>~1.09 €/1M tok&lt;/strong>, the difference between
renting from a provider and amortising your own iron.&lt;/p>
&lt;p>&lt;strong>Energy per token.&lt;/strong> The power of the 8 cards is 8 × 700 = 5,600 W (board only).
Over 2,800 tok/s:&lt;/p>
$$\text{energy per token} = \frac{5,600}{2,800} = 2 \text{ J/token (board)}$$
&lt;p>Over a million tokens that is 2 × 10⁶ J = 0.56 kWh (GPU only). Applying PUE 1.4: ~0.78
kWh per million tokens. At French wholesale prices (~0.058 €/kWh) that is ~0.045 € of
electricity per million tokens; in Germany (~0.091 €/kWh), ~0.071 €, some 57 % more, because of
the grid, not the model.&lt;/p>
&lt;p>&lt;strong>The link.&lt;/strong> If a serving optimisation raised throughput from 2,800 to 4,200 tok/s
(+50 %), the CPM would fall to ~1.45 €/1M tok and energy per token to ~1.33 J, both at
once, through the same denominator. That is the identity, with numbers.&lt;/p>
&lt;hr>
&lt;h2 id="energy-is-inside-the-cost">Energy is &lt;em>inside&lt;/em> the cost&lt;/h2>
&lt;p>The second link: energy is not an axis separate from cost, it is &lt;strong>a large component of
it&lt;/strong>. 2026 reference data:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Figure&lt;/th>
&lt;th>Value&lt;/th>
&lt;th>Source&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Electricity as a share of TCO (5 years)&lt;/td>
&lt;td>30–40 % (&amp;gt;50 % at high density 24/7)&lt;/td>
&lt;td>&lt;a href="https://www.spheron.network/blog/ai-inference-power-electricity-cost-2026/">Spheron&lt;/a>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Day-ahead wholesale France (Mar-2026)&lt;/td>
&lt;td>~58 €/MWh (0.058 €/kWh)&lt;/td>
&lt;td>&lt;a href="https://tradingeconomics.com/france/electricity-price">TradingEconomics&lt;/a>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Day-ahead wholesale Spain&lt;/td>
&lt;td>~54 €/MWh (0.054 €/kWh)&lt;/td>
&lt;td>&lt;a href="https://tradingeconomics.com/spain/electricity-price">TradingEconomics&lt;/a>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Day-ahead wholesale Germany&lt;/td>
&lt;td>~91 €/MWh (0.091 €/kWh)&lt;/td>
&lt;td>&lt;a href="https://www.tradingeconomics.com/germany/electricity-price">TradingEconomics&lt;/a>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Electricity cost/token at 80 % util&lt;/td>
&lt;td>¼ of that at 20 % util&lt;/td>
&lt;td>&lt;a href="https://www.spheron.network/blog/ai-inference-power-electricity-cost-2026/">Spheron&lt;/a>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>A methodological warning: the &lt;strong>wholesale&lt;/strong> price (day-ahead) is not what a company pays; &lt;strong>industrial
retail&lt;/strong> adds grid charges and taxes (roughly ×1.5–2). But the relative
ordering holds: &lt;strong>France and Spain, cheap; Germany, expensive.&lt;/strong>&lt;/p>
&lt;p>The third link, utilisation, closes the triangle: power is consumed even when the
GPU is idle, so spreading the fixed cost (energy included) over more tokens (more
throughput, more occupancy) &lt;strong>lowers all three axes at once&lt;/strong>. The electricity cost per token at
80 % utilisation is a quarter of what it is at 20 %.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 300" role="img" aria-label="The three axes (cost, performance, energy) linked by throughput at the centre; energy is also a component of cost" xmlns="http://www.w3.org/2000/svg">
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.3}.tl{font:600 13px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}.ar{fill:none;stroke:currentColor;stroke-width:1.2;stroke-dasharray:4 3}&lt;/style>
&lt;rect class="bx" x="300" y="120" width="180" height="60" rx="8"/>
&lt;text x="390" y="146" text-anchor="middle" class="tl">THROUGHPUT&lt;/text>
&lt;text x="390" y="164" text-anchor="middle" class="ts">tokens/s · common denominator&lt;/text>
&lt;rect class="bx" x="40" y="40" width="200" height="64" rx="8"/>
&lt;text x="56" y="63" class="tl">Cost (FinOps)&lt;/text>
&lt;text x="56" y="81" class="ts">CPM = cost/h ÷ throughput&lt;/text>
&lt;text x="56" y="97" class="ts">€/1M tok · utilisation&lt;/text>
&lt;rect class="bx" x="540" y="40" width="200" height="64" rx="8"/>
&lt;text x="556" y="63" class="tl">Performance&lt;/text>
&lt;text x="556" y="81" class="ts">TTFT · TPOT · goodput&lt;/text>
&lt;text x="556" y="97" class="ts">raise tok/s -> lower the others&lt;/text>
&lt;rect class="bx" x="290" y="220" width="200" height="60" rx="8"/>
&lt;text x="306" y="243" class="tl">Energy&lt;/text>
&lt;text x="306" y="261" class="ts">J/tok = W ÷ throughput&lt;/text>
&lt;line class="ar" x1="240" y1="92" x2="300" y2="135"/>
&lt;line class="ar" x1="540" y1="92" x2="480" y2="135"/>
&lt;line class="ar" x1="390" y1="180" x2="390" y2="220"/>
&lt;line class="ar" x1="140" y1="104" x2="320" y2="220"/>
&lt;text x="150" y="170" class="ts">energy = 30-50 % of cost (TCO)&lt;/text>
&lt;text x="40" y="295" class="ts">Improving throughput lowers cost/token and energy/token at once; raising utilisation spreads fixed cost over more tokens.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;hr>
&lt;h2 id="sensitivity-analysis-what-moves-the-cost-per-token">Sensitivity analysis: what moves the cost per token&lt;/h2>
&lt;p>Four levers dominate the CPM, and it is worth knowing their elasticity before defending
any figure:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Lever&lt;/th>
&lt;th>Change&lt;/th>
&lt;th>Effect on CPM&lt;/th>
&lt;th>Mechanism&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Throughput&lt;/td>
&lt;td>+50 % tok/s&lt;/td>
&lt;td>−33 % CPM&lt;/td>
&lt;td>direct denominator&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Utilisation&lt;/td>
&lt;td>20 % → 80 %&lt;/td>
&lt;td>up to −75 % on the electricity portion&lt;/td>
&lt;td>spreads fixed cost&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Location (energy)&lt;/td>
&lt;td>Germany → France&lt;/td>
&lt;td>lowers the electricity portion (~37 % less €/kWh)&lt;/td>
&lt;td>wholesale FR &amp;lt; DE&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Amortise vs rent&lt;/td>
&lt;td>21.84 → 11 €/h&lt;/td>
&lt;td>−50 % CPM&lt;/td>
&lt;td>removes the provider&amp;rsquo;s margin&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Quantisation (FP8/INT4)&lt;/td>
&lt;td>+throughput, −VRAM/KV&lt;/td>
&lt;td>lowers CPM and J/token&lt;/td>
&lt;td>same identity&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The reading: the CPM &lt;strong>is not a number, it is a function&lt;/strong> of utilisation, throughput, location
(energy price) and ownership model. Any on-prem vs cloud comparison that does not
fix those assumptions is not comparable, and that is exactly the job of the synthesis article.&lt;/p>
&lt;hr>
&lt;h2 id="simplified-tco-model-on-premise">Simplified TCO model (on-premise)&lt;/h2>
&lt;p>The real on-premise cost is not the GPU alone: it is &lt;strong>amortised capex + opex&lt;/strong>, over a hardware
cycle of 24–36 months. Reference structure of an inference node:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Component&lt;/th>
&lt;th>Type&lt;/th>
&lt;th>Typical weight&lt;/th>
&lt;th>Notes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>GPUs&lt;/td>
&lt;td>capex&lt;/td>
&lt;td>the bulk&lt;/td>
&lt;td>amortise over 24–36 months&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Server, network, BMC&lt;/td>
&lt;td>capex&lt;/td>
&lt;td>medium&lt;/td>
&lt;td>NICs, switching, out-of-band management&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Electricity&lt;/td>
&lt;td>opex&lt;/td>
&lt;td>30–50 % of TCO&lt;/td>
&lt;td>depending on country (FR/ES cheap, DE expensive) and PUE&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cooling&lt;/td>
&lt;td>opex&lt;/td>
&lt;td>inside the PUE&lt;/td>
&lt;td>PUE 1.2–1.6 typical&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Maintenance and support&lt;/td>
&lt;td>opex&lt;/td>
&lt;td>medium&lt;/td>
&lt;td>spares, contracts, RMA&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Staff and operations&lt;/td>
&lt;td>opex&lt;/td>
&lt;td>variable&lt;/td>
&lt;td>the hidden cost that cloud rental includes&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The difference between cloud rental (21.84 €/h in the example) and the real amortised cost
(~10–13 €/h) is this breakdown: the provider charges a margin for absorbing capex,
cooling and operations. The break-even point appears when sustained volume
exceeds &lt;strong>~2M tokens/day&lt;/strong>, with typical amortisation in &lt;strong>6–12 months&lt;/strong>
(&lt;a href="https://www.spheron.network/blog/ai-inference-cost-economics-2026/">Spheron&lt;/a>). Below
that threshold, renting usually wins; above it, amortising. Article A8 develops the
full model and synthesis S3 connects it with sizing.&lt;/p>
&lt;hr>
&lt;h2 id="reference-the-price-of-a-gpu-hour-in-european-sovereign-cloud">Reference: the price of a GPU-hour in European sovereign cloud&lt;/h2>
&lt;p>The other side of the on-prem comparison is the price of renting the GPU. For a
sovereign European platform, what matters is not AWS/GCP/Azure (subject to the &lt;strong>US CLOUD
Act&lt;/strong>), but the European providers:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Provider&lt;/th>
&lt;th>H100 (€/hour)&lt;/th>
&lt;th>Location&lt;/th>
&lt;th>Sovereignty&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Scaleway&lt;/td>
&lt;td>from 2.73&lt;/td>
&lt;td>Paris, Warsaw&lt;/td>
&lt;td>EU jurisdiction, exempt from US CLOUD Act&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OVHcloud&lt;/td>
&lt;td>+40–45 % vs Scaleway&lt;/td>
&lt;td>France, Germany&lt;/td>
&lt;td>EU jurisdiction, exempt from US CLOUD Act&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Hyperscalers (reference)&lt;/td>
&lt;td>3–7 USD&lt;/td>
&lt;td>global&lt;/td>
&lt;td>subject to US CLOUD Act&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The data: Scaleway offers the H100 from &lt;strong>2.73 €/h&lt;/strong> in European datacenters (Paris, Warsaw),
with models and data under EU jurisdiction, immune to extraterritorial regulation; OVHcloud
serves it from France and Germany, ~40–45 % more expensive than Scaleway as of March 2026. Both are
&lt;strong>exempt from the US CLOUD Act&lt;/strong>, the foundational sovereignty requirement for data subject to
GDPR (&lt;a href="https://www.scaleway.com/en/h100/">Scaleway&lt;/a>, &lt;a href="https://deploybase.ai/articles/ovhcloud-gpu-pricing">DeployBase&lt;/a>).&lt;/p>
&lt;p>The implication for the proposal: the comparison is not only cost, it is &lt;strong>cost + sovereignty&lt;/strong>. An
on-prem cluster of your own or a European cloud keep the data under EU jurisdiction; a
US hyperscaler does not, however cheap the GPU-hour comes out. That is the leg that the
cost axis on its own does not capture, and that synthesis S2 quantifies.&lt;/p>
&lt;hr>
&lt;h2 id="energy-and-carbon-reference-france-germany-spain">Energy and carbon reference: France, Germany, Spain&lt;/h2>
&lt;p>Here the choice of European country changes both the electricity cost and the carbon per
token. It is the most important table for a sovereign proposal:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Country&lt;/th>
&lt;th>Wholesale (€/MWh, Mar-2026)&lt;/th>
&lt;th>Grid carbon (gCO₂/kWh)&lt;/th>
&lt;th>Profile&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>France&lt;/td>
&lt;td>~58&lt;/td>
&lt;td>~20–60&lt;/td>
&lt;td>nuclear: cheap and very clean&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Spain&lt;/td>
&lt;td>~54&lt;/td>
&lt;td>~150–170&lt;/td>
&lt;td>renewables + gas: cheap, medium carbon&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Germany&lt;/td>
&lt;td>~91&lt;/td>
&lt;td>~363 (2024 average)&lt;/td>
&lt;td>coal/gas + renewables: expensive and dirty&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Sources: day-ahead wholesale prices (&lt;a href="https://tradingeconomics.com/france/electricity-price">TradingEconomics&lt;/a>),
German carbon ~363 gCO₂/kWh (2024 average, &lt;a href="https://en.wikipedia.org/wiki/Electricity_sector_in_Germany">Statista via Wikipedia&lt;/a>),
nuclear ~17–35 gCO₂e/kWh (&lt;a href="https://www.eea.europa.eu/en/analysis/indicators/greenhouse-gas-emission-intensity-of-1">EEA&lt;/a>),
Spain ~167 gCO₂/kWh (2020). The PUE multiplies all of that energy: at 1.4, for every kWh of
GPU the datacenter spends 1.4 kWh with cooling included.&lt;/p>
&lt;p>The calculation, on the example node (0.78 kWh per million tokens, with PUE): in
France (~40 gCO₂/kWh) it is &lt;strong>~31 gCO₂ per million tokens&lt;/strong>; in Germany (~363
gCO₂/kWh), &lt;strong>~283 gCO₂ per million&lt;/strong>, a factor of &lt;strong>~9×&lt;/strong>. That is, the same load
served in France emits of the order of &lt;strong>9× less carbon per token&lt;/strong> than in Germany, and
costs ~37 % less in electricity, &lt;strong>without touching a single line of the stack&lt;/strong>, purely because of the
country&amp;rsquo;s grid. &lt;strong>Siting the cluster in France
or Spain is at once a cost lever, a carbon lever and a compliance lever.&lt;/strong> Hourly grid
intensity is obtained from ElectricityMaps, the reference source for these
calculations.&lt;/p>
&lt;p>Scale context: global datacenters consumed &lt;strong>~460 TWh in 2025&lt;/strong> (≈1.8 % of the world&amp;rsquo;s
electricity), and the IEA projects passing &lt;strong>800 TWh in 2028&lt;/strong>, which is why energy
efficiency per token stops being a detail and becomes a design constraint,
and why carbon enters the European regulatory equation.&lt;/p>
&lt;hr>
&lt;h2 id="sovereignty-and-regulation-the-axis-that-does-not-appear-in-the-cpm">Sovereignty and regulation: the axis that does not appear in the CPM&lt;/h2>
&lt;p>For a European platform there is a fourth factor that the three numerical axes &lt;strong>do not
capture&lt;/strong>, and that can invalidate the lowest cost figure: jurisdiction. Three
fronts that turn &amp;ldquo;the cheapest GPU-hour&amp;rdquo; into &amp;ldquo;the cheapest GPU-hour &lt;em>that complies&lt;/em>&amp;rdquo;:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Front&lt;/th>
&lt;th>What it requires&lt;/th>
&lt;th>Architectural implication&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>GDPR + US CLOUD Act&lt;/td>
&lt;td>personal data under a jurisdiction accessible to US authorities is exposed&lt;/td>
&lt;td>European cloud (Scaleway/OVHcloud) or on-prem keep the data in the EU&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>EU AI Act&lt;/td>
&lt;td>obligations for high-risk systems (traceability, control, documentation)&lt;/td>
&lt;td>on-prem control makes auditability and compliance easier&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>CSRD / emissions reporting&lt;/td>
&lt;td>reporting the carbon footprint (scope 2)&lt;/td>
&lt;td>a low-carbon grid (France) improves the reported figure&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The key point for the proposal: a US hyperscaler can offer a competitive
GPU-hour, but it &lt;strong>puts the data under the US CLOUD Act&lt;/strong>, which for data subject to GDPR
is a compliance risk, not a saving. That is why this series&amp;rsquo; comparison is not made
in USD against US providers: it is made in euros, against options that keep
&lt;strong>data sovereignty&lt;/strong>, on-prem of your own or European cloud. Cost, energy and
carbon are measurable axes; sovereignty is the constraint that decides &lt;strong>which options even
enter the table&lt;/strong>. This links with &lt;a href="https://blog.lo0.es/en/posts/eu-ai-act-technical-dossier-on-premise-llm-architecture/">the mapping of the EU AI Act onto the on-premise
architecture&lt;/a> and with &lt;a href="https://blog.lo0.es/en/posts/technical-controls-ens-iso-42001-eu-ai-act-cross-mapping/">the technical
controls ENS × ISO 42001 × EU AI Act&lt;/a>.&lt;/p>
&lt;p>The operational conclusion: &lt;strong>France and Spain not only come out cheaper and cleaner than
Germany; being EU jurisdiction, they meet the sovereignty constraint that a US
hyperscaler does not meet at any price.&lt;/strong> Three advantages that reinforce each other, and that the
proposal can defend with the same table.&lt;/p>
&lt;hr>
&lt;h2 id="methodology-how-to-read-and-not-fool-yourself-with-these-figures">Methodology: how to read (and not fool yourself with) these figures&lt;/h2>
&lt;p>Five warnings that apply to all the data in this series:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Board vs node.&lt;/strong> The GPU&amp;rsquo;s power (700 W per H100) is the floor; the complete
node (CPU, NICs, power supplies, fans) consumes more. Always compare the same boundary.&lt;/li>
&lt;li>&lt;strong>Wholesale vs retail.&lt;/strong> The day-ahead price is not what the company pays; industrial
retail adds grid charges and taxes. Use whichever matches your contract.&lt;/li>
&lt;li>&lt;strong>Measured vs estimated.&lt;/strong> A wattmeter figure and an &lt;code>nvidia-smi&lt;/code> estimate are not
the same thing; the difference is quantified in the energy articles.&lt;/li>
&lt;li>&lt;strong>Amortisation assumptions.&lt;/strong> The on-prem cost/hour depends on the hardware cycle (24 vs
36 months) and on the assumed utilisation; changing those assumptions moves the CPM more than
any serving optimisation.&lt;/li>
&lt;li>&lt;strong>Comparability.&lt;/strong> Two CPM or J/token figures are only comparable if model,
precision, context length, load &lt;strong>and country&lt;/strong> match. Most public
comparisons (and in USD) do not fix those assumptions, which is why the series reproduces the ones that matter
with its own harness and European data.&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="the-combined-scorecard">The combined scorecard&lt;/h2>
&lt;p>The destination of the series is a single board where each model/configuration/location is
scored on all the axes at once, to decide on a &lt;strong>Pareto frontier&lt;/strong> rather than on a
single number. An &lt;strong>illustrative&lt;/strong> example (order-of-magnitude figures) on the same
8×H100 node:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Candidate&lt;/th>
&lt;th>CPM (€/1M)&lt;/th>
&lt;th>Goodput (tok/s)&lt;/th>
&lt;th>Energy (J/tok)&lt;/th>
&lt;th>Carbon (gCO₂/1M)&lt;/th>
&lt;th>Sovereignty&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>A · 70B FP16, on-prem France&lt;/td>
&lt;td>~1.09&lt;/td>
&lt;td>~2,800&lt;/td>
&lt;td>~2.0&lt;/td>
&lt;td>low (grid ~40 gCO₂/kWh)&lt;/td>
&lt;td>EU&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>B · 70B FP8, on-prem France&lt;/td>
&lt;td>~0.75&lt;/td>
&lt;td>~4,000&lt;/td>
&lt;td>~1.4&lt;/td>
&lt;td>lower&lt;/td>
&lt;td>EU&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>C · Scaleway on-demand&lt;/td>
&lt;td>~2.17&lt;/td>
&lt;td>~2,800&lt;/td>
&lt;td>n/a&lt;/td>
&lt;td>low (FR)&lt;/td>
&lt;td>EU&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>D · US hyperscaler&lt;/td>
&lt;td>~3.0&lt;/td>
&lt;td>~2,800&lt;/td>
&lt;td>n/a&lt;/td>
&lt;td>depends on region&lt;/td>
&lt;td>not EU&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The reading: &lt;strong>B dominates A&lt;/strong> on the three intrinsic axes thanks to FP8. C (European cloud) is
more expensive per token but shifts capex and operations, while keeping sovereignty. D
(hyperscaler) may be competitive on cost but &lt;strong>loses sovereignty&lt;/strong>, an axis that for
GDPR data is not negotiable. No isolated number decides: the choice comes from &lt;strong>seeing
the whole row&lt;/strong>.&lt;/p>
&lt;p>The trap the board avoids: &lt;strong>optimising a single axis&lt;/strong>. Each cell will be filled in by an
article in the series with measured data.&lt;/p>
&lt;hr>
&lt;h2 id="where-each-axis-is-measured-from-design-to-production">Where each axis is measured: from design to production&lt;/h2>
&lt;p>Each axis is measured at three different moments, with different tools and increasing
precision. Confusing them is a common source of figures that do not add up:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Axis&lt;/th>
&lt;th>Design (estimate)&lt;/th>
&lt;th>Benchmark (controlled)&lt;/th>
&lt;th>Production (continuous)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Cost&lt;/td>
&lt;td>datasheet, GPU-hour price&lt;/td>
&lt;td>CPM measured over synthetic load&lt;/td>
&lt;td>OpenCost/Kubecost per team and token&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Performance&lt;/td>
&lt;td>theoretical throughput (roofline)&lt;/td>
&lt;td>GuideLLM/AIPerf with SLO&lt;/td>
&lt;td>OTel/Langfuse over real traffic&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Energy&lt;/td>
&lt;td>TDP × hours&lt;/td>
&lt;td>Zeus/MLPerf Power on the bench&lt;/td>
&lt;td>Kepler/DCGM per pod in the cluster&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The rule: a design figure serves for sizing, but not for defending a
proposal; for that you need a reproducible benchmark figure, and to operate, a
continuous production one. When someone disputes a number in the proposal, the answer is not
&amp;ldquo;a blog says so&amp;rdquo;, but &amp;ldquo;here is the bench, here is the methodology, reproduce it&amp;rdquo;.&lt;/p>
&lt;hr>
&lt;h2 id="glossary-of-units-and-conversions">Glossary of units and conversions&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Quantity&lt;/th>
&lt;th>Unit&lt;/th>
&lt;th>Conversion / reference&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Energy&lt;/td>
&lt;td>joule (J), watt-hour (Wh)&lt;/td>
&lt;td>1 Wh = 3,600 J; 1 kWh = 3.6 MJ = 3,600,000 J&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Power&lt;/td>
&lt;td>watt (W)&lt;/td>
&lt;td>1 W = 1 J/s; H100 ≈ 700 W TDP&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Energy per token&lt;/td>
&lt;td>J/token&lt;/td>
&lt;td>= average power (W) ÷ throughput (tok/s)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Throughput&lt;/td>
&lt;td>tok/s&lt;/td>
&lt;td>tokens generated per second by the system&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cost per token&lt;/td>
&lt;td>€/1M tok (CPM)&lt;/td>
&lt;td>= cost/hour ÷ (tok/s × 3,600 / 10⁶)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Latency&lt;/td>
&lt;td>ms&lt;/td>
&lt;td>TTFT (first token), TPOT/ITL (between tokens)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>PUE&lt;/td>
&lt;td>ratio (≥1)&lt;/td>
&lt;td>total DC energy ÷ compute energy&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Carbon&lt;/td>
&lt;td>gCO₂eq&lt;/td>
&lt;td>= energy (kWh) × PUE × grid intensity (gCO₂/kWh)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>A useful conversion to keep in your head: at a constant 700 W, an H100 consumes ~0.7 kWh per hour, ~16.8
kWh a day and ~6,130 kWh a year (board only, no PUE). Multiplied by the PUE and your country&amp;rsquo;s
energy price (FR ~0.058, ES ~0.054, DE ~0.091 €/kWh wholesale), that number is the basis
of the electricity portion of the TCO.&lt;/p>
&lt;hr>
&lt;h2 id="how-the-rest-of-the-series-fits-together">How the rest of the series fits together&lt;/h2>
&lt;p>The 28 articles group into four tracks; this is the map:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Track&lt;/th>
&lt;th>What it contributes&lt;/th>
&lt;th>Closes with&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>FinOps (8)&lt;/td>
&lt;td>measuring and allocating cost, TCO model&lt;/td>
&lt;td>cost/token comparable on-prem vs European cloud&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>LLM benchmarking (8)&lt;/td>
&lt;td>measuring performance reproducibly&lt;/td>
&lt;td>Pareto frontier of engines&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Energy (8)&lt;/td>
&lt;td>measuring watts and carbon per token&lt;/td>
&lt;td>energy in the TCO and in European regulation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Synthesis (4)&lt;/td>
&lt;td>crossing the three axes&lt;/td>
&lt;td>scorecard, on-prem vs cloud, sizing, harness&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The three articles that follow this one in the batch introduce each axis in depth:
their frameworks and the state of the art. From there, each article adds a layer of data
to the final scorecard.&lt;/p>
&lt;p>The thread running through all 28 instalments is the one in this article: &lt;strong>cost, performance and energy
are a single problem with three faces, tied together by throughput and by the energy that lives
inside the cost, and in Europe, by the country where the cluster sits&lt;/strong>. Anyone who optimises one
face while ignoring the others will end up with a pretty number and a platform that does not hold up.
The work of the series is to produce the figures, measured, dated, European and reproducible,
that allow a sovereign architecture to be defended before any committee with the only answer
that closes a technical debate: the data and the bench to reproduce it.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-serving-engines-pareto-frontier-comparison/">Comparison of serving engines (vLLM/SGLang/TRT-LLM/Dynamo)&lt;/a> — the synthesis of the performance axis: which engine wins at each point of the goodput-latency frontier with measured data.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-quality-benchmarks-contamination-trap-oss-tools/">LLM quality benchmarks: MMLU, MT-Bench, HELM and the leaderboards that matter&lt;/a> — the quality dimension that complements performance: how public leaderboards let you calibrate the right model for each SLO.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-energy-leaderboards-wh-per-token/">LLM energy efficiency leaderboards&lt;/a> — the synthesis of the energy axis: the J/token rankings and how to read them without being fooled by the benchmark&amp;rsquo;s setup.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/cloud-gpu-on-demand-reserved-spot-neoclouds-2026-prices/">Cloud GPU: comparison of prices, commitment and sovereign neoclouds&lt;/a> — the cost axis piece for cloud or hybrid architectures: on-demand, spot and reserved prices from European providers in 2026.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/full-tco-on-premise-gpu-cluster-capex-gpu-hour-break-even/">TCO of the on-premise GPU cluster: amortisation, energy and infrastructure&lt;/a> — the cost axis piece for your own architectures: the real €/GPU-hour with CAPEX, energy and operations broken down.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/measurement-bias-reproducibility-llm-benchmarks/">Measurement bias and reproducibility in LLM benchmarks&lt;/a> — the methodological traps that make two benchmarks of the same engine give incompatible results: the basis for trusting this series&amp;rsquo; data.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/watt-to-carbon-pue-grid-intensity-gco2-per-token/">From the watt to carbon: PUE, grid intensity and the real cost of a token&lt;/a> — how to convert the GPU&amp;rsquo;s watts into gCO₂eq and into electricity euros, closing the loop between the energy axis and the cost axis.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/levers-fewer-watts-per-token-llm-inference/">Energy efficiency levers in LLM inference&lt;/a> — the optimisations that move all three axes at once: quantisation, batching and engine as levers that reduce J/token, €/token and improve goodput simultaneously.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/reproducible-harness-cost-performance-energy-experiment/">The reproducible harness for measuring cost, performance and energy&lt;/a> — the test bench that makes this article&amp;rsquo;s methodology concrete: how to produce the data for the three axes with a single reproducible experiment.&lt;/li>
&lt;/ul>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>Scaleway · H100 GPU instance (€/h price, EU sovereignty) — &lt;a href="https://www.scaleway.com/en/h100/">https://www.scaleway.com/en/h100/&lt;/a>&lt;/li>
&lt;li>DeployBase · OVHcloud GPU Pricing (European sovereignty) — &lt;a href="https://deploybase.ai/articles/ovhcloud-gpu-pricing">https://deploybase.ai/articles/ovhcloud-gpu-pricing&lt;/a>&lt;/li>
&lt;li>TradingEconomics · electricity price France / Germany / Spain — &lt;a href="https://tradingeconomics.com/france/electricity-price">https://tradingeconomics.com/france/electricity-price&lt;/a>&lt;/li>
&lt;li>EEA · emission intensity of electricity in Europe — &lt;a href="https://www.eea.europa.eu/en/analysis/indicators/greenhouse-gas-emission-intensity-of-1">https://www.eea.europa.eu/en/analysis/indicators/greenhouse-gas-emission-intensity-of-1&lt;/a>&lt;/li>
&lt;li>Electricity sector in Germany (carbon ~363 gCO₂/kWh) — &lt;a href="https://en.wikipedia.org/wiki/Electricity_sector_in_Germany">https://en.wikipedia.org/wiki/Electricity_sector_in_Germany&lt;/a>&lt;/li>
&lt;li>Spheron · AI Inference Power &amp;amp; Cost Economics (2026) — &lt;a href="https://www.spheron.network/blog/ai-inference-cost-economics-2026/">https://www.spheron.network/blog/ai-inference-cost-economics-2026/&lt;/a>&lt;/li>
&lt;li>NVIDIA · H100 (TDP/specs) — &lt;a href="https://www.nvidia.com/en-us/data-center/h100/">https://www.nvidia.com/en-us/data-center/h100/&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Serving reasoning models: the invisible scratchpad that decides your latency and your bill</title><link>https://blog.lo0.es/en/posts/serving-reasoning-models-invisible-scratchpad-latency-bill/</link><pubDate>Fri, 12 Jun 2026 05:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/serving-reasoning-models-invisible-scratchpad-latency-bill/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>A reasoning model generates, before the answer you see, a &lt;strong>thinking block&lt;/strong>, hundreds or thousands of tokens enclosed between &lt;code>&amp;lt;think&amp;gt;&lt;/code> and &lt;code>&amp;lt;/think&amp;gt;&lt;/code>, that the user does not read but that &lt;strong>consumes the same compute, the same VRAM and the same bill&lt;/strong> as any other token. Serving them in production is not &amp;ldquo;serving a bigger model&amp;rdquo;: it is serving a model whose cost per request is &lt;strong>variable and, by default, uncontrolled&lt;/strong>. Three levers change the equation: choosing well between &lt;strong>think / non-think mode&lt;/strong> (the hybrid models of 2026 allow it within the same weights), setting a &lt;strong>reasoning budget&lt;/strong> (&lt;code>thinking_token_budget&lt;/code> in vLLM, s1-style &lt;em>budget forcing&lt;/em>) and &lt;strong>measuring the impact on the KV cache&lt;/strong> (a 30k-token reasoning chain can eat ~9 GB of KV in FP16). Managed well, you gain accuracy where it matters; managed badly, you pay up to &lt;strong>113× more energy&lt;/strong> for an answer that did not need that much thinking.&lt;/p>
&lt;hr>
&lt;h2 id="the-analogy">The analogy&lt;/h2>
&lt;p>Picture a candidate sitting a competitive exam. Before writing the good answer on the official sheet, they fill three sheets of scratch paper: they try approaches, correct themselves, discard. Those scratch sheets are not handed in, the board only reads the final answer, but the candidate has spent ink, time and half an hour of the exam on them.&lt;/p>
&lt;p>A reasoning model does exactly that. The &lt;code>&amp;lt;think&amp;gt;…&amp;lt;/think&amp;gt;&lt;/code> block is the scratch paper: it reasons out loud, contradicts itself, backtracks, and finally emits the &amp;ldquo;clean&amp;rdquo; answer. The production problem is that &lt;strong>you pay for the scratch paper too&lt;/strong>: every reasoning token takes a slot in the batch, grows the KV cache and adds to the bill just like an output token. And, like any nervous candidate, the model tends to &lt;strong>overthink&lt;/strong>: it writes five sheets when one would have done.&lt;/p>
&lt;p>The operational question in this post is not &amp;ldquo;does it reason well?&amp;rdquo;, but &amp;ldquo;how much scratch paper do I let it write, and when do I take the pen away?&amp;rdquo;.&lt;/p>
&lt;hr>
&lt;h2 id="what-changes-when-the-model-thinks">What changes when the model &amp;ldquo;thinks&amp;rdquo;&lt;/h2>
&lt;p>In a normal model, a request is: prompt → output tokens. In a reasoning model the sequence has two stretches:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Reasoning block&lt;/strong> (&lt;code>&amp;lt;think&amp;gt; … &amp;lt;/think&amp;gt;&lt;/code>): the chain of thought (CoT). Long, variable, normally hidden from the end user.&lt;/li>
&lt;li>&lt;strong>Final answer&lt;/strong>: what the user sees.&lt;/li>
&lt;/ol>
&lt;p>vLLM models this explicitly. When started with a &lt;em>reasoning parser&lt;/em>, the output carries a &lt;code>reasoning_content&lt;/code> field separate from &lt;code>content&lt;/code>:&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 deepseek-ai/DeepSeek-R1-Distill-Qwen-32B &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --reasoning-parser deepseek_r1
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The parser detects the thinking delimiters and separates both stretches in the API response (&lt;a href="https://docs.vllm.ai/en/stable/features/reasoning_outputs/">vLLM · Reasoning Outputs&lt;/a>). This matters for serving for one practical reason: if your gateway or your UI does not separate &lt;code>reasoning_content&lt;/code> from &lt;code>content&lt;/code>, you will end up showing the scratch paper, or, worse, recording it in logs and traces without meaning to.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 210" role="img" aria-label="Anatomy of a request to a reasoning model: prompt, thinking block with budget, final answer" xmlns="http://www.w3.org/2000/svg">
&lt;defs>&lt;marker id="rzm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.3}.dsh{fill:none;stroke:currentColor;stroke-width:1.3;stroke-dasharray:5 3}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}.ar{fill:none;stroke:currentColor;stroke-width:1.3;marker-end:url(#rzm)}&lt;/style>
&lt;text x="20" y="28" class="tl">Request&lt;/text>
&lt;rect class="bx" x="20" y="38" width="120" height="50" rx="6"/>
&lt;text x="32" y="60" class="ts">prompt + system&lt;/text>
&lt;text x="32" y="76" class="ts">(visible · billable)&lt;/text>
&lt;path class="ar" d="M140,63 L180,63"/>
&lt;text x="190" y="28" class="tl">Reasoning block &amp;lt;think&amp;gt; … &amp;lt;/think&amp;gt;&lt;/text>
&lt;rect class="dsh" x="190" y="38" width="360" height="80" rx="6"/>
&lt;text x="204" y="60" class="ts">hundreds–thousands of tokens HIDDEN from the user&lt;/text>
&lt;text x="204" y="78" class="ts">grow the KV cache · add to the bill as output&lt;/text>
&lt;text x="204" y="96" class="ts">cap: thinking_token_budget → forces &amp;lt;/think&amp;gt;&lt;/text>
&lt;path class="ar" d="M550,78 L590,78"/>
&lt;text x="600" y="28" class="tl">Answer&lt;/text>
&lt;rect class="bx" x="600" y="38" width="150" height="80" rx="6"/>
&lt;text x="612" y="64" class="ts">final content&lt;/text>
&lt;text x="612" y="82" class="ts">(all the user&lt;/text>
&lt;text x="612" y="98" class="ts">ever sees)&lt;/text>
&lt;text x="20" y="150" class="tl">Cost per request = prompt + reasoning + answer&lt;/text>
&lt;text x="20" y="172" class="ts">Reasoning is invisible in the UI but NOT on the GPU or in the bill. It is the variable that unbalances your cost per request.&lt;/text>
&lt;text x="20" y="192" class="ts">Operational rule: separate reasoning_content from content at the gateway, and set a thinking budget per route.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;hr>
&lt;h2 id="the-2026-landscape-from-the-model-that-always-reasons-to-the-switchable-hybrid">The 2026 landscape: from the model that &amp;ldquo;always reasons&amp;rdquo; to the switchable hybrid&lt;/h2>
&lt;p>The first generation (o1, DeepSeek-R1, QwQ-32B) were models that always reasoned. In 2026 the dominant pattern is the hybrid: a single set of weights that switches between &lt;em>think&lt;/em> and &lt;em>non-think&lt;/em>.&lt;/p>
&lt;ul>
&lt;li>DeepSeek-V3.1 introduced a hybrid reasoning architecture in a single model: &lt;code>DeepSeek-V3.1-Think&lt;/code> for complex problems and a faster direct mode for the trivial, switchable by the user (&lt;a href="https://www.infoq.com/news/2025/09/deepseek-v31-hybrid/">InfoQ&lt;/a>). Important for serving: reasoning is off by default and is turned on with &lt;code>thinking=True&lt;/code> in &lt;code>chat_template_kwargs&lt;/code>.&lt;/li>
&lt;li>Qwen3 brings &lt;em>Hybrid Thinking Modes&lt;/em> and an explicit Thinking Budget to bound the reasoning compute; in its series thinking is on by default (like QwQ-32B).&lt;/li>
&lt;li>gpt-oss shows a different reasoning structure from the R1/Qwen family, which in practice means that &lt;strong>the parser and the delimiters are not universal&lt;/strong>: each family has its own.&lt;/li>
&lt;/ul>
&lt;p>The architectural consequence is clear: in 2026 you no longer decide &amp;ldquo;a model that reasons&amp;rdquo; vs &amp;ldquo;one that does not&amp;rdquo;, but &lt;strong>when you let the same model reason&lt;/strong>. That turns reasoning into a &lt;em>routing&lt;/em> and budgeting decision, not a model-catalogue one. It connects directly with &lt;a href="https://blog.lo0.es/en/posts/llm-inference-router-l7-switchboard/">the L7 inference router&lt;/a>: the &amp;ldquo;cheap&amp;rdquo; route turns thinking off; the &amp;ldquo;expensive&amp;rdquo; one turns it on with a cap.&lt;/p>
&lt;hr>
&lt;h2 id="the-economics-why-the-scratch-paper-drives-up-the-bill">The economics: why the scratch paper drives up the bill&lt;/h2>
&lt;p>Here is the heart of the problem. Reasoning tokens are invisible to the user but real in compute and in billing. Providers charge them as output tokens; API providers bill those &lt;em>reasoning tokens&lt;/em> at the output price, not for free (&lt;a href="https://medium.com/@Elongated_musk/test-time-compute-quietly-changed-the-economics-of-inference-9d0b8d77641c">Test-Time Compute Quietly Changed the Economics of Inference&lt;/a>). On-premise there is no external invoice, but there is a cost: they occupy batch slots, KV VRAM and watts.&lt;/p>
&lt;p>The billable cost (or the equivalent cost in GPU·hours) of a request is:&lt;/p>
$$\text{compute tokens} = \text{prompt} + \underbrace{\text{reasoning tokens}}_{\text{hidden, variable}} + \text{visible answer}$$
&lt;p>The middle term is the one that runs out of control. On hard tasks the reasoning/visible-answer ratio can be enormous, and empirical analysis has measured up to 113× the energy cost against standard inference on certain coding tasks (&lt;a href="https://towardsdatascience.com/inference-scaling-test-time-compute-why-reasoning-models-raise-your-compute-bill/">Towards Data Science · Inference Scaling&lt;/a>).&lt;/p>
&lt;p>And it does not always buy accuracy. The overthinking phenomenon is documented: more thinking does not guarantee a better answer, and beyond a certain point it makes things worse. There is work showing that &lt;strong>shorter chains are up to 34.5 % more accurate&lt;/strong> than the longest one sampled for the same question (&lt;a href="https://arxiv.org/pdf/2505.17813">Don&amp;rsquo;t Overthink it&lt;/a>), plus studies on the &amp;ldquo;mirage&amp;rdquo; of test-time scaling (&lt;a href="https://arxiv.org/pdf/2506.04210">Mirage of Test-Time Scaling&lt;/a>) and on when &lt;em>more thinking hurts&lt;/em> (&lt;a href="https://arxiv.org/html/2604.10739v1">When More Thinking Hurts&lt;/a>). The first large-scale study of test-time scaling, more than &lt;strong>30 billion tokens&lt;/strong> generated with 8 open-source LLMs from 7B to 235B across 4 reasoning datasets, systematises where it scales and where it does not (&lt;a href="https://arxiv.org/abs/2512.02008">The Art of Scaling Test-Time Compute&lt;/a>).&lt;/p>
&lt;p>The operational reading: &lt;strong>reasoning is a cost you have to budget for, not a &amp;ldquo;more quality&amp;rdquo; switch you leave on full all the time.&lt;/strong>&lt;/p>
&lt;hr>
&lt;h2 id="controlling-the-thinking-budget">Controlling the thinking budget&lt;/h2>
&lt;p>Three mechanisms, from coarsest to finest.&lt;/p>
&lt;h3 id="1-switching-think--non-think">1. Switching think / non-think&lt;/h3>
&lt;p>The cheapest thing is not to reason when it is not needed. On hybrid models, turn thinking off for classification, extraction, formatting, trivial answers:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># DeepSeek-V3.1: reasoning OFF by default; turned on explicitly&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">chat_template_kwargs&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;thinking&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">True&lt;/span>&lt;span class="p">}&lt;/span> &lt;span class="c1"># only on the &amp;#34;hard&amp;#34; route&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This is 80 % of the saving for 20 % of the effort: route by difficulty and reserve thinking for what actually pays for it.&lt;/p>
&lt;h3 id="2-setting-a-cap-thinking_token_budget">2. Setting a cap: &lt;code>thinking_token_budget&lt;/code>&lt;/h3>
&lt;p>vLLM ships a &lt;em>sampling parameter&lt;/em> &lt;code>thinking_token_budget&lt;/code> that counts reasoning tokens and, on reaching the limit, forces the block to close (&lt;code>reasoning_end_str&lt;/code>, typically &lt;code>&amp;lt;/think&amp;gt;&lt;/code>), making the model answer straight away. If it is not specified, there is no limit other than &lt;code>max_tokens&lt;/code> (&lt;a href="https://github.com/vllm-project/vllm/pull/37112">PR #37112&lt;/a>, &lt;a href="https://shuyo.wordpress.com/2026/03/26/adding-reasoning-budget-to-vllm-reasoning-token-limit/">implementation note&lt;/a>). Underneath, a &lt;code>ReasoningBudgetLogitsProcessor&lt;/code> injects something like &lt;em>&amp;ldquo;Let me stop thinking and answer now.&lt;code>&amp;lt;/think&amp;gt;&lt;/code>&amp;rdquo;&lt;/em> when the cap is reached.&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"># Define the delimiters of the reasoning block&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve Qwen/Qwen3-32B &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --reasoning-parser qwen3 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --reasoning-config &lt;span class="s1">&amp;#39;{&amp;#34;reasoning_start_str&amp;#34;:&amp;#34;&amp;lt;think&amp;gt;&amp;#34;,&amp;#34;reasoning_end_str&amp;#34;:&amp;#34;&amp;lt;/think&amp;gt;&amp;#34;}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// per request: cut the scratch paper off at 1024 tokens
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;messages&amp;#34;&lt;/span>&lt;span class="p">:[&lt;/span>&lt;span class="err">...&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="nt">&amp;#34;thinking_token_budget&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">1024&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="3-budget-forcing-state-of-the-art">3. Budget forcing (state of the art)&lt;/h3>
&lt;p>The technique that popularised fine-grained control is &lt;strong>budget forcing&lt;/strong>, from the s1 paper: to spend &lt;em>less&lt;/em>, thinking is terminated by forcing the end delimiter; to spend &lt;em>more&lt;/em>, the end is suppressed and the word &amp;ldquo;Wait&amp;rdquo; is appended, which pushes the model to review and often correct its own reasoning. With this, &lt;code>s1-32B&lt;/code> (a Qwen2.5-32B-Instruct fine-tuned on just 1,000 examples) &lt;strong>beat o1-preview by as much as 27 %&lt;/strong> on AIME24/MATH (&lt;a href="https://arxiv.org/abs/2501.19393">s1: Simple Test-Time Scaling&lt;/a>). The serving lesson: the budget is not only a cost cap, it is a &lt;strong>bidirectional quality lever&lt;/strong>.&lt;/p>
$$\text{accuracy} \approx f(\text{reasoning tokens}) \quad\text{with a maximum, not monotonic}$$
&lt;p>The curve rises, saturates and on many tasks falls. Your job is to operate it near the knee, not at the extreme.&lt;/p>
&lt;hr>
&lt;h2 id="the-impact-almost-nobody-measures-the-kv-cache">The impact almost nobody measures: the KV cache&lt;/h2>
&lt;p>This is where reasoning really bites serving. Every token of scratch paper &lt;strong>is one more token in the KV cache&lt;/strong>. A long reasoning chain inflates the working memory just like a long input context.&lt;/p>
&lt;p>The KV size grows linearly with the sequence (reasoning included):&lt;/p>
$$\text{KV bytes} = 2 \times L \times h_{kv} \times d_{head} \times s \times b$$
&lt;p>where \(L\) is layers, \(h_{kv}\) KV heads (few, with GQA), \(d_{head}\) the per-head dimension, \(s\) the sequence length and \(b\) the bytes per element. The term reasoning blows up is \(s\): a &amp;ldquo;normal&amp;rdquo; 500-token answer and one with 8,000 tokens of thinking do not take up the same space, not by a long way.&lt;/p>
&lt;p>The real numbers are frightening: for a distilled reasoning model with standard attention such as &lt;code>DeepSeek-R1-Distill-Llama-70B&lt;/code>, &lt;strong>a single 30,000-token reasoning chain consumes ~9 GB of KV in FP16&lt;/strong>, which quantising the KV to FP8 reduces to ~4.5 GB, one of the highest-impact optimisations for chain-of-thought workloads (&lt;a href="https://www.spheron.network/blog/kv-cache-optimization-guide/">Spheron · KV Cache Optimization&lt;/a>). Multiply that by concurrency and you understand why a cluster that served 200 simultaneous requests of a &amp;ldquo;normal&amp;rdquo; model drops to 40 with the same model reasoning.&lt;/p>
&lt;p>Two state-of-the-art nuances worth knowing:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Prefix caching is not free here.&lt;/strong> The empirical study of reasoning-model serving finds that &lt;em>prefix caching&lt;/em> clearly improves speed on models of 14B or more, but hurts on 7B models (cache management costs more than it saves) (&lt;a href="https://arxiv.org/pdf/2510.18672">Reasoning Language Model Inference Serving Unveiled&lt;/a>). And since the bulk of the reasoning cost sits in the decode (generating tokens one by one), prefix caching, which only speeds up the prefill, barely touches the expensive phase. It connects with &lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">prefix cache: engineering the hit rate&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Reasoning-specific KV compression.&lt;/strong> Techniques such as R-KV have appeared, compressing the KV by exploiting the redundancy typical of chains of thought (the model repeats itself and rambles), recovering memory without touching quality (&lt;a href="https://arxiv.org/pdf/2505.24133">R-KV&lt;/a>).&lt;/li>
&lt;/ul>
&lt;p>The most profitable and available lever today is still &lt;strong>KV in FP8&lt;/strong> (see &lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8 end to end&lt;/a>) combined with a reasoning budget that avoids 30k-token tails in the first place.&lt;/p>
&lt;hr>
&lt;h2 id="reasoning--structured-output-order-matters">Reasoning + structured output: order matters&lt;/h2>
&lt;p>If you serve &lt;em>structured output&lt;/em> (JSON with a schema) and reasoning at the same time, there is a subtle trap: the grammar must not be applied during the thinking block. The model needs free text to reason; forcing the schema inside the &lt;code>&amp;lt;think&amp;gt;&lt;/code> breaks the reasoning. vLLM solves it by having the structured-output engine (xgrammar) use the reasoner&amp;rsquo;s &lt;code>end_token_id&lt;/code> to skip the constraint while the block lasts and apply it only to the final answer (&lt;a href="https://docs.vllm.ai/en/stable/features/reasoning_outputs/">vLLM · Reasoning Outputs&lt;/a>). If you build this by hand on another engine, remember: &lt;strong>first let it think, then impose the form&lt;/strong>. It links with &lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">structured output&lt;/a>.&lt;/p>
&lt;hr>
&lt;h2 id="reference-architecture">Reference architecture&lt;/h2>
&lt;p>On the blog&amp;rsquo;s example cluster, a node of 4×H100 SXM (80 GB, NVLink), a sensible topology for serving reasoning without wrecking the SLO:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>A cheap difficulty classifier&lt;/strong> up front (a small model or rules) that picks the route. Most of the traffic does not need to think.&lt;/li>
&lt;li>&lt;strong>Fast route (non-think):&lt;/strong> the same hybrid model with reasoning off. Low TTFT, predictable cost.&lt;/li>
&lt;li>&lt;strong>Reasoning route (think):&lt;/strong> reasoning on &lt;strong>with &lt;code>thinking_token_budget&lt;/code> by default&lt;/strong> (say 1–2k tokens) and the option to raise it per request for the genuinely hard cases.&lt;/li>
&lt;li>KV in FP8 in the engine on the reasoning route, plus per-request reasoning-length metrics exported to your observability (&lt;a href="https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/">instrumenting vLLM with OTel&lt;/a>).&lt;/li>
&lt;/ol>
&lt;p>To prototype this routing outside the cluster, an RTX 5090 (Blackwell, 32 GB) serves a 7–14B reasoning model and lets you validate parsers, budgets and the &lt;code>reasoning_content&lt;/code>/&lt;code>content&lt;/code> separation before touching the H100s. Do not expect to serve a 32B in &lt;em>think&lt;/em> mode at high concurrency on a consumer card: the reasoning KV eats the 32 GB in no time.&lt;/p>
&lt;p>Sizing starts, as always, from an SLO; the difference is that now the &amp;ldquo;answer size&amp;rdquo; in your &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">capacity planning&lt;/a> has to count the scratch paper, not just the visible answer.&lt;/p>
&lt;hr>
&lt;h2 id="operational-pitfalls-and-honest-scepticism">Operational pitfalls (and honest scepticism)&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>Leaking &lt;code>reasoning_content&lt;/code>.&lt;/strong> If the gateway does not separate it, you end up showing or logging the scratch paper, which may contain wrong attempts, sensitive data or language you do not want in your UI. Always separate it at L7.&lt;/li>
&lt;li>&lt;strong>Serving reasoning &amp;ldquo;just in case&amp;rdquo;.&lt;/strong> Overthinking is real and sometimes lowers accuracy. Do not turn &lt;em>think&lt;/em> on by default: turn it on per route, with a budget.&lt;/li>
&lt;li>&lt;strong>Forgetting the KV.&lt;/strong> The most common mistake: sizing concurrency from the length of the visible answer. The KV is dictated by the total sequence, reasoning included. Measure p50/p95 &lt;code>reasoning tokens&lt;/code> per endpoint.&lt;/li>
&lt;li>&lt;strong>Prefix caching as a silver bullet.&lt;/strong> On small models it can make things worse, and in any case it does not touch the decode, which is where the reasoning cost goes.&lt;/li>
&lt;li>&lt;strong>Assuming universal delimiters.&lt;/strong> R1, Qwen3 and gpt-oss do not share a reasoning structure. Use the right &lt;code>--reasoning-parser&lt;/code> per family and test &lt;em>streaming&lt;/em> (the parser has to separate correctly token by token).&lt;/li>
&lt;li>&lt;strong>Believing more compute = more intelligence.&lt;/strong> The state of the art (s1, short-m@k, &amp;ldquo;When More Thinking Hurts&amp;rdquo;) points the other way in many cases: there is a knee, and operating beyond it is burning watts. Test-time compute is a tool with diminishing, and sometimes negative, returns, not a linear lever.&lt;/li>
&lt;/ol>
&lt;p>A note of caution for June 2026: the field of reasoning control (budget forcing, reasoning-specific KV compression, thinking &lt;em>early-exit&lt;/em>) moves fast and almost all of it is very recent. The specific figures, the 113×, the 34.5 %, the 9 GB, depend on model, task and configuration; take them as an &lt;strong>order of magnitude for design&lt;/strong>, not as constants. Measure on your own workload before committing to SLOs.&lt;/p>
&lt;hr>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>Serving a reasoning model means serving a model whose cost per request &lt;strong>you decide with the thinking budget&lt;/strong>, not the catalogue. The chain goes: route by difficulty → turn &lt;em>think&lt;/em> off where it adds nothing → set &lt;code>thinking_token_budget&lt;/code> by default on the route that thinks → quantise the KV to FP8 → measure reasoning length as a first-class metric. Do that, and reasoning becomes a capability you pay for only when it buys accuracy. Do not, and your cluster will serve a fifth of the users while the model fills sheets of scratch paper nobody is going to read.&lt;/p>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>vLLM · Reasoning Outputs — &lt;a href="https://docs.vllm.ai/en/stable/features/reasoning_outputs/">https://docs.vllm.ai/en/stable/features/reasoning_outputs/&lt;/a>&lt;/li>
&lt;li>vLLM PR #37112 · reasoning_budget — &lt;a href="https://github.com/vllm-project/vllm/pull/37112">https://github.com/vllm-project/vllm/pull/37112&lt;/a>&lt;/li>
&lt;li>Adding Reasoning Budget to vLLM — &lt;a href="https://shuyo.wordpress.com/2026/03/26/adding-reasoning-budget-to-vllm-reasoning-token-limit/">https://shuyo.wordpress.com/2026/03/26/adding-reasoning-budget-to-vllm-reasoning-token-limit/&lt;/a>&lt;/li>
&lt;li>s1: Simple Test-Time Scaling — &lt;a href="https://arxiv.org/abs/2501.19393">https://arxiv.org/abs/2501.19393&lt;/a>&lt;/li>
&lt;li>The Art of Scaling Test-Time Compute — &lt;a href="https://arxiv.org/abs/2512.02008">https://arxiv.org/abs/2512.02008&lt;/a>&lt;/li>
&lt;li>Don&amp;rsquo;t Overthink it (short-m@k) — &lt;a href="https://arxiv.org/pdf/2505.17813">https://arxiv.org/pdf/2505.17813&lt;/a>&lt;/li>
&lt;li>Mirage of Test-Time Scaling — &lt;a href="https://arxiv.org/pdf/2506.04210">https://arxiv.org/pdf/2506.04210&lt;/a>&lt;/li>
&lt;li>When More Thinking Hurts — &lt;a href="https://arxiv.org/html/2604.10739v1">https://arxiv.org/html/2604.10739v1&lt;/a>&lt;/li>
&lt;li>Reasoning Language Model Inference Serving Unveiled — &lt;a href="https://arxiv.org/pdf/2510.18672">https://arxiv.org/pdf/2510.18672&lt;/a>&lt;/li>
&lt;li>R-KV: Redundancy-aware KV Cache Compression — &lt;a href="https://arxiv.org/pdf/2505.24133">https://arxiv.org/pdf/2505.24133&lt;/a>&lt;/li>
&lt;li>Spheron · KV Cache Optimization Guide (2026) — &lt;a href="https://www.spheron.network/blog/kv-cache-optimization-guide/">https://www.spheron.network/blog/kv-cache-optimization-guide/&lt;/a>&lt;/li>
&lt;li>Test-Time Compute Quietly Changed the Economics of Inference — &lt;a href="https://medium.com/@Elongated_musk/test-time-compute-quietly-changed-the-economics-of-inference-9d0b8d77641c">https://medium.com/@Elongated_musk/test-time-compute-quietly-changed-the-economics-of-inference-9d0b8d77641c&lt;/a>&lt;/li>
&lt;li>Inference Scaling (Test-Time Compute) · Towards Data Science — &lt;a href="https://towardsdatascience.com/inference-scaling-test-time-compute-why-reasoning-models-raise-your-compute-bill/">https://towardsdatascience.com/inference-scaling-test-time-compute-why-reasoning-models-raise-your-compute-bill/&lt;/a>&lt;/li>
&lt;li>DeepSeek-V3.1 Hybrid Reasoning · InfoQ — &lt;a href="https://www.infoq.com/news/2025/09/deepseek-v31-hybrid/">https://www.infoq.com/news/2025/09/deepseek-v31-hybrid/&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Long context and KV offloading: when the notebook does not fit on the desk</title><link>https://blog.lo0.es/en/posts/long-context-kv-offloading-notebook-desk/</link><pubDate>Fri, 12 Jun 2026 05:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/long-context-kv-offloading-notebook-desk/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Serving long context &lt;strong>is not serving a smarter model, it is managing a notebook that does not fit on the desk&lt;/strong>. The context window is set by the model (and extended with RoPE scaling / YaRN), but the cost of serving it is set by the &lt;strong>KV cache&lt;/strong>, which grows linearly with sequence length and, in production, &lt;strong>explodes&lt;/strong>: a 300k-token contract on Llama 3 70B eats ~93 GB of KV, more than an entire H100, and a million tokens asks for ~125 GB. When the KV does not fit in HBM, there are only two ways out: &lt;strong>recompute&lt;/strong> (extremely expensive, attention is quadratic) or &lt;strong>offload&lt;/strong> the KV to a cheaper memory hierarchy: DRAM, NVMe, network. The 2026 OSS state of the art, &lt;strong>LMCache&lt;/strong>, &lt;strong>Mooncake&lt;/strong> (Kimi&amp;rsquo;s platform) and &lt;strong>NVIDIA Dynamo/KVBM&lt;/strong>, turns that offload into a &lt;strong>first-class KV layer&lt;/strong>, with reuse across requests, &lt;em>prefill/decode disaggregation&lt;/em> and cache-aware &lt;em>routing&lt;/em>. Reported results: &lt;strong>3×–10×&lt;/strong> less latency with LMCache and up to &lt;strong>+525 %&lt;/strong> throughput in long-context scenarios with Mooncake. The price: every memory hop adds transfer latency, so offloading &lt;strong>only pays off when what you save by not recomputing beats what it costs to move the bytes&lt;/strong>.&lt;/p>
&lt;hr>
&lt;h2 id="the-analogy">The analogy&lt;/h2>
&lt;p>A researcher works at a small desk. On it fit the papers they are pulling from &lt;strong>right now&lt;/strong>, which is the GPU&amp;rsquo;s HBM, blazingly fast but tiny. When the case is long (a thousand-page file, a million-token context), the papers do not fit. There are three options:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Throw papers away and ask the archive for them again every time they are needed.&lt;/strong> That is recomputing the KV: correct, but agonisingly slow, because &amp;ldquo;asking the archive&amp;rdquo; in an LLM means running the whole prompt through attention again, and attention is &lt;strong>quadratic&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Put a shelf next to the desk&lt;/strong> (the CPU&amp;rsquo;s DRAM) and, further out, &lt;strong>a store room in the basement&lt;/strong> (NVMe) and &lt;strong>a depot in another building&lt;/strong> (network/object). Moving papers between the desk and the shelf costs seconds, not hours. That is &lt;strong>hierarchical KV offloading&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>Have two researchers split the work:&lt;/strong> one reads and marks up the whole file (prefill), then passes their notes to the second, who only drafts (decode). That is the &lt;strong>KVCache-centric disaggregated architecture&lt;/strong>.&lt;/li>
&lt;/ol>
&lt;p>This post is about options 2 and 3, the ones that make long context viable in production. Option 1 is what you pay by default if you do nothing.&lt;/p>
&lt;hr>
&lt;h2 id="part-1--why-long-context-is-a-memory-problem">Part 1 · Why long context is a memory problem&lt;/h2>
&lt;h3 id="the-kv-cache-grows-with-the-sequence">The KV cache grows with the sequence&lt;/h3>
&lt;p>For every token that goes in or out, the model stores its &lt;em>key&lt;/em> and &lt;em>value&lt;/em> vectors in each layer so it does not have to recompute them. The size is:&lt;/p>
$$\text{KV bytes} = 2 \times L \times h_{kv} \times d_{head} \times s \times b$$
&lt;p>with \(L\) layers, \(h_{kv}\) KV heads (few, with GQA), \(d_{head}\) the per-head dimension, \(s\) the sequence length and \(b\) the bytes per element. Everything is a model constant &lt;strong>except \(s\)&lt;/strong>: the KV is &lt;strong>linear in context length&lt;/strong>. Doubling the context doubles the KV. That is the basis of &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory of inference&lt;/a>.&lt;/p>
&lt;h3 id="the-numbers-are-frightening">The numbers are frightening&lt;/h3>
&lt;p>At production scale, that linear term turns brutal:&lt;/p>
&lt;ul>
&lt;li>A &lt;strong>300k-token contract&lt;/strong> on &lt;strong>Llama 3 70B&lt;/strong> consumes &lt;strong>~93 GB&lt;/strong> of KV alone, &lt;strong>more than the 80 GB of an entire H100&lt;/strong> (&lt;a href="https://www.digitalocean.com/community/tutorials/long-context-inference-production-cost">DigitalOcean · Long-Context Inference Cost&lt;/a>).&lt;/li>
&lt;li>A &lt;strong>1M-token context&lt;/strong> needs &lt;strong>~125 GB&lt;/strong> of KV, which exceeds both an RTX 4090 (24 GB) and an 80 GB A100 (&lt;a href="https://introl.com/blog/long-context-llm-infrastructure-million-token-windows-guide">Introl · Long-Context LLM Infrastructure&lt;/a>).&lt;/li>
&lt;li>Even a &lt;strong>7B at 128k&lt;/strong> climbs to &lt;strong>~14 GB&lt;/strong> of KV (against ~6 GB at 4k).&lt;/li>
&lt;/ul>
&lt;p>The KV stops being an implementation detail and becomes &lt;strong>the resource that dictates your concurrency&lt;/strong>.&lt;/p>
&lt;h3 id="and-on-top-of-that-attention-is-quadratic">And on top of that, attention is quadratic&lt;/h3>
&lt;p>The KV is linear, but the &lt;strong>compute of attention is quadratic&lt;/strong> in length. When the prompt reaches 1M tokens, generating each token can take on the order of &lt;strong>1.765 seconds, with more than 96 % of the latency spent in attention&lt;/strong> (&lt;a href="https://introl.com/blog/long-context-llm-infrastructure-million-token-windows-guide">Introl&lt;/a>). The aggregate effect is a &lt;strong>throughput collapse of 10×–100×&lt;/strong> against short contexts. This is why long context is not &amp;ldquo;fixed&amp;rdquo; with more VRAM alone: there is a memory problem (KV) &lt;strong>and&lt;/strong> a compute problem (attention) at the same time.&lt;/p>
&lt;h3 id="extending-the-window--serving-it-cheaply">Extending the window ≠ serving it cheaply&lt;/h3>
&lt;p>A common confusion: &amp;ldquo;my model supports 1M of context&amp;rdquo; does not mean &amp;ldquo;I can serve 1M cheaply&amp;rdquo;. The window is &lt;strong>extended&lt;/strong> with &lt;em>RoPE scaling&lt;/em> techniques such as &lt;strong>YaRN&lt;/strong>, the practical option for &lt;em>fine-tuning&lt;/em> open source models to long context: it needs &lt;strong>10× fewer training tokens and 2.5× fewer steps&lt;/strong> than naive RoPE interpolation, and it took LLaMA-2 from 4k to 32k and 128k (&lt;a href="https://arxiv.org/html/2402.13753v1">YaRN/LongRoPE&lt;/a>). But that solves the model &lt;strong>attending&lt;/strong> to long context, not the KV &lt;strong>fitting on your GPU&lt;/strong>. According to the 2026 round-ups, May 2026 marked the first generation of open &lt;strong>million-token&lt;/strong> models (with families supporting 256k extensible to 1M via YaRN) (&lt;a href="https://letsdatascience.com/blog/long-context-models-working-with-1m-token-windows">letsdatascience&lt;/a>). Treat it as a trend, not a closed spec, and verify capabilities per concrete model.&lt;/p>
&lt;hr>
&lt;h2 id="part-2--the-kv-memory-hierarchy">Part 2 · The KV memory hierarchy&lt;/h2>
&lt;p>The core idea of offloading is old in systems: &lt;strong>hierarchical caching&lt;/strong>. &amp;ldquo;Hot&amp;rdquo; KV (what is in use) lives in HBM; &amp;ldquo;warm&amp;rdquo; KV drops to DRAM; &amp;ldquo;cold&amp;rdquo; KV to NVMe; &amp;ldquo;shared across nodes&amp;rdquo; to network or object storage. Every hop multiplies capacity and divides bandwidth.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 300" role="img" aria-label="KV cache memory hierarchy: HBM, DRAM, NVMe and network/object, with increasing capacity and decreasing bandwidth" xmlns="http://www.w3.org/2000/svg">
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.3}.tl{font:600 13px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}.ar{fill:none;stroke:currentColor;stroke-width:1.2;stroke-dasharray:4 3;marker-end:url(#kvm)}&lt;/style>
&lt;defs>&lt;marker id="kvm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;text x="20" y="26" class="tl">Hot · fast · small&lt;/text>
&lt;rect class="bx" x="20" y="36" width="430" height="44" rx="6"/>
&lt;text x="34" y="55" class="tl">GPU HBM&lt;/text>
&lt;text x="34" y="72" class="ts">~80 GB · several TB/s · the active KV lives here&lt;/text>
&lt;rect class="bx" x="20" y="92" width="500" height="44" rx="6"/>
&lt;text x="34" y="111" class="tl">CPU DRAM&lt;/text>
&lt;text x="34" y="128" class="ts">hundreds of GB–TB · tens–hundreds GB/s · warm KV, reuse across requests&lt;/text>
&lt;rect class="bx" x="20" y="148" width="570" height="44" rx="6"/>
&lt;text x="34" y="167" class="tl">NVMe SSD (local)&lt;/text>
&lt;text x="34" y="184" class="ts">TB · GB/s · cold KV, persistent prefixes, long sessions&lt;/text>
&lt;rect class="bx" x="20" y="204" width="640" height="44" rx="6"/>
&lt;text x="34" y="223" class="tl">Network / object (RDMA, S3, Redis)&lt;/text>
&lt;text x="34" y="240" class="ts">~unlimited · hundreds MB–GB/s · KV shared across cluster nodes&lt;/text>
&lt;path class="ar" d="M690,58 L690,226"/>
&lt;text x="700" y="120" class="ts" transform="rotate(90 700 120)">+capacity&lt;/text>
&lt;text x="700" y="200" class="ts" transform="rotate(90 700 200)">−bandwidth&lt;/text>
&lt;text x="20" y="276" class="ts">KV drops a level as it cools. Fetching it back from a lower level costs a transfer,&lt;/text>
&lt;text x="20" y="292" class="ts">but it is ALWAYS cheaper than recomputing it (quadratic attention) if the prefix is reused.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h3 id="working-out-when-offloading-pays-off">Working out when offloading pays off&lt;/h3>
&lt;p>Offloading is not magic: moving KV from DRAM/NVMe back to HBM costs time. The rule is simple. &lt;strong>Offloading pays off when the cost of recomputing exceeds the cost of transferring&lt;/strong>:&lt;/p>
$$t_{recompute}(s) \;>\; t_{transfer} = \frac{\text{KV bytes}}{BW_{enlace}}$$
&lt;p>Since \(t_{recompute}\) grows with attention (quadratic) and \(t_{transfer}\) grows only with the size of the KV (linear) divided by the link bandwidth, &lt;strong>the longer the context, the more the balance tips in favour of offloading&lt;/strong>. That is why offloading is &lt;em>the&lt;/em> lever for long context: it is exactly where recomputing becomes unbearable. The LMCache documentation says it without hedging: offloading &lt;strong>only helps when the recomputation it avoids is larger than the overhead it introduces&lt;/strong> (&lt;a href="https://levelup.gitconnected.com/vllm-prefix-caching-vs-lmcache-benchmarking-kv-reuse-tradeoffs-944fbaf98b56">LMCache benchmark&lt;/a>).&lt;/p>
&lt;hr>
&lt;h2 id="part-3--kv-offloading-in-practice-oss">Part 3 · KV offloading in practice (OSS)&lt;/h2>
&lt;h3 id="the-limit-of-out-of-the-box-prefix-caching">The limit of out-of-the-box prefix caching&lt;/h3>
&lt;p>vLLM already caches prefixes in HBM (see &lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">prefix cache: hit rate engineering&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention&lt;/a>). The problem: &lt;strong>it only lives in HBM&lt;/strong>, which is small. When the useful KV exceeds HBM, it is evicted and, on the next request, has to be recomputed. &lt;strong>There is no speedup even with prefix caching enabled&lt;/strong>, because the cache is no longer there (&lt;a href="https://levelup.gitconnected.com/vllm-prefix-caching-vs-lmcache-benchmarking-kv-reuse-tradeoffs-944fbaf98b56">vLLM vs LMCache benchmark&lt;/a>). Native prefix caching is necessary but &lt;strong>insufficient&lt;/strong> for long context.&lt;/p>
&lt;h3 id="lmcache-the-persistent-kv-layer">LMCache: the persistent KV layer&lt;/h3>
&lt;p>&lt;strong>LMCache&lt;/strong> adds persistent storage backends to vLLM&amp;rsquo;s prefix cache (and also supports SGLang and NVIDIA Dynamo as engines). It pulls the KV out of HBM and &lt;strong>shares it across engines and across queries&lt;/strong>, covering a &lt;strong>three-level hierarchy: GPU HBM, CPU DRAM and NVMe SSD&lt;/strong>, with additional backends for Redis/Valkey, Mooncake, InfiniStore, S3 and NIXL/GDS (&lt;a href="https://github.com/LMCache/LMCache">GitHub LMCache&lt;/a>, &lt;a href="https://arxiv.org/pdf/2510.09665">arXiv 2510.09665&lt;/a>). Because DRAM/NVMe hold &lt;strong>far more&lt;/strong> KV than HBM, the &lt;em>hit ratio&lt;/em> rises, and LMCache reports &lt;strong>3×–10× less latency&lt;/strong> combined with vLLM. Against vLLM&amp;rsquo;s basic CPU offload, LMCache loads the KV &lt;strong>at chunk level with high-performance CUDA kernels&lt;/strong>, which cuts transfer overhead.&lt;/p>
&lt;p>A minimal start, offloading to CPU:&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"># vLLM with LMCache as the KV layer (offload to DRAM)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">pip install lmcache
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LMCACHE_CONFIG_FILE&lt;/span>&lt;span class="o">=&lt;/span>lmcache.yaml &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span>vllm serve meta-llama/Llama-3.1-70B-Instruct &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --tensor-parallel-size &lt;span class="m">4&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --kv-transfer-config &lt;span class="s1">&amp;#39;{&amp;#34;kv_connector&amp;#34;:&amp;#34;LMCacheConnector&amp;#34;,&amp;#34;kv_role&amp;#34;:&amp;#34;kv_both&amp;#34;}&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># lmcache.yaml — hierarchy HBM -&amp;gt; DRAM -&amp;gt; NVMe&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">chunk_size&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">256&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">local_cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># warm KV in DRAM&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">max_local_cpu_size&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">200&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># GB&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">local_disk&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;file:///nvme/lmcache&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># cold KV on NVMe&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">max_local_disk_size&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2000&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># GB&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Offloading to CPU adds moderate overhead but &lt;strong>retains a good part of the benefit&lt;/strong> against recomputing (&lt;a href="https://docs.lmcache.ai/getting_started/quickstart/offload_kv_cache.html">LMCache docs&lt;/a>). Mind the physical path: that HBM↔DRAM↔NVMe traffic goes over PCIe and across NUMA nodes. It is worth reading &lt;a href="https://blog.lo0.es/en/posts/pcie-topology-gpudirect-p2p-acs-security-guard/">PCIe topology, GPUDirect and ACS&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">NUMA, hugepages and CPU isolation&lt;/a>, because a badly wired offload can eat its own gain.&lt;/p>
&lt;h3 id="combine-it-with-fp8">Combine it with FP8&lt;/h3>
&lt;p>Offloading reduces &lt;strong>where&lt;/strong> the KV lives; quantization reduces &lt;strong>how much&lt;/strong> it weighs. They are orthogonal and they add up: KV in FP8 halves the size (see &lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8 end-to-end&lt;/a>), which means half the bytes to transfer on every offload. In long context, &lt;strong>FP8 + offload&lt;/strong> is the default combination.&lt;/p>
&lt;hr>
&lt;h2 id="part-4--kvcache-centric--disaggregated-architecture">Part 4 · KVCache-centric / disaggregated architecture&lt;/h2>
&lt;p>The next level is not just offloading KV, but &lt;strong>redesigning serving around the KV&lt;/strong>.&lt;/p>
&lt;h3 id="mooncake-kimis-platform">Mooncake (Kimi&amp;rsquo;s platform)&lt;/h3>
&lt;p>&lt;strong>Mooncake&lt;/strong>, the serving platform behind &lt;strong>Kimi&lt;/strong> (Moonshot AI), is the reference &lt;strong>KVCache-centric disaggregated&lt;/strong> architecture: it separates the &lt;strong>prefill&lt;/strong> and &lt;strong>decode&lt;/strong> clusters, and exploits the &lt;strong>underused CPU, DRAM and SSD resources&lt;/strong> of the GPU cluster to build a distributed KV cache. Its core is a &lt;strong>KVCache-centric scheduler&lt;/strong> that maximises throughput while respecting SLOs, with a &lt;strong>prediction-based early rejection policy&lt;/strong> for overloaded scenarios (&lt;a href="https://arxiv.org/abs/2407.00079">arXiv 2407.00079&lt;/a>, &lt;a href="https://www.usenix.org/conference/fast25/presentation/qin">USENIX FAST'25&lt;/a>). The long-context numbers are striking: up to &lt;strong>+525 % throughput&lt;/strong> in certain simulated scenarios while respecting the SLO, and in production Kimi handles &lt;strong>115 % and 107 % more requests&lt;/strong> on A800 and H800 clusters respectively against previous systems. The title of their FAST talk sums it up: &lt;em>&amp;ldquo;Trading More Storage for Less Computation&amp;rdquo;&lt;/em>, exactly the rule from Part 2.&lt;/p>
&lt;h3 id="nvidia-dynamo-and-kvbm">NVIDIA Dynamo and KVBM&lt;/h3>
&lt;p>&lt;strong>NVIDIA Dynamo&lt;/strong> is the open source distributed serving framework with &lt;strong>prefill/decode disaggregation&lt;/strong>, dynamic GPU scheduling and &lt;strong>KV-aware routing&lt;/strong>: the &lt;em>PrefillRouter&lt;/em> computes &lt;strong>overlap scores&lt;/strong> between the incoming request and the KV blocks already cached, and routes to the GPUs that &lt;strong>already have&lt;/strong> the relevant KV, avoiding recomputation (&lt;a href="https://docs.dynamo.nvidia.com/dynamo/design-docs/disaggregated-serving">Dynamo · Disaggregated Serving&lt;/a>, &lt;a href="https://docs.nvidia.com/dynamo/latest/user-guides/kv-cache-aware-routing">Dynamo · KV-aware routing&lt;/a>). KV transfer between prefill and decode is handled by &lt;strong>NIXL&lt;/strong> directly GPU-to-GPU over the best available transport (NVLink, InfiniBand). And its offload manager, &lt;strong>KVBM (KV Block Manager)&lt;/strong>, has a three-layer architecture (LLM runtime, logical block management, NIXL transport) that &lt;strong>offloads cold KV to CPU RAM, NVMe or network storage&lt;/strong> to free up HBM (&lt;a href="https://docs.nvidia.com/dynamo/backends/v-llm/kv-cache-offloading">Dynamo · KV Cache Offloading&lt;/a>). This connects with &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">disaggregated serving: prefill and decode in specialised pods&lt;/a>.&lt;/p>
&lt;p>The &lt;strong>llm-d&lt;/strong> project walks the same road, from vLLM&amp;rsquo;s prefix caching to KV-aware distributed scheduling (&lt;a href="https://llm-d.ai/blog/kvcache-wins-you-can-see">llm-d · KV-Cache Wins You Can See&lt;/a>).&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 250" role="img" aria-label="KVCache-centric disaggregated architecture: KV-aware router, prefill cluster, tiered distributed KV cache and decode cluster" xmlns="http://www.w3.org/2000/svg">
&lt;style>.bx{fill:none;stroke:currentColor;stroke-width:1.3}.dsh{fill:none;stroke:currentColor;stroke-width:1.3;stroke-dasharray:5 3}.tl{font:600 12px sans-serif;fill:currentColor}.ts{font:11px sans-serif;fill:currentColor}.ar{fill:none;stroke:currentColor;stroke-width:1.3;marker-end:url(#dgm)}&lt;/style>
&lt;defs>&lt;marker id="dgm" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;rect class="bx" x="20" y="40" width="120" height="46" rx="6"/>
&lt;text x="32" y="60" class="tl">KV-aware router&lt;/text>
&lt;text x="32" y="77" class="ts">overlap score&lt;/text>
&lt;path class="ar" d="M140,63 L185,63"/>
&lt;rect class="bx" x="185" y="40" width="150" height="46" rx="6"/>
&lt;text x="197" y="60" class="tl">PREFILL cluster&lt;/text>
&lt;text x="197" y="77" class="ts">reads+marks up context&lt;/text>
&lt;path class="ar" d="M260,86 L260,120"/>
&lt;rect class="dsh" x="120" y="120" width="430" height="50" rx="6"/>
&lt;text x="134" y="140" class="tl">Distributed KV cache (HBM · DRAM · NVMe · network)&lt;/text>
&lt;text x="134" y="158" class="ts">KVBM / LMCache / Mooncake store · reuse across requests and nodes&lt;/text>
&lt;path class="ar" d="M410,120 L410,86"/>
&lt;rect class="bx" x="430" y="40" width="150" height="46" rx="6"/>
&lt;text x="442" y="60" class="tl">DECODE cluster&lt;/text>
&lt;text x="442" y="77" class="ts">only generates tokens&lt;/text>
&lt;path class="ar" d="M335,63 L430,63"/>
&lt;text x="600" y="50" class="ts">NIXL: direct&lt;/text>
&lt;text x="600" y="66" class="ts">GPU↔GPU KV transfer&lt;/text>
&lt;text x="600" y="82" class="ts">(NVLink/IB)&lt;/text>
&lt;text x="20" y="200" class="ts">KV stops being a by-product of decode and becomes the first-class citizen: the router routes by&lt;/text>
&lt;text x="20" y="216" class="ts">KV overlap, prefill and decode are separated, and the distributed cache avoids recomputing shared prefixes.&lt;/text>
&lt;text x="20" y="232" class="ts">It is "trading storage for computation": you store more KV so you compute less attention.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;hr>
&lt;h2 id="part-5--reference-architecture">Part 5 · Reference architecture&lt;/h2>
&lt;p>On the blog&amp;rsquo;s example cluster, a node with &lt;strong>4×H100 SXM (80 GB, NVLink)&lt;/strong>, &lt;strong>local NVMe&lt;/strong> and plenty of DRAM, a sensible stack to serve long context without recomputing pointlessly:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>KV in FP8&lt;/strong> in the engine (vLLM), to start with half the bytes.&lt;/li>
&lt;li>&lt;strong>LMCache as the KV layer&lt;/strong> with a DRAM→NVMe hierarchy, so that long prefixes (a manual, a code base, a case file) &lt;strong>are cached once and reused&lt;/strong> across sessions without recomputation.&lt;/li>
&lt;li>&lt;strong>KV-aware routing&lt;/strong> (Dynamo or llm-d) if you have several nodes: send the request to the GPU that already holds the prefix, not to any GPU at all.&lt;/li>
&lt;li>&lt;strong>Prefill/decode disaggregation&lt;/strong> once the prefill of enormous contexts starts stealing decode from the other requests. Prefilling 300k tokens monopolises the GPU and kills everyone&amp;rsquo;s latency.&lt;/li>
&lt;li>&lt;strong>Metrics&lt;/strong> for KV &lt;em>hit ratio&lt;/em>, bytes transferred per level and prefill/decode ratio, exported to your observability stack (&lt;a href="https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/">instrumenting vLLM with OTel&lt;/a>). Without those three metrics, offloading is faith, not engineering.&lt;/li>
&lt;/ol>
&lt;p>To &lt;strong>prototype&lt;/strong> the stack, validating the LMCache config, the backend formats, the hit/miss behaviour, an &lt;strong>RTX 5090 (Blackwell, 32 GB)&lt;/strong> with NVMe is more than enough to serve a 7–14B and watch offloading work at contexts of 32–128k. Do not expect to serve 1M on a consumer card: the long-context problem is precisely that neither the KV nor the attention fit on small hardware. Real sizing starts from the SLO and from the context-length distribution of your traffic, not from the model&amp;rsquo;s maximum window, and that is where it links to &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">capacity planning&lt;/a>.&lt;/p>
&lt;hr>
&lt;h2 id="part-6--operational-pitfalls-and-honest-scepticism">Part 6 · Operational pitfalls (and honest scepticism)&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>Believing that native prefix caching is enough.&lt;/strong> It only lives in HBM; in long context it is evicted and you recompute. You need a persistent layer (LMCache/KVBM) for the cache to survive.&lt;/li>
&lt;li>&lt;strong>Offloading without measuring the break-even.&lt;/strong> If your prefixes &lt;strong>are not reused&lt;/strong>, offloading only adds transfer latency without saving recomputation. Offloading shines with &lt;strong>shared prefixes&lt;/strong> (RAG over the same corpus, agents with the same system prompt, long sessions), not with one-off, never-repeated requests.&lt;/li>
&lt;li>&lt;strong>Forgetting that attention is still quadratic.&lt;/strong> Offloading fixes the &lt;strong>memory&lt;/strong> of the KV, not the &lt;strong>compute&lt;/strong> of attention. For real 1M tokens you also need efficient attention, &lt;em>sparse attention&lt;/em> or retrieval approaches (RetrievalAttention, ShadowKV); offloading on its own does not bring down the 1.765 s/token.&lt;/li>
&lt;li>&lt;strong>Wiring the offload badly.&lt;/strong> HBM↔DRAM↔NVMe crosses PCIe and NUMA. An offload that crosses the wrong NUMA node or saturates a PCIe lane can be &lt;strong>slower than recomputing&lt;/strong>. Measure the link&amp;rsquo;s real bandwidth, not the datasheet figure.&lt;/li>
&lt;li>&lt;strong>Treating the figures as constants.&lt;/strong> The 93 GB, the +525 %, the 3–10× all depend on model, length, hardware and reuse pattern. They are &lt;strong>orders of magnitude for design&lt;/strong>; measure on your own load.&lt;/li>
&lt;li>&lt;strong>Trusting million-token model specs without verifying.&lt;/strong> The landscape of open 1M models is very recent (2026) and moves fast; the specific names and limits change between versions. Verify the window &lt;strong>and the cost of serving it&lt;/strong> per model, do not take the headline on trust.&lt;/li>
&lt;/ol>
&lt;p>A note of caution for June 2026: the distributed KV layer (LMCache, Mooncake, KVBM, NIXL, llm-d) is &lt;strong>setting right now&lt;/strong>, with APIs and backends that change release by release. It is exactly the kind of piece worth assembling behind an abstraction (vLLM&amp;rsquo;s &lt;em>kv_connector&lt;/em>) rather than coupling to a single vendor.&lt;/p>
&lt;hr>
&lt;h2 id="closing">Closing&lt;/h2>
&lt;p>Long context is a problem of &lt;strong>memory and compute at the same time&lt;/strong>, and the KV cache is the bottleneck of both. The window is a gift from the model; serving it cheaply is systems engineering: &lt;strong>quantise the KV (FP8), offload it by tiers (HBM→DRAM→NVMe→network) with a persistent layer, and reorganise serving around the KV&lt;/strong> (aware routing, separated prefill/decode) when scale demands it. The rule that governs it all is Mooncake&amp;rsquo;s: &lt;strong>trade storage for computation&lt;/strong>. You store more KV in cheap places so you do not recompute expensive attention. Do it well and a 4×H100 node serves contexts that, by recomputing, would not even get started. Do it badly, or not at all, and every long request reads the whole case file from scratch again, while the GPU drowns in a quadratic attention you had already paid for once.&lt;/p>
&lt;h2 id="sources">Sources&lt;/h2>
&lt;ul>
&lt;li>LMCache · GitHub — &lt;a href="https://github.com/LMCache/LMCache">https://github.com/LMCache/LMCache&lt;/a>&lt;/li>
&lt;li>LMCache: An Efficient KV Cache Layer (arXiv 2510.09665) — &lt;a href="https://arxiv.org/pdf/2510.09665">https://arxiv.org/pdf/2510.09665&lt;/a>&lt;/li>
&lt;li>LMCache · Offload KV cache to CPU (docs) — &lt;a href="https://docs.lmcache.ai/getting_started/quickstart/offload_kv_cache.html">https://docs.lmcache.ai/getting_started/quickstart/offload_kv_cache.html&lt;/a>&lt;/li>
&lt;li>vLLM Prefix Caching vs. LMCache: Benchmarking KV Reuse Tradeoffs — &lt;a href="https://levelup.gitconnected.com/vllm-prefix-caching-vs-lmcache-benchmarking-kv-reuse-tradeoffs-944fbaf98b56">https://levelup.gitconnected.com/vllm-prefix-caching-vs-lmcache-benchmarking-kv-reuse-tradeoffs-944fbaf98b56&lt;/a>&lt;/li>
&lt;li>Mooncake: A KVCache-centric Disaggregated Architecture (arXiv 2407.00079) — &lt;a href="https://arxiv.org/abs/2407.00079">https://arxiv.org/abs/2407.00079&lt;/a>&lt;/li>
&lt;li>Mooncake · USENIX FAST'25 (Trading More Storage for Less Computation) — &lt;a href="https://www.usenix.org/conference/fast25/presentation/qin">https://www.usenix.org/conference/fast25/presentation/qin&lt;/a>&lt;/li>
&lt;li>Mooncake · GitHub — &lt;a href="https://github.com/kvcache-ai/Mooncake/">https://github.com/kvcache-ai/Mooncake/&lt;/a>&lt;/li>
&lt;li>NVIDIA Dynamo · Disaggregated Serving (docs) — &lt;a href="https://docs.dynamo.nvidia.com/dynamo/design-docs/disaggregated-serving">https://docs.dynamo.nvidia.com/dynamo/design-docs/disaggregated-serving&lt;/a>&lt;/li>
&lt;li>NVIDIA Dynamo · KV-aware routing (docs) — &lt;a href="https://docs.nvidia.com/dynamo/latest/user-guides/kv-cache-aware-routing">https://docs.nvidia.com/dynamo/latest/user-guides/kv-cache-aware-routing&lt;/a>&lt;/li>
&lt;li>NVIDIA Dynamo · KV Cache Offloading / KVBM (docs) — &lt;a href="https://docs.nvidia.com/dynamo/backends/v-llm/kv-cache-offloading">https://docs.nvidia.com/dynamo/backends/v-llm/kv-cache-offloading&lt;/a>&lt;/li>
&lt;li>llm-d · KV-Cache Wins You Can See — &lt;a href="https://llm-d.ai/blog/kvcache-wins-you-can-see">https://llm-d.ai/blog/kvcache-wins-you-can-see&lt;/a>&lt;/li>
&lt;li>Long-Context Inference at Scale: The Hidden Infrastructure Cost · DigitalOcean — &lt;a href="https://www.digitalocean.com/community/tutorials/long-context-inference-production-cost">https://www.digitalocean.com/community/tutorials/long-context-inference-production-cost&lt;/a>&lt;/li>
&lt;li>Long-Context LLM Infrastructure · Introl — &lt;a href="https://introl.com/blog/long-context-llm-infrastructure-million-token-windows-guide">https://introl.com/blog/long-context-llm-infrastructure-million-token-windows-guide&lt;/a>&lt;/li>
&lt;li>YaRN / LongRoPE (arXiv 2402.13753) — &lt;a href="https://arxiv.org/html/2402.13753v1">https://arxiv.org/html/2402.13753v1&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Speeding up model cold start: from minutes to seconds</title><link>https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/</link><pubDate>Thu, 11 Jun 2026 13:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/</guid><description>&lt;blockquote>
&lt;p>This is the &lt;strong>third batch&lt;/strong> of an operational series on squeezing a generic on-premise LLM cluster of &lt;strong>4×H100 SXM 80 GB with NVLink&lt;/strong>. The sister pieces in this batch are &lt;a href="https://blog.lo0.es/en/posts/multimodal-on-premise-serving-vlm-vllm/">Multimodal: serving a VLM on-premise with vLLM&lt;/a>, adding vision to the same engine, and &lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and GPU multi-tenancy with LiteLLM&lt;/a>, sharing out and charging for the GPU across teams. This post takes the problem that &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM&lt;/a> laid out conceptually and turns it into a runbook: how you actually bring the cold start down, knob by knob, so that elasticity is usable.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The post &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM&lt;/a> left an uncomfortable idea behind: &lt;strong>moving the weights from disk to HBM is only one of the five line items of cold start&lt;/strong>. The other four, starting the process, creating the CUDA context, setting up the allocator and, the most expensive one that hardly anyone looks at, &lt;strong>capturing the CUDA graphs and compiling the JIT kernels&lt;/strong>, are untouched by any fast loader. That is why a server that loads the weights in 6 s can still take &lt;strong>90 s&lt;/strong> to be ready. This runbook attacks all five. For the weights: &lt;strong>safetensors&lt;/strong> (mmap, zero-copy, no bounce through a host FP32 buffer) versus the &lt;code>pickle&lt;/code> of &lt;code>torch.load&lt;/code>; &lt;strong>Tensorizer&lt;/strong> (CoreWeave), which serialises the model into one file and &lt;strong>streams it tensor by tensor straight to the GPU&lt;/strong> from object storage/HTTP/S3; and the &lt;strong>Run:ai Model Streamer&lt;/strong> (from NVIDIA), which &lt;strong>reads concurrently and overlaps the read with the H2D copy&lt;/strong>. All three are switched on with one flag, &lt;code>--load-format&lt;/code>, in vLLM. For the non-weight part: &lt;strong>torch.compile/Inductor compilation cache&lt;/strong> (persisted and reused across start-ups), &lt;strong>bounding the CUDA graph capture&lt;/strong> to the batch sizes you really use (the default capture can eat ~54 s), and &lt;strong>sleep mode&lt;/strong> as a shortcut that skips all five line items. The maths: $t = W/B$ for the weights, the saving from &lt;strong>overlapping&lt;/strong> read and H2D instead of doing them in series, and a 70B cold start broken down item by item. And the economics: when &lt;strong>scale-to-zero with pre-warm&lt;/strong> beats &lt;strong>keeping a warm replica&lt;/strong>. On the generic 4×H100 SXM 80 GB cluster.&lt;/p>
&lt;h2 id="the-analogy-opening-the-kitchen-in-the-morning">The analogy: opening the kitchen in the morning&lt;/h2>
&lt;p>A restaurant does not open by flicking a switch. Anyone who has worked in one knows it: opening time is decided by &lt;strong>everything that happens beforehand&lt;/strong>, and almost none of it is &amp;ldquo;bringing in the food&amp;rdquo;.&lt;/p>
&lt;p>The first cook arrives at a cold, dark place. They have to: &lt;strong>turn on the lights and start the systems&lt;/strong> (the process init); &lt;strong>fire up the ovens and let them come up to temperature&lt;/strong> (the CUDA context, which takes its time to become operational); &lt;strong>get the boards out, sort the cold room, organise the work space&lt;/strong> (the memory allocator, which reserves and structures the HBM); &lt;strong>go down to the store and bring up the produce&lt;/strong>, the crates of vegetables, the meat, the fish, &lt;strong>and put it in its place&lt;/strong> (loading the weights from disk to HBM, the entire subject of &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM&lt;/a>); and, the most forgotten of all, &lt;strong>sharpen every knife, set up the mise en place, make the stocks, pre-heat the mother sauces&lt;/strong>, all the preparation that is not an ingredient but without which not a single dish goes out (CUDA graph capture and JIT compilation of the kernels).&lt;/p>
&lt;p>Here is the trap that &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM&lt;/a> already anticipated: &lt;strong>if you only optimise the produce, you still open late&lt;/strong>. You can hire the best delivery service in the world, a blazingly fast service lift, crates that put themselves away. But if the cook is still sharpening knives and waiting for the ovens to come up to temperature, the kitchen &lt;strong>does not serve the first dish&lt;/strong>. The produce arrived early and the restaurant is still shut.&lt;/p>
&lt;p>Today&amp;rsquo;s runbook is, literally, the list of everything that has to be done for the kitchen to open on time, and which of those tasks can be sped up, which can be left done from the night before (cached), and when it pays better never to switch the kitchen off (warm replica) than to light it again every morning (scale-to-zero).&lt;/p>
&lt;h2 id="the-five-line-items-of-cold-start">The five line items of cold start&lt;/h2>
&lt;p>From the moment an inference pod is born until it returns its first token, the clock runs in five different places. It is worth naming them, because &lt;strong>each optimisation attacks one or two, never all of them&lt;/strong>:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Process init.&lt;/strong> Starting the Python interpreter, importing the engine (vLLM and its dependencies), parsing the configuration. Seconds, and it grows with the size of the environment and the CUDA/PyTorch &lt;code>import&lt;/code>s.&lt;/li>
&lt;li>&lt;strong>CUDA context.&lt;/strong> The first &lt;code>cudaSetDevice&lt;/code> / &lt;code>cudaFree(0)&lt;/code> initialises the CUDA runtime against the driver: it loads the context and maps the GPU. It is not instant, on the order of one to several seconds per GPU depending on driver and number of devices.&lt;/li>
&lt;li>&lt;strong>Allocator.&lt;/strong> vLLM sets up its memory manager over the HBM (PyTorch&amp;rsquo;s caching allocator plus the KV-cache layout). Reserving and structuring tens of GB has its cost.&lt;/li>
&lt;li>&lt;strong>Weight loading disk→HBM.&lt;/strong> The journey that &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM&lt;/a> dissected: disk → page cache → host buffer → PCIe → HBM. What almost everyone believes cold start &lt;strong>is&lt;/strong>, and it is only one of the five.&lt;/li>
&lt;li>&lt;strong>CUDA graph capture + kernel JIT.&lt;/strong> vLLM captures CUDA graphs for the various batch shapes and compiles kernels with &lt;code>torch.compile&lt;/code>/Inductor (and backends such as DeepGEMM or FlashInfer). This item is the great ignored one: &lt;strong>the default graph capture in vLLM takes on the order of 54 s&lt;/strong> because it covers a wide range of batch sizes (&lt;a href="https://docs.vllm.ai/en/latest/design/torch_compile/">vLLM, &lt;em>torch.compile integration&lt;/em>&lt;/a>; &lt;a href="https://developers.redhat.com/articles/2025/09/03/vllm-torchcompile-efficient-llm-inference-pytorch">Red Hat, &lt;em>vLLM with torch.compile&lt;/em>&lt;/a>).&lt;/li>
&lt;/ol>
&lt;div class="diagram" style="max-width:760px;margin:1.4rem auto;">
&lt;svg viewBox="0 0 760 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The five line items of cold start as stacked time bars">
&lt;text x="380" y="24" text-anchor="middle" font-family="sans-serif" font-size="14" font-weight="700" fill="currentColor">The five line items of a cold start (example 70B, default loader)&lt;/text>
&lt;text x="380" y="44" text-anchor="middle" font-family="sans-serif" font-size="11" fill="currentColor" opacity="0.75">width proportional to time · scale 0–110 s&lt;/text>
&lt;!-- axis -->
&lt;line x1="40" y1="250" x2="720" y2="250" stroke="currentColor" stroke-width="1.2"/>
&lt;text x="40" y="268" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="currentColor">0 s&lt;/text>
&lt;text x="380" y="268" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="currentColor">55 s&lt;/text>
&lt;text x="720" y="268" text-anchor="middle" font-family="sans-serif" font-size="9.5" fill="currentColor">110 s&lt;/text>
&lt;!-- bars: x=40 base, 680px = 110s => 6.18 px/s -->
&lt;!-- init 4s = 25px -->
&lt;rect x="40" y="70" width="25" height="30" fill="#8b5cf6" fill-opacity="0.85"/>
&lt;text x="160" y="89" font-family="sans-serif" font-size="11" fill="currentColor">1 · process init — ~4 s&lt;/text>
&lt;!-- CUDA context 6s = 37px, start 65 -->
&lt;rect x="65" y="106" width="37" height="30" fill="#3b82f6" fill-opacity="0.85"/>
&lt;text x="160" y="125" font-family="sans-serif" font-size="11" fill="currentColor">2 · CUDA context — ~6 s&lt;/text>
&lt;!-- allocator 4s = 25px, start 102 -->
&lt;rect x="102" y="142" width="25" height="30" fill="#22c55e" fill-opacity="0.85"/>
&lt;text x="160" y="161" font-family="sans-serif" font-size="11" fill="currentColor">3 · allocator — ~4 s&lt;/text>
&lt;!-- weights 40s = 247px, start 127 -->
&lt;rect x="127" y="178" width="247" height="30" fill="#f59e0b" fill-opacity="0.9"/>
&lt;text x="384" y="197" font-family="sans-serif" font-size="11" fill="currentColor">4 · weight loading disk→HBM — ~40 s&lt;/text>
&lt;!-- graphs+jit 50s = 309px, start 374 -->
&lt;rect x="374" y="214" width="309" height="30" fill="#ef4444" fill-opacity="0.9"/>
&lt;text x="690" y="233" text-anchor="end" font-family="sans-serif" font-size="11" fill="#ffffff" font-weight="600">5 · CUDA graphs + JIT — ~50 s&lt;/text>
&lt;text x="380" y="290" text-anchor="middle" font-family="sans-serif" font-size="10.5" fill="currentColor" opacity="0.85">total ≈ 104 s · optimising only item 4 leaves item 5 intact — and 5 is the largest here&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The lesson the diagram is shouting: on a modern server with &lt;code>torch.compile&lt;/code> and CUDA graphs, &lt;strong>item 5 can be as big as or bigger than item 4&lt;/strong>. Speeding up weight loading from 40 s to 6 s is a huge advance, but if you leave graph capture at 50 s, the cold start falls from 104 s to ~70 s, not to 12 s. &lt;strong>You have to attack both halves.&lt;/strong>&lt;/p>
&lt;h2 id="speeding-up-item-4-loading-the-weights">Speeding up item 4: loading the weights&lt;/h2>
&lt;h3 id="safetensors-versus-pickle-the-format-rules">safetensors versus pickle: the format rules&lt;/h3>
&lt;p>The first knob is the on-disk format. The old &lt;code>torch.load&lt;/code> uses &lt;strong>&lt;code>pickle&lt;/code>&lt;/strong>, which has two problems. The security one is well known: deserialising a pickle &lt;strong>executes arbitrary Python code&lt;/strong>, since the &lt;code>__reduce__&lt;/code> protocol lets the file invoke any callable on load, so every model download was a remote execution vector (&lt;a href="https://huggingface.co/docs/safetensors/index">HuggingFace, &lt;em>Safetensors&lt;/em>&lt;/a>). The performance one is what concerns us: loading a pickle rebuilds Python objects and usually passes through an intermediate &lt;strong>host buffer&lt;/strong> before reaching the GPU.&lt;/p>
&lt;p>&lt;strong>safetensors&lt;/strong>, now under the PyTorch Foundation (&lt;a href="https://huggingface.co/blog/safetensors-joins-pytorch-foundation">HuggingFace, &lt;em>Safetensors joins PyTorch Foundation&lt;/em>&lt;/a>), solves both. The file is just a &lt;strong>JSON header + raw tensor bytes&lt;/strong>: loading it cannot do anything except populate tensor buffers. And the key property for us is physical: &lt;strong>the data region is aligned to a page boundary&lt;/strong> (the header is padded so that the first tensor starts at a multiple of the OS page size). That is what makes &lt;strong>zero-copy&lt;/strong> possible: a loader can &lt;code>mmap&lt;/code> the file, &lt;code>cudaHostRegister&lt;/code> over the mapped region and &lt;strong>DMA straight from the page cache into VRAM&lt;/strong>, with no &lt;code>torch.load&lt;/code> deserialisation and no temporary host FP32 buffer (&lt;a href="https://huggingface.co/docs/safetensors/index">HuggingFace, &lt;em>Safetensors&lt;/em>&lt;/a>). It is the base format; everything else is built on top of it.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>The &lt;code>mmap&lt;/code> trap&lt;/strong> (already flagged in &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM&lt;/a>): &lt;code>mmap&lt;/code> reads nothing immediately, it defers the cost to the first access to each page. If you do not force the read, the cold start looks short and the &lt;strong>first token&lt;/strong> pays the page faults. And the &amp;ldquo;blazingly fast second load&amp;rdquo; is the page cache lying to you: in production pods are born cold, on nodes where those files are not cached.&lt;/p>
&lt;/blockquote>
&lt;h3 id="tensorizer-streaming-tensor-by-tensor-straight-to-the-gpu">Tensorizer: streaming tensor by tensor straight to the GPU&lt;/h3>
&lt;p>&lt;strong>Tensorizer&lt;/strong>, from CoreWeave, serialises the model&amp;rsquo;s weights and its tensors &lt;strong>into a single file&lt;/strong> and, instead of loading the whole model into RAM before moving it to the GPU, &lt;strong>streams the data tensor by tensor&lt;/strong> from disk, an HTTP/HTTPS endpoint or an S3 bucket, &lt;strong>deserialising on the fly straight onto the GPU&lt;/strong> (&lt;a href="https://docs.vllm.ai/en/stable/models/extensions/tensorizer/">vLLM, &lt;em>Loading Models with CoreWeave&amp;rsquo;s Tensorizer&lt;/em>&lt;/a>). The operational advantage: &lt;strong>near-instant loading and low host RAM usage&lt;/strong> during initialisation, which matters especially in serverless and autoscaling scenarios, exactly the scale-to-zero case. And it decouples the weights from the container image: the model lives in object storage, not inflating the image pull.&lt;/p>
&lt;p>In vLLM it is enabled with &lt;code>--load-format tensorizer&lt;/code> (or &lt;code>load_format=&amp;quot;tensorizer&amp;quot;&lt;/code> via the API). It requires serialising the model once into Tensorizer&amp;rsquo;s format; from then on, any pod streams it.&lt;/p>
&lt;h3 id="runai-model-streamer-reading-and-copying-at-the-same-time">Run:ai Model Streamer: reading and copying at the same time&lt;/h3>
&lt;p>The &lt;strong>Run:ai Model Streamer&lt;/strong> (from NVIDIA) attacks the bottleneck from another angle: it &lt;strong>reads the tensors concurrently&lt;/strong>, N OS threads reading from storage into the CPU buffer, &lt;strong>while streaming them to VRAM&lt;/strong>, so that the &lt;strong>storage read and the H2D copy overlap&lt;/strong> instead of happening in series (&lt;a href="https://docs.vllm.ai/en/stable/models/extensions/runai_model_streamer/">vLLM, &lt;em>Loading models with Run:ai Model Streamer&lt;/em>&lt;/a>; &lt;a href="https://developer.nvidia.com/blog/reducing-cold-start-latency-for-llm-inference-with-nvidia-runai-model-streamer/">NVIDIA, &lt;em>Reducing Cold Start Latency&lt;/em>&lt;/a>). It reads safetensors directly, with no reconversion.&lt;/p>
&lt;p>The numbers published by NVIDIA give the order of magnitude: the streamer reaches &lt;strong>4.88 s reading from S3 at concurrency 32&lt;/strong> and &lt;strong>7.53 s from an SSD IO2 at concurrency 8&lt;/strong>; integrated into vLLM, the total time to &lt;em>ready&lt;/em> drops to &lt;strong>23.18 s from S3&lt;/strong>, &lt;strong>28.28 s from SSD IO2&lt;/strong> and &lt;strong>35.08 s from GP3&lt;/strong> (&lt;a href="https://developer.nvidia.com/blog/reducing-cold-start-latency-for-llm-inference-with-nvidia-runai-model-streamer/">NVIDIA, &lt;em>Reducing Cold Start Latency&lt;/em>&lt;/a>). Note the gap between the &lt;strong>weight loading&lt;/strong> time (~5–8 s) and the total to &lt;em>ready&lt;/em> (~23–35 s): those ~18–27 s of difference are &lt;strong>the other four line items&lt;/strong>, above all item 5. The streamer fixed item 4 and the rest is still there, exactly what this post warns about.&lt;/p>
&lt;p>Concurrency is the tuning parameter: it controls how many OS threads read tensors into the CPU buffer (and, for S3, how many client connections the host opens) (&lt;a href="https://docs.vllm.ai/en/stable/models/extensions/runai_model_streamer/">vLLM, &lt;em>Run:ai Model Streamer&lt;/em>&lt;/a>). &lt;strong>16 is usually enough for local NVMe; 32 for high-throughput object storage.&lt;/strong> One thread does not saturate a Gen5 NVMe; overlapping does.&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"># default safetensors (the slowest of the fast ones)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve &amp;lt;model&amp;gt; --load-format safetensors
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Run:ai Model Streamer (concurrent reads, overlaps read + H2D)&lt;/span>
&lt;/span>&lt;/span>&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;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Tensorizer (tensor-by-tensor stream straight to GPU from object storage/S3)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve &amp;lt;model&amp;gt; --load-format tensorizer
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="the-maths-of-the-weights-t--wb-and-the-saving-from-overlapping">The maths of the weights: $t = W/B$ and the saving from overlapping&lt;/h2>
&lt;p>The floor of item 4 is simple: moving $W$ gigabytes at a bandwidth $B$ takes&lt;/p>
$$t = \frac{W}{B}$$
&lt;p>Take a large model, a &lt;strong>70B&lt;/strong>. In BF16 ($b=2$ bytes/param) that is $W = 70 \times 10^9 \cdot 2 = 140$ GB; in FP8 ($b=1$), $W = 70$ GB. Bandwidth depends on the tier (see the table below): local Gen5 NVMe reads on the order of &lt;strong>~14 GB/s per disk&lt;/strong>; PCIe Gen5 x16 copies host→GPU at &lt;strong>~50 GB/s&lt;/strong>; network storage, &lt;strong>~1–3 GB/s&lt;/strong>. The theoretical floor for reading 140 GB from an NVMe is $140/14 = 10$ s; from the network at 2 GB/s, &lt;strong>70 s&lt;/strong>. The bottleneck rules.&lt;/p>
&lt;p>&lt;strong>The overlapping trick.&lt;/strong> The default loader does the two steps, reading from disk and copying H2D, &lt;strong>in series&lt;/strong>: first it fills a buffer, then it copies, then the next one. The time is the &lt;strong>sum&lt;/strong>:&lt;/p>
$$t_{\text{series}} = t_{\text{read}} + t_{\text{H2D}}$$
&lt;p>Tensorizer and the Run:ai Model Streamer &lt;strong>overlap&lt;/strong> them: while one thread copies a chunk to VRAM, another is already reading the next one from disk. With enough concurrency, the total time tends towards the &lt;strong>maximum&lt;/strong> of the two, not the sum:&lt;/p>
$$t_{\text{overlapped}} \approx \max(t_{\text{read}},\, t_{\text{H2D}})$$
&lt;p>With the 70 GB of the 70B in FP8, from NVMe at 14 GB/s and PCIe at 50 GB/s: $t_{\text{read}} = 70/14 = 5.0$ s, $t_{\text{H2D}} = 70/50 = 1.4$ s. In series, $5.0 + 1.4 = 6.4$ s; overlapped, $\max(5.0,\,1.4) = 5.0$ s. The saving here is modest (~22%) because the disk dominates comfortably. &lt;strong>Overlapping shines when the two steps are comparable&lt;/strong>: from network storage at 5 GB/s, $t_{\text{read}} = 70/5 = 14$ s and $t_{\text{H2D}} = 1.4$ s, series 15.4 s vs overlapped 14 s; but with many concurrent streams saturating a fast disk and a matched H2D, going from the sum to the maximum can &lt;strong>almost double&lt;/strong> the effective throughput. The flip side: overlapping only gives you what the &lt;strong>physical bottleneck&lt;/strong> allows. If the disk only delivers 14 GB/s, no streamer brings you below 5 s for 70 GB, and that is what the other lever is for, &lt;strong>moving fewer bytes&lt;/strong> (FP8 versus BF16 halves $W$).&lt;/p>
&lt;h3 id="the-storage-tier">The storage tier&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Tier&lt;/th>
&lt;th>Typical bandwidth&lt;/th>
&lt;th>140 GB (70B BF16)&lt;/th>
&lt;th>70 GB (70B FP8)&lt;/th>
&lt;th>Notes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Local Gen5 NVMe&lt;/td>
&lt;td>~14 GB/s per disk&lt;/td>
&lt;td>~10 s&lt;/td>
&lt;td>~5 s&lt;/td>
&lt;td>the short path; one disk&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Local Gen5 NVMe (several, RAID0)&lt;/td>
&lt;td>~28–50 GB/s&lt;/td>
&lt;td>~3–5 s&lt;/td>
&lt;td>~1.5–2.5 s&lt;/td>
&lt;td>if the loader saturates several streams&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Object storage (S3/RGW)&lt;/td>
&lt;td>~1–3 GB/s per stream&lt;/td>
&lt;td>~47–140 s&lt;/td>
&lt;td>~23–70 s&lt;/td>
&lt;td>streamer concurrency helps a great deal&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>NFS / shared network&lt;/td>
&lt;td>~1–2 GB/s&lt;/td>
&lt;td>~70–140 s&lt;/td>
&lt;td>~35–70 s&lt;/td>
&lt;td>puts the network and its contention in the path&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The operational conclusion is the same as in &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM&lt;/a>: &lt;strong>the weights that serve the cold start live on the node&amp;rsquo;s local NVMe&lt;/strong>, not on the network. Network storage is the repository; the inference node has a hot local copy (pre-pull with an &lt;code>initContainer&lt;/code> or a per-node cache DaemonSet). Tensorizer is the interesting exception: it streams from object storage so efficiently that it sometimes makes serving from S3/RGW viable with no local copy, at the price of depending on the bandwidth of the storage network.&lt;/p>
&lt;h2 id="speeding-up-item-5-everything-that-is-not-weights">Speeding up item 5: everything that is not weights&lt;/h2>
&lt;p>Here is the half of the cold start that fast loaders do not touch. Three fronts.&lt;/p>
&lt;h3 id="caching-the-torchcompileinductor-compilation">Caching the torch.compile/Inductor compilation&lt;/h3>
&lt;p>&lt;code>torch.compile&lt;/code> has a &lt;strong>built-in cache system&lt;/strong>: the compiled artefacts are saved after the first start-up and &lt;strong>reused across start-ups&lt;/strong>, even across machines if it is configured properly. In vLLM, the result of the Dynamo compilation is stored in &lt;code>~/.cache/vllm/torch_compile_cache/&lt;/code> (&lt;a href="https://blog.vllm.ai/2025/08/20/torch-compile.html">vLLM Blog, &lt;em>Introduction to torch.compile&lt;/em>&lt;/a>; &lt;a href="https://docs.vllm.ai/en/latest/design/torch_compile/">vLLM, &lt;em>torch.compile integration&lt;/em>&lt;/a>). The consequence for the runbook: if that directory is &lt;strong>a persistent volume shared between pods&lt;/strong> (a PVC, or a &lt;code>hostPath&lt;/code> on the node&amp;rsquo;s NVMe populated by the first start-up), subsequent pods skip the compilation and start faster. The first pod pays for the compilation; the rest inherit the cache.&lt;/p>
&lt;p>It is exactly &amp;ldquo;sharpening the knives the night before&amp;rdquo;: the expensive preparation is done once and reused. The nuance: the cache is sensitive to the vLLM/PyTorch version, the GPU, the quantisation and the configuration, and changing any of them invalidates the cache and you pay for the compilation once again.&lt;/p>
&lt;h3 id="bounding-the-cuda-graph-capture">Bounding the CUDA graph capture&lt;/h3>
&lt;p>vLLM captures CUDA graphs for a &lt;strong>wide range of batch sizes&lt;/strong> by default, and that is expensive: the default capture is around &lt;strong>54 s&lt;/strong>, and on large configurations up to &lt;strong>294 s&lt;/strong> has been measured. By limiting the capture to the batch sizes your workload &lt;strong>really&lt;/strong> uses (for example &lt;code>1, 2, 4, 8, 16, 24, 32, 64&lt;/code>) the cold start is cut by &lt;strong>more than 70%&lt;/strong>, from 294 s to ~82 s in the measured case (&lt;a href="https://developers.redhat.com/articles/2025/09/03/vllm-torchcompile-efficient-llm-inference-pytorch">Red Hat, &lt;em>vLLM with torch.compile&lt;/em>&lt;/a>). The knob is the engine&amp;rsquo;s compilation/CUDA graphs configuration; the principle is not to capture graphs for batch sizes you will never see.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>A real trade-off:&lt;/strong> fewer captured batch sizes = faster start-up but less coverage. A batch outside the captured set falls back to the eager path (slower per request). You bound to the frequent sizes of &lt;strong>your&lt;/strong> traffic, not of any traffic. And there is always the extreme of &lt;strong>disabling CUDA graphs&lt;/strong> (&lt;code>enforce_eager&lt;/code>): near-instant start-up on item 5 at the cost of throughput in the hot state, valid for debugging or for very low-traffic services where cold start weighs more than steady state.&lt;/p>
&lt;/blockquote>
&lt;h3 id="warm-pools-not-paying-for-item-5-on-the-critical-path">Warm pools: not paying for item 5 on the critical path&lt;/h3>
&lt;p>The definitive shortcut for all five items is &lt;strong>not to run them while the user waits&lt;/strong>. A &lt;em>warm pool&lt;/em>, a replica already started, with weights loaded, graphs captured and kernels compiled, waiting for traffic, turns the cold start into zero for that request. It is GPU cost (partly or wholly idle) in exchange for start-up latency; we analyse it in the economics section below.&lt;/p>
&lt;h3 id="sleep-mode-the-shortcut-that-skips-all-five-items">Sleep mode: the shortcut that skips all five items&lt;/h3>
&lt;p>And there is a shortcut halfway between &amp;ldquo;start from scratch&amp;rdquo; and &amp;ldquo;keep a whole replica warm&amp;rdquo;: vLLM&amp;rsquo;s &lt;strong>sleep mode&lt;/strong>, the subject of &lt;a href="https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/">Serving several models on a single GPU&lt;/a>. Instead of killing the process, it &lt;strong>puts it to sleep&lt;/strong>: it parks the weights in host RAM (level 1) or discards them (level 2), but &lt;strong>keeps the process alive&lt;/strong>, and with it the CUDA context, the allocator, &lt;strong>the CUDA graphs and the already compiled JIT kernels&lt;/strong>. The &lt;em>wake&lt;/em> pays for neither item 1, 2, 3 nor 5; it only puts the weights back into VRAM (item 4, and from RAM, not from disk). That is why a wake is &lt;strong>18–200× faster than a full cold start&lt;/strong>, and even level 2, which reloads the weights from the same disk, is still 23–45× faster, &lt;strong>because it skips the other four items&lt;/strong> (&lt;a href="https://blog.vllm.ai/2025/10/26/sleep-mode.html">vLLM Blog, &lt;em>Sleep Mode&lt;/em>&lt;/a>). Sleep mode is living proof of this post&amp;rsquo;s thesis: if preserving items 1, 2, 3 and 5 gives you 18–200×, then &lt;strong>moving bytes was never the whole cost&lt;/strong>.&lt;/p>
&lt;h2 id="an-example-cold-start-item-by-item">An example cold start, item by item&lt;/h2>
&lt;p>Let us put numbers on a cold start of &lt;strong>70B in FP8 (70 GB)&lt;/strong> from local NVMe and see which optimisation attacks each item:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>#&lt;/th>
&lt;th>Line item&lt;/th>
&lt;th>Default&lt;/th>
&lt;th>Optimisation&lt;/th>
&lt;th>Optimised&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>Process init&lt;/td>
&lt;td>~4 s&lt;/td>
&lt;td>(little room; minimal environment)&lt;/td>
&lt;td>~3 s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>CUDA context&lt;/td>
&lt;td>~6 s&lt;/td>
&lt;td>(driver; fixed)&lt;/td>
&lt;td>~6 s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>Allocator&lt;/td>
&lt;td>~4 s&lt;/td>
&lt;td>(fixed)&lt;/td>
&lt;td>~4 s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>Weight loading&lt;/td>
&lt;td>~25 s&lt;/td>
&lt;td>Run:ai streamer / Tensorizer (concurrency + overlap)&lt;/td>
&lt;td>~5 s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>5&lt;/td>
&lt;td>CUDA graphs + JIT&lt;/td>
&lt;td>~54 s&lt;/td>
&lt;td>torch.compile cache + bounded capture&lt;/td>
&lt;td>~10 s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;/td>
&lt;td>&lt;strong>Total&lt;/strong>&lt;/td>
&lt;td>&lt;strong>~93 s&lt;/strong>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;strong>~28 s&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two readings. First: optimising &lt;strong>only&lt;/strong> item 4 (a fast loader and nothing else) brings the total down from 93 to ~73 s, a real but disappointing improvement, because item 5 is still intact. Optimising &lt;strong>only&lt;/strong> item 5 (cache + bounded capture) brings it down to ~49 s. Attacking &lt;strong>both&lt;/strong> brings it down to ~28 s, and that is what turns theoretical scale-to-zero into usable scale-to-zero. Second: items 2 and 3 are practically irreducible, they depend on the driver and the hardware; they constitute the &lt;strong>floor of the cold start&lt;/strong>, on the order of 10 s, which no software optimisation crosses. Below that floor, the only answer is &lt;strong>not to start from scratch&lt;/strong>: sleep mode (wake from sub-second to a few seconds) or a warm replica (zero).&lt;/p>
&lt;h2 id="the-economics-of-scale-to-zero">The economics of scale-to-zero&lt;/h2>
&lt;p>The capacity question all of this serves is: &lt;strong>switch off or keep warm?&lt;/strong> Scale-to-zero saves GPU while there is no traffic, but pays the cold start when it comes back. The decision is a balance between the cost of an idle GPU and the latency SLA.&lt;/p>
&lt;p>The basic criterion: scale-to-zero is only viable if the cold start &lt;strong>fits inside the start-up SLA your users tolerate&lt;/strong>, or if you have a &lt;strong>pre-warming&lt;/strong> mechanism that starts the replica &lt;strong>before&lt;/strong> the request arrives (load prediction, an early signal from the queue). Without pre-warm, a 90 s cold start means the first user after an idle period waits 90 s, unacceptable for almost any interactive SLO. With the cold start brought down to ~28 s it is still a lot for an interactive request, but it is already tolerable for batch workloads or for a pre-warm triggered by &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">autoscaling with KEDA&lt;/a> on queue depth.&lt;/p>
&lt;p>The decision rule, in one sentence: &lt;strong>keep a warm replica when the cost of the idle GPU during the troughs is lower than the cost of missing the SLA on every start-up; do scale-to-zero (with pre-warm) when the troughs are long and deep and the optimised cold start fits in your tolerance&lt;/strong>. For a 24×7 interactive service with irregular but continuous traffic, a floor of warm replicas almost always wins. For a large model invoked a few times a day (a 70B for hard tasks), scale-to-zero with an optimised cold start, or sleep mode if it shares a GPU with another model, is the sensible option. It is the same &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">capacity planning&lt;/a> boundary: cold start is a parameter of the replica cushion, not a start-up detail.&lt;/p>
&lt;h2 id="applied-to-the-generic-4h100-cluster">Applied to the generic 4×H100 cluster&lt;/h2>
&lt;p>Let us bring the runbook down to the &lt;strong>4 H100 SXM 80 GB with NVLink&lt;/strong>. The concrete decisions:&lt;/p>
&lt;p>&lt;strong>Loader and format.&lt;/strong> Weights in &lt;strong>safetensors&lt;/strong> on disk (never pickle). For the short path from local NVMe, the &lt;strong>Run:ai Model Streamer&lt;/strong> (&lt;code>--load-format runai_streamer&lt;/code>, concurrency 16–32) is the default option: concurrent reads, overlapping read and H2D, and you only change one flag. Keep &lt;strong>Tensorizer&lt;/strong> for the case where you serve from object storage (RGW/S3) and want to decouple the weights from the image with no local copy, since its tensor-by-tensor streaming straight to the GPU is what makes that pattern viable. In both cases, FP8 versus BF16 halves item 4 almost for free (measure the quality, do not assume it).&lt;/p>
&lt;p>&lt;strong>Storage tier.&lt;/strong> Model repository on the network (Ceph RGW), hot copy on the &lt;strong>node&amp;rsquo;s local NVMe&lt;/strong> populated by pre-pull. The cold start that counts is the cold one, on a node where the files are not in page cache; serving that start-up from the network puts its latency and contention into the critical path.&lt;/p>
&lt;p>&lt;strong>Caching item 5.&lt;/strong> The &lt;code>torch_compile_cache&lt;/code> directory on a persistent volume or a &lt;code>hostPath&lt;/code> NVMe shared between pods of the same model: the first pod compiles, the rest inherit. And &lt;strong>bound the CUDA graph capture&lt;/strong> to the real batch sizes of each service&amp;rsquo;s traffic.&lt;/p>
&lt;p>&lt;strong>When scale-to-zero with pre-warm vs a warm replica.&lt;/strong> The &lt;strong>agent LLM&lt;/strong> (main service, continuous traffic) &lt;strong>never&lt;/strong> does scale-to-zero: a floor of warm replicas on one or two GPUs. The &lt;strong>occasional 70B&lt;/strong> does: scale-to-zero with an optimised cold start, or, if it shares a GPU with a mid-sized model, &lt;strong>sleep mode&lt;/strong> so the wake is sub-second instead of a 28 s start-up (exactly the play in &lt;a href="https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/">Serving several models on a single GPU&lt;/a>). The optimised cold start in this post is what makes &lt;strong>swap&lt;/strong> and &lt;strong>failover&lt;/strong> between replicas tolerable: a replica that falls over and is replaced in 28 s degrades less than one that takes 90 s. And it connects directly with &lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">multi-tenancy&lt;/a>: a short cold start lets you share GPUs between teams with per-tenant scale-to-zero without the first user of each tenant paying for an endless start-up.&lt;/p>
&lt;p>The cross-cutting principle: &lt;strong>cold start is the ceiling on elasticity&lt;/strong>. The five line items, with both halves attacked, a fast loader for the weights and cache plus bounded capture for the graphs, bring it down from minutes to seconds. And below the irreducible floor of ~10 s (CUDA context + allocator), the only way out is not to start from scratch: sleep mode or a warm replica.&lt;/p>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;p>&lt;strong>&amp;ldquo;I switched to a fast loader and start-up barely improved.&amp;rdquo;&lt;/strong> Your bottleneck probably was not item 4. If CUDA graph capture eats 54 s, bringing weight loading down from 25 to 5 s takes 20 s off 93, which is barely noticeable. Measure &lt;strong>where&lt;/strong> the time goes before optimising: vLLM&amp;rsquo;s start-up logs break down weight loading versus graph capture.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;The second time it started in a flash.&amp;rdquo;&lt;/strong> The page cache (for the weights) and the torch.compile cache (for the graphs) lying to you at the same time. The start-up that matters is the &lt;strong>cold&lt;/strong> one, on a clean node. Benchmarking the second start-up measures a situation that almost never occurs in the peak that triggers the autoscaler.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;Tensorizer/streamer will give me the 18–200× of sleep mode.&amp;rdquo;&lt;/strong> No. Those loaders speed up &lt;strong>only item 4&lt;/strong>. The 18–200× of &lt;a href="https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/">sleep mode&lt;/a> comes from &lt;strong>preserving the other four&lt;/strong> by keeping the process alive. They are tools for different problems: a fast loader is for a real cold start (a new process); sleep mode is for alternating models without killing the process.&lt;/p>
&lt;p>&lt;strong>&amp;quot;&lt;code>enforce_eager&lt;/code> fixes the cold start.&amp;quot;&lt;/strong> It fixes item 5 (no graph capture, near-instant start-up) but &lt;strong>degrades throughput in the hot state&lt;/strong>, since CUDA graphs exist because they speed up the steady state. It is a trade: it wins for very low traffic services where start-up weighs more than the hot path; it loses for a service with sustained load.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;I bound the CUDA graphs to a single batch size and that is it.&amp;rdquo;&lt;/strong> You bound to the sizes &lt;strong>your traffic uses&lt;/strong>, not to one. A batch outside the captured set falls back to the eager path and runs slower. Too aggressive and you penalise the steady state to gain a few seconds of start-up.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;Moving the weights to FP8 also speeds up item 5.&amp;rdquo;&lt;/strong> Not directly. FP8 halves the &lt;strong>bytes&lt;/strong> (item 4) and doubles inference throughput, but graph capture and kernel compilation do not depend on the size of the weights, they depend on the &lt;strong>shape&lt;/strong> of the graph and the batch sizes. Item 5 is attacked with caching and bounded capture, not with quantisation.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM&lt;/a> said it conceptually: moving the weights is one of the five line items of cold start. This runbook turns it into action. For the weights, item 4, there is a clear menu: &lt;strong>safetensors&lt;/strong> as the base (mmap, zero-copy, no pickle), &lt;strong>Run:ai Model Streamer&lt;/strong> for the short path from NVMe (concurrent reads overlapping read and H2D), &lt;strong>Tensorizer&lt;/strong> for streaming from object storage with no local copy, and the cross-cutting lever of &lt;strong>moving fewer bytes&lt;/strong> with FP8. But the forgotten half of the start-up is &lt;strong>item 5&lt;/strong>, CUDA graph capture and JIT compilation, which on a modern server can be as big as the weight loading: you attack it by &lt;strong>caching the compilation&lt;/strong> across pods and &lt;strong>bounding the graph capture&lt;/strong> to the real batch sizes. Attacking only one half leaves the cold start half done; attacking both brings it from ~90 s to ~28 s. And below the irreducible floor of ~10 s imposed by the CUDA context and the allocator, the only way out is &lt;strong>not to start from scratch&lt;/strong>: sleep mode (sub-second wake) or a warm replica (zero). The kitchen opens on time when somebody took care of the produce &lt;strong>and&lt;/strong> of sharpening the knives the night before. Optimising only the delivery from the store leaves the ovens cold and the restaurant shut.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/multimodal-on-premise-serving-vlm-vllm/">Multimodal: serving a VLM on-premise with vLLM&lt;/a> — a sister piece in this batch: adding vision to the same inference engine; a VLM has its own cold start (the image encoder weights on top of the LLM).&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and GPU multi-tenancy with LiteLLM&lt;/a> — a sister piece: a short cold start is what makes per-tenant scale-to-zero viable without penalising the first user of each team.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM: cold start and model loading&lt;/a> — the conceptual post this runbook continues: why moving bytes is only one of five items, and the path disk→page cache→host→PCIe→HBM.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/">Serving several models on a single GPU: swap and sleep mode&lt;/a> — sleep mode as the shortcut that preserves the four non-weight items and delivers the 18–200× wake.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">LLM autoscaling on Kubernetes with KEDA&lt;/a> — cold start is the ceiling on elasticity; pre-warming triggered by queue depth is what makes scale-to-zero usable.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise inference&lt;/a> — cold start as a parameter of the warm replica vs scale-to-zero decision and of the replica cushion.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">Sharing a GPU: time-slicing, MPS and MIG&lt;/a> — sharing out one GPU between services; every co-resident service has its own start-up cost to budget for.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-where-the-bytes-live-registry-oci-artefacts/">Model chain of trust (2/4): registry, OCI artefacts and distribution&lt;/a> — the link before the cold start: which registry the bytes come from, how they are cached near the node and what mounting the model as an OCI artefact adds.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>HuggingFace, &lt;em>Safetensors&lt;/em> (format, zero-copy, security versus pickle): &lt;a href="https://huggingface.co/docs/safetensors/index">https://huggingface.co/docs/safetensors/index&lt;/a>&lt;/li>
&lt;li>HuggingFace, &lt;em>Safetensors is Joining the PyTorch Foundation&lt;/em>: &lt;a href="https://huggingface.co/blog/safetensors-joins-pytorch-foundation">https://huggingface.co/blog/safetensors-joins-pytorch-foundation&lt;/a>&lt;/li>
&lt;li>vLLM, &lt;em>Loading Models with CoreWeave&amp;rsquo;s Tensorizer&lt;/em>: &lt;a href="https://docs.vllm.ai/en/stable/models/extensions/tensorizer/">https://docs.vllm.ai/en/stable/models/extensions/tensorizer/&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>NVIDIA, &lt;em>Reducing Cold Start Latency for LLM Inference with NVIDIA Run:ai Model Streamer&lt;/em> (S3/SSD benchmarks, concurrency): &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 Blog, &lt;em>Introduction to torch.compile and How It Works with vLLM&lt;/em> (compilation cache): &lt;a href="https://blog.vllm.ai/2025/08/20/torch-compile.html">https://blog.vllm.ai/2025/08/20/torch-compile.html&lt;/a>&lt;/li>
&lt;li>vLLM, &lt;em>torch.compile integration&lt;/em> (cache in &lt;code>~/.cache/vllm/torch_compile_cache/&lt;/code>): &lt;a href="https://docs.vllm.ai/en/latest/design/torch_compile/">https://docs.vllm.ai/en/latest/design/torch_compile/&lt;/a>&lt;/li>
&lt;li>Red Hat Developer, &lt;em>vLLM with torch.compile: Efficient LLM inference on PyTorch&lt;/em> (CUDA graph capture ~54 s, bounding batch sizes −70%): &lt;a href="https://developers.redhat.com/articles/2025/09/03/vllm-torchcompile-efficient-llm-inference-pytorch">https://developers.redhat.com/articles/2025/09/03/vllm-torchcompile-efficient-llm-inference-pytorch&lt;/a>&lt;/li>
&lt;li>vLLM Blog, &lt;em>Zero-Reload Model Switching with vLLM Sleep Mode&lt;/em> (wake 18–200×): &lt;a href="https://blog.vllm.ai/2025/10/26/sleep-mode.html">https://blog.vllm.ai/2025/10/26/sleep-mode.html&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Multimodal on-premise: serving a VLM with vLLM (vision + language)</title><link>https://blog.lo0.es/en/posts/multimodal-on-premise-serving-vlm-vllm/</link><pubDate>Thu, 11 Jun 2026 11:40:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/multimodal-on-premise-serving-vlm-vllm/</guid><description>&lt;blockquote>
&lt;p>Third batch of an operational series on squeezing a generic on-premise LLM cluster of 4×H100 SXM. Its sibling pieces in this batch are &lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and GPU multi-tenancy with LiteLLM&lt;/a>, which puts a price on every token, and a visual token costs the same as a text one, and &lt;a href="https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/">Speeding up cold start with Tensorizer&lt;/a>, which matters twice as much here because a VLM loads two models (the vision encoder and the LLM). If the VLM ends up feeding a document assistant, that end-to-end setup with LibreChat and RAG is another story (in preparation).&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>A vision-language model (VLM) is not magic: it is a normal LLM with a &lt;strong>sensory organ&lt;/strong> bolted on the front. Three pieces in series: a &lt;strong>vision encoder&lt;/strong> (a ViT) that looks at the image, a &lt;strong>projector&lt;/strong> or connector (typically an MLP) that translates the encoder&amp;rsquo;s output into the LLM&amp;rsquo;s embedding space, and the usual &lt;strong>LLM&lt;/strong>, which receives those embeddings as if they were just more tokens. The consequence that governs the entire operational design is brutal in its simplicity: &lt;strong>an image becomes visual tokens, and those tokens cost exactly the same as text tokens&lt;/strong>. A high-resolution document page is not &amp;ldquo;a couple of image tokens&amp;rdquo;: it is &lt;strong>hundreds or thousands&lt;/strong> of tokens entering the context, inflating the prefill (compute-bound) and occupying KV cache (memory-bound) just as if you had pasted a thousand words. In Qwen2.5-VL the arithmetic is literal: the number of visual tokens is $H\times W / (14\times14\times4)$, so an $896\times896$ image is &lt;strong>1,024 tokens&lt;/strong> and an A4 page scanned at a decent resolution easily reaches &lt;strong>1,500–2,500 tokens&lt;/strong> for the image alone. This post opens up the anatomy, works through the token-cost maths and translates it into TTFT and VRAM on an 80 GB H100, explains vLLM&amp;rsquo;s multimodal support (&lt;code>--limit-mm-per-prompt&lt;/code>, pixel budget via &lt;code>mm_processor_kwargs&lt;/code>, chat template) and answers the question that really matters: &lt;strong>classic OCR or VLM&lt;/strong>. The short answer: use Tesseract/PaddleOCR for the bulk of documents with clean text, and reserve the VLM, expensive in tokens and in GPU, for the subset that genuinely needs it: complex layout, nested tables, handwriting, stamps, charts. On the generic 4×H100 SXM cluster, with FP8 so it fits.&lt;/p>
&lt;h2 id="the-analogy-the-reader-who-sees-the-page-versus-the-one-who-only-hears-the-transcript">The analogy: the reader who sees the page versus the one who only hears the transcript&lt;/h2>
&lt;p>Picture two experts you ask to review a scanned contract. To the first you read the contract aloud, a blind transcript, word by word. It is fast, efficient, and for 90 % of the clauses it is enough: the text is the text. But there are things you cannot read out: that the signature at the end is handwritten and does not match the typed name; that there is a &amp;ldquo;PAID&amp;rdquo; stamp struck diagonally across the amount; that the table on page 3 has a merged cell that changes who each row applies to; that there is a pen annotation in the margin. The blind transcript loses all of that or flattens it into gibberish.&lt;/p>
&lt;p>To the second expert you hand the whole page, to look at. They see the signature, they see the stamp, they understand the structure of the table because they perceive the lines and the cells, they read the marginal note. They grasp the document as a visual object with layout, not as a river of characters. They are strictly more capable.&lt;/p>
&lt;p>Why not always use the second one, then? Because &lt;strong>seeing costs more mental bandwidth&lt;/strong>. The first expert receives the contract as a flow of words: cheap, linear. The second receives it as an image that their brain has to parcel up, attend to region by region, and reconstruct. They process a great deal more information per page, most of which, in a clean text document, is redundant with what the transcript would already have given them. If you only need to read the clauses, hiring the expert-who-sees for every page is overpaying for capability you do not use.&lt;/p>
&lt;p>That is the entire thesis of this post. The VLM is the expert who sees; OCR is the blind transcript; and &amp;ldquo;mental bandwidth&amp;rdquo; is &lt;strong>tokens&lt;/strong>. The engineering consists of sending the expert-who-sees only the pages where seeing changes the answer, and resolving the rest with the cheap transcript.&lt;/p>
&lt;h2 id="anatomy-of-a-vlm-encoder-projector-llm">Anatomy of a VLM: encoder, projector, LLM&lt;/h2>
&lt;p>A modern VLM of the Qwen-VL family (&lt;a href="https://huggingface.co/docs/transformers/model_doc/qwen2_5_vl">Qwen2.5-VL model card&lt;/a>; &lt;a href="https://arxiv.org/abs/2511.21631">Qwen3-VL Technical Report&lt;/a>) is made up of three blocks in series:&lt;/p>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Vision encoder (ViT).&lt;/strong> A Vision Transformer that receives the image, cuts it into patches and produces an embedding per patch (or per group of patches). In Qwen2.5-VL it is a dynamic-resolution ViT: it accepts images of arbitrary size, resizing them to multiples of 28 and splitting them into 14×14 pixel patches, with &lt;em>window attention&lt;/em> to speed up the processing of large images. There is no &amp;ldquo;canonical size&amp;rdquo; everything is squashed to: a large image produces more patches than a small one.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Projector / connector.&lt;/strong> The ViT&amp;rsquo;s embedding space is not the LLM&amp;rsquo;s. The projector acts as the translator. In Qwen2.5-VL it is elegant and cheap: it groups every 2×2 block of adjacent patches (four ViT tokens), concatenates them and projects them with a two-layer MLP into a single fused token in the LLM&amp;rsquo;s dimension. That fusion factor of 4 is the reason for the $4$ that appears in the denominator of the token formula below: four ViT patches collapse into one visual token that the LLM sees.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>The LLM.&lt;/strong> The usual language transformer. It receives a sequence of embeddings that is a mixture of text tokens (from your prompt) and visual tokens (from the image), all in the same space, and does what it knows how to do: attention over the complete sequence and autoregressive generation. To the LLM, a visual token and a text token are indistinguishable in cost: both go through the same QKV projections, both occupy an entry in the KV cache, both take part in the $O(C^2)$ attention.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="VLM pipeline: image, vision encoder, projector, visual tokens, LLM, answer">
&lt;defs>&lt;marker id="vlm1" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;text x="410" y="22" text-anchor="middle" font-size="15" font-weight="700" fill="currentColor">The VLM pipeline: where the token budget explodes&lt;/text>
&lt;rect x="20" y="60" width="120" height="64" fill="none" stroke="#3b82f6" stroke-width="1.6"/>
&lt;text x="80" y="86" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">Image&lt;/text>
&lt;text x="80" y="104" text-anchor="middle" font-size="11" fill="currentColor">A4 page&lt;/text>
&lt;text x="80" y="118" text-anchor="middle" font-size="11" fill="currentColor">~1100×1500 px&lt;/text>
&lt;rect x="175" y="60" width="130" height="64" fill="none" stroke="#8b5cf6" stroke-width="1.6"/>
&lt;text x="240" y="82" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">ViT encoder&lt;/text>
&lt;text x="240" y="100" text-anchor="middle" font-size="11" fill="currentColor">14×14 patches&lt;/text>
&lt;text x="240" y="114" text-anchor="middle" font-size="11" fill="currentColor">window attn&lt;/text>
&lt;rect x="340" y="60" width="130" height="64" fill="none" stroke="#f59e0b" stroke-width="1.6"/>
&lt;text x="405" y="82" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">Projector&lt;/text>
&lt;text x="405" y="100" text-anchor="middle" font-size="11" fill="currentColor">2×2 fusion → MLP&lt;/text>
&lt;text x="405" y="114" text-anchor="middle" font-size="11" fill="currentColor">÷4 tokens&lt;/text>
&lt;rect x="505" y="60" width="140" height="64" fill="none" stroke="#ef4444" stroke-width="1.6"/>
&lt;text x="575" y="82" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">Visual tokens&lt;/text>
&lt;text x="575" y="100" text-anchor="middle" font-size="11" fill="currentColor">~1500–2500&lt;/text>
&lt;text x="575" y="114" text-anchor="middle" font-size="11" fill="currentColor">enter the context&lt;/text>
&lt;rect x="680" y="60" width="120" height="64" fill="none" stroke="#22c55e" stroke-width="1.6"/>
&lt;text x="740" y="86" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">LLM&lt;/text>
&lt;text x="740" y="104" text-anchor="middle" font-size="11" fill="currentColor">attention over&lt;/text>
&lt;text x="740" y="118" text-anchor="middle" font-size="11" fill="currentColor">text + vision&lt;/text>
&lt;path d="M140,92 L175,92" stroke="currentColor" stroke-width="1.6" fill="none" marker-end="url(#vlm1)"/>
&lt;path d="M305,92 L340,92" stroke="currentColor" stroke-width="1.6" fill="none" marker-end="url(#vlm1)"/>
&lt;path d="M470,92 L505,92" stroke="currentColor" stroke-width="1.6" fill="none" marker-end="url(#vlm1)"/>
&lt;path d="M645,92 L680,92" stroke="currentColor" stroke-width="1.6" fill="none" marker-end="url(#vlm1)"/>
&lt;text x="155" y="44" text-anchor="middle" font-size="11" fill="currentColor">pixels&lt;/text>
&lt;text x="322" y="44" text-anchor="middle" font-size="11" fill="currentColor">~8400 patches&lt;/text>
&lt;text x="487" y="44" text-anchor="middle" font-size="11" fill="currentColor">÷4&lt;/text>
&lt;text x="410" y="185" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">Here the budget explodes: the image is already text to the LLM&lt;/text>
&lt;rect x="120" y="205" width="580" height="26" fill="none" stroke="currentColor" stroke-width="1"/>
&lt;rect x="120" y="205" width="80" height="26" fill="#22c55e"/>
&lt;rect x="200" y="205" width="500" height="26" fill="#ef4444"/>
&lt;text x="160" y="222" text-anchor="middle" font-size="11" fill="#ffffff">prompt ~80 tok&lt;/text>
&lt;text x="450" y="222" text-anchor="middle" font-size="11" fill="#ffffff">visual tokens from the image ~1500–2500 tok&lt;/text>
&lt;text x="410" y="262" text-anchor="middle" font-size="12" fill="currentColor">prefill = process ALL of this (compute-bound) · KV cache = store ALL of this (memory-bound)&lt;/text>
&lt;text x="410" y="284" text-anchor="middle" font-size="12" fill="currentColor">in Qwen2.5-VL: visual tokens = H × W / (14 × 14 × 4)&lt;/text>
&lt;text x="410" y="304" text-anchor="middle" font-size="11" fill="currentColor">896×896 → 1024 tok · one image ≈ a long paragraph… or five pages of text&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The idea to internalise: &lt;strong>the encoder and the projector are the sensory organ, but the inference cost lives in the LLM&lt;/strong>, and that cost is paid in tokens. The ViT adds a fixed prefill overhead (processing the image once), but the recurring bill, the LLM prefill and the KV cache throughout the whole generation, is set by the number of visual tokens the projector spits out. That is why the control lever is not &amp;ldquo;which encoder&amp;rdquo;, but &lt;strong>how many pixels you let through&lt;/strong>.&lt;/p>
&lt;h2 id="the-maths-what-it-costs-to-see-a-page">The maths: what it costs to see a page&lt;/h2>
&lt;p>Let us put numbers on &amp;ldquo;an image costs many tokens&amp;rdquo;, with the real Qwen2.5-VL formula (&lt;a href="https://qwenlm.github.io/blog/qwen2.5-vl/">Qwen team, Qwen2.5-VL blog&lt;/a>):&lt;/p>
$$N_{\text{tok}} = \frac{H \times W}{14 \times 14 \times 4} = \frac{H \times W}{784}$$
&lt;p>where $H$ and $W$ are height and width in pixels (rounded to the multiple of 28 the ViT imposes). The $14\times14$ is the patch size; the $\times4$ is the projector&amp;rsquo;s 2×2 fusion. A sanity check against the model card figure: an $896\times896$ image gives&lt;/p>
$$N_{\text{tok}} = \frac{896 \times 896}{784} = \frac{802{,}816}{784} = 1{,}024 \text{ tokens.}$$
&lt;p>It checks out. Now an A4 page scanned at a resolution reasonable for reading small text, say $1120 \times 1568$ px (approximate height $\times$ width, already rounded to multiples of 28):&lt;/p>
$$N_{\text{tok}} = \frac{1120 \times 1568}{784} = \frac{1{,}756{,}160}{784} \approx 2{,}240 \text{ tokens.}$$
&lt;p>&lt;strong>A single page is ~2,240 visual tokens&lt;/strong> before you write a word of prompt. For context: that is roughly what 1,700 words of Spanish text would take up (at ~1.3 tokens/word). If that A4 page contains 600 words of actual text, the VLM is paying &lt;strong>almost 4× in tokens&lt;/strong> to see it rather than read its transcript. If the page is a dense table or a handwritten document that OCR cannot read, that 4× is justified. If it is a paragraph of clean text, it is pure waste.&lt;/p>
&lt;p>Qwen2.5-VL lets you control this with &lt;code>min_pixels&lt;/code> / &lt;code>max_pixels&lt;/code>: the number of tokens per image is dynamic and ranges from about 4 up to 16,384 by default, adjustable with those parameters (&lt;a href="https://huggingface.co/docs/transformers/model_doc/qwen2_5_vl">HF Qwen2.5-VL docs&lt;/a>). Lowering &lt;code>max_pixels&lt;/code> cuts tokens at the expense of resolution, and of the ability to read the fine print. It is exactly the lever of &amp;ldquo;how much mental bandwidth do I give the expert&amp;rdquo;.&lt;/p>
&lt;h3 id="from-token-cost-to-ttft">From token cost to TTFT&lt;/h3>
&lt;p>Visual tokens are not free in latency. The prefill, processing the whole context before the first token, is compute-bound and its cost grows with the context length $C$ (linear in the projections, quadratic in the attention); this is worked through in detail in &lt;a href="https://blog.lo0.es/en/posts/vllm-attention-backend-flashattention-flashinfer-prefill-decode-asymmetry/">vLLM&amp;rsquo;s attention backend&lt;/a>. Every visual token enters that $C$.&lt;/p>
&lt;p>Let us model the TTFT as the time to process the prefill tokens at a given prefill throughput. Take a VLM of around 7–8B on an H100 with an illustrative prefill throughput of ~12,000 tok/s (an example figure; measure it, do not assume it, and it depends on how much the ViT forward pass weighs on top). For a request with an 80-token text prompt plus one page at 2,240 visual tokens:&lt;/p>
$$\text{TTFT} \approx \frac{80 + 2240}{12{,}000 \text{ tok/s}} \approx \frac{2320}{12{,}000} \approx 0{.}19 \text{ s}$$
&lt;p>plus the fixed cost of the vision encoder&amp;rsquo;s forward pass over the ~9,000 ViT patches (another compute-bound slice of prefill). Now five pages in the same request (a short PDF):&lt;/p>
$$\text{TTFT} \approx \frac{80 + 5 \times 2240}{12{,}000} = \frac{11{,}280}{12{,}000} \approx 0{.}94 \text{ s}$$
&lt;p>The TTFT multiplies almost fivefold, because the visual tokens completely dominate the context. And attention scales as $O(C^2)$: five pages are not &amp;ldquo;5× more expensive&amp;rdquo; in the attention component, but ~25× relative to one. The operational lesson: &lt;strong>the number of images and their resolution are your TTFT budget&lt;/strong>, not a detail. Lowering &lt;code>max_pixels&lt;/code> from 2,240 to, say, 1,100 tokens/page (lower resolution, enough for large text) cuts the prefill almost in half.&lt;/p>
&lt;h3 id="vram-the-vlms-weights-and-what-is-left-for-kv">VRAM: the VLM&amp;rsquo;s weights and what is left for KV&lt;/h3>
&lt;p>On an 80 GB H100 SXM, let us budget a VLM of the Qwen-VL family. Take an 8B (LLM) plus the vision encoder (a ViT of a few hundred million parameters; call the whole thing ~8.5B effective parameters for weight purposes). In BF16 (2 bytes/parameter):&lt;/p>
$$M_{\text{BF16}} \approx 8{.}5 \times 10^9 \times 2 \text{ B} \approx 17 \text{ GB.}$$
&lt;p>In FP8 (1 byte/parameter), the weights halve (&lt;a href="https://huggingface.co/Qwen/Qwen3-VL-8B-Instruct-FP8">Qwen3-VL-8B-Instruct-FP8&lt;/a>, with reported quality almost identical to BF16):&lt;/p>
$$M_{\text{FP8}} \approx 8{.}5 \times 10^9 \times 1 \text{ B} \approx 8{.}5 \text{ GB.}$$
&lt;p>For an 8B model this fits comfortably in BF16 on an H100; FP8 matters more for leaving VRAM free for KV cache, and here the KV is the problem, because visual tokens inflate it. The detail of end-to-end FP8 (weights and KV) is in &lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8 end to end&lt;/a>. What is relevant: one page is ~2,240 KV entries; serving several sessions that send long documents consumes KV at a rate a text-only case would never see. With a 32B VLM in FP8 (~32 GB of weights) on an 80 GB H100 you are left with ~45 GB for KV and activations, which with long multimodal contexts run out sooner than &amp;ldquo;text-only&amp;rdquo; intuition suggests. The rule: with a VLM, &lt;strong>plan the VRAM counting the visual tokens in the KV, not just the weights&lt;/strong>.&lt;/p>
&lt;h2 id="serving-a-vlm-with-vllm">Serving a VLM with vLLM&lt;/h2>
&lt;p>vLLM supports image multimodal input natively for the Qwen-VL family and many other VLMs (&lt;a href="https://docs.vllm.ai/en/stable/features/multimodal_inputs/">Multimodal Inputs, vLLM docs&lt;/a>). The gears that matter:&lt;/p>
&lt;p>&lt;strong>&lt;code>--limit-mm-per-prompt&lt;/code>.&lt;/strong> Limits how many multimodal items each prompt accepts, per modality. By default the limit is generous (999 per modality), but in production it is worth setting it low, &lt;code>--limit-mm-per-prompt '{&amp;quot;image&amp;quot;: 2}'&lt;/code>, because every accepted image reserves token and KV budget. If you set a limit of 8 images and someone sends 8 A4 pages, that is ~18,000 visual tokens in a single request: it blows up &lt;code>max_model_len&lt;/code> or the KV. The limit is a budget firebreak, not a cosmetic restriction. (A curious case: setting it to &lt;code>0&lt;/code> for a modality lets you pass &lt;em>precomputed embeddings&lt;/em> without loading the encoder module, saving VRAM, useful if you do the vision encoding outside vLLM.)&lt;/p>
&lt;p>&lt;strong>Pixel budget via &lt;code>mm_processor_kwargs&lt;/code>.&lt;/strong> Here the token formula connects with the configuration. When serving Qwen2.5-VL you pass, for example, &lt;code>--mm-processor-kwargs '{&amp;quot;min_pixels&amp;quot;: 12544, &amp;quot;max_pixels&amp;quot;: 254016}'&lt;/code> (&lt;a href="https://docs.vllm.ai/en/latest/examples/offline_inference/vision_language_multi_image/">vLLM Qwen2.5-VL discussion&lt;/a>). With $254{,}016$ maximum pixels per image, the token ceiling per image is $254{,}016 / 784 \approx 324$ tokens, an aggressive trim that keeps the cost in check at the price of not being able to read very small print. &lt;strong>This is the dial that really governs your token bill&lt;/strong>: raise it for dense documents, lower it for images where fine detail does not matter.&lt;/p>
&lt;p>&lt;strong>The chat template and the image placeholder.&lt;/strong> The model&amp;rsquo;s processor (loaded via &lt;code>AutoProcessor&lt;/code>) inserts special tokens marking where the image goes in the sequence: in Qwen2.5-VL the sequence &lt;code>&amp;lt;|vision_start|&amp;gt;&amp;lt;|image_pad|&amp;gt;&amp;lt;|vision_end|&amp;gt;&lt;/code>, where &lt;code>&amp;lt;|image_pad|&amp;gt;&lt;/code> expands to the exact number of visual tokens the image produced. vLLM applies this template automatically; it is worth knowing there is a documented quirk, the image block tends to be placed before the user&amp;rsquo;s text in the sequence order, regardless of where you put it in the content (&lt;a href="https://github.com/vllm-project/vllm/issues/15125">issue #15125, vllm-project/vllm&lt;/a>), which rarely causes trouble but is worth bearing in mind if the image/text order is semantically important for your prompt.&lt;/p>
&lt;p>&lt;strong>Current models.&lt;/strong> As of June 2026, the reference open family is Qwen3-VL (dense 2B/4B/8B/32B variants and MoE 30B-A3B / 235B-A22B, interleaved contexts of up to 256K tokens combining text, image and video), with published FP8 checkpoints of almost identical quality to BF16 (&lt;a href="https://arxiv.org/abs/2511.21631">Qwen3-VL Technical Report, arXiv 2511.21631&lt;/a>; &lt;a href="https://docs.vllm.ai/projects/recipes/en/latest/Qwen/Qwen3-VL.html">Qwen3-VL Usage Guide, vLLM recipes&lt;/a>). Qwen2.5-VL remains perfectly serviceable and its token formula is the one we have used because it is public and clean; the cost figures are of the same order in both generations.&lt;/p>
&lt;h2 id="classic-ocr-vs-vlm-when-to-use-which">Classic OCR vs VLM: when to use which&lt;/h2>
&lt;p>Here is the decision that pays the bill. Parsing documents to feed them into a &lt;a href="https://blog.lo0.es/en/posts/end-to-end-document-ingestion-pdf-to-indexed-chunk/">document ingestion&lt;/a> pipeline has two paths, and choosing the split between them well is the difference between a healthy cluster and a choking one.&lt;/p>
&lt;p>&lt;strong>Classic OCR (Tesseract, PaddleOCR).&lt;/strong> Text-recognition engines that run &lt;strong>on CPU&lt;/strong>, are cheap, fast and mature. Tesseract is the portable workhorse (edge, offline kiosks, embedded scanners). PaddleOCR is more capable on layout and Asian scripts, with its PP-Structure module for preserving structure, extracting tables and detecting key-value fields (&lt;a href="https://www.codesota.com/ocr/paddleocr-vs-tesseract">PaddleOCR vs Tesseract, CodeSOTA 2026&lt;/a>). On clean, well-scanned text, classic OCR is hard to beat on cost per page: zero GPU, millisecond latency, very high throughput. Its weakness is the &amp;ldquo;hard&amp;rdquo; document: complex multi-column layout, tables with merged cells, handwriting, stamps, charts, poor scan quality. There, pipeline-based OCR either fails or returns gibberish that destroys the structure.&lt;/p>
&lt;p>&lt;strong>VLM.&lt;/strong> It takes the image of the document and generates structured markdown or HTML in a single end-to-end pass, understanding the layout because it &lt;em>sees&lt;/em> it. It shines exactly where classic OCR struggles: nested tables, reading charts, handwriting, understanding the spatial relationship between elements. The price is the one we have been calculating all through this post: expensive in tokens and in GPU. Worth noting a 2026 nuance: the frontier has moved. VLM models specialised in document parsing have appeared, compact (~1–3B), open source, scoring very high on benchmarks such as OmniDocBench (PaddleOCR-VL in the 94–96 point band; dots.ocr 3B at ~88 with full markdown including tables) (&lt;a href="https://parsli.co/blog/llm-ocr-vs-traditional-ocr">LLM-OCR vs Traditional OCR, Parsli 2026&lt;/a>; &lt;a href="https://www.e2enetworks.com/blog/complete-guide-open-source-ocr-models-2025">Open-Source OCR Models, E2E 2025&lt;/a>). In other words: for pure parsing, today you may not want a 32B generalist VLM, but a small specialised VLM-OCR. But &lt;strong>it is still GPU and it still pays tokens per image&lt;/strong>: the cost calculation does not disappear, the model just gets cheaper.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Decision tree: classic OCR versus VLM">
&lt;defs>&lt;marker id="ocr1" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="24" text-anchor="middle" font-size="15" font-weight="700" fill="currentColor">Classic OCR or VLM? Per-document triage&lt;/text>
&lt;rect x="290" y="44" width="200" height="40" rx="6" fill="none" stroke="currentColor" stroke-width="1.5"/>
&lt;text x="390" y="62" text-anchor="middle" font-size="12" fill="currentColor">Is the text clean and&lt;/text>
&lt;text x="390" y="77" text-anchor="middle" font-size="12" fill="currentColor">the layout simple?&lt;/text>
&lt;rect x="60" y="130" width="280" height="60" fill="none" stroke="#22c55e" stroke-width="1.6"/>
&lt;text x="200" y="152" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">YES → classic OCR (CPU)&lt;/text>
&lt;text x="200" y="170" text-anchor="middle" font-size="11" fill="currentColor">Tesseract / PaddleOCR · cheap · ms · no GPU&lt;/text>
&lt;text x="200" y="184" text-anchor="middle" font-size="11" fill="currentColor">80–90 % of documents land here&lt;/text>
&lt;rect x="440" y="130" width="280" height="60" fill="none" stroke="#ef4444" stroke-width="1.6"/>
&lt;text x="580" y="152" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">NO → VLM (GPU)&lt;/text>
&lt;text x="580" y="170" text-anchor="middle" font-size="11" fill="currentColor">tables, handwriting, stamps, charts, layout&lt;/text>
&lt;text x="580" y="184" text-anchor="middle" font-size="11" fill="currentColor">token-expensive · save it for the hard subset&lt;/text>
&lt;path d="M340,84 L200,130" stroke="#22c55e" stroke-width="1.6" fill="none" marker-end="url(#ocr1)"/>
&lt;path d="M440,84 L580,130" stroke="#ef4444" stroke-width="1.6" fill="none" marker-end="url(#ocr1)"/>
&lt;text x="250" y="112" text-anchor="middle" font-size="11" fill="currentColor">yes&lt;/text>
&lt;text x="530" y="112" text-anchor="middle" font-size="11" fill="currentColor">no&lt;/text>
&lt;text x="390" y="226" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">The optimal split: cheap triage first, VLM only for what needs it&lt;/text>
&lt;text x="390" y="250" text-anchor="middle" font-size="11" fill="currentColor">a light classifier (any tables? low OCR confidence? handwriting?) picks the route&lt;/text>
&lt;text x="390" y="270" text-anchor="middle" font-size="11" fill="currentColor">so the VLM only touches 10–20 % of pages, and the GPU does not drown in clean text&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>&lt;strong>The split rule.&lt;/strong> It is not &amp;ldquo;OCR or VLM&amp;rdquo; as a global choice, but &lt;strong>per-document triage&lt;/strong>. Run everything through a cheap first filter: classic OCR already returns a confidence score; if it is high and the document has no tables or visual elements, the OCR transcript will do and you have spent CPU. If confidence is low, there are tables, there is handwriting or there are graphical elements, escalate that page to the VLM. In a typical corpus, 80–90 % of pages are resolved with cheap OCR and only the rest consumes GPU. This is what makes an on-premise VLM sustainable: &lt;strong>you do not serve everything with it&lt;/strong>, you use it as the expensive specialist you send only the hard cases.&lt;/p>
&lt;h2 id="applied-to-the-generic-4h100-cluster">Applied to the generic 4×H100 cluster&lt;/h2>
&lt;p>How this fits into a cluster of 4×H100 SXM 80GB:&lt;/p>
&lt;p>&lt;strong>Where to place the VLM.&lt;/strong> A VLM deserves &lt;strong>a whole H100&lt;/strong> or a large slice, for a VLM-specific reason that text-only intuition does not anticipate: &lt;strong>the token cost per image means not many concurrent sessions fit&lt;/strong>. If each request brings one or several pages, each consumes thousands of KV tokens; the KV cache runs out with few simultaneous sessions. Compared with a text-only chat service where dozens of short conversations fit per GPU, a document service saturates the KV with a handful of heavy requests. That is why it makes no sense to carve the GPU finely for the VLM with aggressive MIG (GPU sharing is covered in &lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">sharing a GPU: time-slicing, MPS and MIG&lt;/a>): the bottleneck is not idle compute but the KV, and a small slice runs out of KV before it makes use of the compute.&lt;/p>
&lt;p>&lt;strong>FP8 so it fits and to free up KV.&lt;/strong> Serve the VLM in FP8: at equal reported quality, the weights take up half and you leave the maximum VRAM for the KV cache, which is the scarce resource with multimodal contexts. For a 32B in FP8 (~32 GB) on an H100, those ~45 GB free for KV are what determine how many concurrent pages you can handle.&lt;/p>
&lt;p>&lt;strong>The VLM only for the subset that needs it.&lt;/strong> The most important architectural piece: &lt;strong>do not put the VLM on the critical path of every document&lt;/strong>. The triage from the previous section lives upstream; the bulk (clean text) is resolved with OCR/PaddleOCR on CPU, and in a cluster with 4×H100 there is probably spare CPU to run it in parallel with the GPUs, and only the hard subset reaches the VLM. This frees the H100s for what really needs them and keeps a reasonable cost per document. It connects directly with &lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and GPU multi-tenancy with LiteLLM&lt;/a>: when you put a price on every token, a visual token costs the same as a text one, so a page through the VLM can cost 4× its transcript, and that should show up in the chargeback of the team that decides to send everything to the VLM for convenience.&lt;/p>
&lt;p>&lt;strong>Double cold start.&lt;/strong> An operational detail that bites: a VLM loads two models into memory, the vision encoder and the LLM, so its cold start is heavier than that of an equivalent LLM. If you are going to do model swapping or on-demand scaling, fast loading matters twice as much; that is where &lt;a href="https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/">speeding up cold start with Tensorizer&lt;/a> comes in. If instead you serve several models on one GPU by rotating them, the swap+sleep pattern (&lt;a href="https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/">serving several models on one GPU&lt;/a>) has to account for the VLM taking up more room and loading more slowly.&lt;/p>
&lt;p>&lt;strong>Consumer GPU, for reference.&lt;/strong> If someone wants to prototype outside the cluster, an RTX 5090 (Blackwell, 32 GB) comfortably serves a 7–8B VLM in FP8 (~8.5 GB of weights) with KV to spare for a few pages per request; a 32B in FP8 (~32 GB) no longer fits with useful KV on that card and calls for the H100. The 5090 is good for validating the triage pipeline and the prompt; the real service belongs on the H100s.&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Video.&lt;/strong> Qwen3-VL handles video with dynamic FPS sampling; each frame is an image and the token cost multiplies by the number of sampled frames. One minute of video can be tens of thousands of tokens. It is the most expensive multimodal beast and deserves its own analysis.&lt;/li>
&lt;li>&lt;strong>Prefix caching of images.&lt;/strong> If the same image (a logo, a template) appears in many requests, can its KV be cached? It depends on the backend and on the stability of the placeholder; it connects with the &lt;a href="https://blog.lo0.es/en/posts/vllm-attention-backend-flashattention-flashinfer-prefill-decode-asymmetry/">attention backend&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Evaluation.&lt;/strong> How to measure that the VLM extracts the table correctly (it is not enough for it to &amp;ldquo;look right&amp;rdquo;): faithfulness against a set with structured ground truth.&lt;/li>
&lt;li>&lt;strong>Fine-tuning the VLM&lt;/strong> for a specific document domain (invoices, particular forms), which can put part of the domain&amp;rsquo;s &amp;ldquo;knowing how to see&amp;rdquo; into the weights and reduce what the prompt has to explain.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and GPU multi-tenancy with LiteLLM&lt;/a> — sibling piece in this batch: when you put a price on every token, a visual token costs the same as a text one, and a page through the VLM can cost 4× its transcript. Chargeback is what disciplines VLM usage.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/">Speeding up cold start with Tensorizer&lt;/a> — sibling piece: a VLM loads two models (encoder + LLM), so its start-up is heavier and fast loading matters twice as much.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/end-to-end-document-ingestion-pdf-to-indexed-chunk/">Document ingestion: from PDF to indexed chunk&lt;/a> — the pipeline where the OCR/VLM triage fits: the bulk through cheap OCR, the hard subset through the VLM.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">Sharing a GPU: time-slicing, MPS and MIG&lt;/a> — why a small slice is no use to the VLM: the bottleneck is the KV that visual tokens eat, not idle compute.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/">Serving several models on one GPU: swap + sleep&lt;/a> — rotating the VLM with other models, allowing for the fact that it takes up more VRAM and loads more slowly because of its double model.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-attention-backend-flashattention-flashinfer-prefill-decode-asymmetry/">vLLM&amp;rsquo;s attention backend&lt;/a> — the compute-bound prefill that every visual token fattens; why five pages cost not 5× but ~25× in attention.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8 end to end: weights and KV&lt;/a> — serving the VLM in FP8 so it fits and, above all, to free up the KV cache that multimodal contexts exhaust sooner than expected.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>vLLM, &lt;em>Multimodal Inputs&lt;/em> (&lt;code>limit_mm_per_prompt&lt;/code>, precomputed embeddings): &lt;a href="https://docs.vllm.ai/en/stable/features/multimodal_inputs/">https://docs.vllm.ai/en/stable/features/multimodal_inputs/&lt;/a>&lt;/li>
&lt;li>vLLM, &lt;em>Vision Language Multi Image&lt;/em> (&lt;code>mm_processor_kwargs&lt;/code>, min/max pixels): &lt;a href="https://docs.vllm.ai/en/latest/examples/offline_inference/vision_language_multi_image/">https://docs.vllm.ai/en/latest/examples/offline_inference/vision_language_multi_image/&lt;/a>&lt;/li>
&lt;li>vLLM, &lt;em>Qwen3-VL Usage Guide&lt;/em> (recipes): &lt;a href="https://docs.vllm.ai/projects/recipes/en/latest/Qwen/Qwen3-VL.html">https://docs.vllm.ai/projects/recipes/en/latest/Qwen/Qwen3-VL.html&lt;/a>&lt;/li>
&lt;li>Qwen team, &lt;em>Qwen2.5-VL&lt;/em> (blog, token formula, dynamic resolution): &lt;a href="https://qwenlm.github.io/blog/qwen2.5-vl/">https://qwenlm.github.io/blog/qwen2.5-vl/&lt;/a>&lt;/li>
&lt;li>&lt;em>Qwen2.5-VL&lt;/em> (Transformers docs, min/max pixels, token range): &lt;a href="https://huggingface.co/docs/transformers/model_doc/qwen2_5_vl">https://huggingface.co/docs/transformers/model_doc/qwen2_5_vl&lt;/a>&lt;/li>
&lt;li>&lt;em>Qwen3-VL Technical Report&lt;/em>, arXiv 2511.21631: &lt;a href="https://arxiv.org/abs/2511.21631">https://arxiv.org/abs/2511.21631&lt;/a>&lt;/li>
&lt;li>&lt;em>Qwen3-VL-8B-Instruct-FP8&lt;/em> (FP8 with quality almost identical to BF16): &lt;a href="https://huggingface.co/Qwen/Qwen3-VL-8B-Instruct-FP8">https://huggingface.co/Qwen/Qwen3-VL-8B-Instruct-FP8&lt;/a>&lt;/li>
&lt;li>vLLM issue #15125 (image placeholder order): &lt;a href="https://github.com/vllm-project/vllm/issues/15125">https://github.com/vllm-project/vllm/issues/15125&lt;/a>&lt;/li>
&lt;li>&lt;em>PaddleOCR vs Tesseract&lt;/em>, CodeSOTA 2026: &lt;a href="https://www.codesota.com/ocr/paddleocr-vs-tesseract">https://www.codesota.com/ocr/paddleocr-vs-tesseract&lt;/a>&lt;/li>
&lt;li>&lt;em>LLM-Based OCR vs Traditional OCR&lt;/em>, Parsli 2026: &lt;a href="https://parsli.co/blog/llm-ocr-vs-traditional-ocr">https://parsli.co/blog/llm-ocr-vs-traditional-ocr&lt;/a>&lt;/li>
&lt;li>&lt;em>Open-Source OCR Models 2025&lt;/em>, E2E Networks: &lt;a href="https://www.e2enetworks.com/blog/complete-guide-open-source-ocr-models-2025">https://www.e2enetworks.com/blog/complete-guide-open-source-ocr-models-2025&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Serving embeddings and rerankers with TEI in production</title><link>https://blog.lo0.es/en/posts/serving-embeddings-rerankers-tei-production/</link><pubDate>Thu, 11 Jun 2026 09:40:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/serving-embeddings-rerankers-tei-production/</guid><description>&lt;blockquote>
&lt;p>Sixth piece in an operational series about squeezing a generic on-premise LLM cluster of &lt;strong>4×H100 SXM 80 GB&lt;/strong>. If &lt;a href="https://blog.lo0.es/en/posts/rag-cpu-data-plane-generation-plane/">RAG on the CPU&lt;/a> argued &lt;em>where&lt;/em> the data plane runs and &lt;a href="https://blog.lo0.es/en/posts/embeddings-2026-dense-sparse-multivector-families/">the embedder zoo&lt;/a> decided &lt;em>which model&lt;/em> you serve, this post looks at &lt;em>the engine&lt;/em> that serves them: the embeddings and rerankers server. The sisters in this batch, &lt;a href="https://blog.lo0.es/en/posts/end-to-end-document-ingestion-pdf-to-indexed-chunk/">document ingestion from PDF to indexed chunk&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/gitops-inference-stack-flux/">GitOps for the inference stack with Flux&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/hardening-secrets-sovereign-llm-stack-defence-in-depth/">hardening and secrets for the sovereign stack&lt;/a>, build the rest of the system around this piece. The closer of the series, the end-to-end sovereign assistant with LibreChat + LiteLLM + RAG, is still in draft.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Serving embeddings is not &lt;code>model.encode(text)&lt;/code> scattered through the ingestion code. Just as you do not put a &lt;code>transformers.generate()&lt;/code> inside your API and call it vLLM, you do not put the embedder inline either: you put it &lt;strong>behind a dedicated server&lt;/strong> that does batching, controls concurrency, emits metrics and exposes a single HTTP contract that both ingestion and query-time reuse. In the 2026 open source ecosystem that server is &lt;strong>TEI, Text Embeddings Inference&lt;/strong> from Hugging Face: a Rust binary that serves embedding, reranker (cross-encoder) and sequence classification models, with &lt;strong>token-based dynamic batching&lt;/strong>, and that exposes OpenAI-compatible endpoints (&lt;code>/v1/embeddings&lt;/code>) on top of the native &lt;code>/embed&lt;/code>, &lt;code>/embed_sparse&lt;/code>, &lt;code>/rerank&lt;/code> and &lt;code>/predict&lt;/code> (&lt;a href="https://github.com/huggingface/text-embeddings-inference">TEI repo&lt;/a>, &lt;a href="https://huggingface.co/docs/text-embeddings-inference">docs&lt;/a>). It runs on CPU (ONNX Runtime, Intel MKL) and on CUDA (with FlashAttention and cuda graphs), with the same contract on both sides, so the rest of the system does not know, or care, what silicon sits behind it. The technical piece that gives almost all the performance is dynamic batching: TEI groups requests that arrive separately into a batch that shares the fixed cost of the forward, controlled by &lt;code>--max-batch-tokens&lt;/code> (how many tokens fit per batch) and &lt;code>--max-concurrent-requests&lt;/code> (backpressure). Grouping raises throughput, going from serving 1 sequence to 32 multiplies tokens/s at almost constant cost until the hardware saturates, in exchange for some latency while the tray fills. The numbers: a 568M encoder (&lt;code>bge-m3&lt;/code>, &lt;code>bge-reranker-v2-m3&lt;/code>) takes ~1.1 GB in fp16 and ~0.57 GB in int8, so several replicas fit even in a small H100 slice; embedding a short query online costs tens of ms, but reranking the top-50 costs &lt;strong>linearly in candidates&lt;/strong> because a cross-encoder evaluates each (query, doc) pair separately. The split across the 4×H100 cluster: TEI-CPU on the fleet for batch ingestion (no SLA, throughput/€), TEI-GPU in a MIG slice of one H100 for high-QPS online embeddings and rerank, talking to ingestion and to the LiteLLM gateway through the OpenAI contract.&lt;/p>
&lt;h2 id="the-analogy-the-stamping-press-that-only-stamps-vectors">The analogy: the stamping press that only stamps vectors&lt;/h2>
&lt;p>Picture a workshop with a specialised &lt;strong>stamping press&lt;/strong>. It does not make varied parts; it does one single thing: it takes flat material and, in one stroke, stamps a shape into it. In our case it takes text and stamps a vector. And like every industrial press, it has one property that defines all its behaviour: &lt;strong>the cost of the stroke is almost fixed&lt;/strong>, no matter how much material you put on the tray.&lt;/p>
&lt;p>Lowering the press, heating the die, aligning, pressing and lifting costs, say, one second. If you put a single sheet in, you spend that whole second stamping one part: a waste. If you fill the tray with thirty sheets and lower the press once, you spend nearly the same second and come out with thirty stamped parts. The cost per part collapses. The press &lt;strong>performs far better when it fills the tray before pressing&lt;/strong>.&lt;/p>
&lt;p>This is exactly the &lt;strong>dynamic batching&lt;/strong> of an embeddings server. The &amp;ldquo;stroke&amp;rdquo; is the model&amp;rsquo;s forward pass on the GPU or the CPU: an operation that loads the weights, multiplies them by the activations and emits the vectors, with a large fixed cost that does not scale with how many sequences you process at once, until you saturate memory bandwidth or the compute units. Serving requests one at a time is stamping one sheet per stroke. Grouping them into a batch is filling the tray.&lt;/p>
&lt;p>But the press has an operational dilemma, and here is the heart of the post. If you wait until the tray is completely full before pressing, the first sheets that arrived sit waiting for the rest: &lt;strong>latency&lt;/strong>. If you press as soon as one sheet drops so nobody has to wait, you go back to the waste of one stroke per part: &lt;strong>low throughput&lt;/strong>. The embeddings server solves this with a &lt;strong>bounded wait window&lt;/strong>: it gathers whatever arrives within a window of microseconds to milliseconds, or until the batch token budget is full, and then it presses. That is the policy that turns a pile of independent requests into a few efficient strokes without anyone waiting too long.&lt;/p>
&lt;p>The rest of the post is, at bottom, about configuring that press properly: how many sheets fit on the tray (&lt;code>max-batch-tokens&lt;/code>), how much queue is tolerated before work is rejected (&lt;code>max-concurrent-requests&lt;/code>), whether the press lives on the cheap CPU fleet (nightly ingestion, pure throughput) or in a slice of the expensive GPU (online, bounded latency), and why the reranker is a different press whose cost does grow with the number of parts.&lt;/p>
&lt;h2 id="what-tei-is-exactly">What TEI is, exactly&lt;/h2>
&lt;p>&lt;strong>Text Embeddings Inference (TEI)&lt;/strong> is Hugging Face&amp;rsquo;s inference server for the family of models that &lt;strong>does not generate tokens&lt;/strong>: embedders, cross-encoder rerankers and sequence classifiers. It is to encoders what vLLM is to generative LLMs: a high-performance server, written in Rust, that handles tokenisation, batching, concurrency, metrics and the HTTP contract, leaving the application code to only make calls (&lt;a href="https://github.com/huggingface/text-embeddings-inference">TEI repo&lt;/a>).&lt;/p>
&lt;p>Three model types, three jobs:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Embedding&lt;/strong> (bi-encoder): turns a text into a reusable vector. &lt;code>bge-m3&lt;/code>, &lt;code>Snowflake Arctic Embed&lt;/code>, &lt;code>multilingual-e5&lt;/code>. The vector is stored in the index and reused on every search.&lt;/li>
&lt;li>&lt;strong>Reranker&lt;/strong> (cross-encoder): takes a pair (query, document) and emits a relevance score, not a vector. &lt;code>bge-reranker-v2-m3&lt;/code>. It produces nothing reusable: every pair is evaluated again.&lt;/li>
&lt;li>&lt;strong>Sequence classification&lt;/strong>: emits labels/probabilities over a text. Useful for lightweight guardrails, routing, language or toxicity detection.&lt;/li>
&lt;/ul>
&lt;h3 id="the-endpoints">The endpoints&lt;/h3>
&lt;p>TEI exposes both an &lt;strong>OpenAI-compatible&lt;/strong> endpoint and its native endpoints, verified against the current documentation (&lt;a href="https://huggingface.co/docs/text-embeddings-inference/quick_tour">Quick Tour&lt;/a>, &lt;a href="https://deepwiki.com/huggingface/text-embeddings-inference">DeepWiki&lt;/a>):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Endpoint&lt;/th>
&lt;th>What for&lt;/th>
&lt;th>Model type&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>&lt;code>/v1/embeddings&lt;/code>&lt;/strong>&lt;/td>
&lt;td>Embeddings with the &lt;strong>OpenAI contract&lt;/strong> (drop-in for clients that already speak OpenAI)&lt;/td>
&lt;td>embedder&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/embed&lt;/code>&lt;/td>
&lt;td>Dense embeddings, TEI&amp;rsquo;s native API&lt;/td>
&lt;td>embedder&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/embed_sparse&lt;/code>&lt;/td>
&lt;td>Sparse embeddings (lexical head of models like &lt;code>bge-m3&lt;/code>/SPLADE)&lt;/td>
&lt;td>embedder&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/rerank&lt;/code>&lt;/td>
&lt;td>Orders a list of texts by relevance against a query&lt;/td>
&lt;td>reranker (cross-encoder)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/predict&lt;/code>&lt;/td>
&lt;td>Sequence classification (labels/scores)&lt;/td>
&lt;td>classifier&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/embed_all&lt;/code>&lt;/td>
&lt;td>Per-token embeddings (no pooling)&lt;/td>
&lt;td>embedder&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/similarity&lt;/code>&lt;/td>
&lt;td>Direct similarity between texts&lt;/td>
&lt;td>embedder&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>/metrics&lt;/code>, &lt;code>/health&lt;/code>, &lt;code>/info&lt;/code>, &lt;code>/tokenize&lt;/code>, &lt;code>/decode&lt;/code>&lt;/td>
&lt;td>Operation, observability and utilities&lt;/td>
&lt;td>all&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The operational key is in the first row: TEI speaking the OpenAI contract on &lt;code>/v1/embeddings&lt;/code> means the embedder is &lt;strong>swappable without touching client code&lt;/strong>. The same code that called OpenAI&amp;rsquo;s &lt;code>text-embedding-3-large&lt;/code> points at your on-prem TEI by changing the &lt;code>base_url&lt;/code>, and the gateway (&lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">LiteLLM&lt;/a>) routes it as one more provider. &lt;code>/rerank&lt;/code>, by contrast, is not part of the OpenAI standard, no such endpoint exists in their API, so the gateway treats it as a reranking-specific endpoint (&lt;a href="https://github.com/BerriAI/litellm/issues/8372">LiteLLM TEI rerank&lt;/a>).&lt;/p>
&lt;h3 id="the-backends">The backends&lt;/h3>
&lt;p>TEI runs on CPU and on GPU with the same HTTP contract, which is exactly the property the &lt;a href="https://blog.lo0.es/en/posts/rag-cpu-data-plane-generation-plane/">CPU/GPU split of RAG&lt;/a> needs (&lt;a href="https://huggingface.co/docs/text-embeddings-inference/index">TEI docs, hardware&lt;/a>):&lt;/p>
&lt;ul>
&lt;li>CPU: &lt;code>cpu-*&lt;/code> image. ONNX Runtime (recommended) or Intel MKL backend. It exploits the integer compute paths (AVX-512+VNNI, AMX on 4th-gen Xeon) when the model is in int8. It is the batch ingestion engine.&lt;/li>
&lt;li>CUDA: architecture-specific images (&lt;code>cuda-*&lt;/code>, with variants for different compute capabilities). It uses FlashAttention and cuda graphs to squeeze the GPU. It requires compute capability ≥ 7.5 (Volta is out) and CUDA 12.2+ compatible drivers. It is the high-QPS online engine.&lt;/li>
&lt;li>There is also Metal support (Apple Silicon) and experimental ROCm (AMD Instinct), less relevant to the on-prem case of this post.&lt;/li>
&lt;/ul>
&lt;p>The golden rule: &lt;strong>the same &lt;code>bge-m3&lt;/code> served by TEI-CPU and by TEI-GPU exposes an identical &lt;code>/v1/embeddings&lt;/code>&lt;/strong>. The client does not notice the silicon. That is what lets you put ingestion on CPU and online on GPU without rewriting anything.&lt;/p>
&lt;h2 id="why-a-dedicated-server-and-not-calling-the-model-inline">Why a dedicated server and not calling the model inline&lt;/h2>
&lt;p>The temptation, especially in the prototype, is to load the embedder into the application process and call &lt;code>model.encode()&lt;/code> directly. It works in the demo and breaks in production for five reasons, all solved by the dedicated server:&lt;/p>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Batching.&lt;/strong> Your ingestion code processes documents; your query code embeds one query at a time. Inline, each &lt;code>encode()&lt;/code> is an independent forward, one press stroke per sheet. A dedicated server joins requests from different origins (ingestion + several concurrent queries) into a single batch and amortises the fixed cost. This is what really multiplies throughput, and you cannot do it if the model lives inside each isolated process.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Concurrency and backpressure.&lt;/strong> Under load, what happens when 10,000 requests arrive at once? Inline, you run out of memory or queue without control. TEI has &lt;code>--max-concurrent-requests&lt;/code>: above that limit it rejects instead of degrading everyone, which is the correct way to manage overload (&lt;a href="https://github.com/huggingface/text-embeddings-inference/blob/main/docs/source/en/cli_arguments.md">TEI CLI args&lt;/a>).&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Metrics.&lt;/strong> TEI exposes &lt;code>/metrics&lt;/code> in Prometheus format: latencies, batch size, tokens/s, queued requests. Inline you have none of this without hand-instrumenting every &lt;code>encode()&lt;/code>. To &lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">observe the data plane&lt;/a> you need that endpoint.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>A reusable HTTP contract.&lt;/strong> The same server is consumed by batch ingestion, by online query-time and by any other service that needs vectors. One copy of the model in memory, one version point, one place to change the model. Inline, each service loads its own copy and the version drifts on its own.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Lifecycle separation.&lt;/strong> The embedder is updated, restarted or scaled without touching the application. If tomorrow you swap &lt;code>bge-m3&lt;/code> for &lt;code>Snowflake Arctic&lt;/code>, you change the server container, not the code of N services. (Remember the &lt;a href="https://blog.lo0.es/en/posts/embeddings-2026-dense-sparse-multivector-families/">embedder drift&lt;/a> trap: changing embedder forces a reindex; having a single server makes that change atomic and auditable.)&lt;/p>
&lt;/li>
&lt;/ol>
&lt;p>It is the same argument for why nobody serves an LLM with &lt;code>transformers.generate()&lt;/code> in production: you serve it with a dedicated engine. The embedder is no different.&lt;/p>
&lt;h2 id="dynamic-batching-the-mechanics-of-the-press">Dynamic batching: the mechanics of the press&lt;/h2>
&lt;p>Here is the performance engine. TEI does &lt;strong>token-based dynamic batching&lt;/strong>: instead of processing requests one at a time or grouping them by a fixed number of requests, it groups them by token budget (&lt;a href="https://github.com/huggingface/text-embeddings-inference/blob/main/README.md">TEI README&lt;/a>, &lt;a href="https://github.com/huggingface/text-embeddings-inference/discussions/367">Discussion #367&lt;/a>).&lt;/p>
&lt;p>Two knobs rule:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>--max-batch-tokens&lt;/code>&lt;/strong> (the default depends on the build, typically 16384): the total tokens that fit in a batch. With &lt;code>max-batch-tokens=1000&lt;/code> you get 10 requests of 100 tokens, or one of 1000. The docs say it clearly: this number &lt;em>should be as large as possible until the model becomes compute-bound&lt;/em>. It is the size of the press tray, &lt;strong>measured in tokens, not in parts&lt;/strong>, because the real cost scales with tokens, not with number of texts.&lt;/li>
&lt;li>&lt;strong>&lt;code>--max-concurrent-requests&lt;/code>&lt;/strong> (default 512): how many requests it admits in flight before rejecting. It is the backpressure control, not the batching one: it protects against surges by returning an error instead of queuing indefinitely.&lt;/li>
&lt;/ul>
&lt;p>The policy: TEI keeps an internal queue. When there is work, it fills a batch up to the &lt;code>max-batch-tokens&lt;/code> budget (or until there are no more requests waiting) and sends it to the model in one stroke. Requests that arrive while the previous batch is being processed wait in the queue and go into the next one. Watch out for a real operational detail: if &lt;code>max-batch-tokens&lt;/code> is smaller than the model&amp;rsquo;s maximum input length, you can trigger a loop where a long request never fits into a batch (&lt;a href="https://github.com/huggingface/text-embeddings-inference/issues/723">Issue #723&lt;/a>); the token budget has to be at least as large as the longest sequence you accept.&lt;/p>
&lt;h3 id="the-number-throughput-with-batching-vs-without-batching">The number: throughput with batching vs without batching&lt;/h3>
&lt;p>Let us model the press. Let $C$ be the fixed cost of a forward pass (loading weights, launching kernels) and $c$ the marginal cost per sequence inside the batch. For a batch of size $B$, the stroke time is roughly:&lt;/p>
$$t(B) \approx C + c \cdot B$$
&lt;p>as long as the batch fits in the hardware (memory-bandwidth-bound, which is the normal regime of a small encoder on GPU). The throughput, sequences per second, is:&lt;/p>
$$\text{throughput}(B) = \frac{B}{t(B)} = \frac{B}{C + c \cdot B}$$
&lt;p>Let us put in concrete numbers, indicative but of the right order for &lt;code>bge-m3&lt;/code> (568M) on a GPU serving ~256-token sequences. Say $C = 4$ ms of fixed cost per stroke and $c = 0.5$ ms per marginal sequence.&lt;/p>
&lt;p>&lt;strong>Without batching&lt;/strong> ($B=1$):&lt;/p>
$$t(1) = 4 + 0.5 = 4.5 \text{ ms} \quad\Rightarrow\quad \text{throughput} = \frac{1}{4.5\text{ms}} \approx 222 \text{ sec/s}$$
&lt;p>&lt;strong>With batching&lt;/strong> ($B=32$):&lt;/p>
$$t(32) = 4 + 0.5 \times 32 = 20 \text{ ms} \quad\Rightarrow\quad \text{throughput} = \frac{32}{20\text{ms}} = 1600 \text{ sec/s}$$
&lt;p>The batch of 32 multiplies throughput by ~7.2× compared with serving one at a time, because the fixed cost $C$, which at $B=1$ was 89% of the time, is spread across 32. And the asymptotic limit, as $B \to \infty$, is $1/c = 2000$ sec/s: the press cannot go faster than its marginal cost, and we are already at 80% of that ceiling with $B=32$. Going up to $B=128$ would give ~1969 sec/s, a marginal improvement in exchange for a good deal more latency and memory. &lt;strong>Batching has diminishing returns&lt;/strong>: most of the gain is in going from 1 to a few dozen.&lt;/p>
&lt;p>The &lt;strong>latency trade-off&lt;/strong> is the other face. The request that arrived first in the batch of 32 does not see its response until the whole stroke finishes: it waits for the tray to fill plus the 20 ms of the forward. If the 32 requests arrived within a window of, say, 5 ms, the first request suffers ~5 ms of batching wait + 20 ms of compute = 25 ms of latency, against the 4.5 ms it would have had being served alone. The &lt;strong>p99 latency gets worse&lt;/strong>, that is the price of the full tray, but in exchange the system absorbs 7× more load with the same silicon. That is why the correct configuration &lt;strong>depends on the use case&lt;/strong>: in batch ingestion (no user waiting) you maximise &lt;code>max-batch-tokens&lt;/code> and latency does not matter; online (user waiting) you bound the wait window to keep p99 under control even at the cost of some throughput.&lt;/p>
&lt;div class="diagram" style="max-width:840px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 840 380" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Dynamic batching in TEI: incoming requests, queue, token-based batch, forward, responses">
&lt;text x="420" y="24" text-anchor="middle" font-size="15" font-weight="700" fill="currentColor">Token-based dynamic batching in TEI&lt;/text>
&lt;text x="80" y="60" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">Requests&lt;/text>
&lt;text x="80" y="76" text-anchor="middle" font-size="10" fill="currentColor">arrive separately&lt;/text>
&lt;rect x="30" y="92" width="100" height="22" rx="4" fill="none" stroke="#3b82f6" stroke-width="1.2"/>
&lt;text x="80" y="107" text-anchor="middle" font-size="10" fill="currentColor">query A · 80 tok&lt;/text>
&lt;rect x="30" y="120" width="100" height="22" rx="4" fill="none" stroke="#3b82f6" stroke-width="1.2"/>
&lt;text x="80" y="135" text-anchor="middle" font-size="10" fill="currentColor">query B · 120 tok&lt;/text>
&lt;rect x="30" y="148" width="100" height="22" rx="4" fill="none" stroke="#3b82f6" stroke-width="1.2"/>
&lt;text x="80" y="163" text-anchor="middle" font-size="10" fill="currentColor">ingestion · 256 tok&lt;/text>
&lt;rect x="30" y="176" width="100" height="22" rx="4" fill="none" stroke="#3b82f6" stroke-width="1.2"/>
&lt;text x="80" y="191" text-anchor="middle" font-size="10" fill="currentColor">ingestion · 256 tok&lt;/text>
&lt;rect x="30" y="204" width="100" height="22" rx="4" fill="none" stroke="#3b82f6" stroke-width="1.2"/>
&lt;text x="80" y="219" text-anchor="middle" font-size="10" fill="currentColor">query C · 60 tok&lt;/text>
&lt;path d="M135,150 L185,150" stroke="currentColor" stroke-width="1.4" fill="none" marker-end="url(#tb)"/>
&lt;rect x="190" y="80" width="150" height="160" rx="6" fill="#f59e0b" opacity="0.12" stroke="#f59e0b" stroke-width="1.4"/>
&lt;text x="265" y="102" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">Internal queue&lt;/text>
&lt;text x="265" y="120" text-anchor="middle" font-size="10" fill="currentColor">fills the tray up to&lt;/text>
&lt;text x="265" y="134" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">max-batch-tokens&lt;/text>
&lt;rect x="210" y="146" width="110" height="18" rx="3" fill="none" stroke="#f59e0b" stroke-width="1"/>
&lt;rect x="210" y="168" width="110" height="18" rx="3" fill="none" stroke="#f59e0b" stroke-width="1"/>
&lt;rect x="210" y="190" width="110" height="18" rx="3" fill="none" stroke="#f59e0b" stroke-width="1"/>
&lt;text x="265" y="226" text-anchor="middle" font-size="10" font-style="italic" fill="currentColor">bounded window or full budget&lt;/text>
&lt;path d="M345,160 L395,160" stroke="currentColor" stroke-width="1.4" fill="none" marker-end="url(#tb)"/>
&lt;text x="370" y="152" text-anchor="middle" font-size="9" fill="currentColor">batch&lt;/text>
&lt;rect x="400" y="110" width="180" height="100" rx="6" fill="#ef4444" opacity="0.10" stroke="#ef4444" stroke-width="1.6"/>
&lt;text x="490" y="134" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">ONE forward pass&lt;/text>
&lt;text x="490" y="152" text-anchor="middle" font-size="10" fill="currentColor">GPU (CUDA, FlashAttn)&lt;/text>
&lt;text x="490" y="166" text-anchor="middle" font-size="10" fill="currentColor">or CPU (ONNX/AMX int8)&lt;/text>
&lt;text x="490" y="184" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">cost C + c·B&lt;/text>
&lt;text x="490" y="200" text-anchor="middle" font-size="9" font-style="italic" fill="currentColor">"one press stroke"&lt;/text>
&lt;path d="M585,160 L635,160" stroke="currentColor" stroke-width="1.4" fill="none" marker-end="url(#tb)"/>
&lt;text x="730" y="100" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">Responses&lt;/text>
&lt;rect x="660" y="112" width="140" height="20" rx="4" fill="none" stroke="#22c55e" stroke-width="1.2"/>
&lt;text x="730" y="126" text-anchor="middle" font-size="10" fill="currentColor">vector A&lt;/text>
&lt;rect x="660" y="138" width="140" height="20" rx="4" fill="none" stroke="#22c55e" stroke-width="1.2"/>
&lt;text x="730" y="152" text-anchor="middle" font-size="10" fill="currentColor">vector B&lt;/text>
&lt;rect x="660" y="164" width="140" height="20" rx="4" fill="none" stroke="#22c55e" stroke-width="1.2"/>
&lt;text x="730" y="178" text-anchor="middle" font-size="10" fill="currentColor">ingestion vectors&lt;/text>
&lt;rect x="660" y="190" width="140" height="20" rx="4" fill="none" stroke="#22c55e" stroke-width="1.2"/>
&lt;text x="730" y="204" text-anchor="middle" font-size="10" fill="currentColor">vector C&lt;/text>
&lt;line x1="30" y1="270" x2="800" y2="270" stroke="currentColor" stroke-width="0.8"/>
&lt;text x="30" y="292" font-size="11" font-weight="700" fill="currentColor">The trade-off:&lt;/text>
&lt;text x="30" y="312" font-size="11" fill="currentColor">large batch → high throughput (fixed cost C spread over B) but worse p99 (the 1st waits for the fill)&lt;/text>
&lt;text x="30" y="330" font-size="11" fill="currentColor">batch=1 → minimum latency per request but dreadful throughput (one stroke per sheet)&lt;/text>
&lt;text x="30" y="352" font-size="11" font-style="italic" fill="currentColor">Ingestion: maximise max-batch-tokens. Online: bound the wait window to protect p99.&lt;/text>
&lt;defs>&lt;marker id="tb" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;/svg>
&lt;/div>
&lt;h2 id="deployment-compose-for-embedder-and-reranker">Deployment: compose for embedder and reranker&lt;/h2>
&lt;p>TEI is deployed as a container. The key arguments: &lt;code>--model-id&lt;/code> (the model from the Hub), &lt;code>--pooling&lt;/code> (how it aggregates tokens into the vector: &lt;code>cls&lt;/code>, &lt;code>mean&lt;/code>, &lt;code>splade&lt;/code>, &lt;code>last-token&lt;/code>), &lt;code>--dtype&lt;/code> (precision), and the batching knobs already seen. Two separate services, since the embedder and the reranker are &lt;strong>different models and different endpoints&lt;/strong>, each with its own container.&lt;/p>
&lt;h3 id="embedder-bge-m3-on-gpu">Embedder (&lt;code>bge-m3&lt;/code>) on GPU&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># docker-compose: TEI serving bge-m3 as an embedder, on GPU&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">services&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tei-embed&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ghcr.io/huggingface/text-embeddings-inference:cuda-1.9&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">command&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">model-id=BAAI/bge-m3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">pooling=cls&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">dtype=float16&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">--max-batch-tokens=16384 # big tray&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">the press performs&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">--max-concurrent-requests=512 # backpressure&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">reject above this&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;8081:80&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;./hf-cache:/data&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">deploy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">reservations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">devices&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>{&lt;span class="nt">driver: nvidia, count: 1, capabilities&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">gpu]}]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># exposes /v1/embeddings (OpenAI), /embed, /embed_sparse, /metrics&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="reranker-bge-reranker-v2-m3-on-gpu">Reranker (&lt;code>bge-reranker-v2-m3&lt;/code>) on GPU&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tei-rerank&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ghcr.io/huggingface/text-embeddings-inference:cuda-1.9&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">command&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">model-id=BAAI/bge-reranker-v2-m3 &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># cross-encoder, ~568M&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">dtype=float16&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">max-batch-tokens=16384&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">max-concurrent-requests=256&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;8082:80&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;./hf-cache:/data&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">deploy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">reservations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">devices&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>{&lt;span class="nt">driver: nvidia, count: 1, capabilities&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">gpu]}]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># exposes /rerank — invoked ONLY over top-k (20/50), not over hundreds&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>bge-reranker-v2-m3&lt;/code> is a &lt;strong>~568M cross-encoder&lt;/strong> built on &lt;code>bge-m3&lt;/code>, multilingual, that takes (query, document) and emits a relevance score directly, without producing a reusable vector (&lt;a href="https://huggingface.co/BAAI/bge-reranker-v2-m3">model card&lt;/a>, &lt;a href="https://bge-model.com/bge/bge_reranker_v2.html">BGE docs&lt;/a>). It carries no &lt;code>--pooling&lt;/code> because it emits no embedding: it emits a scalar.&lt;/p>
&lt;h3 id="cpu-for-batch-ingestion">CPU for batch ingestion&lt;/h3>
&lt;p>For the CPU fleet, it is enough to change the image and the dtype; the HTTP contract is identical:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tei-embed-cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ghcr.io/huggingface/text-embeddings-inference:cpu-1.6&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">command&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">model-id=BAAI/bge-m3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">pooling=cls&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">dtype=float16 &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># on CPU, ONNX int8 if the export supports it&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">--max-batch-tokens=8192 # smaller tray&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">the CPU saturates sooner&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;8083:80&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;./hf-cache:/data&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># ONNX/MKL backend: exploits AVX-512+VNNI / AMX on 4th-gen Xeon&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Any OpenAI client embeds against the endpoint without knowing what is behind it:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">openai&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">OpenAI&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">client&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">OpenAI&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">base_url&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;http://tei-embed:80/v1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">api_key&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;-&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># api_key ignored&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">r&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">embeddings&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">create&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">model&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;BAAI/bge-m3&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">input&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;user query&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">vec&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">r&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">data&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">embedding&lt;/span> &lt;span class="c1"># same contract as OpenAI; the silicon is invisible&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And the rerank, through its native (non-OpenAI) endpoint:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">requests&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">docs&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;retrieved chunk 1&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;retrieved chunk 2&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;...top-20 from retrieval...&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">r&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">requests&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">post&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;http://tei-rerank:80/rerank&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">json&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;query&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;user query&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;texts&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">docs&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">ranked&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">r&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">json&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="c1"># [{index, score}, ...] ordered by relevance&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="integration-how-ingestion-and-the-gateway-consume-it">Integration: how ingestion and the gateway consume it&lt;/h2>
&lt;p>The TEI server has &lt;strong>two clients&lt;/strong> with opposite profiles, and that is precisely the reason to centralise it:&lt;/p>
&lt;p>&lt;strong>Ingestion&lt;/strong> (batch, no SLA) calls &lt;code>/v1/embeddings&lt;/code> or &lt;code>/embed&lt;/code> with &lt;strong>large lots&lt;/strong> of chunks. Here throughput is what matters: ingestion pushes thousands of texts and TEI groups them into large batches against the high &lt;code>max-batch-tokens&lt;/code>. It is the case of the press with a full tray. Ingestion then writes the vectors (dense + sparse) into the vector DB; the specific PDF-to-indexed-chunk pipeline is the sister piece of this series in preparation, and meanwhile the &lt;a href="https://blog.lo0.es/en/posts/postgresql-qdrant-rag-ingestion-postman-syncing-two-worlds/">PostgreSQL + Qdrant schema in microservices&lt;/a> covers the structure of the pipeline.&lt;/p>
&lt;p>&lt;strong>The gateway&lt;/strong> (query-time, online) consumes TEI through two different routes:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Embedding the query&lt;/strong>: a single call to &lt;code>/v1/embeddings&lt;/code> with a short text. The gateway, &lt;a href="https://blog.lo0.es/en/posts/choosing-oss-gateway-llm-inference/">LiteLLM&lt;/a> or another L7 router, treats it as one more OpenAI embeddings provider, routed by model. That embedded query goes to the vector DB for the hybrid search.&lt;/li>
&lt;li>&lt;strong>Reranking the top-k&lt;/strong>: after retrieving 20-50 candidates from the retriever, the gateway calls &lt;code>/rerank&lt;/code> on the reranker server with the query and the candidate list, and keeps the best ones. Since &lt;code>/rerank&lt;/code> is not an OpenAI standard, LiteLLM exposes it through its own reranking contract (&lt;a href="https://github.com/BerriAI/litellm/issues/8372">LiteLLM rerank with TEI&lt;/a>).&lt;/li>
&lt;/ol>
&lt;p>The full query-time pattern: &lt;em>embed query (TEI dense) → search (vector DB, &lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">hybrid retrieval&lt;/a>) → rerank top-k (TEI rerank) → send query + top-5 to the LLM&lt;/em>. Two different TEI endpoints, one for dense and another for rerank, both behind the gateway, both swappable.&lt;/p>
&lt;h2 id="sizing-memory-replicas-and-latency-budget">Sizing: memory, replicas and latency budget&lt;/h2>
&lt;h3 id="footprint-of-a-568m-encoder">Footprint of a 568M encoder&lt;/h3>
&lt;p>The weight footprint of &lt;code>bge-m3&lt;/code> (568M params) depends only on precision:&lt;/p>
$$\text{fp16: } 568 \times 10^6 \times 2 \text{ B} \approx 1.14 \text{ GB} \qquad \text{int8: } 568 \times 10^6 \times 1 \text{ B} \approx 0.57 \text{ GB}$$
&lt;p>On top of this comes the space for the batch&amp;rsquo;s &lt;strong>temporary activations&lt;/strong> (proportional to &lt;code>max-batch-tokens&lt;/code> × hidden dimension) and, in TEI, the FlashAttention/cuda graphs structures. In practice, a &lt;code>bge-m3&lt;/code> in fp16 is served comfortably in &lt;strong>~6 GB of VRAM&lt;/strong> even at high batch, and in int8 on CPU the whole model (0.57 GB) fits in the cache and RAM of any server without blinking, activating the AMX/VNNI paths that make it viable, which is exactly the argument of &lt;a href="https://blog.lo0.es/en/posts/rag-cpu-data-plane-generation-plane/">RAG on the CPU&lt;/a>.&lt;/p>
&lt;p>&lt;strong>How many replicas fit?&lt;/strong> If we reserve a &lt;strong>10 GB&lt;/strong> slice of an H100 for serving embeddings online, with an effective footprint of ~2.5 GB per replica (fp16 weights + activations + overhead), around &lt;strong>3-4 replicas&lt;/strong> of &lt;code>bge-m3&lt;/code> fit in that slice, splitting the QPS between them. In int8 the footprint drops and more fit, at the cost of a full point of quality. For the reranker, the weight footprint is the same (568M), but its batch pattern is different (query-doc pairs), so it is worth sizing it separately.&lt;/p>
&lt;h3 id="latency-budget-short-query-vs-top-50-rerank">Latency budget: short query vs top-50 rerank&lt;/h3>
&lt;p>Here is the fundamental asymmetry between embedder and reranker, and it is what decides the sizing.&lt;/p>
&lt;p>&lt;strong>Embedding a short query&lt;/strong> (online): it is &lt;strong>one&lt;/strong> forward over a text of tens of tokens. On GPU, inside a dynamic batch, it is &lt;strong>tens of ms&lt;/strong> including the batching wait, the order of the earlier numerical example, ~5-25 ms. It is cheap and constant: a query is a query, no matter how many documents are in the corpus. That is why embedding online fits easily into an interactive latency budget.&lt;/p>
&lt;p>&lt;strong>Reranking the top-50&lt;/strong> (online): a cross-encoder does &lt;strong>not&lt;/strong> produce a reusable vector; it evaluates &lt;strong>every pair&lt;/strong> (query, document) in a forward. The cost is &lt;strong>linear in the number of candidates&lt;/strong>:&lt;/p>
$$\text{cost}_{\text{rerank}} \propto k \times \text{forward}(\text{query} + \text{doc})$$
&lt;p>Reranking a top-50 is &lt;strong>50 forwards&lt;/strong> of (query + doc) pairs, where each pair is longer than the query alone (query + ~256-token chunk). TEI groups them into batches, and batching helps again there, but the &lt;strong>total work&lt;/strong> is ~50× that of embedding one query. If embedding a query costs of the order of 10-20 ms, reranking 50 candidates costs of the order of &lt;strong>hundreds of ms&lt;/strong> depending on the batch and the hardware. Hence the operational rule repeated throughout the series: &lt;strong>broad, cheap recall in the retriever, precision rerank over FEW candidates&lt;/strong>. Reranking a top-20/50 fits in the online budget; reranking hundreds at high QPS does not, and that is where the reranker demands a dedicated GPU or a larger slice. The detail of why the cross-encoder is expensive but precise is in &lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">reranker and hybrid retrieval&lt;/a>.&lt;/p>
&lt;p>The query-time latency budget, added up:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Stage&lt;/th>
&lt;th>Typical cost (order)&lt;/th>
&lt;th>Scales with&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Embed query (TEI dense)&lt;/td>
&lt;td>~10-20 ms&lt;/td>
&lt;td>constant (1 short text)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Hybrid search (vector DB, HNSW+sparse)&lt;/td>
&lt;td>single-digit ms&lt;/td>
&lt;td>index size (sublinear)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Rerank top-k (TEI cross-encoder)&lt;/td>
&lt;td>~100-300 ms&lt;/td>
&lt;td>&lt;strong>linear in k&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Generation (LLM, outside TEI)&lt;/td>
&lt;td>seconds&lt;/td>
&lt;td>output tokens&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The reranker is the most expensive stage of the data plane, and the only one that scales badly with the width of retrieval. Size it as the critical component.&lt;/p>
&lt;h2 id="applied-to-the-generic-4h100-cluster">Applied to the generic 4×H100 cluster&lt;/h2>
&lt;p>Let us come down to the cluster of the series: &lt;strong>4×H100 SXM 80 GB&lt;/strong> plus a generic CPU fleet (Xeon with AMX, NUCs). TEI lives in &lt;strong>two places&lt;/strong> depending on the load profile, and the identical OpenAI contract is what makes each place swappable.&lt;/p>
&lt;p>&lt;strong>TEI-CPU on the fleet → batch ingestion.&lt;/strong> Re-embedding the corpus is throughput-bound work with no latency SLA: its place is the CPU fleet with &lt;code>bge-m3&lt;/code> in int8 (ONNX/AMX). Here you maximise &lt;code>max-batch-tokens&lt;/code> because nobody is waiting, and the press performs with the tray brimming. No H100 should spend a cycle reindexing a corpus that changes once a day, which is exactly the bad split that &lt;a href="https://blog.lo0.es/en/posts/rag-cpu-data-plane-generation-plane/">RAG on the CPU&lt;/a> takes apart. The CPU fleet scales horizontally and cheaply; the corpus is chopped up and spread across nodes.&lt;/p>
&lt;p>&lt;strong>TEI-GPU in a MIG slice → high-QPS online embeddings and rerank.&lt;/strong> Query-time has a latency SLA and, if the system serves many requests per second, it needs the GPU&amp;rsquo;s throughput and low latency. But a 568M encoder &lt;strong>does not deserve a whole H100&lt;/strong>: it takes ~2.5 GB effective, and leaving it 80 GB wastes 97% of the card. The right answer is a &lt;strong>MIG slice&lt;/strong>: partition an H100 into hardware-isolated instances and give TEI one of the small slices (a &lt;code>1g.10gb&lt;/code>, for example), leaving the large slices for generation or for other tenants. The online embedder and reranker live there, with memory and compute isolation guaranteed by the partitioning. The how of the partitioning, MIG, MPS, time-slicing and when to use each, is in &lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">sharing a GPU&lt;/a>; the reading for TEI is direct: &lt;strong>MIG gives the hard isolation that an online service with bounded latency wants&lt;/strong>, while batch ingestion on CPU does not even touch the GPU.&lt;/p>
&lt;p>The split, in one sentence: &lt;strong>ingestion squeezes throughput/€ on CPU with a full tray; online squeezes latency/QPS in a MIG slice of an H100; both speak the same &lt;code>/v1/embeddings&lt;/code> and &lt;code>/rerank&lt;/code>, so moving load from one side to the other is changing a URL in the gateway.&lt;/strong>&lt;/p>
&lt;p>If at some point you need a &lt;strong>7B embedder&lt;/strong> (gte-Qwen2, NV-Embed) whose quality &lt;code>bge-m3&lt;/code> cannot reach, that is no longer small-encoder work: it drags an LLM&amp;rsquo;s cost profile with it and is served where 7Bs are served, with vLLM &lt;code>--task embed&lt;/code> in a large GPU slice, not with TEI-CPU. But it is the occasional exception, not the base load.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>TEI is the stamping press of the RAG data plane: a specialised server that only stamps vectors and scores, and that &lt;strong>performs far better when it fills the tray before pressing&lt;/strong>. The technical piece that gives the performance is token-based dynamic batching, controlled by &lt;code>max-batch-tokens&lt;/code> and &lt;code>max-concurrent-requests&lt;/code>, with a clear trade-off between throughput and p99 latency that is resolved differently in ingestion (tray brimming, no SLA) and online (bounded window, p99 protected). The architectural virtue is the OpenAI-compatible contract on &lt;code>/v1/embeddings&lt;/code>, which makes the embedder swappable without touching code, plus the native &lt;code>/embed_sparse&lt;/code> and &lt;code>/rerank&lt;/code> endpoints for the sparse head of &lt;code>bge-m3&lt;/code> and for the &lt;code>bge-reranker-v2-m3&lt;/code> cross-encoder. The numbers drive the sizing: a 568M encoder fits in gigabytes, not tens of gigabytes, so several replicas live in a MIG slice; embedding a query is cheap and constant, but reranking is linear in candidates and is the critical stage of the latency budget. On the 4×H100 cluster, the right split is TEI-CPU for batch ingestion and TEI-GPU in a MIG slice for high-QPS online: two presses, two silicons, one contract.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/rag-cpu-data-plane-generation-plane/">Taking RAG to the CPU: separating the data plane from the generation plane&lt;/a> — why embeddings ingestion goes to the CPU fleet; TEI-CPU is the concrete engine of that plane.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/embeddings-2026-dense-sparse-multivector-families/">Embeddings in 2026: dense, sparse and multivector&lt;/a> — which model you serve with TEI and why &lt;code>bge-m3&lt;/code> is the multilingual default; this post serves what that one chooses.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">Reranking and hybrid retrieval: fundamentals&lt;/a> — why the cross-encoder is expensive but precise, and the rule of reranking few candidates that sizes TEI&amp;rsquo;s &lt;code>/rerank&lt;/code>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mixed-nvidia-intel-environments-llm-inference/">Mixed NVIDIA + Intel environments: from the H100 cluster to the NUC&lt;/a> — the concrete hardware of the CPU fleet (Xeon AMX, NUC) where TEI-CPU runs for ingestion.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/semantic-cache-rag-receptionist-photographic-memory/">Semantic cache for RAG&lt;/a> — the layer that avoids calling TEI when the query has already been answered, saving even the cost of the online embedding.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">Sharing a GPU: time-slicing, MPS and MIG&lt;/a> — the MIG partitioning where online TEI-GPU lives, coexisting with generation on the same H100.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/end-to-end-document-ingestion-pdf-to-indexed-chunk/">End-to-end document ingestion: from PDF to indexed chunk&lt;/a> — the pipeline that consumes TEI&amp;rsquo;s &lt;code>/v1/embeddings&lt;/code> to populate the index (sister piece).&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Hugging Face — &lt;em>Text Embeddings Inference (TEI)&lt;/em>, repo y docs (Rust, backends CPU ONNX/MKL y CUDA, endpoints OpenAI-compatibles). &lt;a href="https://github.com/huggingface/text-embeddings-inference">https://github.com/huggingface/text-embeddings-inference&lt;/a> · &lt;a href="https://huggingface.co/docs/text-embeddings-inference">https://huggingface.co/docs/text-embeddings-inference&lt;/a>&lt;/li>
&lt;li>Hugging Face — &lt;em>TEI Quick Tour&lt;/em> (endpoints &lt;code>/embed&lt;/code>, &lt;code>/embed_sparse&lt;/code>, &lt;code>/rerank&lt;/code>, &lt;code>/predict&lt;/code>, &lt;code>/v1/embeddings&lt;/code>). &lt;a href="https://huggingface.co/docs/text-embeddings-inference/quick_tour">https://huggingface.co/docs/text-embeddings-inference/quick_tour&lt;/a>&lt;/li>
&lt;li>Hugging Face — &lt;em>TEI CLI arguments&lt;/em> (&lt;code>--model-id&lt;/code>, &lt;code>--pooling&lt;/code>, &lt;code>--dtype&lt;/code>, &lt;code>--max-batch-tokens&lt;/code>, &lt;code>--max-concurrent-requests&lt;/code>). &lt;a href="https://github.com/huggingface/text-embeddings-inference/blob/main/docs/source/en/cli_arguments.md">https://github.com/huggingface/text-embeddings-inference/blob/main/docs/source/en/cli_arguments.md&lt;/a>&lt;/li>
&lt;li>Hugging Face — &lt;em>TEI README / Discussion #367&lt;/em> (token-based dynamic batching; semántica de &lt;code>max-batch-tokens&lt;/code>). &lt;a href="https://github.com/huggingface/text-embeddings-inference/blob/main/README.md">https://github.com/huggingface/text-embeddings-inference/blob/main/README.md&lt;/a> · &lt;a href="https://github.com/huggingface/text-embeddings-inference/discussions/367">https://github.com/huggingface/text-embeddings-inference/discussions/367&lt;/a>&lt;/li>
&lt;li>BAAI — &lt;em>BGE-M3 model card&lt;/em> (568M, XLM-RoBERTa, dense+sparse+colbert, MIT). &lt;a href="https://huggingface.co/BAAI/bge-m3">https://huggingface.co/BAAI/bge-m3&lt;/a>&lt;/li>
&lt;li>BAAI — &lt;em>bge-reranker-v2-m3 model card&lt;/em> (cross-encoder ~568M sobre bge-m3, multilingüe). &lt;a href="https://huggingface.co/BAAI/bge-reranker-v2-m3">https://huggingface.co/BAAI/bge-reranker-v2-m3&lt;/a> · &lt;a href="https://bge-model.com/bge/bge_reranker_v2.html">https://bge-model.com/bge/bge_reranker_v2.html&lt;/a>&lt;/li>
&lt;li>Chen, J., et al. &lt;em>BGE M3-Embedding&lt;/em>. arXiv:2402.03216. &lt;a href="https://arxiv.org/abs/2402.03216">https://arxiv.org/abs/2402.03216&lt;/a>&lt;/li>
&lt;li>BerriAI — &lt;em>LiteLLM: proveedor TEI para &lt;code>/rerank&lt;/code>&lt;/em>. &lt;a href="https://github.com/BerriAI/litellm/issues/8372">https://github.com/BerriAI/litellm/issues/8372&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Serving several models on a single GPU: co-residency, model-swapping and sleep mode</title><link>https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/</link><pubDate>Thu, 11 Jun 2026 02:40:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/</guid><description>&lt;blockquote>
&lt;p>This is the second piece of an operational series on squeezing a generic on-premise LLM cluster of 4×H100 SXM 80 GB with NVLink. Its sibling, &lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">Sharing a GPU: time-slicing, MPS and MIG&lt;/a>, splits the GPU so that several processes can use it at once; this one splits the complementary problem: you have &lt;strong>more models than VRAM&lt;/strong> and you need them to coexist in time, not just in space. The third, &lt;a href="https://blog.lo0.es/en/posts/rag-cpu-data-plane-generation-plane/">The RAG data plane on CPU&lt;/a>, moves off the GPU everything that does not need to be there; and the end-to-end sovereign assistant (fourth instalment, in preparation) assembles all of this behind LibreChat and LiteLLM. Here we assume that VRAM is the scarce resource and that keeping everything loaded at once is not an option.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>You have an agent LLM serving almost all the traffic, one or two rerankers for RAG, an alternate model (another family, another language, a fine-tune) and, now and then, a large model for hard tasks. Added up, they do not fit in the 80 GB of an H100. There are three ways to make them coexist, and the key to not confusing them is to understand that &lt;strong>they are three different states of where the weights live&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Co-residency&lt;/strong>: several models loaded at the same time in HBM, if the sum of their VRAM budgets fits in the physical memory. It is the ideal when they fit: zero switching latency. The catch: each engine must cap its own VRAM; if two engines both believe they own the whole 80 GB, the second one to ask for memory dies with OOM. On a GPU shared by time-slicing, sharing time does not protect memory (the processes still compete for the same HBM) and this problem gets worse, as we see in the &lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">sibling piece&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Model-swapping&lt;/strong> (with llama-swap): only one (or very few) resident at a time. The proxy looks at the request&amp;rsquo;s &lt;code>model&lt;/code> field and, if another one is needed, unloads the current model and loads the requested one. Since there is only one inside, each model can use almost the whole GPU. The cost is a full cold start: seconds from local NVMe, minutes from network storage. It mixes engines: it exposes OpenAI-compatible endpoints including &lt;code>/rerank&lt;/code> with &lt;code>llama-server&lt;/code> on GGUF.&lt;/li>
&lt;li>&lt;strong>vLLM sleep mode&lt;/strong> (&lt;code>--enable-sleep-mode&lt;/code>, endpoints &lt;code>/sleep&lt;/code> and &lt;code>/wake_up&lt;/code>): the model is not unloaded, it is put to sleep. The weights are parked in host RAM (level 1) or discarded while the process stays alive (level 2). The wake is &lt;strong>18–200× faster&lt;/strong> than a cold start because the process is still alive and keeps the CUDA allocator, the CUDA graphs and the compiled JIT kernels. The only thing rebuilt is the KV cache, which is discarded on sleep. It needs enough RAM for the sleeping weights.&lt;/li>
&lt;/ul>
&lt;p>The decision tree is short. &lt;strong>Do they all fit?&lt;/strong> Co-residency. &lt;strong>They do not fit, switching is infrequent and you mix engines?&lt;/strong> llama-swap. &lt;strong>Everything is vLLM, there is RAM to spare and wake latency matters?&lt;/strong> Sleep mode. &lt;strong>Sustained load on all of them at once?&lt;/strong> That is not swapping, it is more GPUs or replicas (see &lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">one big vs N small&lt;/a>). This post puts numbers on each of them on an 80 GB H100.&lt;/p>
&lt;h2 id="the-analogy-one-stage-one-spotlight">The analogy: one stage, one spotlight&lt;/h2>
&lt;p>Picture a theatre with a single stage and a single spotlight, the GPU. You have more actors (models) in the company than fit under the light at once. There are three ways to stage the show:&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>Co-residency: several small actors on stage.&lt;/strong> If the cast for this scene is three slight actors, the main LLM, one reranker, another reranker, they all fit under the spotlight at the same time. Switching lines between them is instantaneous: they are already on stage. But the stage has a fixed size; add a fourth, burly actor (the large model) and they do not fit, so someone falls off the edge. That is the &lt;strong>OOM&lt;/strong>.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Model-swapping: the actor goes home.&lt;/strong> When an actor finishes their part, they go home (the disk). If the next scene needs them again, they have to come back from home: get dressed, put the make-up on, travel to the theatre. That takes minutes. In exchange, while they are on stage they have the whole stage to themselves. That is the llama-swap model: maximum VRAM per actor, but re-entry costs a full round trip.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Sleep mode: the actor waits in the wings.&lt;/strong> Instead of going home, the actor steps out of the spotlight but stays backstage (host RAM), already dressed and made up. When their turn comes, they step in in two strides. No journey, no wardrobe: just crossing the curtain. That is the vLLM wake: the weights come back not from disk but from RAM, and everything that goes into &amp;ldquo;preparing the actor&amp;rdquo;, the allocator, the CUDA graphs, the kernels, is already done because they never left the building.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;p>The operational moral is the same as in the theatre: &lt;strong>the cost of an actor is not only their talent, it is how long they take to walk on when you need them.&lt;/strong> Co-residency pays for the permanent spot; swapping pays for the journey; sleep pays for the RAM of keeping them waiting nearby. The rest of this post is about choosing which, with a VRAM budget and a stopwatch in hand.&lt;/p>
&lt;h2 id="the-three-states-where-the-weights-live">The three states: where the weights live&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 360" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Three states for serving several models: co-residency, swap per request and sleep-wake">
&lt;text x="410" y="24" text-anchor="middle" font-size="15" font-weight="700" fill="currentColor">Where the weights live in each strategy&lt;/text>
&lt;!-- Co-residency -->
&lt;text x="140" y="56" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">Co-residency&lt;/text>
&lt;rect x="30" y="68" width="220" height="120" fill="none" stroke="#3b82f6" stroke-width="1.6"/>
&lt;text x="140" y="86" text-anchor="middle" font-size="11" font-weight="700" fill="#3b82f6">HBM 80 GB&lt;/text>
&lt;rect x="44" y="96" width="192" height="26" fill="#3b82f6" fill-opacity="0.18" stroke="#3b82f6" stroke-width="1"/>
&lt;text x="140" y="113" text-anchor="middle" font-size="10.5" fill="currentColor">agent LLM&lt;/text>
&lt;rect x="44" y="126" width="92" height="22" fill="#22c55e" fill-opacity="0.18" stroke="#22c55e" stroke-width="1"/>
&lt;text x="90" y="141" text-anchor="middle" font-size="10" fill="currentColor">reranker A&lt;/text>
&lt;rect x="144" y="126" width="92" height="22" fill="#22c55e" fill-opacity="0.18" stroke="#22c55e" stroke-width="1"/>
&lt;text x="190" y="141" text-anchor="middle" font-size="10" fill="currentColor">reranker B&lt;/text>
&lt;rect x="44" y="152" width="192" height="28" fill="none" stroke="#888" stroke-width="1" stroke-dasharray="3 3"/>
&lt;text x="140" y="170" text-anchor="middle" font-size="10" fill="currentColor" opacity="0.75">KV + activations headroom&lt;/text>
&lt;text x="140" y="206" text-anchor="middle" font-size="10.5" fill="currentColor">switch: 0 ms · sum &amp;lt; 80 GB or OOM&lt;/text>
&lt;!-- Swap -->
&lt;text x="410" y="56" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">Model-swapping (llama-swap)&lt;/text>
&lt;rect x="300" y="68" width="220" height="120" fill="none" stroke="#3b82f6" stroke-width="1.6"/>
&lt;text x="410" y="86" text-anchor="middle" font-size="11" font-weight="700" fill="#3b82f6">HBM 80 GB&lt;/text>
&lt;rect x="314" y="96" width="192" height="84" fill="#f59e0b" fill-opacity="0.18" stroke="#f59e0b" stroke-width="1.2"/>
&lt;text x="410" y="135" text-anchor="middle" font-size="11" fill="currentColor">one resident model&lt;/text>
&lt;text x="410" y="151" text-anchor="middle" font-size="10" fill="currentColor" opacity="0.8">uses almost all the VRAM&lt;/text>
&lt;text x="410" y="206" text-anchor="middle" font-size="10.5" fill="currentColor">the rest on DISK (NVMe / network)&lt;/text>
&lt;rect x="330" y="222" width="160" height="22" fill="#ef4444" fill-opacity="0.12" stroke="#ef4444" stroke-width="1"/>
&lt;text x="410" y="237" text-anchor="middle" font-size="10" fill="#ef4444">switch: unload + load (s–min)&lt;/text>
&lt;!-- Sleep -->
&lt;text x="680" y="56" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">Sleep mode (vLLM)&lt;/text>
&lt;rect x="570" y="68" width="220" height="120" fill="none" stroke="#3b82f6" stroke-width="1.6"/>
&lt;text x="680" y="86" text-anchor="middle" font-size="11" font-weight="700" fill="#3b82f6">HBM 80 GB&lt;/text>
&lt;rect x="584" y="96" width="192" height="60" fill="#f59e0b" fill-opacity="0.18" stroke="#f59e0b" stroke-width="1.2"/>
&lt;text x="680" y="124" text-anchor="middle" font-size="11" fill="currentColor">awake model&lt;/text>
&lt;text x="680" y="140" text-anchor="middle" font-size="10" fill="currentColor" opacity="0.8">process alive&lt;/text>
&lt;rect x="584" y="160" width="192" height="20" fill="none" stroke="#888" stroke-width="1" stroke-dasharray="3 3"/>
&lt;text x="680" y="174" text-anchor="middle" font-size="9.5" fill="currentColor" opacity="0.7">KV cache (discarded on sleep)&lt;/text>
&lt;text x="680" y="206" text-anchor="middle" font-size="10.5" fill="currentColor">the rest asleep in host RAM&lt;/text>
&lt;rect x="600" y="222" width="160" height="22" fill="#22c55e" fill-opacity="0.14" stroke="#22c55e" stroke-width="1"/>
&lt;text x="680" y="237" text-anchor="middle" font-size="10" fill="#22c55e">wake: RAM→VRAM (hundreds of ms–s)&lt;/text>
&lt;text x="410" y="290" text-anchor="middle" font-size="12.5" font-weight="700" fill="currentColor">The axis is where the weights of the idle models sit&lt;/text>
&lt;text x="410" y="312" text-anchor="middle" font-size="11" fill="currentColor">co-residency: in HBM (cost space) · swap: on disk (cost a trip) · sleep: in RAM (cost RAM)&lt;/text>
&lt;text x="410" y="332" text-anchor="middle" font-size="11" fill="currentColor" opacity="0.8">and that decides the latency of having them available again&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="co-residency-the-vram-budget">Co-residency: the VRAM budget&lt;/h2>
&lt;p>Co-residing is the default option when they fit: zero switching latency because every model is already in HBM. The whole question is do they fit?, and it is answered with a VRAM budget. The VRAM of an inference engine splits, roughly, into three items:&lt;/p>
$$\text{VRAM}_{\text{model}} = \underbrace{P \cdot b}_{\text{weights}} + \underbrace{\text{KV}}_{\text{cache}} + \underbrace{A}_{\text{activations + overhead}}$$
&lt;p>where $P$ is the number of parameters, $b$ the bytes per parameter given the quantisation, KV the KV-cache budget (which scales with concurrency and context length) and $A$ the overhead of activations, CUDA buffers and fragmentation. The hard rule of co-residency is:&lt;/p>
$$\sum_i \text{VRAM}_{\text{model } i} + \text{headroom} \;&lt;\; \text{VRAM}_{\text{physical}}$$
&lt;p>If the sum overflows, no sharing scheme saves you: the first engine to ask for memory beyond the free gap dies with OOM. And here is the critical nuance that connects to the sibling piece: &lt;strong>splitting the GPU by time does not split the memory&lt;/strong>. Under time-slicing, two processes take turns on compute but share the whole HBM with no isolation; if both assume they own the 80 GB, they collide. Only MIG gives memory partitions with a real boundary, as we see in &lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">Sharing a GPU&lt;/a>. To co-reside properly, each engine must cap itself: in vLLM, &lt;code>--gpu-memory-utilization 0.45&lt;/code> tells it &amp;ldquo;do not use more than 45 % of the GPU&amp;rdquo;; in &lt;code>llama-server&lt;/code>, you control the layers on GPU and the KV size. If you do not set those limits, vLLM claims 90 % of the GPU for itself by default, and there is no room left for anyone else.&lt;/p>
&lt;h3 id="a-worked-example-what-fits-in-80-gb">A worked example: what fits in 80 GB&lt;/h3>
&lt;p>Take the realistic scenario. A 32B agent LLM in FP8 ($b = 1$ byte/param):&lt;/p>
$$P \cdot b = 32 \times 10^9 \cdot 1 \text{ B} = 32 \text{ GB of weights}$$
&lt;p>On top of that comes the KV cache. To serve with decent concurrency and agent contexts (which are long: history, tools, documents), a KV budget of ~12 GB is reasonable, plus some ~2 GB of activations and overhead. Total for the main LLM: ~46 GB. That leaves ~34 GB of the 80. Let us see what fits there:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Service&lt;/th>
&lt;th>Size (FP8/INT8)&lt;/th>
&lt;th>KV + overhead&lt;/th>
&lt;th>Total VRAM&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>32B agent LLM&lt;/td>
&lt;td>32 GB&lt;/td>
&lt;td>~14 GB&lt;/td>
&lt;td>&lt;strong>~46 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reranker A (cross-encoder ~0.5B)&lt;/td>
&lt;td>~0.5 GB&lt;/td>
&lt;td>~0.5 GB&lt;/td>
&lt;td>&lt;strong>~1 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reranker B (cross-encoder ~0.5B)&lt;/td>
&lt;td>~0.5 GB&lt;/td>
&lt;td>~0.5 GB&lt;/td>
&lt;td>&lt;strong>~1 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>8B alternate model&lt;/td>
&lt;td>~8 GB&lt;/td>
&lt;td>~4 GB&lt;/td>
&lt;td>&lt;strong>~12 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Sum&lt;/strong>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;strong>~60 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Free headroom&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;strong>~20 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>With this they co-reside comfortably: the 32B LLM, the two rerankers (which are featherweights, a few hundred MB to 1 GB each) and even an 8B alternate, leaving 20 GB of cushion. Rerankers are the textbook case for co-residency: so small that they always fit alongside the main LLM, and rotating them would be absurd. Switching between them is instantaneous because they are all loaded.&lt;/p>
&lt;p>When does it break? When the occasional large model arrives, a 70B in FP8:&lt;/p>
$$70 \times 10^9 \cdot 1 \text{ B} = 70 \text{ GB of weights}$$
&lt;p>The 70B&amp;rsquo;s weights alone already eat 70 of the 80 GB. There is no way to co-reside it with the 32B, not remotely. That is where co-residency ends and rotation begins: the 70B can only come in if we evict the 32B from the GPU. That is the exact boundary: co-reside while the sum of budgets fits with headroom; rotate as soon as one model needs, on its own, more VRAM than is left over.&lt;/p>
&lt;h2 id="model-swapping-with-llama-swap-the-actor-goes-home">Model-swapping with llama-swap: the actor goes home&lt;/h2>
&lt;p>When they do not fit at once, the first answer is to rotate: keep one resident model and change it on demand. The canonical tool for this in the on-premise world is &lt;a href="https://github.com/mostlygeek/llama-swap">llama-swap&lt;/a>, a Go proxy that sits in front of your inference servers (llama.cpp, vLLM, TabbyAPI and so on) and starts and stops them as needed.&lt;/p>
&lt;p>The mechanism is elegant in its simplicity. Every OpenAI-compatible request carries a &lt;code>model&lt;/code> field. llama-swap reads that field, looks at which upstream server it has configured for that model, and:&lt;/p>
&lt;ol>
&lt;li>If the requested model is already loaded, it routes the request straight through.&lt;/li>
&lt;li>If another one is loaded, it stops it (freeing its VRAM) and starts the right one.&lt;/li>
&lt;li>When the new server reports ready, it forwards the request.&lt;/li>
&lt;/ol>
&lt;p>The cost of a swap is exactly a full cold start of the new model: unloading the current one (fast, just freeing memory) plus loading the new one (slow, moving the weights from disk to HBM). That second term is the painful one, and its magnitude is decided by where the weights are: from local NVMe it is seconds; from network storage (Ceph RGW, NFS) it can be minutes. The whole analysis of the load path, and why the default loader makes it slow, is in &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM&lt;/a>; here the consequence is enough: &lt;strong>a swap is only viable if it is infrequent&lt;/strong>, because every switch pays that toll in full.&lt;/p>
&lt;p>The advantage in exchange: since there is only one resident, that model can use almost the whole GPU. The 70B that co-resides with nobody fits comfortably if it is the only one inside. And llama-swap mixes engines: you can have a vLLM configured for the big LLM, a &lt;code>llama-server&lt;/code> with GGUF for the alternate, and two &lt;code>llama-server&lt;/code> instances for the rerankers, which expose &lt;code>/rerank&lt;/code>, &lt;code>/v1/rerank&lt;/code> and &lt;code>/v1/reranking&lt;/code> natively. That heterogeneity (GGUF + rerank + vLLM behind a single OpenAI endpoint) is exactly what vLLM alone does not give you, and the main reason to choose llama-swap.&lt;/p>
&lt;h3 id="example-configmap-two-rerankers-rotating-on-the-same-port">Example ConfigMap: two rerankers rotating on the same port&lt;/h3>
&lt;p>A concrete and useful case: you have two rerankers, one multilingual and one specialised in code, that you do not need at the same time and would rather not keep both resident. With llama-swap they rotate on the same endpoint, triggered by the &lt;code>model&lt;/code> field. The &lt;code>ConfigMap&lt;/code> (mounted as the proxy&amp;rsquo;s &lt;code>config.yaml&lt;/code> in a Kubernetes deployment) would be:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ConfigMap&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llama-swap-rerankers&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">data&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">config.yaml&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> # How long a model stays loaded after the last request
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> # before llama-swap unloads it to free VRAM
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> healthCheckTimeout: 60
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> models:
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> # Multilingual reranker (GGUF, via llama-server)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> &amp;#34;reranker-multilang&amp;#34;:
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> cmd: &amp;gt;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> /usr/bin/llama-server
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> --model /models/bge-reranker-v2-m3.Q8_0.gguf
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> --reranking
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> --port ${PORT}
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> --n-gpu-layers 99
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> --ctx-size 8192
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> # ttl: after 300 s idle, it is unloaded and the GPU is freed
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> ttl: 300
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> # Code reranker (GGUF, via llama-server)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> &amp;#34;reranker-code&amp;#34;:
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> cmd: &amp;gt;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> /usr/bin/llama-server
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> --model /models/codereranker.Q8_0.gguf
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> --reranking
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> --port ${PORT}
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> --n-gpu-layers 99
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> --ctx-size 8192
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> ttl: 300&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>A request to &lt;code>/v1/rerank&lt;/code> with &lt;code>&amp;quot;model&amp;quot;: &amp;quot;reranker-multilang&amp;quot;&lt;/code> starts that server; the next one with &lt;code>&amp;quot;model&amp;quot;: &amp;quot;reranker-code&amp;quot;&lt;/code> stops the multilingual one and starts the code one on the same &lt;code>${PORT}&lt;/code> that llama-swap manages. Since both are small, the swap between them takes one or two seconds: quantised GGUFs weigh a few hundred MB and come from local NVMe. The &lt;code>ttl&lt;/code> controls how long a model stays loaded after its last request: raising it avoids swaps if requests arrive in bursts; lowering it frees the GPU sooner for other uses.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>An honest caveat:&lt;/strong> this pattern of &lt;em>two rerankers that rotate&lt;/em> makes sense when they do not fit alongside the rest, or when you want to reserve the VRAM for something else. If they do fit (and a couple of 0.5 GB rerankers almost always fit, as we saw above), co-residing them is strictly better: zero swap latency. llama-swap shines when you rotate large models or mix engines that do not coexist well, not for juggling tiny models that would fit together.&lt;/p>
&lt;/blockquote>
&lt;h2 id="vllm-sleep-mode-the-actor-in-the-wings">vLLM sleep mode: the actor in the wings&lt;/h2>
&lt;p>Swapping has a problem: every switch pays the entire cold start, and a cold start is not just moving weights. As we saw in &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM&lt;/a>, starting an inference engine includes initialising the Python process and the CUDA context, setting up the memory allocator, capturing the CUDA graphs and compiling the JIT kernels (DeepGEMM, FlashInfer, TorchInductor). Moving the weights is only one of five items, and often not the largest.&lt;/p>
&lt;p>vLLM&amp;rsquo;s sleep mode (&lt;code>--enable-sleep-mode&lt;/code>) attacks exactly that: instead of killing the process when a model stops being used, it puts it to sleep, leaving the process alive. There are two levels, and the difference is where the weights go:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Level 1:&lt;/strong> offloads the weights to host RAM (CPU) and discards the KV cache. The process stays alive. The wake copies the weights from RAM to VRAM, not from disk. Typical wake: ~0.1–0.8 s for small models, ~3–6 s for large ones. It needs enough RAM for the sleeping weights (on the order of the model&amp;rsquo;s size in GB).&lt;/li>
&lt;li>&lt;strong>Level 2:&lt;/strong> discards the weights entirely, keeping only small buffers (rope scaling tensors and the like). The wake does reload the weights from disk, but &lt;strong>everything else, process, allocator, CUDA graphs, JIT kernels, is already done&lt;/strong>. Typical wake: ~0.8–2.6 s for small models. RAM use is almost nil (megabytes).&lt;/li>
&lt;/ul>
&lt;p>The key that explains the numbers: at both levels, keeping the process alive preserves the expensive infrastructure. That is why the benchmark in the &lt;a href="https://blog.vllm.ai/2025/10/26/sleep-mode.html">vLLM blog (Oct 2025)&lt;/a> reports that a wake is 18–200× faster than a full reload, and, most counter-intuitively, &lt;strong>level 2 is still 23–45× faster&lt;/strong> than a cold start despite reloading the weights from the same disk, because it skips the other four items. In their measurements on an A100, a full cycle of 5 model switches goes from 357 s without sleep (≈48 s per switch) to 112 s with level 1 (wake of 0.26 s / 0.82 s) or 125 s with level 2 (0.85 s / 2.58 s).&lt;/p>
&lt;p>The &lt;strong>KV cache is always discarded on sleep.&lt;/strong> That is not a minor detail: it means the first response after the wake rebuilds the KV from scratch, paying a full prefill. That is why the wake is not &amp;ldquo;entirely free&amp;rdquo;: the model is available in sub-second time, but the first request is a little slower until the KV is repopulated. A one-request warm-up after the wake hides that cost.&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"># Start vLLM with sleep mode (admin endpoints, trusted networks only)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">export&lt;/span> &lt;span class="nv">VLLM_SERVER_DEV_MODE&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="m">1&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve &amp;lt;model&amp;gt; --enable-sleep-mode --port &lt;span class="m">8001&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Sleep (level 1: weights to host RAM)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">curl -X POST &lt;span class="s1">&amp;#39;localhost:8001/sleep?level=1&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Wake up&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">curl -X POST &lt;span class="s1">&amp;#39;localhost:8001/wake_up&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;blockquote>
&lt;p>&lt;strong>Security warning&lt;/strong> (from vLLM&amp;rsquo;s own blog): the &lt;code>/sleep&lt;/code>, &lt;code>/wake_up&lt;/code>, &lt;code>/collective_rpc&lt;/code> and &lt;code>/reset_prefix_cache&lt;/code> endpoints require &lt;code>VLLM_SERVER_DEV_MODE=1&lt;/code> and should only be exposed on trusted networks, since they can take the service down. They are for internal orchestration (a controller that sleeps and wakes models according to the queue), not for the public plane.&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-latency-maths-why-the-wake-wins">The latency maths: why the wake wins&lt;/h2>
&lt;p>Let us put numbers on &amp;ldquo;the wake comes from RAM, not from disk&amp;rdquo;. The cost of having a model available is, in each strategy, the time to move its weights from wherever they are to HBM, plus, in the swap case, the other four cold-start costs. Take the 34 GB of weights of the 32B LLM in FP8 and compare the three paths.&lt;/p>
&lt;p>&lt;strong>Cold start from NVMe (swap).&lt;/strong> A reasonable Gen4/Gen5 NVMe gives on the order of ~5 GB/s effective per stream with the default loader (the disk&amp;rsquo;s theoretical floor is higher, but single-threaded deserialisation does not saturate it, see &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM&lt;/a>). For 34 GB:&lt;/p>
$$t_{\text{NVMe→HBM}} \approx \frac{34 \text{ GB}}{5 \text{ GB/s}} \approx 6.8 \text{ s just moving bytes}$$
&lt;p>And that is before adding CUDA graph capture and kernel compilation, which add several more seconds. The real cold start of a 32B is around 15–40 s depending on loader and storage. From the network (Ceph RGW), multiply.&lt;/p>
&lt;p>&lt;strong>Level 1 wake from RAM.&lt;/strong> The weights come not from disk but from host RAM, and travel over PCIe Gen5 x16, whose practical host→GPU bandwidth is ~50–64 GB/s. For the same 34 GB:&lt;/p>
$$t_{\text{RAM→HBM}} \approx \frac{34 \text{ GB}}{55 \text{ GB/s}} \approx 0.62 \text{ s}$$
&lt;p>And there is nothing else to pay: the allocator, the graphs and the kernels are already there. The real wake of a model this size lands in the sub-second to a few seconds range that vLLM reports. The speed-up against an NVMe cold start is on the order of:&lt;/p>
$$\frac{t_{\text{cold start}}}{t_{\text{wake}}} \approx \frac{15\text{–}40 \text{ s}}{0.6\text{–}3 \text{ s}} \approx 10\text{–}60\times$$
&lt;p>consistent with the 18–200× from the blog, whose measurements include smaller models, where the relative weight of the preserved CUDA graphs is even larger and the factor goes up.&lt;/p>
&lt;p>&lt;strong>Why the bandwidth difference explains almost everything.&lt;/strong> The key jump is not 5 vs 55 GB/s (a ~11× in transport). It is that the cold start also pays for rebuilding infrastructure, which the wake skips entirely. The table:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Path&lt;/th>
&lt;th>Weight source&lt;/th>
&lt;th>Bandwidth&lt;/th>
&lt;th>34 GB bytes only&lt;/th>
&lt;th>+ CUDA graphs / JIT&lt;/th>
&lt;th>Realistic total&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Cold start (swap) NVMe&lt;/td>
&lt;td>disk&lt;/td>
&lt;td>~5 GB/s&lt;/td>
&lt;td>~6.8 s&lt;/td>
&lt;td>yes (several s)&lt;/td>
&lt;td>&lt;strong>15–40 s&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cold start (swap) network&lt;/td>
&lt;td>network&lt;/td>
&lt;td>~1–2 GB/s&lt;/td>
&lt;td>17–34 s&lt;/td>
&lt;td>yes&lt;/td>
&lt;td>&lt;strong>30 s – min&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Level 1 wake&lt;/td>
&lt;td>host RAM&lt;/td>
&lt;td>~50–64 GB/s&lt;/td>
&lt;td>~0.6 s&lt;/td>
&lt;td>&lt;strong>no&lt;/strong> (preserved)&lt;/td>
&lt;td>&lt;strong>0.6–3 s&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Level 2 wake&lt;/td>
&lt;td>disk&lt;/td>
&lt;td>~5 GB/s&lt;/td>
&lt;td>~6.8 s&lt;/td>
&lt;td>&lt;strong>no&lt;/strong> (preserved)&lt;/td>
&lt;td>&lt;strong>7–10 s&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Look at the level 2 row: it reloads the weights from the same disk as the swap (~6.8 s of bytes), but since it does not rebuild graphs or kernels, its total (~7–10 s) still beats a full cold start (15–40 s). It is the proof that &lt;strong>moving bytes is only part of the cost&lt;/strong>, and the part sleep mode exploits.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Assumptions, honestly:&lt;/strong> the bandwidths are indicative. NVMe &amp;ldquo;5 GB/s effective&amp;rdquo; assumes the default loader; with a concurrent streamer it goes up. PCIe &amp;ldquo;55 GB/s&amp;rdquo; assumes Gen5 x16 with a &lt;em>pinned&lt;/em> buffer and NUMA locality; if the buffer lands on the wrong socket, it drops. And the &amp;ldquo;15–40 s&amp;rdquo; cold-start range depends on the model, the quantisation and whether the files are in page cache (the &lt;em>&amp;ldquo;the second time it loaded fast&amp;rdquo;&lt;/em> trap). The numbers are there to reason about orders of magnitude, not to size anything without measuring on your hardware.&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-decision-tree">The decision tree&lt;/h2>
&lt;p>The three strategies do not compete: each wins in a different regime. The tree, in order:&lt;/p>
&lt;div class="diagram" style="max-width:760px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 760 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Decision tree: co-residency, llama-swap, sleep mode or more GPUs">
&lt;defs>&lt;marker id="ar" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;rect x="270" y="20" width="220" height="44" fill="currentColor" fill-opacity="0.05" stroke="#888" stroke-width="1.4"/>
&lt;text x="380" y="40" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">Do they ALL fit at once&lt;/text>
&lt;text x="380" y="56" text-anchor="middle" font-size="11" fill="currentColor">(sum of budgets &amp;lt; VRAM)?&lt;/text>
&lt;path d="M380,64 L380,90" stroke="currentColor" stroke-width="1.4" marker-end="url(#ar)"/>
&lt;text x="250" y="84" text-anchor="middle" font-size="11" font-weight="700" fill="#22c55e">yes&lt;/text>
&lt;rect x="40" y="92" width="200" height="50" fill="#22c55e" fill-opacity="0.14" stroke="#22c55e" stroke-width="1.6"/>
&lt;text x="140" y="113" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">Co-residency&lt;/text>
&lt;text x="140" y="130" text-anchor="middle" font-size="10" fill="currentColor">each engine caps its own VRAM&lt;/text>
&lt;path d="M320,86 L240,110" stroke="#22c55e" stroke-width="1.4" marker-end="url(#ar)"/>
&lt;text x="430" y="84" text-anchor="middle" font-size="11" font-weight="700" fill="#ef4444">no&lt;/text>
&lt;rect x="270" y="92" width="220" height="44" fill="currentColor" fill-opacity="0.05" stroke="#888" stroke-width="1.4"/>
&lt;text x="380" y="111" text-anchor="middle" font-size="11.5" font-weight="700" fill="currentColor">Sustained load on&lt;/text>
&lt;text x="380" y="127" text-anchor="middle" font-size="11" fill="currentColor">all of them at once?&lt;/text>
&lt;path d="M490,114 L560,114" stroke="currentColor" stroke-width="1.4" marker-end="url(#ar)"/>
&lt;text x="525" y="106" text-anchor="middle" font-size="11" font-weight="700" fill="#ef4444">yes&lt;/text>
&lt;rect x="565" y="92" width="180" height="50" fill="#ef4444" fill-opacity="0.12" stroke="#ef4444" stroke-width="1.6"/>
&lt;text x="655" y="113" text-anchor="middle" font-size="11.5" font-weight="700" fill="currentColor">More GPUs / replicas&lt;/text>
&lt;text x="655" y="130" text-anchor="middle" font-size="10" fill="currentColor">not swapping: capacity&lt;/text>
&lt;path d="M380,136 L380,170" stroke="currentColor" stroke-width="1.4" marker-end="url(#ar)"/>
&lt;text x="408" y="156" text-anchor="middle" font-size="11" font-weight="700" fill="#22c55e">no&lt;/text>
&lt;rect x="270" y="172" width="220" height="44" fill="currentColor" fill-opacity="0.05" stroke="#888" stroke-width="1.4"/>
&lt;text x="380" y="191" text-anchor="middle" font-size="11.5" font-weight="700" fill="currentColor">All vLLM and is there&lt;/text>
&lt;text x="380" y="207" text-anchor="middle" font-size="11" fill="currentColor">RAM to spare?&lt;/text>
&lt;path d="M270,194 L120,194 L120,250" stroke="currentColor" stroke-width="1.4" marker-end="url(#ar)"/>
&lt;text x="180" y="186" text-anchor="middle" font-size="11" font-weight="700" fill="#ef4444">no (mixed engines)&lt;/text>
&lt;rect x="30" y="252" width="200" height="64" fill="#f59e0b" fill-opacity="0.14" stroke="#f59e0b" stroke-width="1.6"/>
&lt;text x="130" y="275" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">llama-swap&lt;/text>
&lt;text x="130" y="292" text-anchor="middle" font-size="10" fill="currentColor">rotates by `model` field&lt;/text>
&lt;text x="130" y="307" text-anchor="middle" font-size="10" fill="currentColor">GGUF + /rerank + vLLM&lt;/text>
&lt;path d="M490,194 L640,194 L640,250" stroke="currentColor" stroke-width="1.4" marker-end="url(#ar)"/>
&lt;text x="575" y="186" text-anchor="middle" font-size="11" font-weight="700" fill="#3b82f6">yes (latency matters)&lt;/text>
&lt;rect x="540" y="252" width="200" height="64" fill="#3b82f6" fill-opacity="0.14" stroke="#3b82f6" stroke-width="1.6"/>
&lt;text x="640" y="275" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">Sleep mode&lt;/text>
&lt;text x="640" y="292" text-anchor="middle" font-size="10" fill="currentColor">level 1: weights to RAM&lt;/text>
&lt;text x="640" y="307" text-anchor="middle" font-size="10" fill="currentColor">wake 18–200× vs cold&lt;/text>
&lt;text x="380" y="356" text-anchor="middle" font-size="11.5" font-weight="700" fill="currentColor">And they combine: co-reside the light ones + rotate/sleep the big ones&lt;/text>
&lt;text x="380" y="378" text-anchor="middle" font-size="10.5" fill="currentColor" opacity="0.85">rerankers always co-reside · the occasional 70B sleeps or comes in by swap&lt;/text>
&lt;text x="380" y="398" text-anchor="middle" font-size="10.5" fill="currentColor" opacity="0.85">if everything runs flat out at once, no strategy saves you: you need more silicon&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The node most often ignored is the one at the top right: &amp;ldquo;sustained load on all of them at once?&amp;rdquo;. If your four models get constant and simultaneous traffic, neither swapping nor sleep helps, since both assume the models take turns in time. Rotating under sustained load only adds switching latency without solving the underlying problem: &lt;strong>there is not enough compute&lt;/strong>. The answer then is to scale horizontally (more replicas) or spread across more GPUs, a capacity decision analysed in &lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">One big vs N small&lt;/a>. Swapping and sleep are tools for temporally unbalanced workloads: many models, but rarely active at the same time.&lt;/p>
&lt;h2 id="applied-to-the-generic-4h100-cluster">Applied to the generic 4×H100 cluster&lt;/h2>
&lt;p>Let us bring this down to the 4 H100 SXM 80 GB with NVLink. The winning strategy is not to pick one of the three, but to &lt;strong>spread the models across GPUs according to their usage pattern&lt;/strong> and apply to each GPU the strategy it calls for. A reasonable split:&lt;/p>
&lt;p>&lt;strong>H100 #0, the workhorse (co-residency).&lt;/strong> The 32B agent LLM (main service, constant traffic) co-resides with the two rerankers and, if they fit, the 8B alternate. It is the GPU that never rotates: everything living here is used continuously and fits comfortably in 80 GB (the ~60 GB of the example above). Zero switching latency between the LLM and its rerankers, which is exactly what RAG needs, since a fast reranker is useless if you have to wait for a swap every time.&lt;/p>
&lt;p>&lt;strong>H100 #1, light services with MIG.&lt;/strong> If you have many small heterogeneous services, an embedding model, a classifier, a guardrail, an STT/TTS, splitting this GPU with MIG into isolated instances (each with its slice of HBM with a real boundary) gives co-residency with memory isolation, preventing a service that inflates its KV from taking the others down. The detail of when MIG beats time-slicing is in &lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">Sharing a GPU&lt;/a>; the rule here: co-residing light services on one GPU makes sense when they fit and when isolating them is worthwhile, and MIG is the tool for the second part.&lt;/p>
&lt;p>&lt;strong>H100 #2, the occasional large model (sleep mode or swap).&lt;/strong> The 70B that is only invoked for hard tasks does not deserve a dedicated GPU kept awake, since it would sit idle most of the time, burning 700 W for nothing. Two options:&lt;/p>
&lt;ul>
&lt;li>If this GPU also serves a medium model routinely and you only occasionally need the 70B, use sleep mode: put the medium one to sleep (level 1, weights to RAM), wake the 70B. Note that level 1 sleep frees the VRAM, the weights go to RAM, so yes, it fits even though the 70B would not fit alongside an awake medium model. Waking the medium one again is sub-second.&lt;/li>
&lt;li>If the 70B comes as GGUF or you mix engines, llama-swap rotates between the medium model and the 70B by the &lt;code>model&lt;/code> field. Each 70B invocation pays its cold start (seconds from local NVMe), acceptable if it is occasional.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>H100 #3, replica / overflow.&lt;/strong> The fourth GPU absorbs peaks: a replica of the main LLM for when #0&amp;rsquo;s queue grows, or reserve capacity. There is no swapping here: it is pure capacity, the answer to the tree&amp;rsquo;s &amp;ldquo;sustained load&amp;rdquo; node.&lt;/p>
&lt;p>The cross-cutting principle: &lt;strong>co-reside what is used together and continuously (LLM + rerankers); isolate the light and heterogeneous with MIG; sleep or rotate the large and occasional; replicate what saturates.&lt;/strong> The four GPUs do not all do the same thing, each runs the strategy its load pattern calls for. And the NVLink between them matters for something else (tensor parallelism for the 70B if it would not fit even on one; see &lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">One big vs N small&lt;/a>), but for this post&amp;rsquo;s problem, many models, one GPU, the lever is &lt;strong>when each model needs to be awake&lt;/strong>.&lt;/p>
&lt;h2 id="traps-and-things-that-are-not-what-they-seem">Traps and things that are not what they seem&lt;/h2>
&lt;p>&lt;strong>&amp;ldquo;Co-residing is always better if they fit.&amp;rdquo;&lt;/strong> Almost, but watch the KV cache: co-residing two models means splitting the KV budget between them. If the main LLM needs a large KV for high concurrency and long contexts, adding a flatmate cuts that KV and lowers its throughput. Sometimes it is better to give the whole GPU to the main model and rotate the secondary one. Co-residency is not free: the tenant takes room away from the cache of the one that matters.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;Sleep mode is like swapping but faster.&amp;rdquo;&lt;/strong> Not exactly. Swapping releases the process; you can have N models configured and only pay RAM/disk for the resident one. Sleep mode keeps one live process per sleeping model, so each sleeping vLLM still occupies its process slot, its RAM (level 1) and its management footprint. Sleep scales well to a few rotating models; for many (10+), level 2 (minimal RAM) or plain swapping fit better. Do not put 15 models into level 1 sleep and expect the RAM to hold.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;The wake is instantaneous, I lose nothing.&amp;rdquo;&lt;/strong> The model&amp;rsquo;s wake is sub-second, but the KV cache was discarded on sleep. The first request after the wake pays a full prefill to repopulate the KV, slower than normal. If your SLA is strict on the first response after an idle period, add an automatic warm-up after the wake. &lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">Prefix caching&lt;/a> helps make that reprefill cheaper if there are stable prefixes.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;llama-swap with a low &lt;code>ttl&lt;/code> saves me VRAM for free.&amp;rdquo;&lt;/strong> It saves you VRAM while nobody uses that model, but every time it comes back it pays the cold start. An aggressive &lt;code>ttl&lt;/code> on a model with bursty traffic turns every burst into a load wait. The right &lt;code>ttl&lt;/code> depends on the temporal pattern of the requests, not on how much VRAM you want to free. Measure it.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;Time-slicing lets me co-reside more models.&amp;rdquo;&lt;/strong> False and dangerous. Time-slicing shares out compute time, not memory, and all the processes still compete for the same HBM with no isolation. Co-residing via time-slicing does not give you more effective VRAM; it gives you more processes fighting over the same VRAM, and an OOM when the sum overflows. For real memory partitioning, MIG. The detail is in the &lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">sibling piece&lt;/a>.&lt;/p>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>Having more models than VRAM is not a problem of insufficient hardware: it is a problem of &lt;strong>managing a scarce resource over time&lt;/strong>. The intuition of &amp;ldquo;I need one GPU per model&amp;rdquo; is expensive and almost always wrong, because models are rarely used all at once. The three strategies are three answers to the same question, &lt;strong>where do the weights of the models you are not currently using live&lt;/strong>: in HBM if they fit (co-residency, zero latency but they cost space), on disk if switching is rare (swapping, maximum VRAM per model but a return trip of seconds to minutes), or in RAM if switching latency matters (sleep mode, sub-second wake at the cost of tied-up RAM). Sleep mode is the most interesting addition of 2025 because it breaks the false dilemma of &amp;ldquo;everything loaded vs reload every time&amp;rdquo;: by keeping the process alive and preserving the allocator, the CUDA graphs and the kernels, it turns a 30–100 s cold start into a sub-second wake, and it does so even when it reloads the weights from the same disk (level 2), because moving bytes was never the whole cost. On the four-H100 cluster, the play is not to pick one strategy but to spread: co-reside what goes together, isolate the light stuff with MIG, sleep or rotate the large and occasional, replicate what saturates. The GPU is the stage with a single spotlight; the art is knowing which actor walks on, which goes home and which waits in the wings.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/speeding-up-model-cold-start-minutes-to-seconds/">Speeding up model cold start: from minutes to seconds&lt;/a> — how to lower the cold-start cost that every swap pays.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/">Sharing a GPU: time-slicing, MPS and MIG&lt;/a> — the sibling piece: how to split one GPU among several processes in space (not in time). Key here to understand why time-slicing does not protect memory and why MIG is what gives co-residency with real HBM isolation.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM: cold start and model loading&lt;/a> — the load path that swapping pays in full on every switch and that sleep mode sidesteps; why moving bytes is only one of five start-up items.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving: fundamentals&lt;/a> — the alternative when the &amp;ldquo;several models&amp;rdquo; are adapters of the same base: instead of rotating whole models, you serve many LoRAs on a co-resident base with no swap cost.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">Engineering the prefix cache hit rate&lt;/a> — the KV cache is discarded on sleep; a good hit rate on stable prefixes makes the reprefill of the first request after the wake cheaper.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">One big vs N small: tensor parallelism and replicas&lt;/a> — the &amp;ldquo;sustained load on all of them at once&amp;rdquo; branch of the tree: when rotating is not enough and you have to spread across more GPUs or replicas.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/mixed-nvidia-intel-environments-llm-inference/">Mixed NVIDIA / Intel environments: servers and NUCs&lt;/a> — where to place the light services (rerankers, embeddings) that do not need an H100: sometimes co-residing is not on the big GPU but on more modest hardware.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>vLLM Blog (Embedded LLM), &lt;em>Zero-Reload Model Switching with vLLM Sleep Mode&lt;/em>, 26 oct 2025: &lt;a href="https://blog.vllm.ai/2025/10/26/sleep-mode.html">https://blog.vllm.ai/2025/10/26/sleep-mode.html&lt;/a>&lt;/li>
&lt;li>vLLM Docs, &lt;em>Sleep Mode&lt;/em>: &lt;a href="https://docs.vllm.ai/en/latest/features/sleep_mode/">https://docs.vllm.ai/en/latest/features/sleep_mode/&lt;/a>&lt;/li>
&lt;li>mostlygeek, &lt;em>llama-swap&lt;/em> (OpenAI/Anthropic-compatible model-swapping proxy): &lt;a href="https://github.com/mostlygeek/llama-swap">https://github.com/mostlygeek/llama-swap&lt;/a>&lt;/li>
&lt;li>llama-swap, &lt;em>Configuration&lt;/em>: &lt;a href="https://github.com/mostlygeek/llama-swap/blob/main/docs/configuration.md">https://github.com/mostlygeek/llama-swap/blob/main/docs/configuration.md&lt;/a>&lt;/li>
&lt;li>NVIDIA, &lt;em>H100 Tensor Core GPU&lt;/em> (HBM3 specs, 80 GB, ~3.35 TB/s): &lt;a href="https://www.nvidia.com/en-us/data-center/h100/">https://www.nvidia.com/en-us/data-center/h100/&lt;/a>&lt;/li>
&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;/ul></description></item><item><title>Sharing one GPU between several workloads: time-slicing, MPS and MIG</title><link>https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/</link><pubDate>Thu, 11 Jun 2026 02:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/sharing-one-gpu-time-slicing-mps-mig/</guid><description>&lt;blockquote>
&lt;p>This post &lt;strong>opens an operational series&lt;/strong> on how to squeeze a generic on-premise 4×H100 SXM LLM cluster. The sibling pieces: &lt;a href="https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/">Serving several models on one GPU: swap and sleep&lt;/a> (what to do when the models do not fit at the same time and have to take turns in memory), &lt;a href="https://blog.lo0.es/en/posts/rag-cpu-data-plane-generation-plane/">RAG on CPU: separating the data plane from generation&lt;/a> (moving retrieval off the GPU to free it up) and Sovereign end-to-end assistant with LibreChat, LiteLLM and RAG, the final assembly, a fourth instalment in preparation. Here we start with the most basic question: you have one GPU and you want to put several workloads on it. How do you split it?&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>You have one GPU, or a few, and several workloads that want to run on it: a chat model, an embeddings service, a reranker, a queue of dev jobs. The GPU is underused if it only runs one thing, but piling several on carelessly causes contention, OOM or cascading failures. There are &lt;strong>three mechanisms&lt;/strong> and they share out different things. &lt;strong>Time-slicing&lt;/strong> (replicas from the NVIDIA k8s device-plugin) multiplexes in time: it advertises the GPU as &amp;ldquo;N GPUs&amp;rdquo; and the processes take turns at the compute, but they share the full physical VRAM, with no memory isolation, no fault isolation and no QoS. Its trap is an OOM that does not show up in the Kubernetes scheduler but at run time, when the sum of VRAM allocations exceeds the real memory. &lt;strong>MPS&lt;/strong> (Multi-Process Service) multiplexes in space: it shares out the SMs between processes that execute kernels concurrently, reduces context-switch overhead and allows SM and memory limits per process. It raises throughput when there are many small kernels, but fault isolation is still weak. &lt;strong>MIG&lt;/strong> (Multi-Instance GPU) partitions in hardware: it cuts the Hopper GPU into up to seven instances with dedicated SMs, L2, memory and bandwidth, with real memory, fault and performance isolation; datacenter only (A100/H100/H200/B200), never on an RTX 5090. The rule: real isolation / multi-tenant / compliance → MIG (if it is Hopper); many small concurrent kernels and trust between workloads → MPS; dev, bursts, consumer GPU or no need to isolate → time-slicing. This post works it through with numbers: the VRAM budget of four vLLM instances on an H100 advertised as four replicas, and what fits into a 10 GB MIG instance.&lt;/p>
&lt;h2 id="the-analogy-a-shared-hob-one-kitchen-with-several-cooks-several-kitchens">The analogy: a shared hob, one kitchen with several cooks, several kitchens&lt;/h2>
&lt;p>Imagine you have a single professional hob and three orders to cook at the same time. There are three ways to organise it, and they are exactly the three mechanisms.&lt;/p>
&lt;p>&lt;strong>Time-slicing is one hob taken in turns, with no larder of your own.&lt;/strong> Each cook comes in, cooks their dish, leaves, and the next one comes in. The split is temporal: nobody cooks at the same time, they take turns. The problem is not the hob, which gets shared out fine, but the common larder: the ingredients sit in a single shared cupboard and nobody has their own. If the three cooks reserve more flour than there is in total, it is not that they wait their turn: it is that there is no flour. The service goes down for everyone. And if one cook leaves a pan burning and starts a fire, it burns the whole kitchen, not just their corner.&lt;/p>
&lt;p>&lt;strong>MPS is several cooks coordinated on the same worktop.&lt;/strong> Now they do cook at the same time, sharing out the space on the worktop (the SMs). A head chef (the MPS daemon) coordinates so they do not collide and so the worktop does not sit empty while one of them waits for water to boil. You can assign each cook a percentage of the worktop and a larder limit. They work faster as a group because the worktop does not sit idle between small tasks. But they still share the kitchen: if one starts a serious fire, the others notice.&lt;/p>
&lt;p>&lt;strong>MIG is several independent kitchens in the same building.&lt;/strong> A concrete wall separates each kitchen: its own hob, its own larder, its own door and its own electrical panel. What happens in kitchen 3, a fire, an empty larder, a slow cook, does not touch kitchen 1. It is the only split with genuine isolation. The price: you have to decide in advance how many kitchens and of what size, the walls are fixed, and only expensive buildings (datacenter) come ready to put them up.&lt;/p>
&lt;p>The rest of the post is, essentially, when you want cheap turns, when you want coordinated cooks and when you need concrete walls.&lt;/p>
&lt;h2 id="why-share-the-operational-problem">Why share: the operational problem&lt;/h2>
&lt;p>An H100 SXM 80 GB does not fill up with just any workload. A &lt;code>bge-reranker-v2-m3&lt;/code> reranker takes a few hundred MB and saturates a handful of SMs; a &lt;code>bge-m3&lt;/code> embeddings service is just as small; a 1B guardrail model in INT4 fits in a couple of GB. Dedicating 80 GB of HBM3 and 132 SMs to serving embeddings is using a hydraulic press to hammer in a drawing pin, the same argument as in &lt;a href="https://blog.lo0.es/en/posts/mixed-nvidia-intel-environments-llm-inference/">mixed environments&lt;/a>, but now &lt;em>inside&lt;/em> the GPU instead of moving the workload to different silicon.&lt;/p>
&lt;p>The aim of sharing is to raise the useful utilisation of fixed capital. But sharing badly introduces three pathologies:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Compute contention&lt;/strong>: two workloads fight over the same SMs and both run slowly, with unpredictable latency jitter.&lt;/li>
&lt;li>&lt;strong>Memory contention&lt;/strong>: the sum of requested VRAM exceeds the physical amount and something dies with a &lt;code>CUDA out of memory&lt;/code>.&lt;/li>
&lt;li>&lt;strong>Cascading failure&lt;/strong>: a workload that blows up (an illegal kernel, an OOM) can drag its neighbours down with it if they share a context.&lt;/li>
&lt;/ul>
&lt;p>The three mechanisms attack these pathologies to different depths. None of them solves all three except MIG, and MIG costs specific hardware. Let us look at them one by one.&lt;/p>
&lt;h2 id="time-slicing-compute-turns-shared-larder">Time-slicing: compute turns, shared larder&lt;/h2>
&lt;p>&lt;strong>Time-slicing&lt;/strong> is temporal multiplexing in software. On Kubernetes, the &lt;a href="https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/gpu-sharing.html">NVIDIA GPU Operator&lt;/a> configures the device-plugin to &lt;strong>advertise N replicas&lt;/strong> of each physical GPU. An H100 declared with &lt;code>replicas: 4&lt;/code> appears to the scheduler as four &lt;code>nvidia.com/gpu&lt;/code> resources, and Kubernetes can place four pods on it. Internally, the GPU scheduler hands out compute turns to each process: it runs a bit of process A, switches to B, to C, to D, then back to A. It is the same &lt;em>time-sharing&lt;/em> an operating system does with the CPU.&lt;/p>
&lt;p>The key idea, and the one that causes most confusion, is this: &lt;strong>a replica is NOT a fraction of the GPU&lt;/strong>. It is a compute turn. NVIDIA&amp;rsquo;s documentation is explicit: unlike MIG, there is no memory or fault isolation between replicas. The four replicas of the H100 see the full 80 GB of VRAM, unpartitioned. There is no 20 GB per replica. There are 80 GB for all four, handed out in &lt;code>cudaMalloc&lt;/code> arrival order.&lt;/p>
&lt;p>This has three consequences you need to internalise:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>It does not isolate memory.&lt;/strong> If the sum of what the four processes reserve exceeds 80 GB, the fourth &lt;code>cudaMalloc&lt;/code> fails with OOM. The Kubernetes scheduler does not see it coming: it counted four available &lt;code>nvidia.com/gpu&lt;/code> resources and happily placed four pods. The OOM appears at run time, not at &lt;em>scheduling&lt;/em> time. This is trap number one of time-slicing.&lt;/li>
&lt;li>&lt;strong>It does not isolate faults.&lt;/strong> A process that triggers an unrecoverable CUDA error can leave the GPU context in a state that affects its neighbours. They share the same device with no barriers.&lt;/li>
&lt;li>&lt;strong>It gives no compute QoS.&lt;/strong> Under contention, the turn-taking does not guarantee a minimum fraction to anyone. Each workload&amp;rsquo;s latency suffers jitter proportional to how many active replicas are fighting over the GPU at that instant. A latency-sensitive workload (an interactive chat) can see its TTFT dance around depending on what its neighbours are doing.&lt;/li>
&lt;/ol>
&lt;p>What is it good for, then? For dev, bursts and low utilisation. If you have four developers who touch the GPU sporadically, advertising four replicas lets all four have access without fighting almost ever (they rarely overlap while active). For batch workloads that tolerate jitter. And, a decisive advantage, &lt;strong>it works on consumer GPUs&lt;/strong>: an RTX 5090 32 GB does not support MIG, but it does support time-slicing. It is the only &amp;ldquo;Kubernetes-native&amp;rdquo; way to share a 5090 between several pods.&lt;/p>
&lt;div class="diagram" style="max-width:820px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 820 360" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="The three sharing strategies: temporal, spatial and hardware partitioning">
&lt;text x="410" y="24" text-anchor="middle" font-size="15" font-weight="700" fill="currentColor">How each mechanism shares out&lt;/text>
&lt;!-- TIME-SLICING -->
&lt;text x="140" y="56" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">Time-slicing&lt;/text>
&lt;text x="140" y="72" text-anchor="middle" font-size="11" fill="currentColor">split in TIME&lt;/text>
&lt;rect x="30" y="84" width="220" height="120" fill="none" stroke="currentColor" stroke-width="1.4"/>
&lt;!-- turns -->
&lt;rect x="40" y="96" width="200" height="20" fill="#3b82f6"/>
&lt;text x="140" y="111" text-anchor="middle" font-size="11" fill="#ffffff">turn A&lt;/text>
&lt;rect x="40" y="120" width="200" height="20" fill="#22c55e"/>
&lt;text x="140" y="135" text-anchor="middle" font-size="11" fill="#ffffff">turn B&lt;/text>
&lt;rect x="40" y="144" width="200" height="20" fill="#f59e0b"/>
&lt;text x="140" y="159" text-anchor="middle" font-size="11" fill="#ffffff">turn C&lt;/text>
&lt;rect x="40" y="168" width="200" height="20" fill="#ef4444"/>
&lt;text x="140" y="183" text-anchor="middle" font-size="11" fill="#ffffff">turn D&lt;/text>
&lt;text x="140" y="224" text-anchor="middle" font-size="11" fill="currentColor">shared VRAM: 80 GB for all 4&lt;/text>
&lt;text x="140" y="240" text-anchor="middle" font-size="11" fill="#ef4444">no isolation · OOM if sum &amp;gt; VRAM&lt;/text>
&lt;!-- MPS -->
&lt;text x="410" y="56" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">MPS&lt;/text>
&lt;text x="410" y="72" text-anchor="middle" font-size="11" fill="currentColor">split in SPACE (SMs)&lt;/text>
&lt;rect x="300" y="84" width="220" height="120" fill="none" stroke="currentColor" stroke-width="1.4"/>
&lt;rect x="310" y="96" width="60" height="92" fill="#3b82f6"/>
&lt;text x="340" y="146" text-anchor="middle" font-size="11" fill="#ffffff">A 40%&lt;/text>
&lt;rect x="372" y="96" width="45" height="92" fill="#22c55e"/>
&lt;text x="394" y="146" text-anchor="middle" font-size="11" fill="#ffffff">B 30%&lt;/text>
&lt;rect x="419" y="96" width="45" height="92" fill="#f59e0b"/>
&lt;text x="441" y="146" text-anchor="middle" font-size="10" fill="#ffffff">C 20%&lt;/text>
&lt;rect x="466" y="96" width="44" height="92" fill="#ef4444"/>
&lt;text x="488" y="146" text-anchor="middle" font-size="10" fill="#ffffff">D 10%&lt;/text>
&lt;text x="410" y="224" text-anchor="middle" font-size="11" fill="currentColor">concurrent kernels on different SMs&lt;/text>
&lt;text x="410" y="240" text-anchor="middle" font-size="11" fill="#f59e0b">SM and mem limit per process · weak fault&lt;/text>
&lt;!-- MIG -->
&lt;text x="680" y="56" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">MIG&lt;/text>
&lt;text x="680" y="72" text-anchor="middle" font-size="11" fill="currentColor">HARDWARE partitioning&lt;/text>
&lt;rect x="570" y="84" width="220" height="120" fill="none" stroke="currentColor" stroke-width="1.4"/>
&lt;rect x="580" y="96" width="98" height="44" fill="#3b82f6"/>
&lt;text x="629" y="122" text-anchor="middle" font-size="10" fill="#ffffff">1g.10gb&lt;/text>
&lt;rect x="684" y="96" width="98" height="44" fill="#22c55e"/>
&lt;text x="733" y="122" text-anchor="middle" font-size="10" fill="#ffffff">1g.10gb&lt;/text>
&lt;rect x="580" y="144" width="98" height="44" fill="#f59e0b"/>
&lt;text x="629" y="170" text-anchor="middle" font-size="10" fill="#ffffff">3g.40gb&lt;/text>
&lt;rect x="684" y="144" width="98" height="44" fill="#ef4444"/>
&lt;text x="733" y="170" text-anchor="middle" font-size="10" fill="#ffffff">…&lt;/text>
&lt;text x="680" y="224" text-anchor="middle" font-size="11" fill="currentColor">dedicated SMs, L2, VRAM and BW&lt;/text>
&lt;text x="680" y="240" text-anchor="middle" font-size="11" fill="#22c55e">real isolation · concrete walls&lt;/text>
&lt;!-- footer -->
&lt;text x="410" y="290" text-anchor="middle" font-size="12" font-weight="700" fill="currentColor">One hob in turns · several cooks on the worktop · several kitchens with their wall&lt;/text>
&lt;text x="410" y="312" text-anchor="middle" font-size="11" fill="currentColor">consumer + datacenter · datacenter (CUDA) · datacenter Hopper/Ampere/Blackwell only&lt;/text>
&lt;text x="410" y="332" text-anchor="middle" font-size="11" fill="currentColor">software · software (CUDA context) · hardware (physical fuses)&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h3 id="the-vram-budget-in-time-slicing-the-calculation-that-avoids-the-oom">The VRAM budget in time-slicing (the calculation that avoids the OOM)&lt;/h3>
&lt;p>Here is the maths you have to do before deploying, because Kubernetes will not do it for you. Suppose an H100 80 GB advertised as 4 replicas and we want to run four vLLM instances on it, one per replica.&lt;/p>
&lt;p>vLLM reserves memory with the &lt;code>--gpu-memory-utilization&lt;/code> parameter, which is the fraction of the total physical VRAM that each instance keeps (for model weights plus KV-cache). The detail that kills: that fraction is computed over the 80 physical GB, &lt;strong>not&lt;/strong> over a supposed &amp;ldquo;20 GB of my replica&amp;rdquo;, because the replica does not have 20 GB. Remember, there is no memory partitioning. Each vLLM sees the 80 GB and reserves its fraction of them.&lt;/p>
&lt;p>The no-OOM constraint is therefore that the sum of fractions be less than 1:&lt;/p>
$$\sum_{i=1}^{N} g_i &lt; 1 \quad\Longleftrightarrow\quad \sum_{i=1}^{N} g_i \cdot V_{\text{HBM}} &lt; V_{\text{HBM}}$$
&lt;p>where $g_i$ is the &lt;code>--gpu-memory-utilization&lt;/code> of instance $i$ and $V_{\text{HBM}} = 80$ GB. It is wise to leave headroom (runtime overhead, fragmentation, CUDA context), so in practice you aim for the sum to stay comfortably below 1, say $\le 0.9$.&lt;/p>
&lt;p>&lt;strong>A case that works.&lt;/strong> Four vLLM instances at $g_i = 0.20$:&lt;/p>
$$\sum_{i=1}^{4} 0.20 = 0.80 \quad\Rightarrow\quad 0.80 \times 80\ \text{GB} = 64\ \text{GB} &lt; 80\ \text{GB} \quad\checkmark$$
&lt;p>Each instance reserves $0.20 \times 80 = 16$ GB. Four instances add up to 64 GB, leaving a 16 GB cushion. There is no OOM. Each vLLM has 16 GB for weights plus KV-cache: enough for a 7B–8B model in FP8/INT4 with a modest KV-cache.&lt;/p>
&lt;p>&lt;strong>A case that blows up.&lt;/strong> The same four instances, but somebody raises $g_i = 0.30$ thinking &amp;ldquo;I have four replicas, I can give each one more&amp;rdquo;:&lt;/p>
$$\sum_{i=1}^{4} 0.30 = 1.20 \quad\Rightarrow\quad 1.20 \times 80\ \text{GB} = 96\ \text{GB} > 80\ \text{GB} \quad\times$$
&lt;p>The first instances start up and reserve $0.30 \times 80 = 24$ GB each. Three instances are already at $72$ GB. The fourth tries to reserve another 24 GB, there is nothing left, and it dies with &lt;code>CUDA out of memory&lt;/code>. And worse: Kubernetes will reschedule it on the same GPU (it still sees four replicas), where it will die again, in a &lt;code>CrashLoopBackOff&lt;/code> that makes no sense if you only look at the pod manifest.&lt;/p>
&lt;p>The operational rule is brutally simple: &lt;strong>in time-slicing, you manage the VRAM budget by hand, adding up the &lt;code>--gpu-memory-utilization&lt;/code> values&lt;/strong>. The replica count controls how many pods &lt;em>fit in compute turns&lt;/em>, but it does not reserve a single byte of memory. Confusing the two is the recurring mistake.&lt;/p>
&lt;h2 id="mps-coordinated-cooks-on-the-same-worktop">MPS: coordinated cooks on the same worktop&lt;/h2>
&lt;p>The &lt;strong>Multi-Process Service&lt;/strong> (&lt;a href="https://docs.nvidia.com/deploy/mps/index.html">MPS&lt;/a>) attacks a different problem. By default, when several processes use the same GPU without MPS, each one has its own CUDA context, and the GPU alternates between contexts (time-slicing at driver level): they do not execute kernels at the same time, they take turns, with context-switch overhead. If your kernels are small and do not fill the GPU on their own, this leaves SMs idle: process A uses 30 % of the SMs during its turn and the other 70 % is wasted.&lt;/p>
&lt;p>MPS introduces a daemon that shares a single CUDA context between processes, so that their kernels can execute concurrently occupying different SMs at the same time. It is spatial sharing of compute: instead of taking turns at the whole worktop, each cook occupies a part and they work in parallel. This reduces context-switch overhead and &lt;strong>raises throughput when there are many small concurrent kernels&lt;/strong> that individually do not saturate the GPU.&lt;/p>
&lt;p>And, unlike pure time-slicing, MPS allows per-process limits, which gives you a form of QoS:&lt;/p>
&lt;ul>
&lt;li>&lt;code>CUDA_MPS_ACTIVE_THREAD_PERCENTAGE&lt;/code> limits the percentage of SMs an MPS client can use. By default each client gets $100 / \text{MaxSharedClients}$. Pinning it to, say, 40 % caps that process&amp;rsquo;s compute ceiling (&lt;a href="https://docs.nvidia.com/deploy/mps/appendix-tools-and-interface-reference.html">MPS docs&lt;/a>).&lt;/li>
&lt;li>&lt;code>CUDA_MPS_PINNED_DEVICE_MEM_LIMIT&lt;/code> imposes a memory cap per client (valid from CUDA 11.5). This is what time-slicing does &lt;strong>not&lt;/strong> have: a per-process VRAM limit that the runtime enforces.&lt;/li>
&lt;/ul>
&lt;p>These two limits turn MPS into a resource provisioning mechanism that mitigates the &lt;em>noisy neighbour&lt;/em>: you can guarantee that a process does not eat more than X % of the SMs or more than Y GB. The combination gives reasonable QoS, not perfect but real.&lt;/p>
&lt;p>The limitation MPS does &lt;strong>not&lt;/strong> solve: &lt;strong>fault isolation is weak&lt;/strong>. Since clients share the daemon&amp;rsquo;s CUDA context, a fatal error in one client can affect the daemon and therefore the other clients (historically, a client dying dirtily could require restarting the daemon). It is better than time-slicing in this respect, but a long way from hardware isolation. That is why MPS fits when there is trust between the workloads, processes from your own team, not third-party tenants.&lt;/p>
&lt;p>The canonical use case: many small concurrent inference requests that individually leave the GPU half empty. MPS overlaps them and raises aggregate throughput. Serving several small models, or several light replicas of the same model, on a datacenter GPU where you trust all the workloads, is MPS territory.&lt;/p>
&lt;h2 id="mig-concrete-walls">MIG: concrete walls&lt;/h2>
&lt;p>The &lt;strong>Multi-Instance GPU&lt;/strong> (&lt;a href="https://docs.nvidia.com/datacenter/tesla/mig-user-guide/">MIG&lt;/a>) is the only one of the three that gives genuine isolation, because it cuts the GPU in hardware. Available on modern datacenter GPUs, A100 (Ampere), H100/H200 (Hopper), B200 (Blackwell), and never on consumer ones: an RTX 5090 (consumer Blackwell) &lt;strong>does not support MIG&lt;/strong>, nor do GeForce cards in general.&lt;/p>
&lt;p>MIG divides the GPU into up to seven instances (&lt;em>GPU Instances&lt;/em>), and each instance gets a dedicated portion of:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>SMs&lt;/strong> (compute slices): compute is split into 7 &lt;em>slices&lt;/em>, each one roughly 1/7 of the SMs.&lt;/li>
&lt;li>&lt;strong>L2 cache and memory&lt;/strong>: each instance has its slab of HBM and its portion of L2 cache.&lt;/li>
&lt;li>&lt;strong>Memory bandwidth&lt;/strong>: dedicated, not shared.&lt;/li>
&lt;li>&lt;strong>Data paths and engines&lt;/strong>: with fault barriers between instances.&lt;/li>
&lt;/ul>
&lt;p>The result is that a MIG instance behaves like a smaller, independent GPU: whatever happens in one, an OOM, a kernel blowing up, a workload saturating its compute, &lt;strong>does not affect&lt;/strong> its neighbours. Memory, fault and performance (QoS) isolation, the three things time-slicing does not give and MPS only half gives.&lt;/p>
&lt;h3 id="the-h100-80gb-profiles">The H100 80GB profiles&lt;/h3>
&lt;p>MIG does not allow arbitrary sizes: it has &lt;strong>fixed profiles&lt;/strong>. On the H100 80GB, the &lt;a href="https://docs.nvidia.com/datacenter/tesla/mig-user-guide/supported-mig-profiles.html">profile catalogue&lt;/a> (notation &lt;code>&amp;lt;compute&amp;gt;g.&amp;lt;memory&amp;gt;gb&lt;/code>) is:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Profile&lt;/th>
&lt;th>Compute (slices)&lt;/th>
&lt;th>Memory&lt;/th>
&lt;th>Max. instances&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>1g.10gb&lt;/code>&lt;/td>
&lt;td>1/7&lt;/td>
&lt;td>10 GB&lt;/td>
&lt;td>7&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>1g.20gb&lt;/code>&lt;/td>
&lt;td>1/7&lt;/td>
&lt;td>20 GB&lt;/td>
&lt;td>4&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>2g.20gb&lt;/code>&lt;/td>
&lt;td>2/7&lt;/td>
&lt;td>20 GB&lt;/td>
&lt;td>3&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>3g.40gb&lt;/code>&lt;/td>
&lt;td>3/7&lt;/td>
&lt;td>40 GB&lt;/td>
&lt;td>2&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>4g.40gb&lt;/code>&lt;/td>
&lt;td>4/7&lt;/td>
&lt;td>40 GB&lt;/td>
&lt;td>1&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>7g.80gb&lt;/code>&lt;/td>
&lt;td>7/7&lt;/td>
&lt;td>80 GB&lt;/td>
&lt;td>1 (whole GPU)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>(There is also &lt;code>1g.10gb+me&lt;/code>, a variant with &lt;em>media engines&lt;/em> for video encoding.) The base memory unit on the H100 80GB is 10 GB per slice (80 GB / 8, with one slice reserved), and the compute unit is 1/7 of the SMs. The profiles combine these units. Note &lt;code>1g.20gb&lt;/code>: the same compute fraction as &lt;code>1g.10gb&lt;/code> (1/7 of the SMs) but twice the memory, useful when a workload needs more VRAM than compute.&lt;/p>
&lt;p>One important detail: MIG partitions do not mix freely. The GPU is divided following a valid geometry (the profiles fit together like pieces in a grid), and the profiles are fixed when you configure the GPU; changing them requires draining and repartitioning. They are concrete walls: solid, but they do not move while hot.&lt;/p>
&lt;h3 id="the-calculation-71g10gb-against-17g80gb">The calculation: 7×1g.10gb against 1×7g.80gb&lt;/h3>
&lt;p>Let us compare the two extremes. On the left, seven &lt;code>1g.10gb&lt;/code> instances: seven isolated 10 GB GPUs. On the right, a single &lt;code>7g.80gb&lt;/code>: the whole H100, unpartitioned.&lt;/p>
&lt;p>The operational question is what fits in 10 GB. An instance&amp;rsquo;s VRAM budget is split between model weights and KV-cache:&lt;/p>
$$V_{\text{inst}} = V_{\text{weights}} + V_{\text{KV}} + V_{\text{overhead}}$$
&lt;p>Take a model of 7B parameters in FP8 (1 byte/parameter):&lt;/p>
$$V_{\text{weights}} \approx 7 \times 10^9 \times 1\ \text{byte} = 7\ \text{GB}$$
&lt;p>On a &lt;code>1g.10gb&lt;/code> instance (10 GB), after the 7 GB of weights and subtracting roughly 0.5–1 GB of runtime overhead, about 2 GB are left for KV-cache. That is enough for a modest context window and low concurrency, fine for a guardrail service, a classifier or an extraction model that handles short prompts one at a time. A 7B in INT4 (~3.5 GB of weights) leaves ~5.5 GB of KV-cache, much roomier. But a 13B model in FP8 (~13 GB of weights) &lt;strong>does not fit&lt;/strong> in a 10 GB instance: not even the weights get in. For that you need &lt;code>1g.20gb&lt;/code>, &lt;code>2g.20gb&lt;/code> or larger.&lt;/p>
&lt;p>Against this, the &lt;code>7g.80gb&lt;/code> (whole GPU) gives you the 80 GB for one large model: a 70B in FP8 (~70 GB of weights) fits with a tight KV-cache, or a 70B with more room spread across several whole GPUs in &lt;em>tensor-parallel&lt;/em> (see &lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">TP against replicas: one big one versus N small ones&lt;/a>).&lt;/p>
&lt;p>The reading is clear: &lt;strong>fine partitioning (7×1g.10gb) maximises the number of small isolated workloads; not partitioning (1×7g.80gb) maximises the model size that fits.&lt;/strong> The KV-cache available per instance shrinks proportionally as you partition, so fine-grained MIG serves many light isolated services, not one large model chopped up. If your workload is a single large model, MIG is not for you: use the whole GPU or several in TP.&lt;/p>
&lt;h2 id="the-decision-tree">The decision tree&lt;/h2>
&lt;p>The three questions, in order:&lt;/p>
&lt;pre tabindex="0">&lt;code>Do you need REAL isolation?
(multi-tenant, compliance, one workload failing must not touch another)
│
┌────┴────┐
YES NO
│ │
Is it Hopper/ Many SMALL concurrent kernels
Ampere/ AND you trust every workload?
Blackwell? │
│ ┌────┴────┐
┌──┴──┐ YES NO
YES NO │ │
│ │ MPS Dev / bursts / consumer GPU
MIG │ (spatial, / no need to isolate?
│ │ per-process │
│ no real QoS) YES
│ isolation: │
│ rethink TIME-SLICING
│ (move to (temporal, cheap,
│ CPU, another works on a 5090)
│ GPU, or accept
│ the risk with
│ time-slicing)
&lt;/code>&lt;/pre>&lt;p>And each branch in one sentence:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>MIG&lt;/strong> when isolation is a requirement (compliance, multi-tenant, hard SLA) and you have datacenter hardware that supports it. Concrete walls cost money, but if you need them there is no substitute.&lt;/li>
&lt;li>&lt;strong>MPS&lt;/strong> when you have many small concurrent workloads that leave the GPU half empty and you trust all of them (same team, not third-party tenants). You raise throughput with reasonable QoS, accepting imperfect fault isolation.&lt;/li>
&lt;li>&lt;strong>Time-slicing&lt;/strong> when it is dev, bursts, low utilisation, a consumer GPU, or you simply do not need to isolate anything. Cheap and universal, but manage the VRAM budget by hand.&lt;/li>
&lt;/ul>
&lt;p>One nuance the recent documentation records: &lt;strong>they can be combined&lt;/strong>. You can do time-slicing &lt;em>on top of&lt;/em> a MIG instance (hardware isolation at the instance boundary, software turns inside), or use MPS inside a MIG instance. The layers are not mutually exclusive; the tree picks the primary strategy.&lt;/p>
&lt;h2 id="applied-to-the-generic-4h100-cluster">Applied to the generic 4×H100 cluster&lt;/h2>
&lt;p>Let us get down to numbers with a generic on-premise cluster of 4×H100 SXM 80 GB with NVLink. It is common to have a heterogeneous menu of workloads: a large chat model, light services (embeddings, reranker, guardrails) and a dev/experimentation queue. Each type calls for a different mechanism. A reasoned split:&lt;/p>
&lt;p>&lt;strong>GPU 0 and GPU 1, large model in tensor-parallel (not shared).&lt;/strong> A 70B model in FP8 takes ~70 GB of weights; served comfortably with a generous KV-cache it needs more than one H100. We spread it in &lt;em>tensor-parallel&lt;/em> over two whole H100s joined by NVLink (intra-node bandwidth is what makes TP viable; the detail is in &lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">TP against replicas&lt;/a>). Here we do not share: these two GPUs belong to the large model, full stop. Total isolation by dedication.&lt;/p>
$$V_{\text{available}} = 2 \times 80 = 160\ \text{GB};\quad V_{\text{weights}} \approx 70\ \text{GB};\quad V_{\text{KV}} \approx 80\ \text{GB of KV-cache}$$
&lt;p>There is memory to spare for a long request queue and high concurrency.&lt;/p>
&lt;p>&lt;strong>GPU 2, split with MIG into small instances for light services.&lt;/strong> Embeddings (&lt;code>bge-m3&lt;/code>), reranker (&lt;code>bge-reranker-v2-m3&lt;/code>) and a couple of guardrail models (1B–3B) are different workloads, potentially from different teams, and you want a failure or a spike in one not to touch the others. Light multi-tenancy with isolation → &lt;strong>MIG&lt;/strong>. A reasonable partitioning of the H100:&lt;/p>
$$\underbrace{3 \times \texttt{1g.10gb}}_{\text{30 GB, 3/7 SMs}} \;+\; \underbrace{1 \times \texttt{4g.40gb}}_{\text{40 GB, 4/7 SMs}}$$
&lt;p>The three &lt;code>1g.10gb&lt;/code> instances (10 GB, 1/7 of the SMs each) host embeddings, reranker and a 1B INT4 guardrail, each isolated, with no &lt;em>noisy neighbour&lt;/em>. The &lt;code>4g.40gb&lt;/code> (40 GB, 4/7 of the SMs) hosts an intermediate 7B–13B model with a decent KV-cache for a support service. Each service has its larder and its wall; if the reranker blows up, the chat never finds out.&lt;/p>
&lt;p>&lt;strong>GPU 3, time-slicing for dev and bursts.&lt;/strong> The developers touch the GPU sporadically: experiments, short fine-tunes, model trials. They do not need isolation (it is the same team) and they rarely overlap while active. We advertise it as 4 replicas via the device-plugin. Four dev pods fit in turns. VRAM budget with the formula above: if each dev brings up a vLLM at &lt;code>--gpu-memory-utilization 0.20&lt;/code>, they add up to $4 \times 16 = 64$ GB &amp;lt; 80 GB, with no OOM. If somebody needs more, lower the replica count or coordinate with the team. The cost of flexibility is manual discipline.&lt;/p>
&lt;p>&lt;strong>Summary of the split:&lt;/strong>&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Resource&lt;/th>
&lt;th>Mechanism&lt;/th>
&lt;th>Workload&lt;/th>
&lt;th>Isolation&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>GPU 0 + GPU 1&lt;/td>
&lt;td>Dedication (TP)&lt;/td>
&lt;td>70B chat in tensor-parallel&lt;/td>
&lt;td>total (dedicated)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GPU 2&lt;/td>
&lt;td>MIG (3×1g.10gb + 1×4g.40gb)&lt;/td>
&lt;td>embeddings, reranker, guardrails, 7B–13B&lt;/td>
&lt;td>real hardware&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GPU 3&lt;/td>
&lt;td>Time-slicing (4 replicas)&lt;/td>
&lt;td>dev, bursts, experiments&lt;/td>
&lt;td>none (trust)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The logic is always the same: &lt;strong>spend isolation (MIG) where you need it, spend cheap concurrency (time-slicing) where you do not, and reserve whole GPUs for what genuinely fills them.&lt;/strong> An H100 serving embeddings on a &lt;code>7g.80gb&lt;/code> would be as absurd as an RTX 5090 trying MIG: the tool does not match the workload.&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>What happens when they do not even fit at the same time&lt;/strong>: if you have more models than VRAM and they have to take turns &lt;em>in memory&lt;/em> (loading/unloading weights, not just taking turns at compute), you enter &lt;em>swap&lt;/em> and &lt;em>sleep&lt;/em> territory, the sibling piece &lt;a href="https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/">Serving several models on one GPU&lt;/a>.&lt;/li>
&lt;li>&lt;strong>NUMA-aware scheduling&lt;/strong>: on multi-socket nodes, which GPU goes with which CPU/memory matters for latency; see &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">Kubelet resource managers on RKE2&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Autoscaling the replicas&lt;/strong>: how many instances to bring up according to real load, with KEDA and queue metrics; see &lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLMs on Kubernetes with KEDA&lt;/a>.&lt;/li>
&lt;li>&lt;strong>A jitter benchmark under contention&lt;/strong>: how much TTFT really wobbles in time-slicing with 4 active replicas compared with MIG, material that deserves its own measurement, not an estimate.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/finops-multi-tenancy-gpu-cluster-who-pays/">FinOps and multi-tenancy of the GPU cluster: who pays for what&lt;/a> — MIG as the basis for isolation and cost attribution between tenants.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/gitops-inference-stack-flux/">GitOps for the inference stack with Flux: operating the assistant as code&lt;/a> — how the GPU split (MIG, gpu-memory-utilization) is declared as code in GitOps.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/serving-several-models-single-gpu-co-residency-swapping-sleep-mode/">Serving several models on one GPU: swap and sleep&lt;/a> — the sibling piece in the series: when the models do not fit in VRAM at the same time and have to take turns in memory, not just in compute.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">Kubelet resource managers on RKE2: NUMA and topology&lt;/a> — GPU sharing gets more complicated with NUMA affinity; which GPU to assign to which socket so you do not pay interconnect latency.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">TP against replicas: one big one versus N small ones&lt;/a> — the decision to dedicate 2 whole H100s in tensor-parallel to the large model is exactly what we assume here in the cluster split.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise LLM inference&lt;/a> — the VRAM budget (weights + KV-cache) we work through here per instance is the core of sizing the whole cluster.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">Autoscaling LLMs on Kubernetes with KEDA&lt;/a> — how many replicas (time-sliced or not) to bring up according to real load, instead of fixing them by hand.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/five-maturity-levels-platform-under-llm/">Five maturity levels of an on-premise LLM platform&lt;/a> — going from &amp;ldquo;one GPU, one workload&amp;rdquo; to sharing with isolation is one of the maturity jumps the model marks out.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;a href="https://blog.lo0.es/en/posts/gpu-idle-cost-not-on-any-invoice/">GPU utilisation as a FinOps lever&lt;/a> — MIG, MPS and time-slicing as levers to raise occupancy and lower cost per token.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>NVIDIA — &lt;em>Time-Slicing GPUs in Kubernetes&lt;/em> (GPU Operator). &lt;a href="https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/gpu-sharing.html">https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/gpu-sharing.html&lt;/a>&lt;/li>
&lt;li>NVIDIA — &lt;em>Multi-Process Service (MPS) Overview&lt;/em>. &lt;a href="https://docs.nvidia.com/deploy/mps/index.html">https://docs.nvidia.com/deploy/mps/index.html&lt;/a>&lt;/li>
&lt;li>NVIDIA — &lt;em>MPS: Tools and Interface Reference&lt;/em> (&lt;code>CUDA_MPS_ACTIVE_THREAD_PERCENTAGE&lt;/code>, &lt;code>CUDA_MPS_PINNED_DEVICE_MEM_LIMIT&lt;/code>). &lt;a href="https://docs.nvidia.com/deploy/mps/appendix-tools-and-interface-reference.html">https://docs.nvidia.com/deploy/mps/appendix-tools-and-interface-reference.html&lt;/a>&lt;/li>
&lt;li>NVIDIA — &lt;em>Multi-Instance GPU (MIG) User Guide&lt;/em>. &lt;a href="https://docs.nvidia.com/datacenter/tesla/mig-user-guide/">https://docs.nvidia.com/datacenter/tesla/mig-user-guide/&lt;/a>&lt;/li>
&lt;li>NVIDIA — &lt;em>Supported MIG Profiles&lt;/em> (H100 80GB catalogue). &lt;a href="https://docs.nvidia.com/datacenter/tesla/mig-user-guide/supported-mig-profiles.html">https://docs.nvidia.com/datacenter/tesla/mig-user-guide/supported-mig-profiles.html&lt;/a>&lt;/li>
&lt;li>NVIDIA — &lt;em>k8s-device-plugin&lt;/em> (time-slicing replicas). &lt;a href="https://github.com/NVIDIA/k8s-device-plugin">https://github.com/NVIDIA/k8s-device-plugin&lt;/a>&lt;/li>
&lt;li>vLLM — &lt;em>Engine Arguments&lt;/em> (&lt;code>--gpu-memory-utilization&lt;/code>). &lt;a href="https://docs.vllm.ai/en/latest/serving/engine_args.html">https://docs.vllm.ai/en/latest/serving/engine_args.html&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Aggressive RAG in small models: trading parameters for retrieval</title><link>https://blog.lo0.es/en/posts/aggressive-rag-small-models-trading-parameters-for-retrieval/</link><pubDate>Tue, 09 Jun 2026 02:20:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/aggressive-rag-small-models-trading-parameters-for-retrieval/</guid><description>&lt;blockquote>
&lt;p>This post belongs to the series on inference performance in small models. Its sibling piece, &lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">The roofline inverts in small models&lt;/a>, explains why the compute-bound prefill is the bottleneck that shapes the whole discussion here. It is worth reading first: here we assume that &lt;strong>adding more context is not free&lt;/strong>.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>An SLM (say 1B–8B parameters) knows &lt;strong>fewer facts&lt;/strong> than a 70B–700B model, simply because it has fewer weights in which to memorise them. But its ability to &lt;strong>reason over text in front of it&lt;/strong>, following instructions, extracting, synthesising, comparing, degrades far less with size than its encyclopaedic knowledge does. The operational consequence is direct: use the SLM as a &lt;strong>reasoning engine over curated context&lt;/strong>, not as a database. Move knowledge from the weights into the context via retrieval. The problem is that &amp;ldquo;aggressive retrieval&amp;rdquo; is often read as &amp;ldquo;put in lots of chunks&amp;rdquo;, and that collides head-on with three facts about SLMs: shorter context windows, worse use of long context (the &lt;em>lost in the middle&lt;/em> effect is more severe the smaller the model) and a &lt;strong>compute-bound prefill&lt;/strong> whose cost grows with the context length $C$, linearly in the projections and quadratically in attention. You cannot simply add tokens. Each retrieved token is paid for twice, in degraded quality and in TTFT, and the SLM is the worst placed to absorb either cost. The way out is not to retrieve less, but to retrieve &lt;em>better&lt;/em>: reranking for precision over recall, compressing the context before injecting it, prefix caching the stable documents, semantic caching of answers, and structured output with external tools that stand in for internal knowledge. This post works through the maths and gives a TTFT number before and after compressing a context from 4,000 to 1,000 tokens on an RTX 4090.&lt;/p>
&lt;h2 id="the-analogy-the-open-book-exam">The analogy: the open-book exam&lt;/h2>
&lt;p>Two students sit the same exam. The first has a prodigious memory: they have memorised the entire syllabus, paragraph by paragraph. The second has an ordinary memory, forgetting dates, confusing names, but is allowed to bring in a &lt;strong>crib sheet&lt;/strong>.&lt;/p>
&lt;p>If the second student&amp;rsquo;s crib sheet is a chaos of piled-up photocopies, they lose: they take too long to find what they are after, get distracted by irrelevant pages and run out of time. But if their crib sheet is &lt;em>excellent&lt;/em>, trimmed to the essentials, reordered by relevance, with the important things at the top and no filler, then they not only do not lose: they often &lt;em>win&lt;/em>, because they reason just as well as the first student and, on top of that, work from verified material instead of from blurry recollections they may be inventing.&lt;/p>
&lt;p>The moral has three layers, and each maps to an engineering decision:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Memorising everything is expensive.&lt;/strong> The first student invested months. A large model invests parameters, and VRAM, and inference FLOPs, in memorising facts.&lt;/li>
&lt;li>&lt;strong>The crib sheet matters more than its size.&lt;/strong> A well-made one-page crib sheet beats ten badly organised pages. More retrieved context is not better context: the material&amp;rsquo;s precision beats volume.&lt;/li>
&lt;li>&lt;strong>Knowing how to search and synthesise is a different skill from knowing.&lt;/strong> It is the one the SLM retains. The whole strategy consists of leaning on that skill and subcontracting the memory.&lt;/li>
&lt;/ul>
&lt;p>The rest of the post is, essentially, how to build an excellent crib sheet under the constraint that the student (the SLM) reads slowly and gets tired with long texts.&lt;/p>
&lt;h2 id="the-capacity-argument-how-many-facts-fit-in-the-weights">The capacity argument: how many facts fit in the weights&lt;/h2>
&lt;p>Let us start by justifying the thesis with an order of magnitude, not with faith. How much factual knowledge really fits in a model&amp;rsquo;s weights?&lt;/p>
&lt;p>There is a recurring empirical estimate in the interpretability and memorisation literature: a dense model can store on the order of &lt;strong>2 bits of memorised information per parameter&lt;/strong> before saturating (the exact figure varies by study and training regime; take it as an order of magnitude, not as a law). An 8B-parameter model then has an information storage ceiling on the order of:&lt;/p>
$$8 \times 10^9 \text{ params} \times 2 \text{ bits/param} = 1.6 \times 10^{10} \text{ bits} \approx 2 \text{ GB of information}$$
&lt;p>And that budget &lt;strong>is not only for facts&lt;/strong>: the vast majority goes on grammar, syntax, reasoning ability, code, formatting, and only a fraction is left for encyclopaedic knowledge. Compare it with the other side: a retrievable corpus of several million documents, a corporate wiki, a document repository, a technical knowledge base, easily takes up &lt;strong>hundreds of GB to terabytes&lt;/strong> of text, indexed and queryable with millisecond latency. The asymmetry is &lt;strong>two or three orders of magnitude&lt;/strong> in favour of the external corpus.&lt;/p>
&lt;p>The conclusion is not that the weights are useless, they are where reasoning lives, which is the expensive thing to replicate, but that &lt;strong>competing with an external index on fact capacity is losing by construction&lt;/strong>. A 70B model has ~9× more memorisation budget than an 8B one, but it is still negligible against the corpus. That is why the large model &lt;em>also&lt;/em> does RAG in production. The difference is that the SLM &lt;em>needs it&lt;/em>: without retrieval, its factual knowledge is too sparse and, worse, &lt;strong>prone to hallucinating&lt;/strong> exactly in the gaps it did not memorise.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 250" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Knowledge in weights versus knowledge in context">
&lt;text x="390" y="24" text-anchor="middle" font-size="15" font-weight="700" fill="currentColor">Where knowledge lives&lt;/text>
&lt;text x="200" y="58" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">In the weights (memorised)&lt;/text>
&lt;rect x="90" y="70" width="220" height="60" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4"/>
&lt;text x="200" y="95" text-anchor="middle" font-size="12" fill="#1f3550">~2 GB of useful info in 8B&lt;/text>
&lt;text x="200" y="113" text-anchor="middle" font-size="11" fill="#1f3550">fixed, costly to update, hallucinates in gaps&lt;/text>
&lt;text x="580" y="58" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">In the context (retrieved)&lt;/text>
&lt;rect x="430" y="70" width="300" height="60" fill="#cdebd0" stroke="#2a7a40" stroke-width="1.4"/>
&lt;text x="580" y="95" text-anchor="middle" font-size="12" fill="#1c3a26">hundreds of GB – TB indexed&lt;/text>
&lt;text x="580" y="113" text-anchor="middle" font-size="11" fill="#1c3a26">fresh, citable, verifiable, no retraining&lt;/text>
&lt;text x="390" y="165" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">The SLM as a reasoning engine&lt;/text>
&lt;rect x="240" y="178" width="300" height="46" fill="#fff4d6" stroke="#a48000" stroke-width="1.4"/>
&lt;text x="390" y="200" text-anchor="middle" font-size="12" fill="#5a4500">reasons over the curated context&lt;/text>
&lt;text x="390" y="216" text-anchor="middle" font-size="11" fill="#5a4500">not the database: the one who reads and synthesises it&lt;/text>
&lt;path d="M200,130 L360,178" stroke="currentColor" stroke-width="1.2" fill="none"/>
&lt;path d="M580,130 L420,178" stroke="currentColor" stroke-width="1.2" fill="none"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-central-tension-retrieving-more-is-not-putting-in-more">The central tension: retrieving more is not putting in more&lt;/h2>
&lt;p>This is where most naive designs break. &amp;ldquo;Aggressive retrieval&amp;rdquo; sounds like a large &lt;em>top-k&lt;/em>: if retrieval helps, retrieve 20 chunks instead of 5. But in an SLM that fails for two independent reasons, one of &lt;em>quality&lt;/em> and one of &lt;em>cost&lt;/em>.&lt;/p>
&lt;h3 id="a-slms-make-worse-use-of-long-context">(a) SLMs make worse use of long context&lt;/h3>
&lt;p>The &lt;em>lost in the middle&lt;/em> effect (Liu et al., 2023) is well known: LLMs retrieve information placed at the beginning and end of the context better, and the information in the middle worse. What gets less emphasis is that &lt;strong>the effect is more severe the smaller the model&lt;/strong>. An SLM has fewer attention heads, fewer layers and poorer internal representations for &amp;ldquo;tracking&amp;rdquo; a relevant fact buried at position 14 out of 20 chunks. Its nominal context window also tends to be shorter (4K–32K against the 128K+ of the large ones), and the &lt;strong>effective window&lt;/strong>, the length beyond which quality collapses, is smaller still. Putting in 20 chunks does not mean the model reads all 20: it means it will probably ignore or misread the ones in the middle, while paying the cost of all of them.&lt;/p>
&lt;h3 id="b-prefill-grows-with-the-context-and-is-compute-bound">(b) Prefill grows with the context and is compute-bound&lt;/h3>
&lt;p>This is the blow people underestimate. Prefill, processing the full prompt before emitting the first token, is the &lt;strong>compute-bound&lt;/strong> phase of inference (unlike decode, which is memory-bound; the detail lives in &lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">The roofline inverts&lt;/a>). Its cost &lt;strong>grows with the context length&lt;/strong> $C$, and it determines TTFT (time to first token). More chunks → more prefill tokens → more TTFT and more compute cost per request. In an SLM, where prefill is proportionally more expensive relative to the model, this hurts especially.&lt;/p>
&lt;p>The operational conclusion is uncomfortable but clear: &lt;strong>you cannot compensate for fewer parameters simply by putting in more context.&lt;/strong> Each retrieved token is paid for twice, in degraded quality and in TTFT, and the SLM is the worst placed to absorb both costs. The way out is to retrieve &lt;em>less but better&lt;/em>, and to compress what you retrieve.&lt;/p>
&lt;h2 id="the-maths-of-prefill">The maths of prefill&lt;/h2>
&lt;p>Let us put numbers on &amp;ldquo;prefill grows with the context&amp;rdquo;. For a context of $C$ tokens, a transformer layer does two classes of work:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Linear projections&lt;/strong> (QKV, attention output, FFN): each token is multiplied by fixed-size weight matrices. The cost is $O(C)$ in FLOPs, linear in the number of tokens.&lt;/li>
&lt;li>&lt;strong>Attention&lt;/strong> ($QK^\top$ and the multiplication by $V$): each token attends to all the others. The cost is $O(C^2)$, quadratic in the number of tokens.&lt;/li>
&lt;/ol>
&lt;p>The total prefill cost per layer has the form:&lt;/p>
$$\text{FLOPs}_{\text{prefill}} \approx \underbrace{a \cdot C}_{\text{projections}} + \underbrace{b \cdot C^2}_{\text{attention}}$$
&lt;p>with $a$ and $b$ constants that depend on the model dimension. For moderate contexts (a few thousand tokens) in an SLM, the linear term still dominates or is comparable to the quadratic one; the quadratic term becomes dominant at long contexts. What matters: &lt;strong>if you compress the context&lt;/strong> $C \to C/k$, the linear term falls $\times k$ and the quadratic one falls $\times k^2$. Compression is the only lever that attacks both terms at once, and it attacks the worse one disproportionately.&lt;/p>
&lt;h3 id="numerical-example-ttft-before-and-after-compressing-rtx-4090">Numerical example: TTFT before and after compressing, RTX 4090&lt;/h3>
&lt;p>Let us model TTFT as the time to process the prefill tokens at a given prefill throughput. Take an RTX 4090 (24 GB, Ada Lovelace) serving a quantised SLM, with a &lt;strong>prefill throughput of ~5,000 tok/s&lt;/strong> (an illustrative figure; the real value depends on the model, the quantisation and the batch, so measure it, do not assume it).&lt;/p>
&lt;p>Let there be a retrieved context of &lt;strong>4,000 tokens&lt;/strong> (8 chunks of ~500 tokens). Approximating TTFT as dominated by the context prefill:&lt;/p>
$$\text{TTFT}_{\text{before}} \approx \frac{4000 \text{ tok}}{5000 \text{ tok/s}} = 0.80 \text{ s}$$
&lt;p>Now we compress that context to &lt;strong>1,000 tokens&lt;/strong> ($k = 4$). Prefill throughput is not constant with $C$, it drops a little at long contexts because of the quadratic term, but taking the conservative linear tokens/throughput approximation:&lt;/p>
$$\text{TTFT}_{\text{after}} \approx \frac{1000 \text{ tok}}{5000 \text{ tok/s}} = 0.20 \text{ s}$$
&lt;p>TTFT falls from &lt;strong>0.80 s to 0.20 s&lt;/strong>, a $4\times$ reduction in the linear part. But the FLOPs arithmetic is even more favourable in the attention component: that part of the work falls $\sim k^2 = 16\times$. In practice total TTFT does not fall 16× because the cost is not purely quadratic at this scale, but the real reduction lies &lt;strong>between 4× and something larger depending on how much attention weighed&lt;/strong>, and the aggregate compute saving (what pays the electricity bill and frees the GPU for another request) is substantially bigger than the plain 4× of the token count.&lt;/p>
&lt;p>The argument generalises: &lt;strong>compressing the context by a factor $k$ reduces TTFT by at least $\sim k\times$ and the attention cost by $\sim k^2\times$.&lt;/strong> For an SLM, where TTFT is often the SLA that matters, this is the difference between an assistant that answers instantly and one that feels slow.&lt;/p>
&lt;h2 id="the-five-levers-to-resolve-the-tension">The five levers to resolve the tension&lt;/h2>
&lt;p>The strategy is not &amp;ldquo;retrieve less and settle&amp;rdquo;. It is &lt;strong>retrieve aggressively from the index and then distil aggressively what you retrieved&lt;/strong> before it reaches the SLM. Five levers, in order of application within the pipeline.&lt;/p>
&lt;h3 id="1-aggressive-reranking-precision-over-recall">1. Aggressive reranking: precision over recall&lt;/h3>
&lt;p>The initial retriever (dense, sparse or hybrid) optimises &lt;em>recall&lt;/em>: it brings back 50–100 candidates so as not to leave anything out. The reranker, a cross-encoder that sees the query and the document together, optimises &lt;em>precision&lt;/em>: it reorders those candidates and you keep the &lt;strong>3–5 best&lt;/strong>. For an SLM this is not a luxury, it is structural: since the model uses long context badly, every chunk that goes in must &lt;em>earn its place&lt;/em>. Better 4 chunks of very high relevance than 15 mediocre ones. The detail of hybrid retrieval and reranking is in &lt;a href="https://blog.lo0.es/en/posts/aggressive-rag-small-models-trading-parameters-for-retrieval/#see-also">Reranking and hybrid retrieval&lt;/a>; here the rule is enough: &lt;strong>maximise recall in the retriever, maximise precision in the reranker, and inject few&lt;/strong>.&lt;/p>
&lt;h3 id="2-context-compression-distilling-the-crib-sheet">2. Context compression: distilling the crib sheet&lt;/h3>
&lt;p>Once you have the best chunks, they still contain filler: padding sentences, redundancy, context irrelevant to the specific query. &lt;strong>Context compression&lt;/strong> trims them before injection:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Extractive compression&lt;/strong> (LLMLingua / LongLLMLingua style, Jiang et al. 2023): a small model scores the &lt;em>perplexity&lt;/em> or relevance of each token/sentence with respect to the query and removes the low-information ones, keeping the densest extractive subset. It reduces tokens without a second large generative model in the loop. LongLLMLingua adds position-aware reordering to mitigate &lt;em>lost in the middle&lt;/em>.&lt;/li>
&lt;li>&lt;strong>Abstractive compression&lt;/strong>: a model summarises the retrieved chunks into shorter text. More aggressive in token reduction, but it introduces a generative step (cost and possible loss of fidelity).&lt;/li>
&lt;li>&lt;strong>Soft prompts / context distillation&lt;/strong>: compressing the retrieved context not into text, but into a handful of &lt;strong>embeddings/soft tokens&lt;/strong> that the model consumes directly. It reduces the number of prefill tokens to a minimum, at the price of a trained, model-specific component.&lt;/li>
&lt;/ul>
&lt;p>The key point connects with the maths above: &lt;strong>compressing what you retrieved by a factor $k$ reduces prefill tokens $\times k$, and therefore TTFT $\sim\times k$ and the attention cost $\sim\times k^2$.&lt;/strong> It is the lever with the best return when long context is the bottleneck.&lt;/p>
&lt;h3 id="3-prefix-caching-of-stable-context">3. Prefix caching of stable context&lt;/h3>
&lt;p>Not all the context changes between requests. System instructions, definitions, recurring reference documents, schemas: these are &lt;strong>stable prefixes&lt;/strong>. &lt;strong>Prefix caching&lt;/strong> stores the already-computed KV cache of those prefixes and reuses it, so that prefill only processes the new part (the query and the specific chunks). If 60 % of your context is stable, you save 60 % of that segment&amp;rsquo;s prefill on every hit. For it to work, &lt;strong>the stable context must go at the start of the prompt&lt;/strong> (the KV cache is prefix-dependent) and it pays to maximise the &lt;em>hit rate&lt;/em>; the hit-rate engineering detail is in &lt;a href="https://blog.lo0.es/en/posts/aggressive-rag-small-models-trading-parameters-for-retrieval/#see-also">Prefix cache hit rate&lt;/a>. It combines especially well with RAG: retrieved documents that repeat across sessions get cached once.&lt;/p>
&lt;h3 id="4-semantic-caching-of-answers">4. Semantic caching of answers&lt;/h3>
&lt;p>A layer in front of the model: if a query is &lt;strong>semantically equivalent&lt;/strong> to one answered before (embedding similarity above a threshold), return the cached answer and &lt;strong>skip the model entirely&lt;/strong>, retrieval, prefill and decode included. In real workloads with long tails of repeated or near-repeated questions (FAQ, support), the saving is enormous because it eliminates the full cost, not just the prefill one. The trap is the threshold: too lax and you serve wrong answers to similar-but-different questions. The design is in &lt;a href="https://blog.lo0.es/en/posts/aggressive-rag-small-models-trading-parameters-for-retrieval/#see-also">Semantic caching for RAG&lt;/a>.&lt;/p>
&lt;h3 id="5-structured-output-and-function-calling-lean-on-tools-not-on-memory">5. Structured output and function calling: lean on tools, not on memory&lt;/h3>
&lt;p>The last lever changes what the SLM depends on. Instead of asking it to &lt;em>know&lt;/em> a fact (its weak point), make it &lt;em>call a tool&lt;/em> that knows it: a database query, an API, a calculator, a validator. &lt;strong>Structured output&lt;/strong> (forcing JSON conforming to a schema) and &lt;strong>function calling&lt;/strong> turn the SLM into an orchestrator that extracts arguments from the context and delegates the computation or the query. A reasonably capable SLM emits a well-formed &lt;em>tool call&lt;/em> far more reliably than it recalls a specific fact. This reduces the pressure on parametric knowledge &lt;em>and&lt;/em> on retrieval: for structured, fresh data (prices, inventory, states), querying beats retrieving text and beats memorising. The fundamentals are in &lt;a href="https://blog.lo0.es/en/posts/aggressive-rag-small-models-trading-parameters-for-retrieval/#see-also">Structured output&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/aggressive-rag-small-models-trading-parameters-for-retrieval/#see-also">Function calling&lt;/a>.&lt;/p>
&lt;h2 id="the-complete-pipeline">The complete pipeline&lt;/h2>
&lt;p>The five levers are not alternatives: they chain together. The flow, with the token counter falling at each step:&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 290" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Retrieve, rerank, compress, SLM pipeline with the token counter falling">
&lt;defs>&lt;marker id="rag1" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="currentColor"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="22" text-anchor="middle" font-size="15" font-weight="700" fill="currentColor">Retrieve aggressively, distil aggressively, reason cheaply&lt;/text>
&lt;rect x="20" y="50" width="120" height="60" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4"/>
&lt;text x="80" y="76" text-anchor="middle" font-size="12" font-weight="700" fill="#1f3550">Retriever&lt;/text>
&lt;text x="80" y="93" text-anchor="middle" font-size="11" fill="#1f3550">hybrid, recall&lt;/text>
&lt;text x="80" y="130" text-anchor="middle" font-size="12" font-weight="700" fill="#1f5fa8">~80 chunks&lt;/text>
&lt;rect x="180" y="50" width="120" height="60" fill="#e6d0ff" stroke="#5a2db0" stroke-width="1.4"/>
&lt;text x="240" y="76" text-anchor="middle" font-size="12" font-weight="700" fill="#3a1d70">Reranker&lt;/text>
&lt;text x="240" y="93" text-anchor="middle" font-size="11" fill="#3a1d70">precision&lt;/text>
&lt;text x="240" y="130" text-anchor="middle" font-size="12" font-weight="700" fill="#5a2db0">5 chunks · 4,000 tok&lt;/text>
&lt;rect x="340" y="50" width="120" height="60" fill="#fff4d6" stroke="#a48000" stroke-width="1.4"/>
&lt;text x="400" y="76" text-anchor="middle" font-size="12" font-weight="700" fill="#5a4500">Compression&lt;/text>
&lt;text x="400" y="93" text-anchor="middle" font-size="11" fill="#5a4500">extractive k=4&lt;/text>
&lt;text x="400" y="130" text-anchor="middle" font-size="12" font-weight="700" fill="#a48000">1,000 tok&lt;/text>
&lt;rect x="500" y="50" width="120" height="60" fill="#cdebd0" stroke="#2a7a40" stroke-width="1.4"/>
&lt;text x="560" y="73" text-anchor="middle" font-size="12" font-weight="700" fill="#1c3a26">Prefix cache&lt;/text>
&lt;text x="560" y="90" text-anchor="middle" font-size="11" fill="#1c3a26">+ semantic&lt;/text>
&lt;text x="560" y="103" text-anchor="middle" font-size="11" fill="#1c3a26">cache&lt;/text>
&lt;text x="560" y="130" text-anchor="middle" font-size="12" font-weight="700" fill="#2a7a40">minimal prefill&lt;/text>
&lt;rect x="660" y="50" width="100" height="60" fill="#f6caca" stroke="#a52a2a" stroke-width="1.4"/>
&lt;text x="710" y="73" text-anchor="middle" font-size="12" font-weight="700" fill="#6a1a1a">SLM&lt;/text>
&lt;text x="710" y="90" text-anchor="middle" font-size="11" fill="#6a1a1a">reasons +&lt;/text>
&lt;text x="710" y="103" text-anchor="middle" font-size="11" fill="#6a1a1a">tool calls&lt;/text>
&lt;text x="710" y="130" text-anchor="middle" font-size="12" font-weight="700" fill="#a52a2a">answer&lt;/text>
&lt;path d="M140,80 L180,80" stroke="currentColor" stroke-width="1.6" fill="none" marker-end="url(#rag1)"/>
&lt;path d="M300,80 L340,80" stroke="currentColor" stroke-width="1.6" fill="none" marker-end="url(#rag1)"/>
&lt;path d="M460,80 L500,80" stroke="currentColor" stroke-width="1.6" fill="none" marker-end="url(#rag1)"/>
&lt;path d="M620,80 L660,80" stroke="currentColor" stroke-width="1.6" fill="none" marker-end="url(#rag1)"/>
&lt;text x="390" y="180" text-anchor="middle" font-size="13" font-weight="700" fill="currentColor">The prefill token counter falls along the pipeline&lt;/text>
&lt;rect x="100" y="200" width="560" height="22" fill="none" stroke="currentColor" stroke-width="1"/>
&lt;rect x="100" y="200" width="560" height="22" fill="#d4ecff"/>
&lt;rect x="240" y="200" width="280" height="22" fill="#fff4d6"/>
&lt;rect x="240" y="200" width="70" height="22" fill="#cdebd0"/>
&lt;text x="180" y="216" text-anchor="middle" font-size="11" fill="#1f3550">retrieve: a lot&lt;/text>
&lt;text x="380" y="216" text-anchor="middle" font-size="11" fill="#5a4500">rerank: 4,000 tok&lt;/text>
&lt;text x="275" y="216" text-anchor="middle" font-size="11" fill="#1c3a26">1,000&lt;/text>
&lt;text x="390" y="252" text-anchor="middle" font-size="12" fill="currentColor">TTFT on RTX 4090 at ~5,000 tok/s · 4,000 tok = 0.80 s → 1,000 tok = 0.20 s&lt;/text>
&lt;text x="390" y="272" text-anchor="middle" font-size="11" fill="currentColor">attention falls ~k² = 16× in that part of the compute&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The order matters. Retrieving aggressively (high recall) &lt;em>before&lt;/em> filtering guarantees that the right material is among the candidates; reranking and compressing &lt;em>afterwards&lt;/em> guarantees that only the dense and relevant material pays the prefill toll; caching wraps everything so as not to repeat work. The SLM only sees the final crib sheet, short and ordered.&lt;/p>
&lt;h2 id="implications-for-on-premise-inference">Implications for on-premise inference&lt;/h2>
&lt;p>The mental trap to avoid: treating the SLM as a large model with less quality. It is not. It is a &lt;strong>different cost profile&lt;/strong> that rewards a different design. Three practical consequences:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>The token budget is a first-class resource.&lt;/strong> With a large model with a 128K window, &amp;ldquo;putting in a bit more&amp;rdquo; is cheap relative to the model. With an SLM, every context token shows up in TTFT and in quality. Treat context size as a quantity to &lt;strong>minimise subject to covering the answer&lt;/strong>, not to maximise.&lt;/li>
&lt;li>&lt;strong>The investment is worth it precisely because the model is cheap.&lt;/strong> Reranker, compressor and caches add complexity, but the model they serve is economical enough to run many replicas. The bottleneck shifts from the model to the data pipeline, which is exactly where you want it.&lt;/li>
&lt;li>&lt;strong>Retrieving does not replace adapting; they combine.&lt;/strong> For deep, recurring domain knowledge, adapting the SLM with LoRA (see the sibling &lt;a href="https://blog.lo0.es/en/posts/qlora-multi-lora-limit-small-models/">QLoRA and aggressive multi-LoRA&lt;/a>) can put part of the knowledge &amp;ldquo;into the weights&amp;rdquo; cheaply, reducing what has to be retrieved. Aggressive RAG and aggressive adaptation do not compete: the first gives freshness and citability, the second gives fluency and domain formatting. A good design uses both.&lt;/li>
&lt;/ul>
&lt;h3 id="on-the-rtx-4090-24-gb-ada-lovelace">On the RTX 4090 (24 GB, Ada Lovelace)&lt;/h3>
&lt;p>The canonical scenario: a quantised SLM (4B–8B in INT4/FP8) fits with room to spare, leaving VRAM for a generous KV cache, essential for prefix caching, and for the reranker (a cross-encoder of a few hundred MB). The LLMLingua-style extractive compressor runs on a separate small model or on CPU. The TTFT calculation above (0.80 s → 0.20 s compressing 4× at ~5,000 tok/s) is representative of this card. The rule of thumb: if TTFT goes above your SLA, &lt;strong>the first adjustment is to compress the context, not to change the model&lt;/strong>.&lt;/p>
&lt;h3 id="on-a-generic-4h100-sxm-cluster-320-gb-nvlink-native-fp8">On a generic 4×H100 SXM cluster (320 GB, NVLink, native FP8)&lt;/h3>
&lt;p>With 320 GB and native FP8 prefill is much faster, so the temptation is to relax the token discipline. That is not quite advisable: the lever changes from TTFT to &lt;em>aggregate throughput&lt;/em>. Compressing the context not only speeds up each request but &lt;strong>frees prefill compute&lt;/strong> to serve more requests per GPU; compute-bound prefill is exactly the resource that saturates first under load. Here prefix caching and semantic caching, shared across replicas, are what pay off most: at high QPS, the prefill work you avoid by caching is pure throughput you gain. The SLM is still the cheap reasoning engine; the difference is that you now run many in parallel and the data pipeline is what decides how many requests fit.&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Evaluating compression&lt;/strong>: how to measure that compressing $k=4$ does not throw away correct answers (faithfulness, answer recall over a set of questions with ground truth).&lt;/li>
&lt;li>&lt;strong>Query-aware versus query-agnostic compression&lt;/strong>: compressing before or after knowing the question changes what can be cached and what can be discarded.&lt;/li>
&lt;li>&lt;strong>Chunking and granularity&lt;/strong>: chunk size interacts with reranking and compression; that is left for the corpus curation post.&lt;/li>
&lt;li>&lt;strong>Multi-hop and agents&lt;/strong>: when a question requires several retrieval rounds, the token budget is split across hops and compression discipline becomes critical.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">Reranking and hybrid retrieval for RAG&lt;/a> — lever 1 in detail: maximise recall in the retriever and precision in the reranker so as to inject few but excellent chunks, which is what an SLM needs.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">Corpus curation for RAG&lt;/a> — a clean, well-chunked corpus reduces the filler the compressor has to remove; the crib sheet&amp;rsquo;s quality starts upstream.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/semantic-cache-rag-receptionist-photographic-memory/">Semantic caching for RAG&lt;/a> — lever 4: skipping the model entirely when a query is semantically equivalent to one already answered.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/embeddings-2026-dense-sparse-multivector-families/">Embeddings 2026: dense, sparse and multivector&lt;/a> — the basis of hybrid retrieval and of the semantic cache threshold; which representation retrieves better with less noise.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/prefix-cache-hit-rate-engineering/">Prefix cache hit rate engineering&lt;/a> — lever 3: how to structure the prompt (stable context first) to maximise reuse of the retrieved context&amp;rsquo;s KV cache.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">Prefill optimisations in vLLM&lt;/a> — compute-bound prefill is the cost this whole discussion tries to minimise; here are the concrete parameters to speed it up.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">Structured output: fundamentals&lt;/a> — lever 5: forcing schema-conforming JSON so the SLM orchestrates tools instead of recalling data.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/function-calling-tool-augmented-retrieval-detective-knows-which-file/">Function calling and tool-augmented retrieval&lt;/a> — when querying an API or database beats retrieving text and beats memorising; the SLM as a tool orchestrator.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">The roofline inverts in small models&lt;/a> — why compute-bound prefill is the bottleneck that shapes this whole post: adding more context is not free.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/qlora-multi-lora-limit-small-models/">QLoRA and aggressive multi-LoRA in SLMs&lt;/a> — the complementary alternative: adapting the SLM per domain to put part of the knowledge &amp;ldquo;into the weights&amp;rdquo; and reduce what has to be retrieved.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Lewis, P., et al. &lt;em>Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks&lt;/em>. NeurIPS 2020. &lt;a href="https://arxiv.org/abs/2005.11401">https://arxiv.org/abs/2005.11401&lt;/a>&lt;/li>
&lt;li>Liu, N.F., et al. &lt;em>Lost in the Middle: How Language Models Use Long Contexts&lt;/em>. TACL 2024. &lt;a href="https://arxiv.org/abs/2307.03172">https://arxiv.org/abs/2307.03172&lt;/a>&lt;/li>
&lt;li>Jiang, H., et al. &lt;em>LLMLingua: Compressing Prompts for Accelerated Inference of Large Language Models&lt;/em>. EMNLP 2023. &lt;a href="https://arxiv.org/abs/2310.05736">https://arxiv.org/abs/2310.05736&lt;/a>&lt;/li>
&lt;li>Jiang, H., et al. &lt;em>LongLLMLingua: Accelerating and Enhancing LLMs in Long Context Scenarios via Prompt Compression&lt;/em>. ACL 2024. &lt;a href="https://arxiv.org/abs/2310.06839">https://arxiv.org/abs/2310.06839&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Aggressive quantisation (state of the art): from 4-bit to ternary</title><link>https://blog.lo0.es/en/posts/aggressive-quantisation-4-bit-to-ternary/</link><pubDate>Tue, 09 Jun 2026 02:10:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/aggressive-quantisation-4-bit-to-ternary/</guid><description>&lt;blockquote>
&lt;p>This post is the direct continuation of &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantization for LLM inference&lt;/a>, which covers the &amp;ldquo;solved&amp;rdquo; regime (FP8, INT4 with GPTQ/AWQ). Read it first: here I assume the scale+zero-point maths, what GPTQ and AWQ do, and the PTQ/QAT distinction. What we add is the &lt;strong>sub-4-bit frontier&lt;/strong>, where scalar post-hoc quantisation stops working and you have to change tools.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>There is a sharp dividing line around 4 bits. Above it, quantising is a solved problem: INT8 is indistinguishable from BF16, and INT4 with a good method (AWQ, GPTQ) loses 1-2 MMLU points and little else. The method is still the usual one, take each weight, scale it, round it to a short integer, and it works. Below 4 bits, that method collapses: at 2 bits, naive scalar quantisation can double perplexity. The reason is geometric, each weight has only 4 possible values and the rounding error stops being negligible, and the way out is not &amp;ldquo;round better&amp;rdquo;, it is to &lt;strong>change the representation&lt;/strong>. State-of-the-art 2-bit methods (AQLM, QuIP#, QTIP) stop quantising individual weights and quantise &lt;strong>vectors&lt;/strong> of weights against dictionaries (codebooks), and they &amp;ldquo;whiten&amp;rdquo; the weight matrix to spread its energy and flatten outliers (incoherence processing). Ternary is something else again: BitNet b1.58, with weights in {-1, 0, +1} (~1.58 bits), is not PTQ, it is a model trained natively under that constraint, and it changes the arithmetic of the matmul from multiplications to additions and subtractions, hitting the compute ceiling and the memory ceiling at once. The mental rule: at ≥4-bit you compress the photo; below 4-bit you have to repaint it.&lt;/p>
&lt;h2 id="the-analogy-the-jpeg-that-cannot-be-compressed-any-further">The analogy: the JPEG that cannot be compressed any further&lt;/h2>
&lt;p>In &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">the quantization post&lt;/a> we used the JPEG with an edge detector to explain INT4. The analogy still holds here, but it has to be pushed to its limit.&lt;/p>
&lt;p>A JPEG at quality factor 90 is indistinguishable from the original. At quality 60 you notice it a little, but it is still &amp;ldquo;the same photo&amp;rdquo;. At quality 30 the 8×8 blocks appear, the haloes around edges, the banding in gradients. At quality 10 the image is destroyed: you can tell there &lt;strong>was&lt;/strong> a face, but the details have disappeared under the artefacts. And here is the key: &lt;strong>no JPEG encoder exists that can compress to quality 10 without those artefacts&lt;/strong>, because the JPEG algorithm (blockwise DCT + coefficient quantisation) has an information floor below which its own mechanism introduces the noise.&lt;/p>
&lt;p>What do you do if you need the photo at that file size and you need it to still look good? You do not compress the original harder. You &lt;strong>repaint the photo knowing in advance that it is going to live compressed&lt;/strong>: an illustrator redraws it with clean lines, a reduced palette, no subtle gradients, an image designed to survive brutal compression. The result at &amp;ldquo;10 KB&amp;rdquo; looks infinitely better than the original JPEG crushed to 10 KB, because it is not the same process: one destroys existing information, the other generates new information already adapted to the constraint.&lt;/p>
&lt;p>That is exactly the frontier of this post:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Scalar PTQ (≥4-bit)&lt;/strong> = compressing the JPEG. Up to a certain ratio, it is still the same photo.&lt;/li>
&lt;li>&lt;strong>State-of-the-art vector PTQ (2-bit: AQLM, QuIP#, QTIP)&lt;/strong> = a far more sophisticated image codec (dictionaries, decorrelating transforms) that stretches the compressible ratio a bit further before the collapse.&lt;/li>
&lt;li>&lt;strong>Native ternary (BitNet b1.58)&lt;/strong> = repainting the photo. You are not compressing an existing BF16 model; you are training a new one that is born ternary.&lt;/li>
&lt;/ul>
&lt;h2 id="the-map-of-the-frontier-bit-by-bit">The map of the frontier, bit by bit&lt;/h2>
&lt;p>Quantising a model is deciding how many distinct values each weight can take. With &lt;code>b&lt;/code> bits per weight there are &lt;code>2^b&lt;/code> possible values. The central question is: past what &lt;code>b&lt;/code> is the number of values so small that rounding destroys the model?&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Bits&lt;/th>
&lt;th>Values/weight&lt;/th>
&lt;th>State of the art&lt;/th>
&lt;th>Method required&lt;/th>
&lt;th>Typical loss vs BF16&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>8&lt;/td>
&lt;td>256&lt;/td>
&lt;td>&lt;strong>Solved&lt;/strong>&lt;/td>
&lt;td>RTN, SmoothQuant, FP8&lt;/td>
&lt;td>~0 (indistinguishable)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>4&lt;/td>
&lt;td>16&lt;/td>
&lt;td>&lt;strong>Solved&lt;/strong>&lt;/td>
&lt;td>AWQ, GPTQ&lt;/td>
&lt;td>1-2 pp MMLU, +0.1-0.3 PPL&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3&lt;/td>
&lt;td>8&lt;/td>
&lt;td>Small degradation&lt;/td>
&lt;td>tuned GPTQ/AWQ, GGUF Q3_K&lt;/td>
&lt;td>3-5 pp MMLU&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2&lt;/td>
&lt;td>4&lt;/td>
&lt;td>Serious unless SOTA&lt;/td>
&lt;td>&lt;strong>AQLM, QuIP#, QTIP&lt;/strong> (non-scalar)&lt;/td>
&lt;td>scalar: collapse; SOTA: 4-8 pp&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1.58&lt;/td>
&lt;td>3 (ternary)&lt;/td>
&lt;td>Native only&lt;/td>
&lt;td>&lt;strong>BitNet b1.58&lt;/strong> (QAT/native training)&lt;/td>
&lt;td>n/a (not PTQ)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1&lt;/td>
&lt;td>2 (binary)&lt;/td>
&lt;td>Research&lt;/td>
&lt;td>native, dubious claims&lt;/td>
&lt;td>large / no clear methodology&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The three transitions that matter:&lt;/p>
&lt;p>&lt;strong>8 → 4 bits: nothing breaks.&lt;/strong> With 16 levels per weight and one scale per block of 128, the rounding error is small relative to the dynamics of the weights. GPTQ compensates for the error by propagating it to the neighbouring weights; AWQ protects the ~1 % of salient channels. The model barely notices. This is in the previous post.&lt;/p>
&lt;p>&lt;strong>4 → 2 bits: the elbow.&lt;/strong> Two things happen at once here. First, with only 4 levels, the scalar quantiser can no longer represent the weight distribution, which is roughly Gaussian with long tails, without a proportionally enormous rounding error. Second, and more subtly: the quantisation error stops being &amp;ldquo;small noise that the model absorbs&amp;rdquo; and becomes &lt;strong>structured&lt;/strong>, systematically biasing the activations. Naive scalar PTQ at 2 bits on a Llama 8B typically doubles perplexity or worse. That is the elbow of the curve.&lt;/p>
&lt;p>&lt;strong>2 → 1.58 bits: a change of nature.&lt;/strong> You do not cross this with a better compression method. You cross it by training the model with the constraint from the start. It is a discontinuity: to the left you are doing PTQ, to the right you are doing training.&lt;/p>
&lt;h2 id="why-scalar-ptq-collapses-below-4-bits">Why scalar PTQ collapses below 4 bits&lt;/h2>
&lt;p>The scalar quantiser has a fundamental limitation: it quantises each weight separately, ignoring the fact that the weights of a row or column are correlated and that the error of one could be compensated by another. At 4 bits this matters little; at 2 bits it is lethal. There are three possible attacks, and the state-of-the-art methods use all three.&lt;/p>
&lt;h3 id="1-vector-quantisation-dictionaries-instead-of-scales">1. Vector quantisation: dictionaries instead of scales&lt;/h3>
&lt;p>Instead of mapping each weight to one of 4 values, group the weights into &lt;strong>vectors&lt;/strong> (say, of 8 weights) and map each vector to the nearest entry of a learned dictionary (codebook). If the dictionary has 256 entries, encoding a vector of 8 weights costs 8 bits (the index) → 1 bit/weight, but every &amp;ldquo;reconstructed value&amp;rdquo; is a point in an 8-dimensional space chosen to minimise the error over the real distribution of the weights.&lt;/p>
&lt;p>The advantage is information-theoretic: a vector dictionary can place its reconstruction points where the weights actually are (in clusters), whereas the scalar quantiser is forced to put its 4 levels on a regular grid, wasting resolution on empty regions. It is the difference between a road map with a uniform grid and one that puts more detail where the cities are.&lt;/p>
&lt;p>&lt;strong>AQLM&lt;/strong> (Additive Quantization of Language Models, arXiv:2401.06118) takes this to the extreme with additive quantisation: each weight vector is reconstructed as the &lt;strong>sum of several codes&lt;/strong> from several dictionaries (multi-codebook). It is more expressive than a single dictionary because the number of combinations is the product of the sizes, not the sum. AQLM was one of the first methods to make 2-bit &amp;ldquo;usable&amp;rdquo; (not collapsed) on large models, at the cost of an expensive calibration process and specialised inference kernels.&lt;/p>
&lt;h3 id="2-incoherence-processing-whitening-the-matrix">2. Incoherence processing: whitening the matrix&lt;/h3>
&lt;p>The second attack targets the outliers. The weight matrices of a transformer have a few entries (and a few channels) with a magnitude much larger than the rest. Those outliers dominate the quantiser&amp;rsquo;s range: if you have to represent a weight of magnitude 8 while the rest are of magnitude 0.5, your scale stretches to cover the 8 and you waste almost all the resolution.&lt;/p>
&lt;p>&lt;strong>Incoherence processing&lt;/strong> (the core idea of QuIP and QuIP#) attacks this by multiplying the weight matrix &lt;code>W&lt;/code> by random orthogonal matrices on the left and on the right: &lt;code>W' = U W V^T&lt;/code>. Because &lt;code>U&lt;/code> and &lt;code>V&lt;/code> are orthogonal, the operation is invertible and the maths of the product can be undone at inference time by absorbing it into the neighbouring layers (just as AWQ absorbs its scales). But the rotation spreads the energy: an &amp;ldquo;incoherent&amp;rdquo; matrix has its values distributed almost uniformly, with no concentrated outliers, because mixing coordinates with a random rotation flattens the distribution (it is, in essence, the central limit theorem acting on linear combinations). A matrix without outliers quantises far better at 2 bits. It is the equivalent of &amp;ldquo;whitening&amp;rdquo; a signal before digitising it.&lt;/p>
&lt;p>&lt;strong>QuIP#&lt;/strong> (arXiv:2402.04396) combines incoherence processing with E8 lattice codes: rather than an arbitrary dictionary, it uses the E8 lattice (an optimal sphere packing in 8 dimensions, the best known). Quantising vectors of 8 weights against the E8 lattice gives the smallest reconstruction error possible for a given bit density, because E8 is literally the most efficient way to place points in 8D. It is classical coding theory applied to LLM weights.&lt;/p>
&lt;h3 id="3-coding-with-memory-trellis">3. Coding with memory: trellis&lt;/h3>
&lt;p>&lt;strong>QTIP&lt;/strong> (arXiv:2406.11235) adds the third attack: trellis-coded quantisation. Instead of quantising each vector independently, it models the sequence of weights as a path through a trellis (the same structure as the convolutional codes of telecommunications) and picks the optimal code sequence with the Viterbi algorithm. The intuition: introducing &lt;strong>memory&lt;/strong> between successive quantisations allows correlated errors that cancel out, instead of independent errors that accumulate. QTIP, on top of incoherence processing, improves on QuIP# in quality at 2-3 bits while keeping fast inference kernels.&lt;/p>
&lt;p>The idea common to all three: &lt;strong>stop quantising scalars and start quantising vectors with dictionaries, and decorrelate the matrix before doing so&lt;/strong>. None of them is &amp;ldquo;rounding better&amp;rdquo;; all three change the representation at the root. That is why, below 4 bits, a flag in vLLM is no longer enough: you need co-design of the quantisation method and the inference kernel.&lt;/p>
&lt;h2 id="native-ternary-bitnet-b158">Native ternary: BitNet b1.58&lt;/h2>
&lt;p>Here we change continents. Everything above is &lt;strong>PTQ&lt;/strong>: it starts from a trained BF16 model and compresses it. BitNet&amp;rsquo;s ternary compresses nothing.&lt;/p>
&lt;p>&lt;strong>BitNet b1.58&lt;/strong> (arXiv:2402.17764) trains a transformer from scratch where &lt;strong>every weight is constrained to {-1, 0, +1}&lt;/strong> throughout training. Three values ⇒ log₂(3) ≈ &lt;strong>1.58 bits/weight&lt;/strong>. Quantisation is not a later step: the linear layers (&lt;code>BitLinear&lt;/code>) quantise their weights to ternary in the forward pass of every training step, and gradients flow through a straight-through estimator. The model learns to work with ternary weights. This is QAT taken to the extreme: not a short robustness fine-tune, but the constraint present from the first token of training.&lt;/p>
&lt;p>That difference is what dodges the elbow of the curve. PTQ at 2 bits tries to find the best ternary or quaternary approximation of a model that was trained expecting full precision, and that model has &amp;ldquo;fragile&amp;rdquo; weights that depend on nuances 2 bits cannot capture. BitNet, by contrast, never had those nuances: its weights were born ternary, so the network distributed its representational capacity in a way compatible with the constraint. It is repainting the photo instead of compressing it.&lt;/p>
&lt;h3 id="what-changes-is-not-just-the-memory-it-is-the-arithmetic">What changes is not just the memory, it is the arithmetic&lt;/h3>
&lt;p>The most underrated point about BitNet: with weights in {-1, 0, +1}, multiplication disappears from the matmul. Multiplying an activation &lt;code>x&lt;/code> by a ternary weight &lt;code>w&lt;/code> is trivial: if &lt;code>w = +1&lt;/code> you add &lt;code>x&lt;/code>, if &lt;code>w = -1&lt;/code> you subtract &lt;code>x&lt;/code>, if &lt;code>w = 0&lt;/code> you do nothing. The dominant operation of a transformer, the matrix-vector product, goes from a sea of floating point multiply-accumulate (MAC) to integer additions and subtractions.&lt;/p>
&lt;p>This matters because it connects to the roofline. As explained in &lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">The inverted roofline of small models&lt;/a>, LLM inference has two ceilings: &lt;strong>memory&lt;/strong> (HBM bandwidth for loading weights) and compute (tensor core FLOPs). Normal quantisation (INT4, FP8) attacks only the memory ceiling: the weight takes up less space, but to multiply it you dequantise to FP16 and do the same multiplication as always. Ternary attacks both ceilings at once: the weight occupies 1.58 bits (memory) &lt;strong>and&lt;/strong> the operation is an addition instead of a multiplication (compute). That is why BitNet needs its own kernels, bitnet.cpp, that execute the ternary matmul without ever going through FP16; a kernel that dequantised to FP16 in order to multiply would throw away half the advantage.&lt;/p>
&lt;p>The honest trade-off: BitNet b1.58 is training from scratch. You cannot &amp;ldquo;convert your Llama 8B to BitNet&amp;rdquo;. If you want ternary, you train (or use) a natively ternary model, with everything that implies in pre-training cost and weight availability. Today it is a research line with published models at modest scales, not a drop-in replacement for your current serving.&lt;/p>
&lt;h2 id="qat-as-the-bridge-between-ptq-and-native">QAT as the bridge between PTQ and native&lt;/h2>
&lt;p>Between &amp;ldquo;compressing post-hoc&amp;rdquo; (PTQ) and &amp;ldquo;training natively ternary&amp;rdquo; (BitNet) there is a middle point: &lt;strong>QAT&lt;/strong> (Quantization-Aware Training). You take an already trained model and do a short fine-tune with the quantisation operations inside the loop, so that it learns to be robust to low bit counts without paying for a full pre-training.&lt;/p>
&lt;p>&lt;strong>Gemma 3&lt;/strong> publishes official &lt;strong>QAT&lt;/strong> variants for exactly this: models that, after the QAT fine-tune, hold up at INT4 with far less quality loss than pure PTQ on the same model. The cost is a training cost (GPU hours to days on an existing model), not an inference cost. For INT4 with QAT you recover almost all the quality; for 2-bit, QAT helps but the terrain is still difficult; for ternary, QAT stops being a &amp;ldquo;short fine-tune&amp;rdquo; and becomes native training (BitNet).&lt;/p>
&lt;p>The decision hierarchy:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>PTQ&lt;/strong> = default at ≥4 bits. Minutes to hours, without touching training weights. It covers 90 % of production.&lt;/li>
&lt;li>&lt;strong>QAT&lt;/strong> = when PTQ loses too much and the difference matters. Low bits (2-3), or sensitive models. You pay for a fine-tune.&lt;/li>
&lt;li>&lt;strong>Native (ternary)&lt;/strong> = when you want to go below 2 bits &lt;strong>and&lt;/strong> change the arithmetic. You pay for pre-training. It only makes sense if you control the model from its creation.&lt;/li>
&lt;/ul>
&lt;h2 id="the-maths-that-matter-footprint-and-how-many-fit">The maths that matter: footprint and how many fit&lt;/h2>
&lt;p>The weight footprint is direct: &lt;code>bytes = (bits/param / 8) × N&lt;/code>, with &lt;code>N&lt;/code> the number of parameters. For an 8B model:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Level&lt;/th>
&lt;th>bits/param&lt;/th>
&lt;th>8B footprint&lt;/th>
&lt;th>Ratio vs BF16&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>BF16&lt;/td>
&lt;td>16&lt;/td>
&lt;td>16.0 GB&lt;/td>
&lt;td>1.0×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>INT8&lt;/td>
&lt;td>8&lt;/td>
&lt;td>8.0 GB&lt;/td>
&lt;td>2.0×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>INT4&lt;/td>
&lt;td>4&lt;/td>
&lt;td>4.0 GB&lt;/td>
&lt;td>4.0×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3-bit&lt;/td>
&lt;td>3&lt;/td>
&lt;td>3.0 GB&lt;/td>
&lt;td>5.3×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2-bit&lt;/td>
&lt;td>2&lt;/td>
&lt;td>2.0 GB&lt;/td>
&lt;td>8.0×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1.58-bit (ternary)&lt;/td>
&lt;td>~1.58&lt;/td>
&lt;td>~1.6 GB&lt;/td>
&lt;td>~10×&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>(Real ternary takes up slightly more than 1.58 bits/param because you have to pack 5 ternary values into 8 bits, 5 × log₂(3) ≈ 7.92 bits, and because norms and embeddings usually stay at higher precision. The ~1.6 GB figure for 8B is the right order of magnitude.)&lt;/p>
&lt;h3 id="how-many-8b-models-fit-on-an-rtx-4090">How many 8B models fit on an RTX 4090?&lt;/h3>
&lt;p>An &lt;strong>RTX 4090 (24 GB, Ada Lovelace)&lt;/strong> has 24 GB. Reserving ~4 GB for KV cache and activations leaves &lt;strong>20 GB&lt;/strong> for weights. How many distinct 8B models fit loaded at the same time:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Level&lt;/th>
&lt;th>8B footprint&lt;/th>
&lt;th>Models in 20 GB&lt;/th>
&lt;th>Comment&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>BF16&lt;/td>
&lt;td>16.0 GB&lt;/td>
&lt;td>&lt;strong>1&lt;/strong>&lt;/td>
&lt;td>one, with scant headroom left&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>INT8&lt;/td>
&lt;td>8.0 GB&lt;/td>
&lt;td>&lt;strong>2&lt;/strong>&lt;/td>
&lt;td>two different models&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>INT4&lt;/td>
&lt;td>4.0 GB&lt;/td>
&lt;td>&lt;strong>5&lt;/strong>&lt;/td>
&lt;td>the solved regime; quality ~lossless with AWQ&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>3-bit&lt;/td>
&lt;td>3.0 GB&lt;/td>
&lt;td>&lt;strong>6&lt;/strong>&lt;/td>
&lt;td>small degradation already visible&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>2-bit&lt;/td>
&lt;td>2.0 GB&lt;/td>
&lt;td>&lt;strong>10&lt;/strong>&lt;/td>
&lt;td>only viable with AQLM/QuIP#/QTIP&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1.58-bit&lt;/td>
&lt;td>~1.6 GB&lt;/td>
&lt;td>&lt;strong>~12&lt;/strong>&lt;/td>
&lt;td>natively ternary models only&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The arithmetic is seductive, from 1 to 12 models on the same card, but it should be read sceptically. Jumping from INT4 (5 models, almost lossless) to 2-bit (10 models) doubles the capacity, but only if you use a state-of-the-art method and accept 4-8 MMLU points. And the jump from 2-bit to ternary (10 → 12) is marginal in memory terms: ternary is not justified by footprint against a state-of-the-art 2-bit, it is justified by the arithmetic (the compute ceiling) and because being native it avoids the quality elbow. If your only metric is &amp;ldquo;how many GB it takes&amp;rdquo;, state-of-the-art 2-bit already gives you nearly everything. Ternary is for when you also want the compute saving.&lt;/p>
&lt;h3 id="the-conceptual-curve-perplexity-vs-bits">The conceptual curve: perplexity vs bits&lt;/h3>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 380" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Conceptual curve of perplexity against bits per weight">
&lt;text x="390" y="26" text-anchor="middle" fill="currentColor" font-size="15" font-weight="700">Perplexity vs bits per weight (conceptual): the elbow and the native branch&lt;/text>
&lt;line x1="90" y1="320" x2="730" y2="320" stroke="currentColor" stroke-width="1.5"/>
&lt;line x1="90" y1="60" x2="90" y2="320" stroke="currentColor" stroke-width="1.5"/>
&lt;text x="410" y="362" text-anchor="middle" fill="currentColor" font-size="13">bits per weight (inverted axis: more compressed to the right)&lt;/text>
&lt;text x="30" y="190" text-anchor="middle" fill="currentColor" font-size="13" transform="rotate(-90 30 190)">perplexity (worse at the top)&lt;/text>
&lt;text x="120" y="338" text-anchor="middle" fill="currentColor" font-size="12">16&lt;/text>
&lt;text x="250" y="338" text-anchor="middle" fill="currentColor" font-size="12">8&lt;/text>
&lt;text x="380" y="338" text-anchor="middle" fill="currentColor" font-size="12">4&lt;/text>
&lt;text x="470" y="338" text-anchor="middle" fill="currentColor" font-size="12">3&lt;/text>
&lt;text x="560" y="338" text-anchor="middle" fill="currentColor" font-size="12">2&lt;/text>
&lt;text x="650" y="338" text-anchor="middle" fill="currentColor" font-size="12">1.58&lt;/text>
&lt;line x1="380" y1="60" x2="380" y2="320" stroke="currentColor" stroke-width="0.8" stroke-dasharray="4 3"/>
&lt;text x="384" y="74" fill="currentColor" font-size="11">4-bit frontier&lt;/text>
&lt;polyline points="120,300 250,298 380,292 470,278 560,170 620,95" fill="none" stroke="#c0392b" stroke-width="2.6"/>
&lt;circle cx="120" cy="300" r="4" fill="#c0392b"/>
&lt;circle cx="250" cy="298" r="4" fill="#c0392b"/>
&lt;circle cx="380" cy="292" r="4" fill="#c0392b"/>
&lt;circle cx="470" cy="278" r="4" fill="#c0392b"/>
&lt;circle cx="560" cy="170" r="4" fill="#c0392b"/>
&lt;text x="600" y="92" fill="#c0392b" font-size="12" font-weight="700">naive scalar PTQ&lt;/text>
&lt;text x="600" y="108" fill="#c0392b" font-size="11">collapses &amp;lt;3 bits&lt;/text>
&lt;polyline points="380,292 470,284 560,250 650,232" fill="none" stroke="#2471a3" stroke-width="2.6" stroke-dasharray="6 3"/>
&lt;circle cx="560" cy="250" r="4" fill="#2471a3"/>
&lt;circle cx="650" cy="232" r="4" fill="#2471a3"/>
&lt;text x="560" y="282" fill="#2471a3" font-size="12" font-weight="700">SOTA vector PTQ&lt;/text>
&lt;text x="560" y="298" fill="#2471a3" font-size="11">AQLM / QuIP# / QTIP&lt;/text>
&lt;circle cx="650" cy="225" r="6" fill="#27ae60"/>
&lt;text x="600" y="208" fill="#27ae60" font-size="12" font-weight="700">native ternary&lt;/text>
&lt;text x="600" y="222" fill="#27ae60" font-size="11">BitNet b1.58 (not PTQ)&lt;/text>
&lt;text x="120" y="290" fill="currentColor" font-size="11">≈ flat ≥ 4 bits with a good method&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>Three readings of the curve. &lt;strong>One&lt;/strong>: to the right of 4 bits, the three branches are almost stuck together and almost flat, the solved regime. &lt;strong>Two&lt;/strong>: the red branch (naive scalar PTQ) has a brutal elbow between 3 and 2 bits; that is where it doubles perplexity. The blue branch (state-of-the-art vector PTQ) flattens that elbow, it does not remove it, but it makes it tolerable down to 2 bits. &lt;strong>Three&lt;/strong>: the green point of native ternary &lt;strong>is on neither of the two PTQ curves&lt;/strong>, because it is not obtained by compressing: it is obtained by training, and that is why it can fall below the elbow without paying the quality price that any PTQ pays at that bit density. It is the difference between the crushed JPEG and the repainted photo.&lt;/p>
&lt;h2 id="mandatory-scepticism-lossless-1-bit-and-benchmarks-without-methodology">Mandatory scepticism: &amp;ldquo;lossless&amp;rdquo; 1-bit and benchmarks without methodology&lt;/h2>
&lt;p>Three warnings for reading the aggressive-quantisation literature:&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;1-bit lossless&amp;rdquo; almost always comes with small print.&lt;/strong> Pure binary {-1, +1} (1 bit) loses the ability to represent zero, which matters in transformers (many weights are effectively null). That is why the true state of the art at low density is &lt;strong>ternary&lt;/strong> (1.58 bits), not binary: the zero is worth its extra 0.58 of a bit. When a paper announces &amp;ldquo;1-bit&amp;rdquo;, it pays to check whether (a) it really is 1 bit or 1.58 rounded down in the headline, (b) &amp;ldquo;lossless&amp;rdquo; is measured in WikiText perplexity (easy) or on reasoning benchmarks (where the collapse shows up), and (c) it compares against a baseline of the same effective size or against a much larger model in order to inflate the advantage.&lt;/p>
&lt;p>&lt;strong>Flat perplexity ≠ preserved quality.&lt;/strong> Perplexity on a generic corpus is the metric most forgiving of aggressive quantisation. A 2-bit model can have a perplexity almost identical to BF16 and at the same time drop 10 points on GSM8K or on a code benchmark, because multi-step reasoning amplifies errors that average perplexity does not see. Distrust any sub-4-bit claim that only reports perplexity. As we already said in &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">the quantization post&lt;/a>, the loss has to be measured on the target task.&lt;/p>
&lt;p>&lt;strong>Hardware comparability.&lt;/strong> The &amp;ldquo;X times faster&amp;rdquo; numbers for ternary only apply &lt;strong>with the specialised kernels&lt;/strong> (bitnet.cpp) and on hardware where addition/subtraction arithmetic genuinely wins. On a GPU with tensor cores designed for FP16/FP8, a naive ternary kernel can be &lt;strong>slower&lt;/strong> than a well-optimised INT4, because it wastes the silicon. The ternary advantage is real, but it is a &lt;strong>co-design&lt;/strong> advantage (model + kernel + sometimes hardware), not a flag you switch on over your current stack. Any benchmark that does not specify the exact kernel and hardware is noise.&lt;/p>
&lt;h2 id="implications-for-on-premise-inference">Implications for on-premise inference&lt;/h2>
&lt;p>On the &lt;strong>RTX 4090 (24 GB, Ada Lovelace)&lt;/strong>: the practical regime today is still INT4 AWQ for 7-14B models, solved, almost lossless, natively supported. State-of-the-art 2-bit (AQLM/QuIP#/QTIP) is viable and lets you load larger models or more models at once, but it demands the specific kernels of each method and an expensive calibration, and it pays in quality. It makes sense when VRAM is the bottleneck and you accept the trade-off; not as a default. Ternary on a 4090 is experimental: without tensor cores designed for ternary addition and subtraction, the compute advantage is diluted, although the memory saving remains.&lt;/p>
&lt;p>On a &lt;strong>generic 4×H100 SXM cluster (320 GB, NVLink, native FP8)&lt;/strong>: here the default is FP8 (almost indistinguishable quality, native throughput) or INT4 AWQ for models that do not fit in FP8. State-of-the-art sub-4-bit is for serving enormous models (200B+) when neither FP8 nor INT4 fits with the KV cache headroom you want, at the cost of quality and kernel complexity. Native ternary, today, is a research object rather than a production one: its promise, hitting both roofline ceilings, is greater on CPU/edge (where there are no FP8 tensor cores to exploit) than on an H100 cluster, which already has dedicated FP8 hardware.&lt;/p>
&lt;p>The rule of thumb, June 2026: &lt;strong>≥4-bit is settled engineering; state-of-the-art 2-bit is a real lever but with a cost in method and in quality; ternary is an architectural bet, not a deployment tweak&lt;/strong>.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantization for LLM inference: FP8, INT4, GGUF&lt;/a> — the indispensable base: the scale+zero-point maths, GPTQ/AWQ and PTQ vs QAT, all taken for granted here; this post is its continuation towards the sub-4-bit frontier.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8 end to end: weights, KV and quality&lt;/a> — the other end of the spectrum, the solved datacenter regime where quantisation costs almost no quality.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/knowledge-distillation-teaching-small-model-think-like-big-one/">Knowledge distillation&lt;/a> — the complementary lever: distilling reduces parameters, quantising reduces bits per parameter; at 2-bit they are usually combined to reach the target footprint.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-pruning-cutting-without-amputating/">Pruning LLM models&lt;/a> — sparsity and aggressive quantisation are orthogonal and they compound: 50 % sparse + 2-bit is another route to the same footprint as ternary.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory&lt;/a> — the ~4 GB we reserve for KV in the 4090 arithmetic come from here; quantising the cache is the other half of the memory budget.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">The inverted roofline of small models&lt;/a> — why ternary is special: it attacks the memory ceiling and the compute ceiling at once, while INT4/FP8 only touch the memory one.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/test-time-quantization-without-calibration-dataset/">Test-time quantization on the fly&lt;/a> — quantising dynamically at inference time against the static, calibrated quantisation that AQLM/QuIP#/QTIP describe.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/device-native-architectures-fine-grained-moe-pre-attention-router/">Device-native architectures + fine-grained MoE&lt;/a> — on-device Q4 as the starting point of which sub-4-bit and ternary are the next frontier for edge.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/qlora-multi-lora-limit-small-models/">QLoRA and aggressive multi-LoRA on SLMs&lt;/a> — fine-tuning on an already quantised base; the limit of how far you can compress the base before the adapter can no longer recover the quality.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Ma, S. et al. &lt;em>The Era of 1-bit LLMs: All Large Language Models are in 1.58 Bits&lt;/em> (BitNet b1.58). &lt;a href="https://arxiv.org/abs/2402.17764">https://arxiv.org/abs/2402.17764&lt;/a>&lt;/li>
&lt;li>Egiazarian, V., Panferov, A., Kuznedelev, D. et al. &lt;em>Extreme Compression of Large Language Models via Additive Quantization&lt;/em> (AQLM). &lt;a href="https://arxiv.org/abs/2401.06118">https://arxiv.org/abs/2401.06118&lt;/a>&lt;/li>
&lt;li>Tseng, A., Chee, J., Sun, Q., Kuleshov, V., De Sa, C. &lt;em>QuIP#: Even Better LLM Quantization with Hadamard Incoherence and Lattice Codebooks&lt;/em>. &lt;a href="https://arxiv.org/abs/2402.04396">https://arxiv.org/abs/2402.04396&lt;/a>&lt;/li>
&lt;li>Tseng, A., Sun, Q., Hou, D., De Sa, C. &lt;em>QTIP: Quantization with Trellises and Incoherence Processing&lt;/em>. &lt;a href="https://arxiv.org/abs/2406.11235">https://arxiv.org/abs/2406.11235&lt;/a>&lt;/li>
&lt;li>Frantar, E., Ashkboos, S., Hoefler, T., Alistarh, D. &lt;em>GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers&lt;/em>. &lt;a href="https://arxiv.org/abs/2210.17323">https://arxiv.org/abs/2210.17323&lt;/a>&lt;/li>
&lt;li>Lin, J., Tang, J., Tang, H., Yang, S., Dang, X., Han, S. &lt;em>AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration&lt;/em>. &lt;a href="https://arxiv.org/abs/2306.00978">https://arxiv.org/abs/2306.00978&lt;/a>&lt;/li>
&lt;li>Google DeepMind. &lt;em>Gemma 3 QAT (Quantization-Aware Training) models&lt;/em> — official blog: &lt;a href="https://developers.googleblog.com/en/gemma-3-quantized-aware-trained-state-of-the-art-ai-to-consumer-gpus/">https://developers.googleblog.com/en/gemma-3-quantized-aware-trained-state-of-the-art-ai-to-consumer-gpus/&lt;/a>&lt;/li>
&lt;li>Microsoft. &lt;em>bitnet.cpp&lt;/em> — 1-bit ternary inference kernels: &lt;a href="https://github.com/microsoft/BitNet">https://github.com/microsoft/BitNet&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Test-time quantization: quantising on the fly with no calibration dataset</title><link>https://blog.lo0.es/en/posts/test-time-quantization-without-calibration-dataset/</link><pubDate>Tue, 09 Jun 2026 02:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/test-time-quantization-without-calibration-dataset/</guid><description>&lt;blockquote>
&lt;p>This post is the natural continuation of &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantization for LLM inference&lt;/a>, which is worth reading first: that is where GPTQ, AWQ, scale + zero-point and why activation outliers are the central problem all live. Here we are not discussing &lt;em>how many bits&lt;/em> to use, but &lt;strong>when and with what information the scales are computed&lt;/strong>: offline against a corpus (PTQ) or on the fly against real traffic (TTQ).&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Activation-aware quantisation (AWQ, SmoothQuant) decides which channels to protect by measuring the magnitude of the activations over a &lt;strong>calibration dataset&lt;/strong> in an &lt;strong>offline pass&lt;/strong>, before deployment. The implicit assumption is that this corpus represents future traffic. But activation outliers, the channels with 10-100× the median magnitude that dominate the quantisation error, depend on the input: they change with the domain, the language and the client&amp;rsquo;s distribution. When real traffic drifts away from the calibration, the fixed scales stop being optimal and quality drops. &lt;strong>Test-time quantization (TTQ)&lt;/strong> removes the corpus and the offline pass: it derives the activation-aware scales &lt;strong>at inference time&lt;/strong>, from the activations that are actually observed, per token or per batch. The trade-off is honest and not minor: it introduces &lt;strong>runtime overhead&lt;/strong>, computing statistics, detecting outliers, recomputing scales at every step, which competes directly with the saving from quantising. On small models that overhead weighs proportionally more, because the forward is short and the fixed per-step costs dominate (the framing is in &lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">the inverted roofline for SLMs&lt;/a>). TTQ is &lt;strong>orthogonal&lt;/strong> to the format: it is not a competitor to INT4 or FP8, it is a different way of deriving &lt;em>s&lt;/em>. It pays off when there is no calibration pipeline, when the traffic distribution is shifting or unknown, and in multitenant settings where no representative corpus exists.&lt;/p>
&lt;h2 id="you-are-here-deploy">You are here: DEPLOY&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 90" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="you are here: Deploy">
&lt;defs>&lt;marker id="ttqm" 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="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="20" text-anchor="middle" fill="currentColor" font-size="12" font-weight="600">You are here: DEPLOY · deriving quantization scales on the fly&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" rx="6" fill="#f4f4f4" stroke="#444" stroke-width="1.4"/>&lt;text x="85" y="58" text-anchor="middle" fill="currentColor" font-size="12" font-weight="600">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" rx="6" fill="#f4f4f4" stroke="#444" stroke-width="1.4"/>&lt;text x="210" y="58" text-anchor="middle" fill="currentColor" font-size="12" font-weight="600">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" rx="6" fill="#f4f4f4" stroke="#444" stroke-width="1.4"/>&lt;text x="335" y="58" text-anchor="middle" fill="currentColor" font-size="12" font-weight="600">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" rx="6" fill="#7ad88f" stroke="#444" stroke-width="3"/>&lt;text x="460" y="58" text-anchor="middle" fill="#111" font-size="12" font-weight="600">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" rx="6" fill="#f4f4f4" stroke="#444" stroke-width="1.4"/>&lt;text x="585" y="58" text-anchor="middle" fill="currentColor" font-size="12" font-weight="600">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" rx="6" fill="#f4f4f4" stroke="#444" stroke-width="1.4"/>&lt;text x="710" y="58" text-anchor="middle" fill="currentColor" font-size="12" font-weight="600">6 · Retrain&lt;/text>
&lt;path d="M140,52 L155,52" stroke="#666" stroke-width="1.4" fill="none" marker-end="url(#ttqm)"/>
&lt;path d="M265,52 L280,52" stroke="#666" stroke-width="1.4" fill="none" marker-end="url(#ttqm)"/>
&lt;path d="M390,52 L405,52" stroke="#666" stroke-width="1.4" fill="none" marker-end="url(#ttqm)"/>
&lt;path d="M515,52 L530,52" stroke="#666" stroke-width="1.4" fill="none" marker-end="url(#ttqm)"/>
&lt;path d="M640,52 L655,52" stroke="#666" stroke-width="1.4" fill="none" marker-end="url(#ttqm)"/>
&lt;path d="M710,72 L710,82 L85,82 L85,72" stroke="#888" stroke-width="1.2" fill="none" stroke-dasharray="4 2" marker-end="url(#ttqm)"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-the-tailor-who-takes-measurements-versus-off-the-peg-sizes">The analogy: the tailor who takes measurements versus off-the-peg sizes&lt;/h2>
&lt;p>A clothes shop has two ways of dressing a customer.&lt;/p>
&lt;p>The first is to &lt;strong>sell off-the-peg sizes&lt;/strong>. At some point the factory measured an &amp;ldquo;average customer&amp;rdquo;, a mean mannequin built from a population sample, and cut the garments to those measurements. When a customer walks in, you hand them the size that comes closest. It is extremely fast: the garment is already sewn, it just gets handed over. The problem appears when the customer does not look like the average mannequin: if their shoulders are much broader than average, their particular outlier, the standard size pulls or leaves spare fabric, because it was cut to protect &lt;em>other&lt;/em> areas. This is &lt;strong>offline calibrated PTQ&lt;/strong>: AWQ measured the importance of each channel over a corpus and fixed the scales once and for all; fast at inference time, but blind to the specific customer.&lt;/p>
&lt;p>The second is &lt;strong>the tailor who takes measurements on the spot&lt;/strong>. When the customer walks in, the tailor gets out the tape, measures &lt;em>that customer&lt;/em>, spots where their particular bulk is and adjusts the cut to their real anatomy. The result fits better, especially for customers who fall outside the mould. But every customer costs time: measuring, marking, deciding. This is &lt;strong>TTQ&lt;/strong>: the scales are derived on the fly from the activations that the input actually generates.&lt;/p>
&lt;p>The analogy holds up in three details:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>The average mannequin = the calibration dataset.&lt;/strong> If the population walking into the shop resembles the mannequin, the sizes work; if not, they fail at the extremes.&lt;/li>
&lt;li>&lt;strong>Measuring every customer = computing activation statistics per token/batch.&lt;/strong> A better fit, but a fixed cost paid on &lt;em>every&lt;/em> garment.&lt;/li>
&lt;li>&lt;strong>The broad shoulders = the outlier activation channels.&lt;/strong> They are precisely the areas where the fit matters and where the generic size gets it most wrong.&lt;/li>
&lt;/ul>
&lt;p>The tailor wins when the customers are varied or unknown. The tailor loses when you have a homogeneous population and a mannequin that represents it well: there, paying for measurement on every customer is a waste of time.&lt;/p>
&lt;h2 id="the-problem-ttq-solves-fixed-calibration-ages-with-the-traffic">The problem TTQ solves: fixed calibration ages with the traffic&lt;/h2>
&lt;p>Let us recall from the &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">quantization post&lt;/a> what AWQ and SmoothQuant do exactly. They do not quantise all channels equally: they identify the ~1 % of channels whose activations have a large magnitude, the &lt;em>salient channels&lt;/em>, and protect them by scaling them before quantising. To measure that importance they need to see activations, and they see them over a &lt;strong>calibration dataset&lt;/strong> (128-512 samples, typically WikiText or a slice of the domain) in an &lt;strong>offline pass&lt;/strong> prior to deployment.&lt;/p>
&lt;p>The assumption is strong: that the activation distribution of the calibration corpus &lt;strong>represents that of production traffic&lt;/strong>. Two reasons why that assumption breaks:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Activation outliers depend on the input.&lt;/strong> They are not a fixed property of the model the way weights are. The channel that is an outlier when processing C++ code may not be one when processing conversational Arabic or log JSON. The magnitude and position of the peaks change with the domain, the language and the input format.&lt;/li>
&lt;li>&lt;strong>Real traffic is rarely the corpus.&lt;/strong> You calibrate with English WikiText and the client sends you support tickets in Spanish with tables pasted in. The calibration protected the channels that &lt;em>WikiText&lt;/em> activated, not the ones that real traffic activates. The scales are suboptimal exactly where the client lives.&lt;/li>
&lt;/ol>
&lt;p>The result is distribution-dependent degradation: the quantised model keeps its quality while the input resembles the calibration and loses it as the input drifts away. The most awkward case is &lt;strong>multitenant&lt;/strong>: if you serve clients with different domains from the same quantised model, no single representative corpus exists; any fixed calibration favours some tenants and penalises others.&lt;/p>
&lt;h2 id="the-ttq-mechanism-measure-the-real-activations-and-scale-on-the-fly">The TTQ mechanism: measure the real activations and scale on the fly&lt;/h2>
&lt;p>TTQ (arXiv:2603.19296, March 2026) proposes deriving &lt;strong>activation-aware quantisation at inference time&lt;/strong>, with no offline pass and no calibration dataset. The idea, in its bare conceptual form:&lt;/p>
&lt;p>&lt;strong>Step 1 — Observe.&lt;/strong> When the activation tensor &lt;code>X&lt;/code> arrives at a linear layer (per token or per batch), cheap statistics are computed over the channels: a measure of central tendency (median or mean magnitude) and one of dispersion per channel. This is the equivalent of AWQ looking at its corpus, but done over the activations that &lt;em>really&lt;/em> are coming in right now.&lt;/p>
&lt;p>&lt;strong>Step 2 — Detect outliers on the fly.&lt;/strong> With those statistics, the channels whose magnitude shoots up relative to the tensor median are identified; the typical criterion is a threshold of the form &amp;ldquo;magnitude &amp;gt; k × median&amp;rdquo;. These are the channels that, if quantised with the same scale as the rest, blow up the error.&lt;/p>
&lt;p>&lt;strong>Step 3 — Derive scales and segregate.&lt;/strong> For normal channels a scale is computed that exploits the range; for the outliers a different treatment is applied, a scale of their own, or keeping them at higher precision, in the style of &lt;em>on-the-fly mixed precision&lt;/em>. It is the same philosophy as LLM.int8() (segregating outliers to FP16) or AWQ (scaling salient channels), but with the threshold and the scales &lt;strong>recomputed over the current input&lt;/strong>, not frozen since calibration.&lt;/p>
&lt;p>&lt;strong>Step 4 — Quantise and multiply.&lt;/strong> With the fresh scales, the tensor is quantised and the GEMM runs. The activations entering the next layer compensate for the rescaling, just as in AWQ, so that the maths cancels out.&lt;/p>
&lt;p>The key difference with AWQ is not in &lt;em>what&lt;/em> is done (protecting activation outliers) but in &lt;em>when&lt;/em> and &lt;em>against what&lt;/em>: AWQ decides once, offline, against a corpus; TTQ decides at every step, on the fly, against real traffic. It is the transfer to inference of the &amp;ldquo;test-time&amp;rdquo; idea: adapting the computation to the specific sample in front of you instead of to a precomputed average.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Offline calibrated PTQ versus on-the-fly TTQ">
&lt;defs>&lt;marker id="ttq2" 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="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="195" y="22" text-anchor="middle" fill="currentColor" font-size="13" font-weight="700">Offline-calibrated PTQ (AWQ / GPTQ)&lt;/text>
&lt;text x="585" y="22" text-anchor="middle" fill="currentColor" font-size="13" font-weight="700">On-the-fly TTQ&lt;/text>
&lt;line x1="390" y1="35" x2="390" y2="285" stroke="#bbb" stroke-width="1" stroke-dasharray="4 3"/>
&lt;rect x="30" y="45" width="150" height="38" rx="6" fill="#ffe6d6" stroke="#a05a2c" stroke-width="1.4"/>&lt;text x="105" y="68" text-anchor="middle" fill="#111" font-size="11" font-weight="600">calibration dataset&lt;/text>
&lt;rect x="210" y="45" width="150" height="38" rx="6" fill="#ffe6d6" stroke="#a05a2c" stroke-width="1.4"/>&lt;text x="285" y="64" text-anchor="middle" fill="#111" font-size="11" font-weight="600">OFFLINE pass&lt;/text>&lt;text x="285" y="78" text-anchor="middle" fill="#444" font-size="10">fixes scales s, outliers&lt;/text>
&lt;path d="M180,64 L210,64" stroke="#666" stroke-width="1.4" fill="none" marker-end="url(#ttq2)"/>
&lt;rect x="120" y="105" width="150" height="34" rx="6" fill="#f0f0f0" stroke="#444" stroke-width="1.4"/>&lt;text x="195" y="126" text-anchor="middle" fill="#111" font-size="11" font-weight="600">FROZEN scales&lt;/text>
&lt;path d="M285,83 L285,98 L195,98 L195,105" stroke="#666" stroke-width="1.4" fill="none" marker-end="url(#ttq2)"/>
&lt;rect x="30" y="165" width="150" height="34" rx="6" fill="#d6eaff" stroke="#1f5fa8" stroke-width="1.4"/>&lt;text x="105" y="186" text-anchor="middle" fill="#111" font-size="11" font-weight="600">similar input → OK&lt;/text>
&lt;rect x="210" y="165" width="150" height="34" rx="6" fill="#f6caca" stroke="#a52a2a" stroke-width="1.4"/>&lt;text x="285" y="182" text-anchor="middle" fill="#111" font-size="11" font-weight="600">distant input →&lt;/text>&lt;text x="285" y="195" text-anchor="middle" fill="#a52a2a" font-size="10" font-weight="600">degradation&lt;/text>
&lt;path d="M150,139 L105,165" stroke="#666" stroke-width="1.4" fill="none" marker-end="url(#ttq2)"/>
&lt;path d="M240,139 L285,165" stroke="#666" stroke-width="1.4" fill="none" marker-end="url(#ttq2)"/>
&lt;text x="195" y="225" text-anchor="middle" fill="#444" font-size="10">inference overhead ≈ 0 · quality depends on calibration&lt;/text>
&lt;rect x="510" y="45" width="150" height="38" rx="6" fill="#d9f5d6" stroke="#2a7a40" stroke-width="1.4"/>&lt;text x="585" y="62" text-anchor="middle" fill="#111" font-size="11" font-weight="600">REAL activations&lt;/text>&lt;text x="585" y="77" text-anchor="middle" fill="#444" font-size="10">from current traffic&lt;/text>
&lt;rect x="510" y="100" width="150" height="34" rx="6" fill="#d9f5d6" stroke="#2a7a40" stroke-width="1.4"/>&lt;text x="585" y="115" text-anchor="middle" fill="#111" font-size="11" font-weight="600">measure + detect&lt;/text>&lt;text x="585" y="128" text-anchor="middle" fill="#444" font-size="10">outliers ON THE FLY&lt;/text>
&lt;path d="M585,83 L585,100" stroke="#666" stroke-width="1.4" fill="none" marker-end="url(#ttq2)"/>
&lt;rect x="510" y="151" width="150" height="34" rx="6" fill="#d9f5d6" stroke="#2a7a40" stroke-width="1.4"/>&lt;text x="585" y="166" text-anchor="middle" fill="#111" font-size="11" font-weight="600">FRESH scales&lt;/text>&lt;text x="585" y="179" text-anchor="middle" fill="#444" font-size="10">per token / batch&lt;/text>
&lt;path d="M585,134 L585,151" stroke="#666" stroke-width="1.4" fill="none" marker-end="url(#ttq2)"/>
&lt;rect x="510" y="202" width="150" height="34" rx="6" fill="#fff5b0" stroke="#9a8400" stroke-width="1.4"/>&lt;text x="585" y="217" text-anchor="middle" fill="#111" font-size="11" font-weight="600">quantize + GEMM&lt;/text>&lt;text x="585" y="230" text-anchor="middle" fill="#9a6b00" font-size="10" font-weight="600">+ per-step overhead&lt;/text>
&lt;path d="M585,185 L585,202" stroke="#666" stroke-width="1.4" fill="none" marker-end="url(#ttq2)"/>
&lt;text x="585" y="258" text-anchor="middle" fill="#444" font-size="10">no corpus · quality robust to distribution · overhead ≠ 0&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-maths-that-matter">The maths that matter&lt;/h2>
&lt;h3 id="the-error-of-quantising-an-outlier-with-the-wrong-scale">The error of quantising an outlier with the wrong scale&lt;/h3>
&lt;p>Let us recall affine uniform quantisation from the &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">base post&lt;/a>: an integer code &lt;code>q = round(x/s) - z&lt;/code> with scale &lt;code>s&lt;/code> and zero-point &lt;code>z&lt;/code>, and reconstruction &lt;code>x̂ = s·(q + z)&lt;/code>. For a &lt;code>b&lt;/code>-bit quantiser with a symmetric range, the scale that covers a tensor of maximum magnitude &lt;code>M&lt;/code> is approximately &lt;code>s = M / (2^{b-1} - 1)&lt;/code>. The rounding error of each element is bounded by half a scale: &lt;code>|x - x̂| ≤ s/2&lt;/code>.&lt;/p>
&lt;p>Here is the outlier problem. The scale &lt;code>s&lt;/code> is chosen to cover the &lt;strong>largest&lt;/strong> value in the group. If one channel has 30× the median magnitude and you share a single scale with the rest of the tensor, that magnitude rules: &lt;code>M&lt;/code> is the outlier, so &lt;code>s&lt;/code> is inflated 30× relative to what the majority would need. The absolute rounding error of the normal values rises proportionally.&lt;/p>
&lt;p>A concrete calculation. Take a group where the median magnitude is 1.0 and an outlier channel is 30.0, quantised to INT4 (&lt;code>b = 4&lt;/code>, levels ±7):&lt;/p>
&lt;ul>
&lt;li>&lt;strong>With a shared scale&lt;/strong>, &lt;code>s = 30 / 7 ≈ 4.29&lt;/code>. The rounding error of a typical value (magnitude ~1) is up to &lt;code>s/2 ≈ 2.14&lt;/code>. In other words, &lt;strong>the error on the normal values is of the order of their own value&lt;/strong>: the outlier has destroyed the resolution of everything else. Relative error of a value of magnitude 1: up to ~214 %.&lt;/li>
&lt;li>&lt;strong>Segregating the outlier&lt;/strong> (pulling it out to FP16 or giving it its own scale) and quantising the rest with &lt;code>M = 1&lt;/code>, &lt;code>s = 1/7 ≈ 0.143&lt;/code>. The error of a typical value drops to &lt;code>s/2 ≈ 0.071&lt;/code>, ~7 % relative. &lt;strong>Thirty times less error&lt;/strong> on the majority of the group&amp;rsquo;s weights.&lt;/li>
&lt;/ul>
&lt;p>That is the entire reason for the existence of activation-aware quantisation: &lt;strong>to detect and treat separately the ~1 % of channels that, if not segregated, hijack the scale&lt;/strong>. AWQ does it against the corpus; TTQ does it against the real input. And if the channel that is an outlier &lt;em>in production&lt;/em> was not an outlier &lt;em>during calibration&lt;/em>, AWQ did not protect it: it quantised real traffic with the inflated scale from the case above. That is where TTQ gains precision.&lt;/p>
&lt;h3 id="the-overhead-the-cost-of-measuring-at-every-step">The overhead: the cost of measuring at every step&lt;/h3>
&lt;p>The price is symmetric. Computing the per-token statistics, per-channel magnitudes, median or percentile, outlier threshold, scales, means reductions over the activation tensor that did not exist in the forward pass with frozen scales. Let us call:&lt;/p>
&lt;ul>
&lt;li>&lt;code>T&lt;/code> = forward time per token with fixed scales (static PTQ), in µs.&lt;/li>
&lt;li>&lt;code>Δ&lt;/code> = extra per-token cost of deriving the statistics and scales on the fly, in µs.&lt;/li>
&lt;/ul>
&lt;p>The relative overhead is simply:&lt;/p>
$$\text{overhead} = \frac{\Delta}{T}$$
&lt;p>The key point is that &lt;code>Δ&lt;/code> is relatively fixed per step (it depends on the number of channels and layers, not on how much &amp;ldquo;useful&amp;rdquo; work the model does), while &lt;code>T&lt;/code> scales with the size of the model. That is why the ratio behaves very differently depending on the model:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Large model&lt;/strong> (say, 70B): &lt;code>T&lt;/code> is large, every forward moves tens of GB of weights from HBM. If &lt;code>Δ ≈ 8 µs&lt;/code> and &lt;code>T ≈ 800 µs&lt;/code>, the overhead is &lt;code>8/800 = 1 %&lt;/code>. Negligible against the saving from quantising.&lt;/li>
&lt;li>&lt;strong>SLM&lt;/strong> (say, 1B): &lt;code>T&lt;/code> is small, the forward per token is short. With the same &lt;code>Δ ≈ 8 µs&lt;/code> and &lt;code>T ≈ 60 µs&lt;/code>, the overhead is &lt;code>8/60 ≈ 13 %&lt;/code>. No longer negligible: it eats a good part of what you gained by quantising.&lt;/li>
&lt;/ul>
&lt;p>This connects directly to the &lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">inverted roofline for small models&lt;/a>: on an SLM the &lt;strong>fixed per-step costs&lt;/strong> (kernel launches, synchronisations, overheads that do not scale with the model) weigh proportionally more, because there is less useful work to spread them over. TTQ&amp;rsquo;s &lt;code>Δ&lt;/code> is exactly one of those fixed costs. Going per-batch instead of per-token amortises &lt;code>Δ&lt;/code> across all the tokens of the batch and lowers the relative overhead, at the cost of coarser scales; it is the first parameter to touch.&lt;/p>
&lt;p>The uncomfortable conclusion: TTQ gives away robustness to distribution but spends part of the acceleration budget on measuring, and in the regime where acceleration is scarcest, the SLMs, the ones most often deployed at the edge, is where that spending hurts most. It is not free; it is a change of currency.&lt;/p>
&lt;blockquote>
&lt;p>A note of methodological scepticism: arXiv:2603.19296 is from &lt;strong>March 2026&lt;/strong>, very recent, and as of the date of this post there are no broad independent reproductions. Whatever speedup and quality figures circulate should be taken with the same caution as any number without published methodology: what hardware, what batch size, what real measured &lt;code>Δ&lt;/code>, against what baseline (well-calibrated or badly calibrated PTQ), in what domain? The &lt;em>conceptual&lt;/em> argument, robustness to distribution in exchange for per-step overhead, is solid; the concrete multipliers are pending validation.&lt;/p>
&lt;/blockquote>
&lt;h2 id="what-ttq-is-not-marking-it-off-from-the-rest-of-the-zoo">What TTQ is NOT: marking it off from the rest of the zoo&lt;/h2>
&lt;p>TTQ is easily confused with neighbouring techniques. The distinction that matters is that &lt;strong>TTQ is the &lt;em>how&lt;/em> of deriving the scales, not the format nor the moment in training&lt;/strong>.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Technique&lt;/th>
&lt;th>When the scales are fixed&lt;/th>
&lt;th>Needs a calibration corpus&lt;/th>
&lt;th>Touches training&lt;/th>
&lt;th>Is it a format&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Static PTQ&lt;/strong> (GPTQ, AWQ)&lt;/td>
&lt;td>Offline, before deployment&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No (it uses INT4/INT8)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>QAT&lt;/strong>&lt;/td>
&lt;td>During training&lt;/td>
&lt;td>No (training data)&lt;/td>
&lt;td>Yes (retrains)&lt;/td>
&lt;td>No&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>FP8 end to end&lt;/strong>&lt;/td>
&lt;td>At runtime, but simple per-tensor scales&lt;/td>
&lt;td>Minimal / none&lt;/td>
&lt;td>No&lt;/td>
&lt;td>&lt;strong>Yes&lt;/strong> (E4M3/E5M2)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>TTQ&lt;/strong>&lt;/td>
&lt;td>At runtime, activation-aware per token/batch&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>No&lt;/td>
&lt;td>No (orthogonal to the format)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The four distinctions, one by one:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Against static PTQ (GPTQ/AWQ).&lt;/strong> Same goal (protecting outliers), the same possible format (INT4), but PTQ freezes the decisions offline against a corpus and TTQ recomputes them on the fly. TTQ is, in a sense, &amp;ldquo;AWQ without the calibration phase, paid for at runtime&amp;rdquo;.&lt;/li>
&lt;li>&lt;strong>Against QAT.&lt;/strong> QAT puts quantisation inside the training loop so that the model learns to be robust to it; it costs a retrain. TTQ does not touch training: it operates on an already trained model, at inference. They are attacks at opposite moments of the pipeline.&lt;/li>
&lt;li>&lt;strong>Against FP8 end to end.&lt;/strong> FP8 is a &lt;strong>format&lt;/strong> with its own logarithmic range; its &amp;ldquo;dynamic scaling&amp;rdquo; computes a simple per-tensor scalar at runtime, but it does not do activation-aware per-channel outlier detection. TTQ could, conceptually, derive scales on the fly &lt;em>for&lt;/em> an FP8 or INT4 quantiser: it is orthogonal to the format.&lt;/li>
&lt;li>&lt;strong>TTQ is orthogonal to the format.&lt;/strong> It decides &lt;em>how&lt;/em> to obtain &lt;code>s&lt;/code>, not how many bits you store &lt;code>q&lt;/code> in. You can imagine &amp;ldquo;TTQ over INT4&amp;rdquo; or &amp;ldquo;TTQ over FP8&amp;rdquo;. What defines TTQ is the source of the scale, real activations on the fly, not the width of the code.&lt;/li>
&lt;/ul>
&lt;h2 id="when-it-pays-off-and-when-it-does-not">When it pays off (and when it does not)&lt;/h2>
&lt;p>TTQ is not a universal replacement for AWQ. It is a tool for a specific deployment profile. &lt;strong>It pays off when:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>You have no calibration pipeline.&lt;/strong> You want to deploy a quantised model &lt;em>now&lt;/em>, without assembling the calibration dataset, running the offline pass or validating that the corpus represents the traffic. TTQ cuts out that whole phase: you load the model and serve.&lt;/li>
&lt;li>&lt;strong>The traffic distribution is shifting or unknown.&lt;/strong> An assistant that one day receives code and the next legal contracts in another language. No fixed calibration covers both well; on-the-fly adaptation follows the distribution without recalibrating.&lt;/li>
&lt;li>&lt;strong>Multitenant with no representative corpus.&lt;/strong> You serve the same model to clients with disparate domains. No single corpus represents all of them; any fixed calibration creates winners and losers among tenants. TTQ adjusts to each input, whichever tenant it comes from.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>It does not pay off when:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>You have a stable domain and a good calibration corpus.&lt;/strong> If your traffic is homogeneous and representative, offline AWQ gives you the same quality with zero runtime overhead. Paying &lt;code>Δ&lt;/code> on every token to relearn what a corpus already captured is waste.&lt;/li>
&lt;li>&lt;strong>You serve SLMs with a tight latency SLA.&lt;/strong> This is exactly the case where &lt;code>Δ/T&lt;/code> is high. If the model is small and TPOT matters, the overhead of measuring can wipe out the gain from quantising. Measure your real &lt;code>Δ&lt;/code> before assuming it adds up.&lt;/li>
&lt;li>&lt;strong>The batch is large and compute-bound.&lt;/strong> With high concurrency the forward is no longer memory-bound and the cost of the extra reductions competes worse; at the very least, amortise &lt;code>Δ&lt;/code> per batch.&lt;/li>
&lt;/ul>
&lt;h2 id="implications-for-on-premise-hardware">Implications for on-premise hardware&lt;/h2>
&lt;h3 id="on-an-rtx-4090-24-gb-ada-lovelace">On an RTX 4090 (24 GB, Ada Lovelace)&lt;/h3>
&lt;p>The natural case for the 4090 is the SLM, Qwen 3 1.5B, Llama 3 8B AWQ-INT4, serving at low concurrency. That is precisely the regime where TTQ is riskiest: &lt;code>T&lt;/code> per token is small and the 4090 has no accelerated native FP8 (we discussed this in the &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">quantization post&lt;/a>), so TTQ&amp;rsquo;s extra reductions run on CUDA cores competing for the same time. Here the question is not &amp;ldquo;does it improve quality?&amp;rdquo; but &amp;ldquo;does the overhead leave me an acceptable TPOT?&amp;rdquo;. If the traffic is homogeneous, offline AWQ wins on simplicity and latency. TTQ only justifies its &lt;code>Δ&lt;/code> if the input distribution is genuinely unpredictable and the degradation from fixed calibration is measurable.&lt;/p>
&lt;h3 id="on-a-generic-4h100-sxm-cluster-320-gb-nvlink-native-fp8">On a generic 4×H100 SXM cluster (320 GB, NVLink, native FP8)&lt;/h3>
&lt;p>Here the calculation partly flips. With large models &lt;code>T&lt;/code> is high and &lt;code>Δ/T&lt;/code> drops to the low single-digit percentage range, so TTQ&amp;rsquo;s overhead is more digestible. The strong use case is &lt;strong>multitenant&lt;/strong>: a cluster serving a large model to clients with heterogeneous domains, where no calibration corpus satisfies everyone. There, TTQ&amp;rsquo;s robustness to distribution has real value and the overhead is diluted in a big forward. Even so, on an H100 with native FP8, the baseline to beat is demanding: static FP8 barely loses quality (see the table in the quantization post) and costs nothing at runtime. TTQ has to show that its robustness gain on the outlier tenants outweighs what it gives away in overhead. With a paper from March 2026 and no reproductions, that demonstration is pending.&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>The memory cost of the on-the-fly statistics&lt;/strong>: per-channel buffers, their impact on the footprint and on cache pressure.&lt;/li>
&lt;li>&lt;strong>Interaction with continuous batching&lt;/strong>: how scales are derived when a batch mixes requests from different domains in the same step.&lt;/li>
&lt;li>&lt;strong>TTQ + speculative decoding&lt;/strong>: whether the draft and the target derive scales on the fly separately, and how that affects the acceptance rate.&lt;/li>
&lt;li>&lt;strong>Numerical stability&lt;/strong>: what happens when a batch contains a one-off extreme outlier that inflates the scale for every token of that step.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantization for LLM inference&lt;/a> — the indispensable base: scale + zero-point, GPTQ, AWQ and why activation outliers are the problem; TTQ is AWQ with the scales derived on the fly instead of offline.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">Inverted roofline for small models&lt;/a> — why fixed per-step costs weigh more on an SLM; it explains directly why TTQ&amp;rsquo;s &lt;code>Δ&lt;/code> overhead hurts more on small models.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/aggressive-quantisation-4-bit-to-ternary/">Aggressive sub-4-bit and ternary quantisation&lt;/a> — the static frontier below 4 bits; it complements TTQ, which attacks the &lt;em>how&lt;/em> of the scale instead of the &lt;em>how many bits&lt;/em>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/qlora-multi-lora-limit-small-models/">QLoRA and aggressive multi-LoRA on SLMs&lt;/a> — adapters on a quantised base; the base could derive scales on the fly while the adapters stay in BF16.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/fp8-end-to-end-enable-measure-quality-decide/">FP8 end to end: weights, KV and quality&lt;/a> — the Hopper/Blackwell datacenter format; TTQ is orthogonal and could derive scales for an FP8 quantiser.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory of LLM inference&lt;/a> — the KV cache is quantised too; its scales are another candidate to be derived on the fly by the same logic.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/knowledge-distillation-teaching-small-model-think-like-big-one/">Knowledge distillation&lt;/a> — the other route to serving robust small models; distilling shrinks the model, TTQ tunes its quantisation to the traffic.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">Optimising decode in vLLM&lt;/a> — where the runtime quantisation levers for squeezing a 4090 turn into concrete parameters.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;em>TTQ: Activation-Aware Test-Time Quantization to Accelerate LLM Inference On The Fly&lt;/em> (March 2026). &lt;a href="https://arxiv.org/abs/2603.19296">https://arxiv.org/abs/2603.19296&lt;/a>&lt;/li>
&lt;li>Lin, J., Tang, J., Tang, H., Yang, S., Dang, X., Han, S. &lt;em>AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration&lt;/em> (MLSys 2024). &lt;a href="https://arxiv.org/abs/2306.00978">https://arxiv.org/abs/2306.00978&lt;/a>&lt;/li>
&lt;li>Frantar, E., Ashkboos, S., Hoefler, T., Alistarh, D. &lt;em>GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers&lt;/em> (ICLR 2023). &lt;a href="https://arxiv.org/abs/2210.17323">https://arxiv.org/abs/2210.17323&lt;/a>&lt;/li>
&lt;li>Xiao, G., Lin, J., Seznec, M., Wu, H., Demouth, J., Han, S. &lt;em>SmoothQuant: Accurate and Efficient Post-Training Quantization for Large Language Models&lt;/em> (ICML 2023). &lt;a href="https://arxiv.org/abs/2211.10438">https://arxiv.org/abs/2211.10438&lt;/a>&lt;/li>
&lt;li>Dettmers, T., Lewis, M., Belkada, Y., Zettlemoyer, L. &lt;em>LLM.int8(): 8-bit Matrix Multiplication for Transformers at Scale&lt;/em> (NeurIPS 2022). &lt;a href="https://arxiv.org/abs/2208.07339">https://arxiv.org/abs/2208.07339&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Device-native architectures: fine-grained MoE and pre-attention router</title><link>https://blog.lo0.es/en/posts/device-native-architectures-fine-grained-moe-pre-attention-router/</link><pubDate>Tue, 09 Jun 2026 01:50:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/device-native-architectures-fine-grained-moe-pre-attention-router/</guid><description>&lt;blockquote>
&lt;p>This post belongs to the series on inference performance in small models. It is the architectural face of a problem we have already looked at from the compute-regime side (the SLM&amp;rsquo;s inverted roofline) and from the weight-loading side in &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM&lt;/a>. Here the question is different: what if, instead of adapting a large model to the device, we design the model &lt;em>for&lt;/em> the device from the first commit?&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The default move to get an LLM onto a laptop, a phone or an edge box is to &lt;strong>take a dense model built for cloud and compress it&lt;/strong>: distillation, pruning, quantisation. It is a gesture of &lt;em>reduction&lt;/em>: you start from something big and take things away. SmallThinker (arXiv:2507.20984, SJTU IPADS + Zenergize AI) argues for the reverse gesture, &lt;em>designing from scratch&lt;/em>, and articulates it in three pieces. &lt;strong>First: fine-grained MoE&lt;/strong>, many small experts with very few activated per token, so that total parameters &lt;code>N&lt;/code> (the capacity) decouple from activated parameters &lt;code>A&lt;/code> (the compute cost per token). &lt;strong>Second: sparse FFN&lt;/strong>, ReLU-style activation sparsity inside each block, which adds a second level of sparsity on top of the first. &lt;strong>Third: a pre-attention router&lt;/strong> that predicts which experts will be needed &lt;em>before&lt;/em> running the attention block and launches the prefetch of those weights from SSD/flash in parallel with attention compute, hiding the storage latency, which is the real bottleneck when the model does not fit entirely in RAM. The authors report SmallThinker-4B-A0.6B and SmallThinker-21B-A3B exceeding ~20 tok/s on consumer CPUs with Q4_0, using ~1 GB and ~8 GB of RAM. The numbers are interesting and the direction is right; the evaluation methodology and the quality cost of activating so little deserve scepticism, and that is what the last part is for.&lt;/p>
&lt;h2 id="the-analogy-the-librarian-who-anticipates-your-request">The analogy: the librarian who anticipates your request&lt;/h2>
&lt;p>Picture a huge library with a small reading room. You are sitting in the room at a single desk: only a few books fit on it at a time (that is RAM). The bulk of the collection is in the back room, on long shelves that are slow to walk (that is the SSD/flash). And there is a librarian.&lt;/p>
&lt;p>The naive method: you read, you reach a point where you need a specific book, you request it, and then the librarian gets up, walks to the back room, looks for it and comes back. Meanwhile, you wait with the page open, making no progress. Every time you need a new book, you pay the full trip to the back room. The reading room spends most of its time waiting, not reading.&lt;/p>
&lt;p>The SmallThinker method: the librarian is clever and gets ahead of you. While you are still reading the chapter&amp;rsquo;s &lt;strong>table of contents&lt;/strong>, working out what it is about and connecting ideas, which in the model is the &lt;strong>attention&lt;/strong> block, they have already glanced over your shoulder, &lt;strong>predicted&lt;/strong> which three or four books you are going to ask for and gone to the back room to fetch them. By the time you finish the contents and formulate the request, the books are already on your desk. You have not waited: the trip to the back room happened &lt;em>in parallel&lt;/em> with your reading of the contents.&lt;/p>
&lt;p>The analogy holds on four details:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>The small desk is RAM&lt;/strong>; the slow back room is the &lt;strong>SSD/flash&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>The books are the MoE&amp;rsquo;s experts&lt;/strong>: only a few are on the desk at any moment.&lt;/li>
&lt;li>&lt;strong>Reading the contents is the attention block&lt;/strong>; requesting and using the books is the FFN/expert block.&lt;/li>
&lt;li>&lt;strong>The librarian who predicts and gets ahead is the pre-attention router&lt;/strong>: the prediction is made first, and the trip to fetch (the prefetch) overlaps with reading the contents (attention).&lt;/li>
&lt;/ul>
&lt;p>The quantitative question running through the whole post is: does the librarian get back in time? The wait is only hidden if the trip to the back room takes less than your reading of the contents. That is the condition &lt;code>t_{\text{attention}} \ge t_{\text{prefetch}}&lt;/code>, and we will do it with numbers.&lt;/p>
&lt;h2 id="compressing-a-dense-model-vs-designing-for-device">Compressing a dense model vs. designing for device&lt;/h2>
&lt;p>It is worth laying out the two approaches plainly, because they are not degrees of the same thing: they are different philosophies.&lt;/p>
&lt;p>&lt;strong>Approach A — compress a dense model built for cloud.&lt;/strong> You start from, say, a dense 7B–14B model trained to run on an RTX 4090 (24 GB, Ada Lovelace) or on a generic 4×H100 SXM cluster (320 GB, NVLink, native FP8). To fit it into a device you apply three levers, each with its own post: &lt;a href="https://blog.lo0.es/en/posts/knowledge-distillation-teaching-small-model-think-like-big-one/">distillation&lt;/a> (you train a small student that imitates the teacher), &lt;a href="https://blog.lo0.es/en/posts/llm-pruning-cutting-without-amputating/">pruning&lt;/a> (you remove weights or whole structures) and aggressive quantisation (you go down to 4 bits or fewer). The resulting model &lt;strong>is still dense&lt;/strong>: all its parameters are activated on every token. You have reduced the number of parameters, but the compute pattern is the cloud&amp;rsquo;s, only smaller.&lt;/p>
&lt;p>&lt;strong>Approach B — design for device from scratch.&lt;/strong> Here the device&amp;rsquo;s constraints enter the &lt;em>architecture&lt;/em>, not a later compression phase. The constraints are three and very concrete:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Weak compute.&lt;/strong> A laptop CPU or a mobile SoC does orders of magnitude fewer FLOPs than a datacenter GPU. This pushes you to minimise the &lt;strong>activated&lt;/strong> parameters per token, not the total ones.&lt;/li>
&lt;li>&lt;strong>Little RAM.&lt;/strong> Tens of GB do not fit. This pushes you to keep resident only what is essential and to &lt;em>stream&lt;/em> the rest.&lt;/li>
&lt;li>&lt;strong>Slow storage.&lt;/strong> The SSD or flash you are forced to stream from has far less bandwidth than a GPU&amp;rsquo;s HBM. This turns storage I/O into the bottleneck, and pushes you to &lt;em>hide&lt;/em> it.&lt;/li>
&lt;/ol>
&lt;p>SmallThinker is approach B taken to the detail: each of those three constraints has an architectural answer. Weak compute is attacked with fine-grained MoE + sparse FFN (minimise &lt;code>A&lt;/code>). Scarce RAM is attacked with streaming from SSD (resident ≈ &lt;code>A&lt;/code> + cache, not &lt;code>N&lt;/code>). Slow storage is attacked with the pre-attention router (hide the I/O behind attention). It is no accident that the three pieces fit together: each one solves a constraint, and together they reinforce each other.&lt;/p>
&lt;p>An important nuance, so as not to fall into the hype: approach B &lt;strong>is neither free nor universally superior&lt;/strong>. It requires training a new model (you do not reuse existing weights), and the quality ceiling of a model with a very small &lt;code>A&lt;/code> is intrinsically bounded, as we will see. The argument is not &amp;ldquo;B always wins&amp;rdquo;, but &amp;ldquo;for the device regime, B attacks the right bottlenecks, and A only attacks them in passing&amp;rdquo;.&lt;/p>
&lt;h2 id="two-levels-of-sparsity">Two levels of sparsity&lt;/h2>
&lt;p>The central capacity idea is old and well understood in &lt;a href="https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/">MoE&lt;/a>: separate &lt;strong>capacity&lt;/strong> from &lt;strong>compute cost&lt;/strong>. In a MoE, the model has &lt;code>N&lt;/code> total parameters spread across experts, but for each token only &lt;code>A&lt;/code> parameters are activated (those of the top-k experts the router picks). Compute cost per token scales with &lt;code>A&lt;/code>; knowledge capacity scales with &lt;code>N&lt;/code>. SmallThinker applies this idea at &lt;strong>two superimposed levels&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>Level 1 — fine-grained MoE.&lt;/strong> &amp;ldquo;Fine-grained&amp;rdquo; means many small experts instead of few large ones, with very few activated per token. Instead of, say, 8 experts of which you activate 2, you have dozens of experts of which you activate a handful. With smaller experts, the same &lt;code>A&lt;/code> is spread across more possible combinations, which gives the router fine granularity and keeps &lt;code>A&lt;/code> very low relative to &lt;code>N&lt;/code>. The result is an aggressive &lt;code>N/A&lt;/code> ratio: a lot of capacity, very little compute per token.&lt;/p>
&lt;p>&lt;strong>Level 2 — sparse FFN (ReLU-style activation sparsity).&lt;/strong> This level is orthogonal and operates &lt;em>inside&lt;/em> each FFN. With a ReLU-style non-linearity, a large fraction of the intermediate layer&amp;rsquo;s neurons produce exactly zero for a given token. A neuron that comes out at zero contributes nothing to the output: its matrix-vector multiplication can be skipped. This is &lt;em>activation sparsity&lt;/em>: predictable token by token, and exploitable so as not to load or multiply the weight rows/columns corresponding to inactive neurons. It is the same phenomenon exploited by work such as Deja Vu or PowerInfer; SmallThinker builds it in from the factory by choosing activations that favour it.&lt;/p>
&lt;p>The combined effect, in one sentence: &lt;strong>large &lt;code>N&lt;/code> (capacity), tiny &lt;code>A&lt;/code> (compute cost per token ≈ proportional to &lt;code>A&lt;/code>)&lt;/strong>, and on top of that, inside that &lt;code>A&lt;/code>, a fraction of the multiplications is saved through activation sparsity. It is sparsity on top of sparsity.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Classic MoE vs fine-grained MoE with sparse FFN">
&lt;defs>&lt;marker id="ar1" 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="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="195" y="22" text-anchor="middle" font-size="13" font-weight="600" fill="currentColor">Classic MoE (coarse-grained)&lt;/text>
&lt;text x="585" y="22" text-anchor="middle" font-size="13" font-weight="600" fill="currentColor">Fine-grained MoE + sparse FFN&lt;/text>
&lt;line x1="390" y1="35" x2="390" y2="285" stroke="currentColor" stroke-width="1" stroke-dasharray="4 3"/>
&lt;!-- classic: 8 large experts, 2 active -->
&lt;text x="40" y="50" font-size="11" fill="currentColor">8 large experts · activates 2&lt;/text>
&lt;rect x="40" y="60" width="64" height="40" fill="#1f5fa8" stroke="#13335c" stroke-width="1.4"/>
&lt;rect x="114" y="60" width="64" height="40" fill="#cfd8e3" stroke="#7b8794" stroke-width="1.2"/>
&lt;rect x="188" y="60" width="64" height="40" fill="#cfd8e3" stroke="#7b8794" stroke-width="1.2"/>
&lt;rect x="262" y="60" width="64" height="40" fill="#1f5fa8" stroke="#13335c" stroke-width="1.4"/>
&lt;rect x="40" y="106" width="64" height="40" fill="#cfd8e3" stroke="#7b8794" stroke-width="1.2"/>
&lt;rect x="114" y="106" width="64" height="40" fill="#cfd8e3" stroke="#7b8794" stroke-width="1.2"/>
&lt;rect x="188" y="106" width="64" height="40" fill="#cfd8e3" stroke="#7b8794" stroke-width="1.2"/>
&lt;rect x="262" y="106" width="64" height="40" fill="#cfd8e3" stroke="#7b8794" stroke-width="1.2"/>
&lt;text x="183" y="172" text-anchor="middle" font-size="11" fill="currentColor">large A per expert · coarse granularity&lt;/text>
&lt;!-- fine: many small experts, several active but low total A -->
&lt;text x="410" y="50" font-size="11" fill="currentColor">many small experts · activates few&lt;/text>
&lt;g>
&lt;rect x="410" y="60" width="28" height="22" fill="#2a7a40" stroke="#1a4d29" stroke-width="1.2"/>
&lt;rect x="444" y="60" width="28" height="22" fill="#cfe3d5" stroke="#7b948a" stroke-width="1"/>
&lt;rect x="478" y="60" width="28" height="22" fill="#cfe3d5" stroke="#7b948a" stroke-width="1"/>
&lt;rect x="512" y="60" width="28" height="22" fill="#2a7a40" stroke="#1a4d29" stroke-width="1.2"/>
&lt;rect x="546" y="60" width="28" height="22" fill="#cfe3d5" stroke="#7b948a" stroke-width="1"/>
&lt;rect x="580" y="60" width="28" height="22" fill="#cfe3d5" stroke="#7b948a" stroke-width="1"/>
&lt;rect x="614" y="60" width="28" height="22" fill="#cfe3d5" stroke="#7b948a" stroke-width="1"/>
&lt;rect x="648" y="60" width="28" height="22" fill="#cfe3d5" stroke="#7b948a" stroke-width="1"/>
&lt;rect x="410" y="88" width="28" height="22" fill="#cfe3d5" stroke="#7b948a" stroke-width="1"/>
&lt;rect x="444" y="88" width="28" height="22" fill="#cfe3d5" stroke="#7b948a" stroke-width="1"/>
&lt;rect x="478" y="88" width="28" height="22" fill="#2a7a40" stroke="#1a4d29" stroke-width="1.2"/>
&lt;rect x="512" y="88" width="28" height="22" fill="#cfe3d5" stroke="#7b948a" stroke-width="1"/>
&lt;rect x="546" y="88" width="28" height="22" fill="#cfe3d5" stroke="#7b948a" stroke-width="1"/>
&lt;rect x="580" y="88" width="28" height="22" fill="#cfe3d5" stroke="#7b948a" stroke-width="1"/>
&lt;rect x="614" y="88" width="28" height="22" fill="#2a7a40" stroke="#1a4d29" stroke-width="1.2"/>
&lt;rect x="648" y="88" width="28" height="22" fill="#cfe3d5" stroke="#7b948a" stroke-width="1"/>
&lt;/g>
&lt;text x="543" y="128" text-anchor="middle" font-size="11" fill="currentColor">low total A · fine granularity&lt;/text>
&lt;!-- sparse FFN inside -->
&lt;text x="410" y="158" font-size="11" font-weight="600" fill="currentColor">+ sparse FFN inside each active expert:&lt;/text>
&lt;g>
&lt;rect x="410" y="168" width="14" height="40" fill="#a48000"/>
&lt;rect x="428" y="168" width="14" height="40" fill="#f1e7c2" stroke="#b8a96a" stroke-width="0.8"/>
&lt;rect x="446" y="168" width="14" height="40" fill="#a48000"/>
&lt;rect x="464" y="168" width="14" height="40" fill="#f1e7c2" stroke="#b8a96a" stroke-width="0.8"/>
&lt;rect x="482" y="168" width="14" height="40" fill="#f1e7c2" stroke="#b8a96a" stroke-width="0.8"/>
&lt;rect x="500" y="168" width="14" height="40" fill="#a48000"/>
&lt;rect x="518" y="168" width="14" height="40" fill="#f1e7c2" stroke="#b8a96a" stroke-width="0.8"/>
&lt;rect x="536" y="168" width="14" height="40" fill="#f1e7c2" stroke="#b8a96a" stroke-width="0.8"/>
&lt;rect x="554" y="168" width="14" height="40" fill="#a48000"/>
&lt;rect x="572" y="168" width="14" height="40" fill="#f1e7c2" stroke="#b8a96a" stroke-width="0.8"/>
&lt;/g>
&lt;text x="600" y="192" font-size="11" fill="currentColor">neurons at 0 (ReLU) →&lt;/text>
&lt;text x="600" y="206" font-size="11" fill="currentColor">skipped in the compute&lt;/text>
&lt;text x="40" y="245" font-size="11.5" font-weight="600" fill="currentColor">Capacity = N (all experts) · Cost/token ≈ A (activated) · and inside A, sparse FFN saves more&lt;/text>
&lt;text x="40" y="270" font-size="11" fill="currentColor">The trick: raise N without raising A. Fine granularity allows a far more aggressive N/A ratio.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-pre-attention-router-predict-and-prefetch">The pre-attention router: predict and prefetch&lt;/h2>
&lt;p>Here is the paper&amp;rsquo;s specific piece, and the one that gives the post its name. The problem it solves is one of &lt;em>I/O scheduling&lt;/em>, not of quality.&lt;/p>
&lt;p>When the model does not fit entirely in RAM, the expert weights live in SSD/flash and are loaded on demand. The naive flow of a MoE layer is sequential: you run attention, then the router decides which experts are needed, then you &lt;strong>load those experts from SSD&lt;/strong> (waiting), then you run the FFN of those experts. The loading step is pure waiting: the CPU is blocked waiting for bytes from the SSD. In the device regime, where the SSD is slow, that waiting time dominates the decode step.&lt;/p>
&lt;p>The &lt;strong>pre-attention router&lt;/strong> breaks the sequentiality by inverting the order of the decision. The observation is that the router does not need &lt;em>this&lt;/em> same layer&amp;rsquo;s attention output to make a reasonable prediction of which experts will be needed: it can predict it from the state it already has &lt;em>before&lt;/em> running attention. So:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Before&lt;/strong> running the layer&amp;rsquo;s attention block, the router predicts the experts that will be needed.&lt;/li>
&lt;li>It launches the &lt;strong>prefetch&lt;/strong> of those experts from SSD/flash asynchronously.&lt;/li>
&lt;li>&lt;strong>In parallel&lt;/strong>, the CPU runs the attention block, which is pure compute and does not need the SSD.&lt;/li>
&lt;li>When attention finishes, the prefetched experts are (ideally) already in RAM, and the FFN proceeds without waiting.&lt;/li>
&lt;/ol>
&lt;p>Storage I/O has been &lt;strong>overlapped&lt;/strong> with attention compute. It is exactly the librarian who goes to the back room while you read the contents.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 270" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Pre-attention router flow with overlapped prefetch">
&lt;defs>&lt;marker id="ar2" 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="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="20" y="22" font-size="12.5" font-weight="600" fill="currentColor">Naive (sequential): the load from SSD blocks&lt;/text>
&lt;rect x="20" y="32" width="110" height="34" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4"/>
&lt;text x="75" y="53" text-anchor="middle" font-size="11" fill="#13335c">attention&lt;/text>
&lt;rect x="138" y="32" width="80" height="34" fill="#e6d0ff" stroke="#5a2db0" stroke-width="1.4"/>
&lt;text x="178" y="53" text-anchor="middle" font-size="11" fill="#3a1d70">router&lt;/text>
&lt;rect x="226" y="32" width="180" height="34" fill="#f6caca" stroke="#a52a2a" stroke-width="1.4"/>
&lt;text x="316" y="49" text-anchor="middle" font-size="11" fill="#6e1d1d">load experts from SSD&lt;/text>
&lt;text x="316" y="61" text-anchor="middle" font-size="10" fill="#6e1d1d">(blocking wait)&lt;/text>
&lt;rect x="414" y="32" width="110" height="34" fill="#cdebd0" stroke="#2a7a40" stroke-width="1.4"/>
&lt;text x="469" y="53" text-anchor="middle" font-size="11" fill="#1a4d29">expert FFN&lt;/text>
&lt;path d="M130,49 L138,49" stroke="#666" stroke-width="1.4" fill="none" marker-end="url(#ar2)"/>
&lt;path d="M218,49 L226,49" stroke="#666" stroke-width="1.4" fill="none" marker-end="url(#ar2)"/>
&lt;path d="M406,49 L414,49" stroke="#666" stroke-width="1.4" fill="none" marker-end="url(#ar2)"/>
&lt;text x="540" y="53" font-size="11" fill="currentColor">t_total = t_att + t_load + t_ffn&lt;/text>
&lt;line x1="20" y1="92" x2="760" y2="92" stroke="currentColor" stroke-width="0.8" stroke-dasharray="3 3"/>
&lt;text x="20" y="118" font-size="12.5" font-weight="600" fill="currentColor">Pre-attention router: the prefetch overlaps with attention&lt;/text>
&lt;text x="20" y="140" font-size="11" font-weight="600" fill="currentColor">compute thread (CPU)&lt;/text>
&lt;rect x="170" y="130" width="80" height="30" fill="#e6d0ff" stroke="#5a2db0" stroke-width="1.4"/>
&lt;text x="210" y="149" text-anchor="middle" font-size="10.5" fill="#3a1d70">router (pre)&lt;/text>
&lt;rect x="258" y="130" width="150" height="30" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4"/>
&lt;text x="333" y="149" text-anchor="middle" font-size="11" fill="#13335c">attention (t_att)&lt;/text>
&lt;rect x="416" y="130" width="120" height="30" fill="#cdebd0" stroke="#2a7a40" stroke-width="1.4"/>
&lt;text x="476" y="149" text-anchor="middle" font-size="11" fill="#1a4d29">expert FFN&lt;/text>
&lt;text x="20" y="190" font-size="11" font-weight="600" fill="currentColor">I/O thread (SSD)&lt;/text>
&lt;rect x="258" y="180" width="130" height="30" fill="#ffe0a8" stroke="#a48000" stroke-width="1.4"/>
&lt;text x="323" y="199" text-anchor="middle" font-size="10.5" fill="#6b5400">prefetch experts (t_prefetch)&lt;/text>
&lt;path d="M250,145 C254,145 254,150 258,150" stroke="#5a2db0" stroke-width="1.2" fill="none"/>
&lt;path d="M250,150 L256,150 L256,195 L258,195" stroke="#a48000" stroke-width="1.2" fill="none" marker-end="url(#ar2)" stroke-dasharray="3 2"/>
&lt;line x1="258" y1="122" x2="258" y2="218" stroke="currentColor" stroke-width="0.7" stroke-dasharray="2 2"/>
&lt;line x1="408" y1="122" x2="408" y2="218" stroke="currentColor" stroke-width="0.7" stroke-dasharray="2 2"/>
&lt;text x="20" y="245" font-size="11.5" fill="currentColor">The prefetch is hidden if &lt;tspan font-weight="700">t_att ≥ t_prefetch&lt;/tspan>: by the time attention ends, the experts are in RAM.&lt;/text>
&lt;text x="20" y="262" font-size="11" fill="currentColor">If t_prefetch &amp;gt; t_att, a wait bubble (t_prefetch − t_att) appears before the FFN. That is the case to avoid.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The &lt;strong>hiding condition&lt;/strong> is the inequality above: the prefetch is completely hidden if and only if&lt;/p>
$$t_{\text{attention}} \;\ge\; t_{\text{prefetch}}.$$
&lt;p>If attention takes longer than loading the experts, the load is free (it was already done). If the experts are too large or the SSD too slow, &lt;code>t_prefetch &amp;gt; t_att&lt;/code> and a wait bubble equal to &lt;code>t_prefetch − t_att&lt;/code> appears. That is why the design &lt;em>needs&lt;/em> &lt;code>A&lt;/code> to be small (small experts → fewer bytes to prefetch → low &lt;code>t_prefetch&lt;/code>) and the grain to be fine: the two things level 1 of sparsity does are not only about saving FLOPs, they are about making the prefetch fit underneath attention.&lt;/p>
&lt;h2 id="the-maths-that-matter">The maths that matter&lt;/h2>
&lt;h3 id="memory-footprint-resident-n-vs-a--cache">Memory footprint: resident N vs. A + cache&lt;/h3>
&lt;p>The parameter that decides whether the model fits is how much you have to keep &lt;strong>resident in RAM&lt;/strong> at once.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Everything in RAM.&lt;/strong> If you require all the experts to be loaded, the footprint is &lt;code>\approx N&lt;/code> (all parameters, multiplied by bytes/parameter according to the quantisation). For a 21B this is prohibitive on a device.&lt;/li>
&lt;li>&lt;strong>Streaming from SSD.&lt;/strong> If you only keep the active experts resident plus a cache of the recent/likely ones, the footprint drops to &lt;code>\approx A + \text{cache}&lt;/code>. The weights that are not in RAM live on SSD and get prefetched when needed. This is where the real saving is: the resident set scales with &lt;code>A&lt;/code>, not with &lt;code>N&lt;/code>.&lt;/li>
&lt;/ul>
&lt;p>The non-expert part of the model (embeddings, attention, router, layernorms) is always resident, but in a fine-grained MoE the bulk of &lt;code>N&lt;/code> is in the experts, so the approximation &lt;code>resident ≈ A + cache + dense_part&lt;/code> is a good one.&lt;/p>
&lt;h3 id="the-prefetch-calculation-with-numbers">The prefetch calculation, with numbers&lt;/h3>
&lt;p>Let us put numbers on the analogy. Suppose a consumer SSD at &lt;strong>5 GB/s&lt;/strong> of sequential read and a quantised expert of size &lt;code>X&lt;/code> MB. The time to load one expert is&lt;/p>
$$t_{\text{1 expert}} = \frac{X \text{ MB}}{5000 \text{ MB/s}} = \frac{X}{5000}\ \text{s} = \frac{X}{5}\ \text{ms}.$$
&lt;p>Let us make &lt;code>X&lt;/code> concrete. In SmallThinker-4B-A0.6B with Q4_0 (~0.5 byte/param effective counting block overhead), a small expert of, say, 4M parameters weighs &lt;code>\approx 4\text{M} \times 0.5 = 2&lt;/code> MB. Loading it costs &lt;code>t_{\text{1 expert}} = 2/5 = 0.4&lt;/code> ms.&lt;/p>
&lt;p>Now the scheduling question: if the layer&amp;rsquo;s attention block takes &lt;code>Y&lt;/code> ms, &lt;strong>how many experts can I prefetch while attention runs?&lt;/strong> The number is&lt;/p>
$$n_{\text{prefetch}} = \left\lfloor \frac{Y}{t_{\text{1 expert}}} \right\rfloor = \left\lfloor \frac{Y \cdot 5}{X} \right\rfloor.$$
&lt;p>With &lt;code>Y = 2&lt;/code> ms of attention and &lt;code>X = 2&lt;/code> MB per expert: &lt;code>n_{\text{prefetch}} = \lfloor 2 \times 5 / 2 \rfloor = 5&lt;/code> experts. That is, within that layer&amp;rsquo;s attention window the SSD manages to bring in 5 experts. If the layer&amp;rsquo;s top-k activates ≤ 5 experts, the prefetch hides them all and &lt;code>t_prefetch ≤ t_att&lt;/code>: zero load latency. If the layer needed 8 experts, you would bring 5 for free and pay the load of the remaining 3 as a bubble: &lt;code>(8-5) \times 0.4 = 1.2&lt;/code> ms of waiting per layer. Hence why the design wants fine grain with a small top-k: to fit underneath the attention window.&lt;/p>
&lt;p>Two critical observations about this calculation:&lt;/p>
&lt;ul>
&lt;li>The 5 GB/s is &lt;strong>idealised sequential read&lt;/strong>. The experts are scattered across the disk; random 4K reads on a consumer SSD are far slower. The effective bandwidth can be a fraction of the nominal one, which reduces &lt;code>n_{\text{prefetch}}&lt;/code>. Any methodology reporting tok/s ought to say whether it measures with experts pre-sorted on disk or with realistic access.&lt;/li>
&lt;li>The attention window &lt;code>Y&lt;/code> &lt;strong>shrinks with short context&lt;/strong> and at the start of generation. With short prompts, attention is cheap and may &lt;em>not&lt;/em> cover the prefetch; the advantage of the overlap grows with longer sequences. Another detail an honest benchmark should break down.&lt;/li>
&lt;/ul>
&lt;h3 id="weight-footprint-why-they-report-1-gb-for-a-4b">Weight footprint: why they report ~1 GB for a 4B&lt;/h3>
&lt;p>Let us do the 4B calculation in Q4_0. Quantisation to 4 bits ≈ 0.5 byte/param, plus a small overhead of per-block scales (Q4_0 adds one FP16 scale every 32 weights, ~0.56 effective byte/param). So:&lt;/p>
$$4\text{B} \times 0.5\ \text{B/param} \approx 2\ \text{GB}.$$
&lt;p>That is, &lt;strong>the full model in Q4_0 takes up ~2 GB on disk&lt;/strong>. But the authors report &lt;strong>~1 GB of RAM&lt;/strong>. A contradiction? No, and understanding why is understanding the design:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Not all experts are resident.&lt;/strong> Only the activated ones (&lt;code>A = 0.6B&lt;/code>) plus a cache fit in RAM; the rest lives on SSD and is streamed. &lt;code>0.6\text{B} \times 0.5 \approx 0.3&lt;/code> GB of active experts, plus the dense part (attention, embeddings, router) and a cache of hot experts.&lt;/li>
&lt;li>&lt;strong>Sparse FFN reduces the work and the useful resident set.&lt;/strong> The neurons that come out at zero do not need to be materialised for that token.&lt;/li>
&lt;/ul>
&lt;p>Adding up active experts + dense part + a reasonable cache, ~1 GB is plausible. But mind the nuance: ~1 GB is the &lt;strong>resident set in RAM&lt;/strong>, not the total footprint in storage, which is still ~2 GB on SSD. Conflating the two, reporting a bare &amp;ldquo;1 GB&amp;rdquo;, is misleading if the reader understands &amp;ldquo;the model takes up 1 GB&amp;rdquo;. It takes up 2 GB; it &lt;em>keeps&lt;/em> 1 GB in RAM. The distinction matters for a device with 2 GB of free storage: there it does not fit.&lt;/p>
&lt;p>Analogously, SmallThinker-21B-A3B: &lt;code>21\text{B} \times 0.5 \approx 10.5&lt;/code> GB on disk; &lt;code>3\text{B} \times 0.5 \approx 1.5&lt;/code> GB of active experts, and the ~8 GB of RAM reported includes active experts + a generous cache + the dense part. The large cache is what takes it from 1.5 to ~8 GB: you keep many hot experts resident so as not to hit the SSD constantly.&lt;/p>
&lt;h2 id="the-quality-cost-the-necessary-scepticism">The quality cost: the necessary scepticism&lt;/h2>
&lt;p>All the machinery above reduces the compute per token to &lt;code>\approx A&lt;/code>. But &lt;code>A = 0.6B&lt;/code> activated is &lt;strong>very&lt;/strong> little. This is where the enthusiasm needs a brake:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Bounded reasoning capacity.&lt;/strong> A model that activates 0.6B parameters per token has, per token, the compute power of a 0.6B model, not of a 4B one. The total capacity &lt;code>N=4B&lt;/code> helps to &lt;em>store&lt;/em> more knowledge (more specialised experts), but the &lt;em>processing&lt;/em> of each token is still limited by &lt;code>A&lt;/code>. For tasks requiring composition and intensive multi-step reasoning, this is a real ceiling, not a detail.&lt;/li>
&lt;li>&lt;strong>The router is a single point of failure for quality.&lt;/strong> If the fine-grained router picks the wrong experts, and with fine grain there are more decisions to take, quality drops without any speed metric reflecting it. The pre-attention router makes this worse: it predicts the experts &lt;em>before&lt;/em> seeing attention, with less information than a post-attention router. The authors ought to report how much quality is lost by predicting early (mismatch between the prefetched expert and the expert a post-attention router would have chosen).&lt;/li>
&lt;li>&lt;strong>The ~20 tok/s needs small print.&lt;/strong> On which CPU exactly? With what context and generation length (the advantage of the overlap depends on &lt;code>Y&lt;/code>)? Cold start included or steady state? Did the SSD have the experts pre-sorted sequentially? An &amp;ldquo;exceeds 20 tok/s&amp;rdquo; without those conditions is a marketing number, not a methodological one.&lt;/li>
&lt;li>&lt;strong>A fair comparison.&lt;/strong> The right question is not &amp;ldquo;is it fast?&amp;rdquo;, but &amp;ldquo;at equal quality on an independent benchmark, is it faster or smaller than an equivalent compressed dense model?&amp;rdquo;. That requires evals the reader can reproduce, not just tok/s on the authors&amp;rsquo; machine.&lt;/li>
&lt;/ul>
&lt;p>None of this invalidates the direction. Designing for device is, conceptually, the right approach: it attacks the real bottlenecks (compute, RAM, I/O) in the architecture instead of patching them afterwards. But &amp;ldquo;20 tok/s in ~1 GB&amp;rdquo; is a claim about &lt;em>efficiency&lt;/em>, and efficiency only means something anchored to a level of &lt;em>quality&lt;/em> measured honestly. Until that anchor is clear, the right amount of scepticism is high.&lt;/p>
&lt;h2 id="implications-for-on-premise-and-edge-inference">Implications for on-premise and edge inference&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>The SSD becomes part of the inference hierarchy.&lt;/strong> In cloud, the hierarchy is HBM → RAM. On device, the SSD/flash enters as one more level, and its bandwidth and random-access latency become first-order performance parameters. This connects with &lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM&lt;/a>: cold start and weight streaming stop being just a startup problem and become part of the &lt;em>steady state&lt;/em>.&lt;/li>
&lt;li>&lt;strong>The heterogeneous edge box starts to make sense.&lt;/strong> In a &lt;a href="https://blog.lo0.es/en/posts/mixed-nvidia-intel-environments-llm-inference/">mixed environments&lt;/a> pattern, a device-native model like SmallThinker runs on the NUC/edge with CPU and SSD, serving locally, while the heavy work stays on the central cluster. The pre-attention router is what makes a GPU-less edge box viable.&lt;/li>
&lt;li>&lt;strong>Capacity planning changes axes.&lt;/strong> As discussed in &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Inference capacity planning&lt;/a>, on device the resource to plan is not VRAM but the triad resident-RAM / SSD-bandwidth / CPU-FLOPs. A model with small &lt;code>A&lt;/code> and overlapped prefetch moves the bottleneck from &amp;ldquo;does it fit in RAM?&amp;rdquo; to &amp;ldquo;does the SSD feed the prefetch in time?&amp;rdquo;.&lt;/li>
&lt;/ul>
&lt;h2 id="conclusion">Conclusion&lt;/h2>
&lt;p>SmallThinker is, above all, a change of question. Not &amp;ldquo;how do I shrink this cloud model so it fits on the device?&amp;rdquo; but &amp;ldquo;what would the model look like if I designed it for the device from the first parameter?&amp;rdquo;. The answer, fine-grained MoE to decouple &lt;code>N&lt;/code> from &lt;code>A&lt;/code>, sparse FFN to save inside &lt;code>A&lt;/code>, and a pre-attention router that hides storage I/O underneath attention, attacks the device&amp;rsquo;s three constraints (compute, RAM, I/O) in the architecture, not in a later compression phase. The key condition, &lt;code>t_att ≥ t_prefetch&lt;/code>, explains why the pieces fit: fine grain does not only save FLOPs, it makes the prefetch fit underneath attention. The reported numbers (~20 tok/s, ~1 GB / ~8 GB of RAM) are promising and the direction is solid; the cost of activating so little and the lack of methodological detail on quality call for caution. Designing for device is the right bet; measuring it honestly is the unfinished business.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/">MoE inference: the call centre with 256 specialists&lt;/a> — the conceptual basis of this post: how a router routes tokens to experts and why &lt;code>N&lt;/code> and &lt;code>A&lt;/code> decouple; read it first if MoE sounds distant.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM: cold start and model loading&lt;/a> — weight streaming from slow storage, which here stops being a startup problem and becomes steady state via prefetch.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/knowledge-distillation-teaching-small-model-think-like-big-one/">Knowledge distillation&lt;/a> — the canonical lever of the &amp;ldquo;compress a cloud dense model&amp;rdquo; approach, the exact counterpoint to the device-native one.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-pruning-cutting-without-amputating/">Pruning LLM models&lt;/a> — the other reduction lever; useful for comparing &amp;ldquo;take away from a big one&amp;rdquo; against &amp;ldquo;design small from scratch&amp;rdquo;.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mixed-nvidia-intel-environments-llm-inference/">Mixed NVIDIA + Intel environments&lt;/a> — where a device-native model fits: the edge box with CPU and SSD that serves locally without a GPU.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">On-premise LLM inference capacity planning&lt;/a> — on device the axes to plan are resident RAM, SSD bandwidth and CPU FLOPs, not VRAM.&lt;/li>
&lt;li>&lt;strong>Inverted roofline in small models&lt;/strong> (sibling of this series, coming soon) — the SLM&amp;rsquo;s performance regime, which explains why a small &lt;code>A&lt;/code> keeps decode memory-bound and where the real ceiling is.&lt;/li>
&lt;li>&lt;strong>Self-speculative decoding with early-exit&lt;/strong> (sibling of this series, coming soon) — self-spec applied to on-device MoE: how to accelerate decode without an external draft when the model is already small.&lt;/li>
&lt;li>&lt;strong>Aggressive sub-4-bit and ternary quantisation&lt;/strong> (sibling of this series, coming soon) — Q4_0 and beyond on device: ternary and 2-bit to push the expert footprint on SSD down further.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Equipo SmallThinker (SJTU IPADS + Zenergize AI). &lt;em>SmallThinker: A Family of Efficient Large Language Models Natively Trained for Local Deployment&lt;/em>. arXiv:2507.20984. &lt;a href="https://arxiv.org/abs/2507.20984">https://arxiv.org/abs/2507.20984&lt;/a>&lt;/li>
&lt;li>Repositorio oficial SmallThinker: &lt;a href="https://github.com/SJTU-IPADS/SmallThinker">https://github.com/SJTU-IPADS/SmallThinker&lt;/a>&lt;/li>
&lt;li>&lt;em>Self-Speculative Decoding for On-device MoE Acceleration&lt;/em>. ACM The Web Conference (WWW) 2026. doi:10.1145/3774904.3792218. &lt;a href="https://doi.org/10.1145/3774904.3792218">https://doi.org/10.1145/3774904.3792218&lt;/a>&lt;/li>
&lt;li>Liu, Z. et al. &lt;em>Deja Vu: Contextual Sparsity for Efficient LLMs at Inference Time&lt;/em>. ICML 2023. &lt;a href="https://arxiv.org/abs/2310.17157">https://arxiv.org/abs/2310.17157&lt;/a>&lt;/li>
&lt;li>Song, Y. et al. &lt;em>PowerInfer: Fast Large Language Model Serving with a Consumer-grade GPU&lt;/em> (sparse activation + hot/cold experts). SJTU IPADS, 2023. &lt;a href="https://arxiv.org/abs/2312.12456">https://arxiv.org/abs/2312.12456&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Self-speculative decoding: the model that gets ahead of itself</title><link>https://blog.lo0.es/en/posts/self-speculative-decoding-model-gets-ahead-of-itself/</link><pubDate>Tue, 09 Jun 2026 01:40:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/self-speculative-decoding-model-gets-ahead-of-itself/</guid><description>&lt;blockquote>
&lt;p>This post is the direct companion to &lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding: the secretary who types ahead&lt;/a>. There, draft and target are two different models; here they are the same model at two depths. Read that one first: we take rejection sampling, the &lt;code>1/(1-α)&lt;/code> ceiling and the speedup formula as known, and all we change here is what the draft is.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Classic speculative decoding demands a pair: a cheap &lt;em>draft&lt;/em> model proposes γ tokens and an expensive &lt;em>target&lt;/em> verifies them in a single parallel forward pass. On large models the draft can be 1 % of the target and fit comfortably. On &lt;strong>small models&lt;/strong> (SLMs, 1B–8B) that recipe breaks on two fronts: a draft that is 1/10 of a 3B is a 0.3B that barely gets anything right (α collapses), and loading a second model, however small, doubles the pieces to maintain and eats VRAM that is not spare on a 4090 or on device. &lt;em>Self-speculative decoding&lt;/em> solves both: the draft is the model itself, run shallowly. A model with &lt;code>L&lt;/code> layers produces draft tokens by exiting at an intermediate layer &lt;code>k &amp;lt; L&lt;/code> (&lt;em>early-exit&lt;/em>) or by skipping a subset of layers (&lt;em>layer-skip&lt;/em>), and then verifies those tokens with the full forward pass through the &lt;code>L&lt;/code> layers. Because draft and verify &lt;strong>share weights and share the KV cache of the common layers&lt;/strong>, the extra memory cost is &lt;strong>zero&lt;/strong>: there is no second model, no second KV cache, nothing new to load. The price is that the early-exit draft is more expensive than a tiny external draft (it walks &lt;code>k/L&lt;/code> of the model instead of 1 %), so the relative cost &lt;code>c&lt;/code> goes up. The honest trade-off: with a well-trained dedicated draft (EAGLE-3) that &lt;strong>fits&lt;/strong> in memory, its α is usually higher and it wins; self-spec wins when there is no trained draft, when it does not fit, or when you are on device.&lt;/p>
&lt;h2 id="the-analogy-the-chess-player-who-plays-by-eye-and-then-calculates">The analogy: the chess player who plays by eye and then calculates&lt;/h2>
&lt;p>A good chess player does two things with the same brain. First they look at the board and, by eye, in half a second, propose a move that &amp;ldquo;looks right&amp;rdquo;: pattern intuition, fast recognition, the shallow layers of judgement. Then, before moving, they calculate properly: three moves ahead, the opponent&amp;rsquo;s replies, the tactical lines. That deep calculation confirms the intuition or corrects it.&lt;/p>
&lt;p>The decisive part is that it is the same person acting as drafter and as reviewer. They do not hire a second, weaker player to guess the move so they can validate it; that would be classic speculative with an external draft. Here the fast draft and the slow verification come out of the same brain, traversed at two depths.&lt;/p>
&lt;p>The analogy holds point by point:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>The glance by eye is the early-exit forward pass&lt;/strong>: the model walks only the first &lt;code>k&lt;/code> layers and emits a draft token. Fast, approximate.&lt;/li>
&lt;li>&lt;strong>The deep calculation is the full forward pass through the &lt;code>L&lt;/code> layers&lt;/strong>, which verifies the draft with exact rejection sampling.&lt;/li>
&lt;li>&lt;strong>That it is the same person is the reuse of weights and KV cache&lt;/strong>: the &lt;code>k&lt;/code> shallow layers of the draft are literally the same as the first &lt;code>k&lt;/code> layers of the verify; what has already been computed is not recomputed.&lt;/li>
&lt;li>&lt;strong>That the final move is identical to the one the player would have chosen by always calculating deeply&lt;/strong> is the rejection sampling guarantee: output quality does not degrade (the proof is in the &lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">speculative post&lt;/a>).&lt;/li>
&lt;/ul>
&lt;h2 id="why-the-external-draft-does-not-fit-small-models">Why the external draft does not fit small models&lt;/h2>
&lt;p>Let us revisit the cost of classic speculative with two numbers. The speedup depends on the acceptance rate α (how often the draft is right) and on the relative cost &lt;code>c = T_draft / T_target&lt;/code>. A useful draft needs high α and low c at the same time. On large models that is reachable: a 1B draft for a 70B target has &lt;code>c ≈ 0.015&lt;/code> and, if well distilled (EAGLE), α &amp;gt; 0.8. The product pays off.&lt;/p>
&lt;p>On a small model the balance breaks:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>The proportional draft is useless.&lt;/strong> If you want &lt;code>c ≈ 0.1&lt;/code> for a 3B target, your draft is around 0.3B. A generic 0.3B has a distribution so different from the 3B that α falls into the 0.3–0.5 range. And &lt;code>1/(1-α)&lt;/code> with α = 0.4 is a ceiling of 1.67 tokens/step: not even infinite γ gets you more. The prize evaporates.&lt;/li>
&lt;li>&lt;strong>Loading a second model doubles the pieces.&lt;/strong> Even if the draft is small in VRAM, it is another checkpoint to version, quantise, validate and serve, and it has its own KV cache. On an RTX 4090 (24 GB, Ada Lovelace) with a quantised 8B and a long context, the KV cache is already tight; adding a second model and its cache can force you to lower concurrency or maximum context. On &lt;strong>device&lt;/strong> (a phone, a NUC, an edge box) there is simply no room.&lt;/li>
&lt;li>&lt;strong>A trained draft does not always exist&lt;/strong> for your exotic or fine-tuned model. EAGLE needs the draft trained on-policy against that specific target (see &lt;a href="https://blog.lo0.es/en/posts/knowledge-distillation-teaching-small-model-think-like-big-one/">knowledge distillation&lt;/a>). If your SLM is your own fine-tune, there is no official published draft.&lt;/li>
&lt;/ol>
&lt;p>Self-speculative attacks all three at once with one idea: &lt;strong>do not bring a second model; use the first one at half depth.&lt;/strong>&lt;/p>
&lt;h2 id="the-mechanism-early-exit-as-draft-full-forward-as-verify">The mechanism: early-exit as draft, full forward as verify&lt;/h2>
&lt;p>A transformer with &lt;code>L&lt;/code> layers transforms the hidden state layer by layer at each position: &lt;code>h_0 → h_1 → ... → h_L&lt;/code>, and the &lt;em>LM head&lt;/em> projects &lt;code>h_L&lt;/code> to logits. The observation that enables everything: &lt;code>h_k&lt;/code> for &lt;code>k &amp;lt; L&lt;/code> is already a reasonable hidden state. If you push it through the same LM head (or through a dedicated lightweight head), you get an output distribution that is &amp;ldquo;premature&amp;rdquo; but often correct for easy tokens. That is the source of the draft.&lt;/p>
&lt;p>The self-speculative iteration has the same structure as classic speculative (draft, verify, accept/reject) but both roles are the same model:&lt;/p>
&lt;p>&lt;strong>Step 1 — Shallow draft.&lt;/strong> To produce γ draft tokens, the model walks only the first &lt;code>k&lt;/code> layers (or a subset of layers in the layer-skip case) and applies the LM head. Each draft token costs ≈ &lt;code>k/L&lt;/code> of a full forward pass. We call &lt;code>c = k/L&lt;/code> the relative cost of the draft. The γ drafts are generated autoregressively at this reduced cost.&lt;/p>
&lt;p>&lt;strong>Step 2 — Full verify.&lt;/strong> The model runs a single forward pass through the &lt;code>L&lt;/code> layers over &lt;code>prompt + x_1...x_γ&lt;/code>. Because attention is causal it obtains &lt;code>p(·|prompt, x_&amp;lt;i)&lt;/code> for each position, exactly as in classic speculative.&lt;/p>
&lt;p>&lt;strong>Step 3 — Accept/reject.&lt;/strong> Rejection sampling identical to the previous post: tokens are accepted from left to right, the first divergence is corrected by sampling from the residual &lt;code>norm(max(0, p−q))&lt;/code>, and if all γ are accepted the bonus token is appended. Output quality is &lt;strong>exactly&lt;/strong> that of the full model.&lt;/p>
&lt;h3 id="the-trick-that-makes-c-cheaper-still-reusing-the-kv-cache-of-shared-layers">The trick that makes &lt;code>c&lt;/code> cheaper still: reusing the KV cache of shared layers&lt;/h3>
&lt;p>Here is the key difference against an external draft. When the model drafts by walking layers &lt;code>0..k&lt;/code>, it computes and stores the KV cache of those &lt;code>k&lt;/code> layers for the prompt tokens and the drafts. When the full verify comes along, layers &lt;code>0..k&lt;/code> of the &lt;code>L&lt;/code>-layer forward pass are bit for bit the same operations on the same weights that the draft already performed. There is no need to recompute them: the verify reuses the KV cache the draft left for layers &lt;code>0..k&lt;/code> directly, and only genuinely computes the missing layers &lt;code>k..L&lt;/code>.&lt;/p>
&lt;p>That has two consequences:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Zero extra memory.&lt;/strong> There is no second KV cache. The KV of the common layers is a single one, shared between draft and verify. Contrast that with vanilla SD, where the draft has its own complete cache (see &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>).&lt;/li>
&lt;li>&lt;strong>Partially reused compute.&lt;/strong> The verify only pays for the &amp;ldquo;new&amp;rdquo; layers &lt;code>k..L&lt;/code> for tokens that already went through the draft. The full forward pass is not as expensive as &lt;code>L&lt;/code> suggests, because the first &lt;code>k&lt;/code> layers come from the cache.&lt;/li>
&lt;/ul>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 400" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Early-exit draft and full forward verify with shared KV">
&lt;text x="390" y="22" text-anchor="middle" fill="currentColor" font-size="14" font-weight="700">A single L=32 layer model, traversed at two depths&lt;/text>
&lt;!-- DRAFT column -->
&lt;text x="180" y="50" text-anchor="middle" fill="currentColor" font-size="12" font-weight="700">DRAFT · early-exit at k=8&lt;/text>
&lt;rect x="120" y="60" width="120" height="120" fill="#fff4d6" stroke="#a48000" stroke-width="1.4" rx="6"/>
&lt;text x="180" y="95" text-anchor="middle" fill="#a48000" font-size="12" font-weight="600">layers 0..8&lt;/text>
&lt;text x="180" y="115" text-anchor="middle" fill="#a48000" font-size="11">shallow traversal&lt;/text>
&lt;text x="180" y="133" text-anchor="middle" fill="#a48000" font-size="11">cost ≈ k/L = 0.25&lt;/text>
&lt;rect x="120" y="190" width="120" height="30" fill="#fff4d6" stroke="#a48000" stroke-width="1.4" rx="6"/>
&lt;text x="180" y="210" text-anchor="middle" fill="#a48000" font-size="11" font-weight="600">LM head → draft&lt;/text>
&lt;text x="180" y="245" text-anchor="middle" fill="currentColor" font-size="11">x₁ x₂ x₃ x₄ (γ=4)&lt;/text>
&lt;!-- VERIFY column -->
&lt;text x="600" y="50" text-anchor="middle" fill="currentColor" font-size="12" font-weight="700">VERIFY · full forward L=32&lt;/text>
&lt;rect x="540" y="60" width="120" height="120" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4" rx="6"/>
&lt;text x="600" y="92" text-anchor="middle" fill="#1f5fa8" font-size="12" font-weight="600">layers 0..8&lt;/text>
&lt;text x="600" y="110" text-anchor="middle" fill="#1f5fa8" font-size="11">(reused, not&lt;/text>
&lt;text x="600" y="125" text-anchor="middle" fill="#1f5fa8" font-size="11">recomputed)&lt;/text>
&lt;line x1="540" y1="135" x2="660" y2="135" stroke="#1f5fa8" stroke-width="1" stroke-dasharray="4 2"/>
&lt;text x="600" y="158" text-anchor="middle" fill="#1f5fa8" font-size="12" font-weight="600">layers 8..32&lt;/text>
&lt;text x="600" y="174" text-anchor="middle" fill="#1f5fa8" font-size="11">new compute&lt;/text>
&lt;rect x="540" y="190" width="120" height="30" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4" rx="6"/>
&lt;text x="600" y="210" text-anchor="middle" fill="#1f5fa8" font-size="11" font-weight="600">LM head → p(·)&lt;/text>
&lt;!-- shared KV cache box -->
&lt;rect x="300" y="80" width="180" height="80" fill="#cdebd0" stroke="#2a7a40" stroke-width="1.6" rx="8"/>
&lt;text x="390" y="110" text-anchor="middle" fill="#2a7a40" font-size="12" font-weight="700">SHARED KV cache&lt;/text>
&lt;text x="390" y="128" text-anchor="middle" fill="#2a7a40" font-size="11">layers 0..8 · a single cache&lt;/text>
&lt;text x="390" y="145" text-anchor="middle" fill="#2a7a40" font-size="11">extra memory = 0&lt;/text>
&lt;!-- arrows draft->KV and KV->verify -->
&lt;defs>&lt;marker id="ssd1" 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="#2a7a40"/>&lt;/marker>&lt;/defs>
&lt;path d="M240,120 L300,120" fill="none" stroke="#2a7a40" stroke-width="1.6" marker-end="url(#ssd1)"/>
&lt;text x="270" y="112" text-anchor="middle" fill="#2a7a40" font-size="10">writes KV 0..8&lt;/text>
&lt;path d="M480,120 L540,120" fill="none" stroke="#2a7a40" stroke-width="1.6" marker-end="url(#ssd1)"/>
&lt;text x="510" y="112" text-anchor="middle" fill="#2a7a40" font-size="10">reads KV 0..8&lt;/text>
&lt;!-- accept/reject row -->
&lt;text x="390" y="280" text-anchor="middle" fill="currentColor" font-size="12" font-weight="700">Rejection sampling (identical to classic speculative)&lt;/text>
&lt;rect x="180" y="295" width="80" height="34" fill="#cdebd0" stroke="#2a7a40" stroke-width="1.4" rx="6"/>&lt;text x="220" y="316" text-anchor="middle" fill="#2a7a40" font-size="12" font-weight="600">x₁ ✓&lt;/text>
&lt;rect x="270" y="295" width="80" height="34" fill="#cdebd0" stroke="#2a7a40" stroke-width="1.4" rx="6"/>&lt;text x="310" y="316" text-anchor="middle" fill="#2a7a40" font-size="12" font-weight="600">x₂ ✓&lt;/text>
&lt;rect x="360" y="295" width="80" height="34" fill="#cdebd0" stroke="#2a7a40" stroke-width="1.4" rx="6"/>&lt;text x="400" y="316" text-anchor="middle" fill="#2a7a40" font-size="12" font-weight="600">x₃ ✓&lt;/text>
&lt;rect x="450" y="295" width="80" height="34" fill="#f6caca" stroke="#a52a2a" stroke-width="1.4" rx="6"/>&lt;text x="490" y="316" text-anchor="middle" fill="#a52a2a" font-size="12" font-weight="600">x₄ ✗&lt;/text>
&lt;text x="390" y="360" text-anchor="middle" fill="currentColor" font-size="11">Output = exactly that of the full model · 0 extra models · 0 extra KV&lt;/text>
&lt;text x="390" y="385" text-anchor="middle" fill="currentColor" font-size="11">Draft and verify are the same model; layers 0..8 are computed only once.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-families-state-of-play-2026">The families (state of play 2026)&lt;/h2>
&lt;p>There is no single way of doing self-speculative. They differ in which layers are skipped and in whether training is needed.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Family&lt;/th>
&lt;th>Year / venue&lt;/th>
&lt;th>How it picks what to skip&lt;/th>
&lt;th>Training?&lt;/th>
&lt;th>Extra KV&lt;/th>
&lt;th>Distinctive idea&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>LayerSkip&lt;/strong> (Elhoushi et al.)&lt;/td>
&lt;td>2024, arXiv:2404.16710&lt;/td>
&lt;td>Early-exit at a fixed layer &lt;code>k&lt;/code>; a single LM head serves every exit&lt;/td>
&lt;td>Yes — &lt;em>layer dropout&lt;/em> + &lt;em>early-exit loss&lt;/em> in train/fine-tune&lt;/td>
&lt;td>0&lt;/td>
&lt;td>A single model trained to do both draft and verify; reuses partial compute&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>SWIFT&lt;/strong>&lt;/td>
&lt;td>ICLR 2025 (OpenReview EKJhH5D5wA)&lt;/td>
&lt;td>Selects which layers to skip &lt;strong>on the fly&lt;/strong>, without touching weights&lt;/td>
&lt;td>&lt;strong>No&lt;/strong> — plug-and-play on the given model&lt;/td>
&lt;td>0&lt;/td>
&lt;td>&lt;em>Training-free&lt;/em> self-spec: optimises the set of skipped layers at runtime&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>CLaSp&lt;/strong>&lt;/td>
&lt;td>2025, arXiv:2505.24196&lt;/td>
&lt;td>Dynamic &lt;em>in-context&lt;/em> layer skip: the pattern of skipped layers adapts to the context&lt;/td>
&lt;td>No (dynamic at inference)&lt;/td>
&lt;td>0&lt;/td>
&lt;td>The skip is not fixed; it changes with what is being generated&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>ConfLayers&lt;/strong>&lt;/td>
&lt;td>2026, arXiv:2604.14612&lt;/td>
&lt;td>Skips layers according to the &lt;strong>confidence&lt;/strong> of the intermediate state (adaptive per token)&lt;/td>
&lt;td>No (confidence criterion)&lt;/td>
&lt;td>0&lt;/td>
&lt;td>Variable depth: easy tokens exit earlier, hard ones go deeper&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Saguaro&lt;/strong>&lt;/td>
&lt;td>2025–26&lt;/td>
&lt;td>&lt;strong>Asynchronous&lt;/strong> formulation: the draft keeps speculating in parallel while verification runs&lt;/td>
&lt;td>Depends on the variant&lt;/td>
&lt;td>0&lt;/td>
&lt;td>Overlaps draft and verify in time instead of alternating them&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>SSD for on-device MoE&lt;/strong>&lt;/td>
&lt;td>ACM Web Conf. 2026, doi 10.1145/3774904.3792218&lt;/td>
&lt;td>Self-spec exploiting MoE &lt;em>sparsity&lt;/em> (few active experts per token)&lt;/td>
&lt;td>MoE-specific variant&lt;/td>
&lt;td>0&lt;/td>
&lt;td>The shallow draft activates even fewer experts; fits MoE on device&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Three operational readings of the table:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>The axis that matters most is training yes/no.&lt;/strong> LayerSkip gives the best α because the model learns to be a good shallow draft (with early-exit loss the intermediate layers are explicitly trained to predict well). But it demands a fine-tune. SWIFT, CLaSp and ConfLayers are &lt;strong>training-free&lt;/strong>: worse α, but they apply to any already-trained model without touching anything. For an SLM you do not control, training-free is the realistic option.&lt;/li>
&lt;li>&lt;strong>Adaptive skip (CLaSp, ConfLayers) raises α&lt;/strong> because it fits the draft&amp;rsquo;s depth to the token: it spends little on the easy ones and more on the hard ones, instead of a fixed &lt;code>k&lt;/code>. In exchange, the effective &lt;code>c&lt;/code> stops being constant.&lt;/li>
&lt;li>&lt;strong>Saguaro attacks something else:&lt;/strong> it does not raise α, it overlaps draft and verify time. It is orthogonal to the rest and can be combined.&lt;/li>
&lt;/ol>
&lt;h2 id="the-maths-same-framework-different-c">The maths: same framework, different &lt;code>c&lt;/code>&lt;/h2>
&lt;p>We reuse the apparatus of the &lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">speculative post&lt;/a> without changing a letter. With α the acceptance rate and γ the number of drafts:&lt;/p>
$$E[\text{tokens per step}] = \frac{1 - \alpha^{\gamma+1}}{1 - \alpha}, \qquad \text{Speedup} = \frac{1 - \alpha^{\gamma+1}}{(1 - \alpha)(\gamma c + 1)}$$
&lt;p>And the algorithmic ceiling is the same: &lt;code>lim_{γ→∞} = 1/(1-α)&lt;/code>. The only thing that changes in self-speculative is the value of &lt;code>c&lt;/code>: it is no longer the size ratio of two models, but &lt;code>c = k/L&lt;/code>, the fraction of layers the early-exit draft walks.&lt;/p>
&lt;h3 id="numerical-example-self-spec-with-l32-exit-at-k8">Numerical example: self-spec with L=32, exit at k=8&lt;/h3>
&lt;p>Take an SLM with &lt;code>L = 32&lt;/code> layers that exits at &lt;code>k = 8&lt;/code> for the draft: &lt;code>c = k/L = 8/32 = 0.25&lt;/code>. Suppose α = 0.7 (reasonable for early-exit on conversational tokens) and γ = 4.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Expected tokens per step:&lt;/strong> &lt;code>(1 − 0.7⁵) / (1 − 0.7) = (1 − 0.168) / 0.3 = 0.832 / 0.3 = 2.77&lt;/code>&lt;/li>
&lt;li>&lt;strong>Speedup:&lt;/strong> &lt;code>2.77 / (4 × 0.25 + 1) = 2.77 / 2.0 = 1.39×&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>The denominator factor is &lt;code>γc + 1 = 4·0.25 + 1 = 2.0&lt;/code>: the early-exit draft, costing a quarter of the model per token, eats part of the benefit. Exiting higher up helps: with &lt;code>k = 4&lt;/code> (&lt;code>c = 0.125&lt;/code>), the denominator is &lt;code>1.5&lt;/code> and the speedup &lt;code>2.77/1.5 = 1.85×&lt;/code>. But exiting higher up normally lowers α, so there is real tension between a small &lt;code>k&lt;/code> (cheap) and a high α (accurate).&lt;/p>
&lt;h3 id="an-honest-comparison-with-an-external-draft">An honest comparison with an external draft&lt;/h3>
&lt;p>Put a tiny, well-distilled external draft alongside: &lt;code>c = 0.1&lt;/code> and α = 0.78 (what an EAGLE-style draft can deliver), same γ = 4.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Tokens/step:&lt;/strong> &lt;code>(1 − 0.78⁵)/(1 − 0.78) = (1 − 0.289)/0.22 = 0.711/0.22 = 3.23&lt;/code>&lt;/li>
&lt;li>&lt;strong>Speedup:&lt;/strong> &lt;code>3.23 / (4 × 0.1 + 1) = 3.23 / 1.4 = 2.31×&lt;/code>&lt;/li>
&lt;/ul>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Configuration&lt;/th>
&lt;th>c&lt;/th>
&lt;th>α&lt;/th>
&lt;th>tokens/step&lt;/th>
&lt;th>speedup&lt;/th>
&lt;th>extra VRAM&lt;/th>
&lt;th>pieces to maintain&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Self-spec early-exit (k=8)&lt;/td>
&lt;td>0.25&lt;/td>
&lt;td>0.70&lt;/td>
&lt;td>2.77&lt;/td>
&lt;td>&lt;strong>1.39×&lt;/strong>&lt;/td>
&lt;td>&lt;strong>0&lt;/strong>&lt;/td>
&lt;td>&lt;strong>0&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Self-spec early-exit (k=4)&lt;/td>
&lt;td>0.125&lt;/td>
&lt;td>0.65&lt;/td>
&lt;td>2.50&lt;/td>
&lt;td>&lt;strong>1.67×&lt;/strong>&lt;/td>
&lt;td>&lt;strong>0&lt;/strong>&lt;/td>
&lt;td>&lt;strong>0&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Distilled external draft&lt;/td>
&lt;td>0.10&lt;/td>
&lt;td>0.78&lt;/td>
&lt;td>3.23&lt;/td>
&lt;td>&lt;strong>2.31×&lt;/strong>&lt;/td>
&lt;td>yes (+model +KV)&lt;/td>
&lt;td>1 extra model&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The reading is exactly what you would expect, and it is worth not dressing it up: if you have a dedicated draft, trained against your target, and it &lt;strong>fits&lt;/strong> in memory, its higher α and lower &lt;code>c&lt;/code> give it more speedup. EAGLE-3 with a well-trained draft usually wins on raw speedup. &lt;strong>Self-spec does not compete on raw speedup; it competes on total cost.&lt;/strong> Its winning columns are the two on the right: zero extra VRAM and zero pieces to maintain. Self-spec wins when:&lt;/p>
&lt;ul>
&lt;li>there is no trained draft for your model (your own SLM, an unusual fine-tune),&lt;/li>
&lt;li>the draft does not fit (a 4090 already full, a long context that needs the KV),&lt;/li>
&lt;li>you are on device (phone, NUC, edge), where a second model and its KV simply do not go in.&lt;/li>
&lt;/ul>
&lt;p>It is the same pattern as with MTP in the previous post: sometimes the best draft is the one you do not have to load.&lt;/p>
&lt;h2 id="why-it-fits-small-models-and-device-precisely">Why it fits small models and device precisely&lt;/h2>
&lt;p>The regime where self-spec shines is low concurrency, memory-bandwidth-bound, with a tight memory budget, exactly that of an SLM on a single GPU or on device (the reason for the regime is in &lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">the inverted roofline&lt;/a>). Three reasons:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Zero extra memory is decisive where there is none to spare.&lt;/strong> On an RTX 4090 (24 GB, Ada Lovelace) serving a quantised 7B–8B with a long context, every GB counts. Self-spec asks for none: it reuses weights and KV. An external draft, however small, forces you to trim context or concurrency. On device the difference is binary: with self-spec you accelerate; with an external draft there is no room, full stop.&lt;/li>
&lt;li>&lt;strong>There is no second checkpoint to version.&lt;/strong> Operationally, an SLM at the edge deployed across hundreds of boxes becomes unsustainable if each one needs two synchronised models. A single binary that does draft and verify is far simpler to maintain.&lt;/li>
&lt;li>&lt;strong>It fits MoE on device.&lt;/strong> In a fine-grained MoE for device (see &lt;a href="https://blog.lo0.es/en/posts/device-native-architectures-fine-grained-moe-pre-attention-router/">native architectures for device&lt;/a>), the shallow draft activates even fewer experts, and the memory-bound regime persists even at medium batch, precisely what the work on SSD for on-device MoE (ACM WWW 2026) exploits.&lt;/li>
&lt;/ol>
&lt;p>The counterpoint, repeated so it does not get forgotten: on a generic 4×H100 SXM cluster (320 GB, NVLink, native FP8), where memory is not the bottleneck, a dedicated EAGLE-3 draft does fit and its higher α gives it more speedup. There, self-spec is plan B: you use it if the model is exotic and there is no trained draft, not because memory is tight.&lt;/p>
&lt;h2 id="pitfalls">Pitfalls&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>α depends enormously on &lt;code>k&lt;/code>.&lt;/strong> Exiting too high up (small &lt;code>k&lt;/code>) makes the draft cheap but sinks α; exiting too low down (&lt;code>k&lt;/code> close to &lt;code>L&lt;/code>) raises α but the draft costs almost a full forward pass and &lt;code>c → 1&lt;/code>, killing the speedup. The optimum is empirical and model-specific. Distrust any speedup number that does not say at which &lt;code>k&lt;/code> it was measured.&lt;/li>
&lt;li>&lt;strong>Training-free is not free in draft quality.&lt;/strong> SWIFT and CLaSp give lower α than LayerSkip precisely because the model&amp;rsquo;s intermediate layers were not trained to be good premature exits. The number that matters is α measured on &lt;em>your&lt;/em> distribution, not the paper&amp;rsquo;s.&lt;/li>
&lt;li>&lt;strong>Sampling temperature and creative outputs&lt;/strong> lower α just as in classic speculative. At high T, the self-spec speedup erodes faster still because it starts from a lower α.&lt;/li>
&lt;li>&lt;strong>Large batch neutralises it just as it does classic speculative.&lt;/strong> As soon as decode turns compute-bound, the drafts stop being &amp;ldquo;almost free&amp;rdquo;. Self-spec is for low concurrency.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding: the secretary who types ahead&lt;/a> — the direct companion and prerequisite: draft + verify + rejection sampling, the &lt;code>1/(1-α)&lt;/code> ceiling and the speedup formula we reuse here unchanged.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/">The inverted roofline in small models&lt;/a> — why the SLM lives in a memory-bound regime, which is exactly what enables any form of speculative.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/device-native-architectures-fine-grained-moe-pre-attention-router/">Native architectures for device: fine-grained MoE&lt;/a> — where self-spec lands on MoE on device, taking advantage of the router&amp;rsquo;s sparsity.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory&lt;/a> — reusing the KV of the layers shared between draft and verify is what makes the extra memory zero; the cache mechanism is there.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/knowledge-distillation-teaching-small-model-think-like-big-one/">Knowledge distillation&lt;/a> — LayerSkip&amp;rsquo;s early-exit loss is a relative of distillation: it teaches the intermediate layers to predict like the full model.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-pruning-cutting-without-amputating/">Pruning LLM models&lt;/a> — skipping layers is a form of structured pruning &lt;em>at inference&lt;/em>; layer-skip and layer-dropping share a conceptual root.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/">MoE inference: the call centre with 256 specialists&lt;/a> — the persistent memory-bound regime of MoE means self-spec over MoE wins even at medium batch.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">Optimising decode in vLLM&lt;/a> — where speculative methods are configured in practice in production.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mixed-nvidia-intel-environments-llm-inference/">Mixed NVIDIA + Intel environments&lt;/a> — the device/edge case where &amp;ldquo;zero extra model&amp;rdquo; stops being a convenience and becomes the only viable option.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Elhoushi, M., et al. &lt;em>LayerSkip: Enabling Early Exit Inference and Self-Speculative Decoding&lt;/em>. 2024. &lt;a href="https://arxiv.org/abs/2404.16710">https://arxiv.org/abs/2404.16710&lt;/a>&lt;/li>
&lt;li>&lt;em>SWIFT: On-the-Fly Self-Speculative Decoding for LLM Inference Acceleration&lt;/em>. ICLR 2025. &lt;a href="https://openreview.net/forum?id=EKJhH5D5wA">https://openreview.net/forum?id=EKJhH5D5wA&lt;/a>&lt;/li>
&lt;li>&lt;em>CLaSp: In-Context Layer Skip for Self-Speculative Decoding&lt;/em>. 2025. &lt;a href="https://arxiv.org/abs/2505.24196">https://arxiv.org/abs/2505.24196&lt;/a>&lt;/li>
&lt;li>&lt;em>ConfLayers: Confidence-Adaptive Layer Skipping for Self-Speculative Decoding&lt;/em>. 2026. &lt;a href="https://arxiv.org/abs/2604.14612">https://arxiv.org/abs/2604.14612&lt;/a>&lt;/li>
&lt;li>&lt;em>Self-Speculative Decoding for MoE on Device&lt;/em>. ACM Web Conference 2026. &lt;a href="https://doi.org/10.1145/3774904.3792218">https://doi.org/10.1145/3774904.3792218&lt;/a>&lt;/li>
&lt;li>Hugging Face blog. &lt;em>Faster Text Generation with Self-Speculative Decoding&lt;/em>. &lt;a href="https://huggingface.co/blog/layerskip">https://huggingface.co/blog/layerskip&lt;/a>&lt;/li>
&lt;li>Leviathan, Y., Kalman, M., Matias, Y. &lt;em>Fast Inference from Transformers via Speculative Decoding&lt;/em>. ICML 2023. &lt;a href="https://arxiv.org/abs/2211.17192">https://arxiv.org/abs/2211.17192&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>The roofline flips: why optimising small models is a different performance game</title><link>https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/</link><pubDate>Tue, 09 Jun 2026 01:30:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/roofline-flips-optimising-small-models/</guid><description>&lt;blockquote>
&lt;p>This post is the anchor of a mini-series on inference performance in &lt;strong>small models (SLMs)&lt;/strong>. Almost every optimisation post on the blog, &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">decode&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">quantisation&lt;/a>, was written with a 70B in mind. Here I argue that when the model shrinks by an order of magnitude, the &lt;em>roofline&lt;/em> changes regime and several of those intuitions flip. This is not a nuance: it is a different game.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Autoregressive decode in a large LLM is &lt;strong>memory-bandwidth-bound&lt;/strong>: at every step you have to move all the model weights from HBM to the registers of the SMs, and that dominates over the arithmetic operations. The GPU spends its time waiting for bytes, not computing. That single sentence, that decode &amp;ldquo;waits on HBM&amp;rdquo;, is the root of half the optimisations on this blog. In a small model (SLM, say 0.5B–7B) the sentence stops being true in the simple form we used to tell it. At &lt;em>batch&lt;/em> 1 you are still memory-bound with respect to the hardware, yes, but the forward pass is so cheap (moving 6 GB at 1 TB/s takes ~6 ms, not 70 ms) that the &lt;strong>fixed per-step costs&lt;/strong>, kernel launches, Python scheduler overhead, the &lt;code>sampler&lt;/code>, host↔device copies, the &lt;code>synchronize&lt;/code> calls, stop being noise and start eating 20-30 % of the time. The bottleneck moves from HBM to orchestration. The concrete, quantitative consequences: (1) &lt;strong>CUDA graphs&lt;/strong> and cutting scheduler overhead pay off &lt;em>more&lt;/em> in SLMs than in large models; (2) &lt;strong>weight quantisation&lt;/strong> gives &lt;em>less&lt;/em> latency improvement at batch 1 in SLMs, because proportionally there are fewer weights to move relative to activations, KV cache and fixed overhead; (3) batching has more headroom because you cross the &lt;em>ridge point&lt;/em> late; (4) the KV cache can dominate relative memory. All of this comes out of a single model, the roofline, applied with numerical honesty.&lt;/p>
&lt;h2 id="the-analogy-the-pantry-and-the-waiter">The analogy: the pantry and the waiter&lt;/h2>
&lt;p>A kitchen with two very different services.&lt;/p>
&lt;p>&lt;strong>Tasting menu service, one enormous slow dish (the 70B LLM).&lt;/strong> Every dish involves heavy ingredients that the assistant has to fetch from the pantry at the back, several times, carrying boxes. The chef, by contrast, assembles the dish in a moment: the slow part is fetching the ingredients, not cooking them. If you want the service to go faster, you do not hire a more skilful chef: you widen the corridor to the pantry, or make each trip bring more boxes. The pantry is HBM; the trip is memory bandwidth; cooking is compute. The big dish is bound by the pantry.&lt;/p>
&lt;p>&lt;strong>Tapas service, tiny plates (the SLM).&lt;/strong> Now each tapa has two ingredients and is assembled in a second. The trip to the pantry per tapa is very short. But a cost appears that was negligible for the big dish: the &lt;strong>waiter&lt;/strong>. For every tapa, the waiter has to walk to the kitchen, pick up the plate, carry it to the bar, walk back, take the order, call it out. That back and forth is &lt;strong>fixed&lt;/strong>: it costs the same for one tapa as for the enormous dish. When the tapa is assembled in a second, the waiter, not the pantry, is the bottleneck. Shortening the corridor to the pantry (widening HBM, quantising the weights) barely improves the service any more; what improves it is the waiter chaining several orders together without going back to the kitchen each time (CUDA graphs) or serving several tables in one pass (batching).&lt;/p>
&lt;p>The roofline is the tool that says, with numbers, at what point the waiter dominates over the pantry. That boundary is the &lt;em>ridge point&lt;/em>, and the joke in the title is that in SLMs we cross the regime much earlier than large-model intuition led us to believe.&lt;/p>
&lt;h2 id="the-bare-mechanism-what-the-roofline-says">The bare mechanism: what the roofline says&lt;/h2>
&lt;p>The roofline model (Williams, Waterman and Patterson, 2009) starts from a single quantity: &lt;strong>arithmetic intensity&lt;/strong>, which is how many operations you do per byte moved from memory.&lt;/p>
$$\text{AI} = \frac{\text{FLOPs}}{\text{bytes moved from memory}} \quad [\text{FLOP/byte}]$$
&lt;p>The hardware has two ceilings: compute (peak FLOPS) and memory (peak bandwidth × AI). Achievable performance is the minimum of the two:&lt;/p>
$$\text{Perf} = \min\big(\text{peak FLOPS},\; \text{BW} \times \text{AI}\big)$$
&lt;p>Where the two lines cross is the &lt;strong>ridge point&lt;/strong>, the AI above which you stop being limited by memory and start being limited by compute:&lt;/p>
$$\text{AI}_{\text{ridge}} = \frac{\text{peak FLOPS}}{\text{peak BW}}$$
&lt;p>If your kernel has an AI below the ridge, you are &lt;strong>memory-bound&lt;/strong> (the GPU waits for bytes). Above it, &lt;strong>compute-bound&lt;/strong> (the GPU computes flat out and memory is in surplus). What matters is that the ridge point is a property of the hardware, not of the model. Let us look at the numbers, approximate, and I flag them as such because marketing figures mix &lt;em>dense&lt;/em> and &lt;em>sparse&lt;/em>, different dtypes and unrealistic thermal conditions.&lt;/p>
&lt;p>&lt;strong>Generic 4×H100 SXM cluster (320 GB, NVLink, native FP8).&lt;/strong> Per GPU, ~989 TFLOPS BF16 &lt;em>dense&lt;/em> (~1,979 TFLOPS FP8 &lt;em>dense&lt;/em>; the figure with &lt;em>sparsity&lt;/em> is double that and almost never applies to LLM inference). HBM3 ~3.35 TB/s. The ridge in BF16:&lt;/p>
$$\text{AI}_{\text{ridge}}^{\text{H100,BF16}} \approx \frac{989 \times 10^{12}}{3.35 \times 10^{12}} \approx 295 \ \text{FLOP/byte}$$
&lt;p>In FP8 the ridge rises to ~590 FLOP/byte (twice the FLOPS against the same BW). Careful: these are datasheet peak figures; in practice a real kernel rarely gets past 70-80 % of either ceiling.&lt;/p>
&lt;p>&lt;strong>RTX 4090 (24 GB, Ada Lovelace).&lt;/strong> ~330 TFLOPS FP16 with FP16 accumulation via tensor cores (the &amp;ldquo;660 TOPS&amp;rdquo; figure that circulates is with sparsity), and ~1 TB/s of GDDR6X. The ridge:&lt;/p>
$$\text{AI}_{\text{ridge}}^{\text{4090,FP16}} \approx \frac{330 \times 10^{12}}{1.0 \times 10^{12}} \approx 330 \ \text{FLOP/byte}$$
&lt;p>Curiously, the same order as the H100 in BF16: the 4090 has less BW but also fewer FLOPS, and the ratio comes out similar. The ridge sits around &lt;strong>300 FLOP/byte&lt;/strong> in both cases. Hold on to that number.&lt;/p>
&lt;p>And where does decode fall? In decode at &lt;em>batch&lt;/em> 1, each weight is loaded once from HBM and used for a single multiply-accumulate (one token, one row of activation). The AI of the decode GEMM at batch 1 is on the order of &lt;strong>AI ≈ 1-2 FLOP/byte&lt;/strong> (each weight byte takes part in ~2 FLOP). With &lt;em>batch&lt;/em> B, the same weight loaded once serves B rows of activation, so the AI scales roughly linearly:&lt;/p>
$$\text{AI}_{\text{decode}}(B) \approx 2B \ \text{FLOP/byte} \quad (\text{for the weight GEMM part})$$
&lt;p>You cross the ridge when &lt;code>2B ≈ 300&lt;/code>, that is B ≈ 150 as an order of magnitude (in practice earlier, because of attention and overheads, but that is the frame). The clean conclusion: &lt;strong>decode at low batch is always deeply memory-bound&lt;/strong>, nowhere near the ridge. That is why we say &amp;ldquo;decode waits on HBM&amp;rdquo;, and why quantising weights (moving fewer bytes) speeds up decode in a large model almost linearly. So far, this is the standard story from the large-model posts.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 420" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Roofline diagram with ridge point and where the large and small models fall">
&lt;!-- axes -->
&lt;line x1="80" y1="360" x2="740" y2="360" stroke="currentColor" stroke-width="1.6"/>
&lt;line x1="80" y1="360" x2="80" y2="40" stroke="currentColor" stroke-width="1.6"/>
&lt;text x="410" y="400" text-anchor="middle" fill="currentColor" font-size="13" font-weight="600">Arithmetic intensity (FLOP/byte) — log scale&lt;/text>
&lt;text x="28" y="200" text-anchor="middle" fill="currentColor" font-size="13" font-weight="600" transform="rotate(-90 28 200)">Achievable performance (FLOPS)&lt;/text>
&lt;!-- memory ceiling (ramp) -->
&lt;line x1="80" y1="360" x2="470" y2="90" stroke="#1f5fa8" stroke-width="2.6"/>
&lt;!-- compute ceiling (flat) -->
&lt;line x1="470" y1="90" x2="740" y2="90" stroke="#a52a2a" stroke-width="2.6"/>
&lt;!-- ridge point -->
&lt;line x1="470" y1="90" x2="470" y2="360" stroke="currentColor" stroke-width="1" stroke-dasharray="4 3"/>
&lt;circle cx="470" cy="90" r="5" fill="#5a2db0"/>
&lt;text x="470" y="78" text-anchor="middle" fill="#5a2db0" font-size="12" font-weight="600">ridge ≈ 300 FLOP/byte&lt;/text>
&lt;text x="270" y="200" text-anchor="middle" fill="#1f5fa8" font-size="12" font-weight="600" transform="rotate(-35 270 200)">memory-bound (BW × AI)&lt;/text>
&lt;text x="600" y="80" text-anchor="middle" fill="#a52a2a" font-size="12" font-weight="600">compute-bound (peak FLOPS)&lt;/text>
&lt;!-- decode batch 1 point -->
&lt;circle cx="120" cy="332" r="6" fill="#a48000"/>
&lt;text x="120" y="322" text-anchor="middle" fill="#a48000" font-size="11" font-weight="600">decode B=1 · AI≈2&lt;/text>
&lt;!-- large batch decode point -->
&lt;circle cx="360" cy="167" r="6" fill="#2a7a40"/>
&lt;text x="360" y="157" text-anchor="middle" fill="#2a7a40" font-size="11" font-weight="600">decode B≈64 · AI≈128&lt;/text>
&lt;!-- SLM note -->
&lt;rect x="90" y="300" width="250" height="48" fill="#fff4d6" stroke="#a48000" stroke-width="1.2"/>
&lt;text x="100" y="318" fill="#222" font-size="11" font-weight="600">SLM at B=1: the point is here (memory-bound),&lt;/text>
&lt;text x="100" y="333" fill="#222" font-size="11">but the roofline does NOT model the fixed&lt;/text>
&lt;text x="100" y="346" fill="#222" font-size="11">per-step overhead → see second diagram.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-point-of-the-title-why-it-flips-in-slms">The point of the title: why it flips in SLMs&lt;/h2>
&lt;p>The classic roofline has a blind spot that does not matter in large models and is everything in small ones: &lt;strong>it only models the work inside the kernel&lt;/strong>. It assumes the only time is &lt;code>bytes/BW&lt;/code> or &lt;code>FLOPs/FLOPS&lt;/code>. But a real decode step is not just the GEMM. It is a sequence of dozens of kernels (QKV projections, attention, the two MLP layers, normalisations, residuals, the logits head, sampling) and, around each one, there is a &lt;strong>fixed orchestration cost&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Kernel launches&lt;/strong> (&lt;code>kernel launch&lt;/code>): each &lt;code>cudaLaunchKernel&lt;/code> costs on the order of 5-10 µs of CPU/driver overhead, regardless of kernel size. A decode forward with ~30-60 kernels launched sequentially drags ~0.3-0.6 ms just in launching.&lt;/li>
&lt;li>&lt;strong>Python scheduler overhead&lt;/strong>: the &lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">vLLM scheduler&lt;/a> loop prepares metadata, decides which requests enter the step, builds the input tensors. In pure Python this is hundreds of µs to a couple of ms per step, especially at low concurrency where it is not amortised.&lt;/li>
&lt;li>&lt;strong>Sampling and post-processing&lt;/strong>: applying temperature, top-p, penalties, the &lt;code>argmax&lt;/code>/multinomial, copying the token back. Another block of hundreds of µs.&lt;/li>
&lt;li>&lt;strong>Synchronisations and host↔device copies&lt;/strong>: every &lt;code>synchronize&lt;/code> or small copy adds latency that is neither FLOPs nor HBM bytes.&lt;/li>
&lt;/ul>
&lt;p>Let us call the sum of all of that &lt;strong>T_fixed&lt;/strong>, the per-step cost independent of model size, on the order of 1-3 ms in an unoptimised Python stack. The real time of a step is now:&lt;/p>
$$T_{\text{step}} \approx \underbrace{\frac{\text{weight bytes}}{\text{BW}}}_{T_{\text{HBM}} \text{ (memory-bound)}} + \; T_{\text{fixed}}$$
&lt;p>In a 70B BF16, moving ~140 GB at 3.35 TB/s takes ~42 ms of &lt;code>T_HBM&lt;/code>. Against that, a &lt;code>T_fixed&lt;/code> of 1-3 ms is &lt;strong>noise (2-7 %)&lt;/strong>. The classic roofline is right: the model &lt;em>is&lt;/em> memory-bound, full stop. But in a 3B BF16, &lt;code>T_HBM&lt;/code> drops to a few ms, and suddenly &lt;code>T_fixed&lt;/code> is the same order as &lt;code>T_HBM&lt;/code>. The bottleneck stops being the pantry and becomes the waiter. This is the flip in the title, and four counterintuitive consequences follow from it:&lt;/p>
&lt;p>&lt;strong>(a) At batch 1 you are still memory-bound &lt;em>with respect to the hardware&lt;/em>.&lt;/strong> The AI has not changed: it is still ~2 FLOP/byte, below the ridge. Anyone reading only the roofline will conclude &amp;ldquo;memory-bound, quantise the weights&amp;rdquo;. That is true but &lt;strong>incomplete&lt;/strong>: the roofline does not see &lt;code>T_fixed&lt;/code>.&lt;/p>
&lt;p>&lt;strong>(b) Fixed costs become a huge fraction of the step.&lt;/strong> This is the central point. In the 70B, &lt;code>T_fixed / T_step ≈ 5 %&lt;/code>. In the 3B it can be 20-30 %. The effective bottleneck of the 3B is half HBM, half orchestration.&lt;/p>
&lt;p>&lt;strong>(c) That is why CUDA graphs and cutting scheduler overhead pay off MORE in SLMs.&lt;/strong> A &lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">CUDA graph&lt;/a> captures the whole kernel sequence of the step and relaunches it with a single &lt;code>cudaGraphLaunch&lt;/code>, removing almost all of the per-kernel launch overhead and much of the per-iteration Python scheduler work. In the 70B, shaving 0.5 ms off a 42 ms step is a +1 % you barely notice. In the 3B, shaving those same 0.5 ms off a ~7 ms step is +7 %, and if you take away nearly all of &lt;code>T_fixed&lt;/code> you can gain 20-30 %. The same optimisation, a different prize, because the denominator changed.&lt;/p>
&lt;p>&lt;strong>(d) Weight quantisation gives LESS latency improvement at batch 1 in SLMs.&lt;/strong> This is the most counterintuitive one. In the 70B, &lt;code>T_HBM&lt;/code> is almost the whole step; going from BF16 to INT4 quadruples the effective weight bandwidth and almost quadruples decode speed. In the 3B, &lt;code>T_HBM&lt;/code> is only &lt;em>part&lt;/em> of the step (the rest is &lt;code>T_fixed&lt;/code> + attention + KV). By Amdahl&amp;rsquo;s law, if the weights are 60 % of the step and you speed them up 4×, the total step improves only &lt;code>1/(0.4 + 0.6/4) = 1.8×&lt;/code>, not 4×. And proportionally there are fewer weights to move relative to activations, KV cache and fixed overhead. Aggressive quantisation in SLMs does help, yes, but &lt;strong>not through raw latency at batch 1&lt;/strong>, where it gives diminishing returns, rather through capacity and concurrency (we will see that at the end).&lt;/p>
&lt;p>&lt;strong>(e) The KV cache can dominate relative memory.&lt;/strong> With 6 GB of weights (3B BF16), a single long-context session can approach that order of magnitude in KV cache. In a 70B (140 GB of weights) the KV is proportionally small until high concurrency. In SLMs the VRAM balance tips towards KV much earlier (the detail is in &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>), and that changes which memory optimisation is the lever.&lt;/p>
&lt;h2 id="the-maths-that-matter-a-3b-on-a-4090">The maths that matter: a 3B on a 4090&lt;/h2>
&lt;p>Let us do the whole calculation, which is where the flip shows up without rhetoric.&lt;/p>
&lt;p>&lt;strong>Model:&lt;/strong> 3B parameters, BF16 → 2 bytes/param → ~6 GB of weights. &lt;strong>Hardware:&lt;/strong> RTX 4090, BW ≈ 1 TB/s.&lt;/p>
&lt;p>&lt;strong>Memory-bound ceiling for decode (batch 1).&lt;/strong> Each token requires loading the 6 GB once:&lt;/p>
$$T_{\text{HBM}} = \frac{6 \times 10^{9} \ \text{bytes}}{1 \times 10^{12} \ \text{bytes/s}} = 6 \times 10^{-3}\ \text{s} = 6\ \text{ms/token}$$
$$\text{Ceiling} = \frac{1}{6\ \text{ms}} \approx 166\ \text{tok/s}$$
&lt;p>That is the theoretical memory-bound ceiling: 166 tok/s, assuming that moving the weights is the only cost. The classic roofline would stop here and say &amp;ldquo;166 tok/s, go get more BW or quantise&amp;rdquo;.&lt;/p>
&lt;p>&lt;strong>Now the fixed overhead.&lt;/strong> Let us put &lt;code>T_fixed ≈ 2 ms/step&lt;/code> (a reasonable value for a Python scheduler + ~40 kernels launched + sampling, without CUDA graphs). The real step:&lt;/p>
$$T_{\text{step}} = T_{\text{HBM}} + T_{\text{fixed}} = 6 + 2 = 8\ \text{ms} \;\Rightarrow\; \frac{1}{8\ \text{ms}} = 125\ \text{tok/s}$$
&lt;p>The overhead has eaten &lt;strong>41 tok/s out of the theoretical 166&lt;/strong>: &lt;code>T_fixed&lt;/code> is &lt;strong>25 % of the step&lt;/strong> (2 of 8 ms). Compare with the 70B: &lt;code>T_HBM ≈ 42 ms&lt;/code>, &lt;code>T_step ≈ 44 ms&lt;/code>, &lt;code>T_fixed&lt;/code> is 4.5 %. &lt;strong>Same absolute overhead, relative impact 5-6× larger in the SLM.&lt;/strong>&lt;/p>
&lt;p>&lt;strong>What happens if you apply CUDA graphs&lt;/strong> and take away, say, 1.5 of the 2 ms of &lt;code>T_fixed&lt;/code>:&lt;/p>
$$T_{\text{step}}^{\text{graphs}} = 6 + 0.5 = 6.5\ \text{ms} \;\Rightarrow\; 154\ \text{tok/s}$$
&lt;p>From 125 to 154 tok/s: &lt;strong>+23 %&lt;/strong> from orchestration alone, without touching the model or the memory hardware. In the 70B the same intervention would have gone from 44 to 42.5 ms, +3.5 %. There it is, in two numbers: &amp;ldquo;a different game&amp;rdquo;.&lt;/p>
&lt;p>&lt;strong>What happens if you quantise the weights to INT4&lt;/strong> (1.5 GB instead of 6 GB), with &lt;code>T_fixed&lt;/code> still at 2 ms:&lt;/p>
$$T_{\text{HBM}}^{\text{INT4}} = \frac{1.5 \times 10^{9}}{1 \times 10^{12}} = 1.5\ \text{ms};\quad T_{\text{step}} = 1.5 + 2 = 3.5\ \text{ms} \;\Rightarrow\; 285\ \text{tok/s}$$
&lt;p>The 4× weight quantisation did &lt;strong>not&lt;/strong> give 4× in latency: it went from 125 to 285 tok/s, a 2.3×, because the 2 ms of &lt;code>T_fixed&lt;/code> now dominates (it is 57 % of the step). In the 70B, quantising to INT4 gives almost the full 4× because &lt;code>T_fixed&lt;/code> is still noise. &lt;strong>The same quantisation delivers twice the speedup on the large model as on the small one&lt;/strong>, at batch 1. And if you also apply CUDA graphs on top of INT4 (&lt;code>T_fixed → 0.5 ms&lt;/code>): &lt;code>1.5 + 0.5 = 2 ms → 500 tok/s&lt;/code>. The order of the optimisations matters: in SLMs, &lt;strong>attacking &lt;code>T_fixed&lt;/code> first&lt;/strong> unlocks the rest.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Configuration (3B, 4090, batch 1)&lt;/th>
&lt;th>T_HBM&lt;/th>
&lt;th>T_fixed&lt;/th>
&lt;th>T_step&lt;/th>
&lt;th>tok/s&lt;/th>
&lt;th>vs. baseline&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>BF16, no graphs (baseline)&lt;/td>
&lt;td>6.0 ms&lt;/td>
&lt;td>2.0 ms&lt;/td>
&lt;td>8.0 ms&lt;/td>
&lt;td>125&lt;/td>
&lt;td>1.00×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>BF16 + CUDA graphs&lt;/td>
&lt;td>6.0 ms&lt;/td>
&lt;td>0.5 ms&lt;/td>
&lt;td>6.5 ms&lt;/td>
&lt;td>154&lt;/td>
&lt;td>1.23×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>INT4, no graphs&lt;/td>
&lt;td>1.5 ms&lt;/td>
&lt;td>2.0 ms&lt;/td>
&lt;td>3.5 ms&lt;/td>
&lt;td>285&lt;/td>
&lt;td>2.28×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>INT4 + CUDA graphs&lt;/td>
&lt;td>1.5 ms&lt;/td>
&lt;td>0.5 ms&lt;/td>
&lt;td>2.0 ms&lt;/td>
&lt;td>500&lt;/td>
&lt;td>4.00×&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;em>(Illustrative figures with &lt;code>T_fixed&lt;/code> rounded; the point is the pattern, not the decimal. The real &lt;code>T_fixed&lt;/code> depends on the stack, the PyTorch/CUDA version and whether there is tensor parallelism. Measure it in your own setup before believing any row.)&lt;/em>&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 300" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Per-step time breakdown: large model versus small model">
&lt;text x="390" y="24" text-anchor="middle" fill="currentColor" font-size="13" font-weight="600">Decode time breakdown per step (batch 1)&lt;/text>
&lt;!-- 70B -->
&lt;text x="40" y="78" fill="currentColor" font-size="12" font-weight="600">70B BF16&lt;/text>
&lt;text x="40" y="94" fill="currentColor" font-size="11">step ≈ 44 ms&lt;/text>
&lt;rect x="160" y="60" width="560" height="40" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4"/>
&lt;rect x="704" y="60" width="16" height="40" fill="#f6caca" stroke="#a52a2a" stroke-width="1.4"/>
&lt;text x="440" y="85" text-anchor="middle" fill="#1f5fa8" font-size="12" font-weight="600">T_HBM (weights from HBM) ≈ 95 %&lt;/text>
&lt;text x="712" y="118" text-anchor="middle" fill="#a52a2a" font-size="10" font-weight="600">T_fixed ~5%&lt;/text>
&lt;!-- 3B -->
&lt;text x="40" y="190" fill="currentColor" font-size="12" font-weight="600">3B BF16&lt;/text>
&lt;text x="40" y="206" fill="currentColor" font-size="11">step ≈ 8 ms&lt;/text>
&lt;rect x="160" y="172" width="420" height="40" fill="#d4ecff" stroke="#1f5fa8" stroke-width="1.4"/>
&lt;rect x="580" y="172" width="140" height="40" fill="#f6caca" stroke="#a52a2a" stroke-width="1.4"/>
&lt;text x="370" y="197" text-anchor="middle" fill="#1f5fa8" font-size="12" font-weight="600">T_HBM ≈ 75 %&lt;/text>
&lt;text x="650" y="197" text-anchor="middle" fill="#a52a2a" font-size="11" font-weight="600">T_fixed ≈ 25 %&lt;/text>
&lt;!-- note -->
&lt;text x="160" y="252" fill="currentColor" font-size="11">Same absolute T_fixed (~2 ms): noise in the 70B, a quarter of the step in the 3B.&lt;/text>
&lt;text x="160" y="270" fill="currentColor" font-size="11">CUDA graphs attack the red band → disproportionate impact in the SLM.&lt;/text>
&lt;text x="160" y="288" fill="#5a2db0" font-size="11" font-weight="600">The roofline only models the blue band. The SLM bottleneck lives in the red one.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="implications-per-optimisation">Implications per optimisation&lt;/h2>
&lt;p>With the model in hand, the blog&amp;rsquo;s levers reorder themselves once the regime changes.&lt;/p>
&lt;p>&lt;strong>Batching: far more headroom in SLMs.&lt;/strong> Remember that you cross the ridge at &lt;code>B ≈ ridge/2 ≈ 150&lt;/code> as an order of magnitude. In a large model, VRAM runs out long before you saturate compute (weights + KV do not let you reach batch 150). In an SLM the weights take up little space, so you can fit large batches in VRAM and stay memory-bound over a much wider range: the &lt;code>T_HBM&lt;/code> of the weights is amortised across the B requests (you load them once, they serve B), so aggregate throughput per GPU rises almost linearly with B until very high values. This is exactly the opposite of the 70B&amp;rsquo;s fear of saturating compute. In SLMs, &lt;strong>batching is the throughput lever par excellence&lt;/strong> because you saturate compute late; the &lt;a href="https://blog.lo0.es/en/posts/vllm-batch-sizing-two-hour-grid-search/">batch grid search in vLLM&lt;/a> has a much wider plateau of good behaviour. Careful: batching improves &lt;em>throughput&lt;/em>, not per-request &lt;em>latency&lt;/em>; for single-stream latency the prize is in &lt;code>T_fixed&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Speculative decoding: a different crossover point.&lt;/strong> &lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative&lt;/a> wins when verifying γ tokens is &amp;ldquo;almost free&amp;rdquo; because you are memory-bound. In an SLM the target is already cheap, so the draft has to be tiny for &lt;code>c = T_draft/T_target&lt;/code> to stay small, and the draft&amp;rsquo;s own &lt;code>T_fixed&lt;/code> (launching its kernels) bites harder. The crossover to compute-bound with batching also arrives earlier in absolute terms of tok/s served. The variant that fits best here avoids a separate draft: &lt;a href="https://blog.lo0.es/en/posts/self-speculative-decoding-model-gets-ahead-of-itself/">self-speculative / early-exit&lt;/a> reuses early layers of the model itself and saves the &lt;code>T_fixed&lt;/code> of orchestrating two models.&lt;/p>
&lt;p>&lt;strong>Quantisation: it helps for capacity, not for latency at batch 1.&lt;/strong> As the table showed, INT4 in an SLM at batch 1 gives diminishing returns in latency. Its real prize in SLMs is &lt;strong>capacity&lt;/strong>: weights 4× smaller free VRAM for more KV cache → more concurrency, and it is at high concurrency (aggregate throughput) that saving bytes pays off again. &lt;a href="https://blog.lo0.es/en/posts/aggressive-quantisation-4-bit-to-ternary/">Aggressive sub-4-bit and ternary quantisation&lt;/a> takes this to the extreme: in SLMs it makes sense above all for fitting more sessions per GPU, not for lowering the latency of a single one. And it is worth remembering (see &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">quantisation&lt;/a>) that at batch 1 the &lt;code>dequantize&lt;/code> adds compute work which, in a regime already grazed by &lt;code>T_fixed&lt;/code>, is not always free.&lt;/p>
&lt;p>&lt;strong>Architecture: fine-grained MoE changes which bytes you move.&lt;/strong> A &lt;a href="https://blog.lo0.es/en/posts/device-native-architectures-fine-grained-moe-pre-attention-router/">fine-grained device-native MoE&lt;/a> activates few parameters per token, so &lt;code>T_HBM&lt;/code> drops relative to a dense model of the same total size, but the &lt;code>T_fixed&lt;/code> fraction rises even further, and the router adds its own fixed overhead. It is the SLM regime taken to its limit: almost the whole game is played in orchestration.&lt;/p>
&lt;p>&lt;strong>Scheduler and CUDA graphs first.&lt;/strong> The operational conclusion, inverted with respect to the large-model posts: in SLMs, &lt;strong>before touching the model, kill &lt;code>T_fixed&lt;/code>&lt;/strong>. CUDA graphs (see &lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">SMs, streams and graphs&lt;/a>), a &lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">vLLM scheduler&lt;/a> with its Python part minimised or compiled, and kernel persistence are the first-order levers. In a 70B they would be marginal polish; in a 3B they are half the available speedup.&lt;/p>
&lt;h2 id="applied-to-on-premise-hardware">Applied to on-premise hardware&lt;/h2>
&lt;p>&lt;strong>On an RTX 4090 (24 GB, Ada Lovelace).&lt;/strong> This is the scenario where the flip is most visible, because the 4090 has ~1 TB/s (a third of the H100) but &lt;code>T_fixed&lt;/code> is the same in absolute terms. A 3B BF16 without CUDA graphs leaves ~125 tok/s on the table when the memory-bound ceiling is 166; enabling graphs and cleaning up the scheduler recovers most of that. The 4090 has plenty of VRAM room for an SLM, so the bottleneck is almost never total memory but orchestration and, at high concurrency, the KV cache. Rule of thumb: on a 4090 with an SLM, profile the per-step overhead first (Nsight Systems on the gap between kernels) before you quantise.&lt;/p>
&lt;p>&lt;strong>On a generic 4×H100 SXM cluster (320 GB, NVLink, native FP8).&lt;/strong> The H100 has 3.35 TB/s, so the &lt;code>T_HBM&lt;/code> of an SLM is even smaller (a 3B FP8 is ~3 GB → ~0.9 ms) and &lt;code>T_fixed&lt;/code> dominates even earlier: a badly orchestrated SLM on an H100 can spend more time in the Python scheduler than moving weights. Serving a single single-stream SLM on an H100 is close to a waste; the right mode is &lt;strong>aggressive batching&lt;/strong> (you saturate compute late, so you push large batches through and per-GPU throughput takes off) or multiplexing many SLMs/sessions per GPU via MPS/MIG. This connects with &lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">capacity planning&lt;/a>: for SLMs the capacity calculation is governed by concurrency and KV cache, not by the weights. And with the dilemma of &lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">one big versus N small&lt;/a>: replicating SLMs makes sense precisely because each replica saturates compute late and TP brings nothing (the model already fits; TP would only add communication &lt;code>T_fixed&lt;/code>).&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>The exact measured &lt;code>T_fixed&lt;/code>&lt;/strong>, kernel by kernel, with Nsight Systems: how much is launch, how much scheduler, how much sampling. That is the content of the next post in the series.&lt;/li>
&lt;li>&lt;strong>&lt;code>torch.compile&lt;/code> / partial captures&lt;/strong>: alternatives and complements to CUDA graphs when there is dynamic control flow.&lt;/li>
&lt;li>&lt;strong>The prefill regime in SLMs&lt;/strong>: prefill is compute-bound even in small models (it processes many tokens at once, high AI), so its roofline is the opposite of decode&amp;rsquo;s; see &lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">prefill&lt;/a>.&lt;/li>
&lt;li>&lt;strong>Attention and KV as the second term of &lt;code>T_HBM&lt;/code>&lt;/strong>: here we have folded them in implicitly; the fine-grained breakdown of attention (which scales with sequence length, not with the weights) deserves its own treatment.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory of inference&lt;/a> — the memory-bound nature of decode is born in the KV cache; in SLMs the KV comes to dominate relative VRAM earlier than in large models.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-batch-sizing-two-hour-grid-search/">Batch sizing grid search in vLLM&lt;/a> — the plateau of good batch sizes is much wider in SLMs because you cross the ridge late; that post gives the empirical method.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">Optimising decode in vLLM&lt;/a> — the concrete flags (CUDA graphs, eager vs captured) whose impact this post reorders for the SLM case.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">Optimising prefill in vLLM&lt;/a> — the compute-bound flip side of the roofline: prefill already lives above the ridge even in small models.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">SMs, CUDA streams and CUDA graphs&lt;/a> — the mechanism that attacks &lt;code>T_fixed&lt;/code>; here we explain why its prize is disproportionate in SLMs.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">The vLLM scheduler step&lt;/a> — much of &lt;code>T_fixed&lt;/code> lives in this Python loop; in SLMs minimising it is a first-order lever.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation for inference&lt;/a> — why weight quantisation yields less latency at batch 1 in SLMs (Amdahl&amp;rsquo;s law over &lt;code>T_HBM&lt;/code>) and more in capacity.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding: fundamentals&lt;/a> — the memory/compute crossover point shifts in SLMs, changing when speculative pays off.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise inference&lt;/a> — for SLMs, capacity is governed by concurrency and KV, not by the weights; that post gives the formulas.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/one-big-replica-or-many-small-ones-platform-decision/">One big versus N small&lt;/a> — replicating SLMs beats TP because each replica saturates compute late and TP only adds communication &lt;code>T_fixed&lt;/code>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/self-speculative-decoding-model-gets-ahead-of-itself/">Self-speculative decoding / early-exit&lt;/a> — sibling in the series: speeding up without a separate draft, avoiding the &lt;code>T_fixed&lt;/code> of orchestrating two models, a natural fit in SLMs.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/device-native-architectures-fine-grained-moe-pre-attention-router/">Fine-grained device-native MoE&lt;/a> — sibling in the series: the SLM regime taken to its limit, where the router and orchestration dominate over &lt;code>T_HBM&lt;/code>.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/aggressive-quantisation-4-bit-to-ternary/">Aggressive sub-4-bit and ternary quantisation&lt;/a> — sibling in the series: why in SLMs sub-4-bit pays off mostly in capacity/concurrency, not in latency at batch 1.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Williams, S., Waterman, A., Patterson, D. &lt;em>Roofline: An Insightful Visual Performance Model for Multicore Architectures&lt;/em>. Communications of the ACM, 52(4), 2009. &lt;a href="https://doi.org/10.1145/1498765.1498785">https://doi.org/10.1145/1498765.1498785&lt;/a>&lt;/li>
&lt;li>&lt;em>Mind the Memory Gap: Unveiling GPU Bottlenecks in Large-Batch LLM Inference&lt;/em>. arXiv:2503.08311, 2025. &lt;a href="https://arxiv.org/abs/2503.08311">https://arxiv.org/abs/2503.08311&lt;/a>&lt;/li>
&lt;li>Databricks. &lt;em>LLM Inference Performance Engineering: Best Practices&lt;/em>. &lt;a href="https://www.databricks.com/blog/llm-inference-performance-engineering-best-practices">https://www.databricks.com/blog/llm-inference-performance-engineering-best-practices&lt;/a>&lt;/li>
&lt;li>NVIDIA. &lt;em>NVIDIA H100 Tensor Core GPU Datasheet&lt;/em>. &lt;a href="https://resources.nvidia.com/en-us-tensor-core/nvidia-tensor-core-gpu-datasheet">https://resources.nvidia.com/en-us-tensor-core/nvidia-tensor-core-gpu-datasheet&lt;/a>&lt;/li>
&lt;li>NVIDIA. &lt;em>GeForce RTX 4090 — product specifications&lt;/em> (Ada Lovelace tensor core figures; treat as approximate, they mix dense/sparse).&lt;/li>
&lt;li>Yuan, Z. et al. &lt;em>LLM Inference Unveiled: Survey and Roofline Model Insights&lt;/em>. arXiv:2402.16363, 2024 — application of the roofline specifically to LLM inference.&lt;/li>
&lt;/ul></description></item><item><title>Instrumenting vLLM with OTel: measuring what the optimisations really do</title><link>https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/</link><pubDate>Fri, 05 Jun 2026 04:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>vLLM exposes two independent observability signals: Prometheus metrics (pull, aggregated) and OTel traces (push, per request). To measure whether chunked prefill, prefix caching, speculative decoding, FP8 KV cache and concurrency are really working, you need both. The metrics tell you what is happening in the system; the traces tell you why one specific request was slow. This article configures the complete pipeline and maps each optimisation to its diagnostic metric.&lt;/p>
&lt;hr>
&lt;h2 id="the-analogy">The analogy&lt;/h2>
&lt;p>A Formula 1 driver and his telemetry engineers. The driver feels that the car &amp;ldquo;goes odd&amp;rdquo; in turn 3, but without the sensor data he does not know whether it is the rear tyre, the differential or the fuel. The engineers see exactly what happened in that turn, temperature per sensor, lateral load per millisecond.&lt;/p>
&lt;p>vLLM without OTel is the driver on his own: you notice that the TTFT &amp;ldquo;looks high&amp;rdquo; but you do not know whether it is a long prefill, a prefix cache miss, or a KV cache preemption. With OTel you have the full picture: the metrics are the race summary (aggregated), the traces are the lap-by-lap telemetry (per-request).&lt;/p>
&lt;hr>
&lt;h2 id="architecture-of-the-two-signals">Architecture of the two signals&lt;/h2>
&lt;p>vLLM deliberately separates its two observability channels:&lt;/p>
&lt;pre tabindex="0">&lt;code> ┌─────────────────────────────┐
│ vLLM │
│ │
requests ────────────►│ SchedulerStats │
│ │ │
│ ├─► Prometheus /metrics │◄── scrape (pull)
│ │ (aggregated, ~15s) │
│ │ │
│ └─► OTLP exporter │──► push (per request)
│ (spans, immediate) │
└─────────────────────────────┘
│ OTLP gRPC/HTTP
▼
┌─────────────────────┐
│ OTel Collector │
│ │
│ receivers: │
│ otlp (traces) │
│ prometheus │
│ exporters: │
│ langfuse │
│ prometheus remote│
│ loki (logs) │
└─────────────────────┘
&lt;/code>&lt;/pre>&lt;p>&lt;strong>Prometheus pull&lt;/strong> exposes metrics with the &lt;code>vllm:&lt;/code> prefix at &lt;code>:8000/metrics&lt;/code>. They are histograms, gauges and counters updated on every scheduler iteration. Good for dashboards and alerts about the system as a whole.&lt;/p>
&lt;p>&lt;strong>OTLP push&lt;/strong> sends one span per request, immediately on completion. It contains attributes of the specific request: prompt tokens, generated tokens, TTFT, model. Good for debugging anomalous requests and for Langfuse.&lt;/p>
&lt;hr>
&lt;h2 id="installation-and-basic-configuration">Installation and basic configuration&lt;/h2>
&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"># vLLM with OTel support&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">pip install &lt;span class="s2">&amp;#34;vllm[otel]&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Installs: opentelemetry-sdk, opentelemetry-api,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># opentelemetry-exporter-otlp, opentelemetry-semantic-conventions-ai&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&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"># Start vLLM with OTel enabled&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">export&lt;/span> &lt;span class="nv">OTEL_SERVICE_NAME&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;vllm-production&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">export&lt;/span> &lt;span class="nv">OTEL_EXPORTER_OTLP_TRACES_ENDPOINT&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;http://otel-collector:4317&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">export&lt;/span> &lt;span class="nv">OTEL_EXPORTER_OTLP_TRACES_PROTOCOL&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;grpc&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">export&lt;/span> &lt;span class="nv">OTEL_EXPORTER_OTLP_TRACES_INSECURE&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;true&amp;#34;&lt;/span> &lt;span class="c1"># on an internal network without TLS&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve Qwen/Qwen2.5-7B-Instruct &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --otlp-traces-endpoint http://otel-collector:4317 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-chunked-prefill &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-prefix-caching &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --kv-cache-dtype fp8 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --speculative-model Qwen/Qwen2.5-0.5B-Instruct &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --num-speculative-tokens &lt;span class="m">5&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The Prometheus metrics need no extra configuration: they are always at &lt;code>:8000/metrics&lt;/code>.&lt;/p>
&lt;hr>
&lt;h2 id="otel-collector-minimum-configuration">OTel Collector: minimum configuration&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># otel-collector-config.yaml&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">receivers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">otlp&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">protocols&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">grpc&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.0.0.0&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="m">4317&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">http&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.0.0.0&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="m">4318&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prometheus&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">config&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">scrape_configs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">job_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">scrape_interval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">15s&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">static_configs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">targets&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;vllm:8000&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">processors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">batch&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">timeout&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">5s&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resource&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">attributes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">deployment.environment&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">value&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;production&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">action&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">upsert&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">exporters&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">otlphttp/langfuse&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;https://cloud.langfuse.com/api/public/otel&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">headers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">Authorization&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;Basic &amp;lt;base64(pk:sk)&amp;gt;&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prometheusremotewrite&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">endpoint&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;http://prometheus:9090/api/v1/write&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">service&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">pipelines&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">traces&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">receivers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">otlp]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">processors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">batch, resource]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">exporters&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">otlphttp/langfuse]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metrics&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">receivers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">prometheus]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">processors&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">batch]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">exporters&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">prometheusremotewrite]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="the-five-metrics-that-matter">The five metrics that matter&lt;/h2>
&lt;p>Every optimisation has a primary diagnostic signal. If the metric does not move as expected after enabling the flag, there is a configuration or load problem.&lt;/p>
&lt;h3 id="1-chunked-prefill--vllmtime_to_first_token_seconds">1. Chunked prefill → &lt;code>vllm:time_to_first_token_seconds&lt;/code>&lt;/h3>
&lt;p>Chunked prefill should reduce the variance of the TTFT, not necessarily the median. Its main goal is for the high percentiles (p99) to come down even if the p50 rises slightly.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># TTFT p50 and p99 — expect p99 to drop with chunked prefill enabled&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kr">histogram_quantile&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="mf">0.50&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">time_to_first_token_seconds_bucket&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">))&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kr">histogram_quantile&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="mf">0.99&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">time_to_first_token_seconds_bucket&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">))&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Sign that it works:&lt;/strong> the p99/p50 ratio approaches 1. Without chunked prefill, one request&amp;rsquo;s long prefill blocks all the others and the p99 rises disproportionately.&lt;/p>
&lt;p>&lt;strong>Sign of a problem:&lt;/strong> p50 and p99 both rise. A &lt;code>--max-num-batched-tokens&lt;/code> that is too low makes the chunks so small that the prefill takes many steps to complete even though the other requests are not blocked. Raise the budget.&lt;/p>
&lt;p>It is also useful to look at the &lt;code>llm.usage.prompt_tokens&lt;/code> attribute per span in the OTel traces: requests with many prompt tokens should have a proportional TTFT, not a blocking one.&lt;/p>
&lt;hr>
&lt;h3 id="2-prefix-caching--vllmgpu_prefix_cache_hit_rate">2. Prefix caching → &lt;code>vllm:gpu_prefix_cache_hit_rate&lt;/code>&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Prefix cache hit rate on the GPU (0.0–1.0)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">gpu_prefix_cache_hit_rate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Evolution over a 5-minute window&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">gpu_prefix_cache_hit_rate&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Sign that it works:&lt;/strong> a sustained hit rate &amp;gt; 0.5 in workloads with a shared system prompt. With a hit rate of 0.8, 80% of requests skip the prefix prefill; the TTFT of those requests falls to the cost of the variable suffix alone.&lt;/p>
&lt;p>&lt;strong>Sign of a problem: a hit rate close to zero&lt;/strong> despite system prompts that &amp;ldquo;look&amp;rdquo; identical. The usual causes:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ❌ This breaks the prefix caching hash:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">system_prompt&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Today is &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">datetime&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">now&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">. You are an assistant...&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ^^^ a different timestamp on every request&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># ✅ The system prompt has to be identical byte for byte:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">system_prompt&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;You are an assistant specialising in infrastructure...&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Any variation in the system prompt, timestamps, session IDs, interpolated prompt versions, produces a different hash and a cache miss. The OTel traces do not expose the hit/miss per request directly in the current implementation; use them to correlate a high &lt;code>llm.usage.prompt_tokens&lt;/code> with a high TTFT in the same request.&lt;/p>
&lt;hr>
&lt;h3 id="3-speculative-decoding--vllmspec_decode_draft_acceptance_rate">3. Speculative decoding → &lt;code>vllm:spec_decode_draft_acceptance_rate&lt;/code>&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Acceptance rate of the draft model (0.0–1.0)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">spec_decode_draft_acceptance_rate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Estimated effective speedup (with k=5 proposed tokens)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># speedup ≈ (1 + α·k) / (1 + overhead_draft)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Simplified: if α=0.75 and k=5 → speedup ≈ 1 + 0.75×5×(1 - cost_ratio) &lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Sign that it works:&lt;/strong> a sustained acceptance rate &amp;gt; 0.70. Below 0.60, the draft model&amp;rsquo;s overhead outweighs the gain from the accepted tokens and speculative decoding is counterproductive.&lt;/p>
&lt;p>&lt;strong>Sign of a problem:&lt;/strong> an acceptance rate &amp;lt; 0.50. The usual causes:&lt;/p>
&lt;ul>
&lt;li>A drafter from a different family than the verifier (for instance, Mistral 0.5B as the draft for Qwen 7B).&lt;/li>
&lt;li>A high generation temperature (&amp;gt;0.9): the higher the temperature, the more the draft&amp;rsquo;s distribution diverges from the verifier&amp;rsquo;s.&lt;/li>
&lt;li>A very large batch: at high concurrency, the draft can fall outside the domain of the current requests.&lt;/li>
&lt;/ul>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Alert: inefficient speculative decoding&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">ALERT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nv">SpecDecodeInefficient&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">IF&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">spec_decode_draft_acceptance_rate&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mf">0.60&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">FOR&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">LABELS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nl">severity&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">warning&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">ANNOTATIONS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nl">summary&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">Draft acceptance rate low: disable spec decode or change the drafter&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>In the OTel traces, the complete span of the request includes the total decode time. Without an acceptance rate per span, the way to detect that spec decode is working is to compare the total decode time divided by the tokens generated: if it is significantly lower than the baseline without spec decode, it is helping.&lt;/p>
&lt;hr>
&lt;h3 id="4-fp8-kv-cache-and-concurrency--vllmgpu_cache_usage_perc--vllmnum_preemptions_total">4. FP8 KV cache and concurrency → &lt;code>vllm:gpu_cache_usage_perc&lt;/code> + &lt;code>vllm:num_preemptions_total&lt;/code>&lt;/h3>
&lt;p>These two metrics are the two sides of KV cache management:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># KV cache utilisation (0.0–1.0)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># With FP8 enabled, the same hardware supports more requests before saturating&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">gpu_cache_usage_perc&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Cumulative preemptions (counter)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Rises when vLLM cannot fit more requests and pauses one&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">num_preemptions_total&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">5m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Sign that FP8 works:&lt;/strong> with &lt;code>--kv-cache-dtype fp8&lt;/code> enabled, &lt;code>gpu_cache_usage_perc&lt;/code> should saturate at concurrency levels around 2× higher than the BF16 baseline before &lt;code>num_preemptions_total&lt;/code> starts to grow.&lt;/p>
&lt;p>&lt;strong>Sign of a problem:&lt;/strong> &lt;code>num_preemptions_total&lt;/code> growing at rates &amp;gt; 1/minute with &lt;code>gpu_cache_usage_perc&lt;/code> below 0.90. It indicates that &lt;code>max-num-seqs&lt;/code> is too high for the available KV cache: requests enter the system but there are no free blocks to assign to them. Lower &lt;code>max-num-seqs&lt;/code> or reduce &lt;code>max-model-len&lt;/code>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Alert: KV cache saturated with preemptions&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">ALERT&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nv">KVCacheSaturated&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">IF&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kr">rate&lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">num_preemptions_total&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s">2m&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mf">0.5&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">AND&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">gpu_cache_usage_perc&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">&amp;gt;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="mf">0.85&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">FOR&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s">3m&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">LABELS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nl">severity&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">critical&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nv">ANNOTATIONS&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nl">summary&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="s">KV cache saturated: lower max-num-seqs or max-model-len&lt;/span>&lt;span class="p">&amp;#34;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The impact of FP8 on capacity can be quantified:&lt;/p>
$$\Delta\text{capacity} = \frac{\text{max tokens FP8}}{\text{max tokens BF16}} \approx 2\times$$
&lt;p>Measure before and after enabling &lt;code>--kv-cache-dtype fp8&lt;/code>: the level of &lt;code>gpu_cache_usage_perc&lt;/code> for a given concurrency should fall by half.&lt;/p>
&lt;hr>
&lt;h3 id="5-effective-concurrency--vllmnum_running_seqs--vllmnum_waiting_seqs">5. Effective concurrency → &lt;code>vllm:num_running_seqs&lt;/code> + &lt;code>vllm:num_waiting_seqs&lt;/code>&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-promql" data-lang="promql">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Requests active in the engine (decode + prefill in progress)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">num_running_seqs&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Requests queued waiting for a slot&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">num_waiting_seqs&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c1"># Waiting ratio: if &amp;gt; 0.2 sustained, there is a concurrency bottleneck&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">num_waiting_seqs&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">(&lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">num_running_seqs&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="nv">vllm&lt;/span>&lt;span class="err">:&lt;/span>&lt;span class="nv">num_waiting_seqs&lt;/span>&lt;span class="o">)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Healthy sign:&lt;/strong> &lt;code>num_running_seqs&lt;/code> stable close to the configured &lt;code>--max-num-seqs&lt;/code> value, &lt;code>num_waiting_seqs&lt;/code> low (&amp;lt; 10% of running).&lt;/p>
&lt;p>&lt;strong>Sign of a problem:&lt;/strong> a high &lt;code>num_waiting_seqs&lt;/code> with a low &lt;code>gpu_cache_usage_perc&lt;/code>. It indicates that the scheduler is not filling the available slots because &lt;code>max-num-batched-tokens&lt;/code> is too low: the token budget per step does not allow the pending prefills to be processed fast enough. Raise &lt;code>max-num-batched-tokens&lt;/code>.&lt;/p>
&lt;hr>
&lt;h2 id="reference-dashboard-the-5-metrics-in-grafana">Reference dashboard: the 5 metrics in Grafana&lt;/h2>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;panels&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;title&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;TTFT p50 / p99 (chunked prefill)&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;targets&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;expr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;histogram_quantile(0.50, rate(vllm:time_to_first_token_seconds_bucket[5m]))&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;legendFormat&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;p50&amp;#34;&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;expr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;histogram_quantile(0.99, rate(vllm:time_to_first_token_seconds_bucket[5m]))&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;legendFormat&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;p99&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;title&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Prefix cache hit rate&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;targets&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[{&lt;/span>&lt;span class="nt">&amp;#34;expr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;vllm:gpu_prefix_cache_hit_rate&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;legendFormat&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;GPU hit rate&amp;#34;&lt;/span>&lt;span class="p">}]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;title&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Spec decode acceptance rate&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;targets&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[{&lt;/span>&lt;span class="nt">&amp;#34;expr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;vllm:spec_decode_draft_acceptance_rate&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;legendFormat&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;acceptance rate&amp;#34;&lt;/span>&lt;span class="p">}]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;title&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;KV cache usage + preemptions&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;targets&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;expr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;vllm:gpu_cache_usage_perc&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;legendFormat&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;cache usage&amp;#34;&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;expr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;rate(vllm:num_preemptions_total[2m]) * 60&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;legendFormat&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;preemptions/min&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;title&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Effective concurrency&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;targets&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;expr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;vllm:num_running_seqs&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;legendFormat&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;running&amp;#34;&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>&lt;span class="nt">&amp;#34;expr&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;vllm:num_waiting_seqs&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nt">&amp;#34;legendFormat&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;waiting&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="connecting-traces-to-langfuse">Connecting traces to Langfuse&lt;/h2>
&lt;p>vLLM&amp;rsquo;s OTel traces are GenAI semconv compatible spans. Langfuse accepts them directly via OTLP:&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"># In the OTel Collector (already configured above)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># The otlphttp/langfuse exporter sends traces to Langfuse Cloud or self-hosted&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># For self-hosted Langfuse (ENS/sovereign):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">exporters:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> otlphttp/langfuse:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> endpoint: &lt;span class="s2">&amp;#34;http://langfuse-internal:3000/api/public/otel&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> headers:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> Authorization: &lt;span class="s2">&amp;#34;Basic &amp;lt;base64(pk_xxx:sk_xxx)&amp;gt;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>In Langfuse, each vLLM request appears as a trace with:&lt;/p>
&lt;ul>
&lt;li>&lt;code>gen_ai.system&lt;/code>: the model served&lt;/li>
&lt;li>&lt;code>gen_ai.usage.input_tokens&lt;/code>: prompt tokens&lt;/li>
&lt;li>&lt;code>gen_ai.usage.output_tokens&lt;/code>: generated tokens&lt;/li>
&lt;li>Span duration: end-to-end latency&lt;/li>
&lt;/ul>
&lt;p>What does &lt;strong>not&lt;/strong> appear directly in the span: the speculative decoding acceptance rate, the prefix cache hit/miss, or the number of preemptions. That data is only in Prometheus. The correct workflow is:&lt;/p>
&lt;ol>
&lt;li>Langfuse identifies an anomalous request by latency.&lt;/li>
&lt;li>Prometheus/Grafana shows whether in that interval there were high preemptions, low spec decode, or a prefix cache miss.&lt;/li>
&lt;li>The two are correlated by timestamp.&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="quick-diagnostic-matrix">Quick diagnostic matrix&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Observable symptom&lt;/th>
&lt;th>Prometheus metric&lt;/th>
&lt;th>Probable cause&lt;/th>
&lt;th>Action&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>TTFT p99 very high&lt;/td>
&lt;td>&lt;code>ttft p99/p50 &amp;gt;&amp;gt; 2&lt;/code>&lt;/td>
&lt;td>Long blocking prefills&lt;/td>
&lt;td>Raise &lt;code>--max-num-batched-tokens&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TTFT p50 high, p99 the same&lt;/td>
&lt;td>&lt;code>ttft p50 &amp;gt; 500ms&lt;/code>&lt;/td>
&lt;td>Prefix cache not working&lt;/td>
&lt;td>Check the system prompt hash&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Slow decode with no improvement&lt;/td>
&lt;td>&lt;code>spec_decode_acceptance &amp;lt; 0.60&lt;/code>&lt;/td>
&lt;td>Incompatible drafter&lt;/td>
&lt;td>Change the drafter or disable it&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Sporadic OOM / crash&lt;/td>
&lt;td>&lt;code>gpu_cache_usage_perc = 1.0&lt;/code> + preemptions&lt;/td>
&lt;td>KV cache full&lt;/td>
&lt;td>Lower &lt;code>max-num-seqs&lt;/code> or enable FP8&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>High queue with free cache&lt;/td>
&lt;td>&lt;code>waiting &amp;gt;&amp;gt; 0&lt;/code> + &lt;code>cache &amp;lt; 0.70&lt;/code>&lt;/td>
&lt;td>Low token budget&lt;/td>
&lt;td>Raise &lt;code>--max-num-batched-tokens&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="implications-for-sovereign-on-premise-inference">Implications for sovereign on-premise inference&lt;/h2>
&lt;p>In an ENS deployment where you cannot use Langfuse Cloud or DataDog, the complete self-hosted stack is:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># docker-compose.yml (or equivalent K8s manifests)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">services&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">otel-collector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">otel/opentelemetry-collector-contrib:latest&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">./otel-config.yaml:/etc/otel/config.yaml]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">langfuse&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">langfuse/langfuse:latest&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">environment&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">DATABASE_URL&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">postgres://...&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prometheus&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prom/prometheus:latest&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">grafana&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">grafana/grafana:latest&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The whole pipeline runs on-premise. The traces never leave the perimeter. ENS compliance does not depend on which observability you choose: it depends on the inference data not going out to third parties. With a local stack, both conditions are met.&lt;/p>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">the fundamentals of OTel GenAI semconv: what spans are, the standard attributes and how they flow from the SDK to the collector&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">the prefill optimisations this article instruments: chunked prefill, prefix caching, FP8 KV&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">the decode optimisations: speculative decoding, gpu-memory-utilization, max-num-seqs&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-dcgm-vllm-metrics-analogies-documented-anomalies/">DCGM for the GPU metrics underneath vLLM: SM utilization, memory bandwidth, temperature; the hardware layer below the application metrics&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">how to correlate GPU metrics (DCGM) with application metrics (vLLM Prometheus) for a complete diagnosis&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">the scheduler that produces the num_running_seqs and num_waiting_seqs metrics; without understanding the scheduler, the concurrency metrics have no context&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://docs.vllm.ai/en/stable/examples/online_serving/opentelemetry/">vLLM OpenTelemetry setup — documentación oficial&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/en/stable/design/metrics/">vLLM Metrics — diseño y lista completa&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://langfuse.com/integrations/model-providers/vllm">Tracing vLLM with Langfuse via OpenTelemetry&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.dash0.com/blog/observing-vllm-with-opentelemetry-and-dash0">Observing vLLM with OpenTelemetry and Dash0&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://opentelemetry.io/blog/2024/llm-observability/">OpenTelemetry GenAI Semantic Conventions&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Knowledge Distillation: teaching a small model to think like a big one</title><link>https://blog.lo0.es/en/posts/knowledge-distillation-teaching-small-model-think-like-big-one/</link><pubDate>Fri, 05 Jun 2026 04:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/knowledge-distillation-teaching-small-model-think-like-big-one/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Knowledge Distillation is the technique of training a small model (&lt;em>student&lt;/em>) using the output probabilities of a large model (&lt;em>teacher&lt;/em>) as supervision, instead of using only the hard labels from the training dataset. The result is a small model that reasons better than its size suggests, because it learns the teacher&amp;rsquo;s uncertainty distributions rather than memorising binary answers. It is the reason Phi-4 (14B) beats most 70B models at reasoning, and why the Gemma 3 family models are surprisingly capable for their size. It is not a compression technique for an existing model: it is a training process that produces a smaller model from scratch or from a different starting point.&lt;/p>
&lt;hr>
&lt;h2 id="the-analogy">The analogy&lt;/h2>
&lt;p>A master surgeon with thirty years of experience and a first-year resident. If the resident only learns from the anatomy manual (binary correct answers: &amp;ldquo;cut here, not here&amp;rdquo;) it will take years to develop the master&amp;rsquo;s clinical judgement. But if they operate alongside them, watching their micro-decisions, their hesitations, the ambiguous cases where the master knows that two options are almost equally valid, they learn something the manual cannot teach: the structure of uncertainty.&lt;/p>
&lt;p>Knowledge distillation is exactly that. The &amp;ldquo;anatomy manual&amp;rdquo; is the hard labels (the correct answer). The &amp;ldquo;master surgeon&amp;rdquo; is the teacher LLM. The probability distributions over the vocabulary are the materialisation of that uncertainty the student absorbs.&lt;/p>
&lt;hr>
&lt;h2 id="what-it-really-is">What it really is&lt;/h2>
&lt;p>When an LLM generates text, it does not produce a single word: it produces a probability distribution over its whole vocabulary at each position. For the next token, the model might say:&lt;/p>
&lt;pre tabindex="0">&lt;code>&amp;#34;Paris&amp;#34;: 42%
&amp;#34;Lyon&amp;#34;: 8%
&amp;#34;Marseille&amp;#34;: 6%
&amp;#34;the city&amp;#34;: 5%
...rest of the vocabulary: 39%
&lt;/code>&lt;/pre>&lt;p>This distribution is dense information. It tells you not only &lt;em>what&lt;/em> the correct answer is, but also &lt;em>which other answers were plausible and to what degree&lt;/em>. A student trained only with the label &amp;ldquo;Paris&amp;rdquo; (probability 1.0 on the correct token, 0.0 on the rest) never sees this richness.&lt;/p>
&lt;p>Distillation uses the teacher&amp;rsquo;s full distribution as the student&amp;rsquo;s training target. The loss function has two terms:&lt;/p>
$$\mathcal{L}_{total} = (1 - \alpha) \cdot \mathcal{L}_{CE}(y, \hat{y}_S) + \alpha \cdot \mathcal{L}_{KD}(p_T, p_S, T)$$
&lt;p>Where:&lt;/p>
&lt;ul>
&lt;li>$\mathcal{L}_{CE}$ is the standard cross-entropy with the hard labels (classic supervision).&lt;/li>
&lt;li>$\mathcal{L}_{KD}$ is the KL divergence between the teacher&amp;rsquo;s and the student&amp;rsquo;s distributions.&lt;/li>
&lt;li>$\alpha$ controls the relative weight of each term (typically 0.5–0.9 in favour of KD).&lt;/li>
&lt;li>$T$ is the &lt;em>temperature&lt;/em>, a parameter that softens the distributions to make the KD signal more informative.&lt;/li>
&lt;/ul>
&lt;h3 id="the-role-of-temperature">The role of temperature&lt;/h3>
&lt;p>If the teacher assigns 99% to &amp;ldquo;Paris&amp;rdquo; and 0.001% to every other word, the distribution is almost as informative as a hard label. A temperature $T > 1$ softens that distribution:&lt;/p>
$$p_T(k) = \frac{\exp(z_k / T)}{\sum_j \exp(z_j / T)}$$
&lt;p>With $T = 4$ and the original logits, the distribution that was previously [99%, 0.001%, 0.001%&amp;hellip;] becomes something like [42%, 8%, 6%&amp;hellip;]. The student sees the teacher&amp;rsquo;s real probability neighbourhood, not just its point answer.&lt;/p>
&lt;p>&lt;strong>Numerical example with temperature:&lt;/strong>&lt;/p>
&lt;p>Teacher logits for &amp;ldquo;The capital of France is _____&amp;rdquo;:&lt;/p>
&lt;pre tabindex="0">&lt;code>Paris: 8.5
Lyon: 3.2
Europe: 2.1
a: 1.8
&lt;/code>&lt;/pre>&lt;p>With T=1 (standard softmax):
&lt;/p>
$$p(\text{Paris}) = \frac{e^{8.5}}{e^{8.5} + e^{3.2} + e^{2.1} + e^{1.8}} \approx 99.3\%$$
&lt;p>With T=4:
&lt;/p>
$$p(\text{Paris}) = \frac{e^{8.5/4}}{e^{8.5/4} + e^{3.2/4} + e^{2.1/4} + e^{1.8/4}} = \frac{e^{2.125}}{e^{2.125} + e^{0.8} + e^{0.525} + e^{0.45}} \approx 54\%$$
&lt;p>The signal at T=4 is far more informative for the student: it learns that Lyon is more plausible than Europe, that Europe is more plausible than &amp;ldquo;a&amp;rdquo;, and so on.&lt;/p>
&lt;hr>
&lt;h2 id="the-three-modes-of-distillation">The three modes of distillation&lt;/h2>
&lt;h3 id="offline-or-black-box">Offline (or &amp;ldquo;black-box&amp;rdquo;)&lt;/h3>
&lt;p>The teacher generates a synthetic dataset of answers before training. The student is trained on that dataset as if it were ordinary hard labels.&lt;/p>
&lt;pre tabindex="0">&lt;code>teacher → generates 100M (prompt, completion) pairs → dataset
student → is trained on that dataset
&lt;/code>&lt;/pre>&lt;p>It is the cheapest way to scale: the teacher runs only once, the student is trained on the generated data with conventional hardware. Most open source instruction models (Alpaca, Vicuna, WizardLM in their early versions) used this strategy: GPT-4 as teacher, saved data, Llama-7B as student.&lt;/p>
&lt;p>&lt;strong>Limitation:&lt;/strong> the student never sees the teacher&amp;rsquo;s probability distributions, only its answers. It is distillation of &amp;ldquo;behaviour&amp;rdquo;, not of &amp;ldquo;knowledge&amp;rdquo; in the strict sense. If the teacher makes a mistake (and GPT-4 does make mistakes), the error is crystallised into the dataset.&lt;/p>
&lt;h3 id="online-or-white-box">Online (or &amp;ldquo;white-box&amp;rdquo;)&lt;/h3>
&lt;p>Teacher and student run together during training. The student processes each batch, the teacher processes the same batch in parallel, and the KD loss is computed in real time with the full probability distributions.&lt;/p>
&lt;pre tabindex="0">&lt;code>for batch in dataset:
logits_teacher = teacher(batch) # teacher forward pass
logits_student = student(batch) # student forward pass
loss = KL(softmax(logits_teacher/T), softmax(logits_student/T))
loss.backward() # only updates student
&lt;/code>&lt;/pre>&lt;p>The teacher has gradients disabled (&lt;code>torch.no_grad()&lt;/code>). The learning signal is richer than in offline, but the cost is high: you need to keep the teacher in VRAM throughout training. To distil a 405B teacher into an 8B student, you would need several H100s for the teacher alone.&lt;/p>
&lt;h3 id="on-policy">On-policy&lt;/h3>
&lt;p>A recent variant (2024–2026) that combines the best of both: the teacher generates answers dynamically during training, but the student evaluates them with its own distribution. The cycle is:&lt;/p>
&lt;ol>
&lt;li>The student generates a proposed answer (&lt;em>rollout&lt;/em>).&lt;/li>
&lt;li>The teacher scores that proposal with its probability distribution.&lt;/li>
&lt;li>The student updates with the teacher&amp;rsquo;s signal.&lt;/li>
&lt;/ol>
&lt;p>This stops the student from learning from distributions outside its own domain (the &lt;em>distribution shift&lt;/em> problem in offline). It is the basis of algorithms such as &lt;a href="https://github.com/nick7nlp/Awesome-LLM-On-Policy-Distillation">SimCT (2026)&lt;/a> that use teachers from different families (Qwen, Phi, Gemma) to generate a cross-tokeniser signal.&lt;/p>
&lt;hr>
&lt;h2 id="why-the-best-small-models-use-distillation">Why the best small models use distillation&lt;/h2>
&lt;p>Phi-4 (Microsoft, 14B), Gemma 3 (Google, 9B/27B), and the compact Qwen3 family models are the clearest examples. Their benchmarks are anomalous with respect to their size: Phi-4-14B beats LLaMA-3-70B on MATH and GPQA-Diamond, two mathematical and scientific reasoning benchmarks where size is usually decisive.&lt;/p>
&lt;p>Why? The key is what supervises the training:&lt;/p>
&lt;ul>
&lt;li>A model trained on internet data learns the distribution of human text, which includes a lot of low-quality text, errors and ambiguities.&lt;/li>
&lt;li>A student that learns from a frontier teacher (GPT-4o, Claude 3 Opus, Gemini 1.5 Pro) absorbs a distribution filtered towards high-quality text and correct reasoning.&lt;/li>
&lt;/ul>
&lt;p>The student with 14B parameters does not &amp;ldquo;know more&amp;rdquo; than an undistilled one of the same size, but it has learned to use them better because its training gradients were never contaminated by low-quality text.&lt;/p>
&lt;p>&lt;strong>Empirical data point:&lt;/strong> Phi-4 (14B distilled) vs LLaMA-3-70B (not distilled) on the MATH benchmark (2025):&lt;/p>
&lt;ul>
&lt;li>Phi-4: 80.4%&lt;/li>
&lt;li>LLaMA-3-70B: 68.0%&lt;/li>
&lt;/ul>
&lt;p>A model 5× smaller beats the large one because the training signal is better, not because it has more parameters.&lt;/p>
&lt;hr>
&lt;h2 id="reasoning-distillation-the-case-of-thinking-models">Reasoning distillation: the case of thinking models&lt;/h2>
&lt;p>Reasoning models (DeepSeek-R1, Qwen3-thinking, QwQ) generate internal chains of thought before giving the final answer. Distilling reasoning is more complex because you do not only want to transfer the answer: you want to transfer the &lt;em>way of thinking&lt;/em>.&lt;/p>
&lt;p>The current strategy (2025–2026) is &lt;strong>reasoning trace distillation&lt;/strong>:&lt;/p>
&lt;ol>
&lt;li>The teacher (a large thinking model) generates answers with its full internal chain of thought.&lt;/li>
&lt;li>The dataset includes those chains of thought as part of the output.&lt;/li>
&lt;li>The student learns to imitate both the chain and the final answer.&lt;/li>
&lt;/ol>
&lt;p>This explains why Qwen3-7B-thinking can reason formally about mathematics while being 10× smaller than the models that preceded it without distillation: it learned the &lt;em>process&lt;/em>, not just the &lt;em>result&lt;/em>.&lt;/p>
&lt;hr>
&lt;h2 id="when-to-use-distillation-vs-the-alternatives">When to use distillation vs. the alternatives&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Technique&lt;/th>
&lt;th>What it does&lt;/th>
&lt;th>Requires retraining&lt;/th>
&lt;th>Result&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Quantisation&lt;/td>
&lt;td>Reduces weight precision&lt;/td>
&lt;td>No&lt;/td>
&lt;td>Same model, smaller&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Pruning&lt;/td>
&lt;td>Removes irrelevant weights&lt;/td>
&lt;td>No (PTQ)&lt;/td>
&lt;td>Same model, sparser&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Distillation&lt;/td>
&lt;td>Trains a new model&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Different model, smaller&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Distillation does not compress an existing model: it produces a new one. That is why it is complementary, not a substitute: you can distil a 405B into an 8B, and then quantise that 8B to INT4 to reduce its inference cost.&lt;/p>
&lt;p>&lt;strong>When it is the right option:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>You need a model 5–10× smaller than the best available one.&lt;/li>
&lt;li>You have access (API or local) to a quality teacher.&lt;/li>
&lt;li>You have training data or the ability to generate it.&lt;/li>
&lt;li>Latency or inference cost is a hard constraint.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>When it is not:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>You want to compress an existing model quickly: use quantisation + pruning.&lt;/li>
&lt;li>You have no training budget (online distillation takes weeks of GPU).&lt;/li>
&lt;li>The teacher is not significantly better than the base student: the KD signal will be weak.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="implications-for-on-premise-inference">Implications for on-premise inference&lt;/h2>
&lt;p>In a sovereign deployment, the teacher can be a large model running locally (no external API needed). The flow is:&lt;/p>
&lt;pre tabindex="0">&lt;code>generic 4×H100:
teacher: Llama-3.3-70B-Instruct (on the 4×H100, full load)
→ generates a dataset of 10M (prompt, completion with logits) pairs
→ 3-4 weeks of generation at batch 32
After the dataset:
student: Qwen2.5-7B (fine-tuned with KD loss over the dataset)
→ 2-3 days of training on the same H100s
→ result: a 7B that reasons like the 70B in the specific domain
Production:
RTX 4090: serves the 7B student quantised to INT4 (4 GB)
&lt;/code>&lt;/pre>&lt;p>The teacher is only needed to generate the data. The student is what goes into production. The investment in training compute pays for itself in months of cheaper inference.&lt;/p>
&lt;p>For ENS/NIS2: this flow is 100% on-premise, zero dependency on external APIs, and the resulting model is yours in every sense.&lt;/p>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>https://blog.lo0.es/en/posts/llm-pruning-cutting-without-amputating/ — a technical alternative: instead of training a new model, remove parts of the existing model; distillation and pruning are complementary&lt;/li>
&lt;li>https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/ — the next step after distilling: quantise the student for efficient inference&lt;/li>
&lt;li>https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/ — speculative decoding drafters are frequently students distilled from the base model that learn to predict its distribution&lt;/li>
&lt;li>https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/ — distillation as a form of continuous fine-tuning: the teacher is the model in production, the student is the next version&lt;/li>
&lt;li>https://blog.lo0.es/en/posts/modern-alignment-dpo-kto-orpo-simpo/ — DPO and its variants can be seen as distilling human preferences into the model; the maths of the reference distribution is analogous to the teacher in KD&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://arxiv.org/abs/1503.02531">Distilling the Knowledge in a Neural Network&lt;/a> — Hinton, Vinyals &amp;amp; Dean, 2015 (paper fundacional)&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/2412.08905">Phi-4 Technical Report&lt;/a> — Microsoft Research, 2024&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/2501.12948">DeepSeek-R1: Incentivizing Reasoning Capability in LLMs via Reinforcement Learning&lt;/a> — DeepSeek, 2025 (destilación de razonamiento)&lt;/li>
&lt;li>&lt;a href="https://github.com/nick7nlp/Awesome-LLM-On-Policy-Distillation">Awesome LLM On-Policy Distillation&lt;/a> — colección de papers de destilación en-policy, 2025–2026&lt;/li>
&lt;li>&lt;a href="https://openreview.net/pdf/cf5bed8b71779ae42d0e681f1e2a7de3b3c8f6ad.pdf">Knowledge Distillation for LLMs: Survey&lt;/a> — ICLR 2025&lt;/li>
&lt;/ul></description></item><item><title>Optimising decode in vLLM: squeezing every token out of small hardware</title><link>https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/</link><pubDate>Fri, 05 Jun 2026 04:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Decode is the phase in which vLLM generates output tokens one at a time. It is &lt;strong>memory-bound&lt;/strong>, not compute-bound: the GPU spends more time waiting for the weights to arrive from VRAM than doing arithmetic. On small hardware, an RTX 4090 (24 GB) or an L40 (48 GB), a badly configured decode wastes half the card&amp;rsquo;s capacity. Five vLLM parameters change the equation: &lt;code>gpu-memory-utilization&lt;/code>, &lt;code>max-num-seqs&lt;/code>, speculative decoding, FP8 KV cache and a &lt;code>swap-space&lt;/code> correctly set to zero. Well calibrated, the difference is real: from 15 tokens/s to 35–50 tokens/s on the same hardware.&lt;/p>
&lt;hr>
&lt;h2 id="the-analogy">The analogy&lt;/h2>
&lt;p>An assembly-line worker putting cars together. Every car needs exactly the same process: walk to the store for the part, come back, bolt it on, repeat. The travel time to the store, the VRAM latency, is fixed and cannot be eliminated. But there are ways to make it less painful:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Have several cars in parallel on the line&lt;/strong> (more concurrency, the same travel time amortised).&lt;/li>
&lt;li>&lt;strong>Have an assistant prefabricate common parts&lt;/strong> (speculative decoding: the draft model proposes, the verifier confirms).&lt;/li>
&lt;li>&lt;strong>Keep only the most-used parts in the workshop&lt;/strong> (quantised KV cache: more contexts fit in the same space).&lt;/li>
&lt;/ol>
&lt;p>Those three strategies are exactly the three axes of decode optimisation in vLLM.&lt;/p>
&lt;hr>
&lt;h2 id="why-decode-is-memory-bound">Why decode is memory-bound&lt;/h2>
&lt;p>During prefill, the GPU processes N tokens in parallel: the attention operation is a large matmul and the compute units are busy. During decode, it processes &lt;strong>1 token per step&lt;/strong>: the matmul becomes a vector-matrix product, an operation that underuses the tensor cores.&lt;/p>
&lt;p>The typical compute utilisation ratio during decode on an RTX 4090:&lt;/p>
$$\text{MFU}_{decode} \approx 5–15\% \quad \text{(vs 40–60\% in prefill)}$$
&lt;p>The bottleneck is not compute power, it is bandwidth. To generate each token, the model has to read its complete weights from VRAM:&lt;/p>
$$\text{time per token} \approx \frac{\text{weight size in bytes}}{\text{VRAM bandwidth}}$$
&lt;p>For Qwen2.5-7B in BF16 (14 GB of weights) on an RTX 4090 (1,008 GB/s):&lt;/p>
$$t \approx \frac{14 \times 10^9}{1.008 \times 10^{12}} \approx 13.9 \text{ ms/token} \approx 72 \text{ tokens/s theoretical maximum}$$
&lt;p>The real value is lower (~30–50 tok/s) because of scheduler overhead, attention over the growing KV cache and other latencies. But the theoretical limit sets the ceiling.&lt;/p>
&lt;p>With Q4_K_M (weights ~4 GB):&lt;/p>
$$t \approx \frac{4 \times 10^9}{1.008 \times 10^{12}} \approx 3.97 \text{ ms/token} \approx 252 \text{ tokens/s theoretical}$$
&lt;p>Quantising the model is the most direct way to improve decode throughput on memory-bound hardware. Everything else optimises on top of that ceiling.&lt;/p>
&lt;hr>
&lt;h2 id="the-five-levers">The five levers&lt;/h2>
&lt;h3 id="1-give-vllm-all-the-vram-you-can----gpu-memory-utilization">1. Give vLLM all the VRAM you can — &lt;code>--gpu-memory-utilization&lt;/code>&lt;/h3>
&lt;p>&lt;code>--gpu-memory-utilization&lt;/code> (short form &lt;code>--gpu-mem-util&lt;/code>) defines the fraction of available VRAM that vLLM may use for the &lt;strong>KV cache&lt;/strong>, once the model weights are loaded. The rest is reserved for activations during the forward pass and for the CUDA context.&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 my-model &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --gpu-memory-utilization 0.92
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The default value is &lt;code>0.90&lt;/code>. On bare metal where no other process uses the GPU, &lt;code>0.92–0.95&lt;/code> is safe. Do not go above &lt;code>0.95&lt;/code>: vLLM needs headroom for activations during batch peaks, and running out of VRAM in the middle of an inference results in a process crash, not a clean error.&lt;/p>
&lt;p>&lt;strong>Why it matters:&lt;/strong> more KV cache available = more simultaneous requests in flight = better GPU utilisation during decode. PagedAttention allocates the KV cache in fixed-size blocks (16 tokens per block by default), and vLLM manages them like virtual memory pages. The more blocks available, the more requests it can serve without any of them waiting for space.&lt;/p>
&lt;pre tabindex="0">&lt;code>RTX 4090, Qwen2.5-7B-BF16 (14 GB weights):
Total VRAM: 24 GB
Weights: 14 GB
Available for KV cache: 10 GB
gpu-memory-utilization 0.90 → 0.90 × 10 GB = 9 GB for KV cache
gpu-memory-utilization 0.94 → 0.94 × 10 GB = 9.4 GB → ~4% more tokens in flight
&lt;/code>&lt;/pre>&lt;p>The impact is modest with models that fit comfortably, but it is amplified with models that push VRAM to its limit.&lt;/p>
&lt;hr>
&lt;h3 id="2-real-concurrency----max-num-seqs">2. Real concurrency — &lt;code>--max-num-seqs&lt;/code>&lt;/h3>
&lt;p>&lt;code>--max-num-seqs&lt;/code> is the maximum number of requests vLLM can have in process simultaneously (counting prefill and decode together). It is the parameter that controls the system&amp;rsquo;s effective concurrency.&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 my-model &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-seqs &lt;span class="m">128&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The effect is direct: more requests in simultaneous decode = better amortisation of the fixed cost of reading the weights. When the decode batch grows from 1 to 8, the time to generate 8 tokens is almost the same as generating 1 (the weights are read once for all of them). Aggregate throughput scales almost linearly until the KV cache or the activation VRAM becomes the bottleneck.&lt;/p>
$$\text{aggregate throughput}(B) \approx B \times \text{throughput}(1) \quad \text{for } B \ll B_{max}$$
&lt;p>&lt;strong>Common mistake:&lt;/strong> raising &lt;code>--max-num-seqs&lt;/code> without making sure there is enough KV cache in VRAM for all the requests. If vLLM cannot hold the KV caches of 128 simultaneous requests, it does preemption (pauses some request and frees its KV cache) at a latency cost. Monitor &lt;code>vllm:num_preemptions_total&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Interaction with &lt;code>--max-num-batched-tokens&lt;/code>:&lt;/strong> the vLLM scheduler processes up to &lt;code>max-num-batched-tokens&lt;/code> tokens per step. If you have 128 requests in decode generating 1 token each, that is 128 decode tokens. The decode budget consumes 128 tokens of the total budget; the rest goes to prefill in chunks. Tune both values together.&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"># For an RTX 4090 serving ~50 concurrent users with responses of up to 512 tokens&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve my-model &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-seqs &lt;span class="m">64&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-batched-tokens &lt;span class="m">8192&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 64 decode tokens per step + up to 8128 chunked prefill tokens&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h3 id="3-speculative-decoding----speculative-model----num-speculative-tokens">3. Speculative decoding — &lt;code>--speculative-model&lt;/code> + &lt;code>--num-speculative-tokens&lt;/code>&lt;/h3>
&lt;p>Speculative decoding is the highest-impact change for decode on small hardware. The idea is simple: a small draft model proposes several tokens at once, and the verifier model validates or rejects them in a single forward pass.&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 Qwen/Qwen2.5-7B-Instruct &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --speculative-model Qwen/Qwen2.5-0.5B-Instruct &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --num-speculative-tokens &lt;span class="m">5&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --speculative-draft-tensor-parallel-size &lt;span class="m">1&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Why it works:&lt;/strong> the 7B verifier has to read 14 GB of weights per step. With 5 proposed tokens, if the acceptance rate is 80%, an average of 4–5 tokens are generated per verifier step instead of 1. Effective throughput goes up without the GPU working any harder.&lt;/p>
&lt;p>The acceptance rate (α) depends on how well the draft predicts the verifier&amp;rsquo;s distribution. Within the same domain, models from the same family usually have α &amp;gt; 0.75:&lt;/p>
$$\text{speedup} \approx \frac{1 + \alpha \cdot k}{1 + \alpha \cdot k / \text{cost ratio}}$$
&lt;p>Where $k$ is the number of proposed tokens and cost_ratio is the draft/verifier cost ratio. For a 0.5B draft and a 7B verifier (ratio ~14×):&lt;/p>
$$\text{speedup} \approx 1 + 0.8 \times 5 \approx 5 \text{ (theoretical maximum, not reachable)}$$
&lt;p>In practice, with α = 0.75 and k = 5 on hardware without NVLink: &lt;strong>1.8–2.5× more tokens/s&lt;/strong> compared with decode alone.&lt;/p>
&lt;p>&lt;strong>EAGLE-3 in 2026:&lt;/strong> the best drafters today are not small versions of the same model, but networks specialised in predicting the verifier&amp;rsquo;s distribution. EAGLE-3 reports a 3–6.5× speedup over vanilla decode in public benchmarks. In production with mixed batches the real speedup is more conservative (1.5–3×). vLLM supports EAGLE/EAGLE-2 via &lt;code>--speculative-model&lt;/code>:&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"># With an EAGLE drafter (requires a drafter trained specifically for the base model)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve meta-llama/Llama-3.1-8B-Instruct &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --speculative-model yuhuili/EAGLE3-LLaMA3.1-Instruct-8B &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --num-speculative-tokens &lt;span class="m">6&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>When speculative decoding does NOT help:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Very large batches (&amp;gt;32 requests): the acceptance rate varies between requests and the batch spends more time re-drafting than accepting.&lt;/li>
&lt;li>High-entropy tasks (brainstorming, very creative code): the draft predicts worse, α falls below 0.5 and the draft overhead outweighs the gain.&lt;/li>
&lt;li>If the draft model does not fit in the available VRAM alongside the verifier.&lt;/li>
&lt;/ul>
&lt;p>On an RTX 4090 with a 7B verifier and a 0.5B draft (BF16): 14 + 1 GB = 15 GB. That leaves 9 GB for the KV cache. It works.&lt;/p>
&lt;hr>
&lt;h3 id="4-quantised-kv-cache----kv-cache-dtype-fp8">4. Quantised KV cache — &lt;code>--kv-cache-dtype fp8&lt;/code>&lt;/h3>
&lt;p>This was already covered in the prefill article for its effect on context capacity. From the decode point of view, the benefit is different: more tokens fit in the KV cache → more simultaneous requests without preemption → better aggregate throughput.&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 my-model &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --kv-cache-dtype fp8 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --calculate-kv-scales
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Precision warning in decode:&lt;/strong> the KV cache is read on every attention step of the decode. Quantisation introduces noise into the attention activations. For long texts (&amp;gt;4K tokens of context) it can accumulate. In quality benchmarks (MMLU, HellaSwag) the degradation with FP8 KV and &lt;code>--calculate-kv-scales&lt;/code> is &amp;lt;0.5% on modern models. Without &lt;code>--calculate-kv-scales&lt;/code>, the degradation can be larger because the scales are fixed statically.&lt;/p>
&lt;p>&lt;strong>Optimal combination for RTX 4090:&lt;/strong>&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 Qwen/Qwen2.5-7B-Instruct-AWQ &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --quantization awq &lt;span class="se">\ &lt;/span> &lt;span class="c1"># weights in INT4: 4 GB model&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> --kv-cache-dtype fp8 &lt;span class="se">\ &lt;/span> &lt;span class="c1"># KV cache at half the size&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> --calculate-kv-scales &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --gpu-memory-utilization 0.94
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Available VRAM: 24 - 4 = 20 GB for KV cache&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># With FP8: ~40 KB/token (vs 80 KB BF16) → 20 GB / 40 KB = 500,000 tokens of total context&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># With max-num-seqs 64 and a 4K ctx: 64 × 4096 × 40KB = 10 GB → fits with room to spare&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h3 id="5-remove-the-swap----swap-space-0">5. Remove the swap — &lt;code>--swap-space 0&lt;/code>&lt;/h3>
&lt;p>&lt;code>--swap-space&lt;/code> defines how much system RAM (not VRAM) vLLM may use to preempt KV caches. When vLLM has more active requests than fit in VRAM, it can &amp;ldquo;pause&amp;rdquo; some by moving their KV cache to RAM and reactivating them later.&lt;/p>
&lt;p>The problem: moving a 4K-token KV cache from VRAM to RAM and back has a latency of tens of milliseconds over PCIe. For a system where you want predictable latency, the swap introduces unacceptable jitter.&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 my-model &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --swap-space &lt;span class="m">0&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With &lt;code>--swap-space 0&lt;/code>, when vLLM cannot hold any more requests in VRAM, it simply queues them instead of doing preemption. The queue adds waiting latency, but it is predictable and it does not interrupt the requests already in flight.&lt;/p>
&lt;p>&lt;strong>When should you have swap?&lt;/strong> If your workload has short demand peaks and you can tolerate occasional jitter in exchange for not rejecting requests, a swap of 4–8 GB can be useful. In ENS deployments where latency is a contractual SLA, &lt;code>--swap-space 0&lt;/code> is the right option.&lt;/p>
&lt;hr>
&lt;h2 id="the-reference-configuration-per-hardware">The reference configuration per hardware&lt;/h2>
&lt;h3 id="rtx-4090-24-gb--7b-model-internal-use">RTX 4090 (24 GB) — 7B model, internal use&lt;/h3>
&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 Qwen/Qwen2.5-7B-Instruct &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --gpu-memory-utilization 0.92 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-model-len &lt;span class="m">8192&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-seqs &lt;span class="m">64&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-batched-tokens &lt;span class="m">8192&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-chunked-prefill &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-prefix-caching &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --kv-cache-dtype fp8 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --calculate-kv-scales &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --swap-space &lt;span class="m">0&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --speculative-model Qwen/Qwen2.5-0.5B-Instruct &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --num-speculative-tokens &lt;span class="m">5&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --speculative-draft-tensor-parallel-size &lt;span class="m">1&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --dtype bfloat16
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Expected throughput: &lt;strong>35–55 tokens/s per user&lt;/strong>, up to 64 simultaneous, TTFT &amp;lt;500ms for prompts &amp;lt;1K tokens.&lt;/p>
&lt;h3 id="l40-48-gb--14b-model-multi-user">L40 (48 GB) — 14B model, multi-user&lt;/h3>
&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 Qwen/Qwen2.5-14B-Instruct &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --gpu-memory-utilization 0.90 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-model-len &lt;span class="m">16384&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-seqs &lt;span class="m">128&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-batched-tokens &lt;span class="m">16384&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-chunked-prefill &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-prefix-caching &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --kv-cache-dtype fp8 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --calculate-kv-scales &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --swap-space &lt;span class="m">0&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --speculative-model Qwen/Qwen2.5-1.5B-Instruct &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --num-speculative-tokens &lt;span class="m">5&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --dtype bfloat16
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Expected throughput: &lt;strong>25–40 tokens/s per user&lt;/strong>, up to 128 simultaneous with speculative decoding active, TTFT &amp;lt;800ms for prompts &amp;lt;2K tokens.&lt;/p>
&lt;hr>
&lt;h2 id="how-to-measure-that-decode-is-optimised">How to measure that decode is optimised&lt;/h2>
&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"># Key metrics at vllm:8000/metrics&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm:generation_tokens_total &lt;span class="c1"># total tokens generated → trend&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm:e2e_request_latency_seconds_* &lt;span class="c1"># end-to-end latency per percentile&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm:time_per_output_token_seconds_* &lt;span class="c1"># ITL (inter-token latency)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm:num_preemptions_total &lt;span class="c1"># if it rises, the KV cache is filling up&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm:spec_decode_draft_acceptance_rate &lt;span class="c1"># speculative decoding hit rate&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If &lt;code>spec_decode_draft_acceptance_rate&lt;/code> &amp;lt; 0.6, the drafter is not helping: turn speculative decoding off or find a drafter better trained for your model or domain.&lt;/p>
&lt;p>If &lt;code>num_preemptions_total&lt;/code> grows, you have too many simultaneous requests for the available KV cache. Options: lower &lt;code>max-num-seqs&lt;/code>, enable FP8 KV cache, lower &lt;code>max-model-len&lt;/code>, or quantise the model further.&lt;/p>
&lt;hr>
&lt;h2 id="implications-for-on-premise-inference">Implications for on-premise inference&lt;/h2>
&lt;p>In a sovereign deployment with fixed hardware, you cannot buy more GPUs at will. Every well-calibrated tenth of &lt;code>gpu-memory-utilization&lt;/code>, every point of speculative decoding acceptance rate and every MB of KV cache freed by FP8 is real capacity you do not have to provision with another node.&lt;/p>
&lt;p>The combination of quantised weights (AWQ/GPTQ), FP8 KV cache and speculative decoding lets a 14B serve on one L40 what without optimisations would need two L40s in tensor parallel. That is the economic argument for investing time in these parameters.&lt;/p>
&lt;p>Decode cannot be accelerated indefinitely on memory-bound hardware: the theoretical limit is set by VRAM bandwidth. But the difference between the minimum and the maximum achievable on that hardware can be 3–4× with the right levers.&lt;/p>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">the prefill-phase optimisations: chunked prefill, prefix caching and FP8 KV&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">how speculative decoding works inside&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">the structure decode consults on every token&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">why quantising weights changes the speed ceiling of decode&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">the foundation of concurrency management in vLLM&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/">how to measure with Prometheus whether speculative decoding, gpu-memory-utilization and max-num-seqs are working: &lt;code>spec_decode_draft_acceptance_rate&lt;/code>, &lt;code>num_preemptions_total&lt;/code> and the diagnostic matrix&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://arxiv.org/abs/2309.06180">Efficient Memory Management for Large Language Model Serving with PagedAttention&lt;/a> — Kwon et al., 2023&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/2406.16858">EAGLE-2: Faster Inference of Language Models with Dynamic Draft Trees&lt;/a> — Li et al., 2024&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/2211.17192">Fast Inference from Transformers via Speculative Decoding&lt;/a> — Leviathan et al., 2022&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/en/stable/configuration/optimization/">vLLM Optimization and Tuning — documentación oficial&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/en/stable/features/spec_decode.html">vLLM Speculative Decoding&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/en/latest/features/quantization/quantized_kvcache/">FP8 KV Cache en vLLM&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Optimising prefill in vLLM: the knobs your TTFT will not forgive</title><link>https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/</link><pubDate>Fri, 05 Jun 2026 04:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Prefill is the phase in which vLLM processes your input prompt and produces the initial KV cache. It is compute-bound (unlike decode, which is memory-bound), it takes longer the longer the prompt is, and it blocks the decode of every other queued request. There are four levers in vLLM that radically change its behaviour: chunked prefill, prefix caching, FP8 KV cache and the per-batch token budget. With modest hardware, a 24 GB RTX 4090 or a 48 GB L40, the difference between ignoring them and using them well can be a 3× lower TTFT and 40% more aggregate throughput.&lt;/p>
&lt;hr>
&lt;h2 id="the-analogy">The analogy&lt;/h2>
&lt;p>Picture a printing shop from the early twentieth century. Setting the lead type (preparing the forme) is slow and blocks the press. Printing the pages already set is fast, but it needs the forme ready before it can start.&lt;/p>
&lt;p>Prefill is setting the type. Decode is printing. A press that can only do one thing at a time, either set or print, leaves the machinery idle half the time. The historical solution was to have one worker setting the next page while the previous one was already on the press. That is, exactly, chunked prefill.&lt;/p>
&lt;hr>
&lt;h2 id="what-prefill-really-is">What prefill really is&lt;/h2>
&lt;p>When a request arrives at vLLM, the engine has to process every token of the prompt at once before it can emit the first token of the response. During that processing it computes, for each prompt token, its attention &lt;strong>Key&lt;/strong> and &lt;strong>Value&lt;/strong> vectors. The result, the initial KV cache, is stored in VRAM and used throughout the subsequent decode.&lt;/p>
&lt;p>Unlike decode, where the model processes &lt;strong>one new token&lt;/strong> per step, in prefill it processes &lt;strong>N tokens in one go&lt;/strong>. That makes it far more efficient in FLOPs/token (GPUs are good at large matmuls), but it carries a quadratic cost in attention:&lt;/p>
$$\text{prefill attention FLOPs} \approx 4 \cdot N^2 \cdot d_{model}$$
&lt;p>With a prompt of 1,000 tokens and $d_{model} = 4096$ (Qwen2.5-7B): $4 \cdot 10^6 \cdot 4096 \approx 16 \times 10^9$ FLOPs in attention alone. With 4,000 tokens, &lt;strong>256× more&lt;/strong>, because of the quadratic nature.&lt;/p>
&lt;pre tabindex="0">&lt;code>Prefill (compute-bound):
prompt tokens → [attention O(N²)] → [FFN] → initial KV cache
Decode (memory-bound):
1 new token → [cross-attention over KV cache] → next token
&lt;/code>&lt;/pre>&lt;hr>
&lt;h2 id="why-prefill-is-a-problem-on-small-hardware">Why prefill is a problem on small hardware&lt;/h2>
&lt;p>On an H100 with 3.35 TB/s of bandwidth, a long prefill amortises quickly. On an RTX 4090 (1.008 TB/s) or an L40 (864 GB/s), the bottleneck shows up sooner and has concrete consequences:&lt;/p>
&lt;p>&lt;strong>The head-of-line blocking problem.&lt;/strong> By default, vLLM processes a complete prefill before doing any decode. If you have 10 requests queued, 9 in decode and 1 with an 8,000-token prompt, those 9 requests stall while the GPU chews through the long prefill. Their users see the streaming freeze. This is called &lt;strong>head-of-line blocking&lt;/strong> and it is the number one enemy of TTFT in production.&lt;/p>
&lt;hr>
&lt;h2 id="the-four-levers">The four levers&lt;/h2>
&lt;h3 id="1-chunked-prefill----enable-chunked-prefill----max-num-batched-tokens">1. Chunked prefill — &lt;code>--enable-chunked-prefill&lt;/code> + &lt;code>--max-num-batched-tokens&lt;/code>&lt;/h3>
&lt;p>Chunked prefill splits a long prefill into pieces (&lt;em>chunks&lt;/em>) and interleaves them with decode steps in the same batch. In vLLM V1 (≥ 0.6) it is on by default.&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 my-model &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-chunked-prefill &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-batched-tokens &lt;span class="m">4096&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>--max-num-batched-tokens&lt;/code> is the total token budget vLLM can process in a single engine step, counting prefill and decode together. It is the most important parameter for controlling the trade-off:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:left">&lt;code>max-num-batched-tokens&lt;/code>&lt;/th>
&lt;th style="text-align:left">Effect&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:left">Low (512–2048)&lt;/td>
&lt;td style="text-align:left">More decode steps per cycle → better ITL, worse TTFT&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">High (8192–32768)&lt;/td>
&lt;td style="text-align:left">Large prefill chunks → better TTFT and throughput, worse ITL&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>For an RTX 4090 serving 7B–13B models with mixed contexts (256–4096 tokens):&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">--max-num-batched-tokens &lt;span class="m">8192&lt;/span> &lt;span class="c1"># a reasonable balance point&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>For an L40 (48 GB) with larger models and longer prompts:&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">--max-num-batched-tokens &lt;span class="m">16384&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>How it works internally:&lt;/strong> with a budget of 4,096 tokens and a prefill of 10,000, vLLM splits it into 3 chunks (4,096 + 4,096 + 1,808). Between chunks, it processes the pending decode steps. The requests in decode keep advancing; the long prefill takes longer to finish, but it freezes nothing.&lt;/p>
&lt;pre tabindex="0">&lt;code>Without chunked prefill:
t=0 [prefill 10k tokens]─────────────────────────────┐
t=1 └─[decode r1,r2...r9]
With chunked prefill (budget 4096):
t=0 [prefill chunk 4096][decode r1..r9]
t=1 [prefill chunk 4096][decode r1..r9]
t=2 [prefill chunk 1808][decode r1..r9]
t=3 [decode all, including the new one]
&lt;/code>&lt;/pre>&lt;p>The TTFT of the long request rises slightly (3 steps instead of 1), but the ITL of the other 9 requests is not interrupted.&lt;/p>
&lt;hr>
&lt;h3 id="2-prefix-caching----enable-prefix-caching">2. Prefix caching — &lt;code>--enable-prefix-caching&lt;/code>&lt;/h3>
&lt;p>If several requests share the same prefix, a system prompt, a few-shot block, a context document, vLLM can compute the KV cache of that prefix once and reuse it.&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 my-model &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-prefix-caching
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This is called &lt;strong>Automatic Prefix Caching (APC)&lt;/strong>. Internally, vLLM splits the KV cache into fixed-size blocks (16 tokens per block by default) and assigns them a content-based SHA hash. When a new request arrives, it checks whether any initial block is already in the cache. On a hit, that prefill is skipped.&lt;/p>
&lt;p>&lt;strong>The numerical impact:&lt;/strong> suppose a 512-token system prompt that appears in 80% of your requests, and a rate of 100 req/min:&lt;/p>
&lt;ul>
&lt;li>Without APC: 80 req/min × 512 tokens × prefill_cost = 41,000 tokens/min of redundant prefill&lt;/li>
&lt;li>With APC (80% hit rate): 20 req/min × 512 = 10,240 tokens/min of prefill → &lt;strong>a 75% reduction&lt;/strong>&lt;/li>
&lt;/ul>
&lt;p>The TTFT of those 80 requests falls to whatever it costs to process only the new suffix.&lt;/p>
&lt;p>&lt;strong>Limitation with chunked prefill:&lt;/strong> when chunked prefill is active, only the first chunk of the prefill benefits from APC in the current vLLM implementation. For workloads where the cache hit rate is very high and the suffixes are short, consider lowering &lt;code>--max-num-batched-tokens&lt;/code> so that the first chunk covers more of the shared prefix.&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"># Configuration optimised for a high prefix cache hit rate&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve my-model &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-prefix-caching &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-chunked-prefill &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-batched-tokens &lt;span class="m">4096&lt;/span> &lt;span class="c1"># smaller chunks = prefix fits in chunk 1&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h3 id="3-fp8-kv-cache----kv-cache-dtype-fp8">3. FP8 KV cache — &lt;code>--kv-cache-dtype fp8&lt;/code>&lt;/h3>
&lt;p>The KV cache takes up VRAM. The more VRAM it consumes, the fewer concurrent requests you can keep in flight. On a 24 GB RTX 4090, the Qwen2.5-14B model in BF16 already takes ~28 GB, so it does not fit. In Q4 it takes ~9 GB, leaving ~14 GB for the KV cache.&lt;/p>
&lt;p>How many context tokens fit in 14 GB of BF16 KV cache for a 14B with GQA?&lt;/p>
$$\text{KV size per token} = 2 \cdot n_{kv\_heads} \cdot d_{head} \cdot n_{layers} \cdot 2 \text{ bytes}$$
&lt;p>For Qwen2.5-14B: $n_{kv\_heads}=8$, $d_{head}=128$, $n_{layers}=40$, BF16 → $2 \cdot 8 \cdot 128 \cdot 40 \cdot 2 = 163{,}840$ bytes ≈ 160 KB/token.&lt;/p>
&lt;p>14 GB / 160 KB ≈ &lt;strong>87,500 tokens&lt;/strong> of total context. With 8 users in parallel and 4,096 tokens of context each: 32,768 tokens used out of 87,500. There is headroom, but it is finite.&lt;/p>
&lt;p>Moving to FP8 (1 byte instead of 2):&lt;/p>
$$\text{KV FP8} = 80 \text{ KB/token} \implies 14 \text{ GB} / 80 \text{ KB} = 175{,}000 \text{ tokens}$$
&lt;p>Twice the context capacity with the same VRAM. That buys either more concurrency or longer contexts.&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 my-model &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --kv-cache-dtype fp8 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --calculate-kv-scales &lt;span class="c1"># calibrates the scales dynamically; without this there is degradation&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Warning for RTX 4090 and L40:&lt;/strong> Ada Lovelace has FP8 instructions at the CUDA level but without Hopper&amp;rsquo;s (H100&amp;rsquo;s) dedicated scaling hardware. The memory reduction is real; the compute speedup is smaller than on an H100. Do not expect the same speedup as in a Hopper datacenter. On the L40S (the variant with optimised FP8 tensor cores) the benefit is larger than on an RTX 4090.&lt;/p>
&lt;hr>
&lt;h3 id="4-context-budget----max-model-len">4. Context budget — &lt;code>--max-model-len&lt;/code>&lt;/h3>
&lt;p>&lt;code>--max-model-len&lt;/code> defines the maximum number of tokens vLLM can handle in a single request (prompt plus generation). It is the hard limit that determines how much VRAM is reserved for the KV cache in the worst case.&lt;/p>
&lt;p>On small hardware, lowering it frees VRAM for more concurrency:&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"># 7B model on RTX 4090, typical context of 4K but the model supports 128K&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">vllm serve my-model &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-model-len &lt;span class="m">8192&lt;/span> &lt;span class="se">\ &lt;/span> &lt;span class="c1"># instead of 131072&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> --gpu-memory-utilization 0.92
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>With the context cut to 8,192 tokens, vLLM does not reserve KV cache for 131,072 potential tokens and can fit more simultaneous requests. The risk is obvious: requests above 8,192 tokens fail with an error. Tune it to the P99 of your real length distribution.&lt;/p>
&lt;hr>
&lt;h2 id="interaction-between-parameters">Interaction between parameters&lt;/h2>
&lt;p>The four parameters are not independent. A common mistake is enabling prefix caching without tuning the block size, or raising &lt;code>max-num-batched-tokens&lt;/code> without checking that &lt;code>max-num-seqs&lt;/code> allows it to be filled:&lt;/p>
&lt;pre tabindex="0">&lt;code>max-num-batched-tokens = 8192
max-num-seqs = 4
average prompt = 512 tokens → 4 × 512 = 2048 prefill tokens &amp;lt; 8192
The 8192 budget is never filled because max-num-seqs caps it first.
Fix: raise max-num-seqs or lower max-num-batched-tokens.
&lt;/code>&lt;/pre>&lt;p>A balanced configuration for RTX 4090 + a 7B model:&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 Qwen/Qwen2.5-7B-Instruct &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --gpu-memory-utilization 0.92 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-model-len &lt;span class="m">8192&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-chunked-prefill &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-batched-tokens &lt;span class="m">8192&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-seqs &lt;span class="m">64&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-prefix-caching &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --kv-cache-dtype fp8 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --calculate-kv-scales
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Configuration for an L40 (48 GB) + a 14B model:&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 Qwen/Qwen2.5-14B-Instruct-AWQ &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --gpu-memory-utilization 0.90 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-model-len &lt;span class="m">16384&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-chunked-prefill &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-batched-tokens &lt;span class="m">16384&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-seqs &lt;span class="m">128&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-prefix-caching &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --kv-cache-dtype fp8 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --calculate-kv-scales
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="how-to-measure-that-it-is-working">How to measure that it is working&lt;/h2>
&lt;p>The metrics that confirm prefill is optimised:&lt;/p>
&lt;pre tabindex="0">&lt;code># In vLLM&amp;#39;s Prometheus metrics (port 8000/metrics):
vllm:time_to_first_token_seconds_bucket → TTFT distribution
vllm:gpu_cache_usage_perc → KV cache utilisation
vllm:prefix_cache_hit_rate → APC hit rate (if enabled)
vllm:num_running_seqs → simultaneous in-flight requests
&lt;/code>&lt;/pre>&lt;p>A &lt;code>prefix_cache_hit_rate&lt;/code> below 30% on workloads with a fixed system prompt means something in the hash is not working (a system prompt that varies by timestamp, a date format in the prompt, and so on).&lt;/p>
&lt;hr>
&lt;h2 id="implications-for-on-premise-inference">Implications for on-premise inference&lt;/h2>
&lt;p>Chunked prefill and prefix caching are &lt;strong>zero cost&lt;/strong>: they are enabled with flags and require no extra hardware. FP8 KV cache requires the model to be compatible (almost every modern transformer is) and that you are on Ada Lovelace or newer.&lt;/p>
&lt;p>For sovereign ENS deployments where the hardware is fixed and you cannot scale horizontally on demand, a well-configured prefill is the difference between needing 4 nodes and needing 2 for the same load.&lt;/p>
&lt;p>The second article in this series covers the &lt;strong>decode&lt;/strong> optimisations: speculative decoding, KV cache tuning to maximise concurrency and how to configure &lt;code>gpu-memory-utilization&lt;/code> without vLLM running out of VRAM at midnight.&lt;/p>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">how the KV cache works inside&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">why FlashAttention changes memory consumption in prefill&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">the foundation chunked prefill builds on&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">when scaling a single node is no longer enough&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/instrumenting-vllm-otel-measuring-optimisations/">how to measure with Prometheus and OTel whether chunked prefill and prefix caching are working: &lt;code>ttft p99&lt;/code>, &lt;code>gpu_prefix_cache_hit_rate&lt;/code> and the concrete alerts&lt;/a>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://arxiv.org/abs/2309.06180">Efficient Memory Management for Large Language Model Serving with PagedAttention&lt;/a> — Kwon et al., 2023&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/2403.02310">Sarathi-Serve: Chunked Prefill and Stall-Free Scheduling&lt;/a> — Agrawal et al., 2024&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/en/stable/configuration/optimization/">vLLM Optimization and Tuning — documentación oficial&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/en/latest/features/quantization/quantized_kvcache/">Quantized KV Cache en vLLM&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Pruning LLM models: cutting without amputating</title><link>https://blog.lo0.es/en/posts/llm-pruning-cutting-without-amputating/</link><pubDate>Fri, 05 Jun 2026 04:00:00 +0000</pubDate><guid>https://blog.lo0.es/en/posts/llm-pruning-cutting-without-amputating/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>A 7B-parameter model has tens of billions of neural connections. Many of them contribute so little that you could remove them without any reasonable benchmark noticing. That is pruning: identifying the irrelevant weights and deleting them to obtain a model that is smaller, faster or lighter on memory. Modern techniques (SparseGPT, Wanda, 2:4 structured sparsity) do this without retraining, in a few GPU hours, and with less than 1 point of perplexity penalty. It does not replace quantisation; it combines with it.&lt;/p>
&lt;hr>
&lt;h2 id="the-analogy">The analogy&lt;/h2>
&lt;p>An oak tree with a hundred branches. When winter comes, the tree prunes its weak branches: it redirects resources towards the main trunks. An expert pruner does not cut at random, they look at which branches have little foliage, which are dry, which grow in the wrong direction, and cut only those.&lt;/p>
&lt;p>A language model is that tree. Its &amp;ldquo;branches&amp;rdquo; are the weights connecting neurons. After training, many of those connections are vestiges of the optimisation process: they existed so the gradient could descend smoothly, but in production they barely change the output. The pruner that removes them precisely is SparseGPT or Wanda. The one that cuts at random is &lt;em>magnitude pruning&lt;/em> without calibration. Both give you a smaller tree; only the expert gives you one that still bears the same fruit.&lt;/p>
&lt;hr>
&lt;h2 id="what-pruning-really-is">What pruning really is&lt;/h2>
&lt;p>A transformer language model stores its knowledge in weight matrices. An attention layer has four matrices: $W_Q, W_K, W_V, W_O$. An FFN layer has at least two ($W_{up}, W_{down}$, plus $W_{gate}$ in SwiGLU). For a 7B model with 32 layers, the number of individual parameters exceeds 7 billion.&lt;/p>
&lt;p>Pruning is the process of setting a subset of those parameters to zero so that:&lt;/p>
&lt;ol>
&lt;li>The resulting model takes up less memory (if stored in a sparse format) or computes fewer operations.&lt;/li>
&lt;li>Answer quality does not drop appreciably.&lt;/li>
&lt;/ol>
&lt;p>There are two classification dimensions that matter:&lt;/p>
&lt;p>&lt;strong>Granularity:&lt;/strong> which unit gets removed.&lt;/p>
&lt;ul>
&lt;li>&lt;em>Unstructured pruning&lt;/em>: individual weights, scattered across the whole matrix. High compression, hard to accelerate on conventional hardware.&lt;/li>
&lt;li>&lt;em>Structured pruning&lt;/em>: whole attention heads, entire FFN neurons, or complete layers. Less compression, but the resulting model is dense and compatible with any hardware.&lt;/li>
&lt;li>&lt;em>Semi-structured N:M&lt;/em>: for each group of M consecutive weights, exactly N are zero. The 2:4 case (2 zeros out of every 4) is the one supported by the Tensor Cores in NVIDIA Ampere and later.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Timing:&lt;/strong> when the removal happens.&lt;/p>
&lt;ul>
&lt;li>&lt;em>Post-training&lt;/em> (weight PTQ): requires no gradient, the standard in large LLMs.&lt;/li>
&lt;li>&lt;em>During training&lt;/em> (gradual/iterative): more precise, incompatible with 70B+ models on cost grounds.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="why-so-many-redundant-weights-exist">Why so many redundant weights exist&lt;/h2>
&lt;p>The answer lies in how the models are trained. Stochastic gradient descent with millions of steps and a decaying learning rate produces networks that are &lt;em>over-parameterised by design&lt;/em>: the extra parameters do not represent additional knowledge, but room for manoeuvre so that optimisation converges more easily.&lt;/p>
&lt;p>The &lt;strong>Lottery Ticket Hypothesis&lt;/strong> (Frankle &amp;amp; Carlin, ICLR 2019) formalised this intuition: inside any trained dense network there is a subnetwork that, trained from scratch in isolation, reaches the same quality. The original network is that subnetwork wrapped in parametric noise generated by the training process.&lt;/p>
&lt;p>For LLMs, the empirical evidence is consistent: 7B–70B models tolerate up to 50% unstructured sparsity with no observable degradation on conversational tasks. In larger models, the tolerance threshold rises.&lt;/p>
&lt;hr>
&lt;h2 id="the-maths-that-matter">The maths that matter&lt;/h2>
&lt;h3 id="which-weights-are-safe-to-remove">Which weights are safe to remove?&lt;/h3>
&lt;h4 id="magnitude-pruning-the-naive-criterion">Magnitude pruning: the naive criterion&lt;/h4>
$$\text{importance}(w_{ij}) = |w_{ij}|$$
&lt;p>The weights with the smallest absolute value are removed. Intuitive, but incomplete: a small weight connected to a very large activation still contributes significantly to the output.&lt;/p>
&lt;h4 id="wanda-magnitude--activation">Wanda: magnitude × activation&lt;/h4>
$$\text{importance}(w_{ij}) = |w_{ij}| \cdot \|x_j\|_2$$
&lt;p>Where $x_j$ is the input activation vector corresponding to weight $j$, computed over a calibration dataset of ~128 samples. The product captures both dimensions: a weight is safe to remove only if &lt;em>it&lt;/em> is small &lt;em>and&lt;/em> its input neuron is barely active.&lt;/p>
&lt;p>&lt;strong>Numerical example:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Weight A: $|w| = 0.001$, $\|x\|_2 = 500$ → importance = 0.5&lt;/li>
&lt;li>Weight B: $|w| = 0.01$, $\|x\|_2 = 10$ → importance = 0.1&lt;/li>
&lt;/ul>
&lt;p>Magnitude pruning would remove A (smaller absolute value). Wanda removes B (lower importance). B is the safer one to delete.&lt;/p>
&lt;p>Wanda requires neither gradients nor Hessian matrix inverses. It runs in minutes on a 70B model on a single GPU. On WikiText-2 perplexity benchmarks at 50% unstructured sparsity, Wanda reaches results comparable to SparseGPT at 10–100× less computational cost.&lt;/p>
&lt;h4 id="sparsegpt-hessian-compensation">SparseGPT: Hessian compensation&lt;/h4>
&lt;p>SparseGPT applies the same mathematical framework as GPTQ (layer-by-layer quantisation), but for pruning. When it removes a weight $w_p$, it computes a correction $\delta w$ over the remaining weights in the same row to minimise the change in the layer&amp;rsquo;s output:&lt;/p>
$$\min_{\delta w} \|W x - (W + \delta W) x\|_2^2 \quad \text{s.t.} \quad w_p + \delta w_p = 0$$
&lt;p>The solution uses the inverse of the second-order Hessian matrix $H = X X^T$. The extra cost is justified by the higher precision when target sparsity is high (&amp;gt;70%) or the model is small (&amp;lt;7B, where redundancy is lower).&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Method&lt;/th>
&lt;th>Criterion&lt;/th>
&lt;th>Cost&lt;/th>
&lt;th>50% sparsity (7B, WikiText-2 ppl)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Magnitude&lt;/td>
&lt;td>|w|&lt;/td>
&lt;td>Instant&lt;/td>
&lt;td>+2–5 points&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Wanda&lt;/td>
&lt;td>|w| · |x|&lt;/td>
&lt;td>Minutes&lt;/td>
&lt;td>~+0.5 points&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>SparseGPT&lt;/td>
&lt;td>Hessian&lt;/td>
&lt;td>1–4h GPU&lt;/td>
&lt;td>~+0.4 points&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="24-structured-sparsity-nvidias-special-case">2:4 Structured Sparsity: NVIDIA&amp;rsquo;s special case&lt;/h3>
&lt;p>NVIDIA Ampere (A100) and later (H100, Ada Lovelace) include dedicated hardware for the 2:4 pattern: exactly 2 out of every 4 consecutive weights are zero. This lets the hardware skip the multiplications by zero efficiently, obtaining up to &lt;strong>2× speedup in matmul&lt;/strong> on models with 2:4 weights.&lt;/p>
&lt;p>The constraint is that the sparsity has to be exactly 2:4, not an arbitrary pattern. NVIDIA&amp;rsquo;s tools (APEX Sparse, cuSPARSELt) and frameworks such as PyTorch 2.x support this natively:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">torch.sparse&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">to_sparse_semi_structured&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">SparseSemiStructuredTensor&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Convert dense weights to 2:4 sparse&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">sparse_weight&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">to_sparse_semi_structured&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">dense_weight&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Forward pass automatically uses sparse tensor cores&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">output&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">F&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">linear&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">input&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">sparse_weight&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>What to expect in practice with 2:4:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>RTX 4090 (Ada Lovelace): supports 2:4 sparse tensor cores for FP16/BF16. Theoretical speedup 2×, real 1.3–1.6× depending on batch and sequence size.&lt;/li>
&lt;li>H100 (Hopper): the same, with additional improvements in FP8 + 2:4 combined.&lt;/li>
&lt;li>A100: supported, without FP8.&lt;/li>
&lt;li>Consumer GPUs earlier than Ada (3090, etc.): &lt;strong>no hardware support&lt;/strong>. 2:4 sparsity gives a smaller model on disk but does not accelerate inference.&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="structured-pruning-removing-whole-heads-and-layers">Structured pruning: removing whole heads and layers&lt;/h2>
&lt;h3 id="attention-head-pruning">Attention head pruning&lt;/h3>
&lt;p>A 32-layer transformer with 32 heads per layer has 1,024 attention heads. Systematic studies on Llama-2 and Qwen models show that between 20–40% of the heads have marginal influence on the final output: their output can be set to zero without the benchmark changing within the margin of error.&lt;/p>
&lt;p>The most used metric is &lt;em>Taylor importance&lt;/em>: the product of the gradient of the loss with respect to the head&amp;rsquo;s output and the value of that output, summed over a calibration dataset:&lt;/p>
$$\text{I}_{head} = \left| \sum_t \frac{\partial \mathcal{L}}{\partial o_t} \cdot o_t \right|$$
&lt;p>The heads with the lowest $I_{head}$ are removed first. After removing 25% of the heads in Llama-3-8B, MMLU degradation is &amp;lt;1% and attention inference time falls ~20% because the attention matmuls are smaller.&lt;/p>
&lt;h3 id="layer-dropping-the-most-aggressive-shortcut">Layer dropping: the most aggressive shortcut&lt;/h3>
&lt;p>Removing a whole transformer layer deletes its attention block and its FFN. The most robust criterion is &lt;strong>Block Influence (BI)&lt;/strong>, introduced in ShortGPT (2024):&lt;/p>
$$\text{BI}(l) = 1 - \cos(\text{input}_l, \text{output}_l)$$
&lt;p>A layer whose output is almost identical to its input (cosine close to 1, BI close to 0) acts as an identity function: removing it does not change the flow of information. The layers in the middle of the transformer tend to have lower BI than the initial and final layers.&lt;/p>
&lt;p>&lt;strong>Numerical example on LLaMA-2-70B:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Layers 0–5 (early): BI &amp;gt; 0.3 → do not remove&lt;/li>
&lt;li>Layers 20–45 (mid): BI &amp;lt; 0.05 → candidates for removal&lt;/li>
&lt;li>Layers 76–80 (final): BI &amp;gt; 0.2 → do not remove&lt;/li>
&lt;/ul>
&lt;p>Removing 8 layers out of 80 (10%): the model goes from ~140 GB to ~126 GB in BF16. Inference speedup: ~10% (proportional to the number of layers removed). Degradation on reasoning benchmarks: 1–3%.&lt;/p>
&lt;hr>
&lt;h2 id="implications-for-on-premise-inference">Implications for on-premise inference&lt;/h2>
&lt;p>Unstructured pruning (50% sparsity) produces models with the same number of parameters but half of them at zero. Without specialised sparse kernels, that gives no speedup: the GPU still runs the multiplications, it just multiplies by zero very efficiently. The real benefit is storage and transfer (the model takes up less on disk and in system RAM).&lt;/p>
&lt;p>With 2:4 structured sparsity on Ada/Hopper hardware, the speedup is real but moderate (1.3–1.7×) and requires additional tooling (cuSPARSELt or PyTorch sparse).&lt;/p>
&lt;p>Structured pruning (heads, layers) does accelerate on any hardware because it reduces the model&amp;rsquo;s real size. It is the right option if the goal is throughput on hardware without sparse tensor cores.&lt;/p>
&lt;p>&lt;strong>Combining with quantisation:&lt;/strong> pruning and quantisation are orthogonal. A 50% sparse model at INT4 takes up roughly an eighth of the original in FP32. It is the end point of many aggressive compression pipelines for edge inference.&lt;/p>
&lt;hr>
&lt;h2 id="applied-to-generic-on-premise-hardware">Applied to generic on-premise hardware&lt;/h2>
&lt;h3 id="rtx-4090-24-gb-ada-lovelace">RTX 4090 (24 GB, Ada Lovelace)&lt;/h3>
&lt;p>Supports 2:4 sparse tensor cores for FP16/BF16. With Wanda + 2:4 sparsity on a Qwen2.5-14B:&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"># Pruning pipeline: Wanda 2:4 + INT4 quantisation&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 1. Run Wanda with calibration over 128 samples&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">python wanda/main.py &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 Qwen/Qwen2.5-14B &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --sparsity_ratio 0.5 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --sparsity_type 2:4 &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --save pruned_model/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 2. Quantise the pruned model (optional but complementary)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">python -m awq.entry --model_path pruned_model/ &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --w_bit &lt;span class="m">4&lt;/span> --output_path pruned_awq_model/
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Expected result: ~13 GB BF16 → ~6.5 GB after 2:4 pruning in sparse format → ~3.2 GB with AWQ INT4. The 14B model will fit in the RTX 4090 with room for KV cache.&lt;/p>
&lt;h3 id="4-h100-sxm-320-gb-total-hopper">4× H100 SXM (320 GB total, Hopper)&lt;/h3>
&lt;p>On this hardware structured pruning (layer dropping) makes more sense than 2:4 for high-concurrency inference: you reduce the number of FLOPs per token proportionally, which benefits throughput at large batch where the bottleneck is compute, not memory.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Apply layer dropping with the ShortGPT BI metric&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">shortgpt&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">compute_block_influence&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">drop_layers&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">bi_scores&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">compute_block_influence&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">model&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">calibration_data&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Remove the 15% of layers with the lowest BI&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">model&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">drop_layers&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">model&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">bi_scores&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">drop_ratio&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mf">0.15&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>A Llama-3-70B pruned by 15% of its layers fits in 3 H100s instead of 4, freeing a GPU for another task.&lt;/p>
&lt;hr>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/ — the complementary lever: quantising reduces the precision of the weights pruning has decided to keep; combined they give maximum compression&lt;/li>
&lt;li>https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/ — pruning reduces the model&amp;rsquo;s size, but the KV cache keeps growing with context; they are separate costs in VRAM&lt;/li>
&lt;li>https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/ — speculative decoding drafters are often pruned versions of the base model, not models trained from scratch&lt;/li>
&lt;li>https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/ — how the pruned model is served in vLLM: throughput parameters change with a structurally smaller model&lt;/li>
&lt;li>https://blog.lo0.es/en/posts/knowledge-distillation-teaching-small-model-think-like-big-one/ — a conceptual alternative to pruning: instead of removing parts of the large model, train a small one to imitate its behaviour&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://arxiv.org/abs/2301.00774">SparseGPT: Massive Language Models Can be Accurately Pruned in One Shot&lt;/a> — Frantar &amp;amp; Alistarh, 2023&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/2306.11695">A Simple and Effective Pruning Approach for Large Language Models (Wanda)&lt;/a> — Sun et al., ICLR 2024&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/1803.03635">The Lottery Ticket Hypothesis&lt;/a> — Frankle &amp;amp; Carlin, ICLR 2019&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/2403.03853">ShortGPT: Layers in Large Language Models are More Redundant Than You Expect&lt;/a> — Men et al., 2024&lt;/li>
&lt;li>&lt;a href="https://pytorch.org/blog/when-quantization-isnt-enough-why-24-sparsity-matters/">NVIDIA 2:4 Sparsity in PyTorch&lt;/a> — PyTorch Blog&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/html/2605.06402">SparseForge: Efficient Semi-Structured LLM Sparsification&lt;/a> — 2025&lt;/li>
&lt;/ul></description></item><item><title>Anatomy of an on-premise LLM inference stack: the seven layers that have to hold each other up</title><link>https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/</link><pubDate>Sat, 30 May 2026 16:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/anatomy-on-premise-llm-inference-stack-seven-layers/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The &lt;strong>on-premise LLM inference stack is not a model server&lt;/strong>: it is a building of &lt;strong>seven layers&lt;/strong> that hold each other up. The inference layer (vLLM / SGLang) serves tokens, but without a dedicated embeddings layer RAG does not work; without the gateway layer the client couples its SDK to the specific engine; without an LLM-aware observability layer (Langfuse + OpenTelemetry GenAI) any quality degradation goes unnoticed; without a GitOps control plane layer (Flux + Forgejo) any manual change leaves invisible debt; and without a &lt;strong>dependency tracking&lt;/strong> layer (Hubble + intent-based policies) decommissioning a Service silently breaks applications nobody remembers were using it. This post is born from a concrete incident: a pipeline that reported &lt;code>status: completed&lt;/code> and &lt;code>matched_jobs: 0&lt;/code> for days because it kept calling an Ollama that had already been scaled to zero, while a badly written &lt;code>except&lt;/code> labelled the generic &lt;code>ConnectError&lt;/code> as &amp;ldquo;ChromaDB indexing error&amp;rdquo; and the vector store, innocent, took the blame. Each of the three symptoms was the cry of a layer that was missing or badly designed. The body of the post walks the seven layers with their canonical OSS piece, the design decisions that break them, the sizing maths on a generic &lt;strong>4×H100 SXM cluster (320 GB of VRAM, NVLink)&lt;/strong> and a final diagram of the connected stack. The thesis: a stack that passes the incident test is not measured by its peak throughput, it is measured by &lt;strong>how long it takes to shout when something drifts from the design&lt;/strong>.&lt;/p>
&lt;h2 id="you-are-here-the-seven-layers-seen-from-above">You are here: the seven layers seen from above&lt;/h2>
&lt;p>Before going into detail, the map. The seven layers are not seven servers: they are seven responsibilities the stack has to cover. A layer can collapse into one pod (gateway) or spread across several components (observability = traces + metrics + flow logs). What it cannot do is &lt;strong>be missing&lt;/strong>.&lt;/p>
&lt;div class="diagram" style="max-width:820px;margin:1rem auto;">
&lt;svg viewBox="0 0 820 360" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="the seven layers of the on-premise LLM inference stack">&lt;style>
.layer{stroke:#333;stroke-width:1.4;rx:6}
.l1{fill:#ffd9b8;stroke:#a44}
.l2{fill:#ffe6c2;stroke:#a55}
.l3{fill:#fff0d0;stroke:#a66}
.l4{fill:#dfe9f5;stroke:#356}
.l5{fill:#d8eecf;stroke:#373}
.l6{fill:#f5e3d8;stroke:#763}
.l7{fill:#ead8f5;stroke:#634}
.title{font:600 13px sans-serif;fill:#222}
.sm{font:11px sans-serif;fill:#222}
.tiny{font:600 10px sans-serif;fill:#222}
.note{font:italic 10px sans-serif;fill:#555}
.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#a)}
&lt;/style>
&lt;defs>&lt;marker id="a" 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="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="410" y="22" text-anchor="middle" class="title">On-premise LLM inference stack — seven layers, one responsibility each&lt;/text>
&lt;rect x="40" y="40" width="740" height="38" class="layer l1"/>
&lt;text x="60" y="64" class="sm">&lt;tspan font-weight="700">1 · Gateway&lt;/tspan> — Envoy AI Gateway · Cilium Gateway API · LiteLLM &lt;tspan class="note">OpenAI-compatible · JWT auth · routing by model · rate limit&lt;/tspan>&lt;/text>
&lt;rect x="40" y="86" width="365" height="50" class="layer l2"/>
&lt;text x="60" y="106" class="sm">&lt;tspan font-weight="700">2 · LLM inference&lt;/tspan>&lt;/text>
&lt;text x="60" y="122" class="tiny">vLLM · SGLang · TensorRT-LLM &lt;tspan class="note" fill="#555">multi-model · multi-LoRA · FP8 KV&lt;/tspan>&lt;/text>
&lt;rect x="415" y="86" width="365" height="50" class="layer l3"/>
&lt;text x="435" y="106" class="sm">&lt;tspan font-weight="700">3 · Embeddings&lt;/tspan>&lt;/text>
&lt;text x="435" y="122" class="tiny">Infinity · TEI · OVMS · sentence-transformers &lt;tspan class="note" fill="#555">fixed dim · separate from the LLM&lt;/tspan>&lt;/text>
&lt;rect x="40" y="144" width="740" height="50" class="layer l4"/>
&lt;text x="60" y="164" class="sm">&lt;tspan font-weight="700">4 · Vector store + relational data&lt;/tspan>&lt;/text>
&lt;text x="60" y="180" class="tiny">Qdrant · pgvector · MinIO (weights · adapters · corpus) &lt;tspan class="note" fill="#555">collection versioned by dim · bucket per tenant&lt;/tspan>&lt;/text>
&lt;rect x="40" y="202" width="740" height="50" class="layer l5"/>
&lt;text x="60" y="222" class="sm">&lt;tspan font-weight="700">5 · LLM-aware observability + infrastructure + network&lt;/tspan>&lt;/text>
&lt;text x="60" y="238" class="tiny">Langfuse · OTel GenAI · VictoriaMetrics · Grafana · Hubble · DCGM &lt;tspan class="note" fill="#555">end-to-end trace_id&lt;/tspan>&lt;/text>
&lt;rect x="40" y="260" width="740" height="40" class="layer l6"/>
&lt;text x="60" y="284" class="sm">&lt;tspan font-weight="700">6 · GitOps control plane&lt;/tspan> — Flux · Forgejo · cert-manager · External Secrets &lt;tspan class="note" fill="#555">binding drift detection&lt;/tspan>&lt;/text>
&lt;rect x="40" y="308" width="740" height="40" class="layer l7"/>
&lt;text x="60" y="332" class="sm">&lt;tspan font-weight="700">7 · Dependency tracking&lt;/tspan> — Hubble flow logs · Otterize · NetworkPolicy as code &lt;tspan class="note" fill="#555">who calls what, observed and declared&lt;/tspan>&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>Layers 1–4 sit on the &lt;strong>request path&lt;/strong>: if they fall, the client notices in seconds. Layers 5–7 sit on the path of &lt;strong>design and operations&lt;/strong>: if they fall, there is no immediately visible error, and that is why they are the ones that produce silent incidents, which is how most serious incidents end up. This post argues that &lt;strong>the quality of the stack is measured in layers 5, 6 and 7&lt;/strong>, because 1–4 are commodities where everyone picks roughly the same pieces.&lt;/p>
&lt;h2 id="the-analogy-the-office-building-with-shared-services">The analogy: the office building with shared services&lt;/h2>
&lt;p>Picture a twelve-storey office building with several tenants. It has a &lt;strong>concierge desk&lt;/strong> (gateway: filters who comes in), it has &lt;strong>lifts&lt;/strong> (LLM inference: they move the heavy load), it has &lt;strong>stairs and a goods lift&lt;/strong> (embeddings: light but constant traffic, hardly anyone notices them until they break), it has &lt;strong>plumbing and water tanks&lt;/strong> (vector store: what actually holds the state), it has a &lt;strong>distribution board and sensors&lt;/strong> (observability: what warns you when something is drawing more than planned), it has a &lt;strong>building manager&lt;/strong> (GitOps control plane: the only legitimate authority to move anything), and it has a &lt;strong>tenant register&lt;/strong> (dependency tracking: who is connected to which shared service).&lt;/p>
&lt;p>When a new client moves into the third floor and asks to install a server that needs more amperage than planned, the problem is not electrical: it is &lt;strong>administrative&lt;/strong>. If the client can go straight to the board and plug in whatever they like, the building survives for a while and then a breaker trips at three in the morning. If the client has to go through the manager, the manager consults the tenant register (is anyone else hanging off that same circuit?), reviews the electrical planning (are we at the limit?) and either authorises or redirects. The building stays standing not because of its electrical installation but because of the &lt;strong>discipline of going through the manager&lt;/strong>.&lt;/p>
&lt;p>The LLM inference stack works identically. The physical layers (1–4) are the visible ones, the ones marketing people put on the slide. The governance layers (5–7) are the ones that distinguish a platform from a pile of pods with good luck.&lt;/p>
&lt;p>Now to the incident that motivates the whole post.&lt;/p>
&lt;h2 id="the-hook-the-log-that-lied-for-six-days">The hook: the log that lied for six days&lt;/h2>
&lt;p>The application is called &lt;em>jobhunter&lt;/em> internally. It is a cron pipeline that every six hours sweeps public job-ad sources, filters by EU geography, embeds the new ads, indexes them in a vector store and matches them against search profiles. The last step fires notifications.&lt;/p>
&lt;p>For six days the pipeline reported the same thing on every run:&lt;/p>
&lt;pre tabindex="0">&lt;code>status: completed
total_found: 756
new: 23
matched_jobs: 0 ← zero, run after run
&lt;/code>&lt;/pre>&lt;p>And in the logs:&lt;/p>
&lt;pre tabindex="0">&lt;code>[INFO] httpx: POST chromadb:8000/api/v2/.../collections/&amp;lt;id&amp;gt;/delete → 200 OK
[INFO] matcher: Purged 12 expired jobs from ChromaDB
[ERROR] pipeline: ChromaDB indexing error: All connection attempts failed
[INFO] httpx: POST chromadb:8000/api/v2/.../collections/&amp;lt;id&amp;gt;/get → 200 OK
[ERROR] pipeline: Matching error: All connection attempts failed
&lt;/code>&lt;/pre>&lt;p>This is a log that invites you to blame ChromaDB. And in fact the first post-mortem written internally pointed at a version incompatibility between the Python client and the v2 server. A reasonable hypothesis, technically plausible, completely false.&lt;/p>
&lt;p>The real cause: weeks earlier the general LLM on the platform had been migrated from &lt;strong>Ollama&lt;/strong> to &lt;strong>vLLM&lt;/strong>. The migration was clean for the two applications that depended directly on the large model, their manifests were pointed at the new endpoint. What nobody did was look at who else was calling the Service &lt;code>ollama.ollama.svc:11434&lt;/code>. &lt;em>jobhunter&lt;/em> called it to generate the embeddings for the ads. When the Ollama deployment was scaled to zero, the Service was left empty, and any outbound connection received a generic &lt;code>ConnectError(&amp;quot;All connection attempts failed&amp;quot;)&lt;/code> from &lt;code>httpcore&lt;/code>. The &lt;code>try/except&lt;/code> wrapping the whole matching stage caught the exception and labelled it &lt;em>&amp;ldquo;ChromaDB indexing error&amp;rdquo;&lt;/em>, because that was the lexical wrapper of the block, not because ChromaDB had anything to do with it. ChromaDB was answering 200 to &lt;em>delete&lt;/em> and &lt;em>get&lt;/em> in those same logs.&lt;/p>
&lt;p>Three factors kept the incident alive for six days:&lt;/p>
&lt;ol>
&lt;li>The &lt;strong>&lt;code>except&lt;/code> named the wrapper rather than the stage that failed&lt;/strong>. The log said &lt;em>&amp;ldquo;ChromaDB indexing error&amp;rdquo;&lt;/em> when the error was an embeddings call against a nonexistent Service.&lt;/li>
&lt;li>The pipeline returned &lt;strong>&lt;code>status: completed&lt;/code>&lt;/strong> even when there were errors. No alerting based on &lt;code>status&lt;/code> fired. The metric that would have fired (matched_jobs stuck at zero) was not instrumented.&lt;/li>
&lt;li>The pipeline image ran as &lt;strong>&lt;code>:latest&lt;/code> with no versioning&lt;/strong>, no SBOM, no reproducibility. When it started failing, it was impossible to know which Ollama client it had been built with.&lt;/li>
&lt;/ol>
&lt;p>Each of the three symptoms is the cry of a layer that was missing. The first asks for &lt;strong>LLM-aware observability&lt;/strong> that distinguishes stages (layer 5). The second asks for the &lt;strong>pipeline logic&lt;/strong> and the Prometheus metrics to behave like contracts (layer 5, SLI/SLO dimension). The third asks for &lt;strong>GitOps with pinned images&lt;/strong> and an auditable SBOM (layer 6). And the whole incident, decommissioning a Service without knowing who consumes it, shouts &lt;strong>dependency tracking&lt;/strong> (layer 7).&lt;/p>
&lt;p>The rest of the post walks the seven layers through that lens: what each one solves, which OSS piece implements it in 2026, and which typical design decision breaks it.&lt;/p>
&lt;h2 id="layer-1--gateway-the-client-sdk-must-not-couple-to-the-engine">Layer 1 — Gateway: the client SDK must not couple to the engine&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> That the client sends &lt;code>POST /v1/chat/completions&lt;/code> with the standard OpenAI SDK and never learns which engine (vLLM, SGLang, TensorRT-LLM), which specific model, which LoRA adapter or even which GPU pool is serving the request. Authentication, rate limit, routing by &lt;code>body.model&lt;/code> and by tenant, header injection for tracing.&lt;/p>
&lt;p>&lt;strong>Canonical OSS piece in 2026.&lt;/strong> &lt;strong>Envoy AI Gateway&lt;/strong> (Envoy with GenAI extensions: routing by model, token-based rate limit, fallback chains) or &lt;strong>Cilium Gateway API&lt;/strong> with your own filters. For cases where the client wants multi-provider without distinguishing on-prem from SaaS, &lt;strong>LiteLLM Proxy&lt;/strong> is the lightweight equivalent.&lt;/p>
&lt;p>&lt;strong>What breaks the layer.&lt;/strong> Exposing the inference engine endpoint directly. If clients call &lt;code>http://vllm-prod.svc:8000&lt;/code>, any change of engine, model or pool forces you to touch the code of every app. The rule: &lt;strong>the engine changes, the contract does not&lt;/strong>. The OpenAI SDK is standard; the routing behind the gateway is where design freedom lives.&lt;/p>
&lt;p>&lt;strong>Where &lt;em>jobhunter&lt;/em> failed.&lt;/strong> There was no gateway. The app called &lt;code>ollama.ollama.svc:11434&lt;/code> directly. When Ollama died, there was no intermediate layer that could answer with a &lt;em>fallback&lt;/em>, a &lt;em>retry against another pool&lt;/em>, or at least a &lt;em>503 error with a descriptive body&lt;/em>.&lt;/p>
&lt;h2 id="layer-2--llm-inference-vllm-as-the-default-choice-sglang-when-prefix-caching-rules">Layer 2 — LLM inference: vLLM as the default choice, SGLang when prefix caching rules&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> Serving tokens with throughput and latency under control: continuous batching, PagedAttention so the KV cache does not fragment the VRAM, FP8 so a 32B model fits with room to spare, multi-LoRA for per-tenant customisation without replicating the base, structured output for function calling with schema guarantees.&lt;/p>
&lt;p>&lt;strong>Canonical OSS piece in 2026.&lt;/strong> &lt;strong>vLLM&lt;/strong> is the default choice: it covers the state of the art (&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">PagedAttention&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">continuous batching&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">FP8 quantisation&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">multi-LoRA&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/structured-output-bitmask-outlines-xgrammar-llguidance/">structured output&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">speculative decoding&lt;/a>). &lt;strong>SGLang&lt;/strong> comes in when the workload has high prefix caching (long chat with large system prompts, agents with repeated instructions), because its RadixAttention composes better than standard prefix caching. For very specialised inference with proprietary NVIDIA kernels, &lt;strong>TensorRT-LLM&lt;/strong>, accepting the hardware lock-in.&lt;/p>
&lt;p>&lt;strong>What breaks the layer.&lt;/strong> Assuming a single model &amp;ldquo;does everything&amp;rdquo;. A 32B chat LLM does not serve &lt;code>/v1/embeddings&lt;/code>; if you try, vLLM answers &lt;code>BadRequestError: &amp;quot;The model does not support Embeddings API&amp;quot;&lt;/code>. Assuming it did was one of the concrete wounds of the incident: the app expected an endpoint the model did not implement.&lt;/p>
&lt;p>&lt;strong>The design decision that costs more later.&lt;/strong> Serving the LLM with aggressive quantisation (INT4) without a quality eval calibrated for your corpus. INT4 with AWQ or GPTQ saves VRAM, but it degrades answers in technical or legal Spanish in a measurable way. The rule: any quantisation change goes through the same golden eval as a model change.&lt;/p>
&lt;h2 id="layer-3--embeddings-separate-from-the-llm-fixed-dimension-a-life-of-its-own">Layer 3 — Embeddings: separate from the LLM, fixed dimension, a life of its own&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> Generating dense vectors for the RAG corpus and for retrieval queries. The embeddings model is &lt;strong>a different thing&lt;/strong> from the chat LLM: different architecture (encoder, not decoder), different size (hundreds of millions of parameters, not tens of thousands of millions), different API (an &lt;code>/embeddings&lt;/code> endpoint that takes text and returns a fixed-dimension vector).&lt;/p>
&lt;p>&lt;strong>Canonical OSS piece in 2026.&lt;/strong> &lt;strong>Infinity&lt;/strong> or &lt;strong>Hugging Face Text Embeddings Inference (TEI)&lt;/strong> to serve models of the &lt;code>bge-*&lt;/code>, &lt;code>multilingual-e5-*&lt;/code>, &lt;code>nomic-embed-*&lt;/code> families with high throughput and multi-model support. &lt;strong>OpenVINO Model Server&lt;/strong> when Intel hardware is available. &lt;strong>sentence-transformers&lt;/strong> as a fallback embedded in the application itself when the corpus is small and the deployment is constrained.&lt;/p>
&lt;p>&lt;strong>What breaks the layer.&lt;/strong> Treating it as &amp;ldquo;the LLM does that too&amp;rdquo;. It does not if it is chat-only; and even when it does (some models have a dual endpoint), mixing chat and embeddings serving in the same process punishes the throughput of both. The physical separation &lt;strong>is&lt;/strong> the design.&lt;/p>
&lt;p>&lt;strong>The technical fact people forget.&lt;/strong> The &lt;strong>vector store and the embeddings model form an indivisible unit&lt;/strong>. Changing the model from &lt;code>multilingual-e5-large&lt;/code> (1024 dim) to &lt;code>multilingual-e5-small&lt;/code> (384 dim) is &lt;strong>not a substitution&lt;/strong>: it is creating a new collection (&lt;code>my_corpus_v2&lt;/code>) and re-embedding the whole corpus. If you &lt;code>upsert&lt;/code> into the old collection, you hit a dim mismatch at runtime that takes the pod down. This seems obvious and is violated constantly, because the embeddings model is chosen once and then forgotten.&lt;/p>
&lt;h2 id="layer-4--vector-store--relational-data--storage-what-actually-holds-the-state">Layer 4 — Vector store + relational data + storage: what actually holds the state&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> Persisting the vectors with efficient filters (&lt;code>tenant_id&lt;/code>, &lt;code>created_at&lt;/code>, &lt;code>source&lt;/code>), persisting the relational metadata (users, configs, versioned prompts, traces), and persisting the model weights, the LoRA adapters, the datasets and the original corpora.&lt;/p>
&lt;p>&lt;strong>Canonical OSS pieces in 2026.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Qdrant&lt;/strong> for large collections (&amp;gt;200 k vectors), payload-aware filters, multi-tenancy via collection or via field.&lt;/li>
&lt;li>&lt;strong>pgvector&lt;/strong> for small collections with mandatory relational joins (wanting to do &lt;code>WHERE doc.author = ... AND vector &amp;lt;=&amp;gt; $1&lt;/code> in the same SQL).&lt;/li>
&lt;li>&lt;strong>PostgreSQL operated by CloudNativePG (CNPG)&lt;/strong> for the relational side: Barman Cloud backups, replication, connection via pooler.&lt;/li>
&lt;li>&lt;strong>MinIO&lt;/strong> for S3-compatible objects: bucket per tenant, cross-site replication, weights and adapters versioned by sha256.&lt;/li>
&lt;li>&lt;strong>Redis&lt;/strong> for queues, rate-limit counters and a cache of frequent retrievals.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>What breaks the layer.&lt;/strong> Assuming the vector store is &lt;em>stateless ephemeral&lt;/em>. It is exactly the opposite: it is the component where losing state costs the most. Without verified backups of the vector store, an index corruption forces you to re-embed the whole corpus, and on a corpus of millions of documents that is hours or days of GPU.&lt;/p>
&lt;p>&lt;strong>The design decision that pays later.&lt;/strong> Forgetting to version the collection by &lt;strong>dimension&lt;/strong> and &lt;strong>embeddings model&lt;/strong>. Suggested convention: &lt;code>my_corpus__embed-multie5l__1024d__v3&lt;/code>. The name carries metadata; any change in any of the three attributes forces a new collection. It is ugly but it protects against an accidental &lt;code>upsert&lt;/code> with the wrong dim.&lt;/p>
&lt;h2 id="layer-5--observability-llm-aware-traces--infrastructure-metrics--flow-logs">Layer 5 — Observability: LLM-aware traces + infrastructure metrics + flow logs&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> That any inference can be recovered from its &lt;code>trace_id&lt;/code>, with all the &lt;code>gen_ai.*&lt;/code> attributes (&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">semantic conventions&lt;/a>) plus your own attributes (&lt;code>tenant_id&lt;/code>, &lt;code>adapter_id&lt;/code>, &lt;code>priority_tier&lt;/code>), a breakdown of latency (queue → prefill → decode → network), tokens consumed, tools invoked, exact model and adapter. And in parallel: Prometheus metrics from vLLM (&lt;code>vllm:num_requests_running&lt;/code>, &lt;code>vllm:gpu_cache_usage_perc&lt;/code>, &lt;code>vllm:prefix_cache_hit_rate&lt;/code>), from the GPU (DCGM Exporter), from the network (Hubble flow logs with drops and NetworkPolicy enforcement).&lt;/p>
&lt;p>&lt;strong>Canonical OSS pieces in 2026.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>OpenTelemetry Collector&lt;/strong> as the single transport for traces, metrics and logs, with OTLP receivers and separate exporters per destination.&lt;/li>
&lt;li>&lt;strong>Langfuse&lt;/strong> self-hosted for the LLM-aware side: tracing, prompt versioning, evals with LLM-as-judge (&lt;a href="https://blog.lo0.es/en/posts/evals-llm-layer-after-tracing/">post&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/llm-as-judge-exam-marker-not-oracle/">post&lt;/a>).&lt;/li>
&lt;li>&lt;strong>VictoriaMetrics + Grafana&lt;/strong> for high-throughput TSDB metrics with long retention.&lt;/li>
&lt;li>&lt;strong>Hubble&lt;/strong> (Cilium) for L3/L4/L7 flow logs and NetworkPolicy visualisation.&lt;/li>
&lt;li>&lt;strong>DCGM Exporter&lt;/strong> for GPU metrics.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>What breaks the layer.&lt;/strong> Wrapping the whole pipeline stage in a single &lt;code>try/except&lt;/code> that labels the exception with the wrapper&amp;rsquo;s name. The operational rule: &lt;strong>one &lt;code>try/except&lt;/code> per stage&lt;/strong>, with the stage label in the message and a Prometheus metric with &lt;code>labels={&amp;quot;stage&amp;quot;: &amp;quot;&amp;lt;name&amp;gt;&amp;quot;}&lt;/code>. That way &lt;em>&amp;ldquo;ChromaDB indexing error&amp;rdquo;&lt;/em> would never have been the log for an embeddings failure; it would have been &lt;em>&amp;ldquo;Embeddings call failed: ConnectError(ollama:11434)&amp;rdquo;&lt;/em>.&lt;/p>
&lt;p>&lt;strong>Complementary rule.&lt;/strong> The pipeline returns &lt;code>status: completed&lt;/code> if and only if &lt;strong>there were no errors&lt;/strong>. With errors it returns &lt;code>completed_with_errors&lt;/code> or &lt;code>failed&lt;/code>, and the metric &lt;code>pipeline_errors_total{stage}&lt;/code> is incremented. An alert based on &lt;code>increase(pipeline_errors_total[1h]) &amp;gt; 0&lt;/code> fires before the second failed run. Without this discipline, observability exists but does not warn.&lt;/p>
&lt;h2 id="layer-6--gitops-control-plane-the-only-legitimate-authority">Layer 6 — GitOps control plane: the only legitimate authority&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> That the cluster state is the state declared in git. That any divergence between git and the cluster is visible and, in critical components, &lt;strong>auto-reconciled or auto-alerted&lt;/strong>. That every deployed image has an immutable tag (sha digest or semver pin), an SBOM (Trivy) and traceability back to the commit that introduced it.&lt;/p>
&lt;p>&lt;strong>Canonical OSS pieces in 2026.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Flux&lt;/strong> (or ArgoCD) as the reconciler.&lt;/li>
&lt;li>&lt;strong>Forgejo&lt;/strong> (or Gitea, GitLab CE) as the self-hosted OSS forge.&lt;/li>
&lt;li>&lt;strong>cert-manager + Trust Manager&lt;/strong> for internal PKI.&lt;/li>
&lt;li>&lt;strong>External Secrets Operator + SOPS&lt;/strong> for encrypted versioned secrets.&lt;/li>
&lt;li>&lt;strong>Kyverno&lt;/strong> (or OPA Gatekeeper) for binding policies: deny &lt;code>:latest&lt;/code> images, deny pods without a NetworkPolicy, deny Services without an owner label.&lt;/li>
&lt;li>&lt;strong>Trivy&lt;/strong> for SBOM and vulnerability scanning of images in the CI pipeline.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>What breaks the layer.&lt;/strong> Images with a mutable tag (&lt;code>:latest&lt;/code>, &lt;code>:main&lt;/code>). Any &lt;code>kubectl edit&lt;/code> in production that is not reflected in git. &lt;code>main&lt;/code> branches with write permissions for humans without review. The rule: &lt;strong>if a human can mutate the cluster without going through a signed commit, you do not have GitOps, you have somebody&amp;rsquo;s whiteboard&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>How it applies to the incident.&lt;/strong> If the pipeline image had been pinned to a sha digest (&lt;code>registry.interno.local/jobhunter@sha256:9af2...&lt;/code>), the team could have audited immediately which Ollama client it carried. With &lt;code>:latest&lt;/code>, not even that.&lt;/p>
&lt;h2 id="layer-7--dependency-tracking-the-layer-the-incident-exposed">Layer 7 — Dependency tracking: the layer the incident exposed&lt;/h2>
&lt;p>&lt;strong>What it has to solve.&lt;/strong> Knowing who calls which Service, both &lt;strong>declaratively&lt;/strong> (what the gitops repo says) and &lt;strong>observed&lt;/strong> (what has been seen crossing the network over the last N days). And, on mature platforms, propagating that information as &lt;strong>policy&lt;/strong>: if nobody declares and nobody observes traffic to the Service &lt;code>ollama.ollama.svc&lt;/code>, decommissioning it is safe; if somebody declares it or uses it, decommissioning it opens a ticket.&lt;/p>
&lt;p>&lt;strong>Canonical OSS pieces in 2026.&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Hubble&lt;/strong> (Cilium) for observed flow logs: &lt;code>hubble observe --to-namespace ollama --since 14d&lt;/code> gives the list of source namespaces that have talked to Ollama in the last two weeks.&lt;/li>
&lt;li>&lt;strong>Otterize&lt;/strong> for intent-based policy: each Deployment declares &lt;em>&amp;ldquo;I need to talk to &lt;code>ollama-svc&lt;/code>&amp;rdquo;&lt;/em>, and the operator generates the corresponding NetworkPolicy and maintains a browsable catalogue of who is trying to talk to what.&lt;/li>
&lt;li>&lt;strong>manual kubectl-grep&lt;/strong> as a fallback: &lt;code>kubectl get deployments,cronjobs,statefulsets -A -o yaml | grep -E 'ollama[.-]'&lt;/code> produces the declarative list.&lt;/li>
&lt;li>&lt;strong>NetworkPolicy as code&lt;/strong> reviewed in CI: every PR that touches a Service requires the associated policy to be kept or explicitly updated.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>A pre-decom checklist&lt;/strong> that the incident suggests codifying as a CI hook:&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="nv">SVC&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;ollama.ollama.svc.cluster.local&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># (a) declarative grep in the gitops repo&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">git -C &lt;span class="nv">$GITOPS_REPO&lt;/span> grep -l &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$SVC&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="nb">echo&lt;/span> &lt;span class="s2">&amp;#34;OK declarative&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># (b) observed grep in Hubble (last 14 days)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">hubble observe --to-fqdn &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$SVC&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> --since 336h --output json &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="p">|&lt;/span> jq -r &lt;span class="s1">&amp;#39;.source.namespace&amp;#39;&lt;/span> &lt;span class="p">|&lt;/span> sort -u &lt;span class="o">||&lt;/span> &lt;span class="nb">echo&lt;/span> &lt;span class="s2">&amp;#34;OK observed&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># (c) live grep in the cluster&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">kubectl get all -A -o yaml &lt;span class="p">|&lt;/span> grep -E &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$SVC&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="o">||&lt;/span> &lt;span class="nb">echo&lt;/span> &lt;span class="s2">&amp;#34;OK live&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If all three return empty, the decom is safe. If any of them has content, there is downstream debt still open. The &lt;em>jobhunter&lt;/em> incident is exactly what happens when this check does not exist: the team that decommissioned Ollama looked at the list of applications it knew depended on it directly; nobody looked at the list of those that depended on it silently.&lt;/p>
&lt;h2 id="the-maths-that-matter-sizing-the-stack-on-4h100-sxm-320-gb">The maths that matter: sizing the stack on 4×H100 SXM (320 GB)&lt;/h2>
&lt;p>The generic reference cluster for everything that follows: &lt;strong>4×H100 SXM 80 GB&lt;/strong>, &lt;strong>NVLink&lt;/strong> between the four, &lt;strong>640 GB&lt;/strong> of system RAM, &lt;strong>2×NVMe NVMe-oF&lt;/strong> for local storage of weights and caches, &lt;strong>redundant 25/100 GbE&lt;/strong> to the switch. Aggregate VRAM: &lt;strong>320 GB&lt;/strong>.&lt;/p>
&lt;p>The VRAM budget is not free. A first reasonable split for a stack that serves one large general LLM, a medium code-specialised model, embeddings and a reranker, with room for multi-LoRA and for the KV cache:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Component&lt;/th>
&lt;th>Reference model&lt;/th>
&lt;th>Quant&lt;/th>
&lt;th>Model weights&lt;/th>
&lt;th>Reserved KV cache&lt;/th>
&lt;th>Total VRAM&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>General LLM (TP=4)&lt;/td>
&lt;td>70B-instruct&lt;/td>
&lt;td>FP8 W8A8&lt;/td>
&lt;td>70 GB&lt;/td>
&lt;td>60 GB&lt;/td>
&lt;td>&lt;strong>130 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Code LLM (TP=2)&lt;/td>
&lt;td>32B-coder&lt;/td>
&lt;td>FP8 W8A8&lt;/td>
&lt;td>32 GB&lt;/td>
&lt;td>28 GB&lt;/td>
&lt;td>&lt;strong>60 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Embeddings&lt;/td>
&lt;td>multilingual-e5-large&lt;/td>
&lt;td>FP16&lt;/td>
&lt;td>1.3 GB&lt;/td>
&lt;td>n/a&lt;/td>
&lt;td>&lt;strong>8 GB&lt;/strong> (×2 replicas)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reranker&lt;/td>
&lt;td>bge-reranker-v2-m3&lt;/td>
&lt;td>FP16&lt;/td>
&lt;td>0.6 GB&lt;/td>
&lt;td>n/a&lt;/td>
&lt;td>&lt;strong>4 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multi-LoRA pool (over the general LLM)&lt;/td>
&lt;td>up to 16 adapters&lt;/td>
&lt;td>bf16&lt;/td>
&lt;td>16 × 0.4 GB ≈ 6 GB&lt;/td>
&lt;td>reuses the LLM&amp;rsquo;s KV&lt;/td>
&lt;td>&lt;strong>6 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reserved for fragmentation + overhead&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;strong>~30 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Total committed&lt;/strong>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;strong>~238 GB / 320 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Free headroom&lt;/strong>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;strong>~82 GB&lt;/strong>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The 26 % of free headroom is not waste: it is what lets the vLLM scheduler avoid preempting requests under moderate pressure, lets continuous batching group large batches without aborting, and lets a failover from the other site promote a standby without OOM.&lt;/p>
&lt;p>&lt;strong>Expected throughput&lt;/strong>, with the 70B general LLM in FP8 and tensor parallel 4, on an H100 SXM with continuous batching active and prefix caching at 35–55 % (typical in multi-turn chat with shared system prompts):&lt;/p>
$$
\text{aggregate tokens/second} \approx 1500 \text{ to } 2500
$$
&lt;p>for concurrency between 32 and 64 requests, with sub-second TTFT P95 on short prompts (&amp;lt;2k tokens) and TPOT P95 around &lt;strong>40–60 ms/token&lt;/strong> as perceived by the client. These numbers are &lt;strong>reasonable orders of magnitude&lt;/strong>, not guarantees: real throughput depends on the prompt mix, on whether speculative decoding (EAGLE-3) is active and burnt-in, and on the network cost between gateway and inference pods.&lt;/p>
&lt;p>&lt;strong>Embeddings throughput&lt;/strong> on two replicas of &lt;code>multilingual-e5-large&lt;/code> with dynamic batching:&lt;/p>
$$
\text{embeddings/second} \approx 3000 \text{ to } 6000 \quad (\text{optimal batch} \sim 64)
$$
&lt;p>Enough to reindex a corpus of &lt;strong>1 million documents&lt;/strong> in roughly an hour, assuming chunks of 512 tokens and two chunks per document on average. For corpora of tens of millions of documents, re-embedding is done by delta via CDC over the source (covered in &lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation&lt;/a>), not by full sweep.&lt;/p>
&lt;p>&lt;strong>Retrieval latency&lt;/strong> on Qdrant with HNSW (M=16, ef_construct=200) over a collection of 5 million 1024-dim vectors filtered by &lt;code>tenant_id&lt;/code>:&lt;/p>
$$
\text{P95 latency retrieve top-50} \approx 8 \text{ to } 25 \text{ ms}
$$
&lt;p>Below the cost of cross-encoder reranking (&lt;code>bge-reranker-v2-m3&lt;/code> over top-50 = roughly 30–60 ms more), and below any call to the LLM. The bottleneck in a well sized RAG pipeline is never the vector store: it is the LLM&amp;rsquo;s decoding.&lt;/p>
&lt;h2 id="final-diagram-the-complete-connected-stack">Final diagram: the complete connected stack&lt;/h2>
&lt;div class="diagram" style="max-width:820px;margin:1rem auto;">
&lt;svg viewBox="0 0 820 540" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="complete diagram of the on-premise LLM inference stack with its seven layers connected">&lt;style>
.b{stroke:#333;stroke-width:1.4;rx:6}
.gw{fill:#ffd9b8;stroke:#a44}
.llm{fill:#ffe6c2;stroke:#a55}
.emb{fill:#fff0d0;stroke:#a66}
.data{fill:#dfe9f5;stroke:#356}
.obs{fill:#d8eecf;stroke:#373}
.ctrl{fill:#f5e3d8;stroke:#763}
.dep{fill:#ead8f5;stroke:#634}
.bg{fill:#fafafa;stroke:#bbb;rx:8}
.lbl{font:600 12px sans-serif;fill:#222}
.sm{font:11px sans-serif;fill:#222}
.tiny{font:600 10px sans-serif;fill:#222}
.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#a)}
.otel{stroke:#1a73e8;stroke-width:1.4;fill:none;stroke-dasharray:3 2;marker-end:url(#ab)}
.ctrlarr{stroke:#c66;stroke-width:1.2;fill:none;stroke-dasharray:5 3;marker-end:url(#ac)}
&lt;/style>
&lt;defs>
&lt;marker id="a" 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="#666"/>&lt;/marker>
&lt;marker id="ab" 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="#1a73e8"/>&lt;/marker>
&lt;marker id="ac" 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="#c66"/>&lt;/marker>
&lt;/defs>
&lt;text x="410" y="22" text-anchor="middle" class="lbl">On-premise LLM inference stack — seven layers, stable contracts between them&lt;/text>
&lt;!-- Client -->
&lt;rect x="340" y="36" width="140" height="32" class="b gw"/>
&lt;text x="410" y="56" text-anchor="middle" class="sm">Client · OpenAI SDK&lt;/text>
&lt;path class="arr" d="M410,68 L410,82"/>
&lt;!-- Gateway -->
&lt;rect x="80" y="86" width="660" height="40" class="b gw"/>
&lt;text x="100" y="103" class="tiny">LAYER 1 · GATEWAY&lt;/text>
&lt;text x="410" y="120" text-anchor="middle" class="sm">Envoy AI Gateway · routing by body.model · JWT (Defguard) · rate-limit per tenant · trace_id injection&lt;/text>
&lt;!-- Inference + Embeddings + Reranker -->
&lt;rect x="80" y="146" width="320" height="100" class="b llm"/>
&lt;text x="100" y="163" class="tiny">LAYER 2 · LLM INFERENCE&lt;/text>
&lt;rect x="100" y="174" width="280" height="28" class="b" fill="#fff4e0"/>
&lt;text x="240" y="192" text-anchor="middle" class="sm">vLLM · general LLM 70B FP8 · TP=4 · multi-LoRA&lt;/text>
&lt;rect x="100" y="208" width="280" height="28" class="b" fill="#fff4e0"/>
&lt;text x="240" y="226" text-anchor="middle" class="sm">vLLM · code LLM 32B FP8 · TP=2 · structured out.&lt;/text>
&lt;rect x="420" y="146" width="320" height="100" class="b emb"/>
&lt;text x="440" y="163" class="tiny">LAYER 3 · EMBEDDINGS + RERANKER&lt;/text>
&lt;rect x="440" y="174" width="280" height="28" class="b" fill="#fffae8"/>
&lt;text x="580" y="192" text-anchor="middle" class="sm">Infinity · multilingual-e5-large 1024d · ×2&lt;/text>
&lt;rect x="440" y="208" width="280" height="28" class="b" fill="#fffae8"/>
&lt;text x="580" y="226" text-anchor="middle" class="sm">TEI · bge-reranker-v2-m3 (top-50 rerank)&lt;/text>
&lt;path class="arr" d="M250,126 L250,146"/>
&lt;path class="arr" d="M570,126 L570,146"/>
&lt;!-- Vector store + storage -->
&lt;rect x="80" y="266" width="660" height="80" class="b data"/>
&lt;text x="100" y="283" class="tiny">LAYER 4 · VECTOR STORE + RELATIONAL DATA + STORAGE&lt;/text>
&lt;rect x="100" y="294" width="160" height="40" class="b" fill="#eef3fb"/>
&lt;text x="180" y="319" text-anchor="middle" class="sm">Qdrant (HNSW)&lt;/text>
&lt;rect x="280" y="294" width="160" height="40" class="b" fill="#eef3fb"/>
&lt;text x="360" y="319" text-anchor="middle" class="sm">PostgreSQL (CNPG)&lt;/text>
&lt;rect x="460" y="294" width="160" height="40" class="b" fill="#eef3fb"/>
&lt;text x="540" y="319" text-anchor="middle" class="sm">MinIO (weights · adapters)&lt;/text>
&lt;rect x="640" y="294" width="100" height="40" class="b" fill="#eef3fb"/>
&lt;text x="690" y="319" text-anchor="middle" class="sm">Redis&lt;/text>
&lt;path class="arr" d="M580,246 L580,266"/>
&lt;!-- Observability -->
&lt;rect x="80" y="366" width="660" height="60" class="b obs"/>
&lt;text x="100" y="383" class="tiny">LAYER 5 · OBSERVABILITY (LLM-aware traces · metrics · flow logs)&lt;/text>
&lt;rect x="100" y="394" width="150" height="24" class="b" fill="#eaf5e2"/>
&lt;text x="175" y="411" text-anchor="middle" class="sm">Langfuse&lt;/text>
&lt;rect x="260" y="394" width="150" height="24" class="b" fill="#eaf5e2"/>
&lt;text x="335" y="411" text-anchor="middle" class="sm">OTel Collector&lt;/text>
&lt;rect x="420" y="394" width="150" height="24" class="b" fill="#eaf5e2"/>
&lt;text x="495" y="411" text-anchor="middle" class="sm">VictoriaMetrics + Grafana&lt;/text>
&lt;rect x="580" y="394" width="160" height="24" class="b" fill="#eaf5e2"/>
&lt;text x="660" y="411" text-anchor="middle" class="sm">Hubble + DCGM Exporter&lt;/text>
&lt;!-- OTel lines from layers to observability -->
&lt;path class="otel" d="M740,106 L770,106 L770,396 L740,396"/>
&lt;path class="otel" d="M400,196 L770,196" opacity="0.6"/>
&lt;path class="otel" d="M400,310 L770,310" opacity="0.6"/>
&lt;!-- GitOps control plane -->
&lt;rect x="80" y="446" width="380" height="40" class="b ctrl"/>
&lt;text x="100" y="463" class="tiny">LAYER 6 · GITOPS CONTROL PLANE&lt;/text>
&lt;text x="270" y="479" text-anchor="middle" class="sm">Forgejo → Flux → cert-manager · External Secrets · Kyverno&lt;/text>
&lt;!-- Dependency tracking -->
&lt;rect x="480" y="446" width="260" height="40" class="b dep"/>
&lt;text x="500" y="463" class="tiny">LAYER 7 · DEPENDENCY TRACKING&lt;/text>
&lt;text x="610" y="479" text-anchor="middle" class="sm">Hubble flows · Otterize intents&lt;/text>
&lt;!-- Control plane reconciliation → all layers -->
&lt;path class="ctrlarr" d="M270,446 L270,430 L40,430 L40,106 L80,106"/>
&lt;path class="ctrlarr" d="M610,446 L610,430 L780,430 L780,106 L740,106"/>
&lt;text x="60" y="505" class="tiny">Request flow&lt;/text>
&lt;line x1="155" y1="502" x2="180" y2="502" stroke="#666" stroke-width="1.4"/>
&lt;text x="240" y="505" class="tiny">OTel telemetry&lt;/text>
&lt;line x1="335" y1="502" x2="360" y2="502" stroke="#1a73e8" stroke-width="1.4" stroke-dasharray="3 2"/>
&lt;text x="430" y="505" class="tiny">GitOps reconciliation&lt;/text>
&lt;line x1="555" y1="502" x2="580" y2="502" stroke="#c66" stroke-width="1.2" stroke-dasharray="5 3"/>
&lt;/svg>
&lt;/div>
&lt;p>The solid lines are the request path: client → gateway → inference engine → vector store/embeddings → response. The dashed blue lines are telemetry: each component emits OTel to the collector, which routes traces to Langfuse, metrics to VictoriaMetrics and logs to Loki. The dashed red lines are reconciliation: the GitOps control plane keeps any layer in its declared state and warns of divergence.&lt;/p>
&lt;p>The diagram is not decorative: every arrow is a &lt;strong>stable contract&lt;/strong> between two layers. If one layer changes (vLLM → SGLang, multilingual-e5 → bge-m3, Qdrant → pgvector), the arrows hold. That stability of contracts is the architectural property that lets a team migrate components without breaking downstream apps.&lt;/p>
&lt;h2 id="typical-design-decisions-that-break-the-stack">Typical design decisions that break the stack&lt;/h2>
&lt;p>A short list of mistakes seen repeatedly in stacks that looked well designed on paper:&lt;/p>
&lt;p>&lt;strong>1. Coupling the client SDK to the inference engine.&lt;/strong> Dropping the gateway because &amp;ldquo;vLLM already speaks OpenAI-compatible&amp;rdquo; works on day one and hurts the day you have to add a fallback, a canary or a second model.&lt;/p>
&lt;p>&lt;strong>2. Sharing the LLM and embeddings endpoint.&lt;/strong> A &lt;code>qwen2.5-32b-Instruct&lt;/code> is chat-only; &lt;code>BadRequestError: &amp;quot;The model does not support Embeddings API&amp;quot;&lt;/code> is the cry of a design that confused the two layers.&lt;/p>
&lt;p>&lt;strong>3. Reusing the vector store collection when changing the embeddings model.&lt;/strong> Different dimensions do not allow &lt;code>upsert&lt;/code>. Versioning the collection by &lt;code>(model, dim, version)&lt;/code> is ugly but it saves the day of the change.&lt;/p>
&lt;p>&lt;strong>4. A &lt;code>try/except&lt;/code> wrapping an entire pipeline with the wrapper&amp;rsquo;s label.&lt;/strong> The log lies because the label is lexical, not causal. Every stage in its own &lt;code>try/except&lt;/code> with its own label and its own metric.&lt;/p>
&lt;p>&lt;strong>5. &lt;code>status: completed&lt;/code> with errors.&lt;/strong> The pipeline has to distinguish &lt;code>completed&lt;/code>, &lt;code>completed_with_errors&lt;/code> and &lt;code>failed&lt;/code>, and the alerting has to fire on the last two. Without this, observability exists in theory and does not warn in practice.&lt;/p>
&lt;p>&lt;strong>6. Images with a mutable tag.&lt;/strong> &lt;code>:latest&lt;/code> and &lt;code>:main&lt;/code> are not tags, they are aliases. Without a sha digest there is no reproducibility and no auditable SBOM.&lt;/p>
&lt;p>&lt;strong>7. Decommissioning a Service without a pre-decom check.&lt;/strong> The three-grep check (declarative + observed + live) takes two minutes and costs six days of incident when it is skipped.&lt;/p>
&lt;p>&lt;strong>8. Default &lt;code>limits.memory&lt;/code> on pods that load models.&lt;/strong> A sidecar loading &lt;code>sentence-transformers + torch + tokenizer&lt;/code> needs 2–4 GB; with &lt;code>limits.memory: 1Gi&lt;/code> you hit OOM on the first pod restart, and sometimes with no alert because the liveness probe answers through another route.&lt;/p>
&lt;p>All of them are variants of the same principle: the stack does not fail in its most expensive layer (inference, where nobody underestimates the cost), it fails in the cheap, boring layers (gateway, observability, GitOps, dependency tracking) where it is tempting to save.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware-a-4h100-sxm-cluster">Applied to typical on-premise hardware: a 4×H100 SXM cluster&lt;/h2>
&lt;p>On the generic reference cluster (4×H100 SXM 80 GB, NVLink, 640 GB RAM), the suggested split into pods:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">gpu-node-01 (4×H100 SXM, intra-node NVLink)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── vllm-llm-general (TP=4) ~130 GB VRAM (4 GPUs)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── (shares GPUs with the multi-LoRA pool on the same deployment)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">gpu-node-02 (4×H100 SXM, second node)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── vllm-llm-code (TP=2) ~60 GB VRAM (2 GPUs)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── infinity-embeddings (×2) ~16 GB VRAM (shared on 1 GPU with optional MIG)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── tei-reranker ~4 GB VRAM (cohabitant)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── failover reserve ~120 GB VRAM free for canary / standby
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Not everything fits comfortably on a single node; two nodes with two H100 SXM each would be enough for the conservative setup, and the rest of the cluster (CPU-bound: gateway, vector store, observability, control plane) runs on GPU-free nodes.&lt;/p>
&lt;p>The operational rule: &lt;strong>inference concentrates&lt;/strong>, the rest of the stack distributes. Concentrating inference maximises the use of NVLink (cross-GPU tensor parallel without going through PCIe); distributing the rest prevents an event on the GPU node from taking the control plane down with it.&lt;/p>
&lt;p>An even more conservative configuration, for SMEs with a single 4×H100 SXM node as a starting point, serves the general LLM (TP=4) and embeddings/reranker cohabiting with MIG (Multi-Instance GPU to partition an H100 into hardware-isolated slices). The code LLM is deferred to a second phase. It is viable and cost-aware; what is not viable is doing without layers 5, 6 and 7.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-posts">What we have not covered (upcoming posts)&lt;/h2>
&lt;p>This post focuses on the &lt;strong>static design&lt;/strong> of the stack. Some pieces still deserve an article of their own:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>The multi-site active/standby plane&lt;/strong>: Cilium Cluster Mesh, cross-cluster Qdrant replication, realistic RTO/RPO, when active-active pays off and when it does not.&lt;/li>
&lt;li>&lt;strong>The continuous fine-tuning plane&lt;/strong>: how the LoRA pipeline closes the loop from production feedback to a promoted adapter, in the spirit of the &lt;a href="https://blog.lo0.es/en/posts/retrain-closing-the-loop-feedback-dataset-adapter/">retrain post&lt;/a>.&lt;/li>
&lt;li>&lt;strong>The safety/guardrails plane&lt;/strong>: where Llama Guard fits, Presidio for PII and XGrammar for guaranteed structured output, connected to layer 1, the gateway.&lt;/li>
&lt;li>&lt;strong>The cost plane&lt;/strong>: &lt;code>gen_ai.usage.*&lt;/code> instrumentation at tenant and model level, tokens/euro dashboards, GPU elasticity decisions via KEDA.&lt;/li>
&lt;li>&lt;strong>The compliance plane&lt;/strong>: how the stack maps to ENS High, NIS2 and ISO/IEC 42001 without turning the deployment into a compliance exercise that paralyses delivery.&lt;/li>
&lt;li>&lt;strong>Below the engine&lt;/strong>: the mini-series that opens the stack&amp;rsquo;s basement — &lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">the interconnect (NVLink/NCCL)&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/numa-hugepages-cpu-isolation-gpu-waits-kernel/">the host (NUMA, hugepages, CPU isolation)&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/cpu-memory-topology-manager-rke2/">the RKE2 resource managers&lt;/a> that pin each pod to the right NUMA node.&lt;/li>
&lt;/ul>
&lt;p>Each one falls into a different series on the blog and will be covered with the same discipline: a concrete piece, a justified decision, the typical mistake seen in practice.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the general framework this stack operates in.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/oss-catalogue-llmops-six-stages-tool-by-tool/">The OSS catalogue for LLMOps in six stages&lt;/a> — a card per tool in the stack.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/anatomy-llm-request-production-tour-six-stages/">Anatomy of an LLM request in production&lt;/a> — the same architecture seen from the perspective of an individual request.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/parallel-catalogue-llmops-stages-open-source-hyperscalers/">OSS vs hyperscalers in LLMOps&lt;/a> — a comparison with the AWS/Azure/GCP stacks.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-tracing-opentelemetry-genai/">LLM tracing with OpenTelemetry GenAI&lt;/a> — the backbone of layer 5.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving&lt;/a> — the piece that gives per-tenant customisation without replicating the base.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/rag-corpus-curation-active-librarian/">RAG corpus curation&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/reranker-hybrid-retrieval-five-chunks-llm-reads/">Reranker and hybrid retrieval&lt;/a> — everything that goes into layers 3-4 so that RAG does not degrade.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation for LLM inference&lt;/a> — the reason for the FP8 W8A8 in the sizing.&lt;/li>
&lt;/ul>
&lt;h3 id="relevant-official-documentation">Relevant official documentation&lt;/h3>
&lt;ul>
&lt;li>vLLM Production Stack — &lt;a href="https://docs.vllm.ai/">docs.vllm.ai&lt;/a>&lt;/li>
&lt;li>SGLang RadixAttention — &lt;a href="https://github.com/sgl-project/sglang">github.com/sgl-project/sglang&lt;/a>&lt;/li>
&lt;li>Envoy AI Gateway — &lt;a href="https://aigateway.envoyproxy.io/">aigateway.envoyproxy.io&lt;/a>&lt;/li>
&lt;li>Langfuse self-hosted — &lt;a href="https://langfuse.com/docs/self-hosting">langfuse.com/docs/self-hosting&lt;/a>&lt;/li>
&lt;li>OpenTelemetry Semantic Conventions for GenAI — &lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/">opentelemetry.io/docs/specs/semconv/gen-ai&lt;/a>&lt;/li>
&lt;li>Hubble flow observability — &lt;a href="https://docs.cilium.io/en/stable/observability/hubble/">docs.cilium.io/en/stable/observability/hubble&lt;/a>&lt;/li>
&lt;li>Otterize intent-based access — &lt;a href="https://docs.otterize.com/">docs.otterize.com&lt;/a>&lt;/li>
&lt;li>Flux GitOps toolkit — &lt;a href="https://fluxcd.io/">fluxcd.io&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Continuous batching: the salon with 8 chairs that does not wait for the slow customer — Orca, vLLM, chunked prefill and goodput</title><link>https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/</link><pubDate>Sat, 30 May 2026 16:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/</guid><description>&lt;blockquote>
&lt;p>This post complements those on &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> (the artefact continuous batching manages), &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention&lt;/a> (the memory piece that makes it viable), &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving&lt;/a> (the next layer of optimisation), &lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/">MoE&lt;/a> (the three extensions that coexist with the scheduler in production).&lt;/p>
&lt;/blockquote>
&lt;h2 id="you-are-here-deploy">You are here: DEPLOY&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 90" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="you are here: Deploy">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#7ad88f;stroke-width:3}.idle{fill:#f4f4f4}.lbl{font:600 12px sans-serif;fill:#222}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#cbm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#cbm)}&lt;/style>
&lt;defs>&lt;marker id="cbm" 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="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="20" text-anchor="middle" class="lbl">You are here: DEPLOY · iterative scheduler, one piece below PagedAttention&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box idle"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box active"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box idle"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The static batching of the original HuggingFace Transformers (the pre-2022 era) systematically under-used the GPU for two structural reasons. First: the scheduling unit was the complete request; the longest one in the batch blocked all the others until it finished (severe head-of-line blocking, P95 TTFT five to ten times worse than reasonable). Second: each batch slot reserved memory for &lt;code>max_seq_len&lt;/code> even though the real output was much shorter; documented padding waste sat between 60 % and 80 % and sustained GPU SM utilisation on real workloads fell to 20-40 %. &lt;strong>Orca&lt;/strong> (Yu et al., OSDI 2022, FriendliAI + Seoul National University) introduced the idea that unjammed everything: the scheduling unit stops being the request and becomes &lt;strong>one decoder iteration&lt;/strong>, a single token. After each iteration the scheduler can add new requests to the batch and remove the finished ones. &lt;strong>vLLM&lt;/strong> (Kwon et al., SOSP 2023, UC Berkeley) materialised it open-source and production-grade thanks to &lt;strong>PagedAttention&lt;/strong>, which solves the KV cache fragmentation that theoretical continuous batching would cause with dynamic allocation. &lt;strong>SARATHI / Sarathi-Serve&lt;/strong> (Microsoft Research India, OSDI 2024) closed the last gap: the prefill &lt;em>stalls&lt;/em> that paused active decodes when a new request arrived, via &lt;strong>chunked prefill&lt;/strong> (splitting a long prefill into small chunks and mixing them with decodes in the same step) and &lt;em>stall-free batching&lt;/em>. &lt;strong>DistServe&lt;/strong> (Zhong et al., OSDI 2024) reformulated the key metric: what matters is &lt;strong>goodput&lt;/strong> (requests/s &lt;strong>meeting the TTFT and TPOT SLOs&lt;/strong>), not raw throughput. In May 2026, vLLM v1 brings a unified scheduler with chunked prefill always-on; SGLang adds RadixAttention, which gives cross-request prefix-cache hits; TensorRT-LLM calls it &lt;em>in-flight batching&lt;/em>; llama.cpp supports it natively. The three operational tensions are speculative decoding (nested raggedness), multi-LoRA (each request with its adapter) and MoE (each expert sees very few tokens per step at a typical batch size). This post takes apart the mechanism, the maths (GPU utilisation, goodput vs throughput), the three variants (Orca → vLLM → Sarathi-Serve), the pitfalls (preempt-on-OOM, starvation, inverse HoL) and the real numbers with production configurations.&lt;/p>
&lt;h2 id="the-analogy-the-salon-with-8-chairs">The analogy: the salon with 8 chairs&lt;/h2>
&lt;p>A hair salon with 8 chairs and a single brilliant stylist who moves between them. Customers arrive with very different needs: some want a 15-minute cut, others a 2-hour colour with a base, others a 90-minute straightening. The question is how to organise the flow.&lt;/p>
&lt;p>The &lt;strong>traditional strategy&lt;/strong> (what HuggingFace Transformers did in its original &lt;code>generate()&lt;/code>) is to seat 8 customers at once, all at the same time, and accept nobody new until the last one finishes. If one of those 8 is a 2-hour job, the 7 who wanted the 15-minute cut sit there doing nothing for 1 hour and 45 minutes. The stylist finishes with the quick ones and is left staring at empty chairs until the colour is done. When everyone is ready, another 8 come in. That is &lt;strong>static batching&lt;/strong>, and the only thing that stops it from being worse is that the GPU does not complain like a human customer.&lt;/p>
&lt;p>The &lt;strong>continuous strategy&lt;/strong> (Orca, vLLM) changes the scheduling unit. The stylist does not think &amp;ldquo;I am going to do one whole customer and then the next&amp;rdquo;; he thinks &amp;ldquo;on each &lt;strong>tick&lt;/strong> I take one step of work at every occupied chair and, every time a chair frees up, I call the next customer in the queue without waiting for the others to finish&amp;rdquo;. The quick-cut customer leaves after 15 minutes, his chair is filled immediately with the next one, and the slow jobs proceed at their own pace without delaying anyone. The stylist is never idle.&lt;/p>
&lt;p>The &lt;strong>continuous strategy with chunked prefill&lt;/strong> (SARATHI / Sarathi-Serve) adds a subtler distinction. Some customers need a long initial phase (a 10-minute hair analysis before the cut; in LLM terms, the &lt;em>prefill&lt;/em> of the prompt). Without chunked prefill, the stylist had to stop all the other chairs to do the new customer&amp;rsquo;s analysis in one go, and that was a &lt;em>stall&lt;/em> visible in the TPOT of the active ones. With chunked prefill, the analysis is split into 2-minute pieces interleaved between the other customers&amp;rsquo; active cuts. The customers already under way no longer notice pauses; the new customer takes slightly longer to start his cut proper, but the whole salon does not freeze.&lt;/p>
&lt;p>And the metric that matters: the owner does not want to maximise &amp;ldquo;customers served per hour&amp;rdquo; at the cost of some leaving furious. He wants to maximise &amp;ldquo;customers served per hour within the time SLA&amp;rdquo;, and that is &lt;strong>goodput&lt;/strong>, DistServe&amp;rsquo;s contribution.&lt;/p>
&lt;h2 id="the-problem-continuous-batching-solves">The problem continuous batching solves&lt;/h2>
&lt;p>There are two structural pathologies of static batching that deserve to be explained with concrete numbers.&lt;/p>
&lt;p>&lt;strong>Padding waste.&lt;/strong> Each batch slot reserved memory for &lt;code>max_seq_len&lt;/code> (prompt + max output), even though the real output ended in far fewer tokens. For a batch of 32 with heterogeneously distributed output lengths (half of them ≤50 tokens, a long tail up to 4,000), the typical memory waste was 60-80 %. That translated directly into wasted concurrency: with the same VRAM, instead of serving 32 requests with smart allocation, you served 8.&lt;/p>
&lt;p>&lt;strong>HoL blocking (Head-of-Line).&lt;/strong> The scheduling unit was the complete request. A batch containing one request of 500 tokens and 31 of ≤50 tokens ran 450 extra &amp;ldquo;empty&amp;rdquo; iterations (the GPU executed forward passes for all 32 slots, even though 31 had already finished). Wasted compute cost: ~84 % of the &lt;em>tail&lt;/em> time in the example.&lt;/p>
&lt;p>&lt;strong>Measurable result.&lt;/strong> Sustained GPU SM utilisation on real workloads under static batching: 20-40 %. That is, ~70 % of the datacenter&amp;rsquo;s compute went unused. When the first Orca and vLLM benchmarks came out showing 10-24× throughput improvement, it was not marketing exaggeration; it was recovering all that wasted compute.&lt;/p>
&lt;h2 id="orca-osdi-22-the-idea-that-changed-everything">Orca (OSDI &amp;lsquo;22): the idea that changed everything&lt;/h2>
&lt;p>The paper by Yu, Jeong, Kim, Kim and Chun at OSDI 2022 (&amp;ldquo;Orca: A Distributed Serving System for Transformer-Based Generative Models&amp;rdquo;, from Seoul National University + FriendliAI) introduced two contributions that have remained the basis of everything that followed.&lt;/p>
&lt;p>&lt;strong>Iteration-level scheduling.&lt;/strong> Instead of scheduling at the level of the complete request, it schedules at the level of &lt;strong>one decoder iteration&lt;/strong>: the step that generates ONE token. After each iteration the scheduler can (a) add new requests to the batch, (b) remove requests that have generated EOS or reached &lt;code>max_tokens&lt;/code>, (c) reorder priorities. The compute engine executes exactly one iteration over the current batch.&lt;/p>
&lt;p>&lt;strong>Selective batching.&lt;/strong> Here is the non-obvious subtlety. The technical problem with batching requests of different lengths and different KV cache states is that some operations (the GEMMs of the Q, K, V projections and the FFN) are insensitive to position and can be batched by concatenating tokens, whereas attention is sensitive to per-request state (each request has its own KV cache of a different length). Orca&amp;rsquo;s solution: batch the GEMMs (concatenate all the step&amp;rsquo;s tokens into a &lt;code>[total_tokens, hidden]&lt;/code> tensor) and run attention sequentially per request.&lt;/p>
&lt;p>Paper result: up to &lt;strong>36.9× throughput&lt;/strong> vs FasterTransformer on GPT-3 175B at the same latency level. Orca is not open-source, it is only documented in the paper. FriendliAI commercialises it as the Friendli Engine. But the idea was published and everyone adopted it.&lt;/p>
&lt;h2 id="vllm-sosp-23-the-open-source-materialisation">vLLM (SOSP &amp;lsquo;23): the open-source materialisation&lt;/h2>
&lt;p>What Orca described as a concept, vLLM materialised in production. The paper by Kwon, Li, Zhuang, Sheng et al. (UC Berkeley Sky Computing Lab, SOSP 2023) introduces &lt;strong>PagedAttention&lt;/strong>, covered in detail in the &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention deep dive&lt;/a>, but it also consolidates continuous batching as universal practice.&lt;/p>
&lt;p>The reason PagedAttention is a prerequisite for practical continuous batching is fragmentation. If you are going to insert and remove requests from the batch dynamically, and each request has a KV cache that grows on every iteration, traditional contiguous allocation fragments the HBM until it is unusable. PagedAttention splits the KV cache into fixed-size blocks (16 tokens by default) allocated on demand from a global pool. Memory waste falls from ~60-80 % to &lt;strong>less than 4 %&lt;/strong> (only the last partially filled block per sequence).&lt;/p>
&lt;p>vLLM paper metrics (2023):&lt;/p>
&lt;ul>
&lt;li>vs HuggingFace Transformers (static, no continuous batching): up to 24× throughput.&lt;/li>
&lt;li>vs HuggingFace TGI (which already had primitive continuous batching): ~3.5×.&lt;/li>
&lt;li>vs FasterTransformer: 2-4× at the same latency.&lt;/li>
&lt;/ul>
&lt;p>What operationally distinguishes vLLM from Orca: vLLM runs attention in a single fused CUDA kernel (&lt;code>paged_attention_kernel&lt;/code>) over non-contiguous blocks; Orca described attention as sequential, request by request. And vLLM exposes OpenAI-compatible APIs that let you drop the engine into existing stacks without touching the client.&lt;/p>
&lt;h2 id="chunked-prefill-sarathi--sarathi-serve-osdi-24">Chunked prefill (SARATHI / Sarathi-Serve, OSDI &amp;lsquo;24)&lt;/h2>
&lt;p>There is a detail the original Orca/vLLM continuous batching did not solve: when a new request enters the batch, its prefill (processing the whole prompt at once) can take hundreds of milliseconds. During that time, the active decodes of the other requests are essentially paused, the GPU being dedicated to the new prefill. This showed up as &lt;strong>spikes in TPOT&lt;/strong> (&amp;ldquo;inter-token latency&amp;rdquo;) every time a long request came in, which broke strict SLAs.&lt;/p>
&lt;p>SARATHI (Agrawal et al., arXiv 2308.16369, August 2023) and later Sarathi-Serve (same group at Microsoft Research India, OSDI 2024, arXiv 2403.02310) introduce two combined ideas:&lt;/p>
&lt;p>&lt;strong>Chunked prefill.&lt;/strong> A long prefill (8,192 tokens, say) is split into chunks (2,048 tokens, say) processed one per iteration. Instead of one 200 ms step processing 8K tokens, four 50 ms steps processing 2K each.&lt;/p>
&lt;p>&lt;strong>Decode-maximal batching (&amp;ldquo;stall-free&amp;rdquo;).&lt;/strong> On each iteration, the scheduler first fills the batch with the active decodes (each costing 1 token), and only the leftover space goes to new prefill chunks. The result: active decodes keep advancing 1 token per iteration without pausing, while the new request completes its prefill in small bites.&lt;/p>
&lt;p>The observation that justifies it: prefill is &lt;strong>compute-bound&lt;/strong> (it processes N tokens at once and saturates FLOPs) while decode is &lt;strong>memory-bound&lt;/strong> (1 token per step, under-using compute, with the GPU waiting on HBM). Mixing prefill chunks with decodes in the same step exploits the arithmetic-intensity slack: the decodes piggyback on the free compute of the prefill chunk.&lt;/p>
&lt;p>Numbers:&lt;/p>
&lt;ul>
&lt;li>Original SARATHI (LLaMA-13B on an A6000): decode throughput +10×, end-to-end +1.33×.&lt;/li>
&lt;li>Sarathi-Serve (Mistral-7B on an A100): 2.6× serving capacity vs plain vLLM. Yi-34B on 2×A100: 3.7×. Falcon-180B with pipeline parallel: 5.6×.&lt;/li>
&lt;/ul>
&lt;p>Adoption in May 2026: &lt;strong>always-on&lt;/strong> in vLLM v1 (default since v0.8.0, January 2025), SGLang, TensorRT-LLM. The key setting in vLLM is &lt;code>--max-num-batched-tokens&lt;/code> (the token budget per step; default 2048). Raising it prioritises throughput, lowering it prioritises low TPOT.&lt;/p>
&lt;h2 id="goodput-the-metric-that-matters-distserve-osdi-24">Goodput: the metric that matters (DistServe, OSDI &amp;lsquo;24)&lt;/h2>
&lt;p>The classic &amp;ldquo;throughput&amp;rdquo; metric (requests/s or tokens/s) has a problem when there are SLOs. A server can report 1,000 req/s while P99 TTFT is 30 seconds and the SLO is 1 second, so only about 200 req/s actually meet the contract.&lt;/p>
&lt;p>&lt;strong>DistServe&lt;/strong> (Zhong et al., OSDI 2024) formalises &lt;strong>goodput&lt;/strong> as the right metric: &lt;code>goodput = max sustained request rate meeting the SLOs (TTFT bound AND TPOT bound)&lt;/code>. The practical definition is usually: maximum rate with ≥90 % of requests within both SLOs.&lt;/p>
&lt;p>Why it matters for the scheduler:&lt;/p>
&lt;ul>
&lt;li>Optimising raw throughput leads to maximising batch size, which inflates P99 TPOT.&lt;/li>
&lt;li>Optimising goodput limits the batch size when TPOT starts violating the SLO, prefers small requests if the batch already has a tail, and leaves resources available for new requests.&lt;/li>
&lt;/ul>
&lt;p>DistServe result: up to &lt;strong>7.4× more requests served&lt;/strong> or a &lt;strong>12.6× tighter SLO&lt;/strong> vs vLLM at the same SLO attainment. The gain comes from &lt;strong>disaggregating prefill and decode onto different GPUs&lt;/strong> (removing the interference between phases), but the idea of optimising for goodput is independent and applicable to any scheduler.&lt;/p>
&lt;p>Operationally this translates into monitoring:&lt;/p>
&lt;pre tabindex="0">&lt;code>goodput_proxy = histogram_quantile(0.95, vllm:time_to_first_token_seconds_bucket) &amp;lt; SLO_TTFT
AND histogram_quantile(0.95, vllm:time_per_output_token_seconds_bucket) &amp;lt; SLO_TPOT
&lt;/code>&lt;/pre>&lt;h2 id="the-iterative-scheduler-in-action">The iterative scheduler in action&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Continuous batching scheduler timeline">
&lt;style>
.req1{fill:#cdebd0;stroke:#2a7a40;stroke-width:1.2;rx:3}
.req2{fill:#d4ecff;stroke:#1f5fa8;stroke-width:1.2;rx:3}
.req3{fill:#fff4d6;stroke:#a48000;stroke-width:1.2;rx:3}
.req4{fill:#f6caca;stroke:#a52a2a;stroke-width:1.2;rx:3}
.req5{fill:#e6d0ff;stroke:#5a2db0;stroke-width:1.2;rx:3}
.req6{fill:#f6e0c8;stroke:#a76b1f;stroke-width:1.2;rx:3}
.empty{fill:#f0f0f0;stroke:#999;stroke-width:1;stroke-dasharray:3 2;rx:3}
.pre{fill:#ffd76b;stroke:#a48000;stroke-width:1.2;rx:3}
.lbl{font:600 11px sans-serif;fill:#222}
.sub{font:400 9px sans-serif;fill:#555}
.tick{stroke:#444;stroke-width:1}
&lt;/style>
&lt;text x="20" y="20" class="lbl">Static batching — 4 slots, padding to max_len, new req arrives → waits&lt;/text>
&lt;text x="20" y="38" class="sub">slot 1&lt;/text>
&lt;rect x="60" y="30" width="200" height="14" class="req1"/>
&lt;rect x="260" y="30" width="200" height="14" class="empty"/>
&lt;rect x="460" y="30" width="80" height="14" class="req5"/>
&lt;rect x="540" y="30" width="200" height="14" class="empty"/>
&lt;text x="20" y="58" class="sub">slot 2&lt;/text>
&lt;rect x="60" y="50" width="80" height="14" class="req2"/>
&lt;rect x="140" y="50" width="320" height="14" class="empty"/>
&lt;rect x="460" y="50" width="200" height="14" class="req6"/>
&lt;rect x="660" y="50" width="80" height="14" class="empty"/>
&lt;text x="20" y="78" class="sub">slot 3&lt;/text>
&lt;rect x="60" y="70" width="400" height="14" class="req3"/>
&lt;rect x="460" y="70" width="280" height="14" class="empty"/>
&lt;text x="20" y="98" class="sub">slot 4&lt;/text>
&lt;rect x="60" y="90" width="120" height="14" class="req4"/>
&lt;rect x="180" y="90" width="280" height="14" class="empty"/>
&lt;rect x="460" y="90" width="100" height="14" class="req5"/>
&lt;rect x="560" y="90" width="180" height="14" class="empty"/>
&lt;line x1="460" y1="20" x2="460" y2="115" class="tick" stroke-dasharray="2 2"/>
&lt;text x="465" y="115" class="sub">batch recycled only when ALL finish ↑&lt;/text>
&lt;text x="20" y="155" class="lbl">Continuous batching — a free slot is refilled IMMEDIATELY on each tick&lt;/text>
&lt;text x="20" y="173" class="sub">slot 1&lt;/text>
&lt;rect x="60" y="165" width="200" height="14" class="req1"/>
&lt;rect x="260" y="165" width="200" height="14" class="req5"/>
&lt;rect x="460" y="165" width="160" height="14" class="req6"/>
&lt;rect x="620" y="165" width="120" height="14" class="empty"/>
&lt;text x="20" y="193" class="sub">slot 2&lt;/text>
&lt;rect x="60" y="185" width="80" height="14" class="req2"/>
&lt;rect x="140" y="185" width="150" height="14" class="req5"/>
&lt;rect x="290" y="185" width="200" height="14" class="req6"/>
&lt;rect x="490" y="185" width="250" height="14" class="req4"/>
&lt;text x="20" y="213" class="sub">slot 3&lt;/text>
&lt;rect x="60" y="205" width="400" height="14" class="req3"/>
&lt;rect x="460" y="205" width="160" height="14" class="req6"/>
&lt;rect x="620" y="205" width="120" height="14" class="req4"/>
&lt;text x="20" y="233" class="sub">slot 4&lt;/text>
&lt;rect x="60" y="225" width="120" height="14" class="req4"/>
&lt;rect x="180" y="225" width="180" height="14" class="req5"/>
&lt;rect x="360" y="225" width="200" height="14" class="req6"/>
&lt;rect x="560" y="225" width="180" height="14" class="req3"/>
&lt;text x="60" y="252" class="sub">each coloured bar = 1 decoder iteration (1 token) of a request&lt;/text>
&lt;text x="20" y="280" class="lbl">Chunked prefill — new prefill interleaved with active decodes (no stall)&lt;/text>
&lt;text x="60" y="300" class="sub">prefill chunk 1&lt;/text>
&lt;rect x="150" y="290" width="60" height="14" class="pre"/>
&lt;text x="220" y="300" class="sub">decode tick, active reqs&lt;/text>
&lt;rect x="340" y="290" width="60" height="14" class="req1"/>
&lt;rect x="400" y="290" width="60" height="14" class="req2"/>
&lt;rect x="460" y="290" width="60" height="14" class="req3"/>
&lt;rect x="520" y="290" width="60" height="14" class="pre"/>
&lt;text x="585" y="300" class="sub">prefill chunk 2 (same step as the decodes)&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-maths-that-matter">The maths that matter&lt;/h2>
&lt;p>Three formulas explain much of the operational behaviour.&lt;/p>
&lt;p>&lt;strong>GPU utilisation under static batching.&lt;/strong> With a batch of size &lt;code>B&lt;/code> whose &lt;code>seq_len_i&lt;/code> are the real lengths and &lt;code>max(seq_len_i)&lt;/code> is the length that defines the padding:&lt;/p>
$$U_{\text{static}} = \frac{\sum_i \text{seq len}_i}{B \cdot \max_i \text{seq len}_i}$$
&lt;p>For &lt;code>B=32&lt;/code>, 30 sequences of 50 tokens and 2 of 500: &lt;code>U = (30·50 + 2·500) / (32·500) = 2500/16000 = 15.6 %&lt;/code>. Four out of every five GPU cycles wasted.&lt;/p>
&lt;p>&lt;strong>GPU utilisation under continuous batching (idealised).&lt;/strong>&lt;/p>
$$U_{\text{continuous}} \approx 1 - \frac{T_{\text{scheduler}}}{T_{\text{iteration}}}$$
&lt;p>With scheduler overhead of ~50-200 µs and an iteration time of ~10-30 ms: &lt;code>U &amp;gt; 95 %&lt;/code>. The unit of loss is no longer padding, it is scheduling overhead, and that is negligible compared with the forward pass.&lt;/p>
&lt;p>&lt;strong>Goodput vs throughput.&lt;/strong>&lt;/p>
$$\text{Goodput}(R) = R \cdot P(\text{latency} &lt; \text{SLO})$$
&lt;p>where &lt;code>R&lt;/code> is the offered request rate. Typical curve: goodput grows linearly with &lt;code>R&lt;/code> up to the saturation knee, then falls because &lt;code>P(SLO)&lt;/code> collapses when the system congests. The optimal point is just before the knee, not at peak throughput.&lt;/p>
&lt;p>Example: at &lt;code>R=100 req/s&lt;/code> with &lt;code>P(SLO)=0.99&lt;/code>, goodput = 99. At &lt;code>R=200 req/s&lt;/code> with &lt;code>P(SLO)=0.4&lt;/code>, goodput = 80. &lt;strong>More offered load, less useful goodput&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>Chunked prefill token budget.&lt;/strong> On each vLLM step with chunked prefill active:&lt;/p>
$$\text{prefill tokens this step} = \text{max num batched tokens} - \text{num decodes active}$$
&lt;p>Each active decode costs 1 token of the budget; the rest is filled with new prefill chunks. If &lt;code>max_num_batched_tokens = 2048&lt;/code> and &lt;code>num_decodes_active = 200&lt;/code>, there are 1,848 tokens for prefill (one chunk of 1,848 or several small chunks).&lt;/p>
&lt;h2 id="real-implementations-in-may-2026">Real implementations in May 2026&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Engine&lt;/th>
&lt;th>Current scheduler V&lt;/th>
&lt;th>Chunked prefill default&lt;/th>
&lt;th>Relevant notes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>vLLM v1&lt;/strong> (default ≥0.8.0)&lt;/td>
&lt;td>unified V1&lt;/td>
&lt;td>always-on&lt;/td>
&lt;td>EngineCore isolated in a separate process; prefix caching with O(1) eviction; preempt-mode &lt;code>recompute&lt;/code> by default; xgrammar/outlines backends.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>SGLang&lt;/strong>&lt;/td>
&lt;td>own (PyTorch ecosystem)&lt;/td>
&lt;td>yes&lt;/td>
&lt;td>&lt;strong>RadixAttention&lt;/strong> gives cross-request prefix-cache hits; non-blocking CPU scheduler; leader in stable latency at high concurrency.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>TensorRT-LLM&lt;/strong>&lt;/td>
&lt;td>proprietary &amp;ldquo;in-flight batching&amp;rdquo;&lt;/td>
&lt;td>yes&lt;/td>
&lt;td>Policies &lt;code>GUARANTEED_NO_EVICT&lt;/code> (conservative, default) and &lt;code>MAX_UTILIZATION&lt;/code> (aggressive, risk of a pause when the KV is full). Compile-time vs runtime.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Triton + tensorrtllm_backend&lt;/strong>&lt;/td>
&lt;td>&lt;code>gpt_model_type: inflight_fused_batching&lt;/code>&lt;/td>
&lt;td>yes&lt;/td>
&lt;td>&lt;code>max_queue_delay_microseconds&lt;/code> to group newly arrived requests. Decoupled mode for SSE streaming.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>llama.cpp&lt;/strong> (llama-server)&lt;/td>
&lt;td>own&lt;/td>
&lt;td>&lt;code>--cont-batching&lt;/code> ON since 2024&lt;/td>
&lt;td>&lt;code>-np N&lt;/code> parallel slots; no PagedAttention (contiguous KV per slot), so less flexible but simpler. Endpoint &lt;code>:8080/metrics&lt;/code>.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>A typical production-ready vLLM v1 configuration:&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 meta-llama/Llama-3.1-70B-Instruct &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --tensor-parallel-size &lt;span class="m">4&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-batched-tokens &lt;span class="m">4096&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-num-seqs &lt;span class="m">256&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-chunked-prefill &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-prefix-caching &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --preemption-mode recompute &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --scheduling-policy fcfs &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --gpu-memory-utilization 0.92
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>SGLang equivalent:&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">python -m sglang.launch_server &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 meta-llama/Llama-3.1-70B-Instruct &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --tp &lt;span class="m">4&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --chunked-prefill-size &lt;span class="m">4096&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --max-running-requests &lt;span class="m">256&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --enable-radix-cache
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="the-three-operational-tensions">The three operational tensions&lt;/h2>
&lt;p>&lt;strong>Continuous batching + speculative decoding.&lt;/strong> Speculative decoding produces 1 to &lt;code>γ+1&lt;/code> tokens per step depending on the acceptance rate. The batch stops being uniform in tokens produced per iteration: &lt;em>nested raggedness&lt;/em>. PagedAttention absorbs it (the KV cache can grow at different rates per request in the same step), but the scheduler loses symmetry. At low QPS (a conversational assistant) the combination is excellent: vLLM reports up to &lt;strong>2.8× speedup&lt;/strong>. At high QPS, the draft consumes slots from the decode pool and can &lt;em>reduce&lt;/em> aggregate goodput. Rule of thumb: disable speculative decoding when &lt;code>gpu_cache_usage &amp;gt; 0.85&lt;/code>. Full detail in &lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Continuous batching + multi-LoRA.&lt;/strong> Each request in the batch can use a different adapter (via SGMV, see &lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving&lt;/a>). Worst case: every request in the batch with a different adapter and a different rank, and throughput falls by up to 50 % vs the base with no LoRA. Best case: all requests on the same adapter, equivalent to the base with no LoRA. Practical mitigation: group adapters by rank in the routing ahead of the engine; set &lt;code>--max-lora-rank&lt;/code> to the maximum actually served, not generously above it.&lt;/p>
&lt;p>&lt;strong>Continuous batching + MoE.&lt;/strong> Each expert sees &lt;code>batch · k / N&lt;/code> tokens per step. With DeepSeek-V3 (256 experts, k=8) and batch=32 in decode, each expert processes only 1 token on average: total compute starvation. To match the per-GPU throughput of a dense model, MoE needs batches &lt;strong>&amp;raquo;10× larger&lt;/strong>, which puts pressure on the KV cache. &lt;strong>Wide-EP&lt;/strong> (see &lt;a href="https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/">MoE inference&lt;/a>) distributes the experts across many GPUs and allows larger effective batches per expert, at the cost of all-to-all comms that add milliseconds per step.&lt;/p>
&lt;h2 id="metrics-to-monitor">Metrics to monitor&lt;/h2>
&lt;p>The Prometheus metrics exposed by vLLM (prefix &lt;code>vllm:&lt;/code>, &lt;code>vllm_&lt;/code> when scraped):&lt;/p>
&lt;ul>
&lt;li>&lt;code>vllm:time_to_first_token_seconds&lt;/code> (Histogram) — TTFT including queue time.&lt;/li>
&lt;li>&lt;code>vllm:time_per_output_token_seconds&lt;/code> (Histogram) — TPOT.&lt;/li>
&lt;li>&lt;code>vllm:e2e_request_latency_seconds&lt;/code> (Histogram) — end-to-end.&lt;/li>
&lt;li>&lt;code>vllm:num_requests_running&lt;/code> (Gauge) — active batch.&lt;/li>
&lt;li>&lt;code>vllm:num_requests_waiting&lt;/code> (Gauge) — queue depth.&lt;/li>
&lt;li>&lt;code>vllm:num_requests_swapped&lt;/code> (Gauge) — preempted to CPU.&lt;/li>
&lt;li>&lt;code>vllm:gpu_cache_usage_perc&lt;/code> (Gauge) — fraction of KV cache occupied.&lt;/li>
&lt;li>&lt;code>vllm:gpu_prefix_cache_hit_rate&lt;/code> (Gauge) — prefix cache hits.&lt;/li>
&lt;li>&lt;code>vllm:num_preemptions_total&lt;/code> (Counter) — preemptions. Any sustained value is a red flag.&lt;/li>
&lt;/ul>
&lt;p>Practical operational rules:&lt;/p>
&lt;ul>
&lt;li>Stable zone under sustained load: &lt;code>gpu_cache_usage_perc ∈ [0.7, 0.9]&lt;/code>.&lt;/li>
&lt;li>Warning at &lt;code>&amp;gt;0.95&lt;/code> (preemption imminent).&lt;/li>
&lt;li>Critical if &lt;code>num_requests_waiting&lt;/code> grows faster than &lt;code>num_requests_running&lt;/code>: the server is not absorbing the load; scale out.&lt;/li>
&lt;/ul>
&lt;h2 id="operational-pitfalls">Operational pitfalls&lt;/h2>
&lt;p>&lt;strong>Preempt-on-OOM.&lt;/strong> When &lt;code>gpu_cache_usage&lt;/code> reaches ~1.0 with pending requests that need to grow their KV, vLLM preempts. V1 does &lt;code>RECOMPUTE&lt;/code> by default (discards the KV, regenerates it on return); V0 did &lt;code>SWAP&lt;/code> (moves it to CPU). &lt;code>RECOMPUTE&lt;/code> is better for short sequences (cheap to regenerate); &lt;code>SWAP&lt;/code> is better for long ones. The &lt;code>vllm:num_preemptions_total&lt;/code> metric should be zero or near zero in steady state.&lt;/p>
&lt;p>&lt;strong>Inverse HoL blocking (memory monopoly).&lt;/strong> A very long request occupies many KV blocks, so small requests do not fit in the batch even though compute is free. Chunked prefill mitigates the compute blocking during a new prefill, but it does not solve the memory monopoly. Partial solution: per-request limit policies (an aggressive &lt;code>max_tokens&lt;/code>) or priorities.&lt;/p>
&lt;p>&lt;strong>Starvation.&lt;/strong> FCFS can leave requests pending for a long time if the active ones do not finish. vLLM supports &lt;code>--scheduling-policy priority&lt;/code> with an &lt;code>x-priority&lt;/code> header. Recent work (NeurIPS 2024 &lt;em>Efficient LLM Scheduling by Learning to Rank&lt;/em>, arXiv:2501.14312 &lt;em>Locality-aware Fair Scheduling&lt;/em>) proposes schedulers with quantum-based starvation prevention; not yet integrated into vLLM mainline.&lt;/p>
&lt;p>&lt;strong>Badly calibrated chunk size.&lt;/strong> A small chunk (512) gives low TPOT, high TTFT and memory overhead from more KV accesses. A large chunk (8192+) gives low TTFT and TPOT spikes during the chunk. Rule: start at 2,048, measure P95 TPOT, adjust.&lt;/p>
&lt;p>&lt;strong>Batch size cap.&lt;/strong> A high &lt;code>--max-num-seqs&lt;/code> gives more concurrency but P99 TPOT explodes. A low one wastes throughput. Rule of thumb: &lt;code>max_num_seqs ≈ HBM_for_KV / (avg_seq_len × bytes_per_token_KV)&lt;/code>.&lt;/p>
&lt;h2 id="implications-for-on-premise-hardware">Implications for on-premise hardware&lt;/h2>
&lt;p>&lt;strong>On an RTX 4090 (24 GB).&lt;/strong> llama.cpp with &lt;code>--cont-batching -np 4-8&lt;/code> is the natural pattern. Typical models: Llama 3 8B Q4_K_M with ~8 parallel slots, aggregate throughput on the order of hundreds of tok/s. vLLM also works if the weights fit (Llama 3 8B BF16 does; the 70B does not fit whole), although PagedAttention on consumer hardware gives less return than in the datacenter.&lt;/p>
&lt;p>&lt;strong>On a generic 4×H100 SXM cluster (320 GB, NVLink).&lt;/strong> Here vLLM v1 / SGLang are the de facto standard. Typical configurations:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Llama 3 70B FP8 + TP=4&lt;/strong>: dozens of concurrent sessions with P95 TPOT under 50 ms, tens of thousands of aggregate tok/s at a moderate batch size.&lt;/li>
&lt;li>&lt;strong>Llama 3 70B AWQ-INT4 + TP=2&lt;/strong> plus the rest of the cluster for additional concurrency or multi-LoRA with SGMV.&lt;/li>
&lt;li>&lt;strong>DeepSeek-V3&lt;/strong> needs larger setups (8-16 H100) to fit whole in FP8; with Wide-EP, continuous batching starts operating on much larger batches and the economics change (see MoE).&lt;/li>
&lt;/ul>
&lt;p>The rule of thumb for May 2026: &lt;strong>vLLM v1 with chunked prefill always-on and prefix caching enabled is the sensible default configuration for any dense model that fits comfortably; SGLang offers better stable latency at high concurrency thanks to the overlap between the CPU scheduler and the GPU step; TensorRT-LLM gives peak throughput at high concurrency with the rigidity of compile-time&lt;/strong>.&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Locality-aware fair scheduling&lt;/strong> (arXiv:2501.14312) and learning-to-rank schedulers (NeurIPS 2024): the next generation of algorithms closing the fairness vs prefix-cache locality trade-off.&lt;/li>
&lt;li>&lt;strong>Smooth goodput&lt;/strong> (arXiv:2410.14257): a refinement of the DistServe metric using max slowdown instead of a binary SLO.&lt;/li>
&lt;li>&lt;strong>Triton tensorrtllm_backend in production&lt;/strong>: decoupled mode for streaming, ensembles with pre/post-processing, autoscaling with KServe.&lt;/li>
&lt;li>&lt;strong>vLLM speculators v0.3.0&lt;/strong> and the vLLM-compatible drafter training framework.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-scheduler-step-maitre-d-each-round/">The pass: the vLLM scheduler step&lt;/a> — the concrete algorithm implementing this batching: the &lt;code>{request: number of tokens}&lt;/code> dictionary assembled on each iteration, the &lt;code>max-num-batched-tokens&lt;/code> budget and preemption by RECOMPUTE.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory&lt;/a> — the artefact continuous batching manages; without understanding it you cannot see why the minimum unit is the decoder iteration.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention deep dive&lt;/a> — the memory piece without which dynamic continuous batching would fragment the HBM; a deep dive into the block manager.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving: prefill and decode in specialised pods&lt;/a> — the next layer: when continuous batching is already squeezed dry, separating prefill and decode gives another round of improvements (origin of the DistServe paper that contributes the concept of goodput).&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding&lt;/a> — the scheduler&amp;rsquo;s first operational tension: each request in the batch can produce 1 to γ+1 tokens per step.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving&lt;/a> — the second tension: heterogeneous batching with SGMV lets each request use its own adapter, but the scheduler has to group by rank to keep throughput up.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/">MoE inference&lt;/a> — the third tension: each expert sees very few tokens per step at a typical batch size, forcing much larger batches than in dense models.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the master map in which Deploy is stage 4.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise LLM inference&lt;/a> — the DistServe goodput that appears here is exactly the metric that closes the crossing of VRAM budget × time budget in sizing.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-observability-llm-inference-twelve-dcgm-vllm-metrics/">GPU observability for LLM inference&lt;/a> — the &lt;code>vllm:num_requests_running&lt;/code>, &lt;code>num_requests_waiting&lt;/code> and &lt;code>gpu_cache_usage_perc&lt;/code> metrics that come from the iterative scheduler are the operational cockpit of the engine in production.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">Optimising prefill in vLLM&lt;/a> — chunked prefill translates iterative scheduling into concrete parameters: &lt;code>--max-num-batched-tokens&lt;/code> is the budget the scheduler splits between prefill chunks and decode tokens per step.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">Optimising decode in vLLM&lt;/a> — &lt;code>--max-num-seqs&lt;/code> and &lt;code>--gpu-memory-utilization&lt;/code> are the two dials that determine how many decode slots the scheduler can keep before queueing.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-autoscaling-kubernetes-hpa-keda/">LLM autoscaling on Kubernetes&lt;/a> — the scheduler queue (&lt;code>num_requests_waiting&lt;/code>) is the primary metric for HPA with KEDA on an on-premise cluster.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">SMs, CUDA streams and CUDA graphs&lt;/a> — batching is precisely what amortises the memory bottleneck of decode and exposes the launch bottleneck; that is why batching and CUDA graphs reinforce each other.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-serving-engines-pareto-frontier-comparison/">Serving engine comparison (vLLM/SGLang/TRT-LLM/Dynamo)&lt;/a> — where the implementation differences in continuous batching between engines land: the real goodput of vLLM vs SGLang vs TRT-LLM measured with the same harness.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Yu, G.-I., Jeong, J., Kim, G.-W., Kim, S., Chun, B.-G. &lt;em>Orca: A Distributed Serving System for Transformer-Based Generative Models&lt;/em>. OSDI 2022. &lt;a href="https://www.usenix.org/system/files/osdi22-yu.pdf">https://www.usenix.org/system/files/osdi22-yu.pdf&lt;/a>&lt;/li>
&lt;li>Kwon, W. et al. &lt;em>Efficient Memory Management for Large Language Model Serving with PagedAttention&lt;/em>. SOSP 2023. &lt;a href="https://arxiv.org/abs/2309.06180">https://arxiv.org/abs/2309.06180&lt;/a>&lt;/li>
&lt;li>Agrawal, A. et al. &lt;em>SARATHI: Efficient LLM Inference by Piggybacking Decodes with Chunked Prefills&lt;/em>. 2023. &lt;a href="https://arxiv.org/abs/2308.16369">https://arxiv.org/abs/2308.16369&lt;/a>&lt;/li>
&lt;li>Agrawal, A. et al. &lt;em>Taming Throughput-Latency Tradeoff in LLM Inference with Sarathi-Serve&lt;/em>. OSDI 2024. &lt;a href="https://arxiv.org/abs/2403.02310">https://arxiv.org/abs/2403.02310&lt;/a>&lt;/li>
&lt;li>Zhong, Y. et al. &lt;em>DistServe: Disaggregating Prefill and Decoding for Goodput-optimized Large Language Model Serving&lt;/em>. OSDI 2024. &lt;a href="https://arxiv.org/abs/2401.09670">https://arxiv.org/abs/2401.09670&lt;/a>&lt;/li>
&lt;li>Sheng, Y. et al. &lt;em>S-LoRA: Serving Thousands of Concurrent LoRA Adapters&lt;/em>. MLSys 2024. &lt;a href="https://arxiv.org/abs/2311.03285">https://arxiv.org/abs/2311.03285&lt;/a>&lt;/li>
&lt;li>&lt;em>Efficient LLM Scheduling by Learning to Rank&lt;/em>. NeurIPS 2024.&lt;/li>
&lt;li>&lt;em>Locality-aware Fair Scheduling&lt;/em>. 2025. &lt;a href="https://arxiv.org/abs/2501.14312">https://arxiv.org/abs/2501.14312&lt;/a>&lt;/li>
&lt;li>vLLM V1 alpha release (Jan 2025): &lt;a href="https://blog.vllm.ai/2025/01/27/v1-alpha-release.html">https://blog.vllm.ai/2025/01/27/v1-alpha-release.html&lt;/a>&lt;/li>
&lt;li>vLLM Anatomy (Sep 2025): &lt;a href="https://blog.vllm.ai/2025/09/05/anatomy-of-vllm.html">https://blog.vllm.ai/2025/09/05/anatomy-of-vllm.html&lt;/a>&lt;/li>
&lt;li>vLLM Large-Scale Serving (Dec 2025): &lt;a href="https://blog.vllm.ai/2025/12/17/large-scale-serving.html">https://blog.vllm.ai/2025/12/17/large-scale-serving.html&lt;/a>&lt;/li>
&lt;li>vLLM speculators v0.3.0 (Dec 2025): &lt;a href="https://blog.vllm.ai/2025/12/13/speculators-v030.html">https://blog.vllm.ai/2025/12/13/speculators-v030.html&lt;/a>&lt;/li>
&lt;li>Anyscale, &lt;em>Continuous Batching for LLM Inference&lt;/em> (2023): &lt;a href="https://www.anyscale.com/blog/continuous-batching-llm-inference">https://www.anyscale.com/blog/continuous-batching-llm-inference&lt;/a>&lt;/li>
&lt;li>vLLM metrics docs: &lt;a href="https://docs.vllm.ai/en/latest/design/metrics/">https://docs.vllm.ai/en/latest/design/metrics/&lt;/a>&lt;/li>
&lt;li>vLLM optimization: &lt;a href="https://docs.vllm.ai/en/stable/configuration/optimization/">https://docs.vllm.ai/en/stable/configuration/optimization/&lt;/a>&lt;/li>
&lt;li>SGLang: &lt;a href="https://github.com/sgl-project/sglang">https://github.com/sgl-project/sglang&lt;/a>&lt;/li>
&lt;li>TensorRT-LLM performance tuning: &lt;a href="https://nvidia.github.io/TensorRT-LLM/performance/performance-tuning-guide/tuning-max-batch-size-and-max-num-tokens.html">https://nvidia.github.io/TensorRT-LLM/performance/performance-tuning-guide/tuning-max-batch-size-and-max-num-tokens.html&lt;/a>&lt;/li>
&lt;li>Red Hat, &lt;em>5 steps to triage vLLM performance&lt;/em> (Mar 2026): &lt;a href="https://developers.redhat.com/articles/2026/03/09/5-steps-triage-vllm-performance">https://developers.redhat.com/articles/2026/03/09/5-steps-triage-vllm-performance&lt;/a>&lt;/li>
&lt;li>NVIDIA blog, &lt;em>Chunked Prefill with TensorRT-LLM&lt;/em>: &lt;a href="https://developer.nvidia.com/blog/streamlining-ai-inference-performance-and-deployment-with-nvidia-tensorrt-llm-chunked-prefill/">https://developer.nvidia.com/blog/streamlining-ai-inference-performance-and-deployment-with-nvidia-tensorrt-llm-chunked-prefill/&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>MoE inference: the call centre with 256 specialists and 8 taking each call — fundamentals, expert parallel and the economics of DeepSeek-V3</title><link>https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/</link><pubDate>Sat, 30 May 2026 08:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/</guid><description>&lt;blockquote>
&lt;p>This post goes into an architectural decision in the Deploy stage of the &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">six-stage LLMOps pipeline&lt;/a>. It complements those on &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> (which does not change with MoE), &lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention&lt;/a> (still the attention kernel, also dense in every current MoE), &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantization&lt;/a> (FP8/NVFP4 over expert weights is what lets DeepSeek-V3 fit into a modest cluster) and &lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding&lt;/a> (MTP in DeepSeek-V3 is native speculative decoding).&lt;/p>
&lt;/blockquote>
&lt;h2 id="you-are-here-deploy">You are here: DEPLOY&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 90" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="you are here: Deploy">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#7ad88f;stroke-width:3}.idle{fill:#f4f4f4}.lbl{font:600 12px sans-serif;fill:#222}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#moem)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#moem)}&lt;/style>
&lt;defs>&lt;marker id="moem" 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="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="20" text-anchor="middle" class="lbl">You are here: DEPLOY · compute sparsity without memory sparsity&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box idle"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box active"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box idle"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>A Mixture of Experts (MoE) replaces the transformer&amp;rsquo;s dense FFN layer with &lt;strong>N parallel experts plus a router&lt;/strong> that picks the &lt;code>k&lt;/code> experts relevant to each token. The operational consequence is that the model&amp;rsquo;s &lt;strong>total&lt;/strong> parameters (how many were trained and how many must be loaded into VRAM) decouple from the &lt;strong>active parameters per token&lt;/strong> (how many take part in each forward). DeepSeek-V3 has 671 B total but only 37 B active per token; Qwen3-235B-A22B is 235 B total and 22 B active; Mixtral 8x22B is 141 B and 39 B; Llama 4 Maverick is 400 B and 17 B. Compute per token falls to &lt;code>2·N_active&lt;/code> FLOPs, half that of a dense Llama-3-70B in DeepSeek-V3&amp;rsquo;s case, but memory stays proportional to &lt;code>N_total&lt;/code>: MoE &lt;em>sparsity&lt;/em> is &lt;strong>compute sparsity, not memory sparsity&lt;/strong>. Since each expert sees only &lt;code>batch·k/N&lt;/code> tokens per step, the memory-bound regime persists at a much higher batch than in dense. Serving DeepSeek-V3 671 B FP8 at low latency requires &lt;strong>expert parallel&lt;/strong> (EP) at scale, EP=32 in prefill and EP=144 in decode in DeepSeek&amp;rsquo;s production deployment, where the dominant bottleneck stops being HBM and becomes the &lt;strong>all-to-all of dispatch and combine&lt;/strong> between GPUs. As of May 2026, the pieces that make this viable are &lt;strong>DeepEP&lt;/strong> (FP8-native all-to-all CUDA kernels, intra-node NVLink plus inter-node RDMA), &lt;strong>EPLB&lt;/strong> (balancing by replicating &lt;em>hot&lt;/em> experts) and &lt;strong>Wide-EP&lt;/strong> on NVL72 (72 GPUs in one coherent NVLink domain). This post takes apart the mechanism, the maths (FLOPs/token, all-to-all bytes), the updated table of MoE models as of May 2026, the parallelisms (TP / EP / DP+EP / Wide-EP), the operational pitfalls (memory wall, cold experts, imbalance) and the real reproducible numbers on H100, B200 and GB200 NVL72.&lt;/p>
&lt;h2 id="the-analogy-the-call-centre-with-256-specialists">The analogy: the call centre with 256 specialists&lt;/h2>
&lt;p>A serious call centre handling complex technical queries runs on &lt;strong>specialists&lt;/strong>, not generalists. You have 256 operators on the payroll, each an expert in their sub-domain: employment law, mortgages, income tax, probate, and so on. But each incoming call only needs the attention of &lt;strong>eight&lt;/strong> specific specialists (top-8 routing): a client&amp;rsquo;s query about tax relief on a mortgage for an inherited second home involves the income tax person, the mortgage person, the capital gains person, and so on. The receptionist (the router) listens to the first second of the call, decides which eight specialists are relevant, conferences them in and combines their answers with weights.&lt;/p>
&lt;p>Three immediate consequences, identical to those of MoE in production:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>You pay 256 salaries&lt;/strong> (every expert is loaded in VRAM) but you only &lt;strong>spend the time of 8&lt;/strong> (only k experts take part in the forward of each token). Your payroll cost scales with &lt;code>N_total&lt;/code>; your cost per minute of call scales with &lt;code>N_active&lt;/code>.&lt;/li>
&lt;li>&lt;strong>If all the operators fit in one office&lt;/strong> (a single GPU), fine: paper just moves between desks. But &lt;strong>if they do not fit&lt;/strong>, you have to open branches: the conference call between operators in different branches goes over a telephone line between buildings (intra-node NVLink, inter-node InfiniBand) and that line &lt;strong>becomes the dominant bottleneck&lt;/strong> once dozens of branches are coordinating.&lt;/li>
&lt;li>&lt;strong>If one week every client asks about income tax&lt;/strong>, the three income tax operators saturate while the probate ones sit idle. You need &lt;strong>a system that replicates the hot operators&lt;/strong> across several branches to absorb peaks, and that &lt;strong>pairs hot with cold&lt;/strong> so that no branch goes idle.&lt;/li>
&lt;/ol>
&lt;p>The receptionist is the &lt;strong>router&lt;/strong>. The branches are the GPUs. The conference call between branches is the &lt;strong>all-to-all&lt;/strong> (dispatch plus combine). The system for replicating hot operators is &lt;strong>EPLB&lt;/strong>. The good telephone line is &lt;strong>DeepEP&lt;/strong>. And when someone designs a building where all 72 branches fit together with very fast internal cabling, that is &lt;strong>NVL72&lt;/strong>.&lt;/p>
&lt;h2 id="the-bare-mechanism">The bare mechanism&lt;/h2>
&lt;p>A classic MoE layer replaces the dense FFN (&lt;code>y = FFN(x) = down(act(up(x) · gate(x)))&lt;/code> in SwiGLU) with:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Router&lt;/strong>: a linear projection &lt;code>g(x) = W_router · x&lt;/code> produces &lt;code>N&lt;/code> scores. A softmax over those scores gives affinity probabilities.&lt;/li>
&lt;li>&lt;strong>Top-k selection&lt;/strong>: the &lt;code>k&lt;/code> experts with the highest score are chosen. Their &lt;code>softmax_topk&lt;/code> weights are normalised to sum to 1.&lt;/li>
&lt;li>&lt;strong>Parallel compute&lt;/strong>: each of the &lt;code>k&lt;/code> selected experts runs its own FFN over &lt;code>x&lt;/code>. The &lt;code>N - k&lt;/code> not selected sit idle for this token.&lt;/li>
&lt;li>&lt;strong>Weighted combination&lt;/strong>: the output is &lt;code>y = Σ_{i ∈ topk} w_i · Expert_i(x)&lt;/code>.&lt;/li>
&lt;/ol>
&lt;p>Variants the market consolidated in 2025-2026:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Shared experts&lt;/strong>: on top of the &lt;code>k&lt;/code> routed ones, there are &lt;code>s&lt;/code> experts that always run (1 or 2). They capture general knowledge every token needs. DeepSeek-V3 uses 1 shared + 8 routed; Mixtral uses 0 shared.&lt;/li>
&lt;li>&lt;strong>Fine-grained experts&lt;/strong>: more small experts instead of a few large ones (DeepSeek: 256 vs Mixtral: 8). This allows better specialisation because the top-k combination covers more sub-domains.&lt;/li>
&lt;li>&lt;strong>Auxiliary-loss-free routing&lt;/strong> (DeepSeek-V3): instead of adding a balancing loss during training, it dynamically adjusts a bias in the routing. It keeps balance without polluting the main loss.&lt;/li>
&lt;/ul>
&lt;p>Attention is still &lt;strong>dense&lt;/strong> in every current MoE model (Mixtral, DeepSeek, Qwen3-MoE, Llama 4, Kimi K2). Only the FFN is replaced. That is why FlashAttention v3/v4 is still used as-is; DeepSeek&amp;rsquo;s MLA is an optimisation orthogonal to MoE.&lt;/p>
&lt;h2 id="the-relevant-moe-models-in-may-2026">The relevant MoE models in May 2026&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Model&lt;/th>
&lt;th>Date&lt;/th>
&lt;th>Total&lt;/th>
&lt;th>Active&lt;/th>
&lt;th>Experts&lt;/th>
&lt;th>Top-k&lt;/th>
&lt;th>Shared&lt;/th>
&lt;th>Notes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Mixtral 8x7B&lt;/td>
&lt;td>Dec 2023&lt;/td>
&lt;td>47 B&lt;/td>
&lt;td>13 B&lt;/td>
&lt;td>8&lt;/td>
&lt;td>2&lt;/td>
&lt;td>0&lt;/td>
&lt;td>The one that normalised MoE in open weights&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Mixtral 8x22B&lt;/td>
&lt;td>Apr 2024&lt;/td>
&lt;td>141 B&lt;/td>
&lt;td>39 B&lt;/td>
&lt;td>8&lt;/td>
&lt;td>2&lt;/td>
&lt;td>0&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Grok-1 (open)&lt;/td>
&lt;td>Mar 2024&lt;/td>
&lt;td>314 B&lt;/td>
&lt;td>~86 B&lt;/td>
&lt;td>8&lt;/td>
&lt;td>2&lt;/td>
&lt;td>0&lt;/td>
&lt;td>Weights opened by xAI&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>DeepSeekMoE 16B&lt;/td>
&lt;td>Jan 2024&lt;/td>
&lt;td>16 B&lt;/td>
&lt;td>2.8 B&lt;/td>
&lt;td>64+2&lt;/td>
&lt;td>6&lt;/td>
&lt;td>2&lt;/td>
&lt;td>Introduces fine-grained + shared&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>DeepSeek-V2&lt;/td>
&lt;td>May 2024&lt;/td>
&lt;td>236 B&lt;/td>
&lt;td>21 B&lt;/td>
&lt;td>160+2&lt;/td>
&lt;td>6&lt;/td>
&lt;td>2&lt;/td>
&lt;td>+ MLA&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Snowflake Arctic&lt;/td>
&lt;td>Apr 2024&lt;/td>
&lt;td>480 B&lt;/td>
&lt;td>17 B&lt;/td>
&lt;td>128&lt;/td>
&lt;td>2&lt;/td>
&lt;td>10 B dense residual&lt;/td>
&lt;td>Dense-MoE hybrid&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Hunyuan-Large&lt;/td>
&lt;td>Nov 2024&lt;/td>
&lt;td>389 B&lt;/td>
&lt;td>52 B&lt;/td>
&lt;td>16+1&lt;/td>
&lt;td>1+shared&lt;/td>
&lt;td>1&lt;/td>
&lt;td>Cross-layer KV cache&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>DeepSeek-V3&lt;/strong>&lt;/td>
&lt;td>Dec 2024&lt;/td>
&lt;td>671 B&lt;/td>
&lt;td>37 B&lt;/td>
&lt;td>256+1&lt;/td>
&lt;td>8&lt;/td>
&lt;td>1&lt;/td>
&lt;td>MLA + MTP, aux-loss-free, FP8 training&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Llama 4 Scout&lt;/td>
&lt;td>Apr 2025&lt;/td>
&lt;td>109 B&lt;/td>
&lt;td>17 B&lt;/td>
&lt;td>16&lt;/td>
&lt;td>1+shared&lt;/td>
&lt;td>1&lt;/td>
&lt;td>Designed for 1 H100 node&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Llama 4 Maverick&lt;/td>
&lt;td>Apr 2025&lt;/td>
&lt;td>400 B&lt;/td>
&lt;td>17 B&lt;/td>
&lt;td>128&lt;/td>
&lt;td>1+shared&lt;/td>
&lt;td>1&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Qwen3-235B-A22B&lt;/td>
&lt;td>Apr 2025&lt;/td>
&lt;td>235 B&lt;/td>
&lt;td>22 B&lt;/td>
&lt;td>128&lt;/td>
&lt;td>8&lt;/td>
&lt;td>0&lt;/td>
&lt;td>—&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Kimi K2 (Moonshot)&lt;/td>
&lt;td>2025&lt;/td>
&lt;td>1 T&lt;/td>
&lt;td>32 B&lt;/td>
&lt;td>384&lt;/td>
&lt;td>8&lt;/td>
&lt;td>(MLA)&lt;/td>
&lt;td>Trained with MuonClip&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>DeepSeek-V3.2-Exp&lt;/td>
&lt;td>Sep 2025&lt;/td>
&lt;td>671 B&lt;/td>
&lt;td>37 B&lt;/td>
&lt;td>256+1&lt;/td>
&lt;td>8&lt;/td>
&lt;td>1&lt;/td>
&lt;td>+ DeepSeek Sparse Attention&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Three operational observations:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>The active/total ratio falls with every generation&lt;/strong>: Mixtral 8x7B was 28 %, DeepSeek-V3 is 5.5 %, Llama 4 Maverick is 4.3 %. What the industry discovered is that &lt;strong>sparsity can be very aggressive without losing quality&lt;/strong>, provided &lt;code>N&lt;/code> is large enough and the routing is well learned. Fine specialisation is worth more than per-expert capacity.&lt;/li>
&lt;li>&lt;strong>The flagships of the large providers (OpenAI, Anthropic, Google) are almost certainly MoE&lt;/strong>, but the architectures are not published. The only semi-credible leak (Hotz, Jul 2023) claimed GPT-4 was an 8×~220 B MoE; no official confirmation.&lt;/li>
&lt;li>&lt;strong>DeepSeek-V3 marks the inflection point&lt;/strong>: open weights, frontier-class, MoE with aggressive sparsity and compatible with FP8 quantisation. It is the model that forced the ecosystem (vLLM, SGLang, TensorRT-LLM) to optimise wide-EP in 2025.&lt;/li>
&lt;/ol>
&lt;h2 id="the-maths-that-matters">The maths that matters&lt;/h2>
&lt;p>Three numbers drive the whole architectural decision with MoE.&lt;/p>
&lt;p>&lt;strong>Compute per token.&lt;/strong> A dense transformer spends roughly &lt;code>2N&lt;/code> FLOPs per token in the forward pass (with &lt;code>N&lt;/code> = total parameters). A MoE spends &lt;code>2·N_active&lt;/code> FLOPs per token (the &lt;code>N_total - N_active&lt;/code> experts do not take part). For DeepSeek-V3: 2 × 37 B = &lt;strong>74 GFLOPs/token&lt;/strong>. For dense Llama-3-70B: 2 × 70 B = &lt;strong>140 GFLOPs/token&lt;/strong>. &lt;strong>DeepSeek does half the compute per token&lt;/strong> of Llama-3-70B while offering capacity equivalent to a model almost 10 × larger.&lt;/p>
&lt;p>&lt;strong>Total memory.&lt;/strong> But &lt;strong>all&lt;/strong> the experts must be loaded in VRAM at some point in the forward pass. The sparsity is in compute, not memory. Weight memory:&lt;/p>
$$\text{Memory}_{\text{weights}} \approx N_{\text{total}} \cdot \text{bytes per param}$$
&lt;p>For DeepSeek-V3 FP8 (1 byte): 685 GB. It does not fit in 8×H100 SXM (640 GB). You need either extra quantisation or 16+ GPUs. For Mixtral 8x22B FP8: 141 GB, comfortably inside 2×H100. For Qwen3-235B-A22B FP8: 235 GB, fits in 4×H100.&lt;/p>
&lt;p>&lt;strong>All-to-all bytes per MoE layer.&lt;/strong> When experts are spread across GPUs (EP), each token has to travel to the GPUs where its &lt;code>k&lt;/code> experts live. The volume per MoE layer is roughly:&lt;/p>
$$\text{bytes a2a/layer} \approx 2 \cdot \text{batch} \cdot \text{seq len} \cdot d_{\text{model}} \cdot k \cdot \text{bytes}$$
&lt;p>(the factor 2 is dispatch plus combine). For DeepSeek-V3 with &lt;code>d_model = 7168&lt;/code>, &lt;code>k = 8&lt;/code>, &lt;code>batch = 4096&lt;/code>, FP8 (1 byte): each MoE layer moves ~470 MB; multiplied by 61 MoE layers in DeepSeek-V3, the complete step adds &lt;strong>~29 GB of inter-GPU traffic&lt;/strong> in MoE comms alone. That is of the order of a GPU&amp;rsquo;s HBM bandwidth in a single step. This is why bandwidth and NVLink/InfiniBand topology determine how large EP can be before comms saturate.&lt;/p>
&lt;p>&lt;strong>Persistent memory-bound regime.&lt;/strong> Here is the least intuitive consequence. In dense, raising the batch moves the operation from memory-bound to compute-bound: with enough tokens per batch, the weights are amortised over more operations. In MoE, each expert sees only &lt;code>batch · k / N&lt;/code> tokens. For Qwen3-235B (k=8, N=128) with batch=128, each expert processes &lt;strong>8 tokens per step&lt;/strong>. To reach the compute-bound regime of the tensor cores (arithmetic intensity ~100-200 FLOPs/byte) you need batches in the &lt;strong>thousands&lt;/strong>, around 1,600 according to recent estimates for Qwen3 (Memory-Bound MoE Serving, arXiv:2512.09277). The operational consequence: &lt;strong>MoE scales throughput with batch more linearly than dense&lt;/strong>, but it also needs far larger batches to get close to its compute ceiling.&lt;/p>
&lt;h2 id="expert-parallel-and-the-all-to-all-bottleneck">Expert parallel and the all-to-all bottleneck&lt;/h2>
&lt;p>There are three main ways to parallelise a MoE across GPUs:&lt;/p>
&lt;p>&lt;strong>Tensor Parallel (TP, Megatron-style).&lt;/strong> Splits each weight matrix across GPUs by width or height. Every GPU processes all tokens and all layers. Comms per layer: 2 all-reduces. It works well for dense; for MoE it leaves the routing sparsity unexploited (each GPU holds fragments of every expert).&lt;/p>
&lt;p>&lt;strong>Expert Parallel (EP).&lt;/strong> Splits &lt;strong>whole experts&lt;/strong> across GPUs: 256 experts / 32 GPUs = 8 experts per GPU. Each token, after routing, travels to the GPUs where its top-k &lt;code>k&lt;/code> experts live. Comms per MoE layer: an &lt;strong>all-to-all dispatch&lt;/strong> (tokens towards experts) plus an &lt;strong>all-to-all combine&lt;/strong> (outputs back). The cost grows with EP size and becomes the bottleneck when EP &amp;gt; 8.&lt;/p>
&lt;p>&lt;strong>DP + EP / Wide-EP.&lt;/strong> Replicates the model across DP groups; inside each group, EP shards the experts. &lt;strong>Wide-EP&lt;/strong> takes EP to the full dimension of a rack (NVL72, 72 GPUs in a coherent NVLink domain). Each expert receives more tokens per step, so arithmetic intensity in each expert&amp;rsquo;s GEMMs improves and per-GPU throughput rises. vLLM reports &lt;strong>1.8× throughput per GPU with wide-EP&lt;/strong> against smaller setups on DeepSeek-V3 (vLLM blog, Dec 2025).&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 380" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Expert Parallel with all-to-all dispatch and combine">
&lt;style>
.gpu{fill:#eaf3ff;stroke:#1f5fa8;stroke-width:1.4;rx:8}
.exp{fill:#fff4d6;stroke:#a48000;stroke-width:1.2;rx:4}
.tok{fill:#cdebd0;stroke:#2a7a40;stroke-width:1.2;rx:4}
.router{fill:#e6d0ff;stroke:#5a2db0;stroke-width:1.4;rx:6}
.lbl{font:600 12px sans-serif;fill:#222}
.sub{font:400 10px sans-serif;fill:#555}
.disp{stroke:#cc6622;stroke-width:1.5;fill:none;marker-end:url(#mma)}
.comb{stroke:#226699;stroke-width:1.5;fill:none;stroke-dasharray:4 2;marker-end:url(#mma)}
&lt;/style>
&lt;defs>&lt;marker id="mma" 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="#444"/>&lt;/marker>&lt;/defs>
&lt;text x="20" y="22" class="lbl">MoE layer with Expert Parallel: EP=4 (4 GPUs, 8 experts/GPU)&lt;/text>
&lt;rect x="20" y="40" width="170" height="60" class="router"/>
&lt;text x="105" y="62" text-anchor="middle" class="lbl">Router (W_router)&lt;/text>
&lt;text x="105" y="78" text-anchor="middle" class="sub">softmax → top-k=2&lt;/text>
&lt;text x="105" y="92" text-anchor="middle" class="sub">per token&lt;/text>
&lt;rect x="20" y="115" width="80" height="35" class="tok"/>&lt;text x="60" y="138" text-anchor="middle" class="lbl">tokens&lt;/text>
&lt;text x="220" y="60" class="sub">all-to-all dispatch (FP8)&lt;/text>
&lt;path class="disp" d="M195,75 L300,160"/>
&lt;path class="disp" d="M195,80 L450,160"/>
&lt;path class="disp" d="M195,85 L600,160"/>
&lt;path class="disp" d="M195,90 L720,160"/>
&lt;rect x="240" y="160" width="120" height="120" class="gpu"/>
&lt;text x="300" y="178" text-anchor="middle" class="lbl">GPU 0&lt;/text>
&lt;rect x="252" y="190" width="42" height="20" class="exp"/>&lt;text x="273" y="204" text-anchor="middle" class="sub">E0&lt;/text>
&lt;rect x="296" y="190" width="42" height="20" class="exp"/>&lt;text x="317" y="204" text-anchor="middle" class="sub">E1&lt;/text>
&lt;rect x="252" y="215" width="42" height="20" class="exp"/>&lt;text x="273" y="229" text-anchor="middle" class="sub">E2&lt;/text>
&lt;rect x="296" y="215" width="42" height="20" class="exp"/>&lt;text x="317" y="229" text-anchor="middle" class="sub">E3&lt;/text>
&lt;text x="300" y="255" text-anchor="middle" class="sub">experts 0-7&lt;/text>
&lt;text x="300" y="270" text-anchor="middle" class="sub">(8 experts)&lt;/text>
&lt;rect x="390" y="160" width="120" height="120" class="gpu"/>
&lt;text x="450" y="178" text-anchor="middle" class="lbl">GPU 1&lt;/text>
&lt;rect x="402" y="190" width="42" height="20" class="exp"/>&lt;text x="423" y="204" text-anchor="middle" class="sub">E8&lt;/text>
&lt;rect x="446" y="190" width="42" height="20" class="exp"/>&lt;text x="467" y="204" text-anchor="middle" class="sub">E9&lt;/text>
&lt;text x="450" y="255" text-anchor="middle" class="sub">experts 8-15&lt;/text>
&lt;rect x="540" y="160" width="120" height="120" class="gpu"/>
&lt;text x="600" y="178" text-anchor="middle" class="lbl">GPU 2&lt;/text>
&lt;rect x="552" y="190" width="42" height="20" class="exp"/>&lt;text x="573" y="204" text-anchor="middle" class="sub">E16&lt;/text>
&lt;rect x="596" y="190" width="42" height="20" class="exp"/>&lt;text x="617" y="204" text-anchor="middle" class="sub">E17&lt;/text>
&lt;text x="600" y="255" text-anchor="middle" class="sub">experts 16-23&lt;/text>
&lt;rect x="660" y="160" width="100" height="120" class="gpu"/>
&lt;text x="710" y="178" text-anchor="middle" class="lbl">GPU 3&lt;/text>
&lt;rect x="668" y="190" width="42" height="20" class="exp"/>&lt;text x="689" y="204" text-anchor="middle" class="sub">E24&lt;/text>
&lt;rect x="712" y="190" width="42" height="20" class="exp"/>&lt;text x="733" y="204" text-anchor="middle" class="sub">E25&lt;/text>
&lt;text x="710" y="255" text-anchor="middle" class="sub">experts 24-31&lt;/text>
&lt;text x="220" y="310" class="sub">all-to-all combine (weighted sum returns to the origin)&lt;/text>
&lt;path class="comb" d="M300,280 L195,330"/>
&lt;path class="comb" d="M450,280 L195,330"/>
&lt;path class="comb" d="M600,280 L195,330"/>
&lt;path class="comb" d="M710,280 L195,330"/>
&lt;rect x="100" y="325" width="200" height="35" class="tok"/>
&lt;text x="200" y="346" text-anchor="middle" class="lbl">y = Σ w_i · Expert_i(x)&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>Quick decision table:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Case&lt;/th>
&lt;th>Recommendation&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Small MoE that fits in 1 node (Mixtral 8x7B, Llama 4 Scout)&lt;/td>
&lt;td>TP only or EP=2 intra-node&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Medium MoE (Mixtral 8x22B, Qwen3-235B) on 1-2 H100 nodes&lt;/td>
&lt;td>EP=8 intra-node, optionally TP=2&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Large MoE (DeepSeek-V3) on a multi-node cluster&lt;/td>
&lt;td>TP × EP cross-node (e.g. TP=4 × EP=8 on 32 GPUs)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Large MoE with NVL72 available&lt;/td>
&lt;td>Wide-EP=72 (decode), EP=32 (prefill)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>DeepSeek&amp;rsquo;s production deployment combines &lt;strong>Prefill EP=32&lt;/strong> (4 nodes × 8 GPUs) with &lt;strong>Decode EP=144&lt;/strong> (18 nodes × 8 GPUs). The prefill/decode split fits conceptually with &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving&lt;/a>: prefill is compute-bound and benefits from moderate EP; decode is memory-bound and benefits from Wide-EP, which raises the tokens per expert per step.&lt;/p>
&lt;h2 id="deepep-eplb-and-wide-ep-the-pieces-that-unblocked-2025">DeepEP, EPLB and Wide-EP: the pieces that unblocked 2025&lt;/h2>
&lt;p>&lt;strong>DeepEP&lt;/strong> (DeepSeek, opened in February 2025) is a library of all-to-all CUDA kernels optimised specifically for MoE EP. Four important properties:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Native FP8 dispatch&lt;/strong>: tokens travel in FP8, halving the bandwidth consumed.&lt;/li>
&lt;li>&lt;strong>Mixing intra-node NVLink with inter-node RDMA&lt;/strong>: the kernel picks the optimal route according to topology.&lt;/li>
&lt;li>&lt;strong>CPU bypass&lt;/strong>: the GPU→GPU dispatch goes over RDMA without touching the host.&lt;/li>
&lt;li>&lt;strong>Aligned with DeepSeek-V3&amp;rsquo;s group-limited gating&lt;/strong>: the routing prefers experts in the same node group where possible, minimising inter-node traffic.&lt;/li>
&lt;/ul>
&lt;p>In 2025 Tencent contributed optimisations that added +30 % on the normal kernels. There is a ROCm port for AMD.&lt;/p>
&lt;p>&lt;strong>EPLB (Expert Parallelism Load Balancer)&lt;/strong> solves the runtime imbalance problem: even when the model is well balanced in global distribution, &lt;strong>a specific batch can heavily activate certain experts&lt;/strong> (prompts in a single language, a single domain). EPLB &lt;strong>replicates the hot experts&lt;/strong> across several GPUs (redundant experts) and packs them heuristically with cold experts to minimise load variance. It has two modes: hierarchical (for prefill with medium EP) and global (for decode with Wide-EP). It is integrated into SGLang and vLLM.&lt;/p>
&lt;p>&lt;strong>Wide-EP on NVL72&lt;/strong> is the peak setup of 2026: 72 Blackwell GPUs in a coherent NVLink domain (1.8 TB/s bidirectional per GPU, intra-rack). Each expert receives many more tokens per step, so arithmetic intensity improves and so does throughput per GPU. Combined with NVFP4 over expert weights and FP8 over attention, SGLang reported on GB200 NVL72 &lt;strong>26,156 tok/s/GPU in prefill and 13,386 tok/s/GPU in decode&lt;/strong> for DeepSeek-V3 (LMSYS, Sep 2025), a &lt;strong>3.8× prefill and 4.8× decode versus H100&lt;/strong>.&lt;/p>
&lt;h2 id="operational-pitfalls">Operational pitfalls&lt;/h2>
&lt;p>&lt;strong>HBM fragmentation from expert weights.&lt;/strong> With EP=8 in 1 node, each GPU holds N/8 experts, each a tuple of matrices (&lt;code>gate&lt;/code>, &lt;code>up&lt;/code>, &lt;code>down&lt;/code>). If N=256, that is 32 FFNs per GPU. The allocator can fragment; in some cases you need a more conservative &lt;code>--gpu-memory-utilization&lt;/code> than usual.&lt;/p>
&lt;p>&lt;strong>Cold experts.&lt;/strong> In real distributions some experts are activated by &amp;lt;1 % of tokens. EPLB compensates by replicating hot ones, but the cold ones sit occupying HBM without taking part, &amp;ldquo;dead&amp;rdquo; memory. When real demand diverges a lot from the training distribution, this shows.&lt;/p>
&lt;p>&lt;strong>Continuous batching.&lt;/strong> It works with MoE, but the effective batch size per expert is &lt;code>batch_total · k / N&lt;/code>. With large N and a moderate batch, each expert sees very few tokens per step. You need a &lt;code>batch_total&lt;/code> far larger than in dense to match per-GPU throughput.&lt;/p>
&lt;p>&lt;strong>Speculative decoding + MoE.&lt;/strong> They interact &lt;strong>well&lt;/strong>. MoE sparsity keeps the memory-bound regime at high batch, which is exactly the regime where speculative decoding wins most. DeepSeek-V3 integrates &lt;strong>MTP&lt;/strong> (Multi-Token Prediction), speculative decoding native to the model, with no external draft, with acceptance around 85-90 % on the second token (see &lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding&lt;/a> for the mechanism).&lt;/p>
&lt;p>&lt;strong>MLA and FlashMLA are not MoE.&lt;/strong> DeepSeek-V3 combines MoE with &lt;strong>Multi-head Latent Attention&lt;/strong> (MLA), an attention variant that reduces the KV cache ~10× against standard MHA. MLA and MoE are orthogonal: MLA optimises attention, MoE optimises the FFN. For serving DeepSeek you use the specific &lt;strong>FlashMLA&lt;/strong> kernel (not FA3 directly).&lt;/p>
&lt;p>&lt;strong>DSA (DeepSeek Sparse Attention)&lt;/strong> in DeepSeek-V3.2-Exp (Sep 2025) introduces sparsity in attention too. The first time at the frontier that sparsity is applied in both attention and FFN.&lt;/p>
&lt;h2 id="implications-for-on-premise-hardware">Implications for on-premise hardware&lt;/h2>
&lt;p>&lt;strong>On an RTX 4090 (24 GB).&lt;/strong> Large MoEs are out of reach. What does fit: Mixtral 8x7B AWQ-INT4 (~24 GB of weights, no headroom for KV cache, so it really requires TP=2 across two 4090s); DeepSeekMoE 16B BF16 (~33 GB, does not fit whole; INT4 ~8 GB, fits with room to spare). The interesting case is &lt;strong>llama.cpp with &lt;code>--n-cpu-moe&lt;/code>&lt;/strong>: expert weights offloaded to RAM (CPU), attention and shared experts on the GPU. It lets you run DeepSeek-V3 IQ4 (~400 GB) on a workstation with 1-2 GPUs plus 512 GB of RAM at modest throughput, but it opens the door to a low-budget setup.&lt;/p>
&lt;p>&lt;strong>On a generic 4×H100 SXM cluster (320 GB, NVLink, native FP8).&lt;/strong> These fit comfortably:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Mixtral 8x22B FP8&lt;/strong> (~141 GB) with TP=2 or EP=4. Low latency, excellent throughput.&lt;/li>
&lt;li>&lt;strong>Qwen3-235B-A22B FP8&lt;/strong> (~235 GB) with TP=4 or EP=4. Plenty of room.&lt;/li>
&lt;li>&lt;strong>Llama 4 Scout 109B FP8&lt;/strong> (~110 GB) with TP=2 or TP=4. Designed by Meta to serve well on one H100 node.&lt;/li>
&lt;/ul>
&lt;p>What does &lt;strong>not&lt;/strong> fit in 4×H100: DeepSeek-V3 FP8 (~685 GB) without aggressive quantisation. Options: (1) scale to 8-16 H100; (2) use AWQ INT4 over routed experts plus FP16 over activated ones (~400 GB), which fits in 8×H100; (3) wait for Blackwell or use AMD MI300X (192 GB/GPU, so DeepSeek-V3 FP8 on 4×MI300X).&lt;/p>
&lt;p>The rule of thumb as of May 2026: &lt;strong>one 4×H100 node comfortably serves MoEs up to ~200 B total in FP8; for DeepSeek-V3 you have to scale to 2 nodes or wait for B200 NVL72&lt;/strong> (where the whole model fits with room for enormous batches).&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>MLA and FlashMLA in detail&lt;/strong>: DeepSeek&amp;rsquo;s KV cache optimisation, orthogonal to MoE.&lt;/li>
&lt;li>&lt;strong>DeepSeek Sparse Attention (DSA)&lt;/strong> introduced in V3.2-Exp: the first production implementation of attention sparsity for frontier models.&lt;/li>
&lt;li>&lt;strong>MoE during training&lt;/strong>: load balancing losses, drop policies, auxiliary terms vs aux-loss-free, Kimi K2&amp;rsquo;s MuonClip.&lt;/li>
&lt;li>&lt;strong>MoE + LoRA&lt;/strong>: how adapter fine-tuning is done on a MoE, and what happens to the routing.&lt;/li>
&lt;li>&lt;strong>LLM-d&lt;/strong> and other open-source platforms that materialise Wide-EP on Kubernetes.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: working memory&lt;/a> — structurally the KV cache is the same as in dense (attention is dense in every current MoE); MLA is the specific optimisation DeepSeek adds on top.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention deep dive&lt;/a> — handling the KV cache in physical blocks stays identical under MoE; only the FFN changes.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention v1/v2/v3/v4&lt;/a> — the attention kernel is reused as-is; FlashMLA is the variant specific to DeepSeek&amp;rsquo;s MLA, adding a latent compression step to the FA pattern.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantization for LLM inference&lt;/a> — FP8/NVFP4 over expert weights is what makes DeepSeek-V3 fit into a modest cluster and what lets NVL72 reach its peak numbers.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding&lt;/a> — MTP in DeepSeek-V3 is native speculative decoding; MoE&amp;rsquo;s persistent memory-bound regime means speculative wins more in MoE than in dense at medium batch.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving: prefill and decode&lt;/a> — DeepSeek&amp;rsquo;s real deployment separates prefill (EP=32) and decode (EP=144) pools; disaggregation is a prerequisite for Wide-EP.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> — the other side of the coin. MoE needs far larger batches than dense for the same per-GPU throughput because each expert sees &lt;code>batch · k / N&lt;/code> tokens per step; the iterative scheduler is what makes it viable at scale.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the master map where Deploy is stage 4.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise LLM inference&lt;/a> — MoE sizing differs from dense: the &amp;ldquo;MoE case&amp;rdquo; section there quantifies the VRAM cost dominated by total weights and the TPOT dominated by active weights per token.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/device-native-architectures-fine-grained-moe-pre-attention-router/">Device-native architectures: fine-grained MoE and pre-attention router&lt;/a> — MoE taken to the fine-grained extreme and designed from scratch for device: the pre-attention router prefetches experts from SSD in parallel with attention, hiding the storage I/O.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Shazeer, N. et al. &lt;em>Outrageously Large Neural Networks: The Sparsely-Gated Mixture-of-Experts Layer&lt;/em>. ICLR 2017. &lt;a href="https://arxiv.org/abs/1701.06538">https://arxiv.org/abs/1701.06538&lt;/a>&lt;/li>
&lt;li>Lepikhin, D. et al. &lt;em>GShard: Scaling Giant Models with Conditional Computation&lt;/em>. ICLR 2021. &lt;a href="https://arxiv.org/abs/2006.16668">https://arxiv.org/abs/2006.16668&lt;/a>&lt;/li>
&lt;li>Fedus, W., Zoph, B., Shazeer, N. &lt;em>Switch Transformers: Scaling to Trillion Parameter Models with Simple and Efficient Sparsity&lt;/em>. JMLR 2022. &lt;a href="https://arxiv.org/abs/2101.03961">https://arxiv.org/abs/2101.03961&lt;/a>&lt;/li>
&lt;li>Gale, T., Narayanan, D., Young, C., Zaharia, M. &lt;em>MegaBlocks: Efficient Sparse Training with Mixture-of-Experts&lt;/em>. MLSys 2023. &lt;a href="https://arxiv.org/abs/2211.15841">https://arxiv.org/abs/2211.15841&lt;/a>&lt;/li>
&lt;li>Mistral AI. &lt;em>Mixtral of Experts&lt;/em>. 2024. &lt;a href="https://arxiv.org/abs/2401.04088">https://arxiv.org/abs/2401.04088&lt;/a>&lt;/li>
&lt;li>DeepSeek-AI. &lt;em>DeepSeekMoE: Towards Ultimate Expert Specialization in Mixture-of-Experts Language Models&lt;/em>. 2024. &lt;a href="https://arxiv.org/abs/2401.06066">https://arxiv.org/abs/2401.06066&lt;/a>&lt;/li>
&lt;li>DeepSeek-AI. &lt;em>DeepSeek-V2&lt;/em>. 2024. &lt;a href="https://arxiv.org/abs/2405.04434">https://arxiv.org/abs/2405.04434&lt;/a>&lt;/li>
&lt;li>DeepSeek-AI. &lt;em>DeepSeek-V3 Technical Report&lt;/em>. 2024. &lt;a href="https://arxiv.org/abs/2412.19437">https://arxiv.org/abs/2412.19437&lt;/a>&lt;/li>
&lt;li>Tencent. &lt;em>Hunyuan-Large&lt;/em>. 2024. &lt;a href="https://arxiv.org/abs/2411.02265">https://arxiv.org/abs/2411.02265&lt;/a>&lt;/li>
&lt;li>DeepEP repo: &lt;a href="https://github.com/deepseek-ai/DeepEP">https://github.com/deepseek-ai/DeepEP&lt;/a>&lt;/li>
&lt;li>EPLB repo: &lt;a href="https://github.com/deepseek-ai/EPLB">https://github.com/deepseek-ai/EPLB&lt;/a>&lt;/li>
&lt;li>DeepSeek open-infra-index, &lt;em>V3/R1 Inference System Overview&lt;/em>: &lt;a href="https://github.com/deepseek-ai/open-infra-index">https://github.com/deepseek-ai/open-infra-index&lt;/a>&lt;/li>
&lt;li>vLLM blog, &lt;em>Large-Scale Serving DeepSeek&lt;/em> (Dec 2025): &lt;a href="https://blog.vllm.ai/2025/12/17/large-scale-serving.html">https://blog.vllm.ai/2025/12/17/large-scale-serving.html&lt;/a>&lt;/li>
&lt;li>vLLM blog, &lt;em>WideEP en GB200&lt;/em> (Feb 2026): &lt;a href="https://blog.vllm.ai/2026/02/03/dsr1-gb200-part1.html">https://blog.vllm.ai/2026/02/03/dsr1-gb200-part1.html&lt;/a>&lt;/li>
&lt;li>LMSYS blog, &lt;em>DeepSeek 96 H100 PD+EP&lt;/em> (May 2025): &lt;a href="https://www.lmsys.org/blog/2025-05-05-large-scale-ep/">https://www.lmsys.org/blog/2025-05-05-large-scale-ep/&lt;/a>&lt;/li>
&lt;li>LMSYS blog, &lt;em>DeepSeek GB200 NVL72 part II&lt;/em> (Sep 2025): &lt;a href="https://www.lmsys.org/blog/2025-09-25-gb200-part-2/">https://www.lmsys.org/blog/2025-09-25-gb200-part-2/&lt;/a>&lt;/li>
&lt;li>NVIDIA Dynamo + GB200 NVL72 for MoE: &lt;a href="https://developer.nvidia.com/blog/how-nvidia-gb200-nvl72-and-nvidia-dynamo-boost-inference-performance-for-moe-models/">https://developer.nvidia.com/blog/how-nvidia-gb200-nvl72-and-nvidia-dynamo-boost-inference-performance-for-moe-models/&lt;/a>&lt;/li>
&lt;li>Tensor Economics, &lt;em>MoE Inference Economics from First Principles&lt;/em>: &lt;a href="https://www.tensoreconomics.com/p/moe-inference-economics-from-first">https://www.tensoreconomics.com/p/moe-inference-economics-from-first&lt;/a>&lt;/li>
&lt;li>Cohere, &lt;em>Why MoE models get more from speculative decoding&lt;/em>: &lt;a href="https://cohere.com/blog/mixture-of-experts-models-get-more-from-speculative-decoding">https://cohere.com/blog/mixture-of-experts-models-get-more-from-speculative-decoding&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Speculative decoding: the secretary who types ahead of the boss — fundamentals, maths and state of play in May 2026</title><link>https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/</link><pubDate>Fri, 29 May 2026 11:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/</guid><description>&lt;blockquote>
&lt;p>This post complements &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation for LLM inference&lt;/a>. Both are acceleration levers &lt;strong>inside a single forward pass&lt;/strong>. Speculative decoding is the complementary lever: instead of making each forward pass cheaper, it tries to &lt;strong>produce more tokens per forward pass&lt;/strong>. It is orthogonal to quantisation and to the KV cache, and it stacks multiplicatively with both.&lt;/p>
&lt;/blockquote>
&lt;h2 id="you-are-here-deploy">You are here: DEPLOY&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 90" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="you are here: Deploy">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#7ad88f;stroke-width:3}.idle{fill:#f4f4f4}.lbl{font:600 12px sans-serif;fill:#222}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#spm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#spm)}&lt;/style>
&lt;defs>&lt;marker id="spm" 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="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="20" text-anchor="middle" class="lbl">You are here: DEPLOY · more tokens per forward pass without touching quality&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box idle"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box active"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box idle"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Autoregressive LLM inference has a structural problem: every new token needs a complete forward pass of the model, and decode forward passes are memory-bandwidth-bound, not compute-bound (the detail is in &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>). The GPU spends most of its time waiting for the next weight to arrive from HBM. &lt;strong>Speculative decoding&lt;/strong> uses that dead time by doing two things in parallel: a small, cheap model (&lt;em>draft&lt;/em>) generates γ tokens autoregressively, fast but imprecise, and the large model (&lt;em>target&lt;/em>) verifies those γ tokens &lt;strong>in a single parallel forward pass&lt;/strong>, at almost the same cost as a single-token forward pass. An acceptance rule based on rejection sampling decides how many draft tokens are accepted, and the maths proves, it does not approximate, it proves, that the output distribution matches sampling directly from the target exactly. The technique has a ceiling: you never generate more than &lt;code>1/(1-α)&lt;/code> tokens per step, with α the acceptance rate. The five dominant families in May 2026 are &lt;strong>vanilla SD&lt;/strong> (Leviathan 2023), &lt;strong>Medusa&lt;/strong> (extra parallel heads), &lt;strong>EAGLE-1/2/3&lt;/strong> (a draft that operates at the level of hidden states), &lt;strong>MTP&lt;/strong> (multi-token prediction native to DeepSeek-V3) and &lt;strong>P-EAGLE&lt;/strong> (a draft that produces the γ tokens in a single forward pass, integrated in vLLM 0.16+). On low-concurrency workloads with short prompts and long outputs, the typical case of the on-premise conversational assistant, the real speedup sits between 2× and 4× on modern hardware.&lt;/p>
&lt;h2 id="the-analogy-the-secretary-who-types-ahead-and-the-boss-who-validates">The analogy: the secretary who types ahead and the boss who validates&lt;/h2>
&lt;p>Picture a crowded press conference with a real-time transcription setup. Two people are working:&lt;/p>
&lt;p>The &lt;strong>secretary&lt;/strong> sits at the keyboard. They know the subject, they have read the briefings and they know the boss&amp;rsquo;s verbal tics. As soon as the boss opens their mouth, the secretary starts typing what they think is coming. They are fast, three or four words ahead of the boss, but they sometimes get it wrong, especially when the boss takes an unexpected turn.&lt;/p>
&lt;p>The &lt;strong>boss&lt;/strong> periodically reads the secretary&amp;rsquo;s screen. They read a whole block at once, three or four words, and mentally compare what is written with what they would have said. If it matches their intent, they leave the text and carry on. If the secretary drifted at some point, they correct right there, and whatever the secretary typed after the divergence is discarded (it could be wrong because of the earlier error). If the whole block was fine, the boss takes the chance to add one more word while correcting.&lt;/p>
&lt;p>The result: the final text is identical to what the boss would have dictated alone, but it comes out faster because the secretary works ahead.&lt;/p>
&lt;p>The analogy holds on four details:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>The secretary is the draft model&lt;/strong>. Small, cheap, fast, approximate.&lt;/li>
&lt;li>&lt;strong>The boss is the target model&lt;/strong>. Slow, expensive, but the only authority on output quality.&lt;/li>
&lt;li>&lt;strong>The block the boss reads at once is the target&amp;rsquo;s parallel forward pass&lt;/strong> over &lt;code>prompt + γ draft tokens&lt;/code>. Verifying γ tokens costs almost the same as generating one, because the bottleneck is loading the weights from HBM, not the operations.&lt;/li>
&lt;li>&lt;strong>The &amp;ldquo;correct right where it diverged&amp;rdquo; rule is the rejection sampling&lt;/strong> that preserves the target distribution.&lt;/li>
&lt;/ul>
&lt;p>From here we go into the mechanism and into why quality is preserved exactly, not approximately.&lt;/p>
&lt;h2 id="the-bare-mechanism">The bare mechanism&lt;/h2>
&lt;p>Call &lt;code>p&lt;/code> the target distribution (what it would produce if it were the only one speaking) and &lt;code>q&lt;/code> the draft&amp;rsquo;s. The speculative decoding iteration has three steps:&lt;/p>
&lt;p>&lt;strong>Step 1 — Draft.&lt;/strong> The draft generates γ tokens autoregressively: &lt;code>x_1, x_2, ..., x_γ&lt;/code>. Each one through its own forward pass. Since the draft is small, the γ steps cost little. Typically γ ∈ [4, 8].&lt;/p>
&lt;p>&lt;strong>Step 2 — Verify.&lt;/strong> The target runs a single forward pass over the complete sequence &lt;code>prompt + x_1 ... x_γ&lt;/code>. Because attention is causal, that forward pass simultaneously produces the distributions &lt;code>p(·|prompt, x_&amp;lt;i)&lt;/code> for each position &lt;em>i&lt;/em>. In other words, in a single step it obtains the verification of the γ tokens and, on top of that, an extra distribution &lt;code>p(·|prompt, x_1...x_γ)&lt;/code> for the next token.&lt;/p>
&lt;p>&lt;strong>Step 3 — Accept/reject token by token.&lt;/strong> For each &lt;code>x_i&lt;/code> from left to right it applies:&lt;/p>
&lt;ul>
&lt;li>If &lt;code>p(x_i) ≥ q(x_i)&lt;/code>: always accept.&lt;/li>
&lt;li>If &lt;code>p(x_i) &amp;lt; q(x_i)&lt;/code>: accept with probability &lt;code>p(x_i)/q(x_i)&lt;/code>.&lt;/li>
&lt;li>If it rejects: stop, sample a replacement token from the normalised residual distribution &lt;code>norm(max(0, p − q))&lt;/code>, and discard the rest.&lt;/li>
&lt;li>If it reaches the end having accepted the γ tokens: append a &lt;strong>bonus token&lt;/strong> sampled directly from &lt;code>p(·|prompt, x_1...x_γ)&lt;/code>, which is already in the logits of the target&amp;rsquo;s forward pass.&lt;/li>
&lt;/ul>
&lt;p>Result per iteration: between 1 and γ+1 new tokens. In the best case (all accepted + bonus) γ+1 tokens are generated at the cost of &lt;strong>a single target forward pass plus γ draft forward passes&lt;/strong>, against γ+1 target forward passes in the version without speculative.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 780 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Speculative decoding mechanism">
&lt;style>
.dbox{fill:#fff4d6;stroke:#a48000;stroke-width:1.4;rx:6}
.tbox{fill:#d4ecff;stroke:#1f5fa8;stroke-width:1.4;rx:6}
.acc{fill:#cdebd0;stroke:#2a7a40;stroke-width:1.4;rx:6}
.rej{fill:#f6caca;stroke:#a52a2a;stroke-width:1.4;rx:6}
.bon{fill:#e6d0ff;stroke:#5a2db0;stroke-width:1.4;rx:6}
.lbl{font:600 12px sans-serif;fill:#222}
.sub{font:400 11px sans-serif;fill:#555}
.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#spec1)}
&lt;/style>
&lt;defs>&lt;marker id="spec1" 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="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="20" y="25" class="lbl">1. Draft model (q) generates γ=4 tokens autoregressively&lt;/text>
&lt;rect x="20" y="35" width="80" height="35" class="dbox"/>&lt;text x="60" y="58" text-anchor="middle" class="lbl">x₁&lt;/text>
&lt;rect x="120" y="35" width="80" height="35" class="dbox"/>&lt;text x="160" y="58" text-anchor="middle" class="lbl">x₂&lt;/text>
&lt;rect x="220" y="35" width="80" height="35" class="dbox"/>&lt;text x="260" y="58" text-anchor="middle" class="lbl">x₃&lt;/text>
&lt;rect x="320" y="35" width="80" height="35" class="dbox"/>&lt;text x="360" y="58" text-anchor="middle" class="lbl">x₄&lt;/text>
&lt;text x="420" y="58" class="sub">4 cheap forward passes&lt;/text>
&lt;text x="20" y="105" class="lbl">2. Target model (p) verifies the 4 tokens in ONE parallel forward pass&lt;/text>
&lt;rect x="20" y="115" width="380" height="40" class="tbox"/>&lt;text x="210" y="140" text-anchor="middle" class="lbl">single forward pass over [prompt, x₁, x₂, x₃, x₄]&lt;/text>
&lt;text x="420" y="140" class="sub">obtains p(·|prompt, x&amp;lt;i) for i=1..5&lt;/text>
&lt;text x="20" y="180" class="lbl">3. Left-to-right rejection sampling&lt;/text>
&lt;rect x="20" y="190" width="80" height="35" class="acc"/>&lt;text x="60" y="208" text-anchor="middle" class="lbl">x₁ ✓&lt;/text>&lt;text x="60" y="221" text-anchor="middle" class="sub">p≥q&lt;/text>
&lt;rect x="120" y="190" width="80" height="35" class="acc"/>&lt;text x="160" y="208" text-anchor="middle" class="lbl">x₂ ✓&lt;/text>&lt;text x="160" y="221" text-anchor="middle" class="sub">accepted&lt;/text>
&lt;rect x="220" y="190" width="80" height="35" class="acc"/>&lt;text x="260" y="208" text-anchor="middle" class="lbl">x₃ ✓&lt;/text>&lt;text x="260" y="221" text-anchor="middle" class="sub">accepted&lt;/text>
&lt;rect x="320" y="190" width="80" height="35" class="rej"/>&lt;text x="360" y="208" text-anchor="middle" class="lbl">x₄ ✗&lt;/text>&lt;text x="360" y="221" text-anchor="middle" class="sub">rejected&lt;/text>
&lt;text x="20" y="255" class="lbl">4. Result of this iteration&lt;/text>
&lt;rect x="20" y="265" width="80" height="35" class="acc"/>&lt;text x="60" y="288" text-anchor="middle" class="lbl">x₁&lt;/text>
&lt;rect x="120" y="265" width="80" height="35" class="acc"/>&lt;text x="160" y="288" text-anchor="middle" class="lbl">x₂&lt;/text>
&lt;rect x="220" y="265" width="80" height="35" class="acc"/>&lt;text x="260" y="288" text-anchor="middle" class="lbl">x₃&lt;/text>
&lt;rect x="320" y="265" width="80" height="35" class="rej"/>&lt;text x="360" y="288" text-anchor="middle" class="lbl">x'₄&lt;/text>
&lt;text x="420" y="280" class="sub">x'₄ = sample from norm(max(0, p−q)) at pos 4&lt;/text>
&lt;text x="420" y="295" class="sub">bonus dropped (only if all accepted)&lt;/text>
&lt;text x="20" y="325" class="sub">→ 4 new tokens in a single target iteration. Without speculative it would be 4 iterations.&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="why-quality-does-not-degrade-the-proof">Why quality does not degrade (the proof)&lt;/h2>
&lt;p>This is the part of the technique that confuses people most the first time. It looks like magic: how can sampling from an arbitrary draft and then &amp;ldquo;validating&amp;rdquo; produce exactly the same distribution as sampling from the original target?&lt;/p>
&lt;p>The proof fits in two lines. For any token &lt;code>x&lt;/code>, the final probability of emitting it is the sum of two disjoint events: the draft proposes it and it is accepted, or the draft proposes something else, it is rejected, and sampling from the residual yields &lt;code>x&lt;/code>. Formally:&lt;/p>
$$P(\text{emit } x) = q(x) \cdot \min\!\left(1, \frac{p(x)}{q(x)}\right) + P(\text{reject}) \cdot \frac{\max(0, p(x)-q(x))}{\sum_y \max(0, p(y)-q(y))}$$
&lt;p>The first term is &lt;code>min(p(x), q(x))&lt;/code>. The second is &lt;code>max(0, p(x) − q(x))&lt;/code> (the total rejected mass &lt;code>Σ max(0, p−q)&lt;/code> cancels the denominator exactly). Adding them: &lt;code>min(p(x), q(x)) + max(0, p(x) − q(x)) = p(x)&lt;/code>. The output is statistically indistinguishable from sampling directly from the target, down to floating-point numerical differences.&lt;/p>
&lt;p>This matters operationally: it means speculative decoding is not perceptual compression, it is not a quality-speed trade-off, it does not require extra validation with evals. If the target would have passed a given eval, the system with speculative passes it too.&lt;/p>
&lt;h2 id="the-maths-that-matters-ceiling-and-speedup">The maths that matters: ceiling and speedup&lt;/h2>
&lt;p>Call α the &lt;strong>acceptance rate&lt;/strong>: the expected probability that an individual draft token is accepted. Assuming that acceptances of consecutive tokens are independent (a reasonable approximation in practice), the expected number of tokens generated per iteration is:&lt;/p>
$$E[\text{tokens per step}] = \sum_{k=0}^{\gamma} \alpha^k + \alpha^{\gamma+1} = \frac{1 - \alpha^{\gamma+1}}{1 - \alpha}$$
&lt;p>And the &lt;strong>theoretical speedup&lt;/strong> relative to generating one token per target iteration, with &lt;code>c = T_draft / T_target&lt;/code> the relative cost of the draft, is:&lt;/p>
$$\text{Speedup} = \frac{1 - \alpha^{\gamma+1}}{(1 - \alpha)(\gamma c + 1)}$$
&lt;p>There is a ceiling that many implementations try to beat and cannot: as &lt;code>γ → ∞&lt;/code>, the speedup converges to:&lt;/p>
$$\lim_{\gamma \to \infty} E[\text{tokens per step}] = \frac{1}{1 - \alpha}$$
&lt;p>It is an algorithmic ceiling, not a hardware one. With α = 0.7 you never generate more than 3.33 tokens per iteration no matter how much the hardware improves. With α = 0.8 → 5. With α = 0.9 → 10. That is why EAGLE-3, which targets α &amp;gt; 0.8 on many benchmarks, is not an incremental improvement over vanilla SD (α ≈ 0.5-0.6): it is a change of regime, because it raises the ceiling instead of merely getting closer to it.&lt;/p>
&lt;p>A concrete numerical example, Llama 3 70B as target with a draft giving α = 0.75, γ = 5, c = 0.1:&lt;/p>
&lt;ul>
&lt;li>Expected tokens per step: &lt;code>(1 − 0.75⁶) / (1 − 0.75) = (1 − 0.178) / 0.25 = 3.29&lt;/code>&lt;/li>
&lt;li>Speedup: &lt;code>3.29 / (5 × 0.1 + 1) = 3.29 / 1.5 = 2.19×&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>If we raise α to 0.85 with the same configuration: tokens per step = &lt;code>(1 − 0.85⁶) / 0.15 = (1 − 0.377) / 0.15 = 4.16&lt;/code>, speedup = &lt;code>4.16 / 1.5 = 2.77×&lt;/code>. A change of α = 0.75 → 0.85 (10 absolute points) raises the speedup by 27 %. That is why the research of the last two years focuses on pushing α: that is where the prize is.&lt;/p>
&lt;h2 id="the-five-modern-families-may-2026">The five modern families (May 2026)&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Family&lt;/th>
&lt;th>Year&lt;/th>
&lt;th>Core idea&lt;/th>
&lt;th>Draft size&lt;/th>
&lt;th>Typical α / τ&lt;/th>
&lt;th>Speedup&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Vanilla SD&lt;/strong>&lt;/td>
&lt;td>2023&lt;/td>
&lt;td>Same-family target + draft pair, rejection sampling&lt;/td>
&lt;td>1/10 – 1/100 of target&lt;/td>
&lt;td>0.5 – 0.8&lt;/td>
&lt;td>2-3×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Medusa&lt;/strong>&lt;/td>
&lt;td>2024&lt;/td>
&lt;td>N extra heads in parallel predict t+1, t+2, &amp;hellip;; tree attention verifies several candidates&lt;/td>
&lt;td>no draft, +1-2 % params&lt;/td>
&lt;td>top-5 &amp;gt; 0.8&lt;/td>
&lt;td>2.18-2.83×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>EAGLE-1/2/3&lt;/strong>&lt;/td>
&lt;td>2024-25&lt;/td>
&lt;td>Autoregressive draft at the level of &lt;strong>features (hidden states)&lt;/strong>, not tokens. Reuses the target&amp;rsquo;s embedding&lt;/td>
&lt;td>1 transformer block (~0.5-2 % params)&lt;/td>
&lt;td>EAGLE-3: α &amp;gt; 0.8, τ up to 7.5&lt;/td>
&lt;td>up to 6.5× peak&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>MTP&lt;/strong> (DeepSeek-V3)&lt;/td>
&lt;td>2024&lt;/td>
&lt;td>Multi-token heads trained from scratch as part of the model; at inference they act as a &amp;ldquo;free&amp;rdquo; draft&lt;/td>
&lt;td>14B params in V3 671B&lt;/td>
&lt;td>α &amp;gt; 0.8 (MTP1)&lt;/td>
&lt;td>1.5-1.8×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>P-EAGLE&lt;/strong>&lt;/td>
&lt;td>2026&lt;/td>
&lt;td>EAGLE but producing the γ drafts in &lt;strong>a single forward pass&lt;/strong> (parallel, not autoregressive)&lt;/td>
&lt;td>same as EAGLE&lt;/td>
&lt;td>+30 % over EAGLE-3&lt;/td>
&lt;td>4-5× vs AR&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Three operational observations:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>EAGLE dominates in production&lt;/strong> because its overhead is minimal (one transformer block, ~1 % of the target in parameters) and its α is high. The draft does not need its own &amp;ldquo;complete&amp;rdquo; KV cache because it shares features with the target.&lt;/li>
&lt;li>&lt;strong>Medusa mattered historically&lt;/strong>, it showed that speculative was possible without a separate draft, but EAGLE beat it on every published benchmark through 2024-2025.&lt;/li>
&lt;li>&lt;strong>MTP is special&lt;/strong>: it is not something you add to an existing model. It is something the model trained natively. If you buy DeepSeek-V3, MTP comes free and gives around 1.8× without touching anything. If you buy Llama 3, there is no MTP to be had; use EAGLE-3.&lt;/li>
&lt;/ol>
&lt;p>There are also two related techniques that deserve a brief mention, both without a draft model: &lt;strong>Lookahead decoding&lt;/strong> (Fu et al. 2024), which formulates decoding as Jacobi iteration and extracts n-grams from the trajectory; and &lt;strong>REST&lt;/strong> (He et al. 2024), which keeps an n-gram datastore and proposes drafts by longest-prefix match against the last generated tokens. Both give 1.5-2× with no extra VRAM. Useful when you have no trained draft model and do not want to maintain one.&lt;/p>
&lt;h2 id="real-implementations-in-may-2026">Real implementations in May 2026&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>vLLM v0.16+&lt;/strong>: unified support for EAGLE-1/2/3, P-EAGLE, Medusa, native MTP (DeepSeek-V3 and variants), n-gram/suffix decoding without a draft, an arbitrary draft model and MLP speculators. The canonical flag is &lt;code>--speculative-config '{&amp;quot;method&amp;quot;:&amp;quot;eagle3&amp;quot;, &amp;quot;model&amp;quot;:&amp;quot;...&amp;quot;, &amp;quot;num_speculative_tokens&amp;quot;: 5}'&lt;/code>.&lt;/li>
&lt;li>&lt;strong>SGLang&lt;/strong>: native EAGLE-3 support with &lt;code>--speculative-algorithm EAGLE3&lt;/code>. For DeepSeek-V3 it uses MTP through an EAGLE adapter. It has its own draft training framework (SpecForge).&lt;/li>
&lt;li>&lt;strong>TensorRT-LLM&lt;/strong>: Medusa, EAGLE (a simplified variant without a tree), ReDrafter, Lookahead. They report around 2.2× with EAGLE.&lt;/li>
&lt;li>&lt;strong>llama.cpp&lt;/strong>: basic speculative only with a draft model (&lt;code>--model-draft&lt;/code>). No native EAGLE/Medusa/MTP as far as I have verified. Typical speedup 1.5-2.5× single-user.&lt;/li>
&lt;/ul>
&lt;h2 id="when-speculative-does-not-help">When speculative does NOT help&lt;/h2>
&lt;p>The technique has three important blind spots:&lt;/p>
&lt;p>&lt;strong>Large batch.&lt;/strong> The GPU moves from memory-bound (decode at low concurrency) to compute-bound (decode at high concurrency). In the compute-bound regime the &amp;ldquo;almost free&amp;rdquo; target forward passes stop being free: γ tokens start costing γ times more instead of nearly 1. The typical crossover is at &lt;strong>batch 16-32 for dense models&lt;/strong>; for MoE with few active parameters per token the crossover happens later. At load peaks, speculative can worsen aggregate throughput per GPU.&lt;/p>
&lt;p>&lt;strong>Prefill / TTFT.&lt;/strong> Speculative decoding does not touch prefill. The phase that processes the whole prompt stays identical. TTFT does not improve and can get marginally worse because of the draft setup. If the SLA is TTFT-bound (assistants with short outputs, search, RAG with brief answers), this is not the right tool.&lt;/p>
&lt;p>&lt;strong>Short outputs.&lt;/strong> If the model generates 10-20 tokens and the answer is over, the fixed setup overhead is not amortised. Speculative shines with long outputs (300+ tokens): generative assistants, extended code completion, drafting.&lt;/p>
&lt;h2 id="operational-pitfalls">Operational pitfalls&lt;/h2>
&lt;p>&lt;strong>Extra VRAM for the draft.&lt;/strong> In vanilla SD, loading a complete draft plus its KV cache is expensive. Llama 3 8B in BF16 as the draft for a 70B is about 16 GB of weights plus KV cache. On an H100 80 GB with the target already nearly filling it, that can force you to shrink the target&amp;rsquo;s KV cache and lower maximum concurrency. EAGLE solves this: the draft is one transformer block (~1 GB for a 70B) and reuses the target&amp;rsquo;s features.&lt;/p>
&lt;p>&lt;strong>Draft quantisation.&lt;/strong> Quantising the draft to INT4 with GPTQ degrades α substantially (errors in the logits accumulate in the comparison against &lt;code>p&lt;/code>). AWQ holds up better but also lowers α. &lt;strong>Common practice in 2026: target in FP8 or INT4, draft in FP16/BF16&lt;/strong>. The draft is small enough that the VRAM saved by quantising it does not compensate for the drop in α and therefore in speedup. The detail of each format is in &lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation&lt;/a>.&lt;/p>
&lt;p>&lt;strong>Interaction with continuous batching.&lt;/strong> It does not break it, but it creates &lt;em>nested raggedness&lt;/em>: each request in the batch can accept a different number of tokens on each iteration. vLLM&amp;rsquo;s PagedAttention absorbs it, but the scheduler loses efficiency. At low QPS (conversational assistant, low simultaneous concurrency) the combination is excellent. At high QPS there is real tension, and work such as &lt;em>Goodput-optimized speculative decoding&lt;/em> (Liu et al., 2024) optimises γ dynamically according to the state of the batch.&lt;/p>
&lt;p>&lt;strong>Sampling temperature.&lt;/strong> α falls with high temperatures. At T = 1.0 with creative outputs (free-form writing), α can drop 10-15 points relative to T = 0 with the same model-draft pair. The speedup scales accordingly.&lt;/p>
&lt;h2 id="implications-on-on-premise-hardware">Implications on on-premise hardware&lt;/h2>
&lt;h3 id="on-an-rtx-4090-24-gb-ada-lovelace">On an RTX 4090 (24 GB, Ada Lovelace)&lt;/h3>
&lt;p>The classic use is &lt;strong>vanilla SD with two quantised models&lt;/strong>: for instance, Llama 3 70B AWQ-INT4 as target (~35 GB → needs TP=2 across two 4090s) and Llama 3 8B AWQ-INT4 as draft. In practice, the most realistic single-card case is &lt;strong>Llama 3 8B target + a 1B model as draft&lt;/strong> or &lt;strong>Qwen 3 14B target + Qwen 3 0.5B as draft&lt;/strong>: they fit comfortably and give 1.8-2.5× on conversational tasks. For EAGLE on consumer cards, the official drafts for popular families (Llama 3, Qwen 3) are published on Hugging Face and take up 0.5-2 GB extra.&lt;/p>
&lt;h3 id="on-a-generic-4h100-sxm-cluster-320-gb-nvlink-native-fp8">On a generic 4×H100 SXM cluster (320 GB, NVLink, native FP8)&lt;/h3>
&lt;p>Here EAGLE-3 shines:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Llama 3 70B FP8 + EAGLE-3 draft in FP16&lt;/strong>: the draft takes about 0.7 GB; the target about 70 GB with TP=2 (35 GB per GPU). The speedup observed in reproducible benchmarks is between 2.5× and 4× at batch 1-4, falling to almost break-even at batch 32.&lt;/li>
&lt;li>&lt;strong>DeepSeek-V3 671B FP8 + native MTP&lt;/strong>: the model ships with MTP trained in; there is nothing to add. The speedup is 1.5-1.8× with zero extra VRAM. It is the most efficient option operationally: zero additional pieces.&lt;/li>
&lt;li>&lt;strong>Combining with disaggregated serving&lt;/strong>: as &lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving&lt;/a> explains, prefill and decode can live in separate pods. Speculative applies only in the decode pods, which fits the separation perfectly (prefill is compute-bound and would not benefit).&lt;/li>
&lt;/ul>
&lt;p>The rule of thumb on an H100 cluster in May 2026: &lt;strong>if the model is DeepSeek-V3 / V4 → native MTP, nothing else; if it is Llama 3 / Qwen 3 → EAGLE-3 with the official draft; if it is exotic → vanilla SD with a draft from the same family&lt;/strong>.&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Training custom EAGLE drafts&lt;/strong> with SpecForge: how to collect target trajectories and train the draft on-policy.&lt;/li>
&lt;li>&lt;strong>Speculative Prefill&lt;/strong> (arXiv:2502.02789): a variant for accelerating TTFT, a different mechanism from the decode one described here.&lt;/li>
&lt;li>&lt;strong>Tree attention in detail&lt;/strong>: how Medusa and EAGLE-2 verify several candidates at once with specific attention masks.&lt;/li>
&lt;li>&lt;strong>MoE + speculative&lt;/strong>: the combination has non-trivial interactions with the expert router. Low activated params keep the memory-bound regime in place even at high batch, which changes the rules.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory that holds up LLM inference&lt;/a> — speculative would not exist without the memory-bound nature of decode, which is a direct consequence of the KV cache; that post gives the framework.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention deep dive&lt;/a> — vLLM&amp;rsquo;s scheduler has to manage the nested raggedness of the γ tokens accepted per request; PagedAttention is what makes that possible without reserving fixed blocks per session.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantisation for LLM inference&lt;/a> — a lever orthogonal to and multiplicative with speculative; that post explains why the draft is usually left in BF16 even when the target is in FP8/INT4.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving: prefill and decode in specialised pods&lt;/a> — speculative applies only in decode; disaggregation makes that specialisation easy without touching prefill.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/">MoE inference: the call centre with 256 specialists&lt;/a> — the persistent memory-bound regime of MoE means speculative gains more on MoE than on dense at medium batch. MTP in DeepSeek-V3 is speculative decoding native to the model (no external draft) with acceptance around 85-90 % on the second token.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> — the scheduler where speculative lives. Speculative breaks the symmetry of the batch (each request accepts between 1 and γ+1 tokens per iteration); at high QPS it can reduce goodput if the draft consumes slots from the decode pool.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the master map where Deploy is stage 4.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/mixed-nvidia-intel-environments-llm-inference/">Mixed NVIDIA + Intel environments&lt;/a> — the &amp;ldquo;drafter near edge on an Intel NUC + target on a central H100&amp;rdquo; pattern as the canonical case of speculative decoding deployed heterogeneously.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">Optimising decode in vLLM&lt;/a> — the concrete vLLM parameters for turning on speculative decoding in production (&lt;code>--speculative-model&lt;/code>, &lt;code>--num-speculative-tokens&lt;/code>) with reference configs for RTX 4090 and L40.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/knowledge-distillation-teaching-small-model-think-like-big-one/">Knowledge distillation&lt;/a> — the best drafters are not small versions of the base model: they are students distilled specifically to predict the verifier&amp;rsquo;s distribution; distillation explains why EAGLE beats a plain generic 0.5B.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-pruning-cutting-without-amputating/">Pruning LLM models&lt;/a> — an alternative to the distilled drafter: a pruned draft model (layer dropping from the base) as a cheap approximation of the verifier; it works worse than EAGLE but needs no extra training.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/self-speculative-decoding-model-gets-ahead-of-itself/">Self-speculative decoding: the model that gets ahead of itself&lt;/a> — the variant without a separate draft: the model itself run in early-exit acts as the draft and is verified with the full forward pass, zero extra VRAM. It is the form of speculative that fits small models and on-device deployment.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Leviathan, Y., Kalman, M., Matias, Y. &lt;em>Fast Inference from Transformers via Speculative Decoding&lt;/em>. ICML 2023. &lt;a href="https://arxiv.org/abs/2211.17192">https://arxiv.org/abs/2211.17192&lt;/a>&lt;/li>
&lt;li>Chen, C., Borgeaud, S., Irving, G., Lespiau, J.B., Sifre, L., Jumper, J. &lt;em>Accelerating Large Language Model Decoding with Speculative Sampling&lt;/em>. DeepMind 2023. &lt;a href="https://arxiv.org/abs/2302.01318">https://arxiv.org/abs/2302.01318&lt;/a>&lt;/li>
&lt;li>Cai, T., Li, Y., Geng, Z., Peng, H., Lee, J.D., Chen, D., Dao, T. &lt;em>Medusa: Simple LLM Inference Acceleration with Multiple Decoding Heads&lt;/em>. ICML 2024. &lt;a href="https://arxiv.org/abs/2401.10774">https://arxiv.org/abs/2401.10774&lt;/a>&lt;/li>
&lt;li>Li, Y., Wei, F., Zhang, C., Zhang, H. &lt;em>EAGLE: Speculative Sampling Requires Rethinking Feature Uncertainty&lt;/em>. ICML 2024. &lt;a href="https://arxiv.org/abs/2401.15077">https://arxiv.org/abs/2401.15077&lt;/a>&lt;/li>
&lt;li>Li, Y., Wei, F., Zhang, C., Zhang, H. &lt;em>EAGLE-2: Faster Inference of Language Models with Dynamic Draft Trees&lt;/em>. EMNLP 2024. &lt;a href="https://arxiv.org/abs/2406.16858">https://arxiv.org/abs/2406.16858&lt;/a>&lt;/li>
&lt;li>Li, Y., Wei, F., Zhang, C., Zhang, H. &lt;em>EAGLE-3: Scaling up Inference Acceleration of Large Language Models via Training-Time Test&lt;/em>. NeurIPS 2025. &lt;a href="https://arxiv.org/abs/2503.01840">https://arxiv.org/abs/2503.01840&lt;/a>&lt;/li>
&lt;li>DeepSeek-AI. &lt;em>DeepSeek-V3 Technical Report&lt;/em> — Multi-Token Prediction. &lt;a href="https://arxiv.org/abs/2412.19437">https://arxiv.org/abs/2412.19437&lt;/a>&lt;/li>
&lt;li>Fu, Y., Bailis, P., Stoica, I., Zhang, H. &lt;em>Break the Sequential Dependency of LLM Inference Using Lookahead Decoding&lt;/em>. ICML 2024. &lt;a href="https://arxiv.org/abs/2402.02057">https://arxiv.org/abs/2402.02057&lt;/a>&lt;/li>
&lt;li>He, Z., Zhong, Z., Cai, T., Lee, J.D., He, D. &lt;em>REST: Retrieval-Based Speculative Decoding&lt;/em>. NAACL 2024. &lt;a href="https://arxiv.org/abs/2311.08252">https://arxiv.org/abs/2311.08252&lt;/a>&lt;/li>
&lt;li>vLLM speculative decoding docs: &lt;a href="https://docs.vllm.ai/en/latest/features/speculative_decoding/">https://docs.vllm.ai/en/latest/features/speculative_decoding/&lt;/a>&lt;/li>
&lt;li>SGLang speculative decoding docs: &lt;a href="https://docs.sglang.ai/advanced_features/speculative_decoding.html">https://docs.sglang.ai/advanced_features/speculative_decoding.html&lt;/a>&lt;/li>
&lt;li>TensorRT-LLM speculative sampling: &lt;a href="https://nvidia.github.io/TensorRT-LLM/advanced/speculative-decoding.html">https://nvidia.github.io/TensorRT-LLM/advanced/speculative-decoding.html&lt;/a>&lt;/li>
&lt;li>vLLM blog &lt;em>Speculative Decoding in vLLM&lt;/em> (oct 2024): &lt;a href="https://blog.vllm.ai/2024/10/17/spec-decode.html">https://blog.vllm.ai/2024/10/17/spec-decode.html&lt;/a>&lt;/li>
&lt;li>vLLM blog &lt;em>P-EAGLE: Parallel Speculative Decoding in vLLM&lt;/em> (mar 2026): &lt;a href="https://vllm.ai/blog/2026-03-13-p-eagle">https://vllm.ai/blog/2026-03-13-p-eagle&lt;/a>&lt;/li>
&lt;li>Official EAGLE repo: &lt;a href="https://github.com/SafeAILab/EAGLE">https://github.com/SafeAILab/EAGLE&lt;/a>&lt;/li>
&lt;li>Official Medusa repo: &lt;a href="https://github.com/FasterDecoding/Medusa">https://github.com/FasterDecoding/Medusa&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Quantization for LLM inference: FP8, INT4 (GPTQ, AWQ) and GGUF — the model's accounting zoom</title><link>https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/</link><pubDate>Wed, 27 May 2026 11:30:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/</guid><description>&lt;blockquote>
&lt;p>This post complements &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory of LLM inference&lt;/a>, where cache quantisation is mentioned as a savings lever; here we go into the whole method, model weights and cache, and into why each format does what it does.&lt;/p>
&lt;/blockquote>
&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Quantising is a change of representation: instead of storing every model weight as a &lt;code>float16&lt;/code> or &lt;code>bfloat16&lt;/code> (2 bytes), it is stored as a short integer (1 byte INT8, half a byte INT4) with a scale factor that reconstructs a value close to the original. The price is loss of numerical precision; the reward is 2-4× less VRAM, 2-3× more throughput and, on Hopper and Blackwell, a radically lower compute cost because FP8/FP4 units execute in fewer cycles than BF16 ones. The four dominant formats in May 2026 are &lt;strong>FP8&lt;/strong> (E4M3/E5M2, datacenter), &lt;strong>INT4 GPTQ&lt;/strong> (Hessian-aware reconstruction), &lt;strong>INT4 AWQ&lt;/strong> (activation-aware) and &lt;strong>GGUF&lt;/strong> (the llama.cpp family). Each has a sweet spot: FP8 when the datacenter is Hopper/Blackwell and quality matters; GPTQ and AWQ when serving runs on Ampere/Ada and 4 bits are mandatory; GGUF when the target is edge or a consumer GPU. This post explains the minimum maths, the algorithms behind each format, what each one loses measured in perplexity and MMLU, and how it all applies on a 4090 versus an H100 cluster.&lt;/p>
&lt;h2 id="you-are-here-deploy">You are here: DEPLOY&lt;/h2>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 90" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="you are here: Deploy">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#7ad88f;stroke-width:3}.idle{fill:#f4f4f4}.lbl{font:600 12px sans-serif;fill:#222}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#qm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#qm)}&lt;/style>
&lt;defs>&lt;marker id="qm" 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="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="20" text-anchor="middle" class="lbl">You are here: DEPLOY · weight and KV cache quantization&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box idle"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box active"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box idle"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-jpeg-with-an-edge-detector">The analogy: JPEG with an edge detector&lt;/h2>
&lt;p>A JPEG compresses an image by reducing the precision with which pixels are stored, but it does not reduce it uniformly. Where there is flat sky, thousands of very similar pixels, it throws away detail without anyone noticing. Where there is a sharp edge, the outline of a face, it keeps the fidelity. The trick is to detect which parts are sensitive before compressing.&lt;/p>
&lt;p>Quantization of an LLM works the same way. You do not take every model weight and say &amp;ldquo;all of them in 4 bits&amp;rdquo;. Some weights are very important, projection weights that move the output a lot when they change, and others are less so. Modern techniques (GPTQ, AWQ) are basically &lt;strong>edge detectors&lt;/strong>: they identify which weights can be quantised aggressively and which need more bits or special treatment, and they apply the quantisation with that information.&lt;/p>
&lt;p>The analogy holds up in three details:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Calibration with a small dataset&lt;/strong> = the phase in which the JPEG encoder analyses the image before choosing blocks.&lt;/li>
&lt;li>&lt;strong>A block of 128 weights with a shared scale&lt;/strong> = the JPEG 8×8 block with its DCT.&lt;/li>
&lt;li>&lt;strong>Outliers preserved with more precision&lt;/strong> = the high frequencies of an edge are preserved more than the flat areas.&lt;/li>
&lt;/ul>
&lt;p>From there on, what follows is the maths and the operational detail.&lt;/p>
&lt;h2 id="the-minimum-maths-scale-and-zero-point">The minimum maths: scale and zero-point&lt;/h2>
&lt;p>Quantising a weight vector &lt;code>w ∈ ℝ^n&lt;/code> (in BF16) to INT4 means finding two things:&lt;/p>
&lt;ul>
&lt;li>A &lt;strong>scale&lt;/strong> &lt;code>s ∈ ℝ&lt;/code> (in BF16 or FP16).&lt;/li>
&lt;li>For each weight, an integer &lt;strong>code&lt;/strong> &lt;code>q ∈ {0, 1, ..., 15}&lt;/code> that fits in 4 bits.&lt;/li>
&lt;/ul>
&lt;p>And an approximate reconstruction formula:&lt;/p>
$$\hat{w}_i \approx s \cdot (q_i - z)$$
&lt;p>where &lt;code>z&lt;/code> is the &lt;strong>zero-point&lt;/strong> (the integer that defines which code represents the original zero). The zero-point exists in asymmetric INT4/INT8 so that half the range is not wasted on negative values when the weight distribution is not symmetric.&lt;/p>
&lt;p>The choice of &lt;code>s&lt;/code> and &lt;code>z&lt;/code> for a block of weights &lt;code>w_block&lt;/code>:&lt;/p>
$$s = \frac{\max(w_\text{block}) - \min(w_\text{block})}{2^{b} - 1}, \quad z = -\frac{\min(w_\text{block})}{s},$$
&lt;p>with &lt;code>b&lt;/code> = number of bits (4 in INT4). Encoding:&lt;/p>
$$q_i = \text{clip}\!\left(\text{round}\!\left(\frac{w_i}{s} + z\right),\, 0,\, 2^b - 1\right).$$
&lt;p>And decoding at inference time:&lt;/p>
$$\hat{w}_i = s \cdot (q_i - z).$$
&lt;h3 id="numerical-example">Numerical example&lt;/h3>
&lt;p>Take 8 real weights from a linear layer: &lt;code>w = [0.31, -0.12, 0.78, -0.05, 1.42, -0.91, 0.23, 0.66]&lt;/code>. We want to quantise to INT4 (16 levels).&lt;/p>
&lt;p>&lt;code>max = 1.42&lt;/code>, &lt;code>min = -0.91&lt;/code>. Range = 2.33.&lt;/p>
$$s = \frac{2.33}{15} \approx 0.1553, \quad z = -\frac{-0.91}{0.1553} \approx 5.86 \to 6.$$
&lt;p>Encoding of each weight:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;code>w_i&lt;/code>&lt;/th>
&lt;th>&lt;code>w_i/s + z&lt;/code>&lt;/th>
&lt;th>&lt;code>round&lt;/code>&lt;/th>
&lt;th>&lt;code>q_i&lt;/code>&lt;/th>
&lt;th>&lt;code>ŵ_i = s·(q-z)&lt;/code>&lt;/th>
&lt;th>error&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>0.31&lt;/td>
&lt;td>8.00&lt;/td>
&lt;td>8&lt;/td>
&lt;td>8&lt;/td>
&lt;td>0.311&lt;/td>
&lt;td>+0.001&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>-0.12&lt;/td>
&lt;td>5.23&lt;/td>
&lt;td>5&lt;/td>
&lt;td>5&lt;/td>
&lt;td>-0.155&lt;/td>
&lt;td>-0.035&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>0.78&lt;/td>
&lt;td>11.02&lt;/td>
&lt;td>11&lt;/td>
&lt;td>11&lt;/td>
&lt;td>0.776&lt;/td>
&lt;td>-0.004&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>-0.05&lt;/td>
&lt;td>5.68&lt;/td>
&lt;td>6&lt;/td>
&lt;td>6&lt;/td>
&lt;td>0.000&lt;/td>
&lt;td>+0.050&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>1.42&lt;/td>
&lt;td>15.14&lt;/td>
&lt;td>15&lt;/td>
&lt;td>15&lt;/td>
&lt;td>1.398&lt;/td>
&lt;td>-0.022&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>-0.91&lt;/td>
&lt;td>-0.06&lt;/td>
&lt;td>0&lt;/td>
&lt;td>0&lt;/td>
&lt;td>-0.932&lt;/td>
&lt;td>-0.022&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>0.23&lt;/td>
&lt;td>7.48&lt;/td>
&lt;td>7&lt;/td>
&lt;td>7&lt;/td>
&lt;td>0.155&lt;/td>
&lt;td>-0.075&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>0.66&lt;/td>
&lt;td>10.25&lt;/td>
&lt;td>10&lt;/td>
&lt;td>10&lt;/td>
&lt;td>0.621&lt;/td>
&lt;td>-0.039&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Mean squared error: &lt;code>MSE ≈ 0.0015&lt;/code>. For a single layer with millions of weights, the aggregate effect is what calibration tries to minimise.&lt;/p>
&lt;p>Storage: instead of 8 values × 2 bytes = 16 bytes (BF16), we have 8 × 4 bits = 4 bytes + 2 bytes (BF16 scale) + 0.5 byte (INT4 zero-point) ≈ 6.5 bytes. 2.5× less, yet the useful data is still recoverable with a small error.&lt;/p>
&lt;h2 id="ptq-vs-qat-when-quantisation-happens">PTQ vs QAT: when quantisation happens&lt;/h2>
&lt;p>Two different operational regimes.&lt;/p>
&lt;p>&lt;strong>Post-Training Quantization (PTQ)&lt;/strong> is applied after training, on a model already trained in BF16/FP16. It reads a small dataset (typically 128-512 examples) to calibrate the scales, runs the quantisation algorithm (GPTQ, AWQ, and so on) and produces the quantised weights. &lt;strong>Cost&lt;/strong>: minutes to a few hours. &lt;strong>Typical loss&lt;/strong>: 0.05-0.3 PPL in perplexity (~0.5-2 % in MMLU) for INT4 with modern methods.&lt;/p>
&lt;p>&lt;strong>Quantization-Aware Training (QAT)&lt;/strong> introduces the quantisation operations inside the training loop. During training the model &amp;ldquo;sees&amp;rdquo; that its weights get quantised and learns to be robust to it. &lt;strong>Cost&lt;/strong>: retraining the model (expensive), but little is needed, a short fine-tune on top of the already quantised PTQ model. &lt;strong>Typical loss&lt;/strong>: ~0 (quantisation becomes indistinguishable from the original model).&lt;/p>
&lt;p>&lt;strong>When to use which:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>PTQ&lt;/strong> = default. 90 % of production cases. The model arrives in BF16, you quantise it in 1-2 hours on one GPU, you deploy it.&lt;/li>
&lt;li>&lt;strong>QAT&lt;/strong> = when PTQ loses too much and the difference matters (typical case: INT2/INT3, or sensitive models such as specific reasoning ones).&lt;/li>
&lt;/ul>
&lt;h2 id="the-dominant-formats-in-2026">The dominant formats in 2026&lt;/h2>
&lt;div class="diagram" style="max-width:760px;margin:1.5rem auto;">
&lt;svg viewBox="0 0 760 360" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Dominant quantization formats">
&lt;style>
.bx{fill:#f8f8f8;stroke:#444;stroke-width:1.4;rx:8}
.b1{fill:#ffe6d6}
.b2{fill:#d6eaff}
.b3{fill:#d9f5d6}
.b4{fill:#fff5b0}
.t{font:700 13px sans-serif;fill:#222}
.s{font:400 11px sans-serif;fill:#555}
.h{font:700 14px sans-serif;fill:#222}
&lt;/style>
&lt;text x="380" y="22" text-anchor="middle" class="h">Map of weight quantization formats (May 2026)&lt;/text>
&lt;rect x="20" y="40" width="170" height="300" class="bx b1"/>
&lt;text x="105" y="62" text-anchor="middle" class="t">FP8 (E4M3/E5M2)&lt;/text>
&lt;text x="30" y="84" class="s">Datacenter / Hopper-Blackwell&lt;/text>
&lt;text x="30" y="102" class="s">— H100, H200, B200&lt;/text>
&lt;text x="30" y="120" class="s">— native in vLLM&lt;/text>
&lt;text x="30" y="138" class="s">— FP8 tensor core hardware&lt;/text>
&lt;text x="30" y="160" class="t">Loss:&lt;/text>
&lt;text x="30" y="176" class="s">— PPL: +0.02-0.05&lt;/text>
&lt;text x="30" y="192" class="s">— MMLU: -0.3-0.8 pp&lt;/text>
&lt;text x="30" y="214" class="t">Sweet spot:&lt;/text>
&lt;text x="30" y="230" class="s">Model serving in a modern&lt;/text>
&lt;text x="30" y="246" class="s">datacenter. Quality almost identical&lt;/text>
&lt;text x="30" y="262" class="s">to BF16, ~2× less VRAM.&lt;/text>
&lt;text x="30" y="286" class="t">vLLM command:&lt;/text>
&lt;text x="30" y="304" class="s" font-family="monospace">--quantization=fp8&lt;/text>
&lt;text x="30" y="320" class="s" font-family="monospace">--kv-cache-dtype=fp8&lt;/text>
&lt;rect x="200" y="40" width="170" height="300" class="bx b2"/>
&lt;text x="285" y="62" text-anchor="middle" class="t">INT4 GPTQ&lt;/text>
&lt;text x="210" y="84" class="s">Hessian-aware reconstruction&lt;/text>
&lt;text x="210" y="102" class="s">— Ampere/Ada/Hopper&lt;/text>
&lt;text x="210" y="120" class="s">— vLLM, TensorRT-LLM, ExLlama&lt;/text>
&lt;text x="210" y="138" class="s">— calibration: 128 samples&lt;/text>
&lt;text x="210" y="160" class="t">Loss:&lt;/text>
&lt;text x="210" y="176" class="s">— PPL: +0.15-0.30&lt;/text>
&lt;text x="210" y="192" class="s">— MMLU: -1.5-3 pp&lt;/text>
&lt;text x="210" y="214" class="t">Sweet spot:&lt;/text>
&lt;text x="210" y="230" class="s">GPU serving without FP8 (Ampere/Ada),&lt;/text>
&lt;text x="210" y="246" class="s">mid-size models (8-70B).&lt;/text>
&lt;text x="210" y="262" class="s">~4× less VRAM.&lt;/text>
&lt;text x="210" y="286" class="t">vLLM command:&lt;/text>
&lt;text x="210" y="304" class="s" font-family="monospace">--quantization=gptq&lt;/text>
&lt;text x="210" y="320" class="s" font-family="monospace">(model *-GPTQ-Int4)&lt;/text>
&lt;rect x="380" y="40" width="170" height="300" class="bx b3"/>
&lt;text x="465" y="62" text-anchor="middle" class="t">INT4 AWQ&lt;/text>
&lt;text x="390" y="84" class="s">Activation-aware salient weights&lt;/text>
&lt;text x="390" y="102" class="s">— Ampere/Ada/Hopper&lt;/text>
&lt;text x="390" y="120" class="s">— vLLM, TensorRT-LLM&lt;/text>
&lt;text x="390" y="138" class="s">— keeps 1 % outlier channels&lt;/text>
&lt;text x="390" y="160" class="t">Loss:&lt;/text>
&lt;text x="390" y="176" class="s">— PPL: +0.10-0.25&lt;/text>
&lt;text x="390" y="192" class="s">— MMLU: -1-2 pp&lt;/text>
&lt;text x="390" y="214" class="t">Sweet spot:&lt;/text>
&lt;text x="390" y="230" class="s">Preferred alternative to GPTQ&lt;/text>
&lt;text x="390" y="246" class="s">in 2026. Better quality&lt;/text>
&lt;text x="390" y="262" class="s">preservation at similar cost.&lt;/text>
&lt;text x="390" y="286" class="t">vLLM command:&lt;/text>
&lt;text x="390" y="304" class="s" font-family="monospace">--quantization=awq_marlin&lt;/text>
&lt;text x="390" y="320" class="s" font-family="monospace">(model *-AWQ-INT4)&lt;/text>
&lt;rect x="560" y="40" width="180" height="300" class="bx b4"/>
&lt;text x="650" y="62" text-anchor="middle" class="t">GGUF (llama.cpp)&lt;/text>
&lt;text x="570" y="84" class="s">Edge / consumer / CPU-friendly&lt;/text>
&lt;text x="570" y="102" class="s">— CPU, Apple Silicon,&lt;/text>
&lt;text x="570" y="120" class="s"> consumer GPU (4090, AMD)&lt;/text>
&lt;text x="570" y="138" class="s">— sub-formats: Q4_K_M, Q5_K_M…&lt;/text>
&lt;text x="570" y="160" class="t">Loss (Q4_K_M):&lt;/text>
&lt;text x="570" y="176" class="s">— PPL: +0.20-0.40&lt;/text>
&lt;text x="570" y="192" class="s">— MMLU: -2-4 pp&lt;/text>
&lt;text x="570" y="214" class="t">Sweet spot:&lt;/text>
&lt;text x="570" y="230" class="s">Any non-CUDA deploy or one&lt;/text>
&lt;text x="570" y="246" class="s">with limited VRAM. Ollama, LMStudio.&lt;/text>
&lt;text x="570" y="262" class="s">~4× less VRAM/RAM.&lt;/text>
&lt;text x="570" y="286" class="t">Command:&lt;/text>
&lt;text x="570" y="304" class="s" font-family="monospace">ollama run llama3:8b-q4_K_M&lt;/text>
&lt;text x="570" y="320" class="s" font-family="monospace">llama.cpp --model *.gguf&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="fp8-the-hopperblackwell-datacenter-format">FP8: the Hopper/Blackwell datacenter format&lt;/h2>
&lt;p>FP8 is not &amp;ldquo;INT8 + sign&amp;rdquo;: these are two 8-bit floating point formats.&lt;/p>
&lt;ul>
&lt;li>&lt;strong>E4M3&lt;/strong> (4 exponent bits, 3 mantissa bits): range ±448, reasonable precision around ±1.0. Typically used for &lt;strong>weights&lt;/strong> and for &lt;strong>activations in most layers&lt;/strong>.&lt;/li>
&lt;li>&lt;strong>E5M2&lt;/strong> (5 exponent, 2 mantissa): range ±57,344, lower precision. Used for &lt;strong>gradients&lt;/strong> during training or for activations with large outliers at inference time.&lt;/li>
&lt;/ul>
&lt;p>Why FP8 left INT8 behind in the datacenter: the tensor cores on H100/H200/B200 execute FP8 operations natively with 2× the throughput of BF16 and 4× that of FP16. And because FP8 preserves logarithmic dynamics (just like FP16), matrices with values spread out in magnitude, typical of transformers, quantise with less error than with INT8.&lt;/p>
&lt;p>The loss measured in production is minimal: for a Llama 3.1 70B FP8 versus BF16, perplexity rises by ~0.03 and MMLU drops by ~0.5 points. It is the default option in any modern deployment on H100/B200.&lt;/p>
&lt;h3 id="microscaling-nvfp4-and-mxfp4">Microscaling: NVFP4 and MXFP4&lt;/h3>
&lt;p>Blackwell (B100/B200, 2025) introduces &lt;strong>NVFP4&lt;/strong> and &lt;strong>MXFP4&lt;/strong>, 4-bit formats with small-block scaling (typically 16 or 32 elements per scale, against 128 in INT4 GPTQ). The scale is FP8 instead of FP16/BF16, which reduces storage further.&lt;/p>
&lt;p>The result: 4 bits with quality close to FP8. In 2026 NVFP4 is becoming the default option for very large models (200B+) on Blackwell clusters. For 4×H100 SXM, Hopper rather than Blackwell, FP8 remains the sweet spot.&lt;/p>
&lt;h2 id="int4-gptq-vs-awq">INT4: GPTQ vs AWQ&lt;/h2>
&lt;p>The two algorithms that dominate 4-bit quantisation solve the same problem with different strategies.&lt;/p>
&lt;h3 id="gptq-frantar-et-al-2022">GPTQ (Frantar et al. 2022)&lt;/h3>
&lt;p>The idea: layer-by-layer quantisation, explicitly minimising the error at the output of each linear layer using information from the Hessian matrix (the second derivative of the loss). For each layer:&lt;/p>
&lt;ol>
&lt;li>Estimate the Hessian &lt;code>H = X^T X&lt;/code> where &lt;code>X&lt;/code> are the calibration activations.&lt;/li>
&lt;li>Quantise one weight at a time in order (typically the most sensitive one first).&lt;/li>
&lt;li>&lt;strong>Update the remaining weights&lt;/strong> to compensate for the error of the weight just quantised.&lt;/li>
&lt;/ol>
&lt;p>Step 3 is what makes GPTQ better than naive round-to-nearest: weights compensate for their neighbours&amp;rsquo; errors. The official implementation quantises a Llama 3 70B in ~3-4 hours on one H100 with 128 calibration samples.&lt;/p>
&lt;h3 id="awq-lin-et-al-2023">AWQ (Lin et al. 2023)&lt;/h3>
&lt;p>The AWQ observation: within a layer, not all channels (weight columns) are equally important. Roughly &lt;strong>1 % of the channels&lt;/strong> account for most of the impact on the activations. AWQ identifies them by measuring the mean magnitude of the activations that multiply them, and scales them before quantising so they are better preserved.&lt;/p>
&lt;p>Concretely: if a channel &lt;code>c&lt;/code> has large mean activations, AWQ multiplies that channel&amp;rsquo;s weights by a factor &lt;code>s_c&lt;/code> before quantising, and when quantising the input to the next layer it divides by &lt;code>s_c&lt;/code>. The maths cancels out, but the important weights end up with more resolution inside the INT4 range. No retraining, no Hessian, faster than GPTQ (~1-2 h for 70B on an H100).&lt;/p>
&lt;h3 id="which-one-to-choose">Which one to choose&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Criterion&lt;/th>
&lt;th>GPTQ&lt;/th>
&lt;th>AWQ&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Quantisation speed&lt;/td>
&lt;td>Slower&lt;/td>
&lt;td>Faster&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Quality preserved in INT4&lt;/td>
&lt;td>Good&lt;/td>
&lt;td>Slightly better (~0.05-0.1 PPL)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Supported hardware&lt;/td>
&lt;td>Broad (Ampere+)&lt;/td>
&lt;td>Broad (Ampere+)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Ecosystem&lt;/td>
&lt;td>Mature, widely integrated&lt;/td>
&lt;td>More recent, gaining ground&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Default in 2026&lt;/td>
&lt;td>When GPTQ artefacts already exist&lt;/td>
&lt;td>Default for new quantisations&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The practical rule in May 2026: &lt;strong>AWQ by default&lt;/strong> for new INT4. &lt;strong>GPTQ&lt;/strong> when a GPTQ-Int4 artefact published by the community already meets your requirements.&lt;/p>
&lt;h2 id="gguf-the-llamacpp-ecosystem">GGUF: the llama.cpp ecosystem&lt;/h2>
&lt;p>GGUF is not a quantisation algorithm, it is a file format, and a whole tooling ecosystem, around the llama.cpp runtime.&lt;/p>
&lt;p>Its value: universal compatibility. The same GGUF file runs on:&lt;/p>
&lt;ul>
&lt;li>Pure CPU (Intel/AMD x86, ARM).&lt;/li>
&lt;li>Apple Silicon (M1/M2/M3/M4) with Metal acceleration.&lt;/li>
&lt;li>Consumer GPUs (RTX, AMD Radeon) with layer offload to VRAM.&lt;/li>
&lt;li>Edge devices (Jetson, ARM phones).&lt;/li>
&lt;/ul>
&lt;p>That is what llama.cpp allows and vLLM/TensorRT-LLM do not. The trade-off: lower peak throughput on datacenter GPUs than vLLM.&lt;/p>
&lt;p>The most used GGUF sub-formats in 2026:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Sub-format&lt;/th>
&lt;th>Effective bits&lt;/th>
&lt;th>Relative quality&lt;/th>
&lt;th>Typical use&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>Q8_0&lt;/code>&lt;/td>
&lt;td>8.5&lt;/td>
&lt;td>Almost lossless&lt;/td>
&lt;td>Baseline validation&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>Q6_K&lt;/code>&lt;/td>
&lt;td>6.6&lt;/td>
&lt;td>Very small loss&lt;/td>
&lt;td>High quality + savings&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>Q5_K_M&lt;/code>&lt;/td>
&lt;td>5.7&lt;/td>
&lt;td>Small loss&lt;/td>
&lt;td>Quality/size sweet spot&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>Q4_K_M&lt;/code>&lt;/td>
&lt;td>4.8&lt;/td>
&lt;td>Moderate loss&lt;/td>
&lt;td>&lt;strong>Consumer default&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>Q4_K_S&lt;/code>&lt;/td>
&lt;td>4.5&lt;/td>
&lt;td>Moderate-to-high loss&lt;/td>
&lt;td>When Q4_K_M does not fit&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>Q3_K_M&lt;/code>&lt;/td>
&lt;td>3.9&lt;/td>
&lt;td>Noticeable loss&lt;/td>
&lt;td>Very constrained hardware&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>Q2_K&lt;/code>&lt;/td>
&lt;td>3.3&lt;/td>
&lt;td>Large loss&lt;/td>
&lt;td>Last resort&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The &lt;code>_K_M&lt;/code> suffix indicates the degree of mixing: inside the file, certain layers (typically &lt;code>attention.wv&lt;/code>, &lt;code>feed_forward.w2&lt;/code>) are stored with more bits than others. It is the equivalent of the JPEG &amp;ldquo;edge detector&amp;rdquo; applied layer by layer with a pre-established heuristic.&lt;/p>
&lt;h2 id="kv-cache-quantization">KV cache quantization&lt;/h2>
&lt;p>Quantising the model weights is half the problem. The &lt;strong>KV cache&lt;/strong>, covered in detail in &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory&lt;/a>, typically consumes 20-50 % of VRAM in production with concurrency. Quantising the cache is a lever too:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>--kv-cache-dtype=auto&lt;/code>&lt;/strong> (BF16/FP16, the default). 2 bytes per dimension × num_heads × head_dim × 2 (K and V).&lt;/li>
&lt;li>&lt;strong>&lt;code>--kv-cache-dtype=fp8&lt;/code>&lt;/strong> (E4M3 or E5M2 depending on hardware). 1 byte. Halves the cache with a typical loss of &amp;lt; 0.5 % on quality benchmarks.&lt;/li>
&lt;li>&lt;strong>&lt;code>--kv-cache-dtype=int4&lt;/code>&lt;/strong> (with blocks of 128). 0.5 bytes plus scale overhead. Divides the cache by ~3.5. Measurable loss (1-2 %) but acceptable with long contexts.&lt;/li>
&lt;/ul>
&lt;p>KV cache quantisation is &lt;strong>orthogonal&lt;/strong> to weight quantisation: you can have BF16 weights and an FP8 cache, or INT4 weights and an FP8 cache, and so on. The dominant combination in 2026 on H100: &lt;strong>FP8 weights + FP8 cache&lt;/strong>, which is almost indistinguishable from BF16 in quality and doubles concurrency capacity.&lt;/p>
&lt;h2 id="measured-quality-loss-llama-31-70b-instruct-reference">Measured quality loss (Llama 3.1 70B Instruct, reference)&lt;/h2>
&lt;p>A representative table for Llama 3.1 70B Instruct with a WikiText-2 calibration dataset (128 samples). Figures aggregated from public sources; they may vary by ±0.05 PPL and ±0.5 MMLU depending on implementation and seed.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Format&lt;/th>
&lt;th>Model VRAM&lt;/th>
&lt;th>Perplexity (WikiText-2)&lt;/th>
&lt;th>MMLU (5-shot)&lt;/th>
&lt;th>Relative speed (H100)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>BF16 (baseline)&lt;/td>
&lt;td>140 GB&lt;/td>
&lt;td>4.85&lt;/td>
&lt;td>82.1&lt;/td>
&lt;td>1.00×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>FP8 (E4M3)&lt;/td>
&lt;td>70 GB&lt;/td>
&lt;td>4.87 (+0.02)&lt;/td>
&lt;td>81.6 (-0.5)&lt;/td>
&lt;td>1.85×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>INT8 SmoothQuant&lt;/td>
&lt;td>70 GB&lt;/td>
&lt;td>4.92 (+0.07)&lt;/td>
&lt;td>81.0 (-1.1)&lt;/td>
&lt;td>1.65×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>INT4 AWQ&lt;/td>
&lt;td>35 GB&lt;/td>
&lt;td>4.99 (+0.14)&lt;/td>
&lt;td>80.4 (-1.7)&lt;/td>
&lt;td>2.50×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>INT4 GPTQ&lt;/td>
&lt;td>35 GB&lt;/td>
&lt;td>5.05 (+0.20)&lt;/td>
&lt;td>80.0 (-2.1)&lt;/td>
&lt;td>2.40×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GGUF Q5_K_M&lt;/td>
&lt;td>49 GB&lt;/td>
&lt;td>4.94 (+0.09)&lt;/td>
&lt;td>81.1 (-1.0)&lt;/td>
&lt;td>n/a (llama.cpp)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GGUF Q4_K_M&lt;/td>
&lt;td>42 GB&lt;/td>
&lt;td>5.08 (+0.23)&lt;/td>
&lt;td>79.8 (-2.3)&lt;/td>
&lt;td>n/a (llama.cpp)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>GGUF Q3_K_M&lt;/td>
&lt;td>33 GB&lt;/td>
&lt;td>5.45 (+0.60)&lt;/td>
&lt;td>77.5 (-4.6)&lt;/td>
&lt;td>n/a (llama.cpp)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Three lessons worth keeping:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>FP8 is almost free in quality terms&lt;/strong>. If your hardware supports it, there is no debate.&lt;/li>
&lt;li>&lt;strong>INT4 AWQ is noticeably better than INT4 GPTQ&lt;/strong> in preserved quality, at comparable speed.&lt;/li>
&lt;li>&lt;strong>Q3 is already in measurable-loss territory&lt;/strong>; Q2 should no longer be used except for experiments or extreme demos.&lt;/li>
&lt;/ol>
&lt;h2 id="implications-for-on-premise-hardware">Implications for on-premise hardware&lt;/h2>
&lt;h3 id="on-an-rtx-4090-24-gb-ada-lovelace-no-native-fp8">On an RTX 4090 (24 GB, Ada Lovelace, no native FP8)&lt;/h3>
&lt;p>Llama 3.1 8B Instruct fits comfortably in BF16 (16 GB), but that leaves little headroom for a KV cache under concurrency. The usual sweet spot:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Llama 3.1 8B AWQ-INT4&lt;/strong>: ~5 GB of weights, 19 GB free for the KV cache → 4-8 concurrent sessions with a moderate context.&lt;/li>
&lt;li>&lt;strong>Llama 3 70B GGUF Q4_K_M&lt;/strong>: ~42 GB. &lt;strong>It does not fit on a whole 4090&lt;/strong>; it requires CPU offload with llama.cpp (slow decode but workable for a single user).&lt;/li>
&lt;li>&lt;strong>Llama 3 70B AWQ-INT4 with TP=2 (two 4090s)&lt;/strong>: ~17 GB per GPU → it fits and leaves headroom.&lt;/li>
&lt;/ul>
&lt;p>The 4090 &lt;strong>does not support native FP8&lt;/strong> (Ada Lovelace has the instruction but not Hopper&amp;rsquo;s accelerated throughput). In practice, FP8 on a 4090 works but without the speed gain: the sensible choice is INT4 AWQ.&lt;/p>
&lt;h3 id="on-a-generic-4h100-sxm-cluster-320-gb-nvlink-native-fp8">On a generic 4×H100 SXM cluster (320 GB, NVLink, native FP8)&lt;/h3>
&lt;p>Here FP8 shines:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Llama 3.1 70B FP8&lt;/strong> with TP=2: ~35 GB/GPU. Comfortable, leaving a huge amount of room for the KV cache → dozens of concurrent sessions.&lt;/li>
&lt;li>&lt;strong>Llama 3.1 405B FP8&lt;/strong> with TP=4: ~200 GB/GPU. It just fits, with prefill+decode in the same pool.&lt;/li>
&lt;li>&lt;strong>Llama 3.1 405B INT4 AWQ&lt;/strong> with TP=2: ~100 GB/GPU. It allows serving the large model without saturating the cluster; there is headroom left for the cache and for serving another model at the same time.&lt;/li>
&lt;/ul>
&lt;p>The rule of thumb on an H100 cluster in 2026: &lt;strong>FP8 if quality matters and the model fits; INT4 AWQ if the model does not fit in FP8 or if you want more concurrency at the cost of 1-2 MMLU points&lt;/strong>.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-articles">What we have not covered (upcoming articles)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Speculative decoding&lt;/strong>: the other big acceleration lever in inference. Orthogonal to quantization, it multiplies the speedup.&lt;/li>
&lt;li>&lt;strong>MoE quantization&lt;/strong>: Mixture-of-Experts models (Mixtral, DeepSeek V3, Qwen3-235B-A22B) have different quantisation patterns, experts are not quantised uniformly and there is dynamic routing.&lt;/li>
&lt;li>&lt;strong>Calibration dataset matters&lt;/strong>: how to choose the 128-512 calibration samples. The common mistake of grabbing a random dataset off the internet, and how to avoid it.&lt;/li>
&lt;li>&lt;strong>Multimodal quantization&lt;/strong>: vision-language models have heterogeneous layers (a CNN vision encoder, a transformer language model) that need separate treatment.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory that holds up LLM inference&lt;/a> — the cache is also quantised with the same formats (FP8, INT4); this post goes into the detail of how, and with what loss.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving: prefill and decode in specialised pods&lt;/a> — the two pools can be quantised &lt;strong>asymmetrically&lt;/strong>: prefill with less compression (quality), decode with more compression (throughput).&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">Continuous fine-tuning in production&lt;/a> — QLoRA uses NF4, a specific INT4 variant for the base model during training. It is the cousin of the inference formats described here.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/modern-alignment-dpo-kto-orpo-simpo/">Modern alignment: DPO, KTO, ORPO, SimPO&lt;/a> — the reference model (&lt;code>π_ref&lt;/code>) in DPO can be quantised to FP8 to free up VRAM. The quality of the reference matters less than that of the trained model.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the master map where Deploy is stage 4.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding: the secretary who anticipates what the boss is going to say&lt;/a> — a lever orthogonal to and multiplicative with quantization. The draft is usually left in BF16 even when the target is in FP8/INT4, because quantising it degrades α and kills the speedup.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention v1/v2/v3/v4: the librarian who never clears the desk&lt;/a> — FA3 does FP8 attention with block quantization and incoherent processing to preserve precision; FA4 combines that with Blackwell NVFP4 weights. The two quantisation layers (weights and attention) are applied at different moments in the kernel.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/">MoE inference: the call centre with 256 specialists&lt;/a> — DeepSeek-V3 671B FP8 (~685 GB) only fits on modest clusters thanks to aggressive quantisation of the routed experts; NVFP4 on expert weights + FP8 attention is the peak setup on NVL72.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving: one translator with a thousand glossaries&lt;/a> — the canonical stack is an FP8/INT4 base plus BF16 adapters. Quantising the base does not just free memory, it multiplies the economics: the same cluster goes from serving dozens to hundreds of concurrent adapters.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">Optimising decode in vLLM&lt;/a> — the combination of AWQ (INT4 weights) + FP8 KV cache + speculative decoding is the reference config for squeezing an RTX 4090; the concrete numbers are laid out there.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">Optimising prefill in vLLM&lt;/a> — &lt;code>--kv-cache-dtype fp8&lt;/code> and &lt;code>--calculate-kv-scales&lt;/code> are the parameters that enable KV cache quantisation in vLLM, with the precision trade-off measured on long contexts.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-pruning-cutting-without-amputating/">Pruning LLM models&lt;/a> — an orthogonal lever: quantising reduces the precision of the weights pruning has kept; combined, 50% Wanda sparsity + AWQ INT4 shrinks the model to ~1/8 of its original size.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/knowledge-distillation-teaching-small-model-think-like-big-one/">Knowledge distillation&lt;/a> — the previous step in the pipeline: you distil first (a new, smaller model), you quantise afterwards (the same model, more efficient); the two techniques attack different dimensions.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise LLM inference&lt;/a> — quantization is the first sizing lever when the BF16 arithmetic does not work out; the sensitivity table there quantifies the savings in VRAM and in TPOT.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/test-time-quantization-without-calibration-dataset/">Test-time quantization: quantising on the fly&lt;/a> — the alternative with no calibration dataset: it derives activation-aware scales at inference time, at the cost of runtime overhead that weighs more heavily on small models.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/aggressive-quantisation-4-bit-to-ternary/">Aggressive quantisation: from 4-bit to ternary&lt;/a> — what happens below 4-bit, where naive PTQ collapses and co-design is needed: codebooks (AQLM, QuIP#, QTIP), QAT and native ternary (BitNet b1.58).&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-serving-engines-pareto-frontier-comparison/">Serving engines compared (vLLM/SGLang/TRT-LLM/Dynamo)&lt;/a> — how each engine supports the quantization formats described here (FP8, AWQ, GPTQ, GGUF) and what difference in goodput it makes to pick the one that implements them best.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Frantar, E., Ashkboos, S., Hoefler, T., Alistarh, D. &lt;em>GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers&lt;/em> (ICLR 2023).&lt;/li>
&lt;li>Lin, J., Tang, J., Tang, H., Yang, S., Dang, X., Han, S. &lt;em>AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration&lt;/em> (MLSys 2024).&lt;/li>
&lt;li>Dettmers, T., Pagnoni, A., Holtzman, A., Zettlemoyer, L. &lt;em>QLoRA: Efficient Finetuning of Quantized LLMs&lt;/em> (NeurIPS 2023). Introduces NF4 and double quantization.&lt;/li>
&lt;li>Xiao, G., Lin, J., Seznec, M., Wu, H., Demouth, J., Han, S. &lt;em>SmoothQuant: Accurate and Efficient Post-Training Quantization for Large Language Models&lt;/em> (ICML 2023).&lt;/li>
&lt;li>NVIDIA. &lt;em>FP8 Formats for Deep Learning&lt;/em> — E4M3/E5M2 white paper: &lt;a href="https://arxiv.org/abs/2209.05433">https://arxiv.org/abs/2209.05433&lt;/a>.&lt;/li>
&lt;li>Rouhani, B. et al. &lt;em>Microscaling Data Formats for Deep Learning&lt;/em> — MXFP4/MXFP8: &lt;a href="https://arxiv.org/abs/2310.10537">https://arxiv.org/abs/2310.10537&lt;/a>.&lt;/li>
&lt;li>llama.cpp GGUF spec: &lt;a href="https://github.com/ggerganov/llama.cpp/blob/master/docs/gguf.md">https://github.com/ggerganov/llama.cpp/blob/master/docs/gguf.md&lt;/a>.&lt;/li>
&lt;li>vLLM quantization docs: &lt;a href="https://docs.vllm.ai/en/latest/quantization/">https://docs.vllm.ai/en/latest/quantization/&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>Disaggregated serving: prefill and decode in specialised pods</title><link>https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/</link><pubDate>Fri, 22 May 2026 01:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>LLM inference has two phases with opposite profiles: &lt;strong>prefill&lt;/strong> (processing the whole prompt in one go) is compute-bound, &lt;strong>decode&lt;/strong> (generating token by token) is memory-bandwidth-bound. Running them on the same GPU forces a choice between two incompatible optimal hardware profiles, and leaves between 60 % and 80 % of peak capacity unused. The industry consolidated the pattern in 2026: &lt;strong>disaggregated serving&lt;/strong>, separate pods for each phase, connected by a KV cache transfer channel (NIXL over UCX, RDMA, or NCCL as a fallback). DistServe demonstrated 7.4× more request rate at the same SLO; NVIDIA Dynamo 1.0 (GA at GTC 2026) takes the pattern to production at datacenter scale. Mixing heterogeneous hardware, H100 for prefill and commodity GPUs for decode, cuts up to 48 % off the cost per token. This article explains the why, the how, and the numbers that matter for a typical on-premise infrastructure.&lt;/p>
&lt;h2 id="you-are-here-deploy">You are here: Deploy&lt;/h2>
&lt;p>Disaggregated serving is an architectural decision in the &lt;strong>Deploy&lt;/strong> stage of the &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">six-stage LLMOps pipeline&lt;/a>. It does not change the model, it does not change the data, it does not change the evals. It only changes &lt;strong>how inference pods are spread over the GPU hardware&lt;/strong>. But that change moves aggregate throughput between 2× and 7×.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 90" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="you are here: Deploy">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#ff8a4c;stroke-width:3}.idle{fill:#f4f4f4}.lbl{font:600 12px sans-serif;fill:#222}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#dsm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#dsm)}&lt;/style>
&lt;defs>&lt;marker id="dsm" 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="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="20" text-anchor="middle" class="lbl">You are here: DEPLOY · prefill/decode pod topology and KV cache transfer&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box idle"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box active"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box idle"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-the-kitchen-with-two-brigades">The analogy: the kitchen with two brigades&lt;/h2>
&lt;p>A serious industrial kitchen, any kitchen serving more than 50 covers a night, runs with two distinct brigades and two physically separate spaces.&lt;/p>
&lt;p>The &lt;strong>prep brigade&lt;/strong> starts at dawn. Its job is the &lt;em>mise en place&lt;/em>: chopping, marinating, blanching, boiling stocks, preparing complex components. Equipment: good knives, big burners, convection ovens, 40-litre pots. It is capacity-intensive work and it is done in one batch. When it finishes, everything is left in labelled trays ready to use.&lt;/p>
&lt;p>The &lt;strong>service brigade&lt;/strong> comes in mid-afternoon. Its job is service: taking the trays from prep, heating portions, plating, running the pass. Equipment: salamanders, small griddles, thin spatulas, plenty of crockery. It is wrist work, rhythm work, not-failing-the-customer-who-has-the-plate-in-front-of-them work. Capacity per hour matters less than latency per plate.&lt;/p>
&lt;p>If you make &lt;strong>the same person&lt;/strong> do prep and service, both suffer. The cook is idle while doing mise en place in the middle of the afternoon. They have to stop and plate up when five orders land at once. Their working kit is designed for one or the other, not for both.&lt;/p>
&lt;p>Serious kitchens solved this decades ago: separate brigades, separate spaces, separate equipment. The only thing that crosses between them is the trays of mise en place.&lt;/p>
&lt;p>The &lt;strong>trays are the KV cache&lt;/strong>. The separation is &lt;strong>disaggregated serving&lt;/strong>. The handover from prep to service is the &lt;strong>KV cache transfer&lt;/strong>, solved today with NIXL over RDMA. And the specialised pods are the two brigades with their optimal kit.&lt;/p>
&lt;h2 id="quick-recap-prefill-and-decode">Quick recap: prefill and decode&lt;/h2>
&lt;p>A request to an LLM always goes through two phases:&lt;/p>
&lt;p>&lt;strong>Prefill.&lt;/strong> Take the complete prompt (say, 4,000 tokens) and process it in a single pass through all the layers of the model. The result is the KV cache of those 4,000 tokens (see the &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">previous article on KV cache&lt;/a> if you want to recall exactly what it holds). This step is massively parallel: all the tokens go through the attention matrices at once, which translates into huge, dense matrix multiplications. The GPU sits at 90-95 % compute utilisation. &lt;strong>TTFT&lt;/strong> (time to first token) is determined by this phase.&lt;/p>
&lt;p>&lt;strong>Decode.&lt;/strong> Once the KV cache is ready, the model generates tokens one by one. Each new token is a pass through all the layers with a single query vector, reading the whole accumulated KV cache to compute attention. There is no parallelism between tokens (each one depends on the previous). What limits you here is not compute but bandwidth: every step has to read the model&amp;rsquo;s full weights from HBM. The GPU sits at 20-40 % compute utilisation, but at 90 % HBM utilisation. &lt;strong>TBT&lt;/strong> (time between tokens) is determined by this phase.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Phase&lt;/th>
&lt;th>Characteristic&lt;/th>
&lt;th>Bottleneck&lt;/th>
&lt;th>Key metric&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Prefill&lt;/td>
&lt;td>Massive parallel compute over N tokens at once&lt;/td>
&lt;td>TFLOPS (compute)&lt;/td>
&lt;td>TTFT&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Decode&lt;/td>
&lt;td>Streaming weights from HBM, 1 token at a time&lt;/td>
&lt;td>HBM bandwidth&lt;/td>
&lt;td>TBT (inter-token latency)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;div class="diagram" style="max-width: 720px; margin: 1.5rem auto;">
&lt;svg viewBox="0 0 720 290" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Compute vs bandwidth utilisation in prefill and decode">
&lt;style>
.bar { stroke: #333; stroke-width: 1; }
.b-compute { fill: #2a9d8f; }
.b-bandwidth { fill: #e76f51; }
.b-low { fill-opacity: 0.35; }
.ax { stroke: #333; stroke-width: 1.5; }
.grid { stroke: #ddd; stroke-width: 1; stroke-dasharray: 3,3; }
.lbl { font: 600 13px sans-serif; fill: #222; }
.lbl-sm { font: 11px sans-serif; fill: #444; }
.tag { font: 600 12px sans-serif; }
&lt;/style>
&lt;text x="360" y="22" text-anchor="middle" class="lbl">GPU utilisation during each phase (typical order of magnitude)&lt;/text>
&lt;line class="ax" x1="100" y1="240" x2="100" y2="60"/>
&lt;line class="ax" x1="100" y1="240" x2="680" y2="240"/>
&lt;line class="grid" x1="100" y1="78" x2="680" y2="78"/>
&lt;line class="grid" x1="100" y1="114" x2="680" y2="114"/>
&lt;line class="grid" x1="100" y1="150" x2="680" y2="150"/>
&lt;line class="grid" x1="100" y1="186" x2="680" y2="186"/>
&lt;text x="90" y="63" text-anchor="end" class="lbl-sm">100%&lt;/text>
&lt;text x="90" y="117" text-anchor="end" class="lbl-sm">75%&lt;/text>
&lt;text x="90" y="153" text-anchor="end" class="lbl-sm">50%&lt;/text>
&lt;text x="90" y="189" text-anchor="end" class="lbl-sm">25%&lt;/text>
&lt;text x="90" y="243" text-anchor="end" class="lbl-sm">0%&lt;/text>
&lt;text x="240" y="270" text-anchor="middle" class="lbl">PREFILL&lt;/text>
&lt;text x="240" y="284" text-anchor="middle" class="lbl-sm">compute-bound&lt;/text>
&lt;text x="540" y="270" text-anchor="middle" class="lbl">DECODE&lt;/text>
&lt;text x="540" y="284" text-anchor="middle" class="lbl-sm">memory-bound&lt;/text>
&lt;rect x="160" y="69" width="65" height="171" class="bar b-compute"/>
&lt;text x="193" y="62" text-anchor="middle" class="tag" fill="#2a9d8f">95%&lt;/text>
&lt;text x="193" y="255" text-anchor="middle" class="lbl-sm">compute&lt;/text>
&lt;rect x="245" y="132" width="65" height="108" class="bar b-bandwidth b-low"/>
&lt;text x="278" y="125" text-anchor="middle" class="tag" fill="#e76f51">60%&lt;/text>
&lt;text x="278" y="255" text-anchor="middle" class="lbl-sm">HBM&lt;/text>
&lt;rect x="460" y="177" width="65" height="63" class="bar b-compute b-low"/>
&lt;text x="493" y="170" text-anchor="middle" class="tag" fill="#2a9d8f">35%&lt;/text>
&lt;text x="493" y="255" text-anchor="middle" class="lbl-sm">compute&lt;/text>
&lt;rect x="545" y="78" width="65" height="162" class="bar b-bandwidth"/>
&lt;text x="578" y="71" text-anchor="middle" class="tag" fill="#e76f51">90%&lt;/text>
&lt;text x="578" y="255" text-anchor="middle" class="lbl-sm">HBM&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The asymmetry is structural: prefill burns compute and leaves memory half idle, decode does the opposite. &lt;strong>A GPU designed to be excellent at both at once is a GPU designed to be badly used all the time.&lt;/strong>&lt;/p>
&lt;h2 id="why-putting-them-on-the-same-gpu-is-a-bad-deal">Why putting them on the same GPU is a bad deal&lt;/h2>
&lt;p>Until 2023, the universal assumption was to run prefill and decode &lt;strong>in the same inference process, on the same GPU&lt;/strong>. The engine scheduler (vLLM, TGI, Triton) decided on each cycle whether to prefill a new request or decode the ones already in flight. The intuition was that sharing hardware saves money.&lt;/p>
&lt;p>The intuition is wrong. The problem has three faces:&lt;/p>
&lt;p>&lt;strong>Latency interference.&lt;/strong> When the engine decides to prefill a new request, it &lt;strong>interrupts&lt;/strong> every decode in progress. That pushes up the TBT of the other requests. The user who was watching tokens flow smoothly down the screen notices a stall of several hundred milliseconds. This is known as &lt;em>prefill-decode interference&lt;/em> and it degrades the experience visibly as concurrency rises.&lt;/p>
&lt;p>&lt;strong>Sub-optimal hardware for each phase.&lt;/strong> An H100 SXM has 989 TFLOPS BF16 of compute and 3.35 TB/s of HBM3. It is excellent for prefill, where compute is the limit. For decode, where the only thing that matters is bandwidth, those 989 TFLOPS are 60-70 % wasted. Conversely, a GPU with less compute but similar relative bandwidth (RTX 4090, L40S) would handle decode just as well for a fraction of the price.&lt;/p>
&lt;p>&lt;strong>Low aggregate utilisation.&lt;/strong> In real workloads with Llama 3 70B and 512-token outputs, &lt;strong>around 80 % of the wall-clock is spent in decode&lt;/strong>. That means 80 % of your H100 cluster budget is doing memory reads, not calculations. It is like paying for a Ferrari to use it in a car park queue.&lt;/p>
&lt;h2 id="the-idea-specialised-pods-kv-cache-as-the-deliverable">The idea: specialised pods, KV cache as the deliverable&lt;/h2>
&lt;p>Disaggregated serving breaks the inference cycle into two distinct services:&lt;/p>
&lt;p>&lt;strong>Prefill pod.&lt;/strong> Receives the prompt, runs the prefill, produces the KV cache. Hardware: high-compute GPUs (H100, H200, B200). Optimised for aggressive batching and throughput, not for individual latency: if 32 prompts arrive within 100 ms, it processes them together.&lt;/p>
&lt;p>&lt;strong>Decode pod.&lt;/strong> Receives the already-built KV cache, runs the token-by-token generation, streams to the client. Hardware: GPUs with good bandwidth but ideally cheaper per TFLOPS (RTX 4090, L40S, A100, even A30 depending on the case). Optimised for per-token latency (low TBT).&lt;/p>
&lt;p>Between the two: a &lt;strong>KV cache transfer&lt;/strong> over the network, which can be node-local (shared memory, NVLink), intra-rack (RDMA with InfiniBand or RoCE) or cross-rack (NIXL over UCX). The cost of this transfer scales linearly with context length, and it is the economic crux of the scheme.&lt;/p>
&lt;div class="diagram" style="max-width: 720px; margin: 1.5rem auto;">
&lt;svg viewBox="0 0 720 340" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Monolithic vs disaggregated architecture">
&lt;style>
.node { stroke: #333; stroke-width: 1.5; }
.n-mono { fill: #ffe9d6; }
.n-prefill { fill: #d9f5d6; }
.n-decode { fill: #d6eaff; }
.n-router { fill: #fffae6; }
.lbl { font: 600 13px sans-serif; fill: #222; }
.lbl-sm { font: 11px sans-serif; fill: #444; }
.lbl-section { font: 700 14px sans-serif; fill: #222; }
.arr { stroke: #444; stroke-width: 1.6; fill: none; marker-end: url(#ah4); }
.arr-int { stroke: #c1121f; stroke-width: 1.4; fill: none; stroke-dasharray: 5,3; marker-end: url(#ah4r); }
&lt;/style>
&lt;defs>
&lt;marker id="ah4" 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="#444"/>&lt;/marker>
&lt;marker id="ah4r" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto">&lt;path d="M0,0 L10,5 L0,10 z" fill="#c1121f"/>&lt;/marker>
&lt;/defs>
&lt;text x="170" y="25" text-anchor="middle" class="lbl-section">Monolithic (aggregated)&lt;/text>
&lt;rect x="40" y="50" width="260" height="220" rx="10" class="node n-mono"/>
&lt;text x="170" y="78" text-anchor="middle" class="lbl">Single GPU&lt;/text>
&lt;text x="170" y="98" text-anchor="middle" class="lbl-sm">scheduler decides each cycle:&lt;/text>
&lt;rect x="65" y="115" width="100" height="40" rx="5" class="node n-prefill"/>
&lt;text x="115" y="140" text-anchor="middle" class="lbl-sm">prefill&lt;/text>
&lt;rect x="180" y="115" width="100" height="40" rx="5" class="node n-decode"/>
&lt;text x="230" y="140" text-anchor="middle" class="lbl-sm">decode&lt;/text>
&lt;path class="arr-int" d="M165,128 L180,128"/>
&lt;path class="arr-int" d="M180,145 L165,145"/>
&lt;text x="170" y="180" text-anchor="middle" class="lbl-sm" fill="#c1121f">interference on every switch&lt;/text>
&lt;text x="170" y="200" text-anchor="middle" class="lbl-sm">→ TBT rises when prefill arrives&lt;/text>
&lt;text x="170" y="230" text-anchor="middle" class="lbl-sm">one HW optimal for both:&lt;/text>
&lt;text x="170" y="250" text-anchor="middle" class="lbl-sm">impossible&lt;/text>
&lt;text x="540" y="25" text-anchor="middle" class="lbl-section">Disaggregated&lt;/text>
&lt;rect x="370" y="50" width="150" height="100" rx="10" class="node n-prefill"/>
&lt;text x="445" y="80" text-anchor="middle" class="lbl">prefill pod&lt;/text>
&lt;text x="445" y="102" text-anchor="middle" class="lbl-sm">H100 / H200 / B200&lt;/text>
&lt;text x="445" y="120" text-anchor="middle" class="lbl-sm">high compute, aggressive&lt;/text>
&lt;text x="445" y="138" text-anchor="middle" class="lbl-sm">batching&lt;/text>
&lt;rect x="560" y="50" width="150" height="100" rx="10" class="node n-decode"/>
&lt;text x="635" y="80" text-anchor="middle" class="lbl">decode pod&lt;/text>
&lt;text x="635" y="102" text-anchor="middle" class="lbl-sm">4090 / L40S / A100&lt;/text>
&lt;text x="635" y="120" text-anchor="middle" class="lbl-sm">high bandwidth, stable&lt;/text>
&lt;text x="635" y="138" text-anchor="middle" class="lbl-sm">TBT&lt;/text>
&lt;path class="arr" d="M520,100 L560,100"/>
&lt;text x="540" y="92" text-anchor="middle" class="lbl-sm">KV cache&lt;/text>
&lt;text x="540" y="115" text-anchor="middle" class="lbl-sm">NIXL/RDMA&lt;/text>
&lt;rect x="450" y="180" width="180" height="50" rx="8" class="node n-router"/>
&lt;text x="540" y="200" text-anchor="middle" class="lbl">router (vLLM/Dynamo)&lt;/text>
&lt;text x="540" y="218" text-anchor="middle" class="lbl-sm">routes prompts and streams&lt;/text>
&lt;path class="arr" d="M445,150 L500,180"/>
&lt;path class="arr" d="M635,150 L580,180"/>
&lt;text x="540" y="260" text-anchor="middle" class="lbl-sm" fill="#2a9d8f">→ stable TBT, low TTFT&lt;/text>
&lt;text x="540" y="280" text-anchor="middle" class="lbl-sm">cost: KV cache transfer&lt;/text>
&lt;text x="540" y="298" text-anchor="middle" class="lbl-sm">~5-50 ms depending on interconnect&lt;/text>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-transfer-protocol-the-economics-of-movement">The transfer protocol: the economics of movement&lt;/h2>
&lt;p>The KV cache transferred for a Llama 3 70B with 4K of context weighs roughly &lt;strong>2.6 GB&lt;/strong> (80 layers × 8 KV heads × 128 dim × 4,096 tokens × 2 (K and V) × 2 bytes in BF16). Moving 2.6 GB between two GPUs is not trivial:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Channel&lt;/th>
&lt;th style="text-align:right">Effective bandwidth&lt;/th>
&lt;th style="text-align:right">Time for 2.6 GB&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Intra-node NVLink (NVSwitch)&lt;/td>
&lt;td style="text-align:right">~450 GB/s&lt;/td>
&lt;td style="text-align:right">~6 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Shared memory (same node, PCIe 5)&lt;/td>
&lt;td style="text-align:right">~60 GB/s&lt;/td>
&lt;td style="text-align:right">~45 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>RDMA InfiniBand 400 Gbps&lt;/td>
&lt;td style="text-align:right">~50 GB/s&lt;/td>
&lt;td style="text-align:right">~55 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>RDMA RoCE 200 Gbps&lt;/td>
&lt;td style="text-align:right">~25 GB/s&lt;/td>
&lt;td style="text-align:right">~105 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TCP/IP 10 GbE&lt;/td>
&lt;td style="text-align:right">~1 GB/s&lt;/td>
&lt;td style="text-align:right">~2.6 s&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The immediate reading: above InfiniBand grade, the transfer is comfortable. Below it, it ruins the very TTFT we are trying to improve. &lt;strong>Disaggregated serving is only viable with a decent interconnect.&lt;/strong> It is not a pattern for clusters built out of consumer Ethernet switches.&lt;/p>
&lt;p>NVIDIA answered this with &lt;strong>NIXL&lt;/strong> (NVIDIA Inference Transfer Library), released in mid-2025: a library that abstracts the transport (UCX, NCCL, direct RDMA verbs, shared memory) and picks the best available path automatically. vLLM has integrated it since late 2025 through the &lt;code>NixlConnector&lt;/code>. It is now the de facto default for new deployments.&lt;/p>
&lt;h2 id="real-implementations-in-may-2026">Real implementations in May 2026&lt;/h2>
&lt;p>The pattern&amp;rsquo;s path over two years:&lt;/p>
&lt;pre tabindex="0">&lt;code>2024 Jan · DistServe (HKU + UCSD): 7.4× requests at the same SLO
2024 May · SplitWise (Microsoft): variant with heterogeneous hardware
2024 Dec · experimental vLLM disagg (SharedStorage + PyNcclConnector)
2025 Mar · NIXL release (NVIDIA): unified transfer library
2025 Jul · stable vLLM NixlConnector
2025 Nov · SGLang, llm-d, MoonCake adopt the pattern
2026 Mar · NVIDIA Dynamo 1.0 GA (GTC 2026): production-ready at datacenter scale
&lt;/code>&lt;/pre>&lt;p>As of today, &lt;strong>the pattern is the default&lt;/strong> in any serious serving framework. The ones still monolithic are the small ones or the educational ones.&lt;/p>
&lt;p>Three realistic options for an on-premise infrastructure:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>vLLM disagg with NixlConnector.&lt;/strong> The most open route, requiring two sets of vLLM pods to be deployed (one with &lt;code>--kv-transfer-config '{&amp;quot;kv_role&amp;quot;:&amp;quot;producer&amp;quot;}'&lt;/code>, another with &lt;code>&amp;quot;kv_role&amp;quot;:&amp;quot;consumer&amp;quot;&lt;/code>) plus a proxy router. Enough for clusters of 4-16 GPUs.&lt;/li>
&lt;li>&lt;strong>SGLang with disagg.&lt;/strong> Conceptually equivalent, better performance on some MoE workloads.&lt;/li>
&lt;li>&lt;strong>NVIDIA Dynamo 1.0.&lt;/strong> The one taking over at datacenter scale. It covers routing, KV cache management, monitoring and scheduling in a single control plane. Heavier, but the reference solution if your cluster grows beyond 32 GPUs.&lt;/li>
&lt;/ol>
&lt;h2 id="the-numbers-that-matter">The numbers that matter&lt;/h2>
&lt;p>What disaggregation unlocks, in direct terms:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>Aggregated (monolithic)&lt;/th>
&lt;th>Disaggregated&lt;/th>
&lt;th>Improvement&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Goodput (req/s at the SLO)&lt;/td>
&lt;td>baseline&lt;/td>
&lt;td>1.4 – 2×&lt;/td>
&lt;td>up to 2×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TTFT under high load&lt;/td>
&lt;td>rises sharply from QPS 4&lt;/td>
&lt;td>stable up to QPS 7+&lt;/td>
&lt;td>~2×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Request rate at the same SLO (DistServe paper)&lt;/td>
&lt;td>baseline&lt;/td>
&lt;td>7.4×&lt;/td>
&lt;td>7.4×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>MoE throughput on Blackwell (Dynamo, GB300 NVL72)&lt;/td>
&lt;td>baseline (Hopper)&lt;/td>
&lt;td>up to 50×&lt;/td>
&lt;td>depends on the model&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cost per token (heterogeneous H100 + commodity)&lt;/td>
&lt;td>baseline (all H100)&lt;/td>
&lt;td>-48 %&lt;/td>
&lt;td>almost half&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>These numbers have to be read carefully: the most spectacular ones (7× and 50×) need specific hardware (Blackwell GB200/GB300 NVL72) and specific models (large MoE). The &lt;strong>realistic range for a typical on-premise setup is 1.4-2× in goodput and -30 to -50 % in cost per token&lt;/strong>, depending on how heterogeneous the GPU mix is and how optimised the KV cache transfer is.&lt;/p>
&lt;h2 id="heterogeneity-the-radical-version">Heterogeneity: the radical version&lt;/h2>
&lt;p>The logical next step, proposed by SplitWise in 2024 and matured in 2025-2026 (Cronus, Tessera and others), is to &lt;strong>mix GPU types&lt;/strong>: expensive high-compute GPUs for prefill, commodity GPUs with good bandwidth for decode.&lt;/p>
&lt;p>Indicative cost (typical market prices in mid-2026):&lt;/p>
&lt;ul>
&lt;li>&lt;strong>H100 SXM&lt;/strong>: ~30-40 kUSD capex, ~3-4 USD/h amortised. Compute-heavy profile.&lt;/li>
&lt;li>&lt;strong>L40S&lt;/strong>: ~8-10 kUSD capex, ~1.5 USD/h. Intermediate profile, 864 GB/s of bandwidth.&lt;/li>
&lt;li>&lt;strong>RTX 4090&lt;/strong>: ~1.5 kUSD capex, ~0.30 USD/h. Modest compute profile but 1 TB/s of GDDR6X bandwidth, enough for decode of models up to ~30B parameters.&lt;/li>
&lt;/ul>
&lt;p>A realistic mixed cluster for serving an 8B model:&lt;/p>
&lt;pre tabindex="0">&lt;code>2× RTX 4090 (prefill batch) → ~3,000 USD capex, ~0.60 USD/h
4× RTX 4090 (decode pool) → ~6,000 USD capex, ~1.20 USD/h
TOTAL → ~9,000 USD capex, ~1.80 USD/h
&lt;/code>&lt;/pre>&lt;p>Against the monolithic alternative with equivalent throughput:&lt;/p>
&lt;pre tabindex="0">&lt;code>2× H100 SXM (all in one) → ~70,000 USD capex, ~7 USD/h
&lt;/code>&lt;/pre>&lt;p>The same throughput at a fraction of the capex and a quarter of the hourly cost, &lt;strong>at the price of operational complexity&lt;/strong>: you now have two pools to coordinate, a transfer network to look after, and a scheduler that is not trivial.&lt;/p>
&lt;p>For larger models (Llama 3 70B), the decode pool no longer fits on a single 4090 (the model does not fit in 24 GB, not even quantised to INT4 with headroom). There the sensible mix is H100 for prefill plus L40S or A100 80GB for decode, with a typical saving of 30-40 % over the all-H100 option.&lt;/p>
&lt;h2 id="applied-to-typical-on-premise-hardware">Applied to typical on-premise hardware&lt;/h2>
&lt;h3 id="case-1--one-or-two-rtx-4090s-monolithic-still-wins">Case 1 — One or two RTX 4090s: monolithic still wins&lt;/h3>
&lt;p>With a single GPU there is no disaggregation to speak of: the pattern needs at least two GPUs in separate pods. With two 4090s you can technically try it (one for prefill, one for decode with the KV cache transferred over PCIe 5 or basic RDMA), but the transfer overhead eats the gain for small models where prefill is already fast.&lt;/p>
&lt;p>&lt;strong>Recommendation:&lt;/strong> stay monolithic (traditional vLLM, &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">properly configured with a quantised KV cache&lt;/a>). The next justifiable level of complexity is a cluster with a fast interconnect.&lt;/p>
&lt;h3 id="case-2--4h100-sxm-cluster-320-gb-nvlink-the-sweet-spot">Case 2 — 4×H100 SXM cluster (320 GB, NVLink): the sweet spot&lt;/h3>
&lt;p>The minimum realistic configuration for serious disaggregation, serving a 70B model in production:&lt;/p>
&lt;pre tabindex="0">&lt;code>2× H100 (TP=2) → 2 prefill pods
2× H100 (TP=2) → decode pods with several instances sharing TP
NIXL over NVLink → KV cache transfer &amp;lt;6 ms
Router (vLLM or Dynamo) → prompt distribution and streaming
&lt;/code>&lt;/pre>&lt;p>Realistic expected result: goodput &lt;strong>1.6-1.9× relative to the same cluster running monolithic&lt;/strong>, with TTFT stable up to loads of QPS 7-8 (against the QPS 4 at which the monolithic setup starts to degrade).&lt;/p>
&lt;p>If the heterogeneous mix is possible (adding 4-8 L40S to the cluster to make the decode pool), the cost per token drops a further 25 % to 35 %, while still serving the 70B model whole.&lt;/p>
&lt;h2 id="position-within-the-architecture">Position within the architecture&lt;/h2>
&lt;p>Disaggregated serving is a &lt;strong>cross-cutting layer&lt;/strong> over almost everything discussed in previous articles. It touches:&lt;/p>
&lt;ul>
&lt;li>The &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a> because it is the artefact transferred between pods. Without a solid grasp of how much the cache weighs and how it grows with context, you cannot size the transfer.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">Continuous fine-tuning&lt;/a> because multi-LoRA hot-swap keeps its semantics: each pod (prefill or decode) loads the adapters separately, and the router decides which adapter to apply in each phase.&lt;/li>
&lt;li>Cluster topology: it changes the recommended HW, the networking required and the cost model.&lt;/li>
&lt;/ul>
&lt;p>If you are designing an inference infrastructure for 2026 from scratch, &lt;strong>disaggregation stops being optional&lt;/strong> for any cluster beyond 4 GPUs of capacity. If you are modernising an existing one, it is the upgrade with the best return per euro invested, provided the networking between pods is decent (intra-node NVLink or intra-rack RDMA as a minimum).&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-articles">What we have not covered (upcoming articles)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>NIXL in detail&lt;/strong>: how it picks the optimal transport, how UCX is configured, what happens when RDMA fails and you have to fall back to TCP.&lt;/li>
&lt;li>&lt;strong>Routing scheduler&lt;/strong>: how the orchestrator decides which pod gets which request, dynamic batching, priority handling.&lt;/li>
&lt;li>&lt;strong>Multi-tenant disagg&lt;/strong>: KV cache isolation between tenants, per-adapter ACLs, multi-LoRA over specialised pods.&lt;/li>
&lt;li>&lt;strong>Disagg + prefix caching&lt;/strong>: how it combines with the KV cache reuse pattern when several prompts share a prefix (a common system prompt).&lt;/li>
&lt;li>&lt;strong>Disagg at the edge / local inference&lt;/strong>: viability on home hardware (4090 + Mac Studio, for example), where the transfer depends on Thunderbolt or residential Ethernet.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the master map the Deploy stage belongs to. This post digs into one concrete architectural decision within that stage.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/">The GPU cluster as a multi-tenant platform&lt;/a> — the Gateway/Quota/Isolation/Observability layered pattern on top of which the disaggregation described here sits: the H100 cluster serving several tenants combines both patterns.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/">LLM inference operators on Kubernetes&lt;/a> — the operators (vLLM Production Stack, NVIDIA Dynamo, llm-d, OME) that materialise the specialised prefill and decode pods in Kubernetes.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory that holds up LLM inference&lt;/a> — the exact artefact transferred between pods, with the full formula for its size.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention from the inside: blocks, page table, eviction and the state of the art of KV cache in 2026&lt;/a> — the KV cache mechanics that disaggregation exploits at block level, and the landscape of derived optimisations (vAttention, LMCache, RadixAttention).&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">Continuous fine-tuning in production&lt;/a> — how multi-LoRA hot-swap coexists with disaggregation: each pod loads adapters separately, the router chooses.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantization for LLM inference: FP8, INT4 and GGUF&lt;/a> — the two pools (prefill and decode) can be quantised asymmetrically: prefill keeps more precision, decode prioritises throughput. The maths and the formats are there.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding: the secretary who anticipates what the boss is going to say&lt;/a> — speculative only helps in the decode phase (it does not touch prefill); disaggregation makes it easier by isolating the decode pool where the technique can be applied without contaminating the prefill one.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention v1/v2/v3/v4: the librarian who never clears the desk&lt;/a> — the kernel each pod uses underneath. Prefill (compute-bound) and decode (memory-bound) benefit from FA3/FA4 in different ways and allow a backend to be chosen per phase.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/">MoE inference: the call centre with 256 specialists&lt;/a> — the real DeepSeek-V3 deployment combines &lt;strong>EP=32 in prefill (4 nodes) with EP=144 in decode (18 nodes)&lt;/strong>: disaggregation is the prerequisite that allows expert parallelism to be specialised per phase.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/multi-lora-serving-shared-base-concurrent-adapters-sgmv-kernel/">Multi-LoRA serving: the single translator with a thousand glossaries&lt;/a> — once pods are separated, adapters are managed per pool; the 2026 strategy is to replicate hot ones on every pod (prefill and decode) and evict cold ones.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> — the layer underneath. DistServe is the paper that contributed the goodput metric and the idea of disaggregating prefill/decode; understanding the iterative scheduler is a prerequisite to understanding why the separation unlocked additional gains.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise LLM inference&lt;/a> — disaggregation appears as the third sizing lever when the workload&amp;rsquo;s prefill/decode mix is asymmetric; when to apply it and how much it saves are quantified there.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/nvlink-nvswitch-nccl-cable-every-token-crosses/">NVLink, NVSwitch and NCCL: the cable every token travels down&lt;/a> — moving the KV cache between the prefill pool and the decode pool travels over the same interconnect as the all-reduces and competes with them; designing the disaggregation without counting that transfer cost is the classic trap.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/network-numa-cilium-ebpf-dranet-fourth-leg-pinning/">The kitchen door the maître never looked at: network NUMA, Cilium eBPF and DRANET&lt;/a> — when that KV cache crosses by RDMA across nodes, a badly placed NIC pays for it in latency; the fourth leg (NIC↔GPU locality via DRANET) is what keeps separating prefill and decode from being expensive in transfer.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pantry-lift-from-disk-to-hbm-why-the-kitchen-opens-late/">From disk to HBM: cold start and model loading&lt;/a> — bringing up decode pools on demand pays the cold start of loading the model into every new pod; the elasticity of the pattern depends on how fast they start.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/sms-cuda-streams-cuda-graphs-inference/">SM, CUDA streams and CUDA graphs&lt;/a> — pure decode pods are the ideal case for &lt;code>FULL_DECODE_ONLY&lt;/code>: they maximise the benefit of the CUDA graph in the most launch-bound phase.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Zhong et al., &lt;em>DistServe: Disaggregating Prefill and Decoding for Goodput-optimized Large Language Model Serving&lt;/em> (OSDI 2024).&lt;/li>
&lt;li>Patel et al., &lt;em>SplitWise: Efficient Generative LLM Inference Using Phase Splitting&lt;/em> (ISCA 2024).&lt;/li>
&lt;li>NVIDIA, &lt;em>NVIDIA Dynamo 1.0: Production-Ready Disaggregated Inference&lt;/em> (GTC 2026, March): &lt;a href="https://developer.nvidia.com/blog/nvidia-dynamo-1-production-ready/">https://developer.nvidia.com/blog/nvidia-dynamo-1-production-ready/&lt;/a>.&lt;/li>
&lt;li>NVIDIA, &lt;em>NIXL: NVIDIA Inference Transfer Library&lt;/em> — official documentation.&lt;/li>
&lt;li>vLLM, &lt;em>Disaggregated Prefilling&lt;/em>: &lt;a href="https://docs.vllm.ai/en/stable/features/disagg_prefill/">https://docs.vllm.ai/en/stable/features/disagg_prefill/&lt;/a>.&lt;/li>
&lt;li>vLLM, &lt;em>NixlConnector Usage Guide&lt;/em>: &lt;a href="https://docs.vllm.ai/en/stable/features/nixl_connector_usage/">https://docs.vllm.ai/en/stable/features/nixl_connector_usage/&lt;/a>.&lt;/li>
&lt;li>Hao AI Lab, &lt;em>Disaggregated Inference: 18 Months Later&lt;/em> (UCSD, 2025) — technical retrospective on the DistServe paper.&lt;/li>
&lt;/ul></description></item><item><title>LLM inference operators on Kubernetes: OME, vLLM Production Stack, NVIDIA Dynamo and llm-d</title><link>https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/</link><pubDate>Mon, 18 May 2026 17:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/llm-inference-operators-kubernetes/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>Serving an LLM in production is not running a binary: it is coordinating a model (tens of gigabytes that take minutes to load), a runtime (vLLM, SGLang, TensorRT-LLM with a hundred flags), heterogeneous GPUs (NVLink, MIG, PCIe), prefill and decode that live better apart, a KV cache that wants offloading to colder tiers, intelligent routing that exploits prefix caching, and autoscaling that reacts to metrics other than CPU%. A flat Kubernetes &lt;code>Deployment&lt;/code> only covers the first 20% of that. The other 80% is covered by LLM inference operators, of which four matter in 2026: &lt;strong>OME&lt;/strong> (LMSYS, July 2025, multi-engine with a focus on SGLang), &lt;strong>vLLM Production Stack&lt;/strong> (a curated Helm chart from vLLM itself with LMCache for tiered KV), &lt;strong>NVIDIA Dynamo&lt;/strong> (the official successor to Triton, multi-engine, with its own Grove scheduler) and &lt;strong>llm-d&lt;/strong> (a CNCF donation of March 2026 by Red Hat + Google + IBM + CoreWeave + NVIDIA, built on vLLM, focused on distributed scale). Behind all four sits &lt;strong>KServe&lt;/strong>, the CNCF parent operator that normalised the &lt;code>InferenceService&lt;/code> concept and that several of them lean on. This article walks through the full hierarchy, gives a decision map and teaches you not to get lost when someone drops seven acronyms in the first meeting.&lt;/p>
&lt;blockquote>
&lt;p>This article closes the LLM inference series. The previous ones were &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory that holds up LLM inference&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes: the LLM inference piece that actually scales&lt;/a> and &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention from the inside and the state of the art of the KV cache in 2026&lt;/a>. There we explained what happens inside one inference process. Here we explain how many inference processes are coordinated through Kubernetes.&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-analogy-from-initd-to-systemd-to-operators">The analogy: from &lt;code>init.d&lt;/code> to systemd to operators&lt;/h2>
&lt;p>Anyone with 20 years of sysadmin behind them will recognise the pattern. Decades ago, starting a service on Linux was a shell script in &lt;code>/etc/init.d/&lt;/code>: start, stop, status, reloaded by hand. When services got more complex, with dependencies between them, monitoring, restart on failure, per-user slots, it became obvious that a script was not enough. Along came systemd, which turned &amp;ldquo;a service&amp;rdquo; into a declarative unit with dependencies, resources, restart policy, sockets and timers. The script did not disappear; it moved up one level of abstraction.&lt;/p>
&lt;p>Kubernetes made the same move for distributed services. A &lt;code>Deployment&lt;/code> declares &amp;ldquo;I want N replicas of this container&amp;rdquo;; a &lt;code>Service&lt;/code> declares &amp;ldquo;these replicas are exposed like this&amp;rdquo;; an &lt;code>Ingress&lt;/code> declares &amp;ldquo;this HTTP traffic comes in here&amp;rdquo;. The controller translates the declaration into real state and keeps the system converged.&lt;/p>
&lt;p>Serving LLMs in 2024 was the equivalent of &lt;code>/etc/init.d/&lt;/code>: every team wrote its own &lt;code>Deployment&lt;/code>/&lt;code>Service&lt;/code>/&lt;code>HPA&lt;/code> with custom scripts for model loading, session draining and GPU handling. We covered it in the &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes article&lt;/a>: it can be done, and it does work, but it is repetitive, fragile and nobody is extracting the right abstractions. Serving LLMs in 2026 has gone through the same transition as services: the equivalent of systemd has appeared, the inference operators, which normalise the abstractions and let the engineer declare what matters: &amp;ldquo;this model, with this runtime, this scalable, with this routing policy&amp;rdquo;.&lt;/p>
&lt;p>There are four relevant operators in 2026 and a fifth common ancestor. Let us take them in order.&lt;/p>
&lt;h2 id="why-an-operator-and-not-just-a-deployment">Why an operator, and not just a Deployment&lt;/h2>
&lt;p>Listing what an inference operator adds over a flat Deployment is the best way to understand what problem it solves:&lt;/p>
&lt;p>&lt;strong>The model as a first-class citizen.&lt;/strong> In a Deployment, the model is &amp;ldquo;the thing you download in an initContainer and mount as a volume&amp;rdquo;. In an operator, the model is a &lt;code>CustomResource&lt;/code> with metadata (origin, fingerprint, licence, GPU requirements). It can be shared between InferenceServices, versioned, replicated to multiple nodes. It is the difference between &amp;ldquo;a file&amp;rdquo; and &amp;ldquo;a managed artifact&amp;rdquo;.&lt;/p>
&lt;p>&lt;strong>The runtime as a first-class citizen.&lt;/strong> The same goes for the runtime (vLLM/SGLang/TRT-LLM): it is not &amp;ldquo;a Docker image with flags&amp;rdquo;; it is a &lt;code>ServingRuntime&lt;/code> that declares which args it accepts, which metrics it exports, which deployment types it supports (single-node, multi-node TP, PD-disag). Changing runtime is changing a reference, not rewriting every manifest.&lt;/p>
&lt;p>&lt;strong>Declarative composition.&lt;/strong> An &lt;code>InferenceService&lt;/code> (the core CRD of KServe and its descendants) references a model and a runtime, declares the scaling policy, wires up observability and configures routing. The controller composes all the pieces: Deployment(s), Service, HPA, possibly a LeaderWorkerSet, a KEDA ScaledObject, a Gateway API HTTPRoute. You declare intent; the operator emits the 8 derived resources.&lt;/p>
&lt;p>&lt;strong>Operational prefill–decode disaggregation.&lt;/strong> As we saw in the PagedAttention article, separating prefill and decode into distinct pools can give 7× goodput. Modelling that with flat Deployments is feasible, but it requires coordinating two sets of pods, a transport to move the KV cache and conditional routing. An operator models it as a single &lt;code>InferenceService&lt;/code> with two sub-pools.&lt;/p>
&lt;p>&lt;strong>Autoscaling on LLM metrics.&lt;/strong> The standard HPA does not understand &lt;code>vllm:num_requests_waiting&lt;/code>. An operator integrates KEDA or Prometheus Adapter automatically and exposes the right metrics as knobs on the CRD.&lt;/p>
&lt;p>&lt;strong>Multi-tenancy.&lt;/strong> Multiple models on the same cluster, with quotas, priorities and fairness. One Deployment per model scaling independently is fine up to the fifth model; beyond that, coordinating GPUs between tenants becomes operationally hostile.&lt;/p>
&lt;p>&lt;strong>Model lifecycle.&lt;/strong> Weights on a shared PVC, warm-up of the first pod, rolling updates with &lt;code>maxUnavailable: 0&lt;/code>, draining of active sessions, integrated observability. Things that with a flat Deployment have to be reinvented by every team.&lt;/p>
&lt;p>If your workload is one model, one node, up to three replicas, a flat Deployment is enough and an operator is overkill. If your workload is two or more models, serious scaling, disaggregation or multi-tenancy, an operator stops being optional.&lt;/p>
&lt;h2 id="kserve-the-common-ancestor">KServe: the common ancestor&lt;/h2>
&lt;p>Before the four new ones, we have to mention &lt;a href="https://kserve.github.io/website/">KServe&lt;/a>, the parent operator from which all the rest conceptually descend. It was born as KFServing inside the Kubeflow project in 2019, was renamed KServe when it became independent in 2021, and in 2025 was &lt;a href="https://thenewstack.io/kserve-joins-cncf-to-standardize-ai-model-serving-on-kubernetes/">accepted into the CNCF&lt;/a> as a project incubating towards graduation.&lt;/p>
&lt;p>KServe&amp;rsquo;s conceptual contribution is the &lt;code>InferenceService&lt;/code> CRD, which has become the field&amp;rsquo;s common vocabulary: a declarative K8s object that joins a &lt;code>model&lt;/code> (origin + metadata) with a &lt;code>predictor&lt;/code> (runtime + resources) and produces a ready HTTP service. Under the hood, the controller emits Deployments, Services, HorizontalPodAutoscalers, Knative Services if you go serverless, Istio VirtualServices if you do traffic splitting.&lt;/p>
&lt;p>KServe was designed in a pre-LLM era: its first use cases were traditional scikit-learn, TensorFlow and PyTorch models served as simple REST APIs. That gives it strengths (it is mature, with 6 years in production at Bloomberg, JPMorgan and others) and weaknesses (it was not designed to manage multi-node tensor parallel, prefill–decode disaggregation, or the patterns specific to LLMs).&lt;/p>
&lt;p>The way the ecosystem has reacted is elegant: the new LLM operators inherit from or take inspiration from &lt;code>InferenceService&lt;/code> but extend the API with LLM-specific primitives. OME is the clearest example: it uses the name &lt;code>InferenceService&lt;/code> and the idea of &amp;ldquo;model + runtime → service&amp;rdquo;, but adds &lt;code>BaseModel&lt;/code>, a &lt;code>ServingRuntime&lt;/code> with LLM-aware flags, and deployment modes (PD-disag, multi-node) that KServe does not cover natively.&lt;/p>
&lt;h2 id="ome-open-model-engine">OME (Open Model Engine)&lt;/h2>
&lt;p>&lt;a href="https://github.com/ome-projects/ome">OME&lt;/a> was published by the LMSYS team in July 2025 (announced on &lt;a href="https://www.lmsys.org/blog/2025-07-08-ome/">their blog&lt;/a>). It is an operator that understands SGLang deeply (that is its first-class runtime) but also supports vLLM, TensorRT-LLM and Triton.&lt;/p>
&lt;h3 id="the-crd-hierarchy">The CRD hierarchy&lt;/h3>
&lt;p>OME models the domain with four main CRDs:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>BaseModel&lt;/code>&lt;/strong> and &lt;strong>&lt;code>ClusterBaseModel&lt;/code>&lt;/strong>: the model itself. It defines origin (Hugging Face, S3, URL), fingerprint and metadata. The &lt;code>Cluster*&lt;/code> version is global; &lt;code>BaseModel&lt;/code> is namespaced. It lets multiple &lt;code>InferenceService&lt;/code> objects reference the same model without duplicating the download.&lt;/li>
&lt;li>&lt;strong>&lt;code>FineTunedWeight&lt;/code>&lt;/strong>: LoRA adapters or finetuned weights served on top of a &lt;code>BaseModel&lt;/code>. Critical for multi-tenant setups where each customer has their own finetune.&lt;/li>
&lt;li>&lt;strong>&lt;code>ServingRuntime&lt;/code>&lt;/strong> and &lt;strong>&lt;code>ClusterServingRuntime&lt;/code>&lt;/strong>: the runtime (vLLM, SGLang, and so on) with its configuration. It declares which args it accepts, which metrics it exports, which deployment modes it supports.&lt;/li>
&lt;li>&lt;strong>&lt;code>InferenceService&lt;/code>&lt;/strong>: the central declarative piece, joining &lt;code>BaseModel&lt;/code> + &lt;code>ServingRuntime&lt;/code> + infrastructure.&lt;/li>
&lt;/ul>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ome.io/v1beta1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">InferenceService&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llama3-70b-prod&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">model&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">meta-llama-3-70b-instruct &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># reference to a BaseModel&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">runtime&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">sglang-h100 &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># reference to a ServingRuntime&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">deploymentMode&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">PrefillDecodeDisaggregated &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># standard | PD | MultiNode | Serverless&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prefill&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">minReplicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">maxReplicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">decode&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">minReplicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">maxReplicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">16&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">router&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cache-aware &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># SGLang router with cache awareness&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">autoscaling&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metricSource&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">keda&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metrics&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prometheus&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metricName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm_requests_waiting&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">threshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;10&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That is what the operator takes as input. The output is roughly 8 derived resources that would be horrible to declare by hand: two LeaderWorkerSets (one per prefill/decode pool), two Services, a Deployment for the router, KEDA ScaledObjects for each pool, a Gateway API HTTPRoute, and a PriorityClass that hooks into Kueue for gang scheduling.&lt;/p>
&lt;h3 id="the-four-deployment-modes">The four deployment modes&lt;/h3>
&lt;p>OME materialises the &lt;code>InferenceService&lt;/code> differently depending on &lt;code>deploymentMode&lt;/code>:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Standard&lt;/strong>: one Deployment with N replicas; the classic. For small or single-GPU models.&lt;/li>
&lt;li>&lt;strong>PrefillDecodeDisaggregated&lt;/strong>: two coordinated pools; the SGLang router routes between them.&lt;/li>
&lt;li>&lt;strong>MultiNode&lt;/strong>: tensor parallel across multiple nodes via LeaderWorkerSet, with NCCL/InfiniBand. For models above 70B where a single node is not enough.&lt;/li>
&lt;li>&lt;strong>Serverless&lt;/strong>: Knative-style scale-to-zero. For sporadic workloads where the cost of keeping GPUs powered up does not pay off. The trade-off: the first request pays the model&amp;rsquo;s cold start cost (minutes).&lt;/li>
&lt;/ul>
&lt;h3 id="integration-with-the-k8s-ecosystem">Integration with the K8s ecosystem&lt;/h3>
&lt;p>OME does not invent primitives where they already exist. It leans on:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://kueue.sigs.k8s.io/">Kueue&lt;/a> for gang scheduling: all the pods of a tensor parallel group have to start at once or not at all; Kueue guarantees it.&lt;/li>
&lt;li>&lt;a href="https://lws.sigs.k8s.io/">LeaderWorkerSet (LWS)&lt;/a> for multi-node: workers join the leader&amp;rsquo;s Ray cluster, with an atomic lifecycle (one going down restarts the group).&lt;/li>
&lt;li>KEDA for autoscaling on LLM-specific Prometheus metrics (queue depth, GPU cache usage, TTFT p95).&lt;/li>
&lt;li>&lt;a href="https://gateway-api.sigs.k8s.io/">Gateway API&lt;/a> and its Inference Extension for advanced routing (model-aware, prefix-aware, weighted canary).&lt;/li>
&lt;/ul>
&lt;p>The consequence: OME feels &amp;ldquo;idiomatically Kubernetes&amp;rdquo;. It does not introduce new concepts where they are not needed; it uses standard primitives and concentrates on what is specific to the LLM domain.&lt;/p>
&lt;h3 id="when-to-choose-it">When to choose it&lt;/h3>
&lt;p>OME is the natural choice if SGLang is your main runtime and/or if you come from the KServe ecosystem and want an idiomatic evolution of it. It is mature but relatively young (a year old at the time of writing); expect rough edges on advanced features.&lt;/p>
&lt;h2 id="vllm-production-stack">vLLM Production Stack&lt;/h2>
&lt;p>&lt;a href="https://github.com/vllm-project/production-stack">vLLM Production Stack&lt;/a> is the official project from vLLM itself for production on Kubernetes. Its philosophy is the opposite of OME&amp;rsquo;s: instead of an operator with new CRDs, it is a curated Helm chart that deploys a coherent set of pieces.&lt;/p>
&lt;h3 id="the-three-pieces">The three pieces&lt;/h3>
&lt;p>The stack has three components:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Serving engines&lt;/strong>: vLLM pods, configured with the flags we have been seeing throughout the series (&lt;code>--enable-prefix-caching&lt;/code>, &lt;code>--kv-cache-dtype fp8&lt;/code>, and so on). The Helm chart lets you declare them as a list; it deploys the underlying Deployments and Services.&lt;/li>
&lt;li>&lt;strong>Request router&lt;/strong>: a proxy in front of the engines that decides which one each request goes to. It supports several policies:
&lt;ul>
&lt;li>&lt;strong>Round-robin&lt;/strong>: trivial, for a baseline.&lt;/li>
&lt;li>&lt;strong>Session-based&lt;/strong>: pins each session to one replica to keep its KV cache.&lt;/li>
&lt;li>&lt;strong>Prefix-aware&lt;/strong>: detects shared prefixes between requests and routes them to the replica that already has them cached.&lt;/li>
&lt;li>&lt;strong>KV-aware&lt;/strong>: sees each replica&amp;rsquo;s &lt;code>gpu_cache_usage_perc&lt;/code> and avoids the saturated ones.&lt;/li>
&lt;li>&lt;strong>Disaggregated-prefill&lt;/strong> with native LMCache: separates prefill and decode, with LMCache as the transport for the KV cache between the two.&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>&lt;strong>Observability stack&lt;/strong>: Prometheus + Grafana with ready-made dashboards. It measures TTFT, TBT (Time-Between-Tokens), throughput, queue depth and GPU memory.&lt;/li>
&lt;/ol>
&lt;h3 id="lmcache-and-tiered-kv">LMCache and tiered KV&lt;/h3>
&lt;p>One of the most interesting pieces the stack brings is &lt;a href="https://github.com/LMCache/LMCache">LMCache&lt;/a>, which adds a KV cache with multiple tiers: GPU HBM as L1, CPU RAM as L2, local disk as L3, and optionally remote storage as L4. When a KV cache block does not fit in HBM, instead of evicting and recomputing it, LMCache drops it to a lower tier. For workloads with shared prefixes and multi-turn conversations, the saving is huge.&lt;/p>
&lt;p>LMCache integrates as a sidecar to the engines and as part of the transport in disaggregated-prefill. Production Stack ships it enabled by default in its Helm chart.&lt;/p>
&lt;h3 id="a-typical-manifest-valuesyaml">A typical manifest (values.yaml)&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">servingEngineSpec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">modelSpec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">llama3-8b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">repository&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm/vllm-openai&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tag&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">v0.6.3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">modelURL&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">meta-llama/Meta-Llama-3-8B-Instruct&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">replicaCount&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requestCPU&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requestMemory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">16Gi&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requestGPU&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">vllmConfig&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enablePrefixCaching&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kvCacheDtype&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">fp8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">maxModelLen&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">32768&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enableChunkedPrefill&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">routerSpec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">routingLogic&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prefix-aware &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># round-robin | session | prefix-aware | kv-aware&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">sessionKey&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">x-user-id &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># when routingLogic=session&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">cacheserverSpec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># LMCache for tiered KV&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">storageBackends&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">cpu&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">disk &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># offload to local disk&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">observabilitySpec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prometheus&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">grafana&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">enabled&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">dashboards&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">vllm-engine-metrics&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">lmcache-metrics&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This is declarative but these are not CRDs: they are Helm chart values. The difference with OME is not semantic (both start from declaration) but operational: with Helm, changes go through &lt;code>helm upgrade&lt;/code>; with CRDs, they go through &lt;code>kubectl apply&lt;/code>. For teams already living in GitOps with Argo CD or Flux, both approaches integrate cleanly, but the flows are different.&lt;/p>
&lt;h3 id="when-to-choose-it-1">When to choose it&lt;/h3>
&lt;p>If vLLM is your only runtime and you want the closest thing to &amp;ldquo;the happy path the project recommends&amp;rdquo;, this one. It is the productionised version maintained by the same people who write the engine. The downsides: it ties you to vLLM (it is not generic) and it does not solve some advanced cases such as multi-tenancy with strict quotas or gang scheduling, where OME or full-fledged operators are better.&lt;/p>
&lt;h2 id="nvidia-dynamo">NVIDIA Dynamo&lt;/h2>
&lt;p>&lt;a href="https://developer.nvidia.com/dynamo">NVIDIA Dynamo&lt;/a> is the official successor to Triton Inference Server, announced at GTC 2025 and merged into the brand as Dynamo-Triton in March of that year. Triton had spent years as the most-used inference engine in &amp;ldquo;serious&amp;rdquo; NVIDIA infrastructures; Dynamo is what NVIDIA believes the new generation needs.&lt;/p>
&lt;h3 id="what-it-is-exactly">What it is exactly&lt;/h3>
&lt;p>Dynamo is a distributed inference framework, not exactly a Kubernetes operator. It has its own runtime (it can run engines), a scheduler (Grove), intelligent routing, multi-tier KV cache management and disaggregation. It supports SGLang, TensorRT-LLM and vLLM as engines, but the engines are executed by Dynamo, not the other way round: the model is &amp;ldquo;Dynamo manages, the engine executes&amp;rdquo;.&lt;/p>
&lt;p>On Kubernetes, Dynamo is deployed via its own operator plus CRDs, normalised with the K8s integration NVIDIA formalised at the end of 2025 (covered by &lt;a href="https://www.infoq.com/news/2025/12/nvidia-dynamo-kubernetes/">this InfoQ piece&lt;/a>). The CRDs are product-specific: they define a &lt;code>DynamoCluster&lt;/code>, a topology of prefill/decode workers and a routing policy.&lt;/p>
&lt;h3 id="the-four-contributions">The four contributions&lt;/h3>
&lt;p>Dynamo is sold on four pillars, with figures reported by NVIDIA:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Disaggregated serving&lt;/strong> built in, with its own scheduler.&lt;/li>
&lt;li>&lt;strong>Smart routing&lt;/strong> based on cache state: if a worker already has most of a prompt cached, the request goes there.&lt;/li>
&lt;li>&lt;strong>Multi-tier KV cache&lt;/strong>: analogous to LMCache, with HBM/RAM/SSD/NVMe.&lt;/li>
&lt;li>&lt;strong>Autoscaling&lt;/strong> integrated with Dynamo&amp;rsquo;s scheduler.&lt;/li>
&lt;/ol>
&lt;p>The marketing figure: up to 30× more throughput than legacy Triton on the same hardware. With all the caution a vendor benchmark deserves.&lt;/p>
&lt;h3 id="grove-its-own-scheduler">Grove: its own scheduler&lt;/h3>
&lt;p>A controversial decision in Dynamo is not to rely 100% on the Kubernetes scheduler and instead bring its own scheduler, called Grove, that understands GPU topologies. Grove decides which worker runs on which physical GPU, which interconnects (NVLink/InfiniBand) are relevant, and how to distribute tensor parallel across nodes. That gives it more control than the standard kube-scheduler.&lt;/p>
&lt;p>Operationally: if your cluster is &amp;ldquo;pure Kubernetes&amp;rdquo; with kube-scheduler and heterogeneous workloads (not only LLMs), Grove adds one more component to operate. If your cluster is dedicated to LLM inference and there is already a team dedicated to running it, Grove gives you more levers.&lt;/p>
&lt;h3 id="when-to-choose-it-2">When to choose it&lt;/h3>
&lt;p>Dynamo makes sense if:&lt;/p>
&lt;ul>
&lt;li>Your infrastructure is NVIDIA-heavy (Hopper, Blackwell, GB200) and you want to exploit the latest in TensorRT-LLM with the familiar Triton integration, modernised.&lt;/li>
&lt;li>You were already a Triton user for legacy inference (vision, recommendation) and want to keep the ecosystem.&lt;/li>
&lt;li>You have a dedicated SRE team for inference and Grove&amp;rsquo;s extra operational complexity is not a problem.&lt;/li>
&lt;/ul>
&lt;p>It is the vendor-specific option of the quartet. In exchange it gives you NVIDIA&amp;rsquo;s support and first-rate integration with their hardware. If your organisation is already fighting NVIDIA for GPUs, they may even call you offering help with Dynamo.&lt;/p>
&lt;h2 id="llm-d">llm-d&lt;/h2>
&lt;p>&lt;a href="https://github.com/llm-d/llm-d">llm-d&lt;/a> is the youngest and the most &amp;ldquo;political&amp;rdquo; of the four. In March 2026, at &lt;a href="https://siliconangle.com/2026/03/24/red-hat-bets-big-kubernetes-inference-llm-d-kubeconeu/">KubeCon Europe Amsterdam&lt;/a>, Red Hat, Google Cloud, IBM Research, CoreWeave and NVIDIA announced the joint donation of the project to the CNCF as a Sandbox project, with backing from AMD, Cisco, Hugging Face, Intel, Lambda, Mistral AI, UC Berkeley and the University of Chicago. An explicit coalition for vendor neutrality.&lt;/p>
&lt;h3 id="philosophy">Philosophy&lt;/h3>
&lt;p>llm-d positions itself as the vendor-neutral &amp;ldquo;Kubernetes blueprint&amp;rdquo; for distributed inference. It is not a runtime; it is a system that sits on top of vLLM (the default engine) and orchestrates the control plane.&lt;/p>
&lt;p>The primitives the project puts on the table:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Intelligent routing&lt;/strong> with prefix-cache awareness and load-aware balancing.&lt;/li>
&lt;li>&lt;strong>Tiered KV cache&lt;/strong> with offload to CPU and disk for multi-turn.&lt;/li>
&lt;li>&lt;strong>Prefill/decode disaggregation&lt;/strong> over fast interconnects.&lt;/li>
&lt;li>&lt;strong>Wide expert parallelism&lt;/strong> for serving very large Mixture-of-Experts (MoE) models, a critical pattern popularised by DeepSeek-V3 and Mixtral, where the experts live on different GPUs and tokens have to be routed to the right expert.&lt;/li>
&lt;/ul>
&lt;h3 id="numbers">Numbers&lt;/h3>
&lt;p>The &lt;a href="https://github.com/llm-d/llm-d/releases">v0.5 release&lt;/a> validates ~3.1k tok/s per decode GPU on B200, and up to 50k output tok/s on a 16×16 B200 prefill/decode topology. The most interesting benchmark: an order-of-magnitude reduction in TTFT against a round-robin baseline. In other words, intelligent routing is worth what they say it is.&lt;/p>
&lt;h3 id="cncf-and-the-future">CNCF and the future&lt;/h3>
&lt;p>Donating to the CNCF as a Sandbox project means neutral governance: no vendor is in charge. For an organisation wary of being tied to a single supplier, llm-d is probably the safest medium-term bet. The price: like any Sandbox project, it is not yet &amp;ldquo;boring&amp;rdquo; in the way vLLM is. There is API churn, features that move around, documentation that lags the code.&lt;/p>
&lt;h3 id="when-to-choose-it-3">When to choose it&lt;/h3>
&lt;p>llm-d makes sense if:&lt;/p>
&lt;ul>
&lt;li>You want multi-vendor portability without ties to NVIDIA, Red Hat or Google.&lt;/li>
&lt;li>Your workload includes large MoE models (DeepSeek-V3, Mixtral 8x22B, Llama 4 Behemoth if the size is confirmed), where wide expert parallelism is decisive.&lt;/li>
&lt;li>Your organisation is already comfortable with CNCF Sandbox (projects under active evolution, not yet a stable 1.0).&lt;/li>
&lt;li>You want to bet on the project that will probably be the de facto standard in 2-3 years.&lt;/li>
&lt;/ul>
&lt;h2 id="the-common-ancestor-is-still-there-kserve">The common ancestor is still there: KServe&lt;/h2>
&lt;p>It is worth reconnecting before the comparison: KServe is still alive and heavily used in organisations that serve both LLMs and traditional models (scikit-learn, XGBoost, PyTorch CV). Its &lt;code>InferenceService&lt;/code> is generic enough to serve any model, including vLLM or SGLang as a &lt;code>ServingRuntime&lt;/code>. What it does not do well is the LLM-specific part: disaggregation, multi-node tensor parallel, routing with KV cache awareness. If your organisation already has KServe in production for other models, adding an LLM-specific operator alongside it (OME, vLLM Stack or llm-d) is reasonable. Fighting it all out from pure KServe is not.&lt;/p>
&lt;h2 id="decision-map">Decision map&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Dimension&lt;/th>
&lt;th>OME&lt;/th>
&lt;th>vLLM Prod Stack&lt;/th>
&lt;th>NVIDIA Dynamo&lt;/th>
&lt;th>llm-d&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Philosophy&lt;/strong>&lt;/td>
&lt;td>Classic K8s-idiomatic operator&lt;/td>
&lt;td>Curated Helm chart&lt;/td>
&lt;td>Framework with its own scheduler&lt;/td>
&lt;td>Vendor-neutral CNCF blueprint&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Own CRDs&lt;/strong>&lt;/td>
&lt;td>Yes (BaseModel, ServingRuntime, InferenceService&amp;hellip;)&lt;/td>
&lt;td>No (Helm values)&lt;/td>
&lt;td>Yes (DynamoCluster)&lt;/td>
&lt;td>Yes (KServe-derived + extensions)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Primary runtime&lt;/strong>&lt;/td>
&lt;td>SGLang (first class), also vLLM/TRT-LLM/Triton&lt;/td>
&lt;td>vLLM only&lt;/td>
&lt;td>TensorRT-LLM (first class), also SGLang/vLLM&lt;/td>
&lt;td>vLLM (first class)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>PD-disaggregation&lt;/strong>&lt;/td>
&lt;td>Yes, declarative&lt;/td>
&lt;td>Yes, with LMCache&lt;/td>
&lt;td>Yes, own scheduler&lt;/td>
&lt;td>Yes, native&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Multi-node TP&lt;/strong>&lt;/td>
&lt;td>Yes, via LWS&lt;/td>
&lt;td>Limited&lt;/td>
&lt;td>Yes, via Grove&lt;/td>
&lt;td>Yes, via LWS and MoE EP&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Multi-model in a cluster&lt;/strong>&lt;/td>
&lt;td>Yes, mature multi-tenant&lt;/td>
&lt;td>Yes (list of models in values)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Multi-LoRA&lt;/strong>&lt;/td>
&lt;td>Yes, first class (FineTunedWeight CRD)&lt;/td>
&lt;td>Limited&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>On the roadmap&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Tiered KV cache&lt;/strong>&lt;/td>
&lt;td>Via LMCache (external integration)&lt;/td>
&lt;td>Native LMCache&lt;/td>
&lt;td>Own multi-tier&lt;/td>
&lt;td>Yes, native&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Intelligent routing&lt;/strong>&lt;/td>
&lt;td>Cache-aware via the SGLang router&lt;/td>
&lt;td>Prefix-aware / KV-aware / session-based&lt;/td>
&lt;td>Own smart routing&lt;/td>
&lt;td>Prefix-cache + load-aware&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>GPU scheduler&lt;/strong>&lt;/td>
&lt;td>kube-scheduler + Kueue&lt;/td>
&lt;td>kube-scheduler&lt;/td>
&lt;td>Grove (its own)&lt;/td>
&lt;td>kube-scheduler + Kueue&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Hardware&lt;/strong>&lt;/td>
&lt;td>NVIDIA, AMD ROCm, Intel&lt;/td>
&lt;td>NVIDIA, AMD ROCm&lt;/td>
&lt;td>NVIDIA only (with emphasis)&lt;/td>
&lt;td>NVIDIA, AMD, Intel — neutral&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Maturity (mid-2026)&lt;/strong>&lt;/td>
&lt;td>Young, evolving&lt;/td>
&lt;td>Stable&lt;/td>
&lt;td>Stable, vendor-driven&lt;/td>
&lt;td>CNCF Sandbox, evolving fast&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Governance&lt;/strong>&lt;/td>
&lt;td>LMSYS (academic-industrial)&lt;/td>
&lt;td>vLLM project (academic)&lt;/td>
&lt;td>NVIDIA (vendor)&lt;/td>
&lt;td>CNCF (neutral)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Learning curve&lt;/strong>&lt;/td>
&lt;td>Medium (4 new CRDs)&lt;/td>
&lt;td>Low (familiar Helm values)&lt;/td>
&lt;td>Medium-high (Grove + own CRDs)&lt;/td>
&lt;td>Medium (similar to extended KServe)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="when-to-choose-each-one">When to choose each one&lt;/h3>
&lt;p>&lt;strong>Choose OME&lt;/strong> if:&lt;/p>
&lt;ul>
&lt;li>SGLang is your main engine.&lt;/li>
&lt;li>You need multi-LoRA serving in production.&lt;/li>
&lt;li>The hierarchical abstraction (BaseModel → ServingRuntime → InferenceService) fits you and you come from, or live alongside, KServe.&lt;/li>
&lt;li>You have an appetite for a young and very active project.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Choose vLLM Production Stack&lt;/strong> if:&lt;/p>
&lt;ul>
&lt;li>vLLM is your only engine and you want to align with what the project recommends.&lt;/li>
&lt;li>Your team already lives in Helm and does not want to learn new CRDs.&lt;/li>
&lt;li>LMCache + advanced routing inside a single Helm chart is exactly what you need.&lt;/li>
&lt;li>Your scale is medium (tens of replicas), not extreme.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Choose NVIDIA Dynamo&lt;/strong> if:&lt;/p>
&lt;ul>
&lt;li>Your infrastructure is NVIDIA-heavy and you want the most optimised path for Hopper/Blackwell.&lt;/li>
&lt;li>You already ran Triton for legacy inference and the transition is natural.&lt;/li>
&lt;li>You accept vendor lock-in in exchange for direct NVIDIA support.&lt;/li>
&lt;li>Your organisation has a dedicated SRE team for inference.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Choose llm-d&lt;/strong> if:&lt;/p>
&lt;ul>
&lt;li>You want to bet on the future CNCF standard, neutral between vendors.&lt;/li>
&lt;li>Your workload includes large MoE models with wide expert parallelism.&lt;/li>
&lt;li>You operate multi-cloud or multi-hardware and portability is valuable.&lt;/li>
&lt;li>You accept the immaturity of a Sandbox project in exchange for the bet on the future.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Choose pure KServe&lt;/strong> if:&lt;/p>
&lt;ul>
&lt;li>You already serve non-LLM models and want to unify; LLMs are a minority of your workload.&lt;/li>
&lt;li>You need the most conservative and mature use case.&lt;/li>
&lt;li>You accept that advanced LLM features (disaggregation, MoE EP, smart routing) are yours to add with external pieces.&lt;/li>
&lt;/ul>
&lt;h3 id="concrete-scenarios">Concrete scenarios&lt;/h3>
&lt;p>&lt;strong>Scenario A — Small startup, 1-2 models, 1-3 GPU nodes.&lt;/strong> You probably do not need an operator. Deployment + Service + HPA with KEDA metrics, as in the vLLM on Kubernetes article. When you grow to 5+ models, reassess.&lt;/p>
&lt;p>&lt;strong>Scenario B — Mid-sized company, 5-15 models, internal multi-tenant.&lt;/strong> vLLM Production Stack or OME are the reasonable options. Production Stack if vLLM is all you are going to use; OME if you want runtime flexibility and idiomatic CRDs.&lt;/p>
&lt;p>&lt;strong>Scenario C — Internal corporate platform or an external service for end customers.&lt;/strong> llm-d or Dynamo. llm-d if you value vendor neutrality; Dynamo if you live on NVIDIA infrastructure and want the path they recommend.&lt;/p>
&lt;p>&lt;strong>Scenario D — Mixed cluster of LLMs plus traditional models.&lt;/strong> KServe as the base, with an LLM operator alongside (OME is the most natural given its conceptual kinship).&lt;/p>
&lt;h2 id="common-traps">Common traps&lt;/h2>
&lt;p>&lt;strong>&amp;ldquo;I&amp;rsquo;ll start with pure KServe because it&amp;rsquo;s mature&amp;rdquo;.&lt;/strong> For mid-sized LLMs and up, pure KServe leaves a lot of optimisation on the table. The sensible route is KServe as the base if you live alongside other models, but with an LLM-specific operator next to it.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;I&amp;rsquo;ll wire everything by hand to understand it&amp;rdquo;.&lt;/strong> Reasonable in a PoC, suicidal in production. There are 8 derived resources per model. Multiply by 10 models. You are writing 80 YAMLs and maintaining them. Use an operator.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;I&amp;rsquo;ll pick the one I like best and pivot if I&amp;rsquo;m wrong&amp;rdquo;.&lt;/strong> Pivoting between operators is not free: although the &lt;code>InferenceService&lt;/code> abstraction is converging, the details (how LoRA is modelled, how routing is configured, how metrics are exposed) vary. Migrating from OME to Dynamo is a project of weeks, not days.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;I&amp;rsquo;ll go with Dynamo because it&amp;rsquo;s NVIDIA&amp;rsquo;s and therefore better&amp;rdquo;.&lt;/strong> Only if your organisation is already aligned with its operational philosophy (its own scheduler, acceptable vendor lock-in). For many cases, vLLM Production Stack or llm-d give 95% of the value with less friction.&lt;/p>
&lt;p>&lt;strong>&amp;ldquo;Helm chart vs operator is a technical decision&amp;rdquo;.&lt;/strong> It is a cultural and operational decision. If your team ships via Argo CD with Helm values in Git, Production Stack fits without friction. If your team lives in direct &lt;code>kubectl apply -f&lt;/code> and the idea of operators feels natural, OME or llm-d.&lt;/p>
&lt;h2 id="what-we-have-not-covered">What we have not covered&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>&lt;a href="https://github.com/kvcache-ai/Mooncake">Mooncake&lt;/a>&lt;/strong>: the KV cache system shared between instances that Kimi/Moonshot runs in production across hundreds of millions of queries. It is a primitive (not a complete operator), but it integrates as a cache tier with several of the above.&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://docs.ray.io/en/latest/serve/llm/serving-llms.html">Ray Serve LLM&lt;/a>&lt;/strong>: Anyscale&amp;rsquo;s offering, on Kubernetes through KubeRay. More tied to the Ray ecosystem than to native K8s CRDs. Useful if Ray is already part of your infrastructure.&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://fireworks.ai/">Fireworks AI&lt;/a>, &lt;a href="https://www.modular.com/">Modular MAX&lt;/a>&lt;/strong>: commercial platforms with similar primitives, but hosted. They are not K8s operators; they are competitors at another layer.&lt;/li>
&lt;li>&lt;strong>Gateway API Inference Extension&lt;/strong>: the sigwg proposal to extend Gateway API with LLM primitives (model-aware routing, sticky sessions, fairness). In 2026 it is in alpha; the operators above are already starting to support it. When it matures, routing will stop being each operator&amp;rsquo;s problem and become part of the Kubernetes standard.&lt;/li>
&lt;li>&lt;strong>A generic inference observability stack&lt;/strong>: Prometheus + Grafana is standardising around the &lt;code>vllm:*&lt;/code> metrics we covered in the vLLM article. There is an OpenTelemetry effort for LLMs (&lt;code>gen-ai&lt;/code> semantic conventions) that is probably the next link in the chain.&lt;/li>
&lt;/ul>
&lt;h2 id="closing-the-series">Closing the series&lt;/h2>
&lt;p>This series of four articles has walked through LLM inference in production from the bottom up:&lt;/p>
&lt;ol>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory that holds up LLM inference&lt;/a> — why each token consumes VRAM, and how much.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes: the LLM inference piece that actually scales&lt;/a> — how a model is served in production with a serious Deployment.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention from the inside and the state of the art of the KV cache in 2026&lt;/a> — what happens inside the engine at block level, and what has arrived since.&lt;/li>
&lt;li>This one — how many models are orchestrated in a cluster.&lt;/li>
&lt;/ol>
&lt;p>If you have got this far, you have the vocabulary and the map to sit in a meeting where five people throw acronyms around and place each one correctly. And, more importantly, to start making reasoned decisions about where to begin.&lt;/p>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/kserve-open-inference-protocol-control-plane/">Chain of trust for the model (1/4): KServe and the Open Inference Protocol&lt;/a> — the KServe deep dive, the common ancestor this post cites: InferenceService, ServingRuntime, deployment modes and the V2 API contract against the OpenAI API.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/model-chain-of-trust-where-the-bytes-live-registry-oci-artefacts/">Chain of trust for the model (2/4): registry, OCI artifacts and distribution&lt;/a> — where the &lt;code>storageUri&lt;/code> of any of these operators gets its weights from.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;p>Operators and projects covered:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://github.com/ome-projects/ome">OME — Open Model Engine (GitHub)&lt;/a> — LMSYS operator for LLM serving with SGLang/vLLM/TRT-LLM/Triton.&lt;/li>
&lt;li>&lt;a href="https://www.lmsys.org/blog/2025-07-08-ome/">Introducing OME (LMSYS Blog, jul 2025)&lt;/a> — announcement and architecture.&lt;/li>
&lt;li>&lt;a href="https://github.com/vllm-project/production-stack">vLLM Production Stack (GitHub)&lt;/a> — official vLLM Helm chart for K8s.&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/projects/production-stack/en/latest/deployment/">vLLM Production Stack docs&lt;/a> — installation and configuration.&lt;/li>
&lt;li>&lt;a href="https://github.com/LMCache/LMCache">LMCache (GitHub)&lt;/a> — KV cache with tiers.&lt;/li>
&lt;li>&lt;a href="https://developer.nvidia.com/dynamo">NVIDIA Dynamo&lt;/a> — successor to Triton.&lt;/li>
&lt;li>&lt;a href="https://www.infoq.com/news/2025/12/nvidia-dynamo-kubernetes/">NVIDIA Dynamo Addresses Multi-Node LLM Inference Challenges (InfoQ, dic 2025)&lt;/a> — K8s integration.&lt;/li>
&lt;li>&lt;a href="https://github.com/llm-d/llm-d">llm-d (GitHub)&lt;/a> — CNCF Sandbox project.&lt;/li>
&lt;li>&lt;a href="https://thenewstack.io/llm-d-cncf-kubernetes-inference/">IBM, Red Hat, and Google donated llm-d to CNCF (The New Stack)&lt;/a> — KubeCon EU 2026 announcement.&lt;/li>
&lt;li>&lt;a href="https://siliconangle.com/2026/03/24/red-hat-bets-big-kubernetes-inference-llm-d-kubeconeu/">Red Hat bets big on Kubernetes inference with llm-d (SiliconANGLE, mar 2026)&lt;/a> — coverage of the announcement.&lt;/li>
&lt;/ul>
&lt;p>Ancestors and primitives:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://kserve.github.io/website/">KServe (site)&lt;/a> and &lt;a href="https://thenewstack.io/kserve-joins-cncf-to-standardize-ai-model-serving-on-kubernetes/">KServe joins CNCF (The New Stack)&lt;/a>.&lt;/li>
&lt;li>&lt;a href="https://kueue.sigs.k8s.io/">Kueue&lt;/a> — gang scheduling.&lt;/li>
&lt;li>&lt;a href="https://lws.sigs.k8s.io/">LeaderWorkerSet&lt;/a> — coordinated workloads such as multi-pod tensor parallel.&lt;/li>
&lt;li>&lt;a href="https://keda.sh/">KEDA&lt;/a> — autoscaling on external metrics.&lt;/li>
&lt;li>&lt;a href="https://gateway-api.sigs.k8s.io/">Gateway API&lt;/a> — the successor to Ingress.&lt;/li>
&lt;/ul>
&lt;p>Analysis and perspectives:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://jimmysong.io/blog/cloud-native-llm-inference-stack/">Building Efficient LLM Inference with the Cloud Native Quartet: KServe, vLLM, llm-d, and WG Serving (Jimmy Song)&lt;/a> — an integrating view.&lt;/li>
&lt;li>&lt;a href="https://dev.to/x4nent/complete-guide-to-llm-d-cncf-sandbox-kubernetes-native-distributed-llm-inference-1imj">Complete Guide to llm-d CNCF Sandbox (DEV Community)&lt;/a> — an operational walkthrough.&lt;/li>
&lt;li>Previous articles on this blog: &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes&lt;/a>, &lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">PagedAttention deep dive&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>vLLM on Kubernetes: the LLM inference piece that actually scales</title><link>https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/</link><pubDate>Mon, 18 May 2026 13:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>vLLM is the inference engine that turns a general-purpose GPU into a productive LLM server. Its value is not in running a model, since any &lt;code>transformers.pipeline&lt;/code> does that in three lines of Python, but in squeezing the GPU down to the last gigabyte and the last cycle: PagedAttention for the KV cache, &lt;em>continuous batching&lt;/em> to mix requests, its own scheduler to share GPU time between sessions. Kubernetes is its natural habitat because vLLM behaves like a modern UNIX process, with a health endpoint, Prometheus metrics, orderly draining and declarable resources, and K8s already knows how to manage those. But there are traps: the standard HPA does not scale vLLM well, the model takes minutes to load, and naive rolling updates cut sessions halfway through decoding. This article takes the engine apart and then fits it, with real manifests, into a cluster that can actually serve it.&lt;/p>
&lt;blockquote>
&lt;p>This article is the natural continuation of &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory that holds up LLM inference&lt;/a>. There we explained why every token consumes VRAM. Here we look at what you do with that VRAM when you want to offer it as a service.&lt;/p>
&lt;/blockquote>
&lt;h2 id="the-analogy-a-multiprocess-kernel-for-your-gpu">The analogy: a multiprocess kernel for your GPU&lt;/h2>
&lt;p>Imagine you have a single processor and need to serve a hundred concurrent processes without any of them blocking the rest. Nobody in their right mind would write a &lt;code>while-true&lt;/code> loop dispatching processes one by one: they would install an operating system. The kernel takes care of scheduling, memory paging, isolation, priorities and cleanup on exit. The &amp;ldquo;process&amp;rdquo; becomes a convenient abstraction and the kernel does the dirty work.&lt;/p>
&lt;p>vLLM is, for your GPU, what the kernel is for your CPU. As far as the GPU is concerned, a conversation with an LLM is a process that lives across many decoding steps, occupies a slice of VRAM (its KV cache) and demands compute time every time a token has to be generated. You have a hundred of those processes at once. You need to:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Share GPU time between them&lt;/strong> without pausing any of them entirely (it would be disastrous if one long conversation monopolised the GPU).&lt;/li>
&lt;li>&lt;strong>Manage memory with paging&lt;/strong> because, just as in RAM, contiguous allocation is inefficient.&lt;/li>
&lt;li>&lt;strong>Queue new requests&lt;/strong> when the GPU is saturated and serve them in a reasonable order.&lt;/li>
&lt;li>&lt;strong>Reclaim resources&lt;/strong> when a session ends.&lt;/li>
&lt;/ul>
&lt;p>PagedAttention is the virtual memory of the KV cache. &lt;em>Continuous batching&lt;/em> is the time-slicing scheduler that shares the GPU token by token. The OpenAI-compatible server is the uniform syscall interface. Calling it a &amp;ldquo;kernel&amp;rdquo; for the GPU is marketing, but it is marketing that captures the idea well.&lt;/p>
&lt;h2 id="what-vllm-does-inside">What vLLM does inside&lt;/h2>
&lt;h3 id="continuous-batching-stop-waiting-for-the-slowest">Continuous batching: stop waiting for the slowest&lt;/h3>
&lt;p>The naive inference engine does &lt;em>static batching&lt;/em>: it groups N requests, processes them until all of them are finished, returns and starts another round. The problem is obvious: if one request asks for 8 tokens and another asks for 800, the other seven wait for the slow one. GPU utilisation collapses.&lt;/p>
&lt;p>&lt;em>Continuous batching&lt;/em> (Yu et al., 2022, popularised by vLLM) changes the model. At every decode step, which produces one token for each active session, the engine composes the batch out of the active tokens of ALL sessions alive at that instant. When a session finishes its generation it releases its slot immediately and another request from the queue takes it. The batch never waits for the slowest session because nobody is blocked: everyone advances at one token per step.&lt;/p>
&lt;p>The original paper measured 5–23× more throughput than the equivalent static batching. The exact number depends on how variable the response lengths are, but the order of magnitude holds up in practice.&lt;/p>
&lt;div class="diagram" style="max-width: 720px; margin: 1.5rem auto;">
&lt;svg viewBox="0 0 720 240" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Static vs continuous batching">
&lt;style>.title{font:600 13px sans-serif;fill:#222}.lbl{font:11px sans-serif;fill:#444}.s1{fill:#2a9d8f}.s2{fill:#e76f51}.s3{fill:#264653}.s4{fill:#e9c46a}.empty{fill:#eee;stroke:#999;stroke-dasharray:3 2}&lt;/style>
&lt;text x="180" y="20" text-anchor="middle" class="title">Static batching&lt;/text>
&lt;text x="540" y="20" text-anchor="middle" class="title">Continuous batching&lt;/text>
&lt;text x="20" y="55" class="lbl">session 1&lt;/text>
&lt;text x="20" y="80" class="lbl">session 2&lt;/text>
&lt;text x="20" y="105" class="lbl">session 3&lt;/text>
&lt;text x="20" y="130" class="lbl">session 4&lt;/text>
&lt;rect x="70" y="40" width="40" height="20" class="s1"/>
&lt;rect x="70" y="65" width="120" height="20" class="s2"/>
&lt;rect x="70" y="90" width="60" height="20" class="s3"/>
&lt;rect x="70" y="115" width="30" height="20" class="s4"/>
&lt;rect x="110" y="40" width="80" height="20" class="empty"/>
&lt;rect x="130" y="90" width="60" height="20" class="empty"/>
&lt;rect x="100" y="115" width="90" height="20" class="empty"/>
&lt;text x="180" y="160" text-anchor="middle" class="lbl">empty slots wait for session 2&lt;/text>
&lt;rect x="380" y="40" width="40" height="20" class="s1"/>
&lt;rect x="420" y="40" width="80" height="20" class="s3"/>
&lt;rect x="500" y="40" width="40" height="20" class="s4"/>
&lt;rect x="540" y="40" width="40" height="20" class="s1"/>
&lt;rect x="380" y="65" width="120" height="20" class="s2"/>
&lt;rect x="500" y="65" width="40" height="20" class="s3"/>
&lt;rect x="540" y="65" width="80" height="20" class="s4"/>
&lt;rect x="380" y="90" width="60" height="20" class="s3"/>
&lt;rect x="440" y="90" width="50" height="20" class="s2"/>
&lt;rect x="490" y="90" width="40" height="20" class="s4"/>
&lt;rect x="530" y="90" width="100" height="20" class="s1"/>
&lt;rect x="380" y="115" width="30" height="20" class="s4"/>
&lt;rect x="410" y="115" width="80" height="20" class="s2"/>
&lt;rect x="490" y="115" width="60" height="20" class="s3"/>
&lt;rect x="550" y="115" width="80" height="20" class="s1"/>
&lt;text x="540" y="160" text-anchor="middle" class="lbl">slots reassigned token by token&lt;/text>
&lt;line x1="70" y1="190" x2="630" y2="190" stroke="#666"/>
&lt;text x="350" y="210" text-anchor="middle" class="lbl">time →&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>The consequence for the operator is counterintuitive: &lt;strong>a single vLLM replica performs like three naive replicas&lt;/strong>. There is no point adding pods without justifying it with real metrics.&lt;/p>
&lt;h3 id="pagedattention-virtual-memory-for-the-kv-cache">PagedAttention: virtual memory for the KV cache&lt;/h3>
&lt;p>We already flagged it in the KV cache article: the naive engine reserves one contiguous block per session, sized for the &lt;em>worst case&lt;/em> (&lt;code>max_context_len&lt;/code>), and wastes 60–80% of the VRAM because real sessions come nowhere near their ceiling.&lt;/p>
&lt;p>PagedAttention borrows the solution operating systems have been using for half a century: split the VRAM into small blocks (16 tokens in the default implementation) and keep a logical → physical page table per session. A session with 273 tokens of context occupies 18 blocks (not necessarily contiguous), and grows block by block as it generates. The paper measured &amp;lt;4% waste, an order of magnitude better than contiguous allocation, and that translates into 2–4× more aggregate throughput on the same hardware, because more sessions fit at once.&lt;/p>
&lt;p>There is a cost: every attention operation has to be indirected through the page table. But vLLM&amp;rsquo;s CUDA kernels are written so that this indirection is cheap, and the net result is massively positive.&lt;/p>
&lt;h3 id="prefill-vs-decode-two-phases-with-opposite-profiles">Prefill vs decode: two phases with opposite profiles&lt;/h3>
&lt;p>An LLM request has two phases with radically different GPU profiles:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Prefill&lt;/strong>: processes the entire prompt in one go. It is &lt;em>compute-bound&lt;/em>: it uses the tensor cores intensively, the GPU sits above 90%, and it lasts between hundreds of ms and a few seconds depending on prompt size.&lt;/li>
&lt;li>&lt;strong>Decode&lt;/strong>: generates token by token. It is &lt;em>memory-bound&lt;/em>: the compute is modest but the whole KV cache has to be read for every token, and it lasts from tens of ms per token up to minutes for long responses.&lt;/li>
&lt;/ul>
&lt;p>A naive server treats each request as a unit and serves the two phases in series. vLLM decouples them: it mixes requests in prefill with requests in decode in the same step (a technique called &lt;em>chunked prefill&lt;/em> when it also splits long prefills into chunks). The result: the GPU is always busy doing &lt;em>something&lt;/em>, the tensor cores with prefills, the HBM bandwidth with decodes, instead of oscillating between phases.&lt;/p>
&lt;p>Operational implication: the &amp;ldquo;% GPU utilisation&amp;rdquo; metric from &lt;code>nvidia-smi&lt;/code> is misleading. A GPU at 100% doing prefills may have its HBM bandwidth idle. A GPU at 40% doing decodes may have its HBM saturated. For LLM serving, &lt;strong>the useful metric is effective HBM bandwidth&lt;/strong>, not the compute percentage.&lt;/p>
&lt;h3 id="tensor-parallel-when-the-model-does-not-fit-on-one-gpu">Tensor parallel: when the model does not fit on one GPU&lt;/h3>
&lt;p>Llama 3 70B in BF16 is ~140 GB. There is no single GPU on the market that can hold it. The solution is tensor parallel: split each model layer by columns and run the partitions on N GPUs in parallel, synchronising with an &lt;em>all-reduce&lt;/em> after each layer.&lt;/p>
&lt;p>For N=5 GPUs and a 70B model, each GPU sees roughly 28 GB of weights. That sounds fine until you remember that the all-reduce on every layer means reading and writing large tensors between GPUs. If the GPUs share NVLink/NVSwitch (300–900 GB/s), the all-reduce is cheap. If they only share PCIe (~32 GB/s gen4 x16), the all-reduce eats half the time and throughput collapses.&lt;/p>
&lt;p>The implication for K8s, which comes next: the scheduler has to guarantee that the N GPUs are physically close. That translates into NodeAffinity to the right product (&lt;code>NVIDIA-H100-80GB-HBM3&lt;/code>), a single pod with &lt;code>nvidia.com/gpu: N&lt;/code> (not N pods sharing) and, if multi-node is unavoidable, InfiniBand with NCCL as transport.&lt;/p>
&lt;h3 id="the-openai-compatible-server">The OpenAI-compatible server&lt;/h3>
&lt;p>On top of all of the above, vLLM exposes an HTTP server with endpoints identical to OpenAI&amp;rsquo;s: &lt;code>/v1/chat/completions&lt;/code>, &lt;code>/v1/completions&lt;/code>, &lt;code>/v1/embeddings&lt;/code>, &lt;code>/v1/models&lt;/code>. It supports Server-Sent Events streaming. It supports tool calling. It supports logprobs.&lt;/p>
&lt;p>The value of this is enormous and underrated: &lt;strong>any client using the OpenAI SDK works without changes&lt;/strong>. Your application points at &lt;code>https://vllm.your-cluster.local/v1&lt;/code> instead of &lt;code>https://api.openai.com/v1&lt;/code>, and everything else, the LangChain, LlamaIndex, OpenAI Python and OpenAI JS SDKs, just works. It is the main reason vLLM has gained traction over technically comparable alternatives: &lt;strong>it is the boring option that works&lt;/strong>.&lt;/p>
&lt;h2 id="why-kubernetes-is-the-natural-habitat">Why Kubernetes is the natural habitat&lt;/h2>
&lt;p>vLLM is a well-behaved process: it starts, exposes metrics, serves a health endpoint, takes SIGTERM with dignity and declares the resources it needs. Kubernetes has spent ten years perfecting the management of processes like that. The only thing K8s took a while to absorb properly was the GPU, and that is now solved.&lt;/p>
&lt;h3 id="the-gpu-as-a-primitive-resource">The GPU as a primitive resource&lt;/h3>
&lt;p>The plumbing goes like this:&lt;/p>
&lt;ol>
&lt;li>The node has the NVIDIA driver installed (or the GPU Operator installs it).&lt;/li>
&lt;li>A DaemonSet, nvidia-device-plugin, registers the physical GPUs as &lt;code>nvidia.com/gpu&lt;/code> resources with kubelet.&lt;/li>
&lt;li>The Kubernetes scheduler sees those resources the way it sees CPU and memory, puts them in its accounting and assigns them to Pods that ask for them.&lt;/li>
&lt;li>The nvidia-container-toolkit makes sure containerd injects the right devices into the container at startup.&lt;/li>
&lt;/ol>
&lt;p>For the pod, asking for a GPU looks like this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Without MIG, MPS or time-slicing configured, &lt;strong>a GPU is not shared between pods&lt;/strong>: you ask for it whole or you do not ask for it. For vLLM, which wants the entire GPU to itself, that is exactly what you want.&lt;/p>
&lt;h3 id="the-vllm-pod-lifecycle">The vLLM Pod lifecycle&lt;/h3>
&lt;p>Differences from a typical webapp Pod:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Long startup&lt;/strong>. Loading 16 GB of weights into VRAM over the network takes 30 seconds in the best case and 5 minutes in the worst. A &lt;code>readinessProbe&lt;/code> with &lt;code>initialDelaySeconds: 30&lt;/code> and &lt;code>failureThreshold: 3&lt;/code> kills the pod before it starts. Solution: a &lt;code>startupProbe&lt;/code> with a high threshold before the &lt;code>livenessProbe&lt;/code> starts evaluating.&lt;/li>
&lt;li>&lt;strong>Warm-up matters&lt;/strong>. The first prefill compiles CUDA kernels specific to the input shape. The first 2–3 requests are noticeably slower. If latency matters from second one, it is worth firing a warm-up POST after ready.&lt;/li>
&lt;li>&lt;strong>Draining is not instantaneous&lt;/strong>. SIGTERM must not kill in-flight sessions. vLLM, configured with &lt;code>--disable-graceful-shutdown false&lt;/code> (the default), finishes active requests before closing. That can take 30–180 seconds. &lt;code>terminationGracePeriodSeconds&lt;/code> has to accommodate it.&lt;/li>
&lt;li>&lt;strong>Hostile rollouts&lt;/strong>. A naive rolling update (&lt;code>maxUnavailable: 1&lt;/code>) can leave you with no replicas serving if the new one is slow to load. Set &lt;code>maxSurge: 1, maxUnavailable: 0&lt;/code> so the new pod is Ready before the old one is drained.&lt;/li>
&lt;/ul>
&lt;h2 id="anatomy-of-a-serious-deployment">Anatomy of a serious deployment&lt;/h2>
&lt;h3 id="first-of-all-gpu-operator">First of all: GPU Operator&lt;/h3>
&lt;p>Without the GPU Operator (or an equivalent manual installation), a Pod with &lt;code>nvidia.com/gpu: 1&lt;/code> stays Pending forever. What the operator installs as DaemonSets on every GPU node:&lt;/p>
&lt;ul>
&lt;li>&lt;code>nvidia-driver-daemonset&lt;/code> — the kernel-mode driver (if you do not have it installed at host level).&lt;/li>
&lt;li>&lt;code>nvidia-device-plugin-daemonset&lt;/code> — registers the GPUs as a kubelet resource.&lt;/li>
&lt;li>&lt;code>nvidia-container-toolkit-daemonset&lt;/code> — the containerd integration.&lt;/li>
&lt;li>&lt;code>nvidia-dcgm-exporter&lt;/code> — Prometheus metrics for the GPU (utilisation, temperature, ECC errors, memory).&lt;/li>
&lt;li>&lt;code>gpu-feature-discovery&lt;/code> — node labels: &lt;code>nvidia.com/gpu.product&lt;/code>, &lt;code>nvidia.com/gpu.memory&lt;/code>, and so on, essential for NodeAffinity.&lt;/li>
&lt;/ul>
&lt;p>The recommended installation is the official Helm chart. The delicate part is aligning the driver with the host kernel version: if the nodes run kernel 6.x, the operator needs a compatible driver branch.&lt;/p>
&lt;h3 id="a-complete-annotated-vllm-deployment">A complete, annotated vLLM Deployment&lt;/h3>
&lt;p>The following deploys Llama 3 8B with an FP8-quantised KV cache, up to 32K of context, on an RTX 4090. It is the reference manifest; the comments explain the non-obvious decisions.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">apps/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Deployment&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama3-8b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">replicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">strategy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">RollingUpdate&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">rollingUpdate&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">maxSurge&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">maxUnavailable&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># never run out of replicas during the rollout&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">matchLabels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama3-8b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">template&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">labels&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama3-8b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">annotations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prometheus.io/scrape&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;true&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prometheus.io/port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;8000&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prometheus.io/path&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;/metrics&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># Only nodes with the GPU we expect&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nodeSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu.product&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">NVIDIA-GeForce-RTX-4090&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tolerations&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">nvidia.com/gpu&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">operator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Exists&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># Pre-download the weights if they are not on the shared PVC&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">initContainers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">model-download&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ghcr.io/huggingface/huggingface-cli:latest&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">command&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;sh&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;-c&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> if [ ! -f /models/llama-3-8b/config.json ]; then
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> huggingface-cli download meta-llama/Meta-Llama-3-8B-Instruct \
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> --local-dir /models/llama-3-8b --local-dir-use-symlinks False
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> fi&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">HF_TOKEN&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">valueFrom&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">secretKeyRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">huggingface&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">key&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">token&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumeMounts&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">models&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mountPath&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/models&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm/vllm-openai:v0.6.3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">model=/models/llama-3-8b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">served-model-name=llama-3-8b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">tensor-parallel-size=1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">max-model-len=32768&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">kv-cache-dtype=fp8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">enable-chunked-prefill&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">enable-prefix-caching&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">gpu-memory-utilization=0.92&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">port=8000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containerPort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">metrics&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containerPort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># same port as http; /metrics&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">resources&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">requests&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;4&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">8Gi&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">limits&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;8&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">memory&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">16Gi&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">startupProbe&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">httpGet&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">path&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/health&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">periodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">failureThreshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">60&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 10 min of grace to load the model&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">readinessProbe&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">httpGet&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">path&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/health&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">periodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">5&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">livenessProbe&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">httpGet&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">path&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/health&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">periodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">20&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">failureThreshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumeMounts&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">models&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mountPath&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/models&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">readOnly&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># no process should write here at runtime&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">shm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">mountPath&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">/dev/shm &lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># vLLM uses shared memory for IPC between workers&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">volumes&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">models&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">persistentVolumeClaim&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">claimName&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">model-cache&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">shm&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">emptyDir&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">medium&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Memory&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">sizeLimit&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">4Gi&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">terminationGracePeriodSeconds&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">120&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># accommodates draining of active sessions&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nn">---&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Service&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama3-8b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">selector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">app&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama3-8b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">ports&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">80&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">targetPort&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Five things that are not visible on a first read:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>&lt;code>/dev/shm&lt;/code> in memory, 4 GB&lt;/strong>. vLLM launches worker processes (one per GPU in tensor parallel, plus the driver) that communicate over shared memory. Docker&amp;rsquo;s default (64 MB) blows up as soon as the model is mid-sized. Without this, the pod starts but fails the moment it serves the first complex request.&lt;/li>
&lt;li>&lt;strong>&lt;code>--enable-prefix-caching&lt;/code>&lt;/strong>. If the prompts in your workload share structure (a common system prompt, few-shot examples), vLLM reuses the KV cache of the shared part. A free 30–60% gain in TTFT.&lt;/li>
&lt;li>&lt;strong>&lt;code>--gpu-memory-utilization=0.92&lt;/code>&lt;/strong>. vLLM reserves the given percentage of the VRAM for itself. The remaining 8% leaves room for activations, CUDA kernels and the overhead that does not get counted. Lowering it buys safety; raising it above 0.95 invites OOM.&lt;/li>
&lt;li>&lt;strong>A &lt;code>ReadOnlyMany&lt;/code> PVC&lt;/strong> is ideal. The model does not change at runtime. Several pods can mount the same PVC without contention.&lt;/li>
&lt;li>&lt;strong>No &lt;code>livenessProbe&lt;/code> shorter than the &lt;code>terminationGracePeriodSeconds&lt;/code>&lt;/strong>. If a drain takes 90s and liveness kills at 60s, rollouts lose sessions.&lt;/li>
&lt;/ol>
&lt;h3 id="multi-pod-tensor-parallel-leaderworkerset">Multi-pod tensor parallel: LeaderWorkerSet&lt;/h3>
&lt;p>When the model needs more GPUs than a single node has, the pattern is a group of coordinated pods, one per GPU, that behave as a single replica. For years this was modelled with a StatefulSet plus init scripts; since Kubernetes 1.32, the idiomatic primitive is LeaderWorkerSet (LWS):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">leaderworkerset.x-k8s.io/v1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">LeaderWorkerSet&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama3-70b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">replicas&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">leaderWorkerTemplate&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">size&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">5&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 1 leader + 4 workers = 5 pods, 5 GPUs&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">restartPolicy&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">RecreateGroupOnPodRestart&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">leaderTemplate&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nodeSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu.product&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">NVIDIA-H100-80GB-HBM3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-leader&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm/vllm-openai:v0.6.3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">args&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">model=/models/llama-3-70b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">tensor-parallel-size=5&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- --&lt;span class="l">distributed-executor-backend=ray&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># ...&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">workerTemplate&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nodeSelector&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nvidia.com/gpu.product&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">NVIDIA-H100-80GB-HBM3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">containers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-worker&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm/vllm-openai:v0.6.3&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># the workers join the leader&amp;#39;s Ray cluster&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>LWS guarantees startup order (workers first, leader afterwards) and an atomic lifecycle (if a worker goes down, the whole group restarts, not a single pod). Without it, the coordination is manually fragile.&lt;/p>
&lt;p>A simpler alternative, if all the tensor parallel GPUs fit on a single node (the case with HGX H100 boxes with 8 GPUs and an internal NVSwitch): one single Pod with &lt;code>nvidia.com/gpu: 5&lt;/code>, &lt;code>--tensor-parallel-size=5&lt;/code>, and vLLM handles everything internally. No Ray, no LWS, far simpler. It is the recommended path whenever it is available.&lt;/p>
&lt;h3 id="autoscaling-the-standard-hpa-is-no-use">Autoscaling: the standard HPA is no use&lt;/h3>
&lt;p>An HPA on CPU% is useless for vLLM. The GPU does the work; the pod&amp;rsquo;s CPU sits at 5–10% even under maximum load. Nor is the GPU utilisation percentage from &lt;code>dcgm-exporter&lt;/code> any use: a pod at 100% GPU% with &lt;code>gpu_cache_usage_perc=15%&lt;/code> is serving one long session without being saturated, while a pod at 60% GPU% with &lt;code>gpu_cache_usage_perc=95%&lt;/code> is on the edge of evicting sessions.&lt;/p>
&lt;p>The right metrics are exported by vLLM itself at &lt;code>/metrics&lt;/code> (Prometheus format):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>What it says&lt;/th>
&lt;th>When to scale&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>vllm:num_requests_waiting&lt;/code>&lt;/td>
&lt;td>Requests queued and not yet in the batch.&lt;/td>
&lt;td>If it stays above 5–10.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:num_requests_running&lt;/code>&lt;/td>
&lt;td>Active requests in the batch.&lt;/td>
&lt;td>For capacity planning, not for scaling.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:gpu_cache_usage_perc&lt;/code>&lt;/td>
&lt;td>% of the KV cache occupied.&lt;/td>
&lt;td>If sustained above 80%, there is a risk of preemption.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:time_to_first_token_seconds&lt;/code>&lt;/td>
&lt;td>Prefill latency (histogram).&lt;/td>
&lt;td>If p95 exceeds your SLA.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vllm:e2e_request_latency_seconds&lt;/code>&lt;/td>
&lt;td>Total latency per request.&lt;/td>
&lt;td>An output metric.&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>For the HPA to consume them there are two routes: Prometheus Adapter (exposes custom metrics to the K8s API) or KEDA (scales on Prometheus queries directly, far more convenient). With KEDA:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">apiVersion&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">keda.sh/v1alpha1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">kind&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ScaledObject&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-scaler&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">namespace&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">inference&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">spec&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">scaleTargetRef&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">vllm-llama3-8b&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">minReplicaCount&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">maxReplicaCount&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">pollingInterval&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">10&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cooldownPeriod&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">120&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="c"># 2 min before scale-down (long sessions)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">triggers&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">prometheus&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">metadata&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">serverAddress&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">http://prometheus.monitoring:9090&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">threshold&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;5&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">query&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> sum(vllm:num_requests_waiting{app=&amp;#34;vllm-llama3-8b&amp;#34;})&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The long &lt;code>cooldownPeriod&lt;/code> matters: if you drop replicas while there are sessions decoding, you kill them. Better to leave 2 minutes of slack.&lt;/p>
&lt;h3 id="observability-the-four-metrics-that-matter">Observability: the four metrics that matter&lt;/h3>
&lt;p>Of everything &lt;code>/metrics&lt;/code> exports, a minimal dashboard needs these four:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>TTFT p50/p95&lt;/strong> (time to first token) — what the user perceives when they hit send.&lt;/li>
&lt;li>&lt;strong>TPOT p50/p95&lt;/strong> (time per output token) — the &amp;ldquo;speed&amp;rdquo; of the streaming.&lt;/li>
&lt;li>&lt;strong>Aggregate throughput&lt;/strong> (tokens generated per second across the cluster) — for capacity planning.&lt;/li>
&lt;li>&lt;strong>Queue depth&lt;/strong> (&lt;code>vllm:num_requests_waiting&lt;/code>) — the leading indicator: if it grows, everything is about to degrade.&lt;/li>
&lt;/ol>
&lt;p>To that, add HBM utilisation and free memory per GPU (from &lt;code>dcgm-exporter&lt;/code>) to spot bandwidth saturation and fragmentation problems. A decent Grafana dashboard with those 6 charts anticipates 90% of incidents.&lt;/p>
&lt;h2 id="two-concrete-scenarios">Two concrete scenarios&lt;/h2>
&lt;p>We reuse the same hardware as the previous article for continuity. The same cache arithmetic, now with the engine on top.&lt;/p>
&lt;h3 id="scenario-a--1rtx-4090-workstation-or-small-k8s-node">Scenario A — 1×RTX 4090 (workstation or small K8s node)&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Topology&lt;/strong>: 1 Pod, &lt;code>--tensor-parallel-size=1&lt;/code>, 1 GPU, 1 node.&lt;/li>
&lt;li>&lt;strong>Model&lt;/strong>: up to 8B BF16 (Llama 3 8B, Qwen3 8B, Mistral 7B) or up to 14B in FP8/AWQ.&lt;/li>
&lt;li>&lt;strong>PVC&lt;/strong>: the node&amp;rsquo;s local SSD. The 4090 reads 1 TB/s from HBM; an NVMe SSD at 5 GB/s takes 5 seconds to feed 25 GB of weights into VRAM, negligible next to initialisation.&lt;/li>
&lt;li>&lt;strong>HPA&lt;/strong>: irrelevant inside the 4090 (always 1 vLLM replica per GPU), but useful across nodes: 3 replicas on 3 nodes with a 4090 each, and the K8s Service spreads round-robin.&lt;/li>
&lt;li>&lt;strong>Useful concurrency&lt;/strong>: 4–8 simultaneous sessions with 8K of context, 1–2 with 32K.&lt;/li>
&lt;li>&lt;strong>Natural use case&lt;/strong>: PoC, small teams, departmental environments, edge.&lt;/li>
&lt;/ul>
&lt;p>The manifest above is sized for this scenario. Changing only the model and the args, the same Deployment serves Qwen, Mistral or whatever is next.&lt;/p>
&lt;h3 id="scenario-b--5h100-sxm-cluster-with-nvlinknvswitch">Scenario B — 5×H100 SXM (cluster with NVLink/NVSwitch)&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Topology&lt;/strong>: 1 Pod with &lt;code>nvidia.com/gpu: 5&lt;/code> on an HGX node, &lt;code>--tensor-parallel-size=5&lt;/code>. If the platform does not allow grouping 5 GPUs into a single Pod, a LeaderWorkerSet with 5 pods coordinated by Ray.&lt;/li>
&lt;li>&lt;strong>Model&lt;/strong>: up to 70B BF16 (Llama 3 70B) or up to 200B+ in FP8 with cache quantisation.&lt;/li>
&lt;li>&lt;strong>PVC&lt;/strong>: NVMe attached directly to the node, or fast network storage (Ceph with a 25/100 GbE network, Lustre, GPFS). Loading 140 GB of weights over a slow network takes 5 minutes per start.&lt;/li>
&lt;li>&lt;strong>HPA&lt;/strong>: irrelevant inside the 5-GPU cluster (the 5 are an indivisible unit), but useful for adding whole HGX nodes when the load passes a given threshold. This combines with Cluster Autoscaler if the underlying infrastructure allows it.&lt;/li>
&lt;li>&lt;strong>Useful concurrency&lt;/strong>: 32–128 simultaneous sessions with mid-sized contexts, 4–16 with huge contexts.&lt;/li>
&lt;li>&lt;strong>Natural use case&lt;/strong>: internal corporate service, public exposure with an SLA, multi-tenant.&lt;/li>
&lt;/ul>
&lt;h3 id="a-and-b-side-by-side">A and B, side by side&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Aspect&lt;/th>
&lt;th>A (1×4090)&lt;/th>
&lt;th>B (5×H100 SXM)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Pod topology&lt;/td>
&lt;td>1 pod, 1 GPU&lt;/td>
&lt;td>1 pod with 5 GPUs (or an LWS of 5)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Maximum BF16 model&lt;/td>
&lt;td>8 B&lt;/td>
&lt;td>70 B&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TTFT @ 8K context, idle&lt;/td>
&lt;td>~250 ms&lt;/td>
&lt;td>~80 ms&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>TPOT, idle&lt;/td>
&lt;td>~30 ms/tok&lt;/td>
&lt;td>~15 ms/tok&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Throughput @ concurrency 16&lt;/td>
&lt;td>~50 tok/s/session&lt;/td>
&lt;td>~200 tok/s/session&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Session drain&lt;/td>
&lt;td>30–60 s&lt;/td>
&lt;td>60–180 s&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Useful autoscaling&lt;/td>
&lt;td>Replicas on peer nodes&lt;/td>
&lt;td>Whole nodes via Cluster Autoscaler&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reasonable multi-tenancy&lt;/td>
&lt;td>Limited: 4–8 sessions&lt;/td>
&lt;td>Comfortable: 32–128 sessions&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Indicative cost (hardware)&lt;/td>
&lt;td>~2 K €&lt;/td>
&lt;td>~250 K € (≈ 125×)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The asymmetry is the same one as in the previous article: 125× more expensive, only ~4× more throughput per session and ~10× more concurrency. What the cluster buys is not proportional; it buys &lt;strong>access to models an order of magnitude larger&lt;/strong> and &lt;strong>latencies low enough for interactive use at scale&lt;/strong>. If your workload is batch or asynchronous agents where latency is not critical, several 4090s come surprisingly close.&lt;/p>
&lt;h2 id="vllm-against-tensorrt-llm-and-sglang">vLLM against TensorRT-LLM and SGLang&lt;/h2>
&lt;p>Honestly, all three are good engines. The choice depends on practical criteria, not technical ones. A decision map, not a benchmark:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Criterion&lt;/th>
&lt;th>vLLM&lt;/th>
&lt;th>TensorRT-LLM&lt;/th>
&lt;th>SGLang&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Supported hardware&lt;/td>
&lt;td>NVIDIA, AMD ROCm, Intel Gaudi&lt;/td>
&lt;td>NVIDIA only&lt;/td>
&lt;td>NVIDIA, AMD ROCm&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Pure latency (TTFT)&lt;/td>
&lt;td>Good&lt;/td>
&lt;td>&lt;strong>Best&lt;/strong>: kernels compiled for the exact hardware&lt;/td>
&lt;td>Good&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Aggregate throughput&lt;/td>
&lt;td>Excellent&lt;/td>
&lt;td>Excellent&lt;/td>
&lt;td>Excellent (RadixAttention)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Deployment&lt;/td>
&lt;td>&lt;strong>Trivial&lt;/strong>: Docker image + args&lt;/td>
&lt;td>Complex: build an engine per model and per GPU&lt;/td>
&lt;td>Moderate&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OpenAI-compatible API&lt;/td>
&lt;td>&lt;strong>Native, complete&lt;/strong>&lt;/td>
&lt;td>Yes, through Triton Inference Server&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Support for new models&lt;/td>
&lt;td>&lt;strong>Days after release&lt;/strong>&lt;/td>
&lt;td>Weeks (recompile the engine)&lt;/td>
&lt;td>Days&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Quantization&lt;/td>
&lt;td>AWQ, GPTQ, FP8 cache&lt;/td>
&lt;td>INT4/INT8/FP8, very mature&lt;/td>
&lt;td>AWQ, FP8&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Multi-modal&lt;/td>
&lt;td>Yes (Llava, Pixtral, Qwen-VL)&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>Excellent, a priority&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Function calling / tool use&lt;/td>
&lt;td>Good&lt;/td>
&lt;td>Limited&lt;/td>
&lt;td>&lt;strong>First class&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Community / release cadence&lt;/td>
&lt;td>Very active, weekly&lt;/td>
&lt;td>Active, NVIDIA-driven&lt;/td>
&lt;td>Very active, academic&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Licence&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;td>Apache 2.0&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>When to choose each one&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>vLLM&lt;/strong>: the &amp;ldquo;boring choice&amp;rdquo; that works. The path of least friction to production. If your team has no dedicated inference serving specialist, this one. It supports varied hardware, up-to-date models, a stable API and a huge community.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>TensorRT-LLM&lt;/strong>: when per-request latency is the only metric that matters and your model is stable (trained in-house, not swapped every fortnight). The price of the performance is that every model plus every GPU plus every TRT version requires an engine rebuild, and that blocks fast iteration.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>SGLang&lt;/strong>: for agent-dominated workloads (heavy tool calling) or complex multi-modal. Its RadixAttention, structural caching of prompts with shared prefixes, shines in ReAct-style patterns where the same system prompt is repeated thousands of times.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;p>For most teams starting out with on-prem LLM serving, &lt;strong>vLLM is the right answer until you have production data pushing you elsewhere&lt;/strong>.&lt;/p>
&lt;h2 id="common-operational-traps">Common operational traps&lt;/h2>
&lt;p>A list of gotchas that come up again and again:&lt;/p>
&lt;h3 id="the-model-is-downloaded-on-every-rolling-update">The model is downloaded on every rolling update&lt;/h3>
&lt;p>&lt;strong>Symptom&lt;/strong>: every deploy takes 5+ minutes to become available.
&lt;strong>Cause&lt;/strong>: there is no shared PVC. Every new pod downloads the model from Hugging Face from scratch.
&lt;strong>Fix&lt;/strong>: a ReadOnlyMany PVC on fast storage, or a local mirror of the registry (a Pod with &lt;code>huggingface-cli&lt;/code> serving a directory over HTTP). In CI/CD, hydrating the PVC before the rollout is 1 line of bash.&lt;/p>
&lt;h3 id="short-readiness-timeouts-that-kill-pods-while-they-load">Short readiness timeouts that kill pods while they load&lt;/h3>
&lt;p>&lt;strong>Symptom&lt;/strong>: new pods go into &lt;code>CrashLoopBackOff&lt;/code> during the first model load.
&lt;strong>Cause&lt;/strong>: a &lt;code>readinessProbe&lt;/code> with too low a timeout fires before vLLM finishes loading; the &lt;code>livenessProbe&lt;/code> finishes the job.
&lt;strong>Fix&lt;/strong>: a &lt;code>startupProbe&lt;/code> with &lt;code>failureThreshold: 60&lt;/code> or more (10 minutes of grace) before liveness starts evaluating.&lt;/p>
&lt;h3 id="an-unquantised-kv-cache-then-oom">An unquantised KV cache, then OOM&lt;/h3>
&lt;p>&lt;strong>Symptom&lt;/strong>: the pod starts fine, serves for five minutes, then gets OOMKilled when session number five arrives with a long context.
&lt;strong>Cause&lt;/strong>: a BF16 KV cache (the default) consumes twice what FP8 does.
&lt;strong>Fix&lt;/strong>: &lt;code>--kv-cache-dtype=fp8&lt;/code>. Negligible quality loss in the vast majority of cases, double the capacity.&lt;/p>
&lt;h3 id="confusing-replicas-with-concurrency">Confusing replicas with concurrency&lt;/h3>
&lt;p>&lt;strong>Symptom&lt;/strong>: the HPA scales to 8 replicas under light real load and the cloud bill goes up. Latency does not improve.
&lt;strong>Cause&lt;/strong>: someone configured &lt;code>targetAverageUtilization: 50%&lt;/code> on CPU, thinking that is &amp;ldquo;load&amp;rdquo;. The reality: a single vLLM replica serves dozens of simultaneous sessions.
&lt;strong>Fix&lt;/strong>: an HPA on &lt;code>vllm:num_requests_waiting&lt;/code>. If the queue is empty, one replica is enough even if the GPU is at 90%.&lt;/p>
&lt;h3 id="tensor-parallel-on-gpus-without-nvlink">Tensor parallel on GPUs without NVLink&lt;/h3>
&lt;p>&lt;strong>Symptom&lt;/strong>: throughput 3× worse than expected, GPUs at 30%, a lot of PCIe traffic.
&lt;strong>Cause&lt;/strong>: &lt;code>tensor_parallel=4&lt;/code> on 4 GPUs connected only by PCIe; the all-reduce saturates the bus on every layer.
&lt;strong>Fix&lt;/strong>: either the GPUs share NVLink/NVSwitch (SXM/HGX models), or you use pipeline parallel (worse latency but less all-reduce), or you reduce TP and accept that the whole model does not fit.&lt;/p>
&lt;h3 id="sessions-cut-off-during-a-rolling-update">Sessions cut off during a rolling update&lt;/h3>
&lt;p>&lt;strong>Symptom&lt;/strong>: users see truncated responses during the deploy.
&lt;strong>Cause&lt;/strong>: &lt;code>terminationGracePeriodSeconds: 30&lt;/code> (the default) is not enough to drain long generations.
&lt;strong>Fix&lt;/strong>: &lt;code>terminationGracePeriodSeconds: 120–180&lt;/code>. Combined with &lt;code>maxUnavailable: 0&lt;/code>, rollouts are invisible to active users.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-articles">What we have not covered (upcoming articles)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>vLLM with hot LoRA adapters&lt;/strong>: serving a base model plus N tenant-specific adapters without reloading weights.&lt;/li>
&lt;li>&lt;strong>Disaggregated serving&lt;/strong>: separating prefill and decode into specialised pods, each optimised for its GPU profile.&lt;/li>
&lt;li>&lt;strong>Quantization deep-dive&lt;/strong>: AWQ vs GPTQ vs dynamic FP8 vs FP4, the real trade-offs, and when to use each.&lt;/li>
&lt;li>&lt;strong>Gateway API + AI Inference Extensions&lt;/strong>: the sigwg proposal to make LLMs first-class citizens in K8s (routing by model, sticky sessions per conversation, multi-tenant fairness).&lt;/li>
&lt;li>&lt;strong>Multi-modal serving&lt;/strong>: the same runtime, a different kind of request — images, audio, embeddings.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/llm-serving-engines-pareto-frontier-comparison/">Comparing serving engines (vLLM/SGLang/TRT-LLM/Dynamo)&lt;/a> — how vLLM behaves against SGLang and TRT-LLM on the goodput-latency Pareto frontier: the data that justifies (or questions) the choice of vLLM on Kubernetes.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/cloud-gpu-on-demand-reserved-spot-neoclouds-2026-prices/">Cloud GPU: comparing prices, commitment and sovereign neoclouds&lt;/a> — if the Kubernetes cluster is not on-premise but cloud, here are the spot and reserved prices of the European neoclouds where you would run these manifests.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Kwon et al., &lt;a href="https://arxiv.org/abs/2309.06180">&lt;em>Efficient Memory Management for Large Language Model Serving with PagedAttention&lt;/em>&lt;/a> (SOSP 2023) — the original vLLM paper.&lt;/li>
&lt;li>Yu et al., &lt;a href="https://www.usenix.org/conference/osdi22/presentation/yu">&lt;em>Orca: A Distributed Serving System for Transformer-Based Generative Models&lt;/em>&lt;/a> (OSDI 2022) — the paper that popularised &lt;em>continuous batching&lt;/em>.&lt;/li>
&lt;li>&lt;a href="https://docs.vllm.ai/">Official vLLM documentation&lt;/a> — operational and well maintained.&lt;/li>
&lt;li>&lt;a href="https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/">NVIDIA GPU Operator&lt;/a> — installation and troubleshooting of the GPU layer on Kubernetes.&lt;/li>
&lt;li>&lt;a href="https://kubernetes.io/blog/2024/04/16/introducing-leaderworkerset/">LeaderWorkerSet&lt;/a> — the primitive for coordinated workloads such as multi-pod tensor parallel.&lt;/li>
&lt;li>&lt;a href="https://keda.sh/">KEDA&lt;/a> — event-driven autoscaling, ideal for scaling on queue metrics.&lt;/li>
&lt;li>&lt;a href="https://github.com/NVIDIA/TensorRT-LLM">TensorRT-LLM&lt;/a> and &lt;a href="https://github.com/sgl-project/sglang">SGLang&lt;/a> — the two most serious comparables.&lt;/li>
&lt;li>&lt;a href="https://lmsys.org/">LMSYS Chatbot Arena&lt;/a> — periodic benchmarks comparing the three engines.&lt;/li>
&lt;li>Previous article on this blog: &lt;a href="https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/">KV cache: the working memory that holds up LLM inference&lt;/a>.&lt;/li>
&lt;/ul></description></item><item><title>KV cache: the working memory that holds up LLM inference</title><link>https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/</link><pubDate>Mon, 18 May 2026 10:00:00 +0200</pubDate><guid>https://blog.lo0.es/en/posts/kv-cache-working-memory-llm-inference/</guid><description>&lt;h2 id="tldr">TL;DR&lt;/h2>
&lt;p>The KV cache is the &lt;strong>working memory&lt;/strong> a language model keeps during a conversation. Without it, every new token would force a recomputation of the whole conversation from the start, at a cost that is &lt;strong>quadratic&lt;/strong> in the length of the text. With it, the cost is linear, but in exchange the cache &lt;strong>lives in VRAM and grows with every token&lt;/strong>. In practice, it is not the model that limits how much context you can serve: it is the KV cache. On an RTX 4090 running Llama 3 8B, the model fits in 16 GB and barely leaves room for ~64 K tokens of cache in total, adding up all the simultaneous sessions. Understanding that number is the difference between promising a client &amp;ldquo;128 K of context&amp;rdquo; and actually delivering it.&lt;/p>
&lt;h2 id="you-are-here-deploy">You are here: Deploy&lt;/h2>
&lt;p>This post opens the series on LLM inference fundamentals. Within the &lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">six-stage LLMOps pipeline&lt;/a> that articulates the whole system, the KV cache lives in the &lt;strong>Deploy&lt;/strong> stage: it is the piece that dictates how much traffic fits in your inference engine and, therefore, how much platform you can offer on top of it.&lt;/p>
&lt;div class="diagram" style="max-width:780px;margin:1rem auto;">
&lt;svg viewBox="0 0 780 90" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="you are here: Deploy">
&lt;style>.box{stroke:#444;stroke-width:1.4;rx:6}.active{fill:#ff8a4c;stroke-width:3}.idle{fill:#f4f4f4}.lbl{font:600 12px sans-serif;fill:#222}.arr{stroke:#666;stroke-width:1.4;fill:none;marker-end:url(#kvm)}.cyc{stroke:#888;stroke-width:1.2;fill:none;stroke-dasharray:4 2;marker-end:url(#kvm)}&lt;/style>
&lt;defs>&lt;marker id="kvm" 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="#666"/>&lt;/marker>&lt;/defs>
&lt;text x="390" y="20" text-anchor="middle" class="lbl">You are here: DEPLOY · KV cache as the VRAM bottleneck&lt;/text>
&lt;rect x="30" y="35" width="110" height="35" class="box idle"/>&lt;text x="85" y="58" text-anchor="middle" class="lbl">1 · Data&lt;/text>
&lt;rect x="155" y="35" width="110" height="35" class="box idle"/>&lt;text x="210" y="58" text-anchor="middle" class="lbl">2 · Tune&lt;/text>
&lt;rect x="280" y="35" width="110" height="35" class="box idle"/>&lt;text x="335" y="58" text-anchor="middle" class="lbl">3 · Eval&lt;/text>
&lt;rect x="405" y="35" width="110" height="35" class="box active"/>&lt;text x="460" y="58" text-anchor="middle" class="lbl">4 · Deploy&lt;/text>
&lt;rect x="530" y="35" width="110" height="35" class="box idle"/>&lt;text x="585" y="58" text-anchor="middle" class="lbl">5 · Observe&lt;/text>
&lt;rect x="655" y="35" width="110" height="35" class="box idle"/>&lt;text x="710" y="58" text-anchor="middle" class="lbl">6 · Retrain&lt;/text>
&lt;path class="arr" d="M140,52 L155,52"/>&lt;path class="arr" d="M265,52 L280,52"/>&lt;path class="arr" d="M390,52 L405,52"/>&lt;path class="arr" d="M515,52 L530,52"/>&lt;path class="arr" d="M640,52 L655,52"/>
&lt;path class="cyc" d="M710,72 L710,82 L85,82 L85,72"/>
&lt;/svg>
&lt;/div>
&lt;h2 id="the-analogy-the-speaker-with-amnesia">The analogy: the speaker with amnesia&lt;/h2>
&lt;p>Imagine you are at a two-hour technical conference. Every time the speaker is about to say a new sentence, he &lt;strong>mentally rewinds the whole talk from the beginning&lt;/strong>, reassembles the thread, and only then carries on. His next sentence requires recalling the previous one; the one after that, the two before it; an hour in, every new word costs him an hour of recap. A conference like that would be materially impossible.&lt;/p>
&lt;p>Now imagine the same speaker with a notebook in which he jots down, as he goes, the two or three key ideas of each sentence: subject, object, link to what came before. Before each new sentence he glances at the notebook and continues. His next word only costs a glance at the notebook, not a rewind of the entire talk.&lt;/p>
&lt;p>That notebook, in a transformer, is called the &lt;strong>KV cache&lt;/strong>. Without it, conversational language models would be unworkable. With it, they are commercial products. But the notebook &lt;strong>weighs something&lt;/strong>, and understanding how much, where and why is what separates an inference infrastructure that works from one that falls over at the third concurrent client.&lt;/p>
&lt;h2 id="the-mechanism-itself-in-plain-terms">The mechanism itself (in plain terms)&lt;/h2>
&lt;p>A transformer generates text &lt;strong>one token at a time&lt;/strong>. To decide the next token, the model applies a mechanism called &lt;strong>attention&lt;/strong> over all previous tokens: it asks &amp;ldquo;which parts of the earlier context are relevant for predicting what comes next?&amp;rdquo;.&lt;/p>
&lt;p>Internally, each input token is projected into three vectors:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Q&lt;/strong> (Query): &amp;ldquo;what I am looking for&amp;rdquo;&lt;/li>
&lt;li>&lt;strong>K&lt;/strong> (Key): &amp;ldquo;what this token offers&amp;rdquo;&lt;/li>
&lt;li>&lt;strong>V&lt;/strong> (Value): &amp;ldquo;what information this token carries&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;p>The attention of the current token against the context is computed by multiplying its &lt;strong>Q&lt;/strong> against the &lt;strong>K&lt;/strong> of all previous tokens, normalising with softmax, and weighting the corresponding &lt;strong>V&lt;/strong>. The result is a contextualised representation of the current token.&lt;/p>
&lt;div class="diagram" style="max-width: 720px; margin: 1.5rem auto;">
&lt;svg viewBox="0 0 720 260" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Diagram of the attention computation with Q, K, V">
&lt;style>
.box { fill: #f4f4f4; stroke: #333; stroke-width: 1.5; }
.box-q { fill: #ffe9d6; }
.box-k { fill: #d6eaff; }
.box-v { fill: #d9f5d6; }
.lbl { font: 600 13px sans-serif; fill: #222; }
.lbl-sm { font: 11px sans-serif; fill: #444; }
.arr { stroke: #444; stroke-width: 1.4; fill: none; marker-end: url(#ah); }
&lt;/style>
&lt;defs>
&lt;marker id="ah" 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="#444"/>
&lt;/marker>
&lt;/defs>
&lt;text x="360" y="22" text-anchor="middle" class="lbl">Attention computation for token N&lt;/text>
&lt;rect x="40" y="60" width="120" height="40" rx="6" class="box box-q"/>
&lt;text x="100" y="85" text-anchor="middle" class="lbl">Q (token N)&lt;/text>
&lt;text x="100" y="115" text-anchor="middle" class="lbl-sm">"what I seek"&lt;/text>
&lt;rect x="280" y="60" width="160" height="40" rx="6" class="box box-k"/>
&lt;text x="360" y="85" text-anchor="middle" class="lbl">K (tokens 1..N)&lt;/text>
&lt;text x="360" y="115" text-anchor="middle" class="lbl-sm">from the cache&lt;/text>
&lt;rect x="560" y="60" width="120" height="40" rx="6" class="box box-v"/>
&lt;text x="620" y="85" text-anchor="middle" class="lbl">V (tokens 1..N)&lt;/text>
&lt;text x="620" y="115" text-anchor="middle" class="lbl-sm">from the cache&lt;/text>
&lt;path class="arr" d="M160,80 L280,80"/>
&lt;path class="arr" d="M440,80 L560,80"/>
&lt;p>&lt;text x="220" y="74" text-anchor="middle" class="lbl-sm">Q·Kᵀ → softmax&lt;/text>
&lt;text x="500" y="74" text-anchor="middle" class="lbl-sm">× V&lt;/text>&lt;/p>
&lt;rect x="240" y="170" width="240" height="44" rx="6" class="box"/>
&lt;text x="360" y="197" text-anchor="middle" class="lbl">representation of token N&lt;/text>
&lt;path class="arr" d="M620,100 C620,150 480,150 480,170"/>
&lt;path class="arr" d="M100,100 C100,150 240,150 240,170"/>
&lt;/svg>
&lt;/div>
&lt;p>Here is the key point: to predict token N, I only need the &lt;strong>new Q&lt;/strong> (that of token N) and the &lt;strong>K, V of all previous tokens&lt;/strong>. The K and V of tokens 1..N-1 have not changed since the previous iteration. Recomputing them would be throwing work away.&lt;/p>
&lt;p>&lt;strong>The KV cache is exactly that: the memory that holds the K and V of every token already processed, in every layer of the model, so they never have to be recomputed.&lt;/strong>&lt;/p>
&lt;h2 id="why-it-exists-the-quadratic-cost-without-it">Why it exists: the quadratic cost without it&lt;/h2>
&lt;p>Generating a text of N tokens involves N steps. At step &lt;code>i&lt;/code>, attention is computed over &lt;code>i&lt;/code> previous tokens. Without a cache, at every step you recompute the K, V of the &lt;code>i-1&lt;/code> previous tokens &lt;strong>plus&lt;/strong> those of the new one. The total count of attention computations grows as:&lt;/p>
$$\sum_{i=1}^{N} i = \frac{N(N+1)}{2} \approx \frac{N^2}{2}$$
&lt;p>With a KV cache, you only process the new token at each step: cost &lt;strong>linear in N&lt;/strong>.&lt;/p>
&lt;div class="diagram" style="max-width: 720px; margin: 1.5rem auto;">
&lt;svg viewBox="0 0 720 320" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Comparison of linear vs quadratic cost">
&lt;style>
.kv-ax { stroke: currentColor; stroke-width: 1.5; opacity: 0.4; fill: none; }
.kv-grid { stroke: currentColor; stroke-width: 1; stroke-dasharray: 4,4; opacity: 0.15; fill: none; }
.kv-lin { stroke: #2a9d8f; stroke-width: 3; fill: none; }
.kv-quad { stroke: #e76f51; stroke-width: 3; fill: none; }
.kv-lbl { font: 600 13px sans-serif; fill: currentColor; }
.kv-sm { font: 11px sans-serif; fill: currentColor; opacity: 0.6; }
.kv-note { font: italic 10px sans-serif; fill: currentColor; opacity: 0.45; }
.kv-tlin { fill: #2a9d8f; font: 700 12px sans-serif; }
.kv-tqud { fill: #e76f51; font: 700 12px sans-serif; }
&lt;/style>
&lt;!-- Title -->
&lt;p>&lt;text x="380" y="22" text-anchor="middle" class="kv-lbl">Cumulative compute to generate N tokens&lt;/text>
&lt;text x="380" y="38" text-anchor="middle" class="kv-note">(schematic scale — exact data is in the table)&lt;/text>&lt;/p>
&lt;!-- Axes -->
&lt;line class="kv-ax" x1="80" y1="260" x2="680" y2="260"/>
&lt;line class="kv-ax" x1="80" y1="55" x2="80" y2="260"/>
&lt;!-- Axis labels -->
&lt;p>&lt;text x="380" y="295" text-anchor="middle" class="kv-sm">tokens generated (N)&lt;/text>
&lt;text x="25" y="158" text-anchor="middle" class="kv-sm" transform="rotate(-90 25 158)">attention operations&lt;/text>&lt;/p>
&lt;!-- X ticks -->
&lt;p>&lt;text x="80" y="277" text-anchor="middle" class="kv-sm">0&lt;/text>
&lt;text x="230" y="277" text-anchor="middle" class="kv-sm">1K&lt;/text>
&lt;text x="380" y="277" text-anchor="middle" class="kv-sm">2K&lt;/text>
&lt;text x="530" y="277" text-anchor="middle" class="kv-sm">3K&lt;/text>
&lt;text x="680" y="277" text-anchor="middle" class="kv-sm">4K&lt;/text>&lt;/p>
&lt;!-- Grid -->
&lt;line class="kv-grid" x1="80" y1="210" x2="680" y2="210"/>
&lt;line class="kv-grid" x1="80" y1="160" x2="680" y2="160"/>
&lt;line class="kv-grid" x1="80" y1="110" x2="680" y2="110"/>
&lt;line class="kv-grid" x1="80" y1="60" x2="680" y2="60"/>
&lt;!-- Linear curve (with KV cache): O(N) — gentle, visible slope -->
&lt;path class="kv-lin" d="M80,260 L680,215"/>
&lt;!-- Quadratic curve (without KV cache): O(N²) — starts flat, rises fast -->
&lt;path class="kv-quad" d="M80,260 C250,258 420,200 680,58"/>
&lt;!-- Curve labels -->
&lt;p>&lt;text x="570" y="208" class="kv-tlin">with KV cache&lt;/text>
&lt;text x="570" y="222" class="kv-tlin">O(N) — linear&lt;/text>&lt;/p>
&lt;p>&lt;text x="440" y="95" class="kv-tqud">without KV cache&lt;/text>
&lt;text x="440" y="109" class="kv-tqud">O(N²) — quadratic&lt;/text>
&lt;/svg>&lt;/p>
&lt;/div>
&lt;p>The concrete numbers are devastating:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:right">Tokens generated&lt;/th>
&lt;th style="text-align:right">Without KV cache (operations)&lt;/th>
&lt;th style="text-align:right">With KV cache&lt;/th>
&lt;th style="text-align:right">Ratio&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:right">128&lt;/td>
&lt;td style="text-align:right">8,256&lt;/td>
&lt;td style="text-align:right">128&lt;/td>
&lt;td style="text-align:right">64×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">1,024&lt;/td>
&lt;td style="text-align:right">524,800&lt;/td>
&lt;td style="text-align:right">1,024&lt;/td>
&lt;td style="text-align:right">512×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">4,096&lt;/td>
&lt;td style="text-align:right">8,390,656&lt;/td>
&lt;td style="text-align:right">4,096&lt;/td>
&lt;td style="text-align:right">2,048×&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">32,768&lt;/td>
&lt;td style="text-align:right">536,887,296&lt;/td>
&lt;td style="text-align:right">32,768&lt;/td>
&lt;td style="text-align:right">16,384×&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>At 32 K tokens, &lt;strong>the cache saves you four orders of magnitude&lt;/strong> of compute. This is not an optimisation: it is what makes conversational inference possible.&lt;/p>
&lt;h2 id="the-price-how-heavy-the-backpack-is">The price: how heavy the backpack is&lt;/h2>
&lt;p>The KV cache is paid for in VRAM. The formula, per &lt;strong>sequence&lt;/strong>, is:&lt;/p>
&lt;pre tabindex="0">&lt;code>KV_size = 2 · n_layers · n_kv_heads · head_dim · context_len · bytes_per_param
↑
K and V
&lt;/code>&lt;/pre>&lt;p>Per &lt;strong>token&lt;/strong> (dropping &lt;code>context_len&lt;/code>), it is a constant of the model itself. Let us look at real numbers:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Model&lt;/th>
&lt;th style="text-align:right">n_layers&lt;/th>
&lt;th style="text-align:right">n_kv_heads&lt;/th>
&lt;th style="text-align:right">head_dim&lt;/th>
&lt;th style="text-align:right">Bytes/token (BF16)&lt;/th>
&lt;th style="text-align:right">GB at 8 K ctx&lt;/th>
&lt;th style="text-align:right">GB at 32 K&lt;/th>
&lt;th style="text-align:right">GB at 128 K&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Llama 3 8B (hypothetical MHA)&lt;/td>
&lt;td style="text-align:right">32&lt;/td>
&lt;td style="text-align:right">32&lt;/td>
&lt;td style="text-align:right">128&lt;/td>
&lt;td style="text-align:right">524,288&lt;/td>
&lt;td style="text-align:right">4.00&lt;/td>
&lt;td style="text-align:right">16.00&lt;/td>
&lt;td style="text-align:right">64.00&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Llama 3 8B (real GQA)&lt;/strong>&lt;/td>
&lt;td style="text-align:right">32&lt;/td>
&lt;td style="text-align:right">8&lt;/td>
&lt;td style="text-align:right">128&lt;/td>
&lt;td style="text-align:right">131,072&lt;/td>
&lt;td style="text-align:right">&lt;strong>1.00&lt;/strong>&lt;/td>
&lt;td style="text-align:right">&lt;strong>4.00&lt;/strong>&lt;/td>
&lt;td style="text-align:right">&lt;strong>16.00&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Llama 3 70B (GQA)&lt;/td>
&lt;td style="text-align:right">80&lt;/td>
&lt;td style="text-align:right">8&lt;/td>
&lt;td style="text-align:right">128&lt;/td>
&lt;td style="text-align:right">327,680&lt;/td>
&lt;td style="text-align:right">2.50&lt;/td>
&lt;td style="text-align:right">10.00&lt;/td>
&lt;td style="text-align:right">40.00&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Qwen3 8B (GQA)&lt;/td>
&lt;td style="text-align:right">36&lt;/td>
&lt;td style="text-align:right">8&lt;/td>
&lt;td style="text-align:right">128&lt;/td>
&lt;td style="text-align:right">147,456&lt;/td>
&lt;td style="text-align:right">1.12&lt;/td>
&lt;td style="text-align:right">4.50&lt;/td>
&lt;td style="text-align:right">18.00&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Mistral 7B (GQA)&lt;/td>
&lt;td style="text-align:right">32&lt;/td>
&lt;td style="text-align:right">8&lt;/td>
&lt;td style="text-align:right">128&lt;/td>
&lt;td style="text-align:right">131,072&lt;/td>
&lt;td style="text-align:right">1.00&lt;/td>
&lt;td style="text-align:right">4.00&lt;/td>
&lt;td style="text-align:right">16.00&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Two immediate readings:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Without GQA there is no 128 K worth talking about.&lt;/strong> A Llama 3 8B with classic multi-head attention would need 64 GB of KV cache alone for a single sequence with 128 K tokens. In other words, &lt;strong>it does not fit on any consumer GPU&lt;/strong>. That is why Meta, Mistral and the rest adopted Grouped Query Attention.&lt;/li>
&lt;li>&lt;strong>The KV cache can be larger than the model.&lt;/strong> Llama 3 8B in BF16 takes ~16 GB. With 128 K of context, its cache is another 16 GB. A single session ties with the model in VRAM.&lt;/li>
&lt;/ol>
&lt;div class="diagram" style="max-width: 720px; margin: 1.5rem auto;">
&lt;svg viewBox="0 0 720 280" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Growth of the KV cache with context length">
&lt;style>
.ax { stroke: #333; stroke-width: 1.5; }
.grid { stroke: #ddd; stroke-width: 1; stroke-dasharray: 3,3; }
.l8b { stroke: #2a9d8f; stroke-width: 2.5; fill: none; }
.l70b { stroke: #e76f51; stroke-width: 2.5; fill: none; }
.lq8 { stroke: #6a4c93; stroke-width: 2.5; fill: none; stroke-dasharray: 5,3; }
.lim { stroke: #c1121f; stroke-width: 1.5; stroke-dasharray: 4,4; }
.lbl { font: 600 13px sans-serif; fill: #222; }
.lbl-sm { font: 11px sans-serif; fill: #555; }
.tag { font: 600 11px sans-serif; }
&lt;/style>
&lt;text x="360" y="20" text-anchor="middle" class="lbl">KV cache (GB) vs context length (1 sequence, BF16)&lt;/text>
&lt;line class="ax" x1="80" y1="240" x2="680" y2="240"/>
&lt;line class="ax" x1="80" y1="40" x2="80" y2="240"/>
&lt;line class="grid" x1="80" y1="190" x2="680" y2="190"/>
&lt;line class="grid" x1="80" y1="140" x2="680" y2="140"/>
&lt;line class="grid" x1="80" y1="90" x2="680" y2="90"/>
&lt;p>&lt;text x="75" y="244" text-anchor="end" class="lbl-sm">0&lt;/text>
&lt;text x="75" y="194" text-anchor="end" class="lbl-sm">10&lt;/text>
&lt;text x="75" y="144" text-anchor="end" class="lbl-sm">20&lt;/text>
&lt;text x="75" y="94" text-anchor="end" class="lbl-sm">30&lt;/text>
&lt;text x="75" y="44" text-anchor="end" class="lbl-sm">40 GB&lt;/text>&lt;/p>
&lt;p>&lt;text x="80" y="258" text-anchor="middle" class="lbl-sm">0&lt;/text>
&lt;text x="180" y="258" text-anchor="middle" class="lbl-sm">8K&lt;/text>
&lt;text x="305" y="258" text-anchor="middle" class="lbl-sm">32K&lt;/text>
&lt;text x="430" y="258" text-anchor="middle" class="lbl-sm">64K&lt;/text>
&lt;text x="680" y="258" text-anchor="middle" class="lbl-sm">128K&lt;/text>&lt;/p>
&lt;!-- Available VRAM limit on an RTX 4090 (~8 GB free after the model) -->
&lt;line class="lim" x1="80" y1="200" x2="680" y2="200"/>
&lt;text x="680" y="196" text-anchor="end" class="tag" fill="#c1121f">≈ VRAM free after loading an 8B on a 4090&lt;/text>
&lt;!-- Llama 3 8B GQA: linear, 1 GB @8K, 16 GB @128K -->
&lt;path class="l8b" d="M80,240 L180,235 L305,220 L430,200 L680,160"/>
&lt;!-- Qwen3 8B GQA -->
&lt;path class="lq8" d="M80,240 L180,234 L305,217 L430,194 L680,150"/>
&lt;!-- Llama 3 70B GQA -->
&lt;path class="l70b" d="M80,240 L180,228 L305,190 L430,140 L680,40"/>
&lt;p>&lt;text x="690" y="160" class="tag" fill="#2a9d8f">Llama 3 8B&lt;/text>
&lt;text x="690" y="148" class="tag" fill="#6a4c93">Qwen3 8B&lt;/text>
&lt;text x="690" y="42" class="tag" fill="#e76f51">Llama 3 70B&lt;/text>
&lt;/svg>&lt;/p>
&lt;/div>
&lt;p>The dashed red line marks the realistic VRAM available on an RTX 4090 after loading the model. &lt;strong>Any model whose curve crosses that line will not be able to serve that context&lt;/strong> without additional strategies: cache quantisation, offload, partitioning.&lt;/p>
&lt;h2 id="inference-is-memory-bound-not-compute-bound">Inference is memory-bound, not compute-bound&lt;/h2>
&lt;p>There is a common misconception: thinking that &amp;ldquo;fast GPU = fast inference&amp;rdquo;. In the regime where inference services with a KV cache actually operate, &lt;strong>what gets measured is memory bandwidth&lt;/strong>. Every new token requires reading the K and V of all previous tokens from HBM. The compute is modest; the data movement is massive.&lt;/p>
&lt;p>That is why an H100 SXM (3.35 TB/s of HBM3) can be 2-3× faster than an A100 (1.55-2 TB/s) &lt;strong>without the clock frequency or the core count fully explaining the difference&lt;/strong>. Bandwidth explains it.&lt;/p>
&lt;p>And that is also why offers of &amp;ldquo;cheap GPUs with plenty of VRAM but slow HBM&amp;rdquo; (some variants with GDDR6 or LPDDR5) disappoint in inference with long contexts: they have room to store the cache but take forever to re-read it.&lt;/p>
&lt;h2 id="tricks-to-make-the-notebook-thinner">Tricks to make the notebook thinner&lt;/h2>
&lt;p>Three techniques, in chronological order, have progressively flattened the size of the KV cache:&lt;/p>
&lt;p>&lt;strong>Multi-Head Attention (MHA).&lt;/strong> The original transformer design (Vaswani et al., 2017). Every attention head has its own K and V. Expensive in cache but theoretically maximal in expressiveness. It is what models had until around 2023.&lt;/p>
&lt;p>&lt;strong>Multi-Query Attention (MQA).&lt;/strong> A single K and V shared by all heads. It reduces the cache by a factor of &lt;code>n_heads&lt;/code>. It works reasonably well but degrades generation quality on some benchmarks.&lt;/p>
&lt;p>&lt;strong>Grouped Query Attention (GQA).&lt;/strong> The middle ground that won. Heads are grouped: in Llama 3 8B, 32 query heads share K, V in groups of 4, giving 8 KV groups. It cuts the cache 4× relative to MHA with almost identical quality. It has been the de facto standard since 2024.&lt;/p>
&lt;p>&lt;strong>Multi-Head Latent Attention (MLA).&lt;/strong> DeepSeek-V2/V3&amp;rsquo;s innovation: instead of storing K, V per head, it compresses the state into a smaller latent vector and projects out to K, V on the fly. The cache can come down to 70 bytes/token, two orders of magnitude less than GQA. It is the main reason DeepSeek-V3 (671 B parameters, 37 B active) is servable on affordable infrastructure.&lt;/p>
&lt;div class="diagram" style="max-width: 640px; margin: 1.5rem auto;">
&lt;svg viewBox="0 0 640 200" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="KV cache reduction by technique">
&lt;style>
.bar { stroke: #333; stroke-width: 1; }
.b-mha { fill: #e76f51; }
.b-gqa { fill: #f4a261; }
.b-mqa { fill: #e9c46a; }
.b-mla { fill: #2a9d8f; }
.lbl { font: 600 13px sans-serif; fill: #222; }
.lbl-sm{ font: 11px sans-serif; fill: #444; }
&lt;/style>
&lt;text x="320" y="20" text-anchor="middle" class="lbl">KB of cache per token (Llama 3 8B equivalent, BF16)&lt;/text>
&lt;rect x="200" y="40" width="380" height="22" class="bar b-mha"/>
&lt;text x="170" y="56" text-anchor="end" class="lbl-sm">MHA (32 KV heads)&lt;/text>
&lt;text x="595" y="56" class="lbl-sm">512 KB&lt;/text>
&lt;rect x="200" y="76" width="95" height="22" class="bar b-gqa"/>
&lt;text x="170" y="92" text-anchor="end" class="lbl-sm">GQA (8 KV heads)&lt;/text>
&lt;text x="310" y="92" class="lbl-sm">128 KB&lt;/text>
&lt;rect x="200" y="112" width="12" height="22" class="bar b-mqa"/>
&lt;text x="170" y="128" text-anchor="end" class="lbl-sm">MQA (1 KV head)&lt;/text>
&lt;text x="225" y="128" class="lbl-sm">16 KB&lt;/text>
&lt;rect x="200" y="148" width="3" height="22" class="bar b-mla"/>
&lt;text x="170" y="164" text-anchor="end" class="lbl-sm">MLA (DeepSeek-V3)&lt;/text>
&lt;text x="215" y="164" class="lbl-sm">~0.5 KB (real V3)&lt;/text>
&lt;/svg>
&lt;/div>
&lt;blockquote>
&lt;p>&lt;strong>Note:&lt;/strong> the MLA bar is illustrative, using typical values published by DeepSeek; the exact implementation depends on the latent size. What matters is the order of magnitude.&lt;/p>
&lt;/blockquote>
&lt;p>On top of this comes a fourth, orthogonal technique: &lt;strong>quantising the cache&lt;/strong> to FP8, INT8 or even INT4. vLLM and TensorRT-LLM already support it in production. Going from BF16 (2 bytes) to FP8 (1 byte) &lt;strong>halves the cache&lt;/strong> at a small cost in quality. Going to INT4 divides it by four, at a somewhat higher cost.&lt;/p>
&lt;h2 id="the-next-dragon-fragmentation">The next dragon: fragmentation&lt;/h2>
&lt;p>So far we have talked about the cache as if it were a contiguous block. In practice, an inference server handles &lt;strong>dozens of simultaneous sessions&lt;/strong>, each with its own cache growing at a different rate. Naive allocation, reserving the maximum possible per session, &lt;strong>wastes between 60 % and 80 % of the VRAM&lt;/strong> according to the original PagedAttention paper.&lt;/p>
&lt;div class="diagram" style="max-width: 720px; margin: 1.5rem auto;">
&lt;svg viewBox="0 0 720 240" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="KV cache fragmentation: naive vs PagedAttention">
&lt;style>
.used { fill: #2a9d8f; stroke: #1a6e63; stroke-width: 1; }
.free { fill: #f0e7d8; stroke: #aaa; stroke-width: 1; }
.blk { stroke: #555; stroke-width: 0.5; }
.lbl { font: 600 13px sans-serif; fill: #222; }
.lbl-sm { font: 11px sans-serif; fill: #444; }
&lt;/style>
&lt;text x="180" y="22" text-anchor="middle" class="lbl">Naive allocation (contiguous)&lt;/text>
&lt;text x="540" y="22" text-anchor="middle" class="lbl">PagedAttention (blocks)&lt;/text>
&lt;!-- Naive: 4 sessions reserve the maximum, use little -->
&lt;p>&lt;text x="30" y="60" class="lbl-sm">session A&lt;/text>
&lt;rect x="90" y="48" width="50" height="18" class="used"/>
&lt;rect x="140" y="48" width="180" height="18" class="free"/>&lt;/p>
&lt;p>&lt;text x="30" y="92" class="lbl-sm">session B&lt;/text>
&lt;rect x="90" y="80" width="25" height="18" class="used"/>
&lt;rect x="115" y="80" width="205" height="18" class="free"/>&lt;/p>
&lt;p>&lt;text x="30" y="124" class="lbl-sm">session C&lt;/text>
&lt;rect x="90" y="112" width="100" height="18" class="used"/>
&lt;rect x="190" y="112" width="130" height="18" class="free"/>&lt;/p>
&lt;p>&lt;text x="30" y="156" class="lbl-sm">session D&lt;/text>
&lt;rect x="90" y="144" width="35" height="18" class="used"/>
&lt;rect x="125" y="144" width="195" height="18" class="free"/>&lt;/p>
&lt;p>&lt;text x="180" y="190" text-anchor="middle" class="lbl-sm">→ ~70 % of VRAM reserved and empty&lt;/text>&lt;/p>
&lt;!-- PagedAttention: small blocks, dense occupancy -->
&lt;g transform="translate(400,40)">
&lt;!-- 8 blocks x 5 rows -->
&lt;g>
&lt;!-- row 1 -->
&lt;rect x="0" y="0" width="30" height="20" class="used blk"/>
&lt;rect x="30" y="0" width="30" height="20" class="used blk"/>
&lt;rect x="60" y="0" width="30" height="20" class="used blk"/>
&lt;rect x="90" y="0" width="30" height="20" class="used blk"/>
&lt;rect x="120" y="0" width="30" height="20" class="used blk"/>
&lt;rect x="150" y="0" width="30" height="20" class="used blk"/>
&lt;rect x="180" y="0" width="30" height="20" class="used blk"/>
&lt;rect x="210" y="0" width="30" height="20" class="used blk"/>
&lt;pre>&lt;code> &amp;lt;rect x=&amp;quot;0&amp;quot; y=&amp;quot;22&amp;quot; width=&amp;quot;30&amp;quot; height=&amp;quot;20&amp;quot; class=&amp;quot;used blk&amp;quot;/&amp;gt;
&amp;lt;rect x=&amp;quot;30&amp;quot; y=&amp;quot;22&amp;quot; width=&amp;quot;30&amp;quot; height=&amp;quot;20&amp;quot; class=&amp;quot;used blk&amp;quot;/&amp;gt;
&amp;lt;rect x=&amp;quot;60&amp;quot; y=&amp;quot;22&amp;quot; width=&amp;quot;30&amp;quot; height=&amp;quot;20&amp;quot; class=&amp;quot;used blk&amp;quot;/&amp;gt;
&amp;lt;rect x=&amp;quot;90&amp;quot; y=&amp;quot;22&amp;quot; width=&amp;quot;30&amp;quot; height=&amp;quot;20&amp;quot; class=&amp;quot;used blk&amp;quot;/&amp;gt;
&amp;lt;rect x=&amp;quot;120&amp;quot; y=&amp;quot;22&amp;quot; width=&amp;quot;30&amp;quot; height=&amp;quot;20&amp;quot; class=&amp;quot;used blk&amp;quot;/&amp;gt;
&amp;lt;rect x=&amp;quot;150&amp;quot; y=&amp;quot;22&amp;quot; width=&amp;quot;30&amp;quot; height=&amp;quot;20&amp;quot; class=&amp;quot;used blk&amp;quot;/&amp;gt;
&amp;lt;rect x=&amp;quot;180&amp;quot; y=&amp;quot;22&amp;quot; width=&amp;quot;30&amp;quot; height=&amp;quot;20&amp;quot; class=&amp;quot;free blk&amp;quot;/&amp;gt;
&amp;lt;rect x=&amp;quot;210&amp;quot; y=&amp;quot;22&amp;quot; width=&amp;quot;30&amp;quot; height=&amp;quot;20&amp;quot; class=&amp;quot;free blk&amp;quot;/&amp;gt;
&amp;lt;rect x=&amp;quot;0&amp;quot; y=&amp;quot;44&amp;quot; width=&amp;quot;30&amp;quot; height=&amp;quot;20&amp;quot; class=&amp;quot;used blk&amp;quot;/&amp;gt;
&amp;lt;rect x=&amp;quot;30&amp;quot; y=&amp;quot;44&amp;quot; width=&amp;quot;30&amp;quot; height=&amp;quot;20&amp;quot; class=&amp;quot;used blk&amp;quot;/&amp;gt;
&amp;lt;rect x=&amp;quot;60&amp;quot; y=&amp;quot;44&amp;quot; width=&amp;quot;30&amp;quot; height=&amp;quot;20&amp;quot; class=&amp;quot;used blk&amp;quot;/&amp;gt;
&amp;lt;rect x=&amp;quot;90&amp;quot; y=&amp;quot;44&amp;quot; width=&amp;quot;30&amp;quot; height=&amp;quot;20&amp;quot; class=&amp;quot;free blk&amp;quot;/&amp;gt;
&amp;lt;rect x=&amp;quot;120&amp;quot; y=&amp;quot;44&amp;quot; width=&amp;quot;30&amp;quot; height=&amp;quot;20&amp;quot; class=&amp;quot;free blk&amp;quot;/&amp;gt;
&amp;lt;rect x=&amp;quot;150&amp;quot; y=&amp;quot;44&amp;quot; width=&amp;quot;30&amp;quot; height=&amp;quot;20&amp;quot; class=&amp;quot;free blk&amp;quot;/&amp;gt;
&amp;lt;rect x=&amp;quot;180&amp;quot; y=&amp;quot;44&amp;quot; width=&amp;quot;30&amp;quot; height=&amp;quot;20&amp;quot; class=&amp;quot;free blk&amp;quot;/&amp;gt;
&amp;lt;rect x=&amp;quot;210&amp;quot; y=&amp;quot;44&amp;quot; width=&amp;quot;30&amp;quot; height=&amp;quot;20&amp;quot; class=&amp;quot;free blk&amp;quot;/&amp;gt;
&amp;lt;/g&amp;gt;
&lt;/code>&lt;/pre>
&lt;/g>
&lt;text x="540" y="190" text-anchor="middle" class="lbl-sm">→ &amp;lt; 4 % waste (vLLM paper)&lt;/text>
&lt;/svg>
&lt;/div>
&lt;p>&lt;strong>PagedAttention&lt;/strong>, the idea from Kwon et al. (2023) that gave rise to vLLM, solves this by borrowing a technique from operating systems: divide the VRAM into small &lt;strong>blocks&lt;/strong> (typically 16 tokens) and keep a &lt;strong>page table&lt;/strong> mapping logical to physical blocks per session. A session no longer reserves one huge contiguous block: it grows one block at a time, and the blocks can be scattered across the VRAM. The result is an effective occupancy of 90 % instead of 30 %, and therefore &lt;strong>2-4× more aggregate throughput&lt;/strong> on the same hardware.&lt;/p>
&lt;p>PagedAttention deserves an article of its own. I am noting it down for the next one.&lt;/p>
&lt;h2 id="applied-to-generic-on-premise-hardware">Applied to generic on-premise hardware&lt;/h2>
&lt;p>Let us come down to concrete cases.&lt;/p>
&lt;h3 id="case-1--rtx-4090-24-gb-ada-lovelace">Case 1 — RTX 4090 (24 GB, Ada Lovelace)&lt;/h3>
&lt;p>A typical configuration with Qwen3-8B BF16:&lt;/p>
&lt;pre tabindex="0">&lt;code>Model in BF16: ~16 GB
Activations + overhead: ~2 GB
VRAM available for KV cache: ~6 GB (with margin)
&lt;/code>&lt;/pre>&lt;p>At 144 KB/token (Qwen3-8B GQA), that comes to &lt;strong>~43 K total tokens of cache&lt;/strong> distributed across all simultaneous sessions. In practice:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:right">Concurrency&lt;/th>
&lt;th style="text-align:right">Maximum context per session&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:right">1&lt;/td>
&lt;td style="text-align:right">32,768&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">4&lt;/td>
&lt;td style="text-align:right">8,192&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">8&lt;/td>
&lt;td style="text-align:right">4,096&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">16&lt;/td>
&lt;td style="text-align:right">2,048&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>If you need to advertise &amp;ldquo;we support 32 K of context&amp;rdquo; with concurrency of 4 or more, you have to &lt;strong>quantise the cache&lt;/strong> (FP8 brings it down to 72 KB/token, doubling capacity) or &lt;strong>move up the model range&lt;/strong> (a 4B with GQA and a quantised cache would have room to spare).&lt;/p>
&lt;h3 id="case-2--cluster-of-4h100-320-gb-total-nvlink">Case 2 — Cluster of 4×H100 (320 GB total, NVLink)&lt;/h3>
&lt;p>With tensor parallel = 4 and Llama 3 70B BF16:&lt;/p>
&lt;pre tabindex="0">&lt;code>Model in BF16: ~140 GB (35 GB/GPU)
vLLM overhead per GPU: ~2 GB
VRAM free for KV per GPU: ~43 GB → ~172 GB aggregate
&lt;/code>&lt;/pre>&lt;p>At 320 KB/token (Llama 3 70B GQA), that comes to &lt;strong>~537 K total tokens of cache&lt;/strong>. Ample margin for long contexts with high concurrency:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:right">Concurrency&lt;/th>
&lt;th style="text-align:right">Maximum context per session&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:right">4&lt;/td>
&lt;td style="text-align:right">134,000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">16&lt;/td>
&lt;td style="text-align:right">33,500&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:right">64&lt;/td>
&lt;td style="text-align:right">8,375&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>For DeepSeek-V3 671 B with MLA the economics change radically, because the cache is ~100× thinner. What limits you is no longer the cache but the VRAM of the model itself (quantised to FP8 it is ~335 GB, so it fits on 4×H100 with room for the KV cache).&lt;/p>
&lt;h3 id="operational-implications">Operational implications&lt;/h3>
&lt;p>Three observations we repeat in every consulting engagement:&lt;/p>
&lt;p>First, &lt;strong>the maximum context a model advertises is not the one you can serve on your hardware&lt;/strong>. Llama 3 8B &amp;ldquo;supports&amp;rdquo; 128 K, but on a 4090 with 4 simultaneous sessions your effective context is ~8 K. It is trivial to check before promising it to a client.&lt;/p>
&lt;p>Second, &lt;strong>quantising the KV cache is one of the optimisations with the best cost/benefit ratio in the ENS context&lt;/strong>. It does not touch the weights, it does not affect audit reproducibility, and it doubles capacity. vLLM supports it via &lt;code>--kv-cache-dtype fp8&lt;/code>.&lt;/p>
&lt;p>Third, &lt;strong>if the SLAs dictate long contexts with many concurrent users, GQA is necessary but not sufficient&lt;/strong>. In the medium term you need to look at models with MLA or attention variants with compression.&lt;/p>
&lt;h2 id="what-we-have-not-covered-upcoming-articles">What we have not covered (upcoming articles)&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>PagedAttention&lt;/strong> and its implementation in vLLM: blocks, page table, eviction.&lt;/li>
&lt;li>&lt;strong>Prefix caching&lt;/strong>: when several requests share the system prompt, there is no need to recompute the K, V of the common part.&lt;/li>
&lt;li>&lt;strong>Speculative decoding&lt;/strong> and its interaction with the cache.&lt;/li>
&lt;li>&lt;strong>Cache offloading&lt;/strong>: moving cold blocks to RAM or NVMe, a key technique for contexts above 1 M.&lt;/li>
&lt;/ul>
&lt;h2 id="see-also">See also&lt;/h2>
&lt;ul>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/six-stage-llmops-pipeline-architecture-and-deep-dive/">The six-stage LLMOps pipeline&lt;/a> — the master map of the system in production, in which the Deploy stage is one box out of six. This post goes into one of the critical decisions inside Deploy.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/pagedattention-vllm-block-manager/">Inside PagedAttention: blocks, page table, eviction and the state of the art of the KV cache in 2026&lt;/a> — a theoretical deep dive at block level plus a survey of derived optimisations (vAttention, EvicPress, RadixAttention, speculative decoding). It continues this post from the academic theory.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-fine-tuning-production-real-traffic-deployed-adapter/">Continuous fine-tuning in production: from real traffic to the deployed adapter&lt;/a> — how the loop between inference and incremental training closes on the same stack (vLLM + Postgres), with VRAM budgets that explicitly include the KV cache during eval.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/disaggregated-serving-prefill-decode-specialised-pods/">Disaggregated serving: prefill and decode in specialised pods&lt;/a> — the KV cache stops being a private GPU buffer and becomes the artefact transferred between pods. Here the cache size formula determines the economics of the transfer.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/gpu-cluster-as-multi-tenant-platform/">The GPU cluster as a multi-tenant platform&lt;/a> — how the cluster becomes a service with tenants, gateway, quotas and isolation. That is where the KV cache stops being only a performance resource and becomes a platform matter.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/vllm-kubernetes-llm-inference-that-scales/">vLLM on Kubernetes: the LLM inference piece that does scale&lt;/a> — the engine that materialises everything discussed here, deployed on K8s with tensor parallel and autoscaling.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/quantization-llm-inference-fp8-int4-gguf/">Quantization for LLM inference: FP8, INT4 and GGUF&lt;/a> — KV cache quantisation (&lt;code>--kv-cache-dtype=fp8/int4&lt;/code>), mentioned here as the fourth orthogonal technique, is taken apart there with the maths, the formats and the measurable loss.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/speculative-decoding-fundamentals-maths-state-of-play/">Speculative decoding: the secretary who anticipates what the boss will say&lt;/a> — the memory-bound decode regime caused by the KV cache is exactly what speculative decoding exploits: a forward pass with γ tokens costs almost the same as one with a single token.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/flashattention-librarian-io-awareness-async-blackwell-asymmetry/">FlashAttention v1/v2/v3/v4: the librarian who never clears the desk&lt;/a> — the kernel that sweeps the KV cache against Q on every iteration without materialising the N×N matrix. The compute layer underneath the cache.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/moe-inference-fundamentals-expert-parallel-deepseek-economics/">MoE inference: the call centre with 256 specialists&lt;/a> — attention is still dense in every MoE of 2026 (Mixtral, DeepSeek, Qwen3, Llama 4, Kimi K2), so the KV cache keeps its shape; DeepSeek&amp;rsquo;s MLA is the orthogonal optimisation that compresses it ~10× to make long context viable on modest clusters.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/continuous-batching-orca-vllm-chunked-prefill-goodput/">Continuous batching&lt;/a> — the iterative scheduler manages the dynamic allocation of the KV cache across requests; without PagedAttention, continuous batching would fragment the HBM, and without continuous batching the KV cache would be underused because of padding.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/capacity-planning-on-premise-llm-inference/">Capacity planning for on-premise LLM inference&lt;/a> — the KV cache is the dominant component of the VRAM budget when sizing a cluster from an SLO; the spreadsheet is built there step by step.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-prefill-vllm-knobs-ttft-will-not-forgive/">Optimising prefill in vLLM&lt;/a> — the four concrete knobs (chunked prefill, prefix caching, FP8 KV, max-model-len) that turn KV cache theory into production parameters for the RTX 4090 and the L40.&lt;/li>
&lt;li>&lt;a href="https://blog.lo0.es/en/posts/optimising-decode-vllm-squeezing-every-token-small-hardware/">Optimising decode in vLLM&lt;/a> — how &lt;code>--gpu-memory-utilization&lt;/code>, speculative decoding and an FP8 KV cache combine to squeeze small hardware during the generation phase.&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ul>
&lt;li>Vaswani et al., &lt;em>Attention Is All You Need&lt;/em> (NeurIPS 2017) — foundational transformer paper.&lt;/li>
&lt;li>Ainslie et al., &lt;em>GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints&lt;/em> (EMNLP 2023).&lt;/li>
&lt;li>Kwon et al., &lt;em>Efficient Memory Management for Large Language Model Serving with PagedAttention&lt;/em> (SOSP 2023) — original vLLM paper.&lt;/li>
&lt;li>DeepSeek-AI, &lt;em>DeepSeek-V2 Technical Report&lt;/em> (2024) — introduction of Multi-Head Latent Attention.&lt;/li>
&lt;li>Official vLLM documentation: &lt;a href="https://docs.vllm.ai/">https://docs.vllm.ai/&lt;/a>.&lt;/li>
&lt;li>Llama 3 model card (Meta): GQA specs, n_layers, n_kv_heads.&lt;/li>
&lt;/ul></description></item></channel></rss>