9 · The router and the Triton split-K kernels

This chapter covers the last code region, lines 475–583. It holds two Triton GEMM kernels and the split-K "epilogue" they share. They run the router at m = 128 and 256 and the pooler at m = 128. The chapter starts with the reason the router needs special treatment at all: its weights are FP32.

ON THIS PAGE

9.1 Why the router is different

The router scores 256 experts, and the top scores pick which experts process each token. A tiny error can flip a choice, so:

Tensor cores multiply BF16, not FP32. The rules allow the standard workaround, which prepare() applies (lines 713–717):

hi = bf16(r)             # the nearest BF16 value: about 8 significant bits
lo = bf16(r - hi)        # the nearest BF16 value to what hi missed: about 8 more bits
x @ r.T  ≈  x @ hi.T + x @ lo.T        (both products accumulated in FP32)

Lab 4 measures it:

output error vs the FP64 reference (router tolerance 1e-4):
  FP32 weights : 1.67e-06  pass
  BF16 hi only : 1.58e-03  FAIL
  BF16 hi + lo : 2.57e-06  pass

SGLang uses FP32 math (F.linear) up to 8 tokens and two separate BF16 GEMMs plus an add above that. We always use hi + lo, fused into one kernel:

One pass reads both matrices, accumulates into one set of FP32 sums, and writes once. That is why the router is our biggest per-shape win: 1.7–4.5×.

9.2 Triton in five minutes

Triton is a Python-embedded language for GPU kernels. You write the code for one program instance, which is one CTA working on one tile, using whole blocks of values instead of individual threads. Triton decides how the block is spread over the threads.

Triton meaning
@triton.jit compile this function for the GPU on first use, once per distinct set of tl.constexpr values
tl.program_id(axis) which tile this program works on, like blockIdx
tl.arange(0, BN) the vector 0 … BN−1, used to build index blocks
tl.load / tl.store read or write a whole block of addresses (with an optional mask)
desc.load([r, c]) copy a block with Hopper's TMA engine through a TensorDescriptor: one instruction moves a whole tile into shared memory
tl.dot(a, b, acc) acc += a @ b on the tensor cores
num_warps, num_stages threads per program; how many loop iterations of loads run ahead of the math
tl.atomic_add, tl.atomic_xchg atomic read-modify-write in global memory

9.3 _proj_tma_kernel, line by line

submission.py, lines 559–565
559
560
561
562
563
564
565
@triton.jit
def _proj_tma_kernel(
    x_desc, w_desc, w2_desc, out_ptr, ws_ptr, sem_ptr,
    M, N, K,
    BN: tl.constexpr, BM: tl.constexpr, BK: tl.constexpr, SPLIT: tl.constexpr,
    DUAL: tl.constexpr, OUT_F32: tl.constexpr, NS: tl.constexpr, ATOM: tl.constexpr = False,
):
submission.py, lines 566–574
566
567
568
569
570
571
572
573
574
    pid_n = tl.program_id(0)
    pid_k = tl.program_id(1)
    pid_m = tl.program_id(2)
    grid_n = tl.num_programs(0)
    offs_n = pid_n * BN + tl.arange(0, BN)
    offs_m = pid_m * BM + tl.arange(0, BM)
    k_per = K // SPLIT
    k0 = pid_k * k_per
    acc = tl.zeros((BN, BM), dtype=tl.float32)
submission.py, lines 575–582
575
576
577
578
579
580
581
582
    for k in tl.range(k0, k0 + k_per, BK, num_stages=NS):
        xt = tl.trans(x_desc.load([pid_m * BM, k]))
        w = w_desc.load([pid_n * BN, k])
        acc = tl.dot(w, xt, acc)
        if DUAL:
            w2 = w2_desc.load([pid_n * BN, k])
            acc = tl.dot(w2, xt, acc)
    _epilogue(acc, out_ptr, ws_ptr, sem_ptr, M, N, pid_n, pid_k, pid_m, grid_n, offs_n, offs_m, BN, BM, SPLIT, OUT_F32, ATOM)
