|
| 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