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.

ON THIS PAGE

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 else

The 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

loadmlayerenginekernel / configSGLang µsours µsfloor µsspeedup
decode 11qkv_gategemvz<16,1,1,2>14.189.637.871.47×
ogemvz<4,2,1,3>6.825.223.501.31×
poolergemvz<16,1,1,2>21.3113.7713.991.55×
unpoolergemv<16,2,1,false,3>7.074.201.751.68×
routergemv<16,4,1,true,3> S=410.115.431.751.86×
decode 1616qkv_gategemvz<4,1,2,1> S=414.1612.197.931.16×
ogemvz<8,2,2,0>6.885.963.561.15×
poolergemvz<8,2,2,3>21.8115.6914.061.39×
unpoolergemvz<4,2,2,3>18.2614.2914.061.28×
routergemv<8,2,2,true,3> S=1614.835.901.812.51×
decode 3232qkv_gatecuBLASnvjet split-K + reduce13.8213.858.001.00×
ocuBLASnvjet6.666.683.621.00×
poolergemvz<8,1,4,3>21.8919.3314.141.13×
unpoolergemvz<4,1,4,3>18.9417.7714.141.07×
routergemv<16,2,4,true,3> S=815.156.141.862.47×
verify 18qkv_gategemvz<16,1,1,2>14.219.947.901.43×
ogemvz<4,2,1,3>7.015.823.531.20×
poolergemvz<16,1,1,2>21.4614.1214.021.52×
unpoolergemv<16,8,1,false,3>7.144.261.761.68×
routergemv<4,2,1,true,2> S=1626.085.841.784.47×
verify 16128qkv_gatecuBLASnvjet split-K + reduce17.0917.148.421.00×
ocuBLASnvjet7.447.573.990.98×
poolerTriton64×128×128, split 225.4423.3314.641.09×
unpoolercuBLASnvjet19.9520.0114.641.00×
routerTritonhi+lo, split 16, atomic16.429.862.211.67×
verify 32256qkv_gatecuBLASnvjet19.3819.499.770.99×
ocuBLASnvjet9.419.514.480.99×
poolercuBLASnvjet30.3231.0817.370.98×
unpoolercuBLASnvjet24.1324.2417.371.00×
routerTritonhi+lo, split 8, atomic17.6010.482.681.68×
Official check run 7e998918 (the submission as shipped). gemvz configs are <NW, UG, NB, PIPE>, gemv configs <NW, U, NB, DUAL, PIPE>; S = K-splits across CTAs (1 if not shown). Floor = the judge’s speed of light for 16-bit weights.

A few things stand out:

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:

gemvz: read fewer bytes

Once you are at the bandwidth limit, the only way to go faster is to move fewer bytes.

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

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.

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:

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.

Try it · lab0_dispatch.py — Lab 0: which engine runs each shape? The tables are read straight out of submission.py.
cd ~/projects/okc/muse_bf16_proj/learn/labs
python3 lab0_dispatch.py
The 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:

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.