[4/5] feat: capture the FFN experts at bucketed padded shapes - #335
Open
specture724 wants to merge 1 commit into
Open
[4/5] feat: capture the FFN experts at bucketed padded shapes#335specture724 wants to merge 1 commit into
specture724 wants to merge 1 commit into
Conversation
specture724
requested review from
hsliuustc0106 and
jiangkuaixue123
as code owners
September 10, 2026 09:20
specture724
force-pushed
the
afd/async-gpu-cudagraph
branch
from
September 10, 2026 11:42
2e31ca8 to
e569e98
Compare
This was referenced Sep 11, 2026
specture724
force-pushed
the
afd/async-gpu-cudagraph
branch
from
September 11, 2026 02:16
e569e98 to
0d6f7eb
Compare
specture724
added this pull request to stack #337
September 11, 2026 02:17
specture724
force-pushed
the
afd/async-gpu-cudagraph
branch
from
September 11, 2026 10:04
0d6f7eb to
9448589
Compare
The connector-driven FFN side never learns the next work item's shape ahead of time -- there is no control plane -- which is why it ran eagerly. It does not need to: a grouped GEMM takes its grouping from a device-side count vector, not from its row count, so one row count can be captured and every smaller item padded up to it, with the padding charged to the last expert and its output sliced off. Sizing that capture is the whole problem. The upper bound is every one of a token's topk partials landing on one rank; with 256 experts over two FFN ranks a rank really receives about half that, so a single graph at the bound charges every replay ~2x. The graphs are a ladder of buckets built as multiples of the *expected* item size, clustered just above 1.0 and 0.5 where routing scatter actually puts items, and an item takes the smallest bucket that holds it -- or runs eager when the bucket would pad it past MAX_REPLAY_PADDING_RATIO, since a replay pays for its whole bucket while eager pays launches. Two capture-order traps, both faulting on the first replay: vLLM's WorkspaceManager grows the shared MoE scratch by *freeing* the old buffer, so capture runs largest-bucket-first behind one eager call at the ceiling; and capturing with all rows on one expert reserves too little per-expert block padding for a replay whose routing touches every expert, so capture counts are spread evenly instead. Measured pure prefill, DeepSeek-V4 2A2F on 4x L20X, 2048-token steps: 27.6 s for a single worst-case graph, 19.0 s bucketed, 17.9 s eager. Bucketing removes a 1.55x regression; it does not beat eager, because the connector issues about one grouped GEMM per item and there are no launches to save. V4 therefore keeps FFN graphs off by default -- see FFN_EAGER in the recipe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: specture724 <specture724@gmail.com>
specture724
force-pushed
the
afd/async-gpu-cudagraph
branch
from
September 14, 2026 14:31
9448589 to
ed945fd
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fourth of a five-PR stack. Based on #334 — the diff shown here is only this
PR's own change; review #332, #333 and #334 first.
What this adds
The connector-driven FFN side never learns the next work item's shape ahead of
time — there is no control plane — which is why it ran eagerly. It does not need
to: a grouped GEMM takes its grouping from a device-side count vector, not from
its row count, so one row count can be captured and every smaller item padded up
to it, with the padding charged to the last expert and its output sliced off.
Sizing that capture is the whole problem. The upper bound is every one of a
token's topk partials landing on one rank; with 256 experts over two FFN ranks a
rank really receives about half that, so a single graph at the bound charges
every replay ~2x. The graphs are a ladder of buckets built as multiples of the
expected item size, clustered just above 1.0 and 0.5 where routing scatter
actually puts items, and an item takes the smallest bucket that holds it — or
runs eager when the bucket would pad it past
MAX_REPLAY_PADDING_RATIO, since areplay pays for its whole bucket while eager pays launches.
Two capture-order traps, both faulting on the first replay, both found the hard
way:
WorkspaceManagergrows the shared MoE scratch by freeing the oldbuffer, so any growth after a capture leaves that graph dangling. Capture
runs largest-bucket-first behind one eager call at the ceiling.
padding for a replay whose routing touches every expert. Capture counts are
spread evenly instead.
Bounded by free memory, not by the token budget
The ladder is sized from
max_num_batched_tokens * topk, so its memory growswith the token budget. At 8192 tokens the default ladder captured 65 GiB per
DeepSeek-V4 FFN rank, and the connector's NVSHMEM heap — set up after
capture — then failed with
cuMemCreate failed. Capture now checks free memorybefore each MoE layer and stops at the first layer whose graphs would cut into
AFD_FFN_GRAPH_MEM_RESERVE_GIB(default 8). The remaining layers run eagerlythrough the existing no-graph fallback.
Validated on DeepSeek-V4-Flash 2A2F, 8192 tokens, default ladder: capture
stopped at 37/43 layers (58.2 GiB), NVSHMEM initialized, and the server
served correct completions. At 2048 tokens every layer still fits, so the
behaviour measured below is unchanged.
Measurements, including where it does not pay
Pure prefill, DeepSeek-V4 2A2F on 4x L20X, 2048-token steps:
Bucketing removes a 1.55x regression; it does not beat eager, because the
connector issues about one grouped GEMM per item and there are no launches to
save.
FFN_EAGER=1is therefore the V4 default and this path is opt-in.Separately, on the Attention side in the decode regime,
FULL_DECODE_ONLYgraphs are a real win — DeepSeek-V2-Lite 1A1F, 2x L20X: 36.6 s eager vs 28.3 s
with graphs (+22.6%), replay-verified. That is an existing vLLM mechanism this
PR does not change; noting it because it is the reason not to leave graphs off
for decode-heavy serving.
Testing
test_cuda_graph.py(bucket ladder, shared-row scaling, bucket selection),test_ffn_model_runner.py(replay, padding slice-off, eager fallbacks, captureordering), and
tests/e2e/async_gpu_ffn_padded_graph.py(1 GPU) which replaysagainst an eager run of the same routing.
Full unit suite unchanged against
main: same 13 pre-existing failures, noneadded.
pre-commitclean over the PR range.🤖 Generated with Claude Code