Skip to content

Commit b33938b

Browse files
jhinpanzhiding512cursoragent
authored
[Kernel] Add opt-in autotuning for Softmax (#1022)
* [Kernel] Add opt-in autotuning for Softmax Softmax hard-coded BLOCK_THREADS=256 for every shape and dtype. A forced search on gfx950 selects 512 for wide bf16/f16 rows and smaller blocks for narrow ones, so 256 is a compatibility default rather than a universal optimum. Make Softmax the second direct-JIT autotune adopter after RMSNorm. The kernel factory takes BLOCK_THREADS as a static input, softmax_direct exposes it as a Constexpr, and softmax_autotuned serves the searched winner, then a matching artifact, then the 256 default. Ordinary calls never benchmark; FLYDSL_AUTOTUNE=1 is the only search path. Two narrow additions to the shared autotuner were required. validate_hook runs a numerical check once per candidate, outside the timed repetitions but under the same stream, compile hints and reset/restore policy, so a candidate that launches cleanly but computes the wrong answer cannot win. An all-rejected search now chains the last failure, which keeps a numerical rejection distinguishable from a compile failure. The candidate gate compares against the row's own scale. The previous absolute tolerance was sized for O(1) values while softmax elements are O(1/N), so at N=8192 the bf16 bound exceeded the signal by two orders of magnitude and an all-zero output would have passed. tests/kernels/test_softmax.py adopts the same criterion so the default is held to the standard it imposes on candidates. tuning_schema is a declared key axis, not kernel input: the scratch winner cache does not fingerprint kernel source, so bumping it is the only way to invalidate stale winners after a change that can move the result. * test(softmax): isolate autotune cache state * fix(autotune): reject candidates with unwritten outputs Wrap validation around an untimed candidate launch so output poisoning catches no-op or partial-store kernels without affecting benchmark rankings. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(softmax): address autotune correctness gaps Synchronize reused reduction scratch before the next block reduction, reject streams from a different device, and pass fully bound call arguments to candidate validation. * fix(ci): pin compatible nanobind for LLVM builds --------- Co-authored-by: zhimding <zhimding@amd.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent dd83733 commit b33938b

10 files changed

Lines changed: 2039 additions & 84 deletions

File tree

docs/autotune_guide.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,57 @@ Artifacts intentionally do not include a compiler or kernel-source fingerprint.
4646
Treat them as reviewed deployment inputs, and retune after a compiler, kernel,
4747
compile-hint, or search-space change that can affect the winner.
4848

49+
The scratch winner cache has the same blind spot: it fingerprints the device,
50+
toolchain, environment and compile hints, but not the adopter's kernel source or
51+
search space. An adopter that needs stale scratch winners invalidated should
52+
declare an integer schema parameter on its entry point and list it in `key`, then
53+
bump it with any change that can move the winner. Softmax does this with
54+
`tuning_schema`.
55+
56+
## Candidate correctness gate
57+
58+
`validate_hook(sig_args)` returns a context manager around one untimed candidate
59+
launch. Code before `yield` can poison outputs; code after `yield` validates the
60+
result, so skipped or partial stores cannot inherit a previous candidate's data.
61+
The launch uses the same stream, compile hints, reset/restore policy and arguments
62+
as timing, but validation work never affects ranking. `sig_args` maps every kernel
63+
parameter name to its value, including positional tensors. Softmax uses this hook
64+
to fill its output with NaNs before launch and check numerics afterward.
65+
66+
Raising from the hook rejects that candidate. If every candidate is rejected the
67+
search raises `RuntimeError("All autotune configs failed")` with the last failure
68+
chained, so a numerical rejection stays distinguishable from a compile failure.
69+
Use it wherever a candidate could launch successfully and still compute the wrong
70+
answer, and hold every candidate to the same tolerance as the default.
71+
72+
## Device timing contract
73+
74+
The shared `do_bench` timer queues a GPU-side backlog before batched event
75+
windows. This is required for sub-100 µs kernels: a fresh event pair on an empty
76+
stream can time the host enqueue gap instead of the kernel. Each window averages
77+
several launches, and the reported value is the median across windows. The
78+
callable must enqueue asynchronous work on the current stream and must not
79+
synchronize internally.
80+
81+
For Softmax results within 2% of the measured minimum, selection prefers the
82+
compatibility default, then a config without an explicit occupancy override,
83+
then the candidate packing more rows per block. This prevents event granularity
84+
from turning equivalent 6--10 µs candidates into unstable deployment artifacts;
85+
an improvement outside the band still wins normally. Softmax uses 10 warmup and
86+
100 measured launches, split into five backlogged event windows; the larger
87+
sample stabilized bandwidth-scale rows that moved by more than the tie band with
88+
the generic 25-launch default.
89+
90+
## Adopters
91+
92+
| Kernel | Module | `artifact_name` | Tuned axes |
93+
|---|---|---|---|
94+
| RMSNorm | `kernels/norm/rmsnorm_autotune.py` | `rmsnorm` | `BLOCK_THREADS`, `waves_per_eu` |
95+
| Softmax forward | `kernels/norm/softmax_autotune.py` | `softmax_fwd` | full-row threads, `waves_per_eu`, threads/rows per block for short rows |
96+
97+
Softmax backward is not an adopter yet; its existing kernel and dispatch are
98+
unchanged by `softmax_fwd` artifacts.
99+
49100
## Failure behavior
50101

51102
FlyDSL ignores missing, unreadable, mismatched, or structurally invalid

docs/prebuilt_kernels_guide.md

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ This guide covers the available FlyDSL kernels — normalization, softmax, GEMM,
88
|---|---|---|---|---|
99
| **LayerNorm** | `build_layernorm_module(N, dtype)` | Layout API (`@flyc.kernel`) | f32, f16, bf16 | Two-pass vectorized normalization |
1010
| **RMSNorm** | `build_rmsnorm_module(N, dtype)` | Layout API (`@flyc.kernel`) | f32, f16, bf16; optional fp32 weight | LDS-cached 3-pass pipeline |
11-
| **Softmax** | `build_softmax_module(M, N, dtype)` | Layout API (`@flyc.kernel`) | f32, f16, bf16 | Online softmax, adaptive block size |
11+
| **Softmax** | `build_softmax_module(M, N, dtype)` | Layout API (`@flyc.kernel`) | f32, f16, bf16 | Register-buffered softmax, opt-in autotuning |
1212
| **Softmax backward** | `build_softmax_bwd_module(N, dtype)` | Layout API (`@flyc.kernel`) | f32, f16, bf16 | fp32 dot reduction, native-dtype register buffering |
1313
| **GEMM** | `compile_preshuffle_gemm(...)` | `@flyc.kernel` | fp8, int8, fp16, bf16 | Preshuffle B, ping-pong LDS, MFMA 16x16 |
1414
| **FlashAttention** | `build_flash_attn_func_module(...)` | `@flyc.kernel` | bf16, f16 (any arch); fp8 e4m3fn (gfx950, D=128, dense) | Dual-wave SWP fwd, GQA/MQA, causal, descale ABI |
@@ -117,9 +117,63 @@ executor = build_softmax_module(M=32768, N=8192, dtype_str="bf16")
117117
**Configuration:**
118118
| Parameter | Value | Description |
119119
|---|---|---|
120-
| `BLOCK_SIZE` | `min(256, next_power_of_2(N))`, min 32 | Adaptive block size |
121-
| `VEC_WIDTH` | 8 | Vector load/store width |
122-
| `WARP_SIZE` | 64 | AMD wavefront size |
120+
| `BLOCK_THREADS` | 256 by default; 64/128/256/512 for full-row candidates | Total threads per block |
121+
| `THREADS_PER_ROW` | Defaults to `BLOCK_THREADS`; 8/16/32/64 for short-row candidates | Reduction subgroup assigned to one row |
122+
| `ROWS_PER_BLOCK` | 1 by default; derived from `BLOCK_THREADS / THREADS_PER_ROW` | Independent rows packed into one block |
123+
| `vec_width` | `128 // elem_bits` (8 for f16/bf16, 4 for f32) | Derived from the 128-bit transaction contract |
124+
| `WARP_SIZE` | 64 on CDNA, 32 on RDNA | Wavefront size, resolved from the target arch |
125+
126+
`THREADS_PER_ROW` also selects the data-movement path: with
127+
`tile_cols = THREADS_PER_ROW * vec_width`, a row takes the vectorized fast path when
128+
`N % tile_cols == 0` and the scalar generic path otherwise.
129+
130+
**Opt-in autotuning** (`kernels/norm/softmax_autotune.py`):
131+
```python
132+
from kernels.norm.softmax_autotune import softmax_autotuned
133+
134+
softmax_autotuned(x, y) # serves the tuned or default config, never searches
135+
```
136+
Ordinary calls follow the searched-winner cache → offline artifact → compatibility default
137+
(`BLOCK_THREADS=256`) ordering and never benchmark. `FLYDSL_AUTOTUNE=1` forces a search over
138+
a bounded, shape-aware space:
139+
140+
- full-row `BLOCK_THREADS ∈ {64,128,256,512}` ×
141+
`waves_per_eu ∈ {none,1,2,4}`;
142+
- Quack-style short-row packing that decouples `THREADS_PER_ROW` from total
143+
block threads and processes several rows per block.
144+
145+
The search-space rationale was checked against AITER
146+
`536118aaf94047b0b559e0730749352659419b34`, SGLang
147+
`955704544c60e920672aa434cefa2ce78c0ceb4c`, and Tri Dao's Quack
148+
`60d88082272a256fa9b3b2ab631c82cfa78337c6`. Quack's portable ideas are the
149+
row-width-dependent reduction subgroup, a separate 128/256-thread CTA size,
150+
multiple rows per CTA, and an online/non-online algorithm choice; its
151+
multi-CTA cluster reduction is NVIDIA-specific. AITER's standalone Triton
152+
kernel is a fixed two-pass online/reload implementation (`.cg`, eight warps,
153+
two stages, `waves_per_eu=2`), not an autotuned space. The pinned SGLang tree
154+
has attention-local and top-k softmax implementations but no directly comparable
155+
standalone row-wise kernel; attention tile/stage choices are therefore not
156+
imported here.
157+
158+
Every candidate is numerically validated before ranking and uses the shared
159+
GPU-backlog and batched-event timer. Input cache policy is deliberately not a
160+
search axis: on gfx950, non-temporal loads changed rank between repeated use of
161+
one address and rotation across fresh addresses. Cache residency is absent from
162+
the shape-only artifact identity, so persisting either result would encode an
163+
unstated workload assumption. The tested three-pass reload algorithm is also
164+
excluded because it lost to the register-buffered compatibility path; a future
165+
algorithm axis should implement a true online pair reduction before entering
166+
the default search.
167+
168+
Within a 2% timing tie, the selector favors the compatibility default, then no
169+
explicit `waves_per_eu`, then more rows per block. Larger measured improvements
170+
still win; the tie rule only avoids persisting noise-level differences between
171+
6–10 µs candidates. Softmax uses 10 warmup and 100 measured launches, divided
172+
into five GPU-backlogged event windows, so bandwidth-scale candidates are also
173+
ranked from a stable sample.
174+
175+
Artifacts use the name `softmax_fwd` and cover forward only. Softmax backward
176+
has no autotune adopter in this change. See [`autotune_guide.md`](autotune_guide.md).
123177

124178
**Algorithm (6 stages):**
125179
1. **Load data**: Vectorized global loads into register buffer with validity masks
@@ -130,11 +184,12 @@ executor = build_softmax_module(M=32768, N=8192, dtype_str="bf16")
130184
6. **Normalize + store**: Divide by sum, convert to output dtype, vectorized store
131185

132186
**Kernel signature:**
133-
```
134-
GPU_MODULE_NAME = f"softmax_{dtype_str}"
187+
```python
188+
build_softmax_module(M, N, dtype_str="f32", BLOCK_THREADS=256) # M is vestigial
189+
launch_softmax(A, C, m_in, stream=...) # returned launcher
135190

136-
@kernel
137-
softmax_kernel(self, A, C, m_in)
191+
# Direct-JIT entry point used by the autotuner
192+
softmax_direct(A, C, m_in, N, dtype_str, BLOCK_THREADS, tuning_schema, stream=...)
138193
```
139194

140195
### 2.2 Softmax backward (`kernels/norm/softmax_bwd_kernel.py`)
@@ -421,6 +476,7 @@ What operation do you need?
421476
| `kernels/norm/rmsnorm_kernel.py` | RMSNorm (layout API) |
422477
| `kernels/norm/softmax_kernel.py` | Softmax (layout API) |
423478
| `kernels/norm/softmax_bwd_kernel.py` | Softmax backward (layout API) |
479+
| `kernels/norm/softmax_autotune.py` | Softmax opt-in autotune adopter |
424480
| `kernels/attention/fused_rope_cache_kernel.py` | Fused RoPE + KV cache |
425481
| `kernels/comm/custom_all_reduce.py` | Multi-GPU all-reduce |
426482
| `kernels/gemm/rdna_f16_gemm.py` | RDNA FP16 GEMM |
@@ -447,6 +503,7 @@ What operation do you need?
447503
| `tests/kernels/test_rmsnorm.py` | RMSNorm |
448504
| `tests/kernels/test_softmax.py` | Softmax |
449505
| `tests/kernels/test_softmax_bwd.py` | Softmax backward |
506+
| `tests/kernels/test_softmax_autotune.py` | Softmax autotune selection and candidate correctness |
450507
| `tests/kernels/test_fused_rope_cache.py` | Fused RoPE + KV cache |
451508
| `tests/kernels/test_allreduce.py` | Multi-GPU all-reduce |
452509
| `tests/kernels/test_rdna_gemm.py` | RDNA GEMM |

0 commit comments

Comments
 (0)