Skip to content

Commit dd8d87a

Browse files
authored
Merge pull request #46 from smb209/fix/mxfp8-a-side-offsets-cudagraph
Pre-warm MXFP8 A-side offsets at load so cudagraph capture cannot hit a host sync
2 parents f4b3274 + 7226077 commit dd8d87a

3 files changed

Lines changed: 189 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,40 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- **Fixed: `FULL_DECODE_ONLY` capture aborted the load in the MXFP8 dense
6+
lane.** `_SfOffsetCache` computes swizzled-plane offsets on the host and
7+
moves them with an unpinned `.to(device)`.
8+
`process_weights_after_loading` resolved the **B side** from weight
9+
dimensions known at load, but the **A side** is keyed by the runtime row
10+
count, so under `cudagraph_mode=FULL_DECODE_ONLY` the first call at each
11+
capture size landed *inside* the capture region and raised `Cannot copy
12+
between CPU and CUDA tensors during CUDA graph capture unless the CPU tensor
13+
is pinned`. It surfaced as `cudaErrorStreamCaptureUnjoined` at
14+
`capture_end()`, because DeepSeek-V4 reaches this lane from inside
15+
`maybe_execute_in_parallel`, leaving that side stream unjoined. The A-side
16+
offsets are now pre-warmed at load for every `cudagraph_capture_sizes` entry
17+
`docs/KERNELS.md` CUDA-graph safety rule 3, mirroring `cb_gemv_v2_prepare`
18+
and `cb_moe_persistent_b_prepare`. **No numerics change**: the same offsets,
19+
computed earlier. Eager serving is unaffected (the reader returns `()` when
20+
no capture sizes are configured, and the pre-warm is then a no-op).
21+
22+
Measured on one GB10 / DGX Spark (`sm_121`, arm64), vLLM
23+
0.26.1rc1.dev515, torch 2.11.0+cu130, a DeepSeek-V4-Flash-0731 NVFP4-CB
24+
artifact at TP=1, `--kv-cache-dtype fp8`, `--max-model-len 8192`,
25+
`--compilation-config {"mode":0,"cudagraph_mode":"FULL_DECODE_ONLY",
26+
"cudagraph_capture_sizes":[1,2,4,8]}`: capture previously failed the load
27+
outright; it now succeeds, and single-stream decode improves **12.05 ->
28+
14.08 tok/s (+16.8%)**, 83.0 -> 71.0 ms/token.
29+
30+
Per-token logprobs under capture are **bit-equal** to `--enforce-eager` on
31+
7 of the 8 probe prompts. The 8th is excluded on the basis of an
32+
eager-vs-eager control run *before* the captured arm was compared: it is a
33+
near-tie that flips between two identical eager runs, so it cannot serve as
34+
a reference. Protocol: prefix caching off, serial single requests — under
35+
the serving default (prefix caching on) and concurrency, eager is not
36+
run-to-run reproducible either.
37+
338
## 0.8.10 — 2026-08-18
439

540
- **0.8.9 could not load a per-expert split-format (mixed) expert bank at

