Skip to content

Commit 6f74879

Browse files
committed
docs + colab
1 parent 157149c commit 6f74879

2 files changed

Lines changed: 360 additions & 137 deletions

File tree

docs/reduce-ops.md

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
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

Comments
 (0)