Repository navigation
Conversation
aoshen02
force-pushed
the
bi/sparse-mla
branch
2 times, most recently
from
August 16, 2026 11:53
aca66aa to
412c3d3
Compare
All three CUDA top-k kernels (cooperative/persistent/per-row, prefill included) resolve exact-score ties through shared-memory atomics, so on tie plateaus both the selected set and its order change run to run — and relu(q·k) DSA scores make exact-0.0 plateaus routine. Selecting different KV is divergence, not rounding noise. Under VLLM_BATCH_INVARIANT=1, decode and prefill dispatch to a deterministic per-row top-k: score descending, exact ties by ascending column index, emitted ascending with a -1 tail, row-local (rowStart- relative) coordinates matching the CUDA prefill kernel's write contract. Fast path is one Triton program per row: the row is register-resident, a 32-step binary search on a monotone int32 key space finds the exact k-th key, and one ascending scan emits key>T plus the first budget ties by column index via exclusive cumsum — no sort anywhere. -0.0 canonicalizes to +0.0 before keying (torch.sort tie semantics); fixed num_warps, no autotune; the path is gated to the CUDA kernels' envelope (topk in 512/1024/2048, <=8192 columns), row bounds clamp to [0, num_cols] before int32 narrowing, and everything else falls back to the torch stable-sort reference (kept for other platforms and as the equivalence oracle). CUDA-graph events: 18.5us flat for n=1..64 (was 90-133us; nondeterministic CUDA baseline 8.2-14.4us); captures cleanly under PIECEWISE+FULL graphs. Tests (12): bitwise-equivalence matrix vs the reference (tie plateaus, quantized ties, mixed signed zeros, denormals, empty rows, narrow chunks, shifted cu_seqlen_ks), NCOLS bucket edges, all-equal and all--inf rows, strided/padded layouts, fallback dispatch past 8192 columns, int64 bounds past int32 range. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Consolidation pass folded in (codex r10/r10b reviewed): shared test helpers, pure Triton key fn, tl.constexpr-instantiated constants (plain global ints fail to compile under Triton 3.7), repo-pinned ruff format. Container suite 39/39 green; topk equivalence probe 240/240 bitwise.
The fused top-k masked lanes outside [row_start, row_end) to -inf and only
gave true out-of-bounds lanes the INT32_MIN sentinel. An in-window score of
-inf therefore shared a key with them, and since the tie budget is spent in
column order, the masked lanes sitting before row_start consumed it first and
were then dropped at emission. The number of indices a row emitted fell by
exactly row_start -- which is where the packer happened to put that request,
i.e. a function of its neighbours. The torch reference made the same choice,
so comparing the two agreed on the wrong answer.
Both now key every out-of-window lane at INT32_MIN, which sits strictly below
_topk_bi_key(-inf). The reference switches to the kernel's integer key rather
than sorting floats, so the two are equivalent by construction instead of by
argument.
tests: test_masked_lanes_do_not_spend_the_tie_budget asserts the invariant
directly -- the same logical row at six different row_start offsets must
select the same local indices -- because a reference comparison cannot catch a
defect the reference shares. Negative control on the pre-fix kernel: 2 failed
("row_start=1 selected different indices than row_start=0"); after: 14 passed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round 2 of the audit found the sentinel is not unique: a negative NaN (0xffffffff) maps to exactly INT32_MIN, the same key out-of-window lanes get. The kernel is unaffected -- its tie scan is already restricted to in-window lanes -- but the reference sorts first and drops after, so a masked lane could take a slot from an in-window NaN. Concretely, 600 in-window -NaN lanes at row_start=1 gave 512 indices from the kernel and 511 + a -1 from the reference. Lift in-window keys off the sentinel in the reference. Scores are not expected to be NaN; this is about the two implementations agreeing on every bit pattern rather than about a production input. tests: negative-NaN plateau against the sentinel, plus a window narrower than top-k inside a wider row (valid_count 30 < k_eff 512, num_cols 630 > TOPK) -- the shape where the cutoff lands on the sentinel. Negative control without the lift: 1 failed. With: 15 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit lifted in-window keys off INT32_MIN so they could not collide with the out-of-window sentinel. That is not sound: INT32_MIN and INT32_MIN + 1 are both reachable keys -- 0xffffffff and 0xfffffffe, two distinct negative NaNs -- so the lift merged them, and a row holding both ordered differently from the kernel. No sentinel value can be reserved here. Rank on validity first and key second instead. The kernel selects only from in-window lanes, so validity is already its implicit primary key; two stable sorts compose lexicographically and reproduce that for every bit pattern. tests: a window holding both sentinel-adjacent NaN patterns. Negative control with the lift: 1 failed. 15 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… tests Round 4 accepted the validity-first ranking as equivalent to the kernel on all fp32 bit patterns but noted the tests only exercised two of them, both negative quiet NaNs. Parametrize over the classes that actually sit near the integer key's edges: signed zeros (canonicalized), infinities (its bounds), quiet and signaling NaNs, min and max denormals, and the two sentinel-adjacent patterns -- across three window shapes and both index_topk sizes. Written as raw int32 views: float32 -> Python float quiets signaling NaNs, so they cannot be constructed through .item(). Two comments contradicted the implementation and are corrected: the kernel no longer masks invalid columns to -inf before keying, and the reference no longer lifts in-window keys off a sentinel. Negative control against the pre-validity-ranking reference: 2 of the classes fail. 31 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2048 is a supported index_topk and the selector specializes on it, but the bit-pattern sweep only ran 512 and 1024. Add it, along with a window wide enough (2600 in-window lanes) that the selection actually truncates at every topk under test rather than emitting the whole window. 39 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_INT32_MIN was instantiated unconditionally at module scope. Without Triton `tl.constexpr` is None, so importing this module on a CPU-only path raised TypeError -- including during test collection for test_mla_short_prefill_indexer.py. Guard it on HAS_TRITON, the pattern already used by gumbel.py and lamport.py. 39 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The B200 batch-invariance job lists its test files one by one; source_file_dependencies only decides whether the job runs, it does not discover new files. This one was never executed by any job. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nvariance Decode: the FlashMLA sparse decode kernel plans its internal KV splits from batch-wide state; under VLLM_BATCH_INVARIANT=1 the tile-scheduler plan is pinned and each request is split by its own length only, built once per layer type per step (host cost 37ms -> 0.2ms/step). CUDA-graph cost 0.99-1.04x vs the shipped plan. Prefill: flash_mla_sparse_fwd takes no schedule metadata and plans its internal splits from the call geometry (chunk query rows / gathered widths); the greedy prefill chunk packing makes a request's geometry depend on its neighbors. Under BI the chunk plan becomes one request per chunk, so the geometry is a function of the request alone — constructively batch invariant. Found by a long-sequence e2e sweep with prefix caching off (first divergence localized to L0 attention via per-layer forward hooks). Tests: decode plan pinning, per-request prefill chunk plan (each request's chunk equals its solo plan), production extra-KV shape. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Consolidation pass folded in (codex r10/r10b reviewed): shared test helpers, pure Triton key fn, tl.constexpr-instantiated constants (plain global ints fail to compile under Triton 3.7), repo-pinned ruff format. Container suite 39/39 green; topk equivalence probe 240/240 bitwise.
…e SM count Round 1 flagged this branch BLOCK; this is the planner half of it. The pinned plan refused to build once the batch exceeded the partition count and handed the step back to the shipped planner -- whose layout is a function of the batch, which is the thing this pin exists to remove. The refusal was only there because the multi-request case was untested, not because it is unsound. Partition p owns requests [floor(p*b/P), floor((p+1)*b/P)). Those spans are contiguous, disjoint and cover every request exactly once for any b: below the partition count each is 0 or 1 wide, above it a partition simply holds several whole requests. No request is ever split across partitions either way, so a row's reduction order still depends only on its own length. end_block_idx belongs to the partition's last request, which is the one index the caller fills in per call. tests: the covering property over b in (1, 3, 4, 5, 11) against P=4 -- once, contiguously, in order; idle rows left begin > end; nothing marked split; one split per request. Negative control restoring the b <= P assumption: the two above-count cases fail. 14 passed. Added to the B200 job, which lists its files individually. Still open on this branch and not claimed fixed: the cold-start step still lets the shipped planner's output through before record_num_sm_parts has seen a plan, and the C128A active_topk_width is derived from a batch-wide max_seq_len in sparse_mla.py. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
num_sm_parts is only observable from a real plan, so the very first step for a given (h_q, s_q) had to run the shipped planner -- and its result went straight into the output buffer. That layout is a function of the batch, so the first request through a fresh shape got an answer no later request would reproduce. Warmup hides it in practice, which is why it read as harmless. Record the partition count from that call as before, then rebuild the pinned plan and redo the kernel call with it. The shipped planner still runs once; its output is no longer what anyone sees. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The B200 Batch Invariance job lists vllm/v1/attention and vllm/model_executor/layers, so the planner change in sparse_swa.py is covered but the two DSv4 model files this PR also changes are not. A later edit to flashmla.py or sparse_mla.py alone would skip the suite that guards them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ove the SM count Three gaps, all in the same direction -- code that looked verified but was not. If build_pinned_sched_meta still returns None after the shipped planner has run and the partition count has been recorded, the decode path kept whatever the shipped planner produced. That is a batch-dependent layout returned under a flag whose whole contract is that it is not, and it happened silently. Raise instead. Every GPU test stopped at batch 65 while a GB200 plans 152 partitions, so the branch that groups whole requests per partition -- the one that replaced the fallback -- had only a CPU assertion on the skeleton's shape. Add invariance and coverage tests at the partition count and above, reading the count from the cache so they follow the hardware. The cold-start rerun has no test of its control flow, which needs the model. Test the fact it rests on instead: an unobserved (h_q, s_q) yields no plan, and one observed plan makes the same shape pinnable. Also fix the annotations on _plan_skeleton and _plan_cache, which declared the second element a Tensor where it is a (busy_idx, last_owned) pair. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…overage The cold-start rerun -- run the shipped planner once because the partition count is not observable otherwise, then redo the call with the pinned plan so its batch-dependent output is never the one returned -- had no test of the control flow itself, only of the fact it rests on. It does not need a model: stub the kernel and the planner and watch the sequence. Removing the rerun makes both new tests fail, which is the point of them. Also stop gating the whole module on SM100. The backend accepts compute capability 9 and 10, so declaring the file "SM100-only" was wrong; what is true is that the kernel tests were only ever run there and the numbers in the docstring are GB200's. Gate those individually and let the structural and control-flow coverage run across the supported range. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ct on SM90 The stub proved less than it looked. Its fake kernel never left a plan behind the way the in-kernel planner does, its fake record was a self-assignment, and its fake build returned the pinned plan on call count alone -- so deleting record_num_sm_parts, or calling it before the kernel, left the test green while the real cold start would fail closed forever. It also never checked that num_splits was installed, so dropping that line stayed green too. Model each participant instead: the kernel leaves a shipped plan on its first call, record only counts as an observation if it was handed one, and build returns pinned only after an observation. Assert both installed fields by identity. All three regressions now turn the tests red, including the mis-ordering, which reproduces exactly as the permanent fail-closed it would be in production. Separately, test_prefill_chunk_plan_batch_invariant is pure CPU metadata work and never launches FlashMLA, so it should not have carried the SM100 gate; and splitting the markers only helps if some lane collects the file below SM100. Add it to the H100 job, where the kernel tests skip and the rest run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…just against itself test_pinned_plan_does_not_drop_work checks the pinned plan value-by-value against FlashMLA's own planner, but only below the SM partition count, where each request still owns a partition. Above it the layout groups whole requests per partition -- a shape the shipped planner never emits and whose metadata columns this PR writes by hand -- and the only coverage there was invariance plus a span check on the metadata. A plan that quietly skipped KV in that regime would satisfy both. Run the same comparison at P+1 and 2P+1. Agreement is also what says each hand-written column means what the kernel reads it as, since getting one wrong moves the output. Leaving one request unowned in the last partition makes the test fail, which is the check it is there for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
aoshen02
force-pushed
the
bi/indexer-topk
branch
from
August 18, 2026 15:11
fe5d557 to
14b96ea
Compare
aoshen02
force-pushed
the
bi/sparse-mla
branch
from
August 18, 2026 15:11
9a075db to
755c4ee
Compare
Owner
Author
|
Rebased onto Two things upstream did for us in that window:
Post-rebase verification: |
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.
What
Pins sparse-MLA decode and prefill scheduling under
VLLM_BATCH_INVARIANT=1.Decode: the FlashMLA sparse decode kernel builds its internal KV-split
plan from batch-wide state. Under the flag the tile-scheduler plan is
pinned and each request is split by its own length only; the plan is built
once per layer type per step (host cost 37 ms → 0.2 ms/step).
Prefill:
flash_mla_sparse_fwdtakes no schedule metadata — it plansinternal splits from the call geometry (chunk query rows, gathered
widths). The greedy prefill chunk packing makes a request's geometry depend
on its neighbors, so identical requests produced different bits depending
on co-scheduled prefills. Under the flag the chunk plan becomes one
request per chunk: the geometry is a function of the request alone,
constructively batch-invariant.
The prefill bug was invisible to the previous e2e because prefix caching
recomputed the victim's prefill only once; a long-sequence sweep with
prefix caching off reproduced it 100% at a fixed 2-request composition, and
per-layer forward hooks localized the first divergence to L0 attention.
Perf
Decode: CUDA-graph cost 0.99–1.04× vs the shipped plan (b=1..17), with
correctness 0/7 mismatching and negative control 6/6 MISMATCH. Prefill
per-request chunks are covered by the e2e throughput A/B in the RFC PR.
Tests
tests/v1/determinism/test_sparse_mla_batch_invariance.py: decode planpinning + invariance, production extra-KV shape, per-request prefill chunk
plan (each request's
(chunk_N, chunk_M)equals its solo plan).E2e: online BI checker (long sequences, prefix caching off) 0 diff on
1 GPU and TP4/EP4, including the full 43-layer model.
GSM8K (full 43-layer Flash-Base, 1319 questions, 5-shot, greedy): BI=1
accuracy 0.911 vs BI=0 0.904 (0 invalid both; within noise).
Review follow-up
An external review (codex, gpt-5.6) read the pinned scheduling end to end and
found no surviving batch dependency:
_split_budgetderives from theconfigured
max_num_seqs,_blocks_per_splitfrom the configured top-k caps,_split_plancuts only at fixed multiples of each row's own block count, and_plan_skeletonnever splits a request — including for batches above thepartition count. It also confirmed the
flashmla.pycold start: the shipped-planresult is overwritten by a second call after
record_num_sm_parts, both pinnedmetadata tensors are installed, and a failure to rebuild raises rather than
returning the first result. No changes were needed.
Notes
stalled; no upstream work touches the DSA sparse prefill chunk plan.
🤖 Generated with Claude Code
Update: consolidation round (2026-08-15)
Branch rebuilt on a clean
bi/baseparent with the consolidation passfolded in (shared test helpers, pure Triton key fn,
tl.constexprconstants — plain global ints fail to compile under Triton 3.7 — and
repo-pinned ruff format), adversarially reviewed (codex r10/r10b).
Container suite on GB200:
test_sparse_mla_batch_invariance.py21 passed.Stacked on
bi/indexer-topk(2026-08-16)This branch is based on
bi/indexer-topk, not onbi/base, and the PR targetsit. The dependency is real rather than a note in this description: the indices
this scheduling plan operates on come from the indexer's top-k, whose dispatch
switches implementation on
num_rows <= 32(sparse_attn_indexer.py,cooperative vs persistent). Both resolve exact score ties through shared-memory
atomics, so on a tie plateau the selected set depends on how many rows share the
step.
bi/indexer-topkcollapses both into one deterministic implementationunder
VLLM_BATCH_INVARIANT.Pinned on its own, this branch would fix the schedule and leave the indices
batch-dependent. Basing it on the fix means that cannot be deployed by accident.
Combined HEAD verified on GB200:
test_sparse_mla_batch_invariance.py21passed,
test_indexer_topk_batch_invariant.py39 passed.Scope
Fixed here:
above the SM partition count; whole requests are grouped per partition.
longest sequence.
against the shipped planner on GPU, at the same 8 bf16 ULP bar the
below-the-count comparison uses. That is what says the hand-written metadata
columns mean what the kernel reads them as: getting one wrong moves the
output. Leaving one request unowned in the last partition makes it fail.
Cost of the constant C128A width
Pinning the width means a short request at a large
max_model_lenwrites thefull row instead of a bucket sized to this step. The width is
align128(max_model_len / 128), so it is 128 -- i.e. unchanged -- for everymax_model_lenup to 16k, and reaches 1024 at 128k.Measured on GB200 by calling
build_c128a_topk_metadatadirectly, device timefrom the profiler (wall time sits on a ~50us launch floor below width 2048 and
shows nothing there):
The kernel writes every column of the row whatever the valid length is, but its
BLOCK_SIZEis 1024, so widths at or below that are the same vector store withmore of it masked -- the traffic grows and the trip count does not. Past 1024
each doubling adds a loop iteration, and the cost is real: at the width implied
by
DeepseekV4Config's defaultmax_position_embeddingsof 1048576 themetadata kernel is 5-7x its non-batch-invariant cost.
So: free up to
max_model_len128k, which covers the deployments this wasbuilt for, and a genuine cost above it. Serving a 1M context under
VLLM_BATCH_INVARIANTshould expect this, and a width-aware plan (bucket bythe request's own length rather than by the step's) is the obvious follow-up if
that matters -- it is deliberately not attempted here, because a per-request
bucket is exactly the batch-dependent quantity this PR removes and getting it
right needs the bucket to come from the request, not from the step.
Not claimed:
the decode split cap. The table above covers the metadata kernel only.
10; every FlashMLA test here was run on SM100 only, and the timings quoted
above are GB200's. The structural and control-flow tests are no longer gated
on SM100, so they do run on the SM90 half, but the kernel ones are skipped
there and nothing in this PR should be read as validated on H100.