Skip to content

Fix(DSpark): sync device before CUDA graph capture to avoid JIT race - #33795

Open
Leoyzen wants to merge 3 commits into
sgl-project:mainfrom
Leoyzen:fix/dspark-compact-capture-jit-sync
Open

Leoyzen wants to merge 3 commits into
sgl-project:mainfrom
Leoyzen:fix/dspark-compact-capture-jit-sync

Conversation

@Leoyzen

@Leoyzen Leoyzen commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Motivation

In DSpark compact ragged-verify mode, large decode CUDA-Graph capture can fail during server startup with a non-deterministic illegal-memory access or host SIGSEGV (#33356). Across runs the failure surfaces through different kernels (DeepGEMM, c128_v2, Triton routing, fused_norm_rope_v2) and different TP ranks, which makes it look like a wild kernel bug — but it is a capture-context violation, not a kernel bug.

FullCudaGraphBackend.capture_one() synchronizes the device before each of the two warmup iterations, but there is no synchronization between the last warmup and torch.cuda.CUDAGraph() creation. Warmup forward passes trigger asynchronous JIT compilation of compressor/plan kernels. In compact mode each new non-uniform verify_lens shape compiles fresh kernels during warmup; if that compilation is still in flight when capture begins, the JIT issues CUDA driver calls (cuModuleLoadData, etc.) inside the capture context, which is illegal — the first kernel to run after that is the one that reports the IMA.

This also explains the deterministic shape signature seen on H200×4:

  • bs with uniform verify_lens (e.g. bs=128..64) reuse the kernels compiled by the previous shape → no new JIT → clean;
  • the first non-uniform shape (e.g. bs=60 → [3]*104 + [2]*24) makes the planner take the general-prefill path → fresh kernels get compiled right as capture begins → race window opens;
  • skipping the failing shape just moves the failure to the next non-uniform tier;
  • static / cap-accept are unaffected because they use uniform verify_lens everywhere, so kernels are compiled once on the first shape.

Modifications

Add a device synchronize() + TP-group barrier() after the warmup loop in FullCudaGraphBackend.capture_one(), before entering the capture context:

for _ in range(2):
    self._device_module.synchronize()
    self._tp_group.barrier()
    forward_fn()
    if post_warmup_hook is not None:
        post_warmup_hook()

# Ensure async JIT compilation and lazy initialization triggered during
# warmup completes before entering the capture context.
self._device_module.synchronize()
self._tp_group.barrier()

graph = torch.cuda.CUDAGraph()

Files changed:

  • python/sglang/srt/model_executor/runner_backend/full_cuda_graph_backend.py (+11)

Accuracy Tests

This change does not alter any kernel or model-forward logic — it only inserts a device sync + TP barrier between warmup and capture. Outputs are bit-identical. No accuracy test is affected.

Speed Tests and Profiling

Setup: H200 × 4 (TP4), DeepSeek-V4-Flash-0731, DSpark (gamma=5, --speculative-num-draft-tokens 6), SGLANG_RAGGED_VERIFY_MODE=compact, --cuda-graph-max-bs 128, --max-running-requests 128, --moe-runner-backend flashinfer_mxfp4.

Before fix After fix
bs=60 (first non-uniform shape) crashes 3/3 runs passes
Full capture (35 + 35 shapes) fails at startup completes
Server startup never reaches ready ready

Startup overhead: one extra synchronize() + barrier() per captured shape (~70 shapes) — negligible versus capture time. No steady-state inference cost (the sync is only in the one-time capture path).

Checklist

Related


CI States

Latest PR Test (Base): ❌ Run #31067800551
Latest PR Test (Extra): ❌ Run #31067800480

FullCudaGraphBackend.capture_one() synchronizes the device before each of
the two warmup iterations but not between the last warmup and
torch.cuda.CUDAGraph() creation. In compact ragged-verify mode, each new
non-uniform verify_lens shape triggers fresh JIT compilation of
compressor/norm_rope kernels during warmup. If that compilation is still
in flight when capture begins, the JIT issues CUDA driver calls
(cuModuleLoadData, etc.) inside the capture context, which is illegal and
surfaces as CUDA_ERROR_ILLEGAL_ADDRESS or cudaErrorStreamCaptureUnsupported.

This predominantly affects compact mode where bs values with uniform
verify_lens reuse previously compiled kernels but the first non-uniform
shape (e.g. bs=60 => [3]*104+[2]*24) compiles new kernels right as capture
starts. static/cap-accept modes are unaffected because they use uniform
verify_lens for all shapes, so kernels are compiled once on the first shape.

Verified: without the sync, compact capture crashes deterministically at
bs=60 (3/3 runs, H200x4 TP4 DSV4-Flash-0731); with it, all 35+35 shapes
capture and the server reaches ready. Fixes the root cause reported in
sgl-project#33356.
Leoyzen added 2 commits August 6, 2026 11:07
…test

Regression guard for sgl-project#33356 / PR sgl-project#33795. Pins the ordering invariant in
FullCudaGraphBackend.capture_one(): a device synchronize + TP-group barrier
must run between the last warmup forward and torch.cuda.CUDAGraph()
construction, so async TVM/DeepGEMM JIT cannot issue driver calls inside the
stream-capture region. Deterministic mock-only call-order check - no GPU.
@Leoyzen

Leoyzen commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Two updates:

  1. Regression test added (test/registered/unit/model_executor/runner_backend/test_full_cuda_graph_backend.py)

Pins the ordering invariant fixed here: capture_one() must execute a device synchronize() + TP-group barrier() strictly between the last warmup forward and torch.cuda.CUDAGraph() construction.
If the post-warmup sync is removed or re-ordered, the async JIT-in-capture race from #33356 reopens and this test goes red. It is a pure mock call-order check (built via new + attribute stubs, following the existing runner unit-test pattern), so it is deterministic and runs on CPU CI (base-a-test-cpu) — no reliance on reproducing the non-deterministic GPU race.

  1. CI status note: the failing pr-test-finish / pr-test-extra-finish statuses on earlier runs are the workflow-level summary of jobs that were skipped pending fork-PR CI authorization — not failures of this change. The new head (cb143f9) should execute once approved.

  2. Known follow-ups surfaced during review (all orthogonal to this fix; happy to land as separate PRs):

  • BreakableCudaGraphBackend has the identical warmup→capture gap (breakable_cuda_graph_backend.py:115-122 still syncs only inside the warmup loop, not between the last warmup and BreakableCUDAGraph() creation). DSpark compact uses the full backend so it is out of scope here, but a follow-up should apply the same sync (and extend this unit test to both backends) if BCG ever enters stream capture with JIT kernels.
  • [BugFix] Fix race in c128 prefill plan kernel on ragged extend #32467 (missing __syncthreads in plan_compress_prefill_kernel0) is orthogonal and recommended to land alongside: this PR fixes capture-infrastructure timing (lets the server start); that one fixes a kernel correctness bug that can still crash during replay. Without both, the capture-time crash masks [BugFix] Fix race in c128 prefill plan kernel on ragged extend #32467 entirely.
  • [Bug] DSpark compact target-verify CUDA Graph transition can hit timing-sensitive illegal memory access on TP8 #31023 (runtime replay twin) is separate — this fix only removes the capture-time crash that currently blocks diagnosing it; a sustained-replay test should be added in a dedicated PR.
  • Optional nightly coverage: a DSpark compact-mode startup smoke (non-uniform shape set, e.g. bs pattern around the 60/64 tier) is worth adding to a nightly-* suite, since it needs H200-class + TP≥2 + DeepGEMM mxfp4 and cannot gate per-commit CI.

@hassellof

Copy link
Copy Markdown
Contributor

We have a third-environment data point on the "Related #32467
complementary, land both" note (full evidence just posted in #33356
#33356 (comment)): on SM120 / TP4 / DeepSeek-V4-Flash-0731,
the same-class capture IMA (a) fires during the eager warmup pass
under CUDA_LAUNCH_BLOCKING=1, i.e. before any capture context exists,
and (b) is fully fixed by backporting #32467 alone, with no ordering
changes — every shape in an 8-row shape/allocator bisection matrix that
previously faulted deterministically now captures.

So at least one environment in the #33356 failure class appears not to be
covered by this PR's sync, which supports the "land both" recommendation
— and suggests Fixes #33356 may auto-close the issue prematurely on
merge; Related/partial-fix wording might be safer until the B300
validation and the #32467 interaction matrix are in (consistent with the
caveats already stated in the issue).