submission.py, lines 524–556 · _proj_kernel: the same GEMM without TMA (unused today)
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
@triton.jit
def _proj_kernel(
    x_ptr, w_ptr, w2_ptr, out_ptr, ws_ptr, sem_ptr,
    M, N, K,
    BN: tl.constexpr, BM: tl.constexpr, BK: tl.constexpr, SPLIT: tl.constexpr,
    DUAL: tl.constexpr, OUT_F32: tl.constexpr,
):
    pid_n = tl.program_id(0)
    pid_k = tl.program_id(1)
    pid_m = tl.program_id(2)
    grid_n = tl.num_programs(0)
    offs_n = pid_n * BN + tl.arange(0, BN)
    offs_m = pid_m * BM + tl.arange(0, BM)
    offs_k = tl.arange(0, BK)
    k_per = K // SPLIT
    k0 = pid_k * k_per
    w_ptrs = w_ptr + offs_n[:, None] * K + (k0 + offs_k)[None, :]
    x_ptrs = x_ptr + offs_m[None, :] * K + (k0 + offs_k)[:, None]
    m_mask = offs_m[None, :] < M
    acc = tl.zeros((BN, BM), dtype=tl.float32)
    if DUAL:
        w2_ptrs = w2_ptr + offs_n[:, None] * K + (k0 + offs_k)[None, :]
    for _ in range(0, k_per, BK):
        xt = tl.load(x_ptrs, mask=m_mask, other=0.0)
        w = tl.load(w_ptrs)
        acc = tl.dot(w, xt, acc)
        if DUAL:
            w2 = tl.load(w2_ptrs)
            acc = tl.dot(w2, xt, acc)
            w2_ptrs += BK
        w_ptrs += BK
        x_ptrs += BK
    _epilogue(acc, out_ptr, ws_ptr, sem_ptr, M, N, pid_n, pid_k, pid_m, grid_n, offs_n, offs_m, BN, BM, SPLIT, OUT_F32)

9.4 The split-K epilogue

The router has only 256 rows. With 64-row tiles that is 4 row tiles, far too few CTAs, so K is split 16 ways (m = 128) or 8 ways (m = 256). Several programs therefore produce partial sums for the same output tile, and someone has to add them up. _epilogue has two strategies.

submission.py, lines 475–495 · variant 1: atomics (router)
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
@triton.jit
def _epilogue(acc, out_ptr, ws_ptr, sem_ptr, M, N, pid_n, pid_k, pid_m, grid_n, offs_n, offs_m,
              BN: tl.constexpr, BM: tl.constexpr, SPLIT: tl.constexpr, OUT_F32: tl.constexpr, ATOM: tl.constexpr = False):
    m_mask = offs_m[None, :] < M
    if ATOM:
        # split-K partials are atomically added into a zeroed fp32 [M, N] buffer; the last CTA of the tile
        # copies it out and re-zeroes it (no serial S-way read)
        tile = pid_m * grid_n + pid_n
        a_ptrs = ws_ptr + offs_m[None, :] * N + offs_n[:, None]
        tl.atomic_add(a_ptrs, acc, mask=m_mask, sem="relaxed")
        tl.debug_barrier()
        cnt = tl.atomic_add(sem_ptr + tile, 1, sem="acq_rel")
        if cnt == SPLIT - 1:
            res = tl.load(a_ptrs, mask=m_mask, other=0.0, cache_modifier=".cg")
            o_ptrs = out_ptr + offs_m[None, :] * N + offs_n[:, None]
            if OUT_F32:
                tl.store(o_ptrs, res, mask=m_mask)
            else:
                tl.store(o_ptrs, res.to(tl.bfloat16), mask=m_mask)
            tl.store(a_ptrs, tl.zeros((BN, BM), dtype=tl.float32), mask=m_mask)
            tl.atomic_xchg(sem_ptr + tile, 0)
