The Moment of Truth: Diagnosing a CUDA Graph Replay Failure in Speculative Decode
Introduction
In any performance optimization effort, there comes a critical moment when assumptions collide with data. The optimization work may have been brilliant—custom CUDA kernels, careful tuning, architectural insight—but if the optimized code path isn't actually executing in production, all that effort yields zero benefit. Message [msg 12321] captures exactly such a moment in a high-stakes speculative decoding deployment on NVIDIA RTX PRO 6000 Blackwell GPUs. The assistant, having built a custom sm_120 verify attention kernel with CUDA graph support achieving 3–6× speedup over Triton in microbenchmarks, confronts the confounding reality that production decode throughput remains abysmal—and must determine whether the bottleneck is memory scatter, Python marshaling overhead, or a failure of the CUDA graph infrastructure itself.
This message is a masterclass in diagnostic reasoning under uncertainty. It demonstrates how to systematically eliminate hypotheses, interpret ambiguous hardware metrics, and design the single decisive experiment that cuts through the noise. The result is both a technical revelation and a narrative turning point in the optimization journey.
Context: The Long Road to Blackwell Optimization
To understand the significance of this message, we must first appreciate the journey that led to it. The broader session (Segment 66) was focused on deploying a 200k context-length speculative decoding service for the Kimi K2.6 model on RTX PRO 6000 Blackwell GPUs (sm_120 architecture). The assistant had already achieved several notable wins:
- Deployed 200k context-length on the CT200 server by tuning
mem-fraction-staticto 0.94 andmax-running-requeststo 8, achieving a KV pool of 218k tokens. - Built a custom sm_120 verify attention kernel (
verify_attn_flash.cu) after discovering that all optimized MLA kernels (FlashMLA, cutlass-MLA, flashinfer-MLA) were compiled only for sm_90a/sm_100a/sm_103a—none supported sm_120 (RTX PRO 6000 Blackwell consumer). The kernel used a KV-split flash-decode design with partial+reduce to improve occupancy. - Made the kernel CUDA graph capture-safe by rewriting it to consume SGLang's native static buffers directly, with no host syncs, copies, or
cudaMalloccalls. Graph capture succeeded in ~1.5 seconds. - Optimized the kernel by increasing
NSPLITfrom 16 to 64 and adding 128-bit vectorized bf16 KV loads, delivering a dramatic 3–6× end-to-end decode speedup over Triton+graphs across all context lengths (4k–65k) in microbenchmarks. - Implemented Tier 0 KV defragmentation by monkeypatching the allocator to force
need_sort=True, keeping per-request KV contiguous on churned pools. Yet despite these achievements, the live service was still showing terrible decode throughput—only 5.2 tokens/second at 46k context length. The user's GPU utilization screenshots (msg 12320) showed both DRAM and tensor cores sitting essentially idle throughout decode. Something was fundamentally wrong.
The Message: A Diagnostic Pivot
Message [msg 12321] opens with the assistant synthesizing the profiler data it has just collected. The key numbers are stark:
The profiler data is clear: the tree building step takes 1.81ms per iteration while mask preparation is only 0.18ms, and the hardware metrics show both DRAM and tensor cores sitting idle throughout the entire decode phase even at 46k context length with a 5.2 token throughput.
This is a devastating finding. The assistant had previously hypothesized (in msg 12313–12315) that the bottleneck was CPU-side orchestration—the Python heap-based tree construction, the mask preparation loops, the per-step metadata copying. These hypotheses were entirely reasonable: the _build_ddtree_verify_input function runs a Python for i in range(bs) loop calling build_ddtree_tree_from_topk via heapq, and prepare_for_verify builds a full custom mask via torch.arange(prefix_len) + torch.cat in a Python loop every step. Both are O(context) CPU operations that should scale poorly.
But the profiler data proved these hypotheses wrong. The tree build took only 1.81ms. The mask prep took only 0.18ms. Together they accounted for less than 2ms of the ~192ms step time. The assistant's earlier reasoning about CPU orchestration being the dominant bottleneck was incorrect—a mistake that the profiler data now corrected.
This is a crucial lesson in performance engineering: always measure before optimizing. The assistant had spent significant effort analyzing the CPU tree-build code, considering GPU kernel integration, and planning optimizations—all for a bottleneck that turned out to be negligible. The profiler, deployed in msg 12316 and triggered in msg 12318, provided the data needed to redirect effort toward the real problem.
The Reasoning: Two Candidate Hypotheses
With the CPU orchestration hypothesis eliminated, the assistant constructs a new diagnostic framework. The remaining ~190ms per step must be explained. The hardware metrics show both DRAM utilization (~2%) and tensor core utilization (0%) near-idle throughout decode. The assistant identifies two plausible candidates:
Candidate A: The verify graph isn't being replayed. If the CUDA graph capture succeeded but the runtime is not replaying the graph during decode, then each decode step would execute the full eager Python path for all 61 layers of the target model. This would mean per-layer Python ctypes marshaling, individual kernel launches, and synchronization overhead—all of which could keep the GPU idle between tiny kernel launches while Python churns through the launch loop.
Candidate B: Scattered paged-KV latency. The KV cache is stored in a paged memory pool with page_size=1. Even though the custom verify kernel is efficient with contiguous data (as demonstrated in microbenchmarks), in production the KV slots for a single request may be scattered across the memory pool due to fragmentation from previous allocations. This would make every gather operation latency-bound, with the GPU spending most of its time waiting on scattered memory reads rather than computing.
The assistant's reasoning in the message shows careful weighing of both candidates:
At 46k context with my kernel running on a graph, I'm reading 3.2GB of KV data per step through scattered indices. At 2% DRAM utilization (36GB/s effective bandwidth), that's about 89ms just for the memory transfer—which actually lines up with part of the missing time.
This calculation is revealing: even with the optimized kernel, scattered KV reads at 2% DRAM utilization would account for roughly half the missing time. But the assistant correctly notes that 2% DRAM utilization is suspiciously low—it suggests the GPU isn't even doing the scattered reads continuously, but rather is stalled between infrequent operations.
But here's the catch: my kernel was supposed to be efficient based on microbenchmarks, yet in production with paged KV indices (scattered memory layout), it's hitting the same latency-bound bottleneck that plagued the original Triton implementation.
The assistant is confronting a painful truth: the microbenchmark results (3–6× speedup) were achieved with contiguous KV data, but the production environment has fragmented KV. The custom kernel's gather operations become latency-bound when indices are scattered, just like Triton's were. The defragmentation work (Tier 0) was supposed to fix this, but the assistant had only just implemented it and wasn't sure it was active.
However, the assistant also considers a third possibility that bridges both candidates:
But I should also consider whether the bottleneck is actually CPU-bound instead—the draft model orchestration, detokenization, or scheduler overhead could be keeping the GPU idle while Python handles the work.
This is the key insight. If the CUDA graph isn't being replayed, then Python is launching kernels one at a time for each of the 61 layers, and the GPU spends most of its time waiting for the next kernel launch. This would explain both the low DRAM utilization (the GPU isn't actively reading memory because it's idle between launches) and the low tensor utilization (same reason).
The Decisive Experiment
The assistant designs the single experiment that can distinguish between the two candidates: check whether the decode batch is actually replaying the CUDA graph. The command is simple but decisive:
timeout 20 ssh -o StrictHostKeyChecking=no root@10.1.230.171 \
'journalctl -u sglang-k26-ddtree --since "12 min ago" --no-pager 2>/dev/null \
| grep -oE "Decode batch.*cuda graph: (True|False)|cuda graph: (True|False)" \
| sort | uniq -c | tail; \
echo "--- raw decode lines ---"; \
journalctl -u sglang-k26-ddtree --since "12 min ago" --no-pager 2>/dev/null \
| grep -E "Decode batch" | tail -3'
The result is damning:
1 Decode batch, #running-req: 1, #token: 63974, token usage: 0.49, accept len: 3.27, accept rate: 0.33, cuda graph: True
36 cuda graph: False
Out of 37 decode steps, only one used the CUDA graph. The other 36 ran eagerly. This is the root cause of the performance disaster.
Why This Matters: The Graph Replay Failure
The discovery that 36 out of 37 decode steps are running eagerly, not replaying the CUDA graph, explains every symptom:
- Low DRAM utilization (~2%): With eager execution, each of the 61 layers launches its kernels individually through Python ctypes. Between launches, Python is busy marshaling arguments, resolving tensor pointers, and calling into the CUDA driver. The GPU sits idle during these CPU operations, resulting in very low DRAM activity.
- Zero tensor core utilization: Same reason—the GPU isn't computing because it's waiting for the next kernel launch.
- ~192ms step time: With 61 layers running eagerly, each requiring multiple kernel launches (attention, MLP, etc.), the total number of kernel launches per step is in the hundreds. Each launch involves Python overhead, CUDA driver overhead, and synchronization. At even 0.5–1ms of overhead per launch, hundreds of launches easily add up to 100–200ms.
- The one "cuda graph: True" step: This is likely the first decode step after prefill, where the graph is captured and replayed once. Subsequent steps may be falling back to eager execution due to some condition that invalidates the graph—perhaps the tree structure changes, the batch size changes, or the KV indices change in a way that the captured graph doesn't support. The assistant had previously made the kernel "capture-safe" by eliminating host syncs, copies, and
cudaMalloccalls. But capture-safety is only half the battle—the runtime must also choose to replay the graph. Something in the SGLang speculative decoding pipeline is preventing graph replay on subsequent steps.
Assumptions and Mistakes
This message reveals several assumptions that were implicitly held and now need revision:
Assumption 1: The CUDA graph would be replayed automatically. The assistant invested significant effort in making the kernel capture-safe, assuming that once captured, the graph would be replayed for every decode step. The data shows this assumption was wrong—the graph is captured but not consistently replayed.
Assumption 2: The bottleneck was CPU orchestration. The assistant spent messages 12313–12315 analyzing the CPU tree-build and mask-preparation code, planning GPU kernel integration to replace the heapq-based tree construction. The profiler data proved this was a red herring—those operations accounted for only ~2ms of the 192ms step.
Assumption 3: Scattered KV was the primary bottleneck. The assistant's earlier analysis (msg 12319) had leaned toward scattered paged-KV latency as the culprit, calculating that 2% DRAM utilization on 3.2GB reads would account for ~89ms. While scattered KV is still a problem, the graph replay failure is clearly the dominant issue—it explains the full 190ms gap, not just half of it.
Mistake: Not verifying graph replay earlier. The assistant had confirmed graph capture success (~1.5 seconds) and validated that generations matched the Triton baseline, but never checked whether the graph was actually being replayed during production decode. This is a classic oversight: verifying that a feature works in isolation (capture succeeds) without verifying that it works in the integrated system (replay happens).
Input Knowledge Required
To fully understand this message, the reader needs:
- Understanding of CUDA Graphs: CUDA graphs allow capturing a sequence of GPU kernel launches and replaying them with minimal CPU overhead. They're essential for reducing launch latency in latency-sensitive workloads like small-batch decode.
- Knowledge of speculative decoding architecture: The DFlash/DDTree pipeline involves a draft model (small, fast) that proposes multiple token candidates, and a target model (large, accurate) that verifies them. The "verify" step runs the target model's attention over the draft's proposed tokens.
- Understanding of paged KV cache: SGLang uses a paged attention mechanism where KV cache blocks are allocated from a pool. Fragmentation can cause a single request's KV to be scattered across non-contiguous pages.
- Knowledge of Blackwell sm_120 architecture: The RTX PRO 6000 uses sm_120, which has a different ISA than Hopper (sm_90) or Blackwell DC (sm_100/sm_103). Many optimized MLA kernels don't support sm_120.
- Familiarity with the profiler instrumentation: The assistant had added an environment-gated profiler (msg 12316) that logged per-operation timing after every 50 calls. The results from this profiler are the foundation of the analysis.
Output Knowledge Created
This message creates several important pieces of knowledge:
- Root cause identification: The primary bottleneck in production decode is CUDA graph replay failure, not CPU orchestration or scattered KV. 36 out of 37 decode steps run eagerly.
- Disconfirmation of the CPU orchestration hypothesis: The tree build (1.81ms) and mask prep (0.18ms) are negligible. Future optimization effort should not focus on these.
- Quantified gap: The gap between observed step time (~192ms) and accounted-for operations (~2ms tree/mask + unknown verify/draft) is ~190ms. Graph replay failure is the most plausible explanation.
- Diagnostic methodology: The message demonstrates a rigorous approach to performance debugging: (a) instrument to measure suspected bottlenecks, (b) use hardware metrics (DRAM%, tensor%) to constrain hypotheses, (c) design a single decisive experiment to distinguish between remaining candidates, (d) act on the result.
- A new question: Why does the graph replay fail on subsequent decode steps? This becomes the next problem to solve. Possible causes include: the tree structure changing between steps, the KV index buffer changing, the batch size changing, or a bug in the SGLang speculative decoding graph capture logic.
The Thinking Process
The assistant's reasoning in this message is a model of disciplined diagnostic thinking. Let me trace through it step by step:
Step 1: Synthesize profiler data. The assistant starts by stating the key numbers: tree build = 1.81ms, mask prep = 0.18ms, DRAM and tensor idle throughout decode. This establishes the known facts.
Step 2: Frame the mystery. "So the real mystery is where the remaining ~190ms per step is disappearing to if the GPU is essentially dormant the whole time." This reframes the problem: we know what's NOT causing it (CPU orchestration), so what is?
Step 3: Calculate the scattered-KV cost. The assistant estimates that reading 3.2GB of KV data at 2% DRAM utilization would take ~89ms. This is a useful calculation even though it doesn't fully explain the gap—it shows that scattered KV could account for part of the time but not all of it.
Step 4: Confront the microbenchmark vs. production discrepancy. The assistant honestly acknowledges that the custom kernel's microbenchmark results (3–6× speedup) don't translate to production because the KV is fragmented. This is intellectual honesty—admitting that the earlier success metric was misleading.
Step 5: Consider the CPU-bound hypothesis. The assistant broadens the search: "I should also consider whether the bottleneck is actually CPU-bound instead—the draft model orchestration, detokenization, or scheduler overhead." This is the key insight that leads to the graph replay check.
Step 6: Weigh the evidence. The assistant notes that DRAM shows ~2% (not zero) and tensor shows 0%, which is ambiguous—it could be scattered memory latency or CPU-bound with occasional small kernels. This ambiguity motivates the decisive experiment.
Step 7: Design the experiment. The assistant realizes that checking the "cuda graph: True/False" flag in the decode logs will definitively distinguish between the candidates. If the graph is being replayed, the bottleneck is scattered KV. If not, the bottleneck is eager Python marshaling.
Step 8: Execute and interpret. The result is clear: 36 out of 37 steps run eagerly. The mystery is solved.
The Broader Implications
This message has implications beyond the immediate debugging session:
For the assistant's optimization strategy: The focus must shift from kernel optimization (which is already good) to ensuring the kernel is actually executed. The graph replay infrastructure needs debugging—why does it fail after the first step?
For the defragmentation work: Tier 0 defrag (forcing need_sort=True) may help once graph replay is fixed, but it's not the immediate bottleneck. The assistant correctly deferred Tier 1 (live relocation) earlier, and this message confirms that decision was right.
For the user's expectations: The user had been pushing for aggressive optimization (GPU tree-build kernel, defrag, etc.). This message shows that the biggest win is not another optimization but fixing the integration of existing optimizations.
For the methodology: The message demonstrates the critical importance of end-to-end verification. Microbenchmarks and component-level tests can be misleading if the integrated system doesn't use the optimized path. The assistant's earlier verification (generations match, capture succeeds) was necessary but not sufficient—it didn't confirm replay.
Conclusion
Message [msg 12321] is a turning point in the optimization journey. After messages of building custom kernels, tuning parameters, and implementing defragmentation, the assistant confronts the uncomfortable truth that the optimized code path isn't actually executing in production. The response is a model of diagnostic rigor: eliminate hypotheses with data, design decisive experiments, and follow the evidence wherever it leads.
The discovery that 36 out of 37 decode steps run eagerly rather than replaying the CUDA graph reframes the entire optimization problem. The custom sm_120 verify kernel, the 3–6× microbenchmark speedup, the capture-safe design—none of it matters if the runtime doesn't replay the graph. The next phase of work must focus not on further optimization but on understanding and fixing the graph replay failure.
This message also serves as a cautionary tale about the gap between component-level optimization and system-level performance. A kernel that achieves 3–6× speedup in isolation can deliver zero benefit if the system doesn't use it. The lesson is timeless: always measure end-to-end, always verify that your optimized path is actually the executed path, and never assume that infrastructure works correctly without checking.