Happy to run this PR's patch on our deterministic repro with and without
#32467 (arms C/D of the matrix proposed in the issue) — ~30 min per arm.

@hassellof

Copy link
Copy Markdown
Contributor

Ran the arms we offered — full matrix and evidence in #33356. Two results relevant to this PR:

Your patch composes cleanly with #32467 (arm D). With both applied, our SM120 box captures the full ladder at the deterministic-fault shape and at the worst row of our bisection matrix (ctx 1M, mem 0.90, cap 64, expandable segments on — 27 shapes in 72.9 s), with normal capture times. No interaction, no regression. That supports your own "complementary, land both" note.

Your patch alone (arm C) does not cover our failure. The traceback lands at full_cuda_graph_backend.py:92forward_fn() in the warmup loop — while the sync you add sits at 96–105 and torch.cuda.CUDAGraph() at 107, so the fault fires before the added sync executes. A same-lineage control with neither patch (A′) also faults, so this isn't the repro having died on our stack.

That's the basis for the earlier suggestion about Fixes #33356: on this hardware the issue's failure class survives this PR, and merging with Fixes would auto-close it while that case is still live. Related to #33356 (or landing alongside #32467) would leave the tracking accurate. Entirely your call — the PR itself looks right for the producer it targets, and arm D says it costs nothing where the other producer dominates.

@Leoyzen

Leoyzen commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

