|
| 1 | +# reduce_sum and reduce_max — Design & Implementation |
| 2 | + |
| 3 | +## What they do |
| 4 | + |
| 5 | +`tt.reduce_sum(x)` and `tt.reduce_max(x)` are cross-thread reduction operations. Every thread in a block contributes one scalar value; every thread receives the fully reduced result (the sum or max of all values across all threads in the block). |
| 6 | + |
| 7 | +```python |
| 8 | +@tt.jit |
| 9 | +def kernel(src, dst, N): |
| 10 | + pid = tt.program_id(0) |
| 11 | + off = pid * 64 + tt.arange(0, 64) # 64 threads per block |
| 12 | + x = tt.load(src + off, mask=off < N) |
| 13 | + total = tt.reduce_sum(x) # all 64 threads get the same sum |
| 14 | + tt.store(dst + pid, total) |
| 15 | +``` |
| 16 | + |
| 17 | +## Architecture overview |
| 18 | + |
| 19 | +The implementation spans four layers: |
| 20 | + |
| 21 | +``` |
| 22 | +Python API (tt.reduce_sum) |
| 23 | + │ |
| 24 | + ▼ |
| 25 | +TinyTon MLIR dialect (tinyton.reduce_sum) |
| 26 | + │ |
| 27 | + ▼ |
| 28 | +GPU dialect lowering (gpu.shuffle + memref + gpu.barrier) |
| 29 | + │ |
| 30 | + ▼ |
| 31 | +NVVM/PTX (nvvm.shfl.sync + st.shared/ld.shared + bar.sync) |
| 32 | +``` |
| 33 | + |
| 34 | +### Layer 1: Python frontend |
| 35 | + |
| 36 | +**Files:** `python/tiny_ton/__init__.py`, `python/tiny_ton/jit.py` |
| 37 | + |
| 38 | +The `KernelVisitor` (AST visitor) recognizes `tt.reduce_sum(x)` and `tt.reduce_max(x)` as builtins and calls `builder.emit_reduce_sum(x)` / `builder.emit_reduce_max(x)` on the C++ `IRBuilder`. |
| 39 | + |
| 40 | +### Layer 2: TinyTon MLIR dialect |
| 41 | + |
| 42 | +**Files:** `include/tiny-ton/Dialect/TinyTon/TinyTonOps.td`, `include/tiny-ton/IR/Builder.h`, `lib/IR/Builder.cpp` |
| 43 | + |
| 44 | +Two ops defined in TableGen: |
| 45 | + |
| 46 | +```tablegen |
| 47 | +def TinyTon_ReduceSumOp : TinyTon_Op<"reduce_sum", |
| 48 | + [AllTypesMatch<["operand", "result"]>]> { |
| 49 | + let arguments = (ins AnyType:$operand); |
| 50 | + let results = (outs AnyType:$result); |
| 51 | +} |
| 52 | +``` |
| 53 | + |
| 54 | +`AllTypesMatch` tells MLIR the result type equals the operand type, avoiding the need for explicit type inference. |
| 55 | + |
| 56 | +### Layer 3: GPU dialect lowering (the interesting part) |
| 57 | + |
| 58 | +**File:** `lib/Conversion/TinyTonToGPU.cpp` |
| 59 | + |
| 60 | +This is a two-phase reduction: intra-warp shuffle, then cross-warp via shared memory. |
| 61 | + |
| 62 | +#### Why two phases? |
| 63 | + |
| 64 | +A GPU block with 64 threads has 2 warps (warp = 32 threads). The `gpu.shuffle xor` instruction only communicates within a single warp. To reduce across the full block, we need shared memory to exchange partial results between warps. |
| 65 | + |
| 66 | +#### Phase 1: Intra-warp butterfly reduction |
| 67 | + |
| 68 | +``` |
| 69 | +for offset in {1, 2, 4, 8, 16}: |
| 70 | + shuffled = gpu.shuffle xor val, offset, 32 |
| 71 | + val = val + shuffled (or max) |
| 72 | +``` |
| 73 | + |
| 74 | +This is a "butterfly" pattern. After 5 rounds, every thread in a warp holds that warp's partial sum. The XOR shuffle ensures all threads receive the result (not just lane 0). |
| 75 | + |
| 76 | +**Example** (4 threads for simplicity, values [1, 2, 3, 4]): |
| 77 | +``` |
| 78 | +Step 1: shuffle xor offset=1 |
| 79 | + Thread 0 gets Thread 1's value → adds: 1+2 = 3 |
| 80 | + Thread 1 gets Thread 0's value → adds: 2+1 = 3 |
| 81 | + Thread 2 gets Thread 3's value → adds: 3+4 = 7 |
| 82 | + Thread 3 gets Thread 2's value → adds: 4+3 = 7 |
| 83 | +
|
| 84 | +Step 2: shuffle xor offset=2 |
| 85 | + Thread 0 gets Thread 2's value → adds: 3+7 = 10 |
| 86 | + Thread 1 gets Thread 3's value → adds: 3+7 = 10 |
| 87 | + Thread 2 gets Thread 0's value → adds: 7+3 = 10 |
| 88 | + Thread 3 gets Thread 1's value → adds: 7+3 = 10 |
| 89 | +``` |
| 90 | + |
| 91 | +All threads end up with 10 (the full sum). On real hardware this runs for 5 rounds (offsets 1,2,4,8,16) to cover all 32 lanes. |
| 92 | + |
| 93 | +#### Phase 2: Cross-warp reduction via shared memory |
| 94 | + |
| 95 | +After Phase 1, each warp has its own partial sum. With BLOCK=64 (2 warps): |
| 96 | +- Warp 0 holds partial_0 (sum of threads 0-31) |
| 97 | +- Warp 1 holds partial_1 (sum of threads 32-63) |
| 98 | + |
| 99 | +``` |
| 100 | +warp_id = threadIdx.x / 32 |
| 101 | +lane_id = threadIdx.x % 32 |
| 102 | +
|
| 103 | +shmem[warp_id] = val // each warp writes its partial |
| 104 | +gpu.barrier // __syncthreads() |
| 105 | +
|
| 106 | +num_warps = blockDim.x / 32 |
| 107 | +safe_idx = (lane_id < num_warps) ? lane_id : 0 |
| 108 | +val = (lane_id < num_warps) ? shmem[safe_idx] : identity |
| 109 | +
|
| 110 | +// second butterfly over the per-warp partials |
| 111 | +for offset in {1, 2, 4, 8, 16}: |
| 112 | + shuffled = gpu.shuffle xor val, offset, 32 |
| 113 | + val = val + shuffled |
| 114 | +``` |
| 115 | + |
| 116 | +**Key insight:** after the barrier, *every* warp loads the same pattern from shared memory (partial_0 at index 0, partial_1 at index 1, identity elsewhere). So every warp runs the same second butterfly and arrives at the same correct final result. No broadcast step needed — all threads in all warps end up with the complete reduction. |
| 117 | + |
| 118 | +#### Shared memory allocation |
| 119 | + |
| 120 | +Shared memory is declared via `gpu.func` workgroup attributions: |
| 121 | + |
| 122 | +```cpp |
| 123 | +auto shmemTy = MemRefType::get( |
| 124 | + {32}, f32Ty, MemRefLayoutAttrInterface{}, addrSpace); |
| 125 | +shmemArg = gpuFunc.addWorkgroupAttribution(shmemTy, loc); |
| 126 | +``` |
| 127 | +
|
| 128 | +This allocates 32 × f32 = 128 bytes of shared memory (enough for up to 32 warps = 1024 threads). The `#gpu.address_space<workgroup>` attribute maps to LLVM address space 3, which the NVPTX backend emits as `__shared__` memory. |
| 129 | +
|
| 130 | +#### Address space mapping |
| 131 | +
|
| 132 | +The `LLVMTypeConverter` must know the GPU-to-LLVM address space mapping, otherwise shared memory pointers end up in the wrong address space (causing GPU crashes): |
| 133 | +
|
| 134 | +```cpp |
| 135 | +populateGpuMemorySpaceAttributeConversions( |
| 136 | + converter, [](gpu::AddressSpace space) -> unsigned { |
| 137 | + switch (space) { |
| 138 | + case gpu::AddressSpace::Global: return 1; |
| 139 | + case gpu::AddressSpace::Workgroup: return 3; // shared memory |
| 140 | + case gpu::AddressSpace::Private: return 5; |
| 141 | + } |
| 142 | + return 0; |
| 143 | + }); |
| 144 | +``` |
| 145 | + |
| 146 | +**File:** `lib/Compiler/Pipeline.cpp` |
| 147 | + |
| 148 | +#### Identity values |
| 149 | + |
| 150 | +| Operation | Float identity | Int identity | |
| 151 | +|-----------|---------------|-------------| |
| 152 | +| reduce_sum | 0.0 | 0 | |
| 153 | +| reduce_max | -infinity | INT_MIN | |
| 154 | + |
| 155 | +Out-of-bounds lanes (where `lane_id >= num_warps`) get the identity value so they don't affect the result. |
| 156 | + |
| 157 | +#### f16 handling |
| 158 | + |
| 159 | +f16 values are promoted to f32 before reduction (the shuffle operates on f32), then truncated back to f16 after the final result. Shared memory is always f32. |
| 160 | + |
| 161 | +### Layer 4: NVVM/PTX lowering |
| 162 | + |
| 163 | +**File:** `lib/Compiler/Pipeline.cpp` (`CombinedGPULoweringPass`) |
| 164 | + |
| 165 | +The pass pipeline converts GPU dialect ops to NVVM: |
| 166 | +- `gpu.shuffle xor` → `nvvm.shfl.sync xor` → `shfl.sync.bfly.b32` in PTX |
| 167 | +- `gpu.barrier` → `nvvm.barrier0` → `bar.sync 0` in PTX |
| 168 | +- `memref.store/load` on workgroup memory → `st.shared.f32` / `ld.shared.f32` in PTX |
| 169 | +- `gpu.func` workgroup attributions → `@shmem = addrspace(3) global [32 x float]` |
| 170 | + |
| 171 | +## Simulator support |
| 172 | + |
| 173 | +**File:** `lib/Runtime/Simulator.cpp` |
| 174 | + |
| 175 | +The CPU simulator uses a two-phase execution model: |
| 176 | +1. Run all threads until they hit a reduce op (each thread records its value) |
| 177 | +2. Compute the reduction across all threads |
| 178 | +3. Resume all threads with the reduced value |
| 179 | + |
| 180 | +This simulates the barrier-synchronized collective without actual parallelism. |
| 181 | + |
| 182 | +**File:** `lib/Compiler/CodeGen.cpp` |
| 183 | + |
| 184 | +Reduce ops use extended opcode `0x0F` with type flags to distinguish sum vs max and float vs int. |
| 185 | + |
| 186 | +## Files changed (complete list) |
| 187 | + |
| 188 | +| File | What | |
| 189 | +|------|------| |
| 190 | +| `include/tiny-ton/Dialect/TinyTon/TinyTonOps.td` | Op definitions | |
| 191 | +| `include/tiny-ton/IR/Builder.h` | Builder API declarations | |
| 192 | +| `lib/IR/Builder.cpp` | Builder API implementations | |
| 193 | +| `bindings/python_bindings.cpp` | Python bindings | |
| 194 | +| `python/tiny_ton/__init__.py` | Python stubs | |
| 195 | +| `python/tiny_ton/jit.py` | AST visitor (`_eval_call`, `visit_AnnAssign`) | |
| 196 | +| `lib/Conversion/TinyTonToGPU.cpp` | GPU lowering (shuffle + shmem + barrier) | |
| 197 | +| `lib/Compiler/Pipeline.cpp` | NVVM pass pipeline (memref-to-LLVM, address space mapping) | |
| 198 | +| `lib/Compiler/CodeGen.cpp` | Simulator bytecode generation | |
| 199 | +| `lib/Runtime/Simulator.cpp` | Two-phase simulator execution | |
| 200 | +| `lib/Conversion/CMakeLists.txt` | Link `MLIRMemRefDialect` | |
| 201 | +| `lib/Compiler/CMakeLists.txt` | Link `MLIRMemRefToLLVM` | |
| 202 | +| `test/test_reduce_e2e.cpp` | Simulator E2E tests | |
| 203 | +| `test/test_gpu_lowering.cpp` | GPU MLIR lowering tests | |
| 204 | + |
| 205 | +## Gotchas we hit |
| 206 | + |
| 207 | +1. **`populateGpuAllReducePatterns` silently fails** on some MLIR 18 builds (Colab apt packages). We bypassed it entirely by emitting the shuffle tree ourselves. |
| 208 | + |
| 209 | +2. **Address space mapping must be registered** on the `LLVMTypeConverter` via `populateGpuMemorySpaceAttributeConversions`. Without it, shared memory pointers get LLVM addrspace 0 instead of 3, causing GPU crashes with no error message — just kernel death. |
| 210 | + |
| 211 | +3. **`ast.AnnAssign` vs `ast.Assign`** — Python type annotations like `x: int = 5` produce `AnnAssign` nodes, not `Assign`. The `KernelVisitor` needed a `visit_AnnAssign` handler. |
| 212 | + |
| 213 | +4. **`AllTypesMatch` trait** is required on the TableGen op definition so MLIR can infer the result type from the operand type without a custom builder. |
0 commit comments