submission.py, lines 496–521 · variant 2: serial (pooler at m = 128)
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
    else:
        if SPLIT == 1:
            res = acc
        else:
            tile = pid_m * grid_n + pid_n
            base = ws_ptr + (tile * SPLIT + pid_k) * (BN * BM)
            tl.store(base + tl.arange(0, BN)[:, None] * BM + tl.arange(0, BM)[None, :], acc)
            tl.debug_barrier()
            cnt = tl.atomic_add(sem_ptr + tile, 1, sem="acq_rel")
            res = acc
        if SPLIT == 1:
            do_out = True
        else:
            do_out = cnt == SPLIT - 1
        if do_out:
            if SPLIT > 1:
                res = tl.zeros((BN, BM), dtype=tl.float32)
                for s in tl.static_range(SPLIT):
                    b2 = ws_ptr + (tile * SPLIT + s) * (BN * BM)
                    res += tl.load(b2 + tl.arange(0, BN)[:, None] * BM + tl.arange(0, BM)[None, :], cache_modifier=".cg")
                tl.atomic_xchg(sem_ptr + tile, 0)
            o_ptrs = out_ptr + offs_m[None, :] * N + offs_n[:, None]
            if OUT_F32:
                tl.store(o_ptrs, res, mask=m_mask)
            else:
                tl.store(o_ptrs, res.to(tl.bfloat16), mask=m_mask)

Why two variants?

Lab 5 simulates both protocols, including what goes wrong if the reset lines are removed.

9.5 The three Triton configurations

shape tile BN × BM × BK SPLIT grid CTAs K per CTA (steps) epilogue
pooler m=128 64 × 128 × 128 2 (64, 2, 1) 128 4096 (32) serial
router m=128 64 × 128 × 128 16 (4, 16, 1) 64 512 (4) atomic, hi + lo
router m=256 64 × 64 × 128 8 (4, 8, 4) 128 1024 (8) atomic, hi + lo

The router's floor is about 2.2–2.7 µs, and we take about 9.9–10.5 µs. A kernel this small is dominated by fixed costs: start-up, a pipeline that only runs 4–8 steps, and the split-K handshake. Shrinking those is one of the open problems (chapter 10).

Try it · lab4_router.py — Lab 4: why the router splits its FP32 weights into a BF16 "hi + lo" pair.
cd ~/projects/okc/muse_bf16_proj/learn/labs
python3 lab4_router.py
The file is already on your Mac at ~/projects/okc/muse_bf16_proj/learn/labs/lab4_router.py; you can also view or download it here.

Exercises.

  1. In lab 4, add the third term lo2 to the output and see how far the error drops.
  2. In lab 5, set REZERO = False and watch the atomic accumulator keep the previous call's sum.
  3. Chapter 10's experiment C turns ATOM off for the router at m = 128 on the real H200. Predict the time first: round 4 measured about 12.4 µs for the serial version.

Check yourself

Why must racc be zero before the very first call, and who keeps it that way afterwards?

The atomic variant adds partial tiles into whatever is already there. prepare() allocates racc with torch.zeros (line 730). After every call, the last program of each tile stores zeros back into the part it used (line 494).

What is the router's arithmetic intensity at m = 128, and what does it tell you?

About 2 × 128 × 256 × 8192 FLOPs ÷ (8.4 MB + x + output) ≈ 50 FLOP/byte, far below the ridge of about 206. So the router is memory-bound, with a floor of about 2.2 µs. Our 9.9 µs is therefore mostly fixed costs, not math.

Why doesn't Triton handle the router at m ≤ 32 too?

We tried in round 4. Even with the atomic epilogue, Triton lost to the CUDA GEMV, which takes about 5.4–6.1 µs there. At these sizes fixed costs dominate, and the GEMV carries less machinery: no TMA descriptors and no pipeline set-up.