@hassellof — thanks, the 4-arm matrix is exactly the separation this issue needed. The arm C traceback at full_cuda_graph_backend.py:92 (forward_fn() in the warmup loop, before the added sync at 96-105 and before CUDAGraph() at 107) cleanly separates the two producers:

  • P1 — JIT crossing the capture boundary (H200 / B300): host-side async JIT issuing driver calls inside the capture context → fixed by this PR's post-warmup sync + barrier.
  • P2 — device-side race in plan_compress_prefill_kernel0 (SM120): missing __syncthreads() → garbage ragged_id → OOB compress-store index → fixed by [BugFix] Fix race in c128 prefill plan kernel on ragged extend #32467 alone.

I've updated the PR wording to Related #33356 so the issue stays open for the P2/SM120 case until #32467 lands (it has been open since 07-27 and on your matrix it closes the deterministic instance — agreed it should land alongside this one; arm D shows they compose with zero interaction).

We'll add H200-side confirmation of arms C and D to the thread (our bs=60 is a deterministic P1 repro; ~30 min per arm) so the landing case has both environments covered. Happy to run any specific capture-ladder or allocator row you want on the H200 box.

@Leoyzen

Leoyzen commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Could a maintainer approve the workflow runs for this fork PR? All 118 jobs are currently skipped pending CI authorization, including base-a-test-cpu which executes the new regression test (test_full_cuda_graph_backend.py) added at head 994910f.

The change is 2 files (+125): the capture_one() sync/barrier fix and its deterministic mock-only unit test.

Happy to rebase/address anything else needed for review.

cc @Ying1123 @merrymercy @ispobock @Fridge003 @hnyls2002

@hassellof

Copy link
Copy Markdown
Contributor

Great — the P1/P2 split reads right to me, and thanks for switching to Related #33356; that keeps the SM120 case visible until #32467 lands.

Most useful H200 rows, if you're offering. The two that carry the most information are the ones where our boundary moved with allocator config rather than with ladder depth alone:

# shape expandable_segments ours (SM120)
1 ladder ≤192 (cap 32), ctx 1M, mem 0.90 off healthy
2 ladder ≤240 (cap 40), ctx 1M, mem 0.90 off fault
3 ladder ≤96 (cap 16), ctx 1M, mem 0.92 on fault
4 single tier 240 (--cuda-graph-bs-decode 40), ctx 64K, mem 0.55 off healthy

Rows 1→2 are the depth boundary; 1→3 is the same-or-shallower ladder faulting once expandable segments are on; row 4 is the odd one — a single 240 tier is clean while a ladder containing 240 faults, which we could never fully separate from a context co-factor.

If P1 is your only producer on H200, I'd expect all four to behave identically there with your sync applied and #32467 absent (arm C), since none of them should be reachable by a device-side ragged_id overrun on that path. If any of them does fault in arm C on H200, that would mean P2 isn't SM120-specific — which is the single most valuable thing this cross-check could tell us. Arms A′/B/D on any one row would be plenty beyond that; no need to run the full matrix.

Offer in return: we have a 4× RTX PRO 6000 (sm_120) box available and our compact-mode repro is deterministic, ~30 min per arm. Happy to run your bs=60 P1 repro on it, or any instrumented build — if P1 turns out to be reachable on SM120 too, that's worth knowing before either patch lands.

On CI: we're stuck at the same gate on #33407, #33813 and #33816 (all jobs skipped pending the run-ci label), so no help to offer there beyond sympathy 😅

Copy link
Copy Markdown

Independent B300/TP8 validation: the patch is insufficient on the original #33356 environment

I tested this PR on the original B300-class reproducer from #33356, using the same container/task, machine, model, and capture configuration throughout.

Environment

Image: official SGLang v0.5.16-cu130
SGLang: 0.5.16
PyTorch: 2.11.0+cu130
Triton: 3.6.0
CUDA: 13.0
GPU: 8 x NVIDIA B30Z (275 GB), TP8
Model: DeepSeek-V4-Pro-DSpark
Verify mode: compact
MoE runner: flashinfer_mxfp4
Attention backend: dsv4
cuda-graph-max-bs-decode: 128 (35 shapes)
mem-fraction-static: 0.82
SPS: disabled
Client workload: none

The PR patch was applied as the exact single hunk from d48fc27aa8b09bcf716915f743a82983e6073a71 (+11/-0). I verified at runtime that Python loaded the patched file and that the order was:

final warmup forward
  < device synchronize
  < TP barrier
  < torch.cuda.CUDAGraph()

Controlled results

Run Patch DeepGEMM precompile Result Captured First failing bs Rank Surfacing path
baseline_unpatched_01 OFF no FAIL 9/35 60 TP7, TP4 Triton CUDA illegal-memory access
patched_pr33795_01 ON no FAIL 4/35 96 TP2 DeepGEMM runtime_utils.hpp:144, CUDA_ERROR_ILLEGAL_ADDRESS
patched_pr33795_02 ON no READY 35/35 none
patched_pr33795_03 ON no FAIL 2/35 112 TP0 DeepGEMM runtime_utils.hpp:144, CUDA_ERROR_ILLEGAL_ADDRESS
patched_precompiled_01 ON attempted FAIL 9/35 60 TP2 illegal-memory access
patched_precompiled_02 ON attempted FAIL 4/35 96 TP2 illegal-memory access
patched_precompiled_03 ON attempted FAIL 9/35 60 TP3 illegal-memory access surfaced through Triton

