Skip to content

Commit 364646c

Browse files
committed
docs: add megakernel-rmsnorm-qmv.md -- setup, testing, and full design history
Reproducibility pass per Brian's request: someone with no prior context should be able to build, enable, correctness-test, and benchmark this branch from this doc alone, without reading commit messages. Covers: build instructions, the embedded-metallib build-cache gotcha that cost real time in this session (cmake sometimes silently skips recompiling a .metal-only edit), exact correctness-check and benchmark commands (verified to work verbatim before committing), the debug env var table, the structural constraint on contiguous fusion (with the exact verification method to re-check it on a different model), the full measured design history (v1 through the tiny-ne01 fix, each with its real number and root cause -- not just "we tried some things"), and an honest "known limitations" section (parity is not yet a demonstrated speedup, untested under concurrent serving load).
1 parent 3f05849 commit 364646c

1 file changed

Lines changed: 235 additions & 0 deletions

File tree

docs/megakernel-rmsnorm-qmv.md

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
# RMSNorm + Q1_0/Q2_0 mat-vec fusion (Metal, batch=1 decode)
2+
3+
Branch: `megakernel/rmsnorm-qmv-fuse`. Status: **parity with baseline, opt-in, not default.**
4+
5+
This fuses ggml-metal's existing two-step pattern —
6+
`kernel_rms_norm_mul_f32` (writes `normed = rmsnorm(x)*w` to device memory) followed by one or
7+
more separate `kernel_mul_mv_{q1_0,q2_0}_f32` dispatches that read it back — into a single
8+
fusion that never materializes the normed vector in device memory. It only applies at batch=1
9+
(single decode token); prefill/batched matmul is untouched.
10+
11+
Built for Bonsai-27B (Qwen3-Next hybrid architecture) on Apple M-series GPUs. Read this whole
12+
file before touching the code — the design went through several measured, wrong-then-fixed
13+
iterations, and the reasons matter for anyone extending it.
14+
15+
## TL;DR for someone picking this up
16+
17+
1. Build normally (`cmake -B build -DGGML_METAL=ON -DCMAKE_BUILD_TYPE=Release && cmake --build
18+
build --target llama-bench llama-simple`). The fused kernels compile in unconditionally;
19+
they just don't run unless you opt in.
20+
2. Set `GGML_METAL_RMSNORM_QMV_FUSE=1` to enable the fusion. Unset (or `=0`) is the default —
21+
baseline behavior, byte-for-byte.
22+
3. Run `llama-bench` with and without the env var set to A/B it. As of the last commit on this
23+
branch, the two are statistically indistinguishable (see "Current status" below) — this is
24+
NOT yet a demonstrated speedup, just no longer a regression.
25+
4. If you change anything in `ggml-metal.metal`, see "Gotcha: the embedded-metallib build cache"
26+
below before you trust a `cmake --build` that reports no work to do.
27+
28+
## Why this exists
29+
30+
Real per-decode-step tracing on this model found that RMSNorm's output almost never feeds
31+
exactly one matmul — it typically feeds 2, 3, or 4 (gate+up MLP share one norm, Q/K/V/gate-style
32+
GDN-input projections share another). A naive single-consumer fusion (v0, not on this branch in
33+
its original form) fires essentially never on this architecture. The shipped design generalizes
34+
to N-consumer fusion (N ∈ {2,3,4}, matching the real, live-traced fan-outs) — see
35+
`ggml_metal_op_rmsnorm_qmv_multi_try` in `ggml/src/ggml-metal/ggml-metal-ops.cpp`.
36+
37+
## What's actually in this branch
38+
39+
- `ggml/src/ggml-metal/ggml-metal.metal`:
40+
- `kernel_rmsnorm_scale_f32` — single-threadgroup kernel, computes the RMSNorm scale once,
41+
writes it to a 1-float scratch slot.
42+
- `kernel_rmsnorm_mv_{q1_0,q2_0}_f32` — single-consumer fused kernel (kept for completeness;
43+
**does not fire on Bonsai-27B**, see below — every RMSNorm here has ≥2 consumers).
44+
- `kernel_rmsnorm_mv{2,3,4}_{q1_0,q2_0}_f32` and the `_small` variants — the N-consumer fused
45+
kernels that actually fire, in two NR0 tunings (see "Tiny-ne01 corner case" below).
46+
- `ggml/src/ggml-metal/ggml-metal-ops.cpp`:
47+
- `ggml_metal_op_rmsnorm_qmv_try` — single-consumer detector/dispatcher (dead code on this
48+
model, kept because it's cheap to check and harmless).
49+
- `ggml_metal_op_rmsnorm_qmv_multi_try` — the real one. Pattern-matches
50+
`RMS_NORM -> MUL -> {MUL_MAT(q1_0|q2_0)} × N` at graph-build time, dispatches the fused
51+
kernels. Returns `0` (decline to fuse, caller falls back to the normal path) on any
52+
mismatch — this is the safe path, not an error path.
53+
- `has_single_use()` / `use_count()` — new methods added to the `ggml_metal_op` struct for
54+
this work.
55+
- `ggml/src/ggml-metal/ggml-metal-common.cpp`: added `GGML_OP_GATED_DELTA_NET` and
56+
`GGML_OP_FLASH_ATTN_EXT` to the graph-reorder pass's `h_safe()` allowlist (a separate,
57+
independently-useful scheduling fix — see "Scheduling fix" below).
58+
- `docs/megakernel-rmsnorm-qmv.md` — this file.
59+
60+
## How to build
61+
62+
Nothing special — the fused kernels are always compiled in, gated at runtime by an env var, not
63+
a build flag.
64+
65+
```sh
66+
cmake -B build -DGGML_METAL=ON -DCMAKE_BUILD_TYPE=Release \
67+
-DLLAMA_BUILD_TESTS=OFF -DLLAMA_BUILD_EXAMPLES=ON -DLLAMA_BUILD_SERVER=OFF
68+
cmake --build build --target llama-simple llama-bench -j 8
69+
```
70+
71+
### Gotcha: the embedded-metallib build cache
72+
73+
`ggml-metal.metal`'s source gets embedded into the binary as text and JIT-compiled at runtime
74+
(you'll see `ggml_metal_library_compile_pipeline: compiling pipeline: ...` in stderr on first
75+
use of each kernel — this is normal, not an error). CMake's dependency tracking on **pure
76+
`.metal`-file-only edits** was unreliable in testing on this box — a `cmake --build` sometimes
77+
reports `Built target ggml-metal` with no compile step even after a real source change. If your
78+
edit doesn't seem to take effect, force it:
79+
80+
```sh
81+
touch ggml/src/ggml-metal/ggml-metal.metal
82+
rm -f build/ggml/src/ggml-metal/autogenerated/ggml-metal-embed.s \
83+
build/ggml/src/ggml-metal/autogenerated/ggml-metal-embed.metal.tmp \
84+
build/ggml/src/ggml-metal/autogenerated/ggml-metal-embed.metal \
85+
build/ggml/src/ggml-metal/CMakeFiles/ggml-metal.dir/autogenerated/ggml-metal-embed.s.o
86+
cmake --build build --target ggml-metal llama-simple llama-bench -j 8
87+
```
88+
89+
If you see `[50%] Building ASM object .../ggml-metal-embed.s.o` in the output, your edit was
90+
picked up. If you don't see that line, it wasn't — force it as above.
91+
92+
You can sanity-check a `.metal` edit compiles at all, independent of the full build, with:
93+
94+
```sh
95+
xcrun metal -S -O3 -I ggml/src/ggml-metal -I ggml/include -I ggml/src \
96+
ggml/src/ggml-metal/ggml-metal.metal -o /tmp/check.air
97+
```
98+
99+
This only checks syntax/type-correctness (AIR codegen), not runtime correctness — always follow
100+
it with a real build + the correctness check below.
101+
102+
## How to test correctness
103+
104+
Greedy output must be byte-identical with the fusion on vs off, on every checkpoint you care
105+
about. This is the only correctness bar that matters here — the kernel is designed so that any
106+
bug can only produce a wrong dot product (caught immediately by this diff), not a crash or a
107+
plausible-looking-but-wrong output that slips through.
108+
109+
```sh
110+
MODEL=~/models/bonsai-27B/bonsai-27B-q1_0.gguf # binary; swap for a Q2_0 (ternary) checkpoint too
111+
112+
./build/bin/llama-simple -m "$MODEL" -n 64 -ngl 999 "The capital of France is" 2>/dev/null > /tmp/off.txt
113+
GGML_METAL_RMSNORM_QMV_FUSE=1 ./build/bin/llama-simple -m "$MODEL" -n 64 -ngl 999 "The capital of France is" 2>/dev/null > /tmp/on.txt
114+
diff /tmp/off.txt /tmp/on.txt && echo "BYTE-IDENTICAL"
115+
```
116+
117+
Note: a raw-text (non-chat-templated) prompt on an instruct model like this often degenerates
118+
into a repeating token ("the the the the...") — that's a known, unrelated prompting artifact,
119+
not a bug in this fusion. It's still a valid correctness check (both runs must produce the
120+
exact same degenerate output), just don't mistake the degeneracy itself for a problem.
121+
122+
Confirm the fusion is actually firing (easy to silently get 0 matches if a future model/arch
123+
change breaks the pattern match):
124+
125+
```sh
126+
GGML_METAL_RMSNORM_QMV_FUSE=1 GGML_METAL_RMSNORM_QMV_TRACE=1 \
127+
./build/bin/llama-simple -m "$MODEL" -n 8 -ngl 999 "Hi" 2>&1 | grep -c MATCHED
128+
```
129+
130+
On Bonsai-27B this should print `126` per forward pass (64 layers × ~2 fusion sites/layer). If
131+
you see `0`, or a much smaller number, something about the graph structure changed — see
132+
`GGML_METAL_RMSNORM_QMV_TRACE`'s `REJECT: <reason>` lines (below) to find out why.
133+
134+
## How to benchmark
135+
136+
```sh
137+
./build/bin/llama-bench -m "$MODEL" -n 128 -ngl 999 -r 5 # fusion off
138+
GGML_METAL_RMSNORM_QMV_FUSE=1 ./build/bin/llama-bench -m "$MODEL" -n 128 -ngl 999 -r 5 # fusion on
139+
```
140+
141+
Compare `tg128` between the two. Run it 3+ times each way before drawing any conclusion — this
142+
box's baseline tok/s drifts several percent run-to-run from background system load (confirmed by
143+
watching `pp512`, which never touches this code path, drift by the same amount as `tg128`
144+
between unrelated runs). A single A/B pair is not a result.
145+
146+
## Debug / diagnostic env vars
147+
148+
| Var | Effect |
149+
|---|---|
150+
| `GGML_METAL_RMSNORM_QMV_FUSE=1` | Enable the fusion (default off). |
151+
| `GGML_METAL_RMSNORM_QMV_TRACE=1` | Print one line per RMS_NORM node: `MATCHED n=<N> qtype=<...> ne01=[...]` if fused, `REJECT: <reason>` if not (`op mismatch`, `multi-use`, `weight not q1_0/q2_0`, `batch != 1 or broadcast dims`, etc). Start here if match count looks wrong. |
152+
| `GGML_METAL_FUSION_DEBUG=2` | ggml-metal's own generic fusion logging — also prints for the *unrelated* built-in `RMS_NORM + MUL` (2-op) fusion, which still fires as a fallback wherever this branch's N-consumer fusion doesn't apply. |
153+
| `GGML_METAL_GRAPH_DEBUG=1` | Full per-node op listing in execution order — useful for checking whether sibling matmuls are actually contiguous in the graph (see "Structural constraint" below) on a model/arch you haven't traced before. |
154+
| `GGML_METAL_CONCURRENCY_STATS=1` | Prints `[concurrency-stats] nodes=<N> barriers=<N> avg_group=<X>` once per graph-compute call — quantifies how fragmented the barrier/concurrency schedule is. Unrelated to the fusion itself; added while investigating the scheduling fix below. |
155+
156+
## Structural constraint you must respect if you extend this
157+
158+
ggml-metal's fusion mechanism (the `n_fuse` return value from an op-dispatch function) only
159+
supports **contiguous** node ranges — the generic caller advances the encode cursor by exactly
160+
`n_fuse` positions, it cannot "skip forward" over interleaved unrelated nodes. This branch's
161+
N-consumer fusion only works because the real fan-out matmuls happen to land contiguously
162+
immediately after their shared `RMS_NORM`/`MUL` pair, in real model-construction order
163+
(`build_ffn`/`build_qkvz` in `src/models/qwen3next.cpp` emit sibling matmuls back-to-back). This
164+
was verified empirically via `GGML_METAL_GRAPH_DEBUG=1` before writing any dispatch code, for
165+
both an N=2 and an N=4 real site — **don't assume it holds on a different architecture without
166+
re-checking.** If a future model interleaves unrelated nodes between siblings, this fusion will
167+
just decline to fire there (safe), not silently miscompute.
168+
169+
## Design history (why it's built this way, not some other way)
170+
171+
Every one of these was root-caused via a real `llama-bench` measurement on real hardware, not
172+
guessed — several "obvious" fixes made things worse before the actual fix was found. If you're
173+
tempted to "simplify" the design, re-read this section first.
174+
175+
1. **v1 (superseded): stage the normed vector in threadgroup memory.** Fuses norm+mul+matvec by
176+
computing `normed[i] = x[i]*scale*norm_w[i]` for the whole hidden dimension into a
177+
threadgroup-memory buffer, then all output rows read from there instead of device memory.
178+
Measured: **-38% tg128** (41.35 → 25.62 t/s). Root cause: ~20KB/threadgroup footprint caps
179+
Apple-GPU occupancy to ~1 resident threadgroup/core, which is exactly wrong for a
180+
bandwidth-bound batch-1 decode kernel where occupancy is what hides DRAM latency while
181+
streaming the (much larger) quantized weight matrix. The underlying premise was also wrong:
182+
x and norm_w are tiny (~20KB) and stay SLC-cache-resident after the first touch, so
183+
"redundant" per-threadgroup device reads of them were never a real bandwidth cost.
184+
2. **v2: drop the staging, read x/norm_w straight from device memory inline** (matching where
185+
the baseline `kernel_mul_mv_{q1_0,q2_0}_f32_impl` reads its own activation), and match
186+
upstream's proven `NSG=2` (had been an arbitrary `4`). **-23%.**
187+
3. **v3: fixed a second bug — grid-imbalance waste.** The multi-consumer kernel dispatched one
188+
uniform `(max_tg, N)` grid sized to the *largest* sibling for every N-slot. Real fan-outs are
189+
wildly imbalanced (observed `ne01=[10240,48,48,6144]` on one real site) — small siblings got
190+
>99% of their dispatched threadgroups wasted, each still paying a full norm reduction before
191+
exiting on the row-bounds check. Fixed: dispatch once per sibling with a grid sized to *that*
192+
sibling's own `ne01`. **-10%.**
193+
4. **v4: broadcast-scale kernel.** Diagnostic (scale hardcoded to `1.0`, wrong output, used only
194+
to isolate cost) showed the remaining gap was the norm reduction itself being redone by every
195+
threadgroup in every sibling's dispatch, where the baseline computes it exactly once. Added
196+
`kernel_rmsnorm_scale_f32` — one threadgroup, computes the scale once, writes it to a 1-float
197+
scratch slot (reuses the `RMS_NORM` node's own now-unused output allocation, no new buffer
198+
allocation needed — see the code comment in `ggml_metal_op_rmsnorm_qmv_try` for the safety
199+
argument). Matvec dispatches read the precomputed scale instead of recomputing it. **~0%**
200+
parity, confirmed stable across repeated runs on both binary and ternary checkpoints.
201+
5. **Tiny-ne01 corner case (separate from the regression-chasing above):** found while auditing
202+
for low-GPU-utilization patterns, not from a throughput regression. Some real sibling
203+
matmuls have `ne01` as low as 48 — at the standard tiling that's only 3 threadgroups total,
204+
leaving most of the GPU idle regardless of fusion. Added an `nr0=1` "_small" kernel variant,
205+
selected per-sibling when the standard tiling would produce fewer than 16 threadgroups. Not
206+
yet isolated as its own measured win (the tiny matmuls' own share of aggregate decode time is
207+
too small for `llama-bench`'s aggregate `tg128` to resolve it) — would need a targeted
208+
microbenchmark of just those dispatches to confirm.
209+
210+
### Scheduling fix (separate from the fusion work, same branch)
211+
212+
While investigating occupancy, found that `ggml_metal_graph_optimize_reorder`'s `h_safe()`
213+
allowlist (which ops the concurrency-scheduling lookahead is allowed to look past) was missing
214+
`GGML_OP_GATED_DELTA_NET` and `GGML_OP_FLASH_ATTN_EXT` — the lookahead does a hard `break` (not
215+
skip) the instant it hits an unlisted op, and this hybrid model hits one of those two on nearly
216+
every layer. Added both (their state is fully explicit in src/dst tensors, same as the
217+
already-safe-listed `GGML_OP_SSM_SCAN` — the actual hazard check, `h_check` against real buffer
218+
ranges, still gates correctness regardless of this list). Verified bit-exact. Measured own
219+
isolated impact (vs a true pre-branch baseline build, fusion off in both): **~0.6%, within
220+
noise** — the barrier-count reduction (~14% fewer) didn't translate to a comparable tok/s
221+
change; the per-token decode critical path is mostly inherently serial.
222+
223+
## Known limitations / not done here
224+
225+
- Single-consumer fusion (`kernel_rmsnorm_mv_{q1_0,q2_0}_f32`) is dead code on Bonsai-27B —
226+
every RMSNorm here has ≥2 consumers. Kept because it's a strict subset check, costs nothing to
227+
leave in, and might matter on a different architecture.
228+
- Not tested under real concurrent serving load (multiple simultaneous requests) — only
229+
single-stream `llama-bench`. Dispatch-count reduction might matter more there than it does
230+
here, but that's untested.
231+
- Not validated on prefill/batched decode (deliberately out of scope — batch=1 only).
232+
- Parity is not yet a demonstrated *speedup*. If you're picking this up hoping for a quick win,
233+
it isn't one yet — it's a structurally-sound fusion that no longer costs anything, on the
234+
hypothesis (untested end-to-end) that fewer dispatches matter more under real serving
235+
conditions than in this isolated benchmark.

0 commit comments

Comments
 (0)