Conversation
aoshen02
force-pushed
the
bi/gate-linear
branch
from
August 15, 2026 09:30
11500cc to
b451152
Compare
The default cuBLAS route for the (4096,256) router GEMM runs splitKreduce_kernel and changes algorithm across M (ncu-verified), so the row-0 routing scores vary bitwise with batch size — a discrete expert- routing flip, not rounding noise. Under VLLM_BATCH_INVARIANT=1 GateLinear skips the tiered dispatch and calls linear_batch_invariant directly in fp32 (router scores must not round through bf16). batch_invariant.py gains a small-N persistent-matmul config (fp32, N<=256 -> BLOCK 64/64/128 w4s3) keyed only on (dtype, N) — M-independent, so it cannot break invariance. Cost drops from 45-49us to a flat 28.7us (baseline 8.2-20.5us); every small-N GEMM on the BI path shares the gain. Tests: bitwise stability of fp32 scores across batch boundaries, negative control on the default path (catches the cuBLAS split-k flip), correctness vs fp32 F.linear at reordering-level tolerance. 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 fp32 path added for batch invariance bypasses ReplicatedLinear.forward to keep the matmul in fp32, and dropped the bias while doing it. GateLinear takes bias=True (which also disables the specialized tiers, so that configuration lands squarely on this path), and a missing bias shifts every router score -- which decides which experts run. Fold it in explicitly. skip_bias_add is mirrored for completeness even though GateLinear never sets it. tests: bias=True through the fp32 path, compared against an explicit F.linear + bias reference. Not run in the pinned container -- that suite needs tests/conftest.py's default_vllm_config fixture, which the container harness cannot provide; it runs in CI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bias regression compared a Triton persistent GEMM against F.linear with rtol=atol=0. Those two have no bitwise-equality contract -- the rest of this file already uses tolerances for that reason -- so the test could fail for a reason unrelated to the bias. Use the same persistent path as the baseline and keep the exact comparison, which is what actually isolates the bias. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…le config The narrow-output config added for the router gate changes BLOCK_M, so the M tiling and the persistent program loop are exactly where a row's reduction order could start tracking the batch. Coverage stopped at M=64 -- always a single M tile, never a second persistent round -- so neither was exercised. Add M in (64, 65, 128, 4096) at N=256, K=4096: the second M tile partially and fully filled, and 256 output tiles against 148 SMs so the persistent programs loop. Each compares rows computed alone against the same rows inside the larger launch, which is the invariant rather than a reference comparison. Measured on GB200: all four shapes bitwise identical (the suite itself needs tests/v1/determinism/utils.py, which the pinned container cannot import, so this was checked with an equivalent standalone script; the test runs in CI). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he input The explicit fp32 path only triggered when the input was not already fp32. With x already fp32 and a bf16 weight -- the natural combination once params_dtype is bf16 -- it fell through to _forward_linear, which casts x down to the weight dtype; the persistent matmul then stores in that dtype and the result is promoted back. Both the input precision and the fp32 logits were already gone by then. Decide on both operands. The test fixture had the matching problem: it never passed params_dtype, so the weight was created in the default dtype (fp32 under pytest) and copy_ only converted values. The bf16-only tiers the file is about were never eligible, so the tier-boundary it claims to exercise was not being exercised -- and the old implementation would have reached the same fallback either way. Pass params_dtype explicitly and assert the weight dtype. Two GPU tests were also missing their skips (@skip_if_not_cuda, @skip_unsupported) while calling .cuda() directly. Not run in the pinned container: this suite needs tests/conftest.py's default_vllm_config fixture, which the container harness cannot provide. The branch change is a condition widening on dtypes and is decidable by inspection; the tests run in CI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The 64/64/128 config applied to every fp32 matmul with N <= 256 and 128-aligned K >= 1024, on every platform. The evidence is one shape on one platform: the DSv4 router gate, (N,K) = (256, 4096), on GB200. The three-stage tile also wants ~192 KiB of shared memory, which is not a given elsewhere. Restrict it to that shape on SM100 and say so, including the 9% regression at M = 4096 that is accepted because the gate runs at decode batch sizes. Not keyed on M, deliberately: splitting on M would make BLOCK_K a function of the batch, which is the reduction-order dependence this module exists to remove. tests: the fp32-output path asserted for both bf16 and fp32 inputs against an explicit fp32 reference -- the fp32-input case is the one the original guard missed. Measured on GB200 after narrowing: rows computed alone match rows inside larger launches bitwise for M in (64, 65, 128, 4096) at the specialized shape, and for (N,K) = (128, 4096) and (256, 2048), which now take the default tiles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The parametrization's comment still described the pre-narrowing predicate (any N <= 256 with 128-aligned K >= 1024). The config is scoped to (N, K) = (256, 4096) on SM100 now; say that, so the shapes below read as what they are. 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. So test_gate_linear_batch_invariant.py -- including the bias regression and the fp32/bf16 branch this PR is about -- was never executed by any job. Add it. The module docstring also still said the batch-invariant path "uses F.linear". With an fp32 out_dtype it calls linear_batch_invariant directly; F.linear is only the other branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…t it is invariant The four shapes added for the fp32 narrow-output config check that a row's result does not move with M. Every one of them holds for the default 128x128x32 tile as well, so a tree where the narrow config was never wired in -- or where the module under test was not the one that got loaded -- passes the sweep unchanged. That is not a run of the new code path. Lift the tile selection out of matmul_persistent into _persistent_matmul_config(dtype, N, K) and assert on it directly: the DSv4 router-gate shape gets 64/64/128 with 4 warps on SM100, a wider N keeps the default, and the choice does not leak into other dtypes. The helper takes no M. Keying the tile on M would make BLOCK_K a function of how many rows share the launch, which is the reduction-order dependence this module exists to remove -- now enforced by the signature instead of by a comment above the branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comment said keying the tile on M would make BLOCK_K a function of the batch, "precisely the reduction-order dependence this module exists to remove". On the fp32 path that is measurably false: acc = tl.dot(a, b, acc) accumulates over k tiles in increasing k and the sum does not depend on the chunking. Three configs with two distinct BLOCK_K values give bitwise equal output at every M tested, and so does switching the tile at M=1024 while watching one row across batches 1..4096. Keep the rule, state the real reason. The equality is a property of how Triton currently lowers an fp32 dot; bf16 and fp16 go through tensor cores where it has no reason to hold, and this helper serves every dtype. Excluding the batch from the key makes the guarantee structural rather than dependent on a lowering detail whose regression would be silent. No behaviour change. A separate sweep of ~30 tile configs found no config that beats this one at both decode sizes and M=4096 -- the trade-off is structural, since A is re-read once per N-tile -- so the shipped choice stands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
aoshen02
force-pushed
the
bi/gate-linear
branch
from
August 18, 2026 15:11
cac2b7f to
4a7cb98
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
Batch-invariant
GateLinearrouter underVLLM_BATCH_INVARIANT=1, plus asmall-N config for the shared batch-invariant persistent matmul.
The default cuBLAS route for the V4 router GEMM
(4096, 256)runscublasLt::splitKreduce_kerneland switches algorithm across M(ncu-verified), so row-0 routing scores change bitwise with batch size — a
discrete expert-routing flip. Under the flag
GateLinearskips the tiereddispatch and calls
linear_batch_invariantdirectly in fp32: thepersistent matmul computes in the input dtype, and V4's router explicitly
wants fp32 scores — routing through bf16 first would round them (caught in
review, covered by a tolerance that a bf16 round-trip fails by ~40×).
batch_invariant.pygains a small-N persistent-matmul config (fp32,N≤256 → BLOCK 64/64/128, w4s3) keyed only on
(dtype, N)— M-independentby construction, so it cannot break invariance; every small GEMM on the BI
path shares the gain.
Perf (CUDA-graph + events, GB200)
≈ +0.65 ms/step over ~58 layers (was +1.7 ms). ncu: grid 2→4, kernel
89.5→43.8 us; the residual is persistent-tile residency, config choice
justified by a two-round sweep (production
weight.t()layout — contiguouslayout ranks configs completely differently).
Tests
tests/v1/determinism/test_gate_linear_batch_invariant.py— 6 passed:bitwise stability of fp32 scores across batch boundaries [1,8,15,16,17,32,
33,64], negative control on the default path that actually fails
(catches the cuBLAS split-k algorithm flip on this exact shape), and
correctness vs fp32
F.linearat reordering-level tolerance(atol 2e-3 / rtol 1e-4; measured 8e-5 worst element).
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) found nothing to change here: batch
invariance bypasses every M-dispatched tier, bias and
skip_bias_addarepreserved, requested fp32 router logits do not round through bf16, and the
fixed
(dtype, N, K)persistent-matmul config does not depend on M.Notes
overrides; the V4 tiered router bypasses them, and no upstream work
touches it.
🤖 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_gate_linear_batch_invariant.py6 passed,test_matmul_batch_invariant.py30 passed.Update: audit round 16 (2026-08-16)
The two tests that cover the bias fold and the fp32 branch decision had never
actually been executed in the container -- the harness died on a circular
import between
vllm.configandvllm.transformers_utils .model_arch_config_convertor, and separately dropped the determinismdirectory's autouse fixture, so the suite would have run with batch invariance
off. Both were harness bugs, not code bugs. With them fixed the suite is
6 passed on GB200,
test_matmul_batch_invariant.py30 passed alongside it.That also caught a silent gap in the container sync list: it did not include
vllm/model_executor/layers/batch_invariant.py, which this PR changes, soearlier runs would have tested a half-updated tree. The sync step now warns
when a file the branch changes is missing from the list.
Update: is the M=4096 cost avoidable? (2026-08-16)
The tile config is keyed on
(dtype, N, K)and never on M, so one config hasto serve every batch size, and the one picked here is faster at decode and
slower at M=4096 than the default. Three more sweep rounds, ~30 configs, on
GB200, profiler device time for the kernel alone (wall time sits on a ~300us
Triton launch floor and shows nothing), production
weight.t()layout:No config wins at both ends, and the reason is structural: A is 4096x4096 fp32
and is re-read once per N-tile, so M=4096 wants few wide tiles, while decode is
latency-bound and wants many.
64/128/64is the one config that beats thedefault at every M -- it would turn the M=4096 number from 13% slower than
default into 30% faster -- and it costs 15% at decode. Over ~58 layers that is
+209us per decode step against -1.06ms per 4096-token prefill step.
Keeping
64/64/128: this work exists for RL rollout alignment, where decodesteps vastly outnumber prefill steps.
64/128/64is the config to switch to fora prefill-heavy deployment, and the numbers above are what that costs.
A correction to why the key excludes M
The comment above the branch used to say that keying on M would make BLOCK_K a
function of the batch, i.e. the reduction-order dependence this module removes.
That is not true on this path.
acc = tl.dot(a, b, acc)accumulates over ktiles in increasing k and the sum does not depend on the chunking: three configs
with two distinct BLOCK_K values give bitwise equal output at every M tested,
and so does deliberately switching the tile at M=1024 while watching one row.
The rule is still right, for a different reason: that equality is a property of
how Triton currently lowers an fp32 dot, not of the contract. bf16 and fp16 go
through tensor cores where it has no reason to hold, this helper serves every
dtype, and a regression would be silent. The comment now says that instead.