3 · The strategy: one dispatcher, four engines
submission.py is not one clever kernel. It is a dispatcher that sends each (layer, m) to whichever of four engines was fastest when we measured it. This chapter shows the whole map first, then explains why each engine wins where it does, and what we tried that did not work.
3.1 The dispatcher
kernel_fn (lines 774–807) asks four questions in order. The first "yes" decides the engine.
kernel_fn(data, state): # submission.py lines 774-807
(layer, m) in TABLE ? ──yes──▶ Triton split-K GEMM pooler m=128, router m=128/256
│ no
(layer, m) in ZTABLE ? ──yes──▶ gemvz (12-bit weights) 12 shapes with m ≤ 32
│ no
m ≤ 32 and CTABLE entry
is not None ? ──yes──▶ gemv (BF16 weights) router m ≤ 32, unpooler slice m ≤ 8,
│ no and any other m ≤ 32 (default config)
router ? ──yes──▶ cuBLAS × 2 (hi, lo) + add
│ no
cuBLAS: x @ W[:n].T everything elseThe three tables it consults (TABLE, ZTABLE, CTABLE) are plain Python dictionaries at lines 586–623. They were generated from measurements (section 3.5).
3.2 All 30 scored shapes
| load | m | layer | engine | kernel / config | SGLang µs | ours µs | floor µs | speedup |
|---|---|---|---|---|---|---|---|---|
| decode 1 | 1 | qkv_gate | gemvz | <16,1,1,2> | 14.18 | 9.63 | 7.87 | 1.47× |
| o | gemvz | <4,2,1,3> | 6.82 | 5.22 | 3.50 | 1.31× | ||
| pooler | gemvz | <16,1,1,2> | 21.31 | 13.77 | 13.99 | 1.55× | ||
| unpooler | gemv | <16,2,1,false,3> | 7.07 | 4.20 | 1.75 | 1.68× | ||
| router | gemv | <16,4,1,true,3> S=4 | 10.11 | 5.43 | 1.75 | 1.86× | ||
| decode 16 | 16 | qkv_gate | gemvz | <4,1,2,1> S=4 | 14.16 | 12.19 | 7.93 | 1.16× |
| o | gemvz | <8,2,2,0> | 6.88 | 5.96 | 3.56 | 1.15× | ||
| pooler | gemvz | <8,2,2,3> | 21.81 | 15.69 | 14.06 | 1.39× | ||
| unpooler | gemvz | <4,2,2,3> | 18.26 | 14.29 | 14.06 | 1.28× | ||
| router | gemv | <8,2,2,true,3> S=16 | 14.83 | 5.90 | 1.81 | 2.51× | ||
| decode 32 | 32 | qkv_gate | cuBLAS | nvjet split-K + reduce | 13.82 | 13.85 | 8.00 | 1.00× |
| o | cuBLAS | nvjet | 6.66 | 6.68 | 3.62 | 1.00× | ||
| pooler | gemvz | <8,1,4,3> | 21.89 | 19.33 | 14.14 | 1.13× | ||
| unpooler | gemvz | <4,1,4,3> | 18.94 | 17.77 | 14.14 | 1.07× | ||
| router | gemv | <16,2,4,true,3> S=8 | 15.15 | 6.14 | 1.86 | 2.47× | ||
| verify 1 | 8 | qkv_gate | gemvz | <16,1,1,2> | 14.21 | 9.94 | 7.90 | 1.43× |
| o | gemvz | <4,2,1,3> | 7.01 | 5.82 | 3.53 | 1.20× | ||
| pooler | gemvz | <16,1,1,2> | 21.46 | 14.12 | 14.02 | 1.52× | ||
| unpooler | gemv | <16,8,1,false,3> | 7.14 | 4.26 | 1.76 | 1.68× | ||
| router | gemv | <4,2,1,true,2> S=16 | 26.08 | 5.84 | 1.78 | 4.47× | ||
| verify 16 | 128 | qkv_gate | cuBLAS | nvjet split-K + reduce | 17.09 | 17.14 | 8.42 | 1.00× |
| o | cuBLAS | nvjet | 7.44 | 7.57 | 3.99 | 0.98× | ||
| pooler | Triton | 64×128×128, split 2 | 25.44 | 23.33 | 14.64 | 1.09× | ||
| unpooler | cuBLAS | nvjet | 19.95 | 20.01 | 14.64 | 1.00× | ||
| router | Triton | hi+lo, split 16, atomic | 16.42 | 9.86 | 2.21 | 1.67× | ||
| verify 32 | 256 | qkv_gate | cuBLAS | nvjet | 19.38 | 19.49 | 9.77 | 0.99× |
| o | cuBLAS | nvjet | 9.41 | 9.51 | 4.48 | 0.99× | ||
| pooler | cuBLAS | nvjet | 30.32 | 31.08 | 17.37 | 0.98× | ||
| unpooler | cuBLAS | nvjet | 24.13 | 24.24 | 17.37 | 1.00× | ||
| router | Triton | hi+lo, split 8, atomic | 17.60 | 10.48 | 2.68 | 1.68× |
A few things stand out:
- Blue (gemvz) covers most shapes with m ≤ 32, and that is where the big wins are (1.07–1.55×).
- Orange (gemv) is the router at m ≤ 32 (2.5–4.5× faster than SGLang) and the small unpooler slice at m ≤ 8 (1.7×).
- Purple (Triton) is the router at m = 128/256 (1.67×) and the pooler at m = 128 (1.09×).
- Gray (cuBLAS) is everything else. These shapes run at 0.98–1.00×, because they are SGLang's kernel.
3.3 Engine by engine: why each wins where it does
gemv: streaming each weight exactly once, at full speed
At small m, cuBLAS's GEMM kernels read weights at well under peak. SGLang's pooler at m = 1 takes 21.3 µs for 67 MB, which is 3.15 TB/s.
Our gemv_kernel gets close to the hardware's limit:
- Each warp streams its own slice of the repacked weights with 16-byte loads that are 100 % useful.
- Partial sums stay in registers until the very end.
- The uncompressed version reads the pooler (m = 1) in 17.1 µs (harness), about 3.9 TB/s.
- A hand-written kernel that only reads the same 67 MB, with the same fixed costs, needs about 16.1–16.4 µs. So the GEMV is within about 5 % of a pure memory copy.
gemvz: read fewer bytes
Once you are at the bandwidth limit, the only way to go faster is to move fewer bytes.
gemvz_kernelreads weights stored losslessly in 12 bits instead of 16, which is 25 % fewer bytes.- It rebuilds the BF16 values in registers with about 12 integer instructions per 8 weights. Those instructions hide under the memory time.
- The rare weights that don't fit the format (about 0.014 %) are fixed up exactly by an extra warp.
| shape | plain GEMV (harness µs) | compressed (harness µs) | change |
|---|---|---|---|
| pooler m=1 | 17.12 | 13.63 | −20 % |
| pooler m=8 | 17.34 | 13.98 | −19 % |
| qkv_gate m=1 | 11.17 | 9.54 | −15 % |
| o m=1 | 6.08 | 5.31 | −13 % |
| pooler m=16 | 18.18 | 15.20 | −16 % |
| unpooler m=16 | 18.11 | 14.85 | −18 % |
| unpooler m=32 | 20.80 | 17.79 | −14 % |
| unpooler slice m=1 | 3.97 | 4.06 | +2 %: too small to profit |
| qkv_gate m=32 | 15.10 | 16.70 | cuBLAS (14.02) wins |
Compression loses on the tiny shapes, where fixed costs dominate, and at m = 32 for qkv_gate and o. There, the work per byte (decode + 4 mma blocks) and the repeated reads of x start to matter more than the weight bytes.
Triton split-K: one fused pass for the router, bigger tiles for the pooler
- Router at m = 128/256.
- SGLang runs two separate cuBLAS GEMMs (hi, then lo) plus an add.
- We run one kernel that loads a hi tile and a lo tile and accumulates both into the same FP32 accumulator.
- The router has only 256 rows, so K is split 16 or 8 ways to give every SM work.
- The partial sums are combined with atomics (chapter 9). Result: 1.67×.
- Pooler at m = 128.
- cuBLAS picks small 64×64 tiles here, so every weight byte is fetched by 2 CTAs and every x byte by 64.
- Our Triton kernel uses 64 weight rows × 128 tokens per CTA with K split 2 ways: 23.3 vs 25.4 µs.
cuBLAS: where nothing else won
For the dense layers at m = 128/256, and for qkv_gate and o at m = 32, we tried hard to beat cuBLAS and never did (table in 3.6). So those shapes simply call x @ W.T, the same thing SGLang does.
Key idea. The best engine depends on m.
- At m ≤ 32, the winner is whoever streams the fewest bytes with the least fixed cost.
- At m = 128/256, the winner is whoever keeps tensor cores busy with the fewest redundant reads; that is usually cuBLAS.
- One kernel for everything would lose somewhere, so the dispatcher picks per shape.
3.4 Where the remaining time is
| load | speedup | what limits it |
|---|---|---|
| verify 1 (m=8) | 1.90× | Near the floor on the big layers. The router (5.8 µs vs 1.8 floor) and the unpooler slice (4.3 vs 1.8) are fixed-cost bound. |
| decode 1 | 1.56× | Same story as verify 1. |
| decode 16 | 1.41× | qkv_gate (12.2 µs vs a ~6 µs compressed floor) pays for repeated x reads and its split-K tail; o (6.0 vs 3.6) is fixed-cost bound. |
| decode 32 | 1.20× | qkv_gate and o are on cuBLAS at 1.0×; pooler and unpooler are 1.07–1.13×. |
| verify 16 (m=128) | 1.11× | Three of five shapes are on cuBLAS at 1.0×. |
| verify 32 (m=256) | 1.06× | Four of five are on cuBLAS, and these shapes are math-bound. |
The two verify loads at m = 128/256 are the biggest gap to the leaders: they sit at cuBLAS parity. Rounds 4, 6 and 7 of the project were attempts to beat cuBLAS there with our own Hopper (wgmma) kernels, and none succeeded.
3.5 How the configurations were chosen
Every kernel has knobs: warps per CTA, K-splits, loads in flight, pipeline mode, and tile sizes for Triton. They were chosen by exhaustive sweeps on a rented H200, timed like the judge: CUDA-graph replay, cold L2, paired against SGLang. build.py then wrote the winners into the tables, using two rules:
- A plain-GEMV entry goes into
CTABLEonly if it beats SGLang by at least 3 %. Otherwise the entry isNone, which means "use cuBLAS". - A compressed entry goes into
ZTABLEonly if it beats both the plain GEMV and cuBLAS by at least 2 %.
3.6 What we tried that did not make it
| idea | result |
|---|---|
| A Triton-only GEMV | Plateaus around 3 TB/s, about the same as cuBLAS. |
| CUTLASS sm90 GEMMs (12 configurations), deep_gemm, a cuBLASLt algorithm search | None beat plain cuBLAS by more than noise at m = 128/256. |
| A persistent stream-K GEMV with a TMA ring | About 1.3 µs of handshake overhead, so it lost to the simple GEMV. |
| L2 prefetch ahead of the loads | Never helped. |
| Thread-block clusters with distributed shared memory for the router reduction | About +0.5 %, below noise. |
wgk: our TMA + wgmma split-K GEMM for m = 128/256 |
Correct, but ties or loses against cuBLAS. |
zwg: the same, on 12-bit weights |
Correct, but slower than cuBLAS everywhere. The per-SM data intake and the split-K tail dominate. |
| Compressing the router's hi and lo | Correct but slower: the router is too small for the byte saving to pay for decoding. |
| 2 or 4 row blocks per CTA; an "x-stationary" GEMV | Slower. |
The details of each are in the project report. Chapter 10 shows how to run your own experiments on the real hardware.
cd ~/projects/okc/muse_bf16_proj/learn/labs
python3 lab0_dispatch.pyThe file is already on your Mac at ~/projects/okc/muse_bf16_proj/learn/labs/lab0_dispatch.py; you can also view or download it here.Before you run the lab, predict which engine handles the test shapes m = 5 unpooler, m = 9 router and m = 33 o. Then check your answers in the second table of the output. The lab also prints each GEMV's launch geometry: CTAs, threads, and how much of K each warp covers.
Check yourself
Why is ('o', 32) set to None in CTABLE instead of simply being absent?
An absent entry means "use the plain GEMV with a default configuration" (CTABLE.get(key, 0) returns 0, which is not None). An explicit None means "don't use the GEMV at all". kernel_fn then falls through to cuBLAS, because the sweep found that cuBLAS wins there.
Which shapes split K across several CTAs, and why those?
Here are the shapes with S > 1:
- gemvz qkv_gate at m = 16 (S = 4);
- every router shape: S = 4, 16, 16 and 8 at m ≤ 32, and Triton S = 16 and 8 at m = 128/256;
- the Triton pooler at m = 128 (S = 2).
Each has too few row blocks to fill 132 SMs on its own (the router has only 16 blocks of 16 rows), or found a better balance with the split.
Why does compression help pooler at m = 1 by 20 % but qkv_gate at m = 16 by only 6 %?
At m = 1 the kernel does almost nothing but stream weights, so 25 % fewer bytes shows up almost fully.
At m = 16 each weight fragment feeds two mma blocks and the x reads double. On top of that, the best configuration for qkv_gate at m = 16 splits K across 4 CTAs, whose combining step adds a fixed tail. So the weight bytes are a smaller share of the time.