gridbook/mxfp8_dense_lane.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,54 @@ def get(self, ext, rows: int, k: int, *, is_b: bool,
9696
_OFFSETS = _SfOffsetCache()
9797

9898

99+
def _cudagraph_capture_sizes() -> tuple[int, ...]:
100+
"""Decode row counts vLLM will capture graphs at, or ``()`` if unknown.
101+
102+
Read the same way ``ops.warn_if_capture_sizes_exceed_the_decode_gates``
103+
reads it: vLLM imported inside the function so ``import gridbook`` stays
104+
vLLM-free, and every failure degrading to silence rather than breaking a
105+
load, because the shape of the compilation config has moved across
106+
releases. Returning ``()`` costs only the pre-warm below; it cannot make
107+
serving wrong.
108+
"""
109+
try:
110+
from vllm.config import get_current_vllm_config
111+
112+
compilation = get_current_vllm_config().compilation_config
113+
sizes = getattr(compilation, "cudagraph_capture_sizes", None) or ()
114+
return tuple(sorted({int(s) for s in sizes if int(s) > 0}))
115+
except Exception: # noqa: BLE001 — advisory only; never break a load
116+
return ()
117+
118+
119+
def _prewarm_activation_offsets(ext, k: int, device) -> None:
120+
"""Populate the A-side offset cache for every graph capture size.
121+
122+
WHY THIS EXISTS. ``_SfOffsetCache.get`` computes offsets on the host and
123+
moves them with an unpinned ``.to(device)``. That copy is illegal inside a
124+
CUDA graph capture ("Cannot copy between CPU and CUDA tensors during CUDA
125+
graph capture unless the CPU tensor is pinned"), so any key first seen
126+
*during* capture is a hard failure rather than a slow path.
127+
128+
The B side never had this problem: ``process_weights_after_loading``
129+
resolves it from weight dimensions known at load. The A side is keyed by
130+
the runtime row count, which under ``FULL_DECODE_ONLY`` is first seen
131+
inside the capture region — one first-time miss per capture size.
132+
133+
Doing it here is ``docs/KERNELS.md`` CUDA-graph safety rule 3 ("all
134+
device-side constants and per-device kernel setup happen once, at model
135+
load"), and mirrors ``cb_gemv_v2_prepare`` / ``cb_moe_persistent_b_prepare``
136+
/ ``cb_fused_fp4v2_prepare``, which are called from this same hook for the
137+
same reason.
138+
139+
Not wrapped in try/except: at load these are the identical calls the
140+
forward will make, so a failure here is a real defect and should surface
141+
at load rather than mid-capture.
142+
"""
143+
for rows in _cudagraph_capture_sizes():
144+
_OFFSETS.get(ext, rows, k, is_b=False, device=device)
145+
146+
99147
def _quantize_activations(ext, x: torch.Tensor) -> tuple[torch.Tensor,
100148
torch.Tensor]:
101149
"""bf16 ``[M, K]`` -> (e4m3 ``[M, K]``, swizzled SF plane)."""
@@ -159,6 +207,12 @@ def process_weights_after_loading(self, layer) -> None:
159207
ext = _require_lane_ext(device)
160208
w = layer.weight.data
161209
n, k = int(w.shape[0]), int(w.shape[1])
210+
# A-side (activation) offsets, for graph capture. Both call sites
211+
# that need them -- ``_quantize_activations`` and the BMM branch of
212+
# ``apply`` -- key on the runtime row count at this same ``k``
213+
# (``k == layer.weight.shape[1]`` for both), so one pre-warm here
214+
# covers both paths.
215+
_prewarm_activation_offsets(ext, k, device)
162216
sf_rm = layer.weight_scale.data
163217
del layer.weight_scale
164218
is_bmm = bool(getattr(layer, "is_bmm", False))

tests/test_mxfp8_dense_lane.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,3 +110,103 @@ def test_audited_and_broken_sets_stay_disjoint_for_fp8_source_entries():
110110
for wire in (WIRE_FP8_BLOCK128, WIRE_MXFP8_G32):
111111
fmt = sp.FORMATS[wire]
112112
assert not (fmt.audited_backends & set(fmt.known_broken_backends))
113+
114+
115+
# --- A-side offset pre-warm: the CUDA-graph capture prerequisite ------------
116+
#
117+
# ``_SfOffsetCache.get`` computes offsets on the host and moves them with an
118+
# unpinned ``.to(device)``. Inside a CUDA graph capture that copy is a hard
119+
# error, so every key must already exist before capture starts. The B side was
120+
# always resolved at load from weight dimensions; the A side is keyed by the
121+
# runtime row count, which under FULL_DECODE_ONLY is first seen INSIDE the
122+
# capture region. These cover the reader and the pre-warm on CPU; the served
123+
# proof is a graph capture on the GPU image.
124+
125+
class _FakeExt:
126+
"""Records the offset requests the lane makes."""
127+
128+
def __init__(self):
129+
self.calls = []
130+
131+
def mxfp8_sf_offsets(self, rows, k, is_b):
132+
self.calls.append((int(rows), int(k), bool(is_b)))
133+
return torch.zeros(4, dtype=torch.int32)
134+
135+
136+
def test_capture_sizes_reader_is_silent_without_vllm():
137+
"""No vLLM in the CPU tier: the reader must degrade to (), not raise --
138+
a load must never fail for a compilation-config schema reason."""
139+
from gridbook.mxfp8_dense_lane import _cudagraph_capture_sizes
140+
assert _cudagraph_capture_sizes() == ()
141+
142+
143+
def test_capture_sizes_reader_dedups_sorts_and_drops_nonpositive(monkeypatch):
144+
"""Exercise the REAL reader against a stub config.
145+
146+
``monkeypatch.setitem`` on ``sys.modules`` is deliberate: it restores the
147+
entries afterwards, so this does not leak stub ``vllm`` modules into later
148+
files the way CONTRIBUTING.md warns about.
149+
"""
150+
import sys
151+
import types
152+
153+
import gridbook.mxfp8_dense_lane as lane
154+
155+
config = types.SimpleNamespace(
156+
compilation_config=types.SimpleNamespace(
157+
cudagraph_capture_sizes=[8, 2, 2, 0, 4, 1, -3]))
158+
stub = types.ModuleType("vllm.config")
159+
stub.get_current_vllm_config = lambda: config
160+
monkeypatch.setitem(sys.modules, "vllm", types.ModuleType("vllm"))
161+
monkeypatch.setitem(sys.modules, "vllm.config", stub)
162+
163+
assert lane._cudagraph_capture_sizes() == (1, 2, 4, 8)
164+
165+
166+
def test_capture_sizes_reader_degrades_to_silence_on_a_hostile_config(
167+
monkeypatch):
168+
"""Schema drift must not fail a load -- the reason ops.py warns instead of
169+
raising. A config whose accessor explodes yields (), not an exception."""
170+
import sys
171+
import types
172+
173+
import gridbook.mxfp8_dense_lane as lane
174+
175+
def boom():
176+
raise RuntimeError("compilation_config moved again")
177+
178+
stub = types.ModuleType("vllm.config")
179+
stub.get_current_vllm_config = boom
180+
monkeypatch.setitem(sys.modules, "vllm", types.ModuleType("vllm"))
181+
monkeypatch.setitem(sys.modules, "vllm.config", stub)
182+
183+
assert lane._cudagraph_capture_sizes() == ()
184+
185+
186+
def test_prewarm_populates_a_side_for_every_capture_size(monkeypatch):
187+
"""The fix proper: one A-side entry per capture size, at the layer's K."""
188+
import gridbook.mxfp8_dense_lane as lane
189+
190+
monkeypatch.setattr(lane, "_OFFSETS", lane._SfOffsetCache())
191+
monkeypatch.setattr(lane, "_cudagraph_capture_sizes", lambda: (1, 2, 4, 8))
192+
ext = _FakeExt()
193+
lane._prewarm_activation_offsets(ext, 4096, torch.device("cpu"))
194+
195+
assert ext.calls == [(1, 4096, False), (2, 4096, False),
196+
(4, 4096, False), (8, 4096, False)]
197+
# and a subsequent forward-time lookup is a cache HIT, i.e. no host copy
198+
# would be issued inside a capture
199+
before = len(ext.calls)
200+
lane._OFFSETS.get(ext, 4, 4096, is_b=False, device=torch.device("cpu"))
201+
assert len(ext.calls) == before
202+
203+
204+
def test_prewarm_is_a_noop_when_capture_sizes_are_unknown(monkeypatch):
205+
"""Eager serving (or an unreadable config) must not pay for this."""
206+
import gridbook.mxfp8_dense_lane as lane
207+
208+
monkeypatch.setattr(lane, "_OFFSETS", lane._SfOffsetCache())
209+
monkeypatch.setattr(lane, "_cudagraph_capture_sizes", tuple)
210+
ext = _FakeExt()
211+
lane._prewarm_activation_offsets(ext, 4096, torch.device("cpu"))
212+
assert ext.calls == []

0 commit comments

Comments
 (0)