Skip to content

[BI][DSv4] Batch-invariant GateLinear router with fp32 persistent matmul - #23

Open
aoshen02 wants to merge 10 commits into
bi/basefrom
bi/gate-linear
Open

aoshen02 wants to merge 10 commits into
bi/basefrom
bi/gate-linear

Conversation

@aoshen02

@aoshen02 aoshen02 commented Aug 15, 2026 •

Copy link
Copy Markdown
Owner

What

Batch-invariant GateLinear router under VLLM_BATCH_INVARIANT=1, plus a
small-N config for the shared batch-invariant persistent matmul.

The default cuBLAS route for the V4 router GEMM (4096, 256) runs
cublasLt::splitKreduce_kernel and switches algorithm across M
(ncu-verified), so row-0 routing scores change bitwise with batch size — a
discrete expert-routing flip. Under the flag GateLinear skips the tiered
dispatch and calls linear_batch_invariant directly in fp32: the
persistent 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.py gains a small-N persistent-matmul config (fp32,
N≤256 → BLOCK 64/64/128, w4s3) keyed only on (dtype, N) — M-independent
by construction, so it cannot break invariance; every small GEMM on the BI
path shares the gain.

Perf (CUDA-graph + events, GB200)

baseline BI before BI after
n=1..128 8.2–20.5 us 45.1–49.2 us 28.7 us flat

≈ +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 — contiguous
layout 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.linear at 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_add are
preserved, requested fp32 router logits do not round through bf16, and the
fixed (dtype, N, K) persistent-matmul config does not depend on M.

Notes

  • Not a duplicate: upstream BI covers the model's main GEMMs via aten
    overrides; the V4 tiered router bypasses them, and no upstream work
    touches it.
  • AI assistance was used (Claude); every line human-reviewed before merge.

🤖 Generated with Claude Code

Update: consolidation round (2026-08-15)

Branch rebuilt on a clean bi/base parent with the consolidation pass
folded in (shared test helpers, pure Triton key fn, tl.constexpr
constants — 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.py 6 passed,
test_matmul_batch_invariant.py 30 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.config and vllm.transformers_utils .model_arch_config_convertor, and separately dropped the determinism
directory'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.py 30 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, so
earlier 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 has
to 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:

config M <= 1024 M = 4096
16/32/256 w4 12.2us 150.1us
64/32/128 w4 20.6us 77.5us
32/64/256 w4 22.1us 83.5us
64/64/128 w4 (this PR) 24.4us 47.4us
64/128/64 w4 28.0us 29.2us
default 128/128/32 w8 40.8us 41.9us

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/64 is the one config that beats the
default 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 decode
steps vastly outnumber prefill steps. 64/128/64 is the config to switch to for
a 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 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 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.

aoshen02 and others added 10 commits August 18, 2026 15:07
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

Copy link
Copy Markdown
Owner Author

Rebased onto fork/bi/base, which now points at upstream aa9903490 — the exact
commit the nightly container image is built from, so the files copied into
site-packages can no longer be half-old. The previous base was 3ee2df303,
218 commits behind, and upstream had touched every file this stack changes.

Two things upstream did for us in that window:

Post-rebase verification: tests/v1/determinism (the six files this stack adds
or touches) → 120 passed, 0 failed on GB200 against the new image.

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.

1 participant