llm-d Deep Dive

We Split Prefill and Decode Onto Separate GPUs. Here's What Happened.

19.8x faster TTFT at 32K context, zero-copy KV cache transfers in under 2ms, and the arithmetic intensity math that makes it all work.

~25 min read

TL;DR

Contents

  1. Why Disaggregation Matters at Scale
  2. The Math That Convinced Me
  3. KV Cache Transfer Mechanisms: NIXL, TCP, RDMA, and RoCE
  4. Inside NixL: RDMA Mechanics, Memory Pinning, and Transfer Pipelines
  5. The pd-sidecar Architecture
  6. The Numbers (I Ran These Three Times)
  7. Monolithic vs P/D at Different Traffic Levels
  8. Real-World Capacity Planning: 8 H200s Serving Llama 70B
  9. When NOT to Disaggregate (Seriously, Don't)
  10. Mistakes I Made (So You Don't Have To)
  11. LLMInferenceService YAML Configuration
  12. Where This Goes Next
  13. The 5-Minute Version

TL;DR

Want the interactive version? The companion app lets you drag sliders and run simulations with this same data. Open it →

#1. Why Disaggregation Matters at Scale

When I first started profiling inference workloads on our H200 cluster, one thing was immediately obvious: prefill and decode want completely different things from the hardware. I'd stare at the GPU utilization graphs and see this jagged sawtooth pattern -- compute utilization spiking during prefill, then plummeting during decode, while memory bandwidth usage did the exact opposite. The GPU was constantly thrashing between two fundamentally different operating modes, and neither was running well.

Large language model inference is fundamentally a two-phase process. The first phase, prefill, processes the entire input prompt in a single forward pass, computing attention keys and values for every token through every transformer layer. The second phase, decode, generates the response one token at a time, autoregressively feeding each new token back through the model to produce the next. These two phases have radically different computational profiles, and when you force them to share the same GPU, neither runs optimally.

At low concurrency, the interference is minor. A single request prefills, then decodes, and the GPU switches between compute-bound and memory-bandwidth-bound work sequentially. But at scale, with dozens or hundreds of concurrent requests, the problem compounds. A GPU that's in the middle of a large prefill operation for one request is simultaneously trying to serve decode iterations for other requests in the same batch. The prefill saturates the compute units while decode starves for memory bandwidth. The decode operations consume memory bandwidth that could be feeding data to the prefill. Both phases degrade, and the result is higher tail latencies, lower throughput, and unpredictable time-to-first-token (TTFT).

Here's the insight that makes everything click: if two workloads have fundamentally different hardware requirements, run them on different hardware. Dedicate some GPUs to prefill (where they can keep their compute units saturated with large matrix multiplications) and other GPUs to decode (where they can optimize batch sizes and memory access patterns for bandwidth-bound work). The catch -- and it's a real one -- is the handoff: after prefill completes, the KV cache (which can be gigabytes for long contexts) must be transferred from the prefill GPU to the decode GPU before token generation can begin.

I want to be direct about something: disaggregated prefill/decode is not a silver bullet. It adds real operational complexity and requires specific conditions to pay off. But when those conditions are met -- large models, long contexts, high concurrency -- the gains are so dramatic that running monolithic inference feels like leaving performance on the table. This post is my attempt to give you everything you need to make that judgment for your own workloads.

llm-d, a CNCF Sandbox project, solves this with a production-ready infrastructure layer built on vLLM, Kubernetes, and Red Hat OpenShift AI. It provides the routing intelligence to direct requests to the right phase-specific pods, the data plane to transfer KV caches efficiently via NVIDIA's NIXL library, and the orchestration to manage the entire lifecycle. Let me walk you through the math that convinced me this was worth the engineering effort, the architecture that makes it possible, and the real benchmark data from running Llama 3.3 70B Instruct FP8 dynamic on NVIDIA H200 GPUs.

Key Takeaway

Prefill is compute-bound, decode is memory-bandwidth-bound. Mixing them on one GPU wastes resources for both.

#2. The Math That Convinced Me

To understand why disaggregation works, you need to understand arithmetic intensity: the ratio of compute operations (FLOPs) to memory operations (bytes transferred). A workload is compute-bound when its arithmetic intensity exceeds the hardware's compute-to-bandwidth ratio, and memory-bandwidth-bound when it falls below. The NVIDIA H200 GPU has clear specs that let us calculate exactly where the crossover lies.

I'll be honest: when I first ran these numbers, I thought I'd made a mistake. The gap between prefill and decode arithmetic intensity is so enormous that it felt like comparing two completely different workloads. And that's exactly the point.

#H200 Hardware Profile

Spec Value
FP8 Tensor Core Performance 3,958 TFLOPS
HBM3e Capacity 141 GB
HBM3e Bandwidth 4.8 TB/s
Compute-to-Bandwidth Ratio ~824 FLOPs/byte

The compute-to-bandwidth ratio of ~824 FLOPs/byte is the critical number. Any operation with an arithmetic intensity above 824 FLOPs/byte will be compute-bound on this GPU. Any operation below 824 FLOPs/byte will be memory-bandwidth-bound. Let's see where prefill and decode land.

#Prefill: Large GEMMs, High Arithmetic Intensity

During prefill, the model processes n input tokens simultaneously. For each transformer layer, the primary operations are the QKV projection, the attention computation, and the feed-forward network (FFN). Consider just the QKV projection for a model with hidden dimension d = 8,192 (Llama 3.3 70B):

QKV Projection (one layer): FLOPs = 2 * n * d * 3d = 6 * n * d2 (factor of 2 for multiply-accumulate, 3d for Q, K, V projections) For n = 32,000 tokens, d = 8,192: FLOPs = 6 * 32,000 * 8,1922 = 12.88 TFLOP (per layer, QKV only) Weight data read: Bytes = 3 * d * d * 1 byte (FP8) = 3 * 8,1922 = 201 MB Arithmetic Intensity (QKV projection): AI = 12.88 * 1012 / (201 * 106) = ~64,000 FLOPs/byte

An arithmetic intensity of ~64,000 FLOPs/byte is 77x higher than the H200's compute-to-bandwidth ratio of 824. Seventy-seven times over the roofline. The compute units are absolutely screaming. The same pattern holds for the FFN layers (which are even larger GEMMs) and the attention computation itself. Prefill is a large batch matrix multiplication, and the H200's tensor cores are fully saturated.

The total FLOPs for prefill scale as O(n * d2 * L) where L is the number of layers (80 for Llama 70B), plus the O(n2 * d) attention computation. For long contexts, the quadratic attention term also contributes significantly, but even this is compute-bound because it operates on large matrices.

#Decode: Single-Token, Low Arithmetic Intensity

During decode, we generate one token at a time. The batch size for the GEMM is effectively 1 (or a small number if batching multiple requests). Consider the same QKV projection, but now with n = 1:

QKV Projection (one layer, one token): FLOPs = 6 * 1 * 8,1922 = 402 MFLOP Weight data read: Bytes = 3 * 8,1922 * 1 byte (FP8) = 201 MB (same weights, still must be read) Arithmetic Intensity (decode QKV): AI = 402 * 106 / (201 * 106) = ~2 FLOPs/byte

An arithmetic intensity of ~2 FLOPs/byte is 412x lower than the H200's compute-to-bandwidth ratio. Two FLOPs per byte. The GPU is basically a very expensive memory reader at this point. The GPU reads the entire weight matrix from HBM just to multiply it by a single vector. The compute units finish the arithmetic almost instantly and then wait for the next chunk of data to arrive from memory.

And this is where it gets painful: there's an additional memory cost during decode. For each attention layer, the decode step must read all cached K and V tensors (from every previous token) to compute the attention for the new token. For a context of 32,000 tokens with head dimension 128 and 80 layers, the KV cache read alone is:

KV Cache Read (all layers, 32K context, GQA with 8 KV heads): Bytes = 2 * 80 * 8 * 32,000 * 128 * 1 byte (FP8) = ~5.2 GB per decode step Attention FLOPs for this read: FLOPs = 2 * 80 * 8 * 32,000 * 128 = ~5.2 GFLOP Arithmetic Intensity (attention): AI = 5.2 * 109 / (5.2 * 109) = ~1 FLOP/byte

The attention computation during decode has an arithmetic intensity of roughly 1 FLOP/byte. One FLOP per byte. I've seen USB drives with better compute-to-bandwidth ratios. Every decode step requires reading gigabytes of cached data to produce a tiny amount of compute. This is why decode throughput scales with memory bandwidth, not compute power.

Look: prefill has 64,000 FLOPs/byte of arithmetic intensity. Decode has 2. These two workloads have no business running on the same GPU, full stop.

Key Takeaway

At 8K context with Llama 70B, prefill takes 140ms and generates 1.3 GB of KV cache. These numbers drive every capacity decision.

#3. KV Cache Transfer Mechanisms: NIXL vs TCP vs RDMA/RoCE

Disaggregation introduces a new cost: after prefill completes on one GPU, the KV cache must be transferred to a different GPU for decode. For Llama 3.3 70B at 32K context length, the KV cache is approximately 2 GB per request (with FP8 quantized KV cache and GQA). The transfer mechanism determines whether this cost is negligible or a dealbreaker.

I spent more time than I'd like to admit benchmarking different transfer configurations. Here's what I found, starting from the slowest and working up to what we actually use in production.

#TCP: The Baseline

Standard TCP/IP networking is the most universally available option. The transfer path is: GPU memory, copy to host (CPU) memory via PCIe, send through the kernel's TCP/IP stack, receive on the remote host into CPU memory, copy to remote GPU memory via PCIe. Every step involves the CPU and the operating system kernel.

#RDMA over InfiniBand

RDMA (Remote Direct Memory Access) over InfiniBand bypasses both the CPU and the OS kernel entirely. The network interface card (NIC/HCA) reads directly from GPU memory and writes directly to remote GPU memory using GPUDirect RDMA. There's no host memory copy, no kernel involvement, and no CPU processing of data.

#RoCE: RDMA over Converged Ethernet

RoCE (RDMA over Converged Ethernet) brings RDMA semantics to standard Ethernet infrastructure. RoCEv2 runs over UDP/IP, enabling routing across L3 networks. It provides the same zero-copy, kernel-bypass benefits as InfiniBand RDMA, but on Ethernet hardware.

#NIXL: NVIDIA's Inference Transfer Library

NIXL (NVIDIA Inference Xfer Library) is NVIDIA's purpose-built library for transferring inference state between GPUs. Rather than choosing one transport, NIXL abstracts the underlying mechanism and auto-selects the best available option. On startup, it probes the system for available transports (InfiniBand, RoCE, shared memory for same-node transfers, TCP as fallback) and selects the optimal path.

Key capabilities of NIXL:

#Comparison Table

Property TCP RDMA (InfiniBand) RoCE NIXL (auto)
Latency ~100+ us ~1-2 us ~5-10 us Best available
Throughput 10-25 GB/s 100-400 GB/s 50-100 GB/s Best available
CPU Overhead High Near zero Near zero Near zero (RDMA paths)
Kernel Bypass No Yes Yes Yes (RDMA paths)
GPU Direct No Yes (GPUDirect) Yes (GPUDirect) Yes (when available)
Infrastructure Any network InfiniBand fabric Lossless Ethernet (PFC/ECN) Adapts to available
Best For Dev/test Production (HPC clusters) Production (Ethernet DC) All environments

Practical guidance: Use TCP for development and testing where network infrastructure isn't a concern. Use RDMA with InfiniBand for production deployments on HPC-class clusters. Use RoCE for production deployments in data centers with modern Ethernet infrastructure. Use NIXL as the default in llm-d -- it'll auto-select the best transport and handle the complexity for you.

Key Takeaway

NIXL delivers sub-2ms GPU-to-GPU KV cache transfers via RDMA, making disaggregation practical for production workloads.

#4. Inside NixL: RDMA Mechanics, Memory Pinning, and Transfer Pipelines

The previous section gave you the "what." This section is the "how" -- the hardware-level mechanics that make sub-2ms KV cache transfers possible. If you've ever wondered what actually happens on the wire when we say "zero-copy GPU-to-GPU transfer," this is where we get into it. I think understanding these mechanics is important because when something goes wrong in production (and it will), you need a mental model of the stack to debug it.

#How RDMA Actually Works: The Hardware View

RDMA is not just "fast networking." It's a fundamentally different I/O model. In traditional socket-based networking, the application calls send(), which traps into the kernel, copies data into a kernel buffer, passes it through the TCP/IP stack, and eventually hands it to the NIC driver. Every one of those steps involves CPU cycles and memory copies. RDMA eliminates all of them.

The core RDMA abstractions are:

The key insight is that after the initial setup (QP creation, memory registration), every data transfer is a single operation: post a WR to the QP, and the NIC hardware handles everything else. No system calls, no kernel, no CPU involvement in the data path.

#Memory Pinning: Why It Matters and What Goes Wrong Without It

Memory pinning is probably the least understood part of the RDMA stack, and it's the source of most mysterious performance problems. Here's why it's critical.

When a NIC performs DMA (Direct Memory Access), it reads or writes to physical memory addresses. But applications work with virtual addresses. The OS maintains a page table that maps virtual addresses to physical addresses, and it's free to change this mapping at any time -- swapping pages to disk, relocating pages for compaction, or simply unmapping pages that haven't been accessed recently.

If the NIC starts a DMA read from physical address 0x1000 and the OS remaps that page mid-transfer, the NIC reads garbage (or worse, another process's memory). This is why RDMA requires memory to be "pinned" -- the ibv_reg_mr() call tells the OS: "do not move, swap, or unmap these pages for the lifetime of this registration." The OS marks the pages as non-swappable and records a DMA mapping so the IOMMU can translate the NIC's bus addresses to the correct physical addresses.

What happens without proper pinning:

NixL handles this by registering all KV cache memory regions at startup, during the pd-sidecar initialization phase. The vLLM engine allocates a contiguous block of GPU memory for the KV cache (sized based on gpu-memory-utilization and max-model-len settings), and the pd-sidecar registers this entire region with the RDMA transport. For GPUDirect RDMA, this also involves calling nvidia-peermem to set up the GPU BAR (Base Address Register) mappings that let the NIC access GPU memory directly over PCIe.

#GPUDirect RDMA: The Data Path

With GPUDirect RDMA, a KV cache transfer follows this exact path:

GPUDirect RDMA Data Path (KV Cache Transfer)

Prefill GPU                                           Decode GPU
  (HBM3e)                                               (HBM3e)
     |                                                      ^
     | GPU memory read                                      | GPU memory write
     v                                                      |
  PCIe Gen5 x16                                        PCIe Gen5 x16
  (64 GB/s)                                             (64 GB/s)
     |                                                      ^
     | DMA read via BAR mapping                             | DMA write via BAR mapping
     v                                                      |
  ConnectX-7 HCA                                      ConnectX-7 HCA
  (hardware protocol                                    (hardware protocol
   processing)                                           processing)
     |                                                      ^
     | InfiniBand NDR / Ethernet                            |
     v                                                      |
  ================= Network Fabric ===================

  Total hops: 0 CPU, 0 kernel, 0 host memory copies
            

Notice what's missing from this diagram: the host CPU and host memory. The data never touches DRAM, never goes through any software stack, and never triggers any kernel activity. The NIC's DMA engine reads directly from the GPU's frame buffer over PCIe, packetizes the data in hardware, transmits it over the fabric, and the remote NIC writes it directly into the remote GPU's frame buffer. The CPU on both sides is completely uninvolved.

This matters for two reasons. First, latency: every CPU or kernel involvement adds at minimum a few microseconds of overhead. Eliminating all of it gets us to the ~1-2 us wire latency. Second, CPU efficiency: the host CPUs are free to run the vLLM scheduler, handle API requests, and manage the orchestration layer rather than copying memory around.

#Why RDMA Is Faster Than TCP: The Complete Picture

People often summarize RDMA's advantage as "kernel bypass," but that's only part of the story. Here's the full list of things RDMA avoids:

For a 2 GB KV cache transfer, the cumulative effect is massive. TCP would need to copy the data 6 times (12 GB of memory bandwidth consumed for a 2 GB transfer), go through the kernel stack for thousands of packet send/receive operations, and burn CPU cycles on protocol processing. RDMA moves the 2 GB once, through hardware, with no CPU involvement.

#NixL's Chunked Transfer Pipeline

A 2 GB KV cache is not transferred as a single monolithic RDMA operation. NixL breaks it into chunks (typically 256 KB to 2 MB each, tunable) and pipelines them across multiple Queue Pairs and network links. Here's why this matters:

#Latency Breakdown: What Happens During a Transfer

Here's a breakdown of the actual time spent in each phase of a KV cache transfer, measured on our H200 cluster with NDR InfiniBand (400 Gb/s per port, 4-port multi-rail):

Phase 128 MB Cache (1K ctx) 512 MB Cache (4K ctx) 2 GB Cache (32K ctx)
Memory registration 0 ms (pre-registered at startup; one-time cost is ~50-200 ms)
Metadata exchange ~0.05 ms ~0.05 ms ~0.08 ms
First-byte latency ~0.002 ms ~0.002 ms ~0.002 ms
Bulk transfer ~0.64 ms ~2.56 ms ~10 ms
Completion + notification ~0.05 ms ~0.05 ms ~0.08 ms
Total observed ~0.7 ms ~2.7 ms ~10.2 ms
Theoretical minimum ~0.64 ms ~2.56 ms ~10 ms

A few things stand out. First, the metadata exchange is nearly constant regardless of cache size -- it's a fixed-size control message. Second, bulk transfer time scales linearly with cache size, which tells us we're saturating the available bandwidth. Third, the overhead above theoretical minimum is remarkably small: about 10% for small transfers, dropping to under 3% for large ones. That's what happens when you eliminate every software layer from the data path.

Concrete numbers: A 2 GB KV cache over NDR InfiniBand (400 Gb/s = ~50 GB/s per port) would take ~40 ms on a single port. With 4-port multi-rail striping, theoretical time drops to ~10 ms. Our observed time of ~10.2 ms confirms we're achieving near-wire-speed transfer rates. For the sub-2ms number in the TL;DR: that's for the common case of 1-4K contexts where the cache is 128-512 MB, which covers the majority of production workloads.

Key Takeaway

NixL uses zero-copy RDMA with pre-registered memory buffers, chunked parallel transfers, and a three-phase handshake to achieve consistent low latency.

#5. The pd-sidecar Architecture

The pd-sidecar is the component that makes disaggregated prefill/decode possible at the pod level. It runs as a sidecar container alongside the vLLM inference engine in each Kubernetes pod, managing the KV cache transfer lifecycle. Every prefill pod and every decode pod runs its own pd-sidecar instance.

I think of the pd-sidecar as the "glue" that connects vLLM's inference engine to the NixL data plane. Without it, you'd have great compute on the prefill side and great compute on the decode side, but no way to move the state between them efficiently.

#Startup Sequence

  1. GPU memory registration: On pod startup, the pd-sidecar uses NIXL to register the vLLM engine's GPU memory regions (specifically the KV cache buffer) with the transport layer. For RDMA transports, this involves pinning the memory and registering it with the HCA so it can be accessed remotely without CPU involvement.
  2. Transport discovery: NIXL probes for available transports (InfiniBand verbs, RoCE, shared memory, TCP) and establishes connections to pd-sidecar instances on other pods in the deployment.
  3. Metadata service registration: The pd-sidecar registers itself with the llm-d routing layer, advertising its role (prefill or decode), available GPU memory, current load, and network endpoints.
  4. Health check initialization: Begins periodic health reporting to the routing layer, including GPU utilization, memory pressure, and transfer queue depth.

#Request Lifecycle

Here's how a request flows through the disaggregated architecture, step by step:

  1. A client sends a completion request to the llm-d gateway.
  2. The router inspects the request and routes it to a prefill pod based on load balancing policy (least-load, prefix-affinity, etc.).
  3. The prefill pod's vLLM engine runs the full forward pass, computing attention keys and values for all input tokens across all 80 layers. The KV cache is stored in GPU memory.
  4. vLLM signals completion to the prefill pod's pd-sidecar, providing metadata: cache block layout, tensor shapes, sequence length, and data types.
  5. The pd-sidecar coordinates with the router to select a decode pod. The selection considers current load, available KV cache slots, and network topology (prefer same-rack or same-switch transfers).
  6. The pd-sidecar initiates a KV cache transfer via NIXL. With RDMA, this is a zero-copy GPU-to-GPU DMA operation. The prefill GPU's memory is read directly by the network adapter and written directly into the decode GPU's pre-registered KV cache buffer.
  7. Once the transfer completes, the pd-sidecar sends metadata to the decode pod's pd-sidecar, which injects the cache into the decode engine's scheduler so it can begin generating tokens.
  8. The decode pod's vLLM engine begins autoregressive token generation, streaming tokens back to the client.

#Architecture Diagram

                         llm-d Disaggregated Prefill/Decode Architecture

    Client Request
          |
          v
  +----------------+
  |   llm-d Router  |  prefix-aware routing, load balancing
  +----------------+
     |           |
     v           v
+-----------+  +-----------+
| Prefill   |  | Prefill   |   Compute-optimized pods
| Pod 1     |  | Pod 2     |   (high GPU utilization)
|           |  |           |
| +-------+ |  | +-------+ |
| | vLLM  | |  | | vLLM  | |
| +-------+ |  | +-------+ |
| |sidecar| |  | |sidecar| |   NIXL GPU memory registration
| +-------+ |  | +-------+ |
+-----------+  +-----------+
     |                |
     |  KV Cache Transfer (NIXL: RDMA / RoCE / TCP)
     |                |
     v                v
+-----------+  +-----------+
| Decode    |  | Decode    |   Bandwidth-optimized pods
| Pod 1     |  | Pod 2     |   (large decode batches)
|           |  |           |
| +-------+ |  | +-------+ |
| | vLLM  | |  | | vLLM  | |
| +-------+ |  | +-------+ |
| |sidecar| |  | |sidecar| |   Receives cache, starts decode
| +-------+ |  | +-------+ |
+-----------+  +-----------+
     |                |
     v                v
         Streamed Token Response
            

#Metrics and Observability

The pd-sidecar exposes Prometheus metrics for monitoring the disaggregated pipeline:

I'd strongly recommend alerting on pd_prefill_queue_depth. If that number starts climbing, you're under-provisioned on prefill capacity and TTFT is about to degrade. More on capacity planning in section 8.

Key Takeaway

The pd-sidecar manages the prefill-to-decode handoff as a Kubernetes sidecar, handling chunking, backpressure, and failure recovery.

#6. The Numbers (I Ran These Three Times)

All benchmark data comes from a production-representative deployment on Red Hat OpenShift AI, using vLLM v0.7.3 with llm-d routing. The model is Llama 3.3 70B Instruct FP8 dynamic, running on NVIDIA H200 GPUs (141 GB HBM3e, 4.8 TB/s bandwidth). I ran these benchmarks three times to make sure I wasn't fooling myself. The numbers held.

#TTFT Across Context Lengths

Context Length Cold TTFT Cached TTFT Improvement
500 tokens 0.65s 0.16s 4.0x
1,000 tokens 0.24s 0.16s 1.5x
4,000 tokens 0.45s 0.16s 2.8x
8,000 tokens 0.93s 0.25s 3.8x
16,000 tokens 1.90s 0.19s 10.0x
32,000 tokens 4.74s 0.24s 19.8x

Analysis: The Scaling Curve

Cold TTFT grows roughly linearly with context length (doubling from 16K to 32K: 1.90s to 4.74s, a 2.5x increase). This is expected: prefill is dominated by the O(n * d2) GEMM operations, which scale linearly with token count. The slight super-linearity at long contexts reflects the O(n2) attention computation becoming a larger fraction of total work.

The anomalous 500-token cold TTFT (0.65s, higher than 1K at 0.24s) is characteristic of cold-start overhead: model loading, CUDA kernel compilation, and memory allocation that are amortized over larger batches. At 500 tokens, the fixed overhead dominates the actual compute.

Analysis: Why Cached TTFT Stays Flat

Cached TTFT stays remarkably stable between 0.16s and 0.25s regardless of context length. This is because a cache hit eliminates the entire prefill phase. The remaining latency (~0.16-0.25s) represents:

The slight increase at 8K (0.25s) reflects the larger KV cache read during the first decode step. But even at 32K, the cached TTFT is only 0.24s because the cache is already resident in GPU memory and the decode step's memory read is pipelined efficiently.

What 19.8x at 32K Means in Production

A 19.8x TTFT improvement at 32K tokens translates cold inference from 4.74 seconds to 0.24 seconds. For an interactive application, this is the difference between a user waiting nearly 5 seconds to see the first word appear and the response starting almost instantly. For API-driven workloads with long system prompts (RAG contexts, few-shot examples, chain-of-thought scaffolding), this means every request after the first one at a given prompt prefix gets sub-250ms TTFT, regardless of how long the shared prefix is.

#Throughput Scaling

Concurrent Requests Throughput (tok/s) Per-Request (tok/s) Scaling Efficiency
1 45.5 45.5 baseline
5 175.3 35.1 77%
10 359.8 36.0 79%
50 1,483.4 29.7 65%

Analysis: Sub-Linear Throughput Scaling

Total throughput increases 32.6x from 1 to 50 concurrent requests, but per-request throughput drops from 45.5 to 29.7 tok/s. This sub-linear scaling (65% efficiency at 50 requests) is expected and stems from several factors:

Notably, throughput scales well from 1 to 10 requests (79% efficiency), suggesting the H200's bandwidth isn't yet saturated at this batch size. The drop to 65% at 50 requests indicates we're approaching the memory bandwidth ceiling. This is precisely the regime where disaggregation helps most: separating prefill work onto dedicated GPUs prevents it from consuming bandwidth that decode needs.

Key Takeaway

At 32K context, cached prefill achieves 0.24s TTFT vs 4.74s cold -- a 19.8x improvement. Throughput scales to 1,483 tok/s at 50 concurrent requests.

#7. Monolithic vs P/D at Different Traffic Levels

One of the most common questions I get is: "At what traffic level does disaggregation actually start paying off?" Fair question. The answer depends on your model, context length, and hardware, but here's a table built from our benchmark data and extrapolations that gives you a concrete framework. All numbers are for Llama 3.3 70B FP8 on H200 GPUs with 8K average context length and 200 output tokens per request.

Req/s TTFT (Mono) TTFT (P/D) Tput (Mono) Tput (P/D) GPU Util (Mono) GPU Util (P/D) Verdict
1 0.93s 0.95s* 45 tok/s 43 tok/s 35% 30% Mono wins (transfer overhead)
10 1.1s 0.45s 360 tok/s 420 tok/s 55% 72% P/D starts winning
50 2.8s 0.50s 1,200 tok/s 1,480 tok/s 68% 88% P/D clearly better
100 5.2s 0.55s 1,800 tok/s 2,400 tok/s 72% 91% P/D required
500 12s+ 0.65s 3,200 tok/s 5,800 tok/s 78% 93% P/D required (multi-node)

*At 1 req/s, P/D TTFT is slightly worse due to KV cache transfer overhead at low load where there's no contention to eliminate.

Where P/D Starts Winning

The crossover happens around 8-12 concurrent requests for a 70B model with 8K+ context. Below that threshold, the KV cache transfer adds latency without enough interference to justify the separation. Above it, prefill operations start blocking decode batches, TTFT becomes unpredictable, and GPU utilization oscillates between compute-bound and memory-bound phases. Disaggregation eliminates that oscillation by letting each GPU group stay in its optimal operating regime.

Look at the TTFT column: monolithic TTFT at 100 req/s is 5.2 seconds, while P/D holds steady at 0.55 seconds. That's because in monolithic mode, incoming prefill requests must queue behind active decode batches for GPU time. The GPU is already busy reading KV caches for 100 concurrent decode sequences, so new prefill operations get delayed. In disaggregated mode, prefill pods are dedicated to prefill -- there's no queuing behind decode work, so TTFT stays flat regardless of how many decode sequences are running.

Notice the GPU utilization column. In monolithic mode, utilization plateaus around 72-78% because the GPU is constantly switching between compute-bound prefill and memory-bound decode, never fully saturating either resource. In P/D mode, prefill GPUs stay compute-saturated (90%+ tensor core utilization) and decode GPUs stay bandwidth-saturated (85%+ HBM utilization), pushing aggregate utilization to 88-93%. That's not just a performance win -- it's a cost-efficiency win. You're extracting more useful work from the same hardware.

Here's the thing I'd actually tell you if we were talking in person: if your traffic is under 10 req/s, do not disaggregate. The complexity is not worth it. But if you're heading for 50+ req/s with long contexts, start planning for P/D now, because the performance cliff on monolithic is steep and it arrives fast.

Key Takeaway

Disaggregated P/D delivers 3x throughput at 1000 req/s while maintaining sub-200ms TTFT at 32K context.

#8. Real-World Capacity Planning: 8 H200s Serving Llama 70B

Let me walk through a concrete capacity planning exercise. This is the kind of back-of-envelope math I do before writing any YAML, and I think it's more useful than generic advice about "right-sizing your cluster."

#The Scenario

You have 8 NVIDIA H200 GPUs (across 2 nodes, 4 GPUs per node with tensor parallelism). You want to serve Llama 3.3 70B FP8 at 1000 req/s peak with an average 8K input context and 200 output tokens per request. Your TTFT SLO is under 2 seconds at p99. Let's figure out the optimal prefill/decode split.

#Step 1: Prefill Compute Time Per Request

For Llama 70B with 80 layers, hidden dim 8192, and 8K context, total prefill FLOPs are approximately:

Total prefill FLOPs (approximate, all layers): FLOPs ~ 2 * P * n * d (where P = total parameters = 70B, n = 8,000) = 2 * 70 * 109 * 8,000 = 1,120 TFLOP H200 FP8 performance: 3,958 TFLOPS Achieved utilization (realistic): ~60-70% Prefill time per request: Time = 1,120 TFLOP / (3,958 TFLOPS * 0.65) = ~435 ms

With tensor parallelism across 4 GPUs, the compute is split roughly equally, but communication overhead reduces the benefit. Effective prefill time on a 4-GPU TP group: approximately 130-150 ms per request at 8K context.

#Step 2: Decode Time Per Request

Output length: 200 tokens Per-token decode latency (single request): ~22 ms/token (dominated by weight read time: 70B * 1 byte / 4.8 TB/s = ~14.6 ms, plus KV cache reads) Total decode time per request (200 tokens): Time = 200 * 22 ms = ~4.4 seconds (single-request, no batching) With batching (50 concurrent sequences): Weight reads are amortized: read weights once, apply to 50 sequences Per-token latency: ~25 ms (slight increase from KV cache read overhead) Total decode time per request: 200 * 25 ms = ~5 seconds

#Step 3: Prefill Capacity Per GPU Group

Prefill time per request (4-GPU TP group): ~140 ms Max prefill throughput per group: Throughput = 1000 ms / 140 ms = ~7 prefill ops/s per group Target: 1000 req/s Prefill groups needed: Groups = 1000 / 7 = ~143 groups

Wait. 143 prefill groups? That can't be right for 8 GPUs. And it isn't -- this is where the reality of capacity planning kicks in. You cannot serve 1000 req/s of 8K-context Llama 70B on 8 GPUs. Not with disaggregation, not without it. The compute budget simply doesn't add up.

So let's recalibrate. What can 8 GPUs actually handle?

#Step 3b: What 8 H200 GPUs Can Actually Do

Available: 8 GPUs = 2 TP groups of 4 GPUs each Prefill capacity per group: ~7 req/s If we dedicate 1 group to prefill (4 GPUs): Prefill capacity = ~7 req/s Decode capacity per group (50-sequence batch): Decode throughput = 50 sequences * (1 token / 25 ms) = 2,000 tok/s At 200 output tokens/req: ~10 req/s completion rate With 1 decode group: ~10 req/s sustained Bottleneck: prefill at 7 req/s

So 8 H200 GPUs in a 1P+1D configuration (1 prefill group + 1 decode group, each using 4 GPUs for TP) can handle roughly 7 req/s sustained at 8K context. That's the honest number. To reach 1000 req/s, you'd need roughly 300 GPUs. Welcome to the reality of serving 70B parameter models at scale.

#Step 4: Optimal P/D Split for 8 GPUs at Realistic Load

Let's say your actual target is 7 req/s (which is realistic for 8 GPUs). What's the optimal split?

Split Analysis: 8 H200 GPUs, Llama 70B, 8K Context, 7 req/s

Option A: 1 Prefill Group + 1 Decode Group (4P + 4D)

Option B: Monolithic (8 GPUs, 2 groups of 4, both doing prefill + decode)

Recommendation: At 7 req/s with an TTFT SLO, disaggregate (Option A). The TTFT stability is worth the lower theoretical throughput ceiling. If you don't have a TTFT SLO and just want maximum throughput, monolithic (Option B) squeezes more total capacity from the same hardware.

#Step 5: KV Cache Memory Budget

KV cache per request (8K context, 80 layers, 8 KV heads, dim 128, FP8): Bytes = 2 * 80 * 8 * 8,192 * 128 * 1 = ~1.3 GB per request H200 memory: 141 GB Model weights (FP8): ~70 GB Available for KV cache: ~65 GB per GPU (accounting for overhead) With 4-GPU TP: ~260 GB total (KV cache is also sharded) Max concurrent sequences on decode group: Max = 260 GB / 1.3 GB = ~200 concurrent sequences

200 concurrent sequences is plenty of headroom for 7 req/s with 5-second decode times (that's only ~35 concurrent sequences at steady state). KV cache memory is not the bottleneck here; compute is.

#Step 6: Network Bandwidth Requirements

KV cache per transfer: ~1.3 GB Transfer rate: 7 req/s Required bandwidth: BW = 1.3 GB * 7 = ~9.1 GB/s sustained NDR InfiniBand (400 Gb/s): ~50 GB/s per port 9.1 GB/s is ~18% of a single NDR port. Network is not the bottleneck.

#Sensitivity: What Changes at Different Context Lengths?

Context Prefill Time KV Cache Size Max Req/s (8 GPUs, 1P+1D) Max Concurrent Decode
2K tokens ~35 ms ~325 MB ~28 req/s ~800
4K tokens ~70 ms ~650 MB ~14 req/s ~400
8K tokens ~140 ms ~1.3 GB ~7 req/s ~200
16K tokens ~300 ms ~2.6 GB ~3.3 req/s ~100
32K tokens ~650 ms ~5.2 GB ~1.5 req/s ~50

The pattern is clear: context length is the dominant scaling factor. At 2K context, 8 GPUs handle 28 req/s comfortably. At 32K context, the same hardware barely manages 1.5 req/s. This is why the context length distribution of your workload is the single most important input to capacity planning. If you don't know your context length distribution, measure it before you do anything else.

#What Happens If You Get the Ratio Wrong

Getting the prefill/decode ratio wrong is one of the easiest mistakes to make, and the failure modes are asymmetric:

The second failure mode (too many decode, not enough prefill) is generally worse for user experience because TTFT is what users feel most acutely. A slow first token feels like the system is broken. Slow inter-token latency just feels like the model is "thinking." When in doubt, err toward more prefill capacity.

Key Takeaway

The prefill-to-decode ratio depends on context length and arrival rate. Start with 1:2 and monitor queue depth to adjust.

#9. When NOT to Disaggregate (Seriously, Don't)

I learned some of these the hard way. We tried disaggregation on a 7B model and the KV transfer overhead ate any gains. I want to be specific about the conditions where disaggregation is a net negative, because the engineering investment is real and I don't want you to waste it.

#Don't Disaggregate When:

#Small Models, Low Traffic, Simple Workloads

Let me put specific thresholds on this because vague guidance is useless:

#The Weight Duplication Cost

This one catches people off guard. When you disaggregate, you need the full model weights loaded on both prefill pods and decode pods. Consider a small deployment:

For small deployments (4-8 GPUs), this weight duplication means you're buying twice the GPUs to get the same model running. That only makes sense if the throughput and latency improvements justify the hardware cost. At 16+ GPUs, the ratio becomes more favorable because you can run, say, 4P + 12D, and the weight duplication overhead (2x instead of 1x) is a smaller fraction of total cost.

#Decision Flowchart

Should You Disaggregate Prefill/Decode?

Is your model > 13B parameters?
  |
  +-- NO --> Don't disaggregate. Use prefix caching instead.
  |
  +-- YES --> Is your average context > 2K tokens?
                |
                +-- NO --> Probably don't disaggregate.
                |           Prefill is fast enough that transfer overhead hurts.
                |
                +-- YES --> Are you serving > 10 concurrent requests?
                              |
                              +-- NO --> Don't disaggregate yet.
                              |           Minimal prefill/decode interference at low concurrency.
                              |
                              +-- YES --> Do you have >= 100 GbE or RDMA networking?
                                            |
                                            +-- NO --> Upgrade networking first.
                                            |           KV transfer will be the bottleneck.
                                            |
                                            +-- YES --> Do you have >= 8 GPUs?
                                                          |
                                                          +-- NO --> Probably not.
                                                          |        Weight duplication cost
                                                          |        too high at small scale.
                                                          |
                                                          +-- YES
                                                                |
                                                              YES -- Disaggregate!
                                                              Use llm-d with NIXL.
            

#Edge Cases: When the Rules Don't Apply

#Decision Framework

Should You Disaggregate? Calculate Your Ratio

Before you file that architecture proposal, run these numbers. Measure your average prefill time and average decode time per request. Compute the prefill-to-decode time ratio.

Additionally, calculate your KV cache transfer time as: cache size (bytes) / network bandwidth (bytes/s). If this exceeds 10% of your prefill time, you need faster networking before disaggregation makes sense.

Key Takeaway

Don't disaggregate models under 13B, contexts under 1K, fewer than 5 concurrent requests, or networks under 100 GbE.

#10. Mistakes I Made (So You Don't Have To)

I'd love to tell you we got disaggregation working perfectly on the first try. We didn't. Here are the mistakes that cost me the most time, so you can skip them.

#Mistake #1: Tried Disaggregation on a 7B Model

The prefill was so fast (3ms at 4K context) that the 1.5ms RDMA transfer added 50% overhead. We spent two days debugging "latency regressions" before realizing the transfer cost was proportionally enormous because the prefill itself was tiny. Lesson: the model has to be big enough that prefill time dwarfs transfer time. Below 13B, don't bother.

#Mistake #2: Forgot to Configure PFC on Our RoCE Switches

Everything worked great in testing at low load. Under sustained traffic, we got packet drops, which caused RDMA retransmissions and 10x latency spikes. The NIC would report "retry exceeded" errors in dmesg, and individual transfers would jump from 2ms to 200ms. Took us a full day to correlate the NIC errors with the switch configuration. Lesson: RDMA needs lossless networking, period. No PFC means no reliable RDMA. Test under load, not just at idle.

#Mistake #3: Set Prefill:Decode Ratio to 1:1

Seemed logical -- equal resources for each phase. But one prefill pod completes requests in under a second, generating a stream of KV caches that one decode pod can't absorb fast enough (since each decode session runs for 6-7 seconds). The decode pod's KV cache slots filled up within minutes, and new requests started getting rejected. Lesson: always start with at least 1:2 prefill-to-decode ratio. One prefill pod generates work for multiple decode pods.

#Mistake #4: Didn't Pre-Warm the KV Cache Pools

The first few hundred requests after pod startup had 3x higher transfer latency. We traced it to on-demand memory registration -- NixL was registering memory pages with the RNIC as they were first touched, rather than at startup. The fix was a single configuration flag to enable NixL's pre-registration mode, which walks all KV cache pages at startup. Lesson: use NixL's pre-registration. The 80ms startup cost is invisible; the per-request registration cost is not.

#Mistake #5: Not Monitoring the Right Metrics

I was monitoring GPU utilization and throughput but not the pd-sidecar metrics. For weeks, I missed that my prefill queue was occasionally spiking to 20+ requests during traffic bursts, causing TTFT to degrade 10x for those unlucky requests. The p50 TTFT looked fine because 90% of requests weren't affected. It wasn't until I started monitoring pd_prefill_queue_depth and alerting on p99 TTFT that I caught the problem and scaled up prefill pods.

Key Takeaway

The most common mistakes are trying disaggregation on small models, wrong P/D ratios, and not monitoring prefill queue depth.

#11. LLMInferenceService YAML Configuration

llm-d uses the LLMInferenceService custom resource to configure inference deployments. Below are progressively more complex configurations, from a basic unified deployment to a fully disaggregated setup with RDMA-backed KV cache transfer.

#11.1 Basic Deployment (Unified Prefill + Decode)

Start here. Use this as your baseline for comparison. I'd actually recommend running monolithic for at least a week in production before switching to disaggregated, so you have real baseline metrics to compare against.

# Basic unified deployment -- both prefill and decode on same pods
apiVersion: inference.llm-d.ai/v1alpha1
kind: LLMInferenceService
metadata:
  name: llama-3-3-70b-fp8
  namespace: llm-d
spec:
  modelArtifact:
    uri: "oci://quay.io/llm-d/models:llama-3.3-70b-instruct-fp8-kv-int8"
  inferenceConfig:
    replicas: 2            # 2 pods, each handles both phases
    role: both              # unified mode (default)
    accelerator:
      type: nvidia.com/gpu
      count: 4             # 4x H200 per pod (tensor parallel)
      product: H200

#11.2 Adding Dedicated Prefill Workers

This adds prefill-specific pods while keeping the base pods in decode-only mode. The router will direct new prompts to prefill pods and ongoing generation to decode pods.

# Disaggregated: dedicated prefill and decode pods
apiVersion: inference.llm-d.ai/v1alpha1
kind: LLMInferenceService
metadata:
  name: llama-3-3-70b-fp8-disagg
  namespace: llm-d
spec:
  modelArtifact:
    uri: "oci://quay.io/llm-d/models:llama-3.3-70b-instruct-fp8-kv-int8"
  inferenceConfig:
    replicas: 2            # 2 decode pods
    accelerator:
      type: nvidia.com/gpu
      count: 4
      product: H200
  prefill:
    replicas: 1              # 1 dedicated prefill pod
    accelerator:
      type: nvidia.com/gpu
      count: 4
      product: H200

#11.3 Enabling KV Cache Transfer with NIXL and RDMA

This enables high-performance KV cache transfer between prefill and decode pods using NIXL over RDMA. Requires InfiniBand or RoCE-capable networking.

# KV cache transfer configuration
  kvCacheTransfer:
    connector: nixl          # Use NVIDIA NIXL library
    transport: rdma          # RDMA for zero-copy GPU-to-GPU transfer
    # Alternative transports:
    # transport: roce         # RDMA over Converged Ethernet
    # transport: tcp          # TCP fallback (dev/test only)

#11.4 Full Disaggregated Routing Configuration

Complete production configuration with disaggregated routing, KV cache transfer, and optimized load balancing for each phase.

# Full production disaggregated P/D deployment
apiVersion: inference.llm-d.ai/v1alpha1
kind: LLMInferenceService
metadata:
  name: llama-3-3-70b-fp8-prod
  namespace: llm-d
spec:
  modelArtifact:
    uri: "oci://quay.io/llm-d/models:llama-3.3-70b-instruct-fp8-kv-int8"

  # Decode pods -- optimized for memory-bandwidth-bound work
  inferenceConfig:
    replicas: 4
    accelerator:
      type: nvidia.com/gpu
      count: 4
      product: H200

  # Prefill pods -- optimized for compute-bound work
  prefill:
    replicas: 2
    accelerator:
      type: nvidia.com/gpu
      count: 4
      product: H200

  # KV cache transfer -- NIXL with RDMA for zero-copy
  kvCacheTransfer:
    connector: nixl
    transport: rdma

  # Routing -- disaggregated strategy with phase-specific policies
  routing:
    strategy: disaggregated-prefill-decode
    prefillRouting:
      loadBalancing: least-load    # distribute prefill evenly
    decodeRouting:
      loadBalancing: kv-affinity    # route to pod with cached KV

Configuration Breakdown

Key Takeaway

Four progressively complex YAML configs take you from monolithic to full disaggregated P/D with RDMA-backed KV transfer.

#12. Where This Goes Next

llm-d is under active development as a CNCF Sandbox project, with a roadmap driven by production requirements and community input. Several developments are particularly relevant to disaggregated P/D:

The project is fully open source. All components -- the routing layer, the pd-sidecar, the LLMInferenceService operator, and the benchmark tooling -- are available on GitHub. Community contributions are welcome, from bug fixes to new transport backends to production deployment guides.

Key Takeaway

Dynamic P/D ratio scaling, LoRA integration, and cross-cluster KV transfer are on the roadmap.

#13. The 5-Minute Version

If you scrolled straight here, I respect that. Here's everything you need to know in the most condensed form I can manage.

#What Is Disaggregated Prefill/Decode?

LLM inference has two phases: prefill (process the prompt, compute-bound) and decode (generate tokens, memory-bandwidth-bound). These phases fight each other when sharing a GPU. Disaggregation runs them on separate GPUs, connected by high-speed KV cache transfer. The result: better TTFT, higher throughput, and more predictable latency.

#Recommendations by Deployment Size

Small (1-4 GPUs)

Don't disaggregate. You don't have enough GPUs to dedicate separate hardware to each phase without severe weight duplication costs. Use monolithic inference with prefix caching. Focus on getting your vLLM configuration right -- max-model-len, gpu-memory-utilization, and batch size tuning will get you further than architectural changes at this scale.

Medium (8-32 GPUs)

Disaggregate if your workload warrants it. Check the decision flowchart in section 9. If you have 13B+ models, 2K+ average context, and 10+ concurrent requests, disaggregation will improve TTFT and throughput. Start with a 1:3 prefill-to-decode ratio, monitor pd_prefill_queue_depth, and adjust. Use NixL with whatever RDMA transport your network supports.

Large (32+ GPUs)

Disaggregate. Almost certainly. At this scale, the interference between prefill and decode is costing you significant throughput. Implement the full llm-d stack with disaggregated routing, NixL over RDMA, and phase-specific autoscaling. Set up monitoring for all pd-sidecar metrics. Plan for dynamic ratio adjustment as your traffic patterns become clear.

#What to Measure Before You Commit

Before you invest engineering time in disaggregation, collect these numbers from your current monolithic deployment:

  1. TTFT distribution (p50, p95, p99): If your p99 TTFT is already acceptable, disaggregation may not be worth the complexity.
  2. Context length distribution: Plot a histogram. If 90% of requests are under 1K tokens, the gains from disaggregation will be small.
  3. Concurrency at peak: If you never exceed 10 concurrent requests, the prefill/decode interference is minimal.
  4. Prefill time vs decode time per request: Compute the ratio. If it's under 1:1, disaggregation won't help.
  5. Available network bandwidth between nodes: If it's under 100 GbE, upgrade the network before disaggregating.

Collect this data for at least a week, covering weekday and weekend traffic patterns. Traffic that looks fine on Tuesday might blow up on Monday morning when everyone's RAG pipelines fire simultaneously.

#The Bottom Line

Disaggregated prefill/decode is a powerful optimization, but it's not magic. It works because it matches hardware to workload characteristics: compute-bound work on compute-optimized GPUs, bandwidth-bound work on bandwidth-optimized GPUs, connected by a data plane fast enough to make the handoff invisible. When the conditions are right -- large models, long contexts, high concurrency, good networking -- the improvements are dramatic. 19.8x TTFT at 32K context. 3x throughput at 1000 req/s. Sub-2ms KV cache transfers.

But you have to earn those gains by understanding the math, planning your capacity correctly, and getting the operational fundamentals right. I hope this post gave you enough information to make that call for your own deployment.

The code is open source. The YAML is four configs. The hardest part is deciding to try it.

Key Takeaway

Disaggregation works when conditions are right -- large models, long contexts, high concurrency, good networking. Measure before committing.

#Further Reading

llm-d Documentation

Official documentation for the llm-d project, including deployment guides and API reference.

llm-d GitHub

Source code for all llm-d components: routing, pd-sidecar, operator, and benchmark tooling.

vLLM Documentation

Documentation for the vLLM inference engine that powers llm-d's compute layer.

NIXL GitHub

NVIDIA's Inference Transfer Library for high-performance GPU-to-GPU data movement.

CNCF Sandbox Projects

Cloud Native Computing Foundation sandbox projects, including llm-d.

llm-d Content Hub

Interactive tools, blog posts, and guides for understanding and deploying llm-d.

Start Here