Aggregate for the patched configuration:

1 / 6 fresh launches reached full 35/35 capture and ready
5 / 6 fresh launches failed during capture

Given the pre-existing non-determinism, the single READY launch is not sufficient evidence that the patch fixes this environment. The failing shape, rank, and surfacing path continued to drift after the patch:

shape: 96 / 112 / 60 / 96 / 60
rank:  TP2 / TP0 / TP2 / TP2 / TP3
path:  DeepGEMM / generic IMA / Triton

Therefore, on B300 x8 / TP8 / DeepSeek-V4-Pro-DSpark / v0.5.16-cu130, the final device sync + TP barrier is insufficient by itself. I do not consider #33356 validated or resolved by this patch, and the issue should remain open.

DeepGEMM precompile control

I also ran python3 -m sglang.compile_deep_gemm with the same model and server arguments before three additional patched launches. The command exited successfully and printed DeepGEMM Kernels compilation finished successfully, but the manipulation did not cover the kernels used by this compact-verify capture path:

/root/.cache/deep_gemm before: 5.4 MB / 120 files
/root/.cache/deep_gemm after:  5.4 MB / 120 files

All three subsequent launches still printed Entering DeepGEMM JIT Pre-Compile session multiple times and all three failed. Consequently, this arm is inconclusive with respect to whether complete DeepGEMM JIT completion would remove the race; it only shows that the current sglang.compile_deep_gemm sweep is not an effective workaround for this DSpark compact-verify path.

The remaining failure is still consistent with some asynchronous activity or lifetime violation near the capture boundary, but these data do not identify the original producer. Candidate areas remain first-use JIT/module loading not covered by the precompile sweep, MoE-routing workspace lifetime, CUDA-Graph pool address reuse, and alternate-stream ownership/lifetime.

Full local evidence includes seven logs, the capture-results table, before/after source hashes, runtime import/order verification, and the exact applied diff.

Copy link
Copy Markdown

Follow-up: completed #32467 × #33795 four-arm matrix on B300/TP8

This supplements my earlier B300 result. I completed the complementary-kernel-fix matrix using the same original #33356 environment.

Fixed environment

8 x NVIDIA B30Z/B300-class, TP8
DeepSeek-V4-Pro-DSpark
official SGLang v0.5.16-cu130
compact ragged verify
flashinfer_mxfp4 + dsv4
cuda-graph-max-bs-decode=128 (35 shapes)
mem-fraction-static=0.82
chunked-prefill-size=4096
no SPS, no client workload
fresh server per run

Results

Arm #32467 kernel barrier #33795 final sync/barrier Result
A OFF OFF 0/7 full-capture successes
C OFF ON 1/6 READY; 5/6 failed at bs=60/96/112
B ON OFF 3/3 READY; 35/35 captured each run
D ON ON 3/3 READY; 35/35 captured each run

All six runs with #32467 enabled were free of illegal-memory, SIGSEGV, capture-failure, and scheduler-initialization signatures.

The source state was checked before each arm:

Arm B: c_plan barrier ON; FullCudaGraphBackend final sync/barrier OFF
Arm D: c_plan barrier ON; FullCudaGraphBackend final sync/barrier ON

The TVM-FFI JIT cache was cleared after changing c_plan.cuh, preventing reuse of a pre-fix compiled plan kernel.

Updated interpretation

For the original B300/TP8 startup-capture failure, #33795 alone is not sufficient (1/6 READY). Once the plan-kernel race from #32467 is fixed, both B and D are stable (3/3 each). Therefore, on this platform:

This does not invalidate the reported H200 result or the general warmup-to-capture ordering invariant; it narrows the B300 root cause and shows that the drifting Triton/DeepGEMM/TRT-LLM surfacing kernels were downstream symptoms of the plan-kernel race.

I also tested python3 -m sglang.compile_deep_gemm as a possible workaround. It returned success, but the DeepGEMM cache remained unchanged and subsequent launches still entered the JIT precompile session; the three attempted runs all failed. It is not an effective workaround for this B300 path.

Scope remains startup capture only; sustained runtime replay from #31023 is separate.

Copy link
Copy Markdown

Follow-up: four-arm matrix with #32467 on B300 / TP8

Following my earlier B300 result where #33795 alone reached READY in only 1/6 fresh runs, I completed a controlled #32467 × #33795 matrix on the same machine and environment.

Environment

