|
| 1 | +# 18 — K2 Shmem GEMM: 2D Grid + Runtime scf.for + Shared Memory Tiling |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +K2 is the third GEMM kernel in tiny-ton's progression toward cuBLAS-level performance. |
| 6 | +It introduces three new compiler features that are independently useful: |
| 7 | + |
| 8 | +| Feature | What it unlocks | |
| 9 | +|---|---| |
| 10 | +| **2D grid launch** — `tt.program_id(1)` | Natural 2D indexing for matrix tiles | |
| 11 | +| **Runtime `scf.for` loop** — `ForRangeOp` | K can be a runtime argument; no IR explosion | |
| 12 | +| **Shared memory tiling** — `tt.shared_store / tt.shared_load` | Block-local caching of A and B tiles | |
| 13 | + |
| 14 | +## Algorithm |
| 15 | + |
| 16 | +``` |
| 17 | +Grid : (M // TM, N // TN) one block per output tile |
| 18 | +Block : TK threads |
| 19 | +
|
| 20 | +For each block (bm, bn): |
| 21 | + acc = 0 |
| 22 | + for k0 in range(0, K, TK): ← runtime loop, K is not constexpr |
| 23 | + shmem_A[0..TK-1] = A[bm, k0..k0+TK-1] ← global → shared |
| 24 | + shmem_B[TK..2TK-1] = B[k0..k0+TK-1, bn] ← global → shared |
| 25 | + sync() |
| 26 | + acc += reduce_sum(shmem_A * shmem_B) |
| 27 | + sync() |
| 28 | + C[bm, bn] = acc |
| 29 | +``` |
| 30 | + |
| 31 | +With TM = TN = 1 (current), each block computes **one scalar** of C. |
| 32 | +Increasing TM / TN (K3+) harvests the shared memory reuse benefit. |
| 33 | + |
| 34 | +## Architecture: Python DSL → PTX |
| 35 | + |
| 36 | +``` |
| 37 | +@tt.jit kernel |
| 38 | + │ |
| 39 | + ▼ jit.py: visit_For detects runtime bounds → ForRangeOp |
| 40 | +TinyTon IR |
| 41 | + tinyton.for_range %start, %K, %step iter_args(%acc = %init) { |
| 42 | + ^bb(%iv: i32, %acc_: f32): ... tinyton.yield %acc2 |
| 43 | + } |
| 44 | + │ |
| 45 | + ▼ TinyTonToGPU.cpp: lowerOneOp (recursive lambda) |
| 46 | +GPU dialect + SCF |
| 47 | + scf.for %iv = %start to %K step %step iter_args(%acc = %init) -> f32 { |
| 48 | + ... scf.yield %acc2 |
| 49 | + } |
| 50 | + │ |
| 51 | + ▼ Pipeline.cpp: createConvertSCFToCFPass() |
| 52 | +CF dialect |
| 53 | + cf.cond_br %done, ^exit, ^body |
| 54 | + ^body: ... cf.br ^header |
| 55 | + │ |
| 56 | + ▼ CombinedGPULoweringPass → NVPTX backend |
| 57 | +PTX |
| 58 | + $loop_header: |
| 59 | + setp.ge.s32 %p0, %k0, %K |
| 60 | + @%p0 bra $loop_exit |
| 61 | + ... |
| 62 | + add.s32 %k0, %k0, 16 |
| 63 | + bra $loop_header |
| 64 | +``` |
| 65 | + |
| 66 | +## Compiler changes |
| 67 | + |
| 68 | +### Workstream A — 2D Grid |
| 69 | + |
| 70 | +| File | Change | |
| 71 | +|---|---| |
| 72 | +| `CUDARuntime.h/.cpp` | `launch(gridX, gridY, blockX, ...)` — `gridY` was hardcoded 1 | |
| 73 | +| `Simulator.h/.cpp` | `run(gridX, gridY, threadsPerBlock)` — PID axis=1 → `blockId / gridX` | |
| 74 | +| `python_bindings.cpp` | Updated `launch` and `run` bindings | |
| 75 | +| `jit.py` | `_launch_cuda` / `_launch_simulator` read `grid[1]` | |
| 76 | + |
| 77 | +### Workstream B — Runtime scf.for |
| 78 | + |
| 79 | +| File | Change | |
| 80 | +|---|---| |
| 81 | +| `TinyTonOps.td` | `ForRangeOp` (body region + iter_args) + `YieldOp` | |
| 82 | +| `Builder.h/.cpp` | `beginForRange` / `endForRange` | |
| 83 | +| `TinyTonToGPU.cpp` | Recursive `lowerOneOp` lambda converts `ForRangeOp` → `scf.for` | |
| 84 | +| `CMakeLists.txt` (Conversion) | `MLIRSCFDialect` | |
| 85 | +| `Pipeline.cpp` | `createConvertSCFToCFPass()` before `CombinedGPULoweringPass` | |
| 86 | +| `CMakeLists.txt` (Compiler) | `MLIRSCFToControlFlow` | |
| 87 | +| `python_bindings.cpp` | `begin_for_range` / `end_for_range` | |
| 88 | +| `jit.py` | `visit_For` falls through to `ForRangeOp` when bounds are runtime | |
| 89 | + |
| 90 | +## Kernel source |
| 91 | + |
| 92 | +```python |
| 93 | +@tt.jit |
| 94 | +def shmem_gemm(A_ptr, B_ptr, C_ptr, M, N, K, |
| 95 | + TM: tt.constexpr, TN: tt.constexpr, TK: tt.constexpr): |
| 96 | + bm = tt.program_id(0) |
| 97 | + bn = tt.program_id(1) |
| 98 | + tid = tt.arange(0, TK) |
| 99 | + acc = 0.0 |
| 100 | + for k0 in range(0, K, TK): |
| 101 | + a_val = tt.load(A_ptr + bm * TM * K + k0 + tid) |
| 102 | + tt.shared_store(tid, a_val, buffer_size=2*TK) |
| 103 | + b_val = tt.load(B_ptr + (k0 + tid) * N + bn * TN) |
| 104 | + tt.shared_store(TK + tid, b_val, buffer_size=2*TK) |
| 105 | + tt.sync() |
| 106 | + a_sh = tt.shared_load(tid, buffer_size=2*TK) |
| 107 | + b_sh = tt.shared_load(TK + tid, buffer_size=2*TK) |
| 108 | + acc = acc + tt.reduce_sum(a_sh * b_sh) |
| 109 | + tt.sync() |
| 110 | + tt.store(C_ptr + bm * TM * N + bn * TN, acc) |
| 111 | +``` |
| 112 | + |
| 113 | +## Key result: compile time vs K1 |
| 114 | + |
| 115 | +| Kernel | K=128 compile | K=256 compile | |
| 116 | +|---|---|---| |
| 117 | +| K1 Row GEMM (constexpr unroll) | ~200 ms | **hangs (>60 s)** | |
| 118 | +| K2 Shmem GEMM (runtime scf.for) | ~200 ms | ~200 ms | |
| 119 | + |
| 120 | +K2's IR size is **constant** regardless of K — `scf.for` emits a single loop |
| 121 | +instead of N×(K/TK) unrolled copies of the body. |
| 122 | + |
| 123 | +## Next: K3 Swizzled GEMM |
| 124 | + |
| 125 | +XOR-swizzle the shared memory addresses to eliminate bank conflicts: |
| 126 | +```python |
| 127 | +swizzled_idx = idx ^ ((idx >> 3) & 0x7) |
| 128 | +tt.shared_store(swizzled_idx, val, buffer_size=2*TK) |
| 129 | +``` |
| 130 | +This requires an address-swizzle helper in the JIT and no new compiler passes. |
0 commit comments