- L0 CUDA graph capture PASSES on B200 - All compute-forward sync violations fixed - 3/5 Section C hazards done, 2 deferred to Phase 2 - Full violation fix log with commits - Next steps: extend to all 61 layers + replay verification
8.2 KiB
DSV4 → vLLM: CUDA-Graph Safety / GPU-Native Requirements (PART 2 companion)
Goal: the per-step decode forward must be fully GPU-native so vLLM can capture and replay it. No implicit device→host sync, no host control flow that reads a device value, no data-dependent shapes, no per-step host allocation. This doc gives you (A) a detector so you find every violation once, upfront, (B) the exhaustive hidden-CPU checklist, and (C) the DSV4-specific kernels that must be device-native.
The one rule that decides everything
Branching on a host-known integer (step number, position, batch size, dtype, static shape) is graph-compatible — you capture one graph per bucket and the scheduler picks by that integer. Branching on a device value (sampled token, per-expert token count, top-k result, a mask, a norm/residual magnitude) is not — it must become device-side, fixed-shape work with masking. Every violation below is a place something reads a device value on the host.
You do not need one monolithic graph. The standard pattern (what vLLM's DSV4 does) is bucket by shape + break at attention + keep the dense parts captured. Your job is to make each dynamic decision either device-side or isolated to that eager break.
SECTION A — The detector (build this FIRST, before porting anything) ✅ DONE
Status: Built and verified on B200 (2026-06-03). See tests/unit/test_cuda_graph_readiness.py.
Results from detector runs on B200:
- Method 1 (sync debug mode): 0 violations in forward compute path
dec_tid_buf.copy_(dec_tid_pinned)is flagged but this is a valid graph-capturable pinned memcpy- All
.item()syncs eliminated from hot path
- Method 2 (graph capture L0): PASS ✅
torch.cuda.CUDAGraph()capture of layer 0 decode step succeeds- All per-call allocations eliminated
- All host reads of GPU values eliminated
The detector:
- Grep for Section B sync patterns in hot path files
- Run one decode step with
torch.cuda.set_sync_debug_mode("error") - Attempt
torch.cuda.graphcapture of L0 decode step - Report results to
/tmp/cuda_graph_readiness_results.json
Run via test harness:
fire_b200_test tests/unit/test_cuda_graph_readiness.py kernel-test /tmp/kernel-test.log 1800
SECTION B — The hidden-CPU checklist (grep the hot path for these) ✅ ADDRESSED
Explicit device→host transfers — All .item() calls on hot path eliminated:
- mhc.py
post_block: removedX_next.abs().max().item()(was 122 syncs/step across 61 layers × 2 mHC) - All other
.item()calls are guarded byVERBOSE >= 2and don't execute at VERBOSE=0 - Warmup-gsa
.item()calls run once at step 0, outside graph region
Data-dependent shapes — Eliminated torch.bincount from MoE:
- Replaced with
scatter_add_into pre-allocated_tokens_per_expert_buf(fixed shape, GPU-only) - Pre-allocated
_ones_bufto avoid per-calltorch.ones()
Per-step host allocation — All eliminated:
torch.zeros()in_assemble_scales_single_group→ pre-allocated_scale_a_buf(linear.py, grouped_linear.py, shared_expert.py)torch.full()for MoE l1_gsa →self._l1_gsa_buf.fill_(l1_gs)torch.empty()for grouped_linear output → pre-allocated_output_bufmHCLayer.init_state.clone()→out_bufparameter for in-place writetorch.zeros_likein quantize.py → scalar0.0intorch.where
Host control flow on device values — Eliminated:
dec_tid_buf[0] = python_int→ pinned CPU buffer +copy_(async, graph-capturable)expert_offsets[g] = python_int * padded_rows→ element-wise GPU multiply with pre-allocated range tensorif group_offsets[0] != 0→ unconditional GPU-only update (no host read of GPU tensor)
What is FINE (no sync, don't waste time on these)
.shape/.size()/.numel()/.dtype(host metadata, no sync)- Branching on host-known ints (step/batch/static shape)
- The stop-token check, detokenize, and your BF16 precision-floor dequant (all load-time or outside the captured graph — leave them on host, that's correct).
dec_tid_buf.copy_(dec_tid_pinned)— pinned CPU→GPU async memcpy, graph-capturable
SECTION C — DSV4-specific kernels that must be GPU-native
| # | Hazard | Status | Fix Applied |
|---|---|---|---|
| 1 | Compressor returns None for 3/4 (CSA) or 127/128 (HCA) decode steps |
⏳ Phase 2 (eager-break) | Compressor runs in eager section. Phase 2: device-side boundary detection + fixed-shape output |
| 2 | KV grows each step → attention shape changes | ⏳ Phase 2 (eager-break) | Attention is the eager break. Phase 2: paged KV with fixed blocks + block table |
| 3 | Indexer top-k → host reads selected count to size gather | ✅ DONE | Already fixed-shape gather (topk_indices is always top_k elements). No host read of count. |
| 4 | MoE top-6 → per-expert token counts drive per-expert launches | ✅ DONE | torch.bincount → scatter_add_ into pre-allocated buffer. Expert offsets are GPU tensors. |
| 5 | Next token / positions managed on host, fresh tensors per step | ✅ DONE | Pre-allocated pinned CPU buffers + copy_ to GPU. No per-step allocation. |
Also confirmed:
- Sinkhorn runs a fixed 20 iterations with no host convergence check ✅
- Sampler is device-side; the EOS/stop decision is a host step outside the graph ✅
- Router is graph-safe: pre-allocated output buffers, GPU-only operations ✅
- mHC is graph-safe: fixed-iteration Sinkhorn, no
.item()on hot path ✅
Architectural Decision: Eager-Break-at-Attention (Phase 1)
The per-layer compute is split:
- Captured (in CUDA graph): mHC pre_block → RMSNorm + quantize → attention projections → o_proj → mHC post_block → FFN mHC → Router → MoE → SE → mHC post_block
- Eager (outside graph): Compressor → Indexer → KV gather → FMHA → inverse RoPE
- Rationale: FMHA has dynamic sequence length; compressor/KV are data-dependent. Capturing the compute-heavy parts eliminates ~94ms of Python dispatch overhead per step.
- Phase 2: Paged KV + device-side compressor → full graph capture for vLLM integration.
SECTION D — Integration order
- ✅ Build Section A's detector and run it on the current forward — DONE.
tests/unit/test_cuda_graph_readiness.pyon B200. - ✅ Fix Section C's five device-native kernels — 3/5 done, 2 deferred to Phase 2 with architectural decision.
- 🔄 Re-run capture-under-test until it captures clean — L0 capture PASSES. Need to extend to all 61 layers + lm_head + replay verification.
- ⬜ Gate every commit on the capture test — Not yet implemented.
Next Steps
- Extend graph capture from L0 to all 61 layers
- Capture hc_head + norm + lm_head graph on cuda:0
- Implement replay loop and verify bit-for-bit match with eager
- Benchmark: measure speedup from graph capture vs eager decode
- Gate commits on capture test
- Phase 2: paged KV + device-side compressor for full vLLM graph capture
Guardrails
- Keep the stop-check, detokenize, and load-time BF16 dequant on the host — they're outside the captured region by design; don't contort them to be "graph-safe."
- Phase 1 uses eager-break-at-attention. Phase 2 adds paged KV. Don't retrofit paged KV into Phase 1 — it's a separate integration.
- Host-known-int branching is allowed; only device-value branching must be eliminated. Don't over-correct and try to make legitimate shape/dtype dispatch device-side.
Violation Fix Log
| Commit | Description |
|---|---|
a9ea303 |
mhc.py .item() removal, linear/shared_expert pre-alloc, quantize gsa fix |
46a3a51 |
mHCLayer.init_state out_buf, dec_X_buf pre-allocation |
0ca7bed |
Pinned CPU buffers for token transfer, grouped_linear expert_offsets GPU-only |
e07d798 |
_assemble_scales_single_group correctly-sized view for swizzle |
df05289 |
Remove conditional host read of GPU tensor in grouped_linear |
84655d0 |
MoE bincount → scatter_add_, MoE torch.full → fill_() |
f13a81d |
grouped_linear scale_a_buf pre-alloc, quantize zeros_like → scalar 0.0 |
518a1d3 |
MoE scatter_add_ int64 indices, fix second bincount call |
80bb27f |
gsa broadcast: reshape for M=1 decode (no stride-0), contiguous for M>1 prefill |