official sglang:v0.5.16-cu130
SGLang 0.5.16 / PyTorch 2.11.0+cu130 / Triton 3.6.0 / CUDA 13.0
8 × NVIDIA B30Z, TP8
DeepSeek-V4-Pro-DSpark
compact ragged verify
flashinfer_mxfp4
cuda-graph-max-bs-decode=128 (35 shapes)
mem-fraction-static=0.82
chunked-prefill-size=4096
no SPS, no client workload

Matrix

Arm #32467 #33795 Result
A OFF OFF 0/7 full captures; failures at bs=60/96/112
C OFF ON 1/6 READY; failures continued to drift across shape/rank/surfacing kernel
B ON OFF 3/3 READY, 35/35 capture each run
D ON ON 3/3 READY, 35/35 capture each run

Arm B explicitly restored the unpatched full_cuda_graph_backend.py and verified the #33795 marker was absent. Arm D re-applied #33795 and verified the post-warmup synchronize() → barrier() → CUDAGraph() ordering. The #32467 c_plan.cuh hash was identical in B and D. The TVM-FFI JIT cache was cleared after changing the .cuh.

Interpretation

For this B300/TP8 reproducer:

This indicates that the primary failure here is the plan-kernel race fixed by #32467, rather than the final warmup-to-capture synchronization gap. This does not invalidate the H200 evidence or the ordering guard added by this PR; it means #33795 is not the necessary recovery condition for this specific B300 case once #32467 is present.

I also attempted python3 -m sglang.compile_deep_gemm as a workaround before the matrix. It returned success, but the DeepGEMM cache remained unchanged and subsequent servers still entered the JIT pre-compile session, so it did not cover the DSPARK compact capture kernels and should not be treated as a workaround for this reproducer.

@Leoyzen

Leoyzen commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the concrete rows — very helpful. Here's what we have so far, and where we can fill in.

H200×4 / TP4 / DeepSeek-V4-Flash-0731 — arm D (both #33795 + #32467)

We ran a 4-stage validation on a dedicated profiling pod (image dsv4-fix-9303e26-sync-c128, SGLang @ 9303e26 with both patches applied, TP4, compact verify, FULL CUDA graph, overlap scheduler ON, cuda-graph-max-bs=128, mem-fraction-static=0.82):

Stage Description Result
Capture smoke CUDA_LAUNCH_BLOCKING=1, bs ladder [1..128] incl. 30/60/128 Zero CUDA errors; target verify 77.7s/3.37GB, draft verify 10.6s/1.23GB
Full SPS profile 60 rounds, 15 bs × 12 M grid All match_fraction=1.00 (zero retraction), self-check passed
Replay canary 48 requests × 2 replays hash(text + output_ids) identical — no silent corruption
Sustained load 30 min mixed load, 354 requests 0 errors, zero CUDA errors

BCG is architecturally disabled for DSV4 (Breakable CUDA graph is incompatible with DeepSeek-V4), so only decode/verify FULL graphs participate in capture.

We also ran a #31023 stress test (128 concurrent, 8192 input / 1000 output tokens, 1280 requests, compact + graph + overlap ON): all completed in 572s, zero IMA. Bug 2 did not reproduce on TP4 H200 (original repro was TP8 B300).

What we haven't run yet — arm C on H200

Your 4-row request is exactly the right experiment. We have not yet run arm C (#33795 only, #32467 absent) on H200. Our image has both patches baked in, so we'd need to rebuild with only #33795 to test this. If P1 is the only producer on H200, all 4 rows should pass with #33795 alone; if any faults, P2 is reachable on Hopper too.

Based on @Phoenix3334's B300 matrix just posted (arm C = 1/6 READY on B300×8 TP8), the picture is now:

The open question is whether H200×4 is P1-only (in which case #33795 is necessary there) or also has a P2 component (in which case #32467 alone might suffice there too, matching B300). We'll run arm C with your 4 rows and report back — should take ~2h to rebuild the image and run the matrix.

On the landing question: given that #33795 alone is insufficient on 2 of 3 environments, and #32467 alone is sufficient on those same 2, the case for landing #32467 first (or at least simultaneously) is strong. #33795 remains a correct ordering guard regardless — the post-warmup synchronize() + barrier() before CUDAGraph() is a legitimate invariant worth pinning — but it shouldn't be positioned as the primary fix for #33356.

Copy link
Copy Markdown

Follow-up from the B300/TP8 four-arm matrix: I also completed runtime replay validation with #32467 ON and this PR OFF.

The startup matrix remains:

A: #32467 OFF, #33795 OFF  -> 0/7 READY on the large-shape reproducer
C: #32467 OFF, #33795 ON   -> 1/6 READY
B: #32467 ON,  #33795 OFF  -> 3/3 READY, 35/35 shapes each
D: #32467 ON,  #33795 ON   -> 3/3 READY, 35/35 shapes each

Runtime validation of Arm B (#32467 only):

  • Gate 1: no SPS, max graph bs=32, max-running-requests=32, c32/n320, 60k/1k -> 320/320 completed, 0 timeouts, health 200 before/after, no GPU error signatures, post-run smoke passed, and observed replay was 100% CUDA Graph.
  • Gate 2': compact + old SPS, max graph bs=128, c256/n512, 60k/1k, request-timeout=7200s -> 512/512 completed, 0 timeouts, health 200 before/after, no illegal-memory / CUDA_ERROR_ILLEGAL_ADDRESS / scheduler-exception / NCCL-watchdog signatures, post-run smoke passed.

A second fresh Gate 2' run was manually stopped at 293/512 to release the machine; it had no GPU error signature up to that point and is not counted as pass/fail.

So on this B300x8/TP8 reproducer, #32467 alone is sufficient for both startup capture and sustained replay in the tested paths. This PR still composes cleanly with #32467 (Arm D 3/3), but it was not required for the successful B300 startup or replay runs above. This does not contradict the H200 result reported for #33795; it narrows the B300 failure class tracked in #33356 toward the plan-kernel race fixed by #32467.

Copy link
Copy Markdown

One more B300 data point relevant to the interaction with #32467: I now reproduced the #32467 failure mechanism directly in a tiny single-GPU plan_prefill(...) harness, without CUDA Graph capture or model loading.

With the unpatched c_plan.cuh, ragged inputs produce OOB plan IDs at high frequency:

[4]x72 + [3]x24:   594 / 2000 bad plans
[3]x104 + [2]x24: 1998 / 2000 bad plans

The bad maximum ID is exactly 383 = B * s_max - 1 while the real ragged row count is 360, i.e. the non-uniform batch is being planned with the uniform fast-path formula. With #32467's __syncthreads() added, the same harness is 0 / 30000 bad plans after clearing the TVM-FFI JIT cache between arms.

That makes the B300 distinction between the two fixes clearer:

This is consistent with our earlier four-arm result (#33795-only 1/6 READY vs #32467-only 3/3 READY) and sustained-replay result (#32467-only 320/320 and 512/512 clean). I still view the two changes as orthogonal/compatible; this update only narrows the B300 root cause to the plan race fixed by #32467.

Copy link
Copy Markdown

One more data point that sharpens the scope distinction here: on the B300 failure class from #33356, I can now reproduce the corruption without CUDA Graph capture at all, by directly exercising the real GPU JIT plan_prefill(...) path that dispatches plan_compress_prefill_kernel0.

With #32467 removed:

uniform [4]x96:             0 / 2000 bad
ragged [4]x72 + [3]x24:   594 / 2000 bad
ragged [3]x104 + [2]x24: 1998 / 2000 bad

With #32467 applied and the TVM-FFI cache cleared/recompiled:

all three cases: 0 / 10000 bad each

For both failing ragged cases, ragged_id.max() is exactly 383 = B * s_max - 1 while the real ragged row count is 360, i.e. the non-uniform batch is getting IDs consistent with the uniform/MTP indexing formula. This reproducer uses one GPU, tiny tensors, no model weights, no server, and no graph-capture context.

So for the B300 branch of #33356, the corruption is demonstrably a kernel-plan correctness race fixed by #32467, not a capture-context-only failure. That does not invalidate the H200 behavior motivating this PR; it does mean the statement in this PR body that the #33356 failure is "a capture-context violation, not a kernel bug" is too broad across environments.

The four-arm B300 matrix remains consistent with this:

and #32467-only also passed sustained replay gates afterward. I would keep #33795 scoped as the H200 capture-ordering fix / complementary fix rather than the root cause for the B300 case.

Copy link
Copy Markdown

One more B300 data point that sharpens the interaction with #32467: I now have a deterministic single-GPU kernel-level reproducer for the c_plan.cuh race, independent of CUDA-Graph capture itself.

Directly calling the real plan_prefill(...) -> plan_compress_prefill_kernel0 GPU JIT path gives:

without #32467:
  uniform [4]x96               0 / 2000 bad
  ragged  [4]x72+[3]x24     594 / 2000 bad
  ragged  [3]x104+[2]x24   1998 / 2000 bad

with #32467:
  all three cases             0 / 10000 bad each

For both failing ragged cases, max(ragged_id) == 383 while the true ragged row count is 360. 383 is exactly B * s_max - 1 for both shapes, i.e. the MTP-uniform indexing formula is being applied to a non-uniform batch.

That directly demonstrates the #32467 mechanism (missing __syncthreads -> warp scratch race -> ragged misclassified as uniform -> OOB ragged_id) before any downstream kernel or graph-capture surfacing point is involved.

So for the B300/TP8 case, this is stronger evidence that the dominant failure is the kernel correctness race rather than the post-warmup capture-ordering gap fixed here. This does not invalidate the H200 ordering issue addressed by #33795; it just separates the two mechanisms more cleanly.

Copy link
Copy Markdown

Follow-up from the B300 investigation: #32467 now has a direct kernel-level reproducer

I reduced the B300 failure to the real single-GPU JIT plan_compress_prefill_kernel0 path (no model weights) and tested the missing-barrier fix directly.

Unpatched #32467:

uniform [4]×96:          0 / 2000 OOB plans
ragged  [4]×72+[3]×24: 594 / 2000 OOB plans
ragged  [3]×104+[2]×24:1998 / 2000 OOB plans

With #32467 applied:

0 / 30000 OOB plans across the same three cases

For both failing ragged cases the bad maximum was exactly ragged_id=383, while the real ragged row count was 360:

383 = B * s_max - 1

which is the exact upper bound of the MTP-uniform mapping appearing on a non-uniform batch. This directly demonstrates the missing-__syncthreads() plan race and the resulting OOB plan generation.

So the B300/TP8 evidence now separates the two fixes more cleanly:

This does not invalidate the H200 ordering evidence behind this PR; it only shows that the original B300 failure had an independently demonstrated device-kernel root cause. Full reduced-test details are posted on #32467 and the consolidated status is in #33356.

Copy link
Copy Markdown

Follow-up from B300: deterministic kernel reproducer points to #32467, not capture ordering

One more data point after the earlier B300 four-arm matrix.

I reduced the failure to a single-GPU harness around plan_compress_prefill_kernel0 and compared the unpatched kernel with #32467's missing-__syncthreads() fix.

Results:

Case Unpatched With #32467
uniform [4] x 96 0 / 2000 bad 0 / 10000 bad
ragged [4] x 72 + [3] x 24 594 / 2000 bad 0 / 10000 bad
ragged [3] x 104 + [2] x 24 1998 / 2000 bad 0 / 10000 bad
total 2592 / 6000 bad 0 / 30000 bad

The bad ragged plans reach rmax=383 even though the real ragged row count is 360, and 383 = B * s_max - 1 for both tested layouts. That matches the MTP-uniform ragged_id formula exactly and directly shows that the unpatched plan kernel can misclassify ragged input as uniform and generate OOB IDs.

This makes the B300 four-arm result easier to interpret:

Runtime under Arm B also passed:

  • low-tier sustained Graph replay: 320/320, no GPU fault;
  • historical compact+SPS c256/60k/1k strong path: 512/512, no GPU fault, health 200/200, post-run smoke PASS.

So for this B300/TP8 reproducer, the producer-side bug is now directly demonstrated in the plan kernel and fixed by #32467. The final device-sync + TP-barrier change in this PR remains compatible, but it is not required to make the B300 case stable.

This does not invalidate the H200 result that motivated #33795; it indicates that at least two distinct races/signatures can exist across platforms, and the B300 case was dominated by the plan-kernel race fixed in #32467.

Leoyzen added a commit to Leoyzen/sglang that referenced this pull request Aug 8, 2026
…project#32035 sgl-project#33656 sgl-project#32183 sgl-project#33145)

Applied PRs (latest from GitHub):
  sgl-project#33288  Indexer logits OOM fix
  sgl-project#30393  HiCache packed/sidecar draft caches
  sgl-project#31170  DPA prefix_affinity load balancing
  sgl-project#33795  DSpark compact ragged-verify CUDA graph JIT race
  sgl-project#32467  C128 plan-kernel warp barrier
  sgl-project#33865  DSpark x prefill CP unblock
  sgl-project#30371  SWA state pool sizing (storage page)
  sgl-project#33358  FlashMLA norm-rope K-tokens-per-block ILP
  sgl-project#33872  num_draft_tokens clamp + extend_len==0 skip (supersede sgl-project#32183)
  sgl-project#34002  Sidecar backup vacuously-successful fix (replaces sgl-project#33656, with tests)
  sgl-project#33862  Reclaim redundant host mirrors after storage backup
  sgl-project#31315  Avoid repeated Mooncake gets after stale hits
  sgl-project#32327  Q8KV8 sparse MLA prefill backend (flashmla_sparse_q8)
  sgl-project#31668  Fix sidecar pool life-time (use-after-free on prefetch abort)
  sgl-project#31195  TP0 verify-token-budget broadcast (adapted to get_schedule() API)

Dropped (per user request or superseded):
  sgl-project#32771  IndexCache C4 top-k reuse — has bug
  sgl-project#32035  DSpark C128 online compressor — has bug
  sgl-project#33656  Superseded by sgl-project#34002 (same fix + unit tests)
  sgl-project#32183  Superseded by sgl-project#33872 (included in supersede PR)
  sgl-project#33145  Base f01f706 already has superior reasoning-effort profile system

Conflicts resolved:
  sgl-project#31195: adapted to base get_schedule().disable_overlap_schedule API
  sgl-project#32327: path remapped jit_kernel/ -> kernels/jit/ and kernels/ops/attention/
  sgl-project#31668: applied cleanly on top of sgl-project#30393+sgl-project#34002+sgl-project#33862 modifications

Copy link
Copy Markdown

B30Z/TP8 v0.5.17 follow-up after the H200 bs=10 report

I reran the cross-version check on the original B30Z×8 / TP8 environment using an isolated sglang==0.5.17 tree.

Relevant results:

#32467 this PR (#33795) max decode graph bs Result
OFF OFF 128 1/1 capture failure at bs=104, TP7 (CUBLAS_STATUS_EXECUTION_FAILED)
ON ON 128 3/3 READY, 35/35 shapes each
ON OFF 128 1/1 READY, 35/35 shapes
ON ON 256 1/1 READY, 51/51 shapes

All five patched runs captured bs=10 successfully. The H200-reported v0.5.17 bs=10 failure therefore did not reproduce on this B30Z/TP8 stack.

For this platform the new result is consistent with the earlier v0.5.16 matrix: #33795 composes cleanly with #32467, but it was not required in the one isolated v0.5.17 #32467-only arm. I am deliberately not generalizing that to H200 or calling this PR unnecessary: TP topology, GPU architecture, CUDA/driver/library stack, model/build provenance, and the capture-shape set differ from the H200 report.

So the current evidence supports a narrower statement:

Copy link
Copy Markdown

Related backend-level follow-up: the same structural warmup-to-capture completion gap is present in BreakableCudaGraphBackend, and I opened #34286 for that path.

The Breakable ordering before the fix is analogous:

final warmup / post_warmup_hook
  -> no final device/TP completion boundary
  -> BreakableCUDAGraph construction

#34286 adds the same boundary:

device synchronize
-> TP-group barrier
-> BreakableCUDAGraph construction

and adds a CPU-only deterministic ordering regression requiring:

final post_warmup_hook
  < synchronize
  < barrier
  < BreakableCUDAGraph construction

I first ran that regression against the unpatched Breakable backend; it went RED specifically because the post-warmup completion boundary was absent. After the production change it passed, and the relevant runner-backend unit suite was 5/5.

I am treating this as evidence for a shared capture-initialization invariant, not as a claim that Breakable CUDA Graph reproduces the H200 DSpark crash from this PR. The B300 producer bug remains separately localized to #32467. The cross-backend contract is tracked in #32432.

Copy link
Copy Markdown

2026-08-11 clarification on the relationship to #32467: the final #32467 implementation has changed from the original barrier experiment to the reviewer-selected no-init formulation (58124fb...). The producer root cause is the redundant shared warp_min / warp_max initialization racing with completed per-warp reduction writes; removing that initialization eliminates the race source directly.

Same-session A/B/C producer regression:

original init / no extra barrier: 13111 / 30000 OOB
init + barrier experiment:             0 / 30000 OOB
final no-init / no extra barrier:      0 / 30000 OOB

The B300 startup isolation also keeps the two mechanisms separate:

#32467 OFF, #33795 OFF: 0/7 full capture
#32467 OFF, #33795 ON:  1/6 READY
#32467 ON,  #33795 OFF: 3/3 READY (35/35 shapes each)
#32467 ON,  #33795 ON:  3/3 READY (35/35 shapes each)

So for the tested B300 path, #32467 is the primary producer-side fix; #33795 remains a separate FullCudaGraphBackend capture-initialization ordering fix with its own H200 evidence. The complete current root-cause map and today's TP8/DP8 decode-only runtime data are in #34297.

jiaqiang-dot-liu added a commit to jiaqiang-dot-liu/sglang that referenced this pull request Sep 15, 2026
Problem

Both CUDA-graph backends run two warmup iterations before capture, with a
`synchronize()` + `barrier()` at the **top** of each iteration:

```python
for warmup_step in range(2):
    self._device_module.synchronize()
    self._tp_group.barrier()
    with self._precarve.measure():
        output = forward_fn()
    ...
    if post_warmup_hook is not None:
        post_warmup_hook()

... nothing drains the last warmup's own async work ...
graph = torch.cuda.CUDAGraph()
```

Those syncs order work issued *before* each warmup. Nothing drains the **final**
warmup's own async work.

Impact

Lazy / JIT kernel compilation kicked off by a first-seen shape can still be in flight
when capture starts. A JIT module that finishes loading mid-capture issues illegal
driver calls (`cuModuleLoadData` and friends) on the capturing stream, surfacing as
`CUDA_ERROR_ILLEGAL_ADDRESS` or `cudaErrorStreamCaptureUnsupported`.

This is intermittent by nature — it depends on whether the last warmup happened to
trigger a compile and whether that compile lands before or after capture begins.

Fix

Drain the device and re-align ranks once more, immediately before the capture.

Applied to **both** backends. sgl-project#33795 fixed
`FullCudaGraphBackend`; `BreakableCudaGraphBackend` has the identical pattern and was
left unfixed.

Verification

Reasoning-only. The ordering gap is visible in the source and the fix is the same
drain sgl-project#33795 already established as correct for the sibling backend. I have not
constructed a reproducer — the failure is a race and reproducing it reliably would
mean forcing a JIT compile on the last warmup shape.

Cost is two extra synchronization points per captured graph, at capture time only.
No effect on the replay path.

---

*Provenance: originally authored by an automated kernel-optimization agent
(hyperloom session `20260806T082051Z`), rebased onto current `main` and reviewed by
hand before submission.*
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants