Skip to content

mate: Marlin-class W4A16 GEMM for mp_31 (SQMMA int4 x fp16) - #6

Open
yeahdongcn wants to merge 15 commits into
MooreThreads:mainfrom
yeahdongcn:pr/w4a16-marlin
Open

yeahdongcn wants to merge 15 commits into
MooreThreads:mainfrom
yeahdongcn:pr/w4a16-marlin

Conversation

@yeahdongcn

@yeahdongcn yeahdongcn commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

What

A Marlin-class W4A16 (int4 weights x fp16 activations) path on mp_31 (MTT S5000), plus the frame the
vllm-musa mixed-precision seam consumes.

  • mate/marlin/: the GPTQ/AWQ frame builder, act-order resolution, and the MPLinearKernel-shaped wrapper.
  • mate/jit/gemm/: the SQMMA W4A16 kernels -- a small-M GEMV that reads each weight once, and a 128-row
    large-M tile with one atom per consumer squad, both with zero-point support.
  • AOT variants for the large-M kernel (two tiles, fp16/bf16, symmetric/zero-point).

Design point worth reviewing first: both this kernel and the shipped Triton W4A16 unpack int4 to fp16 before
the MMA
, so they do the same arithmetic and neither can win on precision. The gap is per byte moved.

Two defects this PR carries a fix for

Both are the same mistake in different clothes: deciding a convention from whether a parameter exists rather
than from the quantization config.

  1. Zero points were read from a parameter that is registered for every quantized layer even when the config says
    zero_points=False (symmetric). The value is then a placeholder, and feeding it through the zero-point
    conversion mis-scales the output -- the "zero point converted twice" bug.
  2. g_idx was read from a parameter a non-desc_act checkpoint never fills. The allocation is torch.empty, so
    the kernel receives uninitialised memory as a permutation.

Both are now gated on the config flag.

  1. marlin_zero_points_to_musa had one inverse for two conventions. Both inputs have the same shape and the same
    multiset of values, so the shape guard accepts either, and picking wrong silently re-orders zero points against
    scale_b/weight_b: measured 1.034323 relative error against an fp32 reference with every guard silent.
    The correct pairing measures 0.000315. The convention is now named by the caller instead of guessed.

Act order

Servable, not refused. resolve_act_order sorts the k rows and stores input_perm; the apply path gathers the
activation with the same permutation, so x[:, perm] @ W[perm] == x @ W exactly. Verified: the act-order and
non-act-order paths agree to 3.4e-04 rms relative against an fp32 dequantised reference, and the result is
distinguishable from a silently skipped gather (which differs by 2.767e+02). A malformed permutation is refused
loudly with the violated invariant named.

Verification

Measured on MTT S5000 (mp_31) on the 24-core tier part, multi_processor_count == 48, on an idle
device, via vllm-musa serving with the kernel selected through
MPLinearKernel:

cc this kernel shipped Triton W4A16 ratio
1 179.49 tok/s 108.68 tok/s 1.65x
8 965.40 tok/s 730.59 tok/s 1.32x

TPOT 5.28 vs 8.74 ms at cc=1; TTFT 50.20 vs 104.44 ms at cc=8. Same checkpoint, same flags, one arm at a time.

Tests in this PR pin both zero-point directions and the act-order/gather equivalence.

Commit structure

Fifteen commits, grouped by area: the kernels, the export + AOT registration, the marlin frame, the tests,
the docs, split-K, the wide-N routing, the joint tile/split plan, and the caster squad.
The history was reorganised from the working branch, and the result is byte-identical to the tree
every number above was measured on (git diff <pre-tidy-tip> HEAD is empty), so the reshuffle changes
how the work reads, not what it does.

The single defect fix that was still uncommitted on that branch -- marlin_zero_points_to_musa naming
the zero-point frame instead of guessing it -- is folded into the frame commit rather than appended, so
no commit in the series contains code the series does not stand behind.

Scope

Kernel, frame and tests only. No vllm-musa code here; that arrives as a separate PR against its
v0.28.0-dev.

Split-K: the decode occupancy lever

The W4A16 decode shape underfills the part, and that turned out to be worth more than any tuning knob. One
CTA streams a whole column block, so its wall time is set by the bytes it reads and not by how many CTAs
are resident. Measured at [M=8, K=8960], device time is flat at 48.27 / 48.15 / 48.04 us from 8 tiles
(N=512) through 60 tiles (N=3840), and only jumps to 89 us at 62 tiles -- one wave is exactly 60 CTAs, and
below that the idle MPs are the whole problem.

So K is split across CTAs when the tile count cannot cover the part: fp32 partials, reduced with a fixed
sum order and no atomics
inside the same ffi call, so a rerun is bit-identical. The split is sized to fill
the machine (num_mps // tiles, where num_mps is the count the launcher resolved -- caller override,
set_num_mps() limit, or the device's own count -- not a module constant) under one floor -- every CTA must keep more than one K block, because the
~4.5 us reduction then costs more than the streaming it removes. On a 60 MP part that gives down_proj
(N=1536, K=8960, 35 blocks) 2, o_proj (N=1536, K=1536, 24 tiles) 2, and qkv (32 tiles) / gate_up
(140 tiles) 1.

An earlier revision of this branch vetoed any split that did not remove >= 6 K blocks from every CTA, which
at K=1536 can never happen -- there are only six blocks -- so o_proj ran 24 CTAs while 36 of the 60 MPs
held no CTA at all. Allowed to split, it measures 14.4 -> 12.2 us at M=8 and 14.4 -> 12.0 us at M=16
(cold cache, bench_kineto, two timers agreeing). The floor is calibrated, not assumed: at M=32 the same
shape is 48 tiles, the fill rule declines, and a forced split measures 13.2 -> 15.7 us; [M=16, K=512, N=128] is 2 tiles with 2 blocks and measures 10.5 -> 12.3 us.

That kernel win is deliberately not sold as an end-to-end win: at cc=8 the serving A/B reads 853.95 against
855.19 tok/s, i.e. inside noise, which is the size 2 us of a ~8.2 ms step over 28 layers should be. What the
A/B establishes is that the change does not regress; what justifies it is the kernel measurement plus the
36-empty-MP argument.

The whole-K path is a separate compile-time instantiation, and that detail is the whole point: a
runtime-branched k_split == 1 version cost 2.6 % end to end, which the serving A/B caught. Appending 24
unused bytes to this kernel's Params is enough to reproduce it, so the split payload now rides in a base
that is empty for the whole-K instantiation, whose argument block and codegen are byte-identical to the
kernel before this change (measured 0.00 / -0.01 / +0.17 / +0.42 % on the shapes that matter).

Serving, Qwen2 W4A16, concurrency 8, decode under MUSAGraph capture -- 12 steady-state samples per arm,
interleaved, cold first pass excluded:

arm tok/s TPOT ms
this PR 1007.12 +-5.88 7.226
before split-K (55e229dcd) 957.20 +-7.20 7.642

+5.21 % tok/s / -5.44 % TPOT (Welch t=18.6; worst split pass 996.54 still beats the best baseline pass
963.05, zero overlap; noise band 0.58 % and 0.75 %). At concurrency 1 nothing moves (+0.08 %, t=0.3),
because M=1 takes the GEMV path and never reaches the SQMMA dense kernel. With 1.45x on down_proj, +5.21 %
implies that layer is ~17 % of the decode step; the two measurements reconcile.

Two cautions for anyone reproducing this. The eager driver is host-dispatch bound on this shape (56 us
of CPU per call against 48 us of GPU), so an eager microbenchmark cannot see the split -- it reports a
regression while the device gets 1.45x faster. And DENSE_W4A16_SPLITK_OVERRIDE reads os.environ at
import; a non-integer raises there instead of being ignored.

Correctness for the two instantiations: 0 failures over M x K x N x k_split (180 combinations), worst
rmsrel 3.55e-04, bit-identical across repeats, both fp16 and bf16. test_w4a16_marlin.py 143 passed,
test_gemm.py 10391 passed, test_dense_w4a16_prefill.py 165, test_deep_gemm.py 86.

Which card tier these numbers come from, and why it matters here

S5000 ships in 24, 28 and 30-core tiers with different multi_processor_count, and every grid in this PR is a
function of that count, so the tier is part of the measurement rather than a footnote.

Everything above was measured on the 24-core tier (multi_processor_count == 48). That is also the tier
where a decode grid happens to land on a wave boundary: at cc=8 the tile choice produces 48 CTAs against 48
MPs, exactly one wave. On a 30-core part (60 MPs) the same launch produces 64 CTAs, so 4 CTAs spill into a
second wave and the roofline the tile was chosen against no longer holds. The direction of both changes is
structural -- one reroutes a launch off a kernel built for long rows, the other makes a split pay for itself --
but the percentages should be re-measured on a 30-core part before they are quoted for one. That measurement
is not in this PR.

Wide-N small-M routing: the other half of the decode gain

A decode-step profile of the served model (Qwen2 W4A16, cc=8, 254 steps) put the W4A16 kernels at 57% of
device time
, and the single largest item in it was not a tuning knob but a mis-route: gate_up at M=8 was
being served by the prefill kernel
at 43.98 us a call, 28 calls a step, 1.23 ms/step. It lands in the M < 32
band, and that band went to prefill because the shape is far wider than the narrow rectangle -- but the
prefill tile has no tile_m/tile_k freedom (the spec generator hard-guards 128x?x128), so a short-row, very
wide launch ran on a tile chosen for long rows.

On the SQMMA GEMM family's 16x128x256 tile the same call costs 31.17 us. The two decisions have to move
together, which is why this is a family change and not a tile change: the tile selector returns the wide tile
exactly inside the rectangle where dense_w4a16_use_prefill now returns False. The rectangle stops at M=16
because at M=32 the wide tile gives 280 CTAs over 6 waves and measures 21.0% worse; M=17..32 keeps
prefill, and N <= 4096 keeps the narrow tile and its own routing. A structural test pins both decisions to the
same rectangle, so they cannot drift apart.

The tile and the split are one decision, and the A restage was worth a squad

Decode cost on this card is a fixed per-CTA prologue plus a per-K-block term, measured at 7.92 us + 1.067 us/block
on down (M=8, N=1536), so 55% of that shape is fixed cost. The per-block term is what a tile buys or wastes, and
the tile and the K split therefore cannot be chosen independently: the tile sets the tile count, the split turns that
count into work items, and the product over 60 MPs is how many K blocks each CTA streams. dense_w4a16_gemm_plan
prices both candidate tiles against the K axis and picks the cheaper (tile, split) pair.

Per kernel, best split per tile, on the four served projections:

projection M=8 before -> after M=16 M=32
down K=8960 24.57 -> 17.29 us (-30%) 24.50 -> 17.17 40.52 -> 26.02 (-36%)
qkv K=1536 N=2048 14.21 -> 12.23 (-14%) 13.17 -> 11.71 19.49 -> 13.74 (-30%)
o 12.33 / 12.24 (tie) 12.00 / 12.39 13.07 -> 12.50
gate_up N=17920 43.08 -> 29.91 41.59 -> 29.10 prefill family

On the production path, same binary: 80.80 -> 71.94 us per layer at M=8 (-11.0%) and 79.32 -> 70.88 at M=16
(-10.6%); a 28-layer captured replay 3558.1 -> 3428.1 us (-3.7%) and 3502.9 -> 3413.3 (-2.6%).

The second half of the decode block is the A restage: SmemLayoutA is row-major for the TME while SQMMA reads the
swizzled SmemLayoutMmaA, so a caster squad restages every A tile through registers. One squad makes that 7-15% of
the block; two saturate it and four add nothing:

shape 1 squad 2 squads 4 squads
down m=8 k=8960 17.42 us 16.25 16.23
qkv m=8 k=1536 n=2048 12.37 12.12 12.03
o m=8 k=1536 n=1536 12.45 11.53 11.43
gate_up m=8 k=17920 26.01 22.02 21.98

The 28-layer replay reads 3078.1/3087.3 -> 2900.4/2911.4 us (-5.7%). Because two squads saturate it, the entire A
layout mismatch is bounded at about 15% of a decode block, which also bounds what matching the TME swizzle to the
SQMMA descriptor could return -- and that would additionally need the row sums moved to a separate kernel the way
the prefill family does. It is deliberately not part of this PR.

Tried and rejected: carrying one block of MMA across the K-block boundary so the tensor engine never drains. It is
numerically exact on 12 shapes and measures no gain (16.25 -> 16.03, 12.12 -> 12.20, 11.53 -> 11.72,
22.02 -> 22.19): holding a stage for one extra block costs exactly the prefetch depth the drain returned.

End to end, the last two commits, arms alternating

Same card, same checkpoint, same flags (--dtype float16 --max-model-len 4096 --gpu-memory-utilization 0.30 --max-num-seqs 64, compiled and graph-captured), with Using MateW4A16LinearKernel for AutoGPTQLinearMethod in every
arm. The arms are this PR at 131c3ae and at 90eed0f, swapped through PYTHONPATH with a restart per arm; the first
burst of each arm is dropped, and the arm order is swapped every round so a monotone clock drift cancels:

round order after 90eed0f before 131c3ae ratio
1 after first 1490.14 tok/s 1412.43 1.055
2 before first 1513.05 1421.67 1.064
3 after first 1480.70 1412.97 1.048
4 before first 1507.29 1406.76 1.072

+6.0% tok/s at concurrency 8 (median of the four paired rounds, range +4.8% to +7.2%), 96 requests per burst,
128 generated tokens each. Both order groups land in the same band, so the result is not a drift artifact.

At concurrency 64 the same comparison is neutral (5986.45 / 5918.06 / 5879.99 against 5982.13 / 5963.76 / 5910.95,
median -0.6%). That is expected rather than contradictory: at cc=64 a step is M~64, the W4A16 GEMMs stop being
latency-bound, and these two commits act on the latency-bound band.

Coherence on the after arm: def quicksort(arr): completes into a correct implementation and
Q: What is 17 times 3? A: answers 51. The checkpoint is synthetic round-to-nearest quantization rather than a
GPTQ-optimised one, so quality is bounded by that and not by the kernel; the numerics are unchanged -- 12 shapes
across the narrow, wide and long-K tiles are bit-exact against 131c3ae on the symmetric path and within one fp16 ulp
on the zero-point path, where the wider reduction tree reorders the row sum.

End to end, three arms, interleaved, two rounds

Same card tier (multi_processor_count == 48), same checkpoint, same flags, one server at a time, a warmup
request excluded, 32 requests per
pass at concurrency 8:

arm tok/s (three passes) mean TPOT ms
before split-K (55e229dcd, via the whole-K override) 911.75 / 915.76 / 921.47 916.3 7.70
this PR as previously described (split-K) 949.23 / 956.36 / 959.16 954.9 7.32
this PR now (split-K + wide-N routing) 995.41 / 1045.74 / 1004.68 1015.3 6.93

+10.8% tok/s and -9.9% TPOT against the pre-split-K tree; taking the worst passing arm against the best
baseline pass still leaves +8.0%. The two halves decompose as +4.2% (split-K) and +6.3% (wide-N), which
matches the per-call measurements rather than being inferred from them. At concurrency 1 all three arms sit
within +-1% (173.6 / 173.0 / 174.4 tok/s): M=1 takes the GEMV path and never reaches either dense kernel.

Kernel selection was verified in every arm -- Using MateW4A16LinearKernel for AutoGPTQLinearMethod present,
TritonW4A16LinearKernel absent -- so none of these numbers came from the fallback.

Two honesty notes on the method. The baseline arm reproduces 55e229dcd through DENSE_W4A16_SPLITK_OVERRIDE=1,
which was separately measured against the real pristine tree at +0.23% (t=1.0, indistinguishable), rather than
by keeping a third tree resident. And an earlier attempt at this same comparison reported ~+5% for the wide-N
half; the difference was a missing warmup request, which left the first pass paying JIT compilation. The
numbers above warm up first and take three passes.

int4 weights x fp16 activations, three shapes of the same problem:

* `dense_w4a16_gemv_kern.j2` -- a GEMV that reads each weight once, for the decode shapes where
  every weight byte is touched exactly once and there is no reuse to tile for.
* `dense_w4a16_gemm_kern.j2` -- a 128-row tile with one SQMMA atom per consumer squad, for the
  prefill shapes.
* `dense_w4a16_prefill_kern.j2` -- the large-M variant, AOT-compiled into two tiles with fp16/bf16
  outputs and symmetric/zero-point flavours.

Both this kernel and the shipped Triton W4A16 path unpack int4 to fp16 before the MMA, so they do the
same arithmetic and neither can win on precision; the difference is per byte moved.

`dense_w4a16_gemm.py` is the host side: it picks the variant from M, stages a stage's scales after its
SQMMAs are issued rather than before the previous stage's, bounds the pipeline depth by its real
limits, and gives a one-atom-wide tile a single consumer squad.
`mate.gemm.dense_w4a16_gemm` is the name a consumer calls; `mate/aot.py` registers the large-M
variants so an image build precompiles them instead of paying the compile on the first forward.
Upstream vLLM's Marlin Python surface, with MATE's kernel behind it and MATE's repack inside it. The
consumer calls two names and nothing else: `prepare_marlin_w4a16` at load time and
`apply_gptq_marlin_linear` on every forward.

A consumer that starts from the checkpoint rather than from upstream's already-repacked tensors uses
`prepare_w4a16_from_gptq_frame`, which takes the loader's GPTQ words, scales and unpacked zero points
directly and skips the repack round trip. That is the serving path in vllm-musa.

Three things worth naming, because getting them wrong is silent rather than loud:

* `resolve_act_order` sorts the k rows and keeps the permutation in the frame; the apply path gathers
  the activation with the same permutation, so `x[:, perm] @ W[perm] == x @ W` exactly. Act order
  cancels, which is why it can be served instead of refused. A `g_idx` whose sorted groups are not one
  contiguous run of `group_size` rows is refused with that invariant spelled out.
* `marlin_zero_points_to_musa` takes a packed word in one of two conventions and has to know which:
  both have the same shape and the same multiset of values, so the shape guard accepts either, and the
  wrong inverse mis-orders zero points against `scale_b`/`weight_b` -- measured 1.034323 relative error
  against an fp32 reference with every guard silent. The caller now names the frame it holds.
* `guards.py` states the frame invariants (packed zero-point shape, group count) so a wrong frame
  fails as a reasoned error instead of as plausible numbers.
These are differential: they compare against a reference computed from the same checkpoint, so a wrong
convention shows up as an error rather than as a second opinion.

Two of them exist because the mistake was made:

* the zero-point frame is pinned in both directions -- `..._zero_point_frame_matches_loader_convention`
  for the serving path and `..._matches_scale_frame` for the tensor `marlin_zero_points` produced.
  Reading one as the other is a 1.034323 relative error with every guard silent.
* the act-order test dequantises with `SCALE[g_idx[k]]`, not `SCALE[k // group]`. The canonical-scale
  reference turned a correct kernel into a 19.5 % "failure".
@yeahdongcn

Copy link
Copy Markdown
Contributor Author

Measured ceiling: why this kernel lands at ~0.45x of fp16 dense

Recorded here so the question "did you try X?" has an answer with a number attached. All arms below ran on
one leased MTT S5000 (mp_31), one variable each, criteria fixed before the run, MATE installed from this
branch. Baseline decode [M=8, K=8960, N=1536] on tile (16,64,256,3):

63.99-64.67 us, 0.44-0.47x of the fp16 sentinel, rmsrel 3.281e-04.

lever measured outcome
pipeline depth 3 -> 4/5/6/7 flat within 2-5 %, depth 6 is a 107 us cliff refuted
tile_n 128 (24 -> 12 CTA) +3.3 % refuted
tile_m 32 +48 % refuted
tile_k 128 or 64, any depth will not build: static_assert(BlockK == 256) + ScaleBlocksPerTile == 2 excluded by the atom contract
prefill tile_n 64 + one more stage -2.2 %, inside sentinel drift refuted
prefill tile_m 256 or 64 rejected: dense_w4a16_gemm.py:303 pins tile_m == 128 excluded by guard
remove the A caster hop (two hops -> one) +7.8 % slower, numerics identical refuted
B-stream cache hint PERSIST / NORMAL +1.8 % / -0.4 %, control spread is 2.2 % refuted
second consumer squad per CTA ~1 % slower; a 16x64 tile has exactly one squad and no legal way to add one refuted
zero-point row sum on the symmetric build already if constexpr-excluded; forcing it costs 8.5 % nothing to reclaim

The hop-removal result is the informative one: landing TME output directly in the MMA-swizzled layout makes
each 512 B row arrive as two 256 B Swizzle<4,4,4> half-rows, which costs more on the weight stream than the
removed smem copy saved. The copy is not on the critical path.

The accounting

For that shape: 24 output tiles on a 60-MP part (40 % occupancy), 12 warps/CTA, 35 K-steps,
295,680 B of packed B plus scales per CTA.

quantity value
per-CTA stream rate 4.62 GB/s packed (17.9 GB/s fp16-equivalent)
device aggregate 110.9 GB/s = 7.6 % of the 1,468 GB/s measured read ceiling
fp16 matmul reference 938 GB/s = 39.1 GB/s per CTA

Since int4 is a quarter of fp16's bytes, speedup = 4 x (0.076 / 0.64) = 0.47x -- which is what the
microbenchmark measures. The kernel is not slow because a parameter is wrong; it is slow because 4.6 GB/s
per CTA is what this tile puts on the wire where fp16 puts 39.1.

What that means for the PR

No lever above met its criterion, so this PR contains no performance change beyond what it shipped with;
the value of recording them is that the ceiling is now a measurement rather than an opinion. Getting to a
3x-class result needs a different kernel shape -- a wider N per CTA than the 16x64 atom allows, more bytes in
flight per CTA than the 25 KB/stage K256 budget allows, and no dedicated caster warp -- not another tile
constant. That is a new kernel, and it is out of scope here.

@yeahdongcn

Copy link
Copy Markdown
Contributor Author

Correction to the ceiling table above: those us numbers were host-bound, not GPU time

Follow-up measurement, so the record is not left wrong. On this shape the eager wrapper costs 56.1 us of
CPU per call
(38.7 us of it inside the custom-op layer alone, plus 5.5 us for torch.empty of the
output), against 49.6 us of GPU time. The microbenchmark's us column tracked the CPU cost, not the
kernel: measured 56.54 us against 56.08 us of measured CPU issue time. So the "63.99-65.66 us" baseline
above is a host-dispatch number.

Device time, measured by bursting the raw ffi entry (CPU 6.6 us, well below the kernel) and cross-checked
against a K-sweep fit:

quantity measured
kernel device time, [8,8960,1536] tile (16,64,256,3) 49.57 us
fp16 sentinel, same run 28.79 us
ratio vs fp16 0.58x, not 0.44x
fixed cost per CTA (K-sweep fit, t = 11.3 + 1.097 x k_blocks) 11.3 us
marginal stream rate per CTA 7.5 GB/s

The wave structure in that table does survive the correction, because it reproduces at device level too:
device time is flat at 50.26 / 50.08 / 50.09 / 49.91 us for 8 / 24 / 48 / 60 tiles and 89.46 us at 62
tiles. Only the column that was host-inflated is retracted; "one CTA's stream sets the wall time, one wave
is 60 CTAs" stands.

Two consequences. First, the 0.47x I reported is really 0.58x -- the kernel is closer to fp16 than the
host-bound harness suggested, and the earlier "4.62 GB/s per CTA" was derived from the inflated number
(the device-level figure is 5.8 GB/s average over the whole K loop, 7.5 GB/s marginal once the 11.3 us
fixed cost is removed). Second, the 11.3 us fixed per-CTA cost is now the single largest identified
inefficiency
: it is 23 % of the baseline kernel time and 33 % of the split-K time, and unlike the tile
and depth levers it has an obvious candidate cause (prologue/TME warmup) that has not yet been attacked.

One CTA streams a whole column block, so its wall time is set by the bytes it reads and not by how many
CTAs are resident: measured on S5000 (60 MPs) at [M=8, K=8960], device time was flat at ~50 us from 8
tiles (N=512) to 60 tiles (N=3840) and only one wave of 60 tiles fills the machine. Below that the idle
MPs are the whole problem, so split K and divide one tile's stream time by the split count.

The whole-K path is a separate compile-time instantiation, so its argument block and codegen are
byte-identical to the kernel before this change (measured 0.00-0.42 % on the shapes that matter); the
split payload lives in a base that is empty for it. The split writes fp32 partials and reduces them with a
fixed sum order and no atomics, deterministically, inside the same ffi call.

Serving, Qwen2 W4A16 at concurrency 8 under MUSAGraph capture: 1007.12 vs 957.20 tok/s and 7.226 vs
7.642 ms TPOT (+5.21 % / -5.44 %, Welch t=18.6, zero overlap over 12 samples per arm). Concurrency 1 does
not move, because M=1 takes the GEMV path and never reaches the SQMMA dense kernel. Only layers that both
underfill a wave and carry enough K split (down_proj, N=1536 K=8960 -> 2); short-K and single-wave shapes
resolve to the untouched whole-K instantiation.
The decode gate_up shape (M=8, K=1536, N=17920) was served by the prefill kernel, because that is where
the M < 32 band had always been sent and the shape is far wider than the narrow rectangle. It is the wrong
kernel for it: the prefill tile has no tile_m/tile_k freedom -- the spec generator hard-guards 128x?x128 --
so the shape ran at 43.98 us on a tile chosen for long rows, against 31.17 us on a 16x128x256 tile from the
SQMMA GEMM family. Across the 28 gate_up calls in a decode step that is -0.39 ms/step.

The two decisions have to move together, which is why this is a family change and not a tile change: the
tile selector returns the wide tile exactly inside the rectangle where dense_w4a16_use_prefill now returns
False, and M=17..32 keeps prefill because at M=32 the wide tile gives 280 CTAs over 6 waves and measures
21.0% worse. N <= 4096 keeps the narrow tile and its own routing.

Serving, Qwen2 W4A16 at concurrency 8 under MUSAGraph capture, both arms on the same GPU with the
activation and weights untouched: 7.3941 -> 7.0068 ms/step (-5.24%) and +5.90% decode tok/s, and the
greedy output of four prompts is byte-identical between the two trees, so the family swap is
numerically transparent.
@yeahdongcn
yeahdongcn marked this pull request as ready for review October 19, 2026 12:45
yeahdongcn and others added 8 commits October 21, 2026 12:30
The split-K guard demanded that a split remove at least six K blocks from
every CTA, so at K=1536 -- where there are only six blocks -- it vetoed the
split that the tile count had just asked for, and o_proj ran 24 CTAs on a
60-MP part with 36 MPs holding no CTA.

Occupancy is what the split buys, so make that the guard: split to cover the
machine (num_mps // tiles), then keep a floor of two K blocks per CTA for the
~4.5 us cross-split reduction to stay worth its cost. The floor is empirical,
calibrated on the measurements below rather than derived.

Cold-cache bench_kineto, two independent timers, MTT S5000 60-MP part:

  o    M=8   split 1: 14.4 us -> split 2: 12.2 us   (-14%)
  o    M=16  split 1: 13.6 us -> split 2: 12.0 us   (-12%)
  o    M=32  split 1: 13.2 us -> split 2: 15.7 us   (48 tiles already cover 80% of the MPs)
  down M=32  split 1: 44.4 us -> split 2: 46.9 us
  [16,512,128] 2 tiles, 2 blocks  split 1: 10.5 -> split 2: 12.3 us

qkv, gate_up and down keep their previous split at every M. End to end the
change is inside noise (853.95 vs 855.19 tok/s at cc=8), which is the expected
size; the kernel-level win and the empty-MP argument are the evidence.
The split policy filled "a wave" using the module constant 60 while the launcher
resolved its grid from resolve_num_mps(input_a.device, num_mps) -- an explicit
caller override, a set_num_mps() limit, or the device count. The prefill tile
chooser in this same file already reads that resolved value; only the split-K
path assumed a fixed machine, so a caller that narrowed num_mps was told to fill
someone else's part.

Take the resolved count as a keyword-only argument and pass launch_mps in. The
default keeps the S5000 figure for callers with no device in hand.

No dispatch changes on either shipping tier: 60 MPs is unchanged by
construction, and on 48 MPs the four production shapes resolve identically
(24 tiles -> 2 either way; 32, 48 and 140 tiles -> 1 either way). What changes
is a narrower machine or a narrowed override: 24 tiles on 24 MPs is 1, because
the unsplit grid already covers it.

Verified without a device by executing the pure function out of the module
source: 21 assertions, including every previously recorded decision
(K=1536 splits at M=8/16 and stays whole at M=32; the 2-block floor between
k_blocks 3 and 4; 24/48/60-MP tier cases; keyword-only enforcement).
The policy tests pin what dense_w4a16_splitk_for returns; none of them would
notice a call site that stopped passing the resolved count, which is the exact
regression the previous commit repaired. Read the launcher's own source and
assert the single split call passes num_mps=launch_mps, and that launch_mps
comes from resolve_num_mps rather than a literal.

Checked to have teeth: passes on this tree, fails when the keyword is dropped
and fails when launch_mps is set from the wave constant.
mcu put every W4A16 kernel in the latency-bound class -- 2.5 active warps, 4.6-9.6 %
issue, one copy of the weights streamed -- and its launch stats said the part could
hold about eight blocks per MP while we launch exactly one. That hypothesis was
testable from Python alone, because the grid is num_mps: raising it changes nothing
at any split (measured with and without the extra CTAs, all four projections), so
more CTAs per MP are simply not resident. What the same sweep did expose is that the
split curve is not monotonic, and the fill rule sits on a bad point of it for long K.

`down` at M=8 has 24 tiles and 35 K blocks. The fill rule picks 2: 48 CTAs, 12 of 60
MPs idle, 18 blocks each. Split 5 puts 120 work items on the part -- exactly two
waves -- at 7 blocks each, and measures 27.72 -> 24.03 us; M=16 27.20 -> 23.87;
M=32 (48 tiles) 43.06 -> 39.36. Splits 3 and 4 land on a fractional wave and are
correspondingly slow, so this is a cost model, not a larger constant:

    cost(s) = ceil(tiles*s/mps) * ceil(kb/s) + 0.3*(s-1)

with a floor of three K blocks per CTA so a CTA can still fill the 3-stage pipeline.
It is consulted only at kb >= 16 and can only raise what the fill rule chose, so
`qkv`, `o` and `gate_up` keep their splits at every M (re-measured in the same
process: all within +-0.4 %).

Against a brute-force sweep of 18 cells, this picks the measured optimum everywhere
except qkv at M=32, where split 2 is 1.8 % faster than the fill rule's 1 and the
model is out of scope by construction.

Host tests: 21 passed in the container (adds the long-K table; two assertions that
encoded the old long-K answer now expect 5).
The search is pure over (tiles, k_blocks, num_mps) but ran on every launch: measured
4.37 us per call against the 0.61 us the previous rule needed. Under graph capture that
is paid once and invisible, but an eager caller pays it per GEMM, and it is large enough
to invert the sign of an eager A/B -- an eager 28-layer loop is CPU-bound at 62 us per
call against a 24-28 us kernel, so it showed the new policy as 5.8 % slower.

Wrapped in lru_cache it measures 1.37 us. The override seam stays uncached on the outer
function, so the benchmark hook still works.

Re-measured the sequence the way serving runs it, captured in a MUSAGraph and replayed
(device time, no CPU path): 28 layers x 4 projections, cold cache, median of 15:

  M=8  old 3221.2 us -> new 3120.0 us   -3.1 % (-101 us per step)
  M=16 old 3197.1 us -> new 3076.3 us   -3.8 % (-121 us per step)

which matches down's own -3.7 us per layer x 28 layers = 104 us.
The model prices K blocks stepped per CTA, which is the binding cost only on the
narrow tile. On the prefill tile the same shape is bandwidth-bound and the split
does not matter at all: `down` at M=128/256/512 measures 71.8 / 71.9 / 72.3 us at
splits 2, 5 and 1 respectively, within the +-0.3 % spread of the instrument.

That was not true before this commit -- at M=128 (24 tiles, 35 K blocks) the model
took the split from 2 to 5 for a shape where the prediction was a 22 % win and the
measurement is 0.0 %, and it cost a consistent +0.2 % in partials traffic. Gated on
the tile the policy was validated on, prefill keeps the fill rule's answer at every
M (5/2/1/1) and the decode win is unchanged: -13.2 / -12.4 / -8.2 % on `down` at
M=8/16/32, re-measured after the gate.

The gate is a tile property, not a shape size: M=32 already uses the narrow tile and
keeps its -8.2 %.
The tile was a rectangle in (M, N) and the split was asked for that tile afterwards. Measured on S5000
(60 MPs, cold cache) with the tile count held and K swept, a launch costs 7.9 us per CTA plus
1.07 us per K block, and the per-block price barely moves when the B tile doubles (8 KB and 16 KB blocks
cost the same), so the stream is latency-bound per CTA and the fixed part is what a short K cannot
amortise: at K=1536 it is ~55% of the run.

Two things follow. The intercept is per CTA, not per work item (8 blocks per CTA measures 18.15 us as
one item and 18.32 us as two), so there is nothing to hoist out of the item loop. And how many blocks a
CTA streams is decided by the tile, because the tile decides the tile count and the split turns that
count into work items. That is why (M, N) cannot choose the tile:

  down K=8960, N=1536   narrow 24 tiles -> 14 blocks/CTA   wide 12 tiles -> 7
                        M=8 24.57 -> 17.29 us, M=16 24.50 -> 17.17, M=32 40.52 -> 26.02
  qkv  K=1536, N=2048   M=8 14.09 -> 11.98, M=16 13.27 -> 11.57, M=32 20.01 -> 13.73
  o    K=1536, N=1536   unchanged (12.46 -> 12.51), M=32 13.15 -> 12.34
  gate_up N=17920       already wide, unchanged

`dense_w4a16_gemm_plan` now prices both decode tiles with the calibrated cost and returns the pair. The
split is still `dense_w4a16_splitk_for` for the tile it picked, so there is no second heuristic; the plan
adds the tile to the search. Four projections per layer: 80.80 -> 71.94 us at M=8 (-11.0%), 79.32 ->
70.88 at M=16 (-10.6%); 28-layer graph replay 3558 -> 3428 us (-3.7%) at M=8 and 3503 -> 3413 (-2.6%) at
M=16. Both tiles agree bit-exactly on the long-K shapes in the symmetric and the zero-point path.

The prefill family and the GEMV are chosen before this and are not priced here.
The symmetric build's caster does nothing but restage each A tile from the TME
layout into the MMA layout, and it did that on one warp squad. Doubling the
squad count splits each row of the restage across twice as many lanes and
measures 7-15% on the decode shapes:

  shape                     1 squad   2 squads   4 squads
  down  m=8  k=8960 n=1536    17.42      16.25      16.23
  qkv   m=8  k=1536 n=2048    12.37      12.12      12.03
  o     m=8  k=1536 n=1536    12.45      11.53      11.43
  gate_up m=8 k=17920 n=1536  26.01      22.02      21.98

Two squads saturate the restage, so the constant stops there and the extra
threads are not spent. A 28-layer captured replay drops from 3078/3087 us to
2900/2911 us (two runs each, -5.7%).

Numerics hold: 12 shapes covering the narrow tile, the wide tile and the long-K
splits are bit-exact against the one-squad build on the symmetric path, and
within a single fp16 ulp on the zero-point path, where the wider reduction tree
reorders the row sum (9.8e-4 on qkv at N=2048, 0 elsewhere).
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.

2 participants