open-standards/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/BENCHMARKS.md
Transaction Science 895778bbb3
Some checks failed
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 8s
jouleclaw public surface must stay documented / Public-surface audit (jouleclaw-rs) (pull_request) Successful in 19s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 2m28s
standards workspaces build + test / jouledesk-rs (jouledesk/jouledesk-rs, jouledesk-rs, macos) (pull_request) Successful in 1m2s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 13m5s
standards workspaces build + test / mesh-rs (mesh/mesh-rs, -p mesh-engine-gguf -p mesh-openai --features mesh-engine-gguf/gguf,mesh-openai/server, mesh-rs, linux-arm64) (pull_request) Successful in 1m18s
standards workspaces build + test / jouleclaw-rs (-nodebug, jouleclaw/jouleclaw-rs, -p jouleclaw-energy --features rapl,nvml, jouleclaw-rs, linux-arm64, -C target-feature=+fp16) (pull_request) Failing after 2m48s
jouleclaw-loader-gguf: BENCHMARKS.md says what each North figure compares, from the run logs
Corrected against the per-run logs of the benchmark (final.log on both
Macs):
- The headline compared with base at its default thread count, which
  it did not say. It now gives median-against-median ratios, decode
  3.3-5.2x and prefill 5.9-10.6x, and names that baseline.
- The M3 Ultra decode median at ~600 context, 267/54 ms = 4.96x, lay
  outside the stated paired range 4.3-4.8x. The range left out a
  20.2x pair in that very column, and a second, 21.2x, at ~2700. The
  text named one at load 130-131 (it was 131, at ~2700). Both are now
  named with their loads, and the text says they are inside the
  medians.
- The M4 Max decode ranges silently left out the identity run's pairs
  (2.0x and 6.3x) while the prefill ranges kept them. They are now
  stated; the M3 prefill floor at ~2700 was 9.7, not 9.8.
- Base at 12 threads is now in the table for the M4 Max too, with
  paired ratios against it on both Macs. Base at 8 threads, its
  fastest in the earlier sweep, was never run in pairs; the text says
  so instead of implying the ratio.
- ~18 s for a turn at base's speed is an estimate (69 x 267 ms) and
  says so. A rayon pool against the spin pool was 18-24% slower on a
  quiet M4 Max (40-42 against 34 ms), not 16-24%. "Running the steps
  inside a rayon pool did not help" is about decode; prefill does run
  inside its pool, and the text says so.
- CI's glibc 2.35 giving the stored lines was shown on the host that
  made them, with 2.35's libm loaded, not by CI; the text says CI's
  run was pending.

The BF16 kernel runs 4 x 2, 2 x 4 and 1 x 4 blocks, not 4 x 4; its doc
comment and the benchmark's bullet now say so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 07:18:42 -05:00

1780 lines
80 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# jouleclaw-loader-gguf — measured numbers
Real-model numbers captured by running `cargo run --release
--example load_model` against models on an external SSD, plus
`cargo bench`. Hardware: Apple Silicon dev box (M-series).
Storage: USB-C external SSD (~3-4 GiB/s sequential read).
These numbers are reproducible — the bench harness is in
`benches/`, the loader example is in `examples/load_model.rs`,
the models are in any standard GGUF distribution. Numbers
shift with: storage class (NVMe vs HDD), page-cache state
(cold vs warm), and host kernel. Captured here as a checkpoint
the substrate's behaviour can be compared against.
## Load + advise wall-clock vs `PrefetchPolicy`
Same 9.5 GiB ZAYA1-8B Q8_0 GGUF, three policies:
| Policy | Load time | What ran |
|---|---:|---|
| `PrefetchPolicy::None` | **28 ms** | mmap + GGUF header parse only |
| `PrefetchPolicy::TopN(32)` | **139 ms** | header + WillNeed on top-32 by byte span + per-tensor hints on all 1283 |
| `PrefetchPolicy::All` (default) | **2,268 ms** | header + WillNeed on full 8.82 GiB region + per-tensor hints on all 1283 |
The `All` policy at 2.27 s corresponds to a sustained ~3.9 GiB/s
read rate from external SSD — close to the storage's
sequential ceiling. The substrate is correctly pushing the OS
to read pages eagerly.
The `TopN(32)` policy delivers most of the cold-load TTFT win
at **6% of the All policy's cost**. For latency-sensitive
paths (interactive inference, MoE expert swap), `TopN` is the
right choice when you know which tensors are first-touch hot.
Per-tensor advise overhead = (139 - 28) ms / 1283 tensors = **~87 µs per tensor**,
including syscall + byte-span computation.
## Load wall-clock by model size + format
All with `PrefetchPolicy::All` (default; full advise pass):
| Model | Format | Size on disk | Load + advise |
|---|---|---:|---:|
| LFM2.5 350M | Q4_K_M | 219 MiB | 53 ms |
| Bonsai 1.7B | Q1_0 (custom 1-bit) | 237 MiB | 67 ms |
| LFM2 350M | Q8_0 | 362 MiB | 93 ms |
| Ternary Bonsai 1.7B | Q2_0 (custom ternary) | 416 MiB | 113 ms |
| TinyLlama 1.1B chat | Q8_0 | 1.1 GiB | 254 ms |
| BitNet b1.58 2B | I2_S | 1.10 GiB | 285 ms |
| Gemma 4 E2B text | Q8_0 | 4.60 GiB | 1,081 ms |
| ZAYA1 8B | Q8_0 | 8.82 GiB | 2,268 ms |
Linear scaling with byte count — the per-tensor hint pass
dominates large-model load.
## Access-hint classifier — real-model behavior
The wave-3 classifier picks `Random` for embeddings,
`Sequential` for matmul weights, `WillNeed` for norms. Counts
from real models:
| Model | Random | Sequential | WillNeed | Total |
|---|---:|---:|---:|---:|
| LFM2.5 350M Q4_K_M | 2 | 72 | 74 | 148 |
| LFM2 350M Q8_0 | 2 | 72 | 74 | 148 |
| Bonsai 1.7B Q1_0 | 1 | 196 | 113 | 310 |
| TinyLlama 1.1B Q8_0 | 2 | 154 | 45 | 201 |
| BitNet b1.58 I2_S | 1 | 210 | 121 | 332 |
| Gemma 4 E2B Q8_0 | 2 | 245 | 354 | 601 |
| ZAYA1 8B Q8_0 | 1 | 360 | 922 | 1,283 |
`Random` count (1 or 2) tracks the number of distinct lookup
tables (token_embd + optional output projection). `Sequential`
tracks the matmul weights per block × block count.
`WillNeed` is everything else — small + always-touched.
## Matmul throughput — Q8_0 scalar vs NEON
`cargo bench --bench matmul_q8_0`. Apple Silicon dev box,
release build.
**`matmul_q8_0_scalar`** — scalar reference + wave-6 prefetch
hint only. Takes pre-quantized activation; no fp32 → i8
overhead.
**`matmul_q8_0`** — dispatching entry point. On aarch64 calls
the NEON kernel (`matmul_q8_0_neon`); also pays for
`quantize_a_per_row` (fp32 activation → i8 + per-row scale)
that the scalar bench skips. Closer to production cost.
| Shape (m × n × k) | Scalar | NEON dispatch | Speedup | NEON GFLOPS |
|---|---:|---:|---:|---:|
| 1 × 256 × 256 | 11.1 µs | **3.6 µs** | 3.1× | 36.4 |
| 1 × 1024 × 1024 | 181 µs | **54 µs** | 3.4× | 38.6 |
| 1 × 2048 × 2048 | 759 µs | **218 µs** | 3.5× | 38.5 |
| 1 × 4096 × 4096 | 3.00 ms | **868 µs** | 3.5× | 38.6 |
| 8 × 1024 × 1024 | 1.42 ms | **432 µs** | 3.3× | 38.8 |
NEON path sustains **~38 GFLOPS** regardless of size — the
size-independent scaling holds for both kernels (wave-6
prefetch + the NEON kernel's own vld1q-loop prefetching keep
the L1 fed past the L2 ceiling).
Context: Apple Silicon's fp32 peak per core is ~200 GFLOPS for
pure fp32 MAD. The NEON Q8_0 kernel's effective rate is
bounded by the int8 widen-multiply + i32 accumulate sequence
(`vmull_s8` → `vpadalq_s16`), not pure fp32 MAD. 38 GFLOPS
against that effective ceiling is **~20%** of the pure-FMA
peak, which is in the right zone for an int8-dominated
quantized matmul.
The NEON dispatch ALSO pays per-call for `quantize_a_per_row`
(fp32 → i8 + scale, ~m·k operations); the matmul-only kernel
is faster than the 3.5× speedup above suggests. For batched
inference where activations stay i8 between layers, the
quantize cost amortizes.
## F32 matmul — scalar vs Apple Accelerate (AMX)
`cargo bench --features accelerate --bench matmul_q8_0
matmul_f32`. The `accelerate` feature routes f32 matmul
through `cblas_sgemm` which uses AMX (the M-series matrix
accelerator) on Apple Silicon. Direct apples-to-apples — no
quantization confounding the measurement.
| Shape (m × n × k) | Scalar | Accelerate (AMX) | Speedup | AMX GFLOPS |
|---|---:|---:|---:|---:|
| 1 × 256 × 256 | 27.2 µs | **1.4 µs** | **19.8×** | 95 |
| 1 × 1024 × 1024 | 632 µs | **13.5 µs** | **47.0×** | 156 |
| 1 × 2048 × 2048 | 2.73 ms | **67.6 µs** | **40.4×** | 248 |
| 1 × 4096 × 4096 | 11.45 ms | **710 µs** | **16.1×** | 47 |
| 8 × 1024 × 1024 | 5.07 ms | **83.8 µs** | **60.5×** | 200 |
| 32 × 1024 × 1024 | 20.30 ms | **90.4 µs** | **224.6×** | **742** |
AMX scales dramatically with batch dimension `m`:
- **m = 1** (single-token decode): peaks at ~250 GFLOPS at
n=k=2048. The 4096² drop to 47 GFLOPS suggests the matrix
exceeds AMX's working set — needs tiling.
- **m = 8** (small batch): 200 GFLOPS sustained.
- **m = 32** (medium batch / prefill): **742 GFLOPS** — the
AMX-favoured regime where weight rows reuse across multiple
activation rows. Approaches the per-core ceiling.
The 224× speedup at m=32 is the headline. Compute-bound +
weight-reuse-dominated workloads (think KV-cache prefill,
batched inference) get the full AMX win.
The single-token-gen path (m=1) caps at ~250 GFLOPS at
medium k — bandwidth-bound, not compute-bound. AMX can't
reuse weights when there's only one activation row; the
fetch from L1 dominates. Still ~50× faster than scalar
which is a real win.
## Q8_0 matmul — NEON direct vs AMX-tiled (wave 24)
`matmul_q8_0_amx_tiled` dequantizes a chunk of weight rows
into a small scratch buffer (128 rows × k floats) using the
new wave-24 NEON-tuned `dequantize_q8_0_neon`, then routes
the chunk through `cblas_sgemm`. Each chunk fits in L2 so
the f32 scratch never hits DRAM — only the Q8_0 weight
(4× smaller) crosses the DRAM boundary.
Side-by-side at the same shapes (m × n × k):
| Shape | NEON Q8_0 (dispatch) | AMX-Tiled Q8_0 | Winner |
|---|---:|---:|---|
| 1 × 256 × 256 | **3.6 µs** | 10.4 µs | NEON 2.9× |
| 1 × 1024 × 1024 | **54 µs** | 118 µs | NEON 2.2× |
| 1 × 2048 × 2048 | **218 µs** | 437 µs | NEON 2.0× |
| 1 × 4096 × 4096 | **868 µs** | 1.77 ms | NEON 2.0× |
| 8 × 1024 × 1024 | 432 µs | **202 µs** | **AMX-Tiled 2.1×** |
| 32 × 1024 × 1024 | ~1.4 ms (extrapolated) | **212 µs** | **AMX-Tiled ~6.6×** |
**The crossover is m ≈ 8.** Below that, the dequant cost
exceeds the AMX speedup — single-token decode (m=1) stays
on the NEON direct path. At m=8, AMX's weight reuse
amortizes the dequant. At m=32 (typical prefill /
batched), AMX-tiled wins decisively.
**Production dispatch decision:** route Q8_0 matmul to
NEON when m ≤ 4; to AMX-tiled when m ≥ 8; either is fine
in between. A consumer-level wrapper can make this
automatic; the substrate ships both kernels + lets the
caller pick.
## Multi-thread fan-out via rayon (wave 25)
Two new variants behind the `rayon` cargo feature:
- `matmul_q8_0_threaded` — wraps the NEON Q8_0 kernel with
rayon. Parallelizes over `n` for `m == 1`; over `m`
otherwise.
- `matmul_q8_0_amx_tiled_threaded` — wraps the AMX-tiled
Q8_0 kernel; parallelizes over weight-row chunks.
**Full dispatch matrix** (Apple Silicon dev box, release build,
`--features "rayon accelerate"`):
| Shape (m × n × k) | NEON Single | NEON Threaded | AMX-Tiled Single | AMX-Tiled Threaded | **Best** |
|---|---:|---:|---:|---:|---|
| 1 × 256 × 256 | **3.6 µs** | 290 µs | 10.4 µs | 69 µs | NEON single |
| 1 × 1024 × 1024 | **54 µs** | 251 µs | 118 µs | 370 µs | NEON single |
| 1 × 2048 × 2048 | **218 µs** | 269 µs | 437 µs | 514 µs | NEON single |
| 1 × 4096 × 4096 | 868 µs | **308 µs** | 1.77 ms | 927 µs | **NEON threaded 2.8×** |
| 8 × 1024 × 1024 | 432 µs | 456 µs | **202 µs** | 337 µs | AMX-tiled single 2.1× |
| 32 × 1024 × 1024 | ~1.4 ms | 374 µs | **212 µs** | 311 µs | **AMX-tiled single 6.6× over NEON** |
**Two empirical results from the bench:**
1. **Rayon hurts small kernels.** Task-spawn overhead is
~200-300 µs on this M-series box. Below ~1 MFLOP of work,
single-thread wins. Above 16 MFLOPs, rayon amortizes and
delivers a real 2.8× speedup (NEON case).
2. **Rayon hurts AMX-tiled at every size measured.** Accelerate
is ALREADY internally multi-threaded for sgemm calls of the
chunk sizes we use. Wrapping with rayon oversubscribes the
cores → contention. The AMX-tiled single-thread path stays
the winner from m=8 onward.
**Production dispatch rules (revised, wave 25):**
| Workload | Choose | Why |
|---|---|---|
| Single-token decode, small/medium k (n,k ≤ 2048) | `matmul_q8_0` (NEON single) | Rayon overhead > parallelism win |
| Single-token decode, large k (n,k = 4096+) | `matmul_q8_0_threaded` (NEON threaded) | 2.8× speedup; kernel large enough to amortize |
| Batched / prefill (m ≥ 8) | `matmul_q8_0_amx_tiled` (AMX single) | Accelerate's internal threading saturates cores; rayon hurts |
| Multiple matmuls fanned out concurrently | Wrap multiple `matmul_q8_0` calls in `par_iter` instead | Coarser-grained parallelism > kernel-internal threading for many small matmuls |
This dispatch is a consumer-level wrapper. The substrate ships
all four kernels + the bench harness; the caller picks based
on shape + workload.
## Q4_K matmul — scalar fused (wave 19) vs AMX-tiled (wave 26)
Q4_K is the dominant format in distributed Llama-family models
(`Q4_K_M`). Wave 19 ships a scalar fused kernel (no f32
intermediate); wave 26 adds the AMX-tiled mirror of the
wave-24 Q8_0 approach.
`cargo bench --features accelerate --bench matmul_q4_k`:
| Shape (m × n × k) | Scalar Fused (W19) | AMX-Tiled (W26) | Speedup |
|---|---:|---:|---:|
| 1 × 256 × 256 | 40.8 µs | **33.0 µs** | 1.24× |
| 1 × 1024 × 1024 | 729 µs | **395 µs** | 1.84× |
| 1 × 2048 × 2048 | 2.94 ms | **1.46 ms** | 2.01× |
| 1 × 4096 × 4096 | 11.62 ms | **5.39 ms** | 2.16× |
| 8 × 1024 × 1024 | 5.78 ms | **473 µs** | **12.2×** |
| 32 × 1024 × 1024 | 22.61 ms | **524 µs** | **43.1×** |
**The Q4_K story differs from Q8_0.** For Q8_0, NEON direct
wins at m=1 (the dequant cost in the tiled path exceeds the
AMX speedup). For Q4_K there's no NEON kernel — only the
scalar fused — so the baseline is much slower (729 µs vs
54 µs at n=k=1024). Even with the dequant overhead, AMX-tiled
wins at every size measured.
### Wave 27 added: NEON Q4_K matmul
`matmul_q4_k_neon` — same fused walk as the scalar version
but the 32-element sub-block inner loop runs through NEON:
extract nibbles (`vandq_u8` / `vshrq_n_u8`), widen u8 → u16
→ u32 → f32, accumulate via `vfmaq_f32`. Uses the algebraic
split `sum(a * (d_sub * q - m_sub)) = d_sub * sum(a*q) -
m_sub * sum(a)` so each sub-block only needs two NEON
horizontal-sums (`vaddvq_f32`), not per-element scaling.
Full Q4_K dispatch table:
| Shape (m × n × k) | Scalar Fused (W19) | **NEON (W27)** | AMX-Tiled (W26) | Winner |
|---|---:|---:|---:|---|
| 1 × 256 × 256 | 41 µs | **7.3 µs** | 33 µs | **NEON 5.6×** |
| 1 × 1024 × 1024 | 729 µs | **115 µs** | 395 µs | **NEON 6.3×** |
| 1 × 2048 × 2048 | 2.94 ms | **458 µs** | 1.46 ms | **NEON 6.4×** |
| 1 × 4096 × 4096 | 11.62 ms | **1.84 ms** | 5.39 ms | **NEON 6.3×** |
| 8 × 1024 × 1024 | 5.78 ms | 911 µs | **473 µs** | AMX 1.9× |
| 32 × 1024 × 1024 | 22.6 ms | 3.66 ms | **524 µs** | **AMX 7×** |
**Same dispatch shape as Q8_0:**
- m = 1 (decode): NEON wins
- m ≥ 8 (prefill/batched): AMX-tiled wins
- m = 4-8: either; bench-driven
Wave 27 closes the Q4_K m=1 gap that wave 26 left open. Q4_K
now has the full kernel family: scalar fused (W19) + NEON
(W27) + AMX-tiled (W26), parallel to Q8_0's scalar (W12) +
NEON (existing) + AMX-tiled (W24).
## Q5_K + Q6_K matmul — scalar fused vs AMX-tiled (wave 28)
Mirror of wave-26 Q4_K for the two larger K-quants. Same shape:
scalar fused + AMX-tiled. No NEON variant yet (mirror of the
gap wave 27 closed for Q4_K).
**Q5_K** (`cargo bench --features accelerate --bench matmul_q56_k`):
| Shape (m × n × k) | Scalar Fused | AMX-Tiled | Speedup |
|---|---:|---:|---:|
| 1 × 256 × 256 | **35.8 µs** | 43.9 µs | -1.2× (scalar wins) |
| 1 × 1024 × 1024 | 738 µs | **569 µs** | 1.30× |
| 1 × 2048 × 2048 | 2.95 ms | **2.16 ms** | 1.37× |
| 1 × 4096 × 4096 | 12.10 ms | **7.64 ms** | 1.58× |
| 8 × 1024 × 1024 | 5.83 ms | **660 µs** | **8.8×** |
| 32 × 1024 × 1024 | 23.15 ms | **669 µs** | **34.6×** |
**Q6_K**:
| Shape (m × n × k) | Scalar Fused | AMX-Tiled | Speedup |
|---|---:|---:|---:|
| 1 × 256 × 256 | 45.7 µs | **39.1 µs** | 1.17× |
| 1 × 1024 × 1024 | 754 µs | **462 µs** | 1.63× |
| 1 × 2048 × 2048 | 3.03 ms | **1.85 ms** | 1.64× |
| 1 × 4096 × 4096 | 12.25 ms | **6.70 ms** | 1.83× |
| 8 × 1024 × 1024 | 6.08 ms | **582 µs** | **10.4×** |
| 32 × 1024 × 1024 | 24.33 ms | **595 µs** | **40.9×** |
**Same shape as Q4_K dispatch:**
- m = 1: marginal AMX wins (1.2-1.8×) for sizes ≥ 1024; for
tiny sizes (n,k=256) the scalar fused path can match or
beat the AMX overhead.
- m ≥ 8: AMX-tiled wins decisively (8.8× and 10.4× at m=8;
34.6× and 40.9× at m=32).
### Wave 30 added: NEON Q5_K + Q6_K matmul
Mirror of wave-27 (NEON Q4_K). Same algebraic split per
sub-block; Q5_K extracts 1 high bit from `qh` per element,
Q6_K extracts 2 high bits + applies a signed -32 offset.
**Full Q5_K dispatch table:**
| Shape (m × n × k) | Scalar | **NEON (W30)** | AMX-Tiled | Winner |
|---|---:|---:|---:|---|
| 1 × 256 × 256 | 35.8 µs | **10.6 µs** | 43.9 µs | NEON 3.4× |
| 1 × 1024 × 1024 | 738 µs | **168 µs** | 569 µs | NEON 4.4× |
| 1 × 2048 × 2048 | 2.95 ms | **671 µs** | 2.16 ms | NEON 4.4× |
| 1 × 4096 × 4096 | 12.10 ms | **2.71 ms** | 7.64 ms | NEON 4.5× |
| 8 × 1024 × 1024 | 5.83 ms | 1.35 ms | **660 µs** | AMX 2.0× over NEON |
| 32 × 1024 × 1024 | 23.15 ms | 5.35 ms | **669 µs** | AMX 8× over NEON |
**Full Q6_K dispatch table:**
| Shape (m × n × k) | Scalar | **NEON (W30)** | AMX-Tiled | Winner |
|---|---:|---:|---:|---|
| 1 × 256 × 256 | 45.7 µs | **7.9 µs** | 39.1 µs | NEON 5.8× |
| 1 × 1024 × 1024 | 754 µs | **123 µs** | 462 µs | NEON 6.1× |
| 1 × 2048 × 2048 | 3.03 ms | **492 µs** | 1.85 ms | NEON 6.2× |
| 1 × 4096 × 4096 | 12.25 ms | **1.97 ms** | 6.70 ms | NEON 6.2× |
| 8 × 1024 × 1024 | 6.08 ms | 988 µs | **582 µs** | AMX 1.7× over NEON |
| 32 × 1024 × 1024 | 24.33 ms | 3.95 ms | **595 µs** | AMX 6.6× over NEON |
Q6_K NEON edges Q5_K NEON at m=1 (~6× vs ~4.4× over scalar) —
no per-element min subtraction, just the signed -32 offset
that NEON's `vsubq_s8` handles in one op.
**Same dispatch pattern across Q4_K / Q5_K / Q6_K:**
- m = 1 (decode): NEON wins
- m ≥ 8 (prefill / batched): AMX-tiled wins
All three K-quant families now have the full three-kernel
suite (scalar / NEON / AMX-tiled) parallel to Q8_0.
## Attention — unchunked (wave 21) vs flash-chunked (wave 29)
Flash attention's online-softmax walks `seq_kv` in chunks of 256,
maintaining a running `(max, sum, output[d_head])` instead of
the full `[seq_kv]` scores buffer the unchunked kernel keeps.
`cargo bench --bench attention`, d_head=128:
| Shape (seq_q × seq_kv) | Unchunked | Flash | Verdict |
|---|---:|---:|---|
| 1 × 256 | 13.8 µs | 14.8 µs | tied (flash +7%) |
| 1 × 1024 | 55 µs | 60 µs | tied |
| 1 × 4096 | 218 µs | 232 µs | tied |
| 1 × 16384 | 883 µs | 927 µs | tied (flash +5%) |
| **1 × 65536** | 3.78 ms | **3.65 ms** | **flash wins 3%** |
| 32 × 4096 | 7.30 ms | 7.33 ms | tied |
**Compute is within 5% across all sizes.** The headline win
isn't speed — it's structural memory:
| Variant | Scratch per query | Scratch at seq_kv=1M |
|---|---|---|
| Unchunked | O(seq_kv) | 4 MB |
| Flash | O(d_head + 256) | 1.5 KB |
For batched long-context inference (seq_q=32 at seq_kv=1M),
unchunked allocates 128 MB of scratch total; flash allocates
50 KB. The flash kernel is the right choice for any context
length beyond ~16K — same compute, dramatically lower memory
footprint, no working-set pressure on L2/SLC.
The 3% speedup at seq_kv=65536 is the inflection point: the
unchunked scores buffer (256 KB) no longer fits cleanly in
the L2 cache. Below that the buffer is L2-resident in both
variants; above it, only flash's per-chunk buffer fits.
## BF16 kernel ladder on real Nemotron-3 weights (waves 42 + 46)
Verification harness `examples/bf16_bringup.rs` run against
**nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16** — a real 7.4 GiB
single-file safetensors checkpoint (263 tensors, 94 2-D BF16
matrices) pulled from HuggingFace. `m=1` (single-token
decode shape), full checkpoint load via
`dispatch::read_any_model` in 1.69 s.
| tensor | shape | scalar | NEON | NEON speedup | dequant→f32 |
|---|---|---|---|---|---|
| `backbone.embeddings.weight` | 131072×3136 | 310.4 ms | 49.2 ms | **6.3×** | 423.4 ms |
| `lm_head.weight` | 131072×3136 | 312.4 ms | 48.4 ms | **6.5×** | 333.9 ms |
| `backbone.layers.0.mixer.in_proj.weight` | 17504×3136 | 42.1 ms | 6.6 ms | **6.4×** | 56.7 ms |
Max relative divergence across all three routes on real
weight bytes: **3.2 × 10⁻⁶**. The NEON decode
(`vshll_n_u16` widening shift = the entire bf16→f32
conversion) sustains ~16 GB/s of weight traffic
single-threaded; the dequant→f32 route loses to fused even
at this size because the 2× widened slab (1.53 GiB for the
embedding matrix) blows every cache level — it only wins
once the AMX `cblas_sgemm` backend is in play with `m ≥ 2`.
## NemotronH end-to-end decode (waves 48-51)
`examples/nemotron_decode.rs` against the real
**nvidia/NVIDIA-Nemotron-3-Nano-4B-BF16** (42 hybrid layers:
Mamba2 / GQA attention / relu² MLP; 131 072-token lm_head),
prompt `"The capital of France is"` → `" Paris."`,
greedy decode, weights BF16 in the mmap end to end:
| build | per-token wall clock | throughput |
|---|---|---|
| single-thread NEON (wave 49) | ~435 ms | 2.3 tok/s |
| `--features rayon` (wave 51) | ~80 ms | **12.5 tok/s** |
The rayon build fans each projection's output rows across
cores (wave 25 dispatch shape) with each thread running the
wave 42 NEON inner loop; outputs are bit-identical to the
single-thread kernel (chunking never splits a dot product).
5.5× on the dev box — short of core count because the
135 MiB/token of weight traffic begins to saturate memory
bandwidth, which is the right ceiling for a decode loop.
## Expert-batch dispatch (wave 63)
Follow-up to the wave 62 floor: the floor stops the waste
but leaves MoE expert matmuls single-core. `matmul_bf16_batch`
runs a layer's whole routed-expert stage (8 experts × gate+up,
then 8 × down) as ONE rayon scope — one pool wakeup amortized
over ~66 MB of work instead of 24 wakeups. Per-job math is the
same single-thread NEON kernel and accumulation stays in pick
order, so results remain bit-identical (parity suites pin it).
Wired into Mellum2 and Cohere2-MoE decode.
Controlled A/B (back-to-back, same heavy external load):
| config | Mellum2 decode (median) |
|---|---|
| RAYON_NUM_THREADS=1 | ~1.15 s/token |
| expert-batch + floor 128 | **~0.78 s/token** (1.45×) |
Best observed at quieter moments: ~0.5 s/token. Absolute
numbers float with box load; the A/B ratio is the honest
signal.
Wave 64 extends the batch shape to Granite h-tiny's fused
expert walk (expert matrices stay zero-copy slices of the
fused [experts, ·, ·] tensors). Same-load A/B: ~174 ms/token
single-thread → ~125 ms/token batched (1.4×) — h-tiny is
now the fastest MoE decode in the set, beating its own
wave-53 ~270 ms baseline.
## Prefill win, measured across the Mac fabric: 47–54× energy, 10–14× speed (wave 91)
Three quiet/loaded Metal-4 boxes, the matmul2d prefill path vs
threaded-NEON, on the canonical 256/512×4096×4096 projections.
**Energy (`mpp_energy` example, IOReport, idle-subtracted, QUIET
boxes) — the number that matters for an energy-metered substrate:**
```
idle matmul2d µJ/GFLOP NEON µJ/GFLOP CPU/GPU ratio
M5 Max 20.6 W 6 630 358 906 54×
M4 Max 4.45 W 5 052 238 990 47×
```
So a prefill projection costs **~47× fewer joules per GFLOP** on
the Metal-4 tensor path than on the CPU — an order-of-magnitude
energy collapse for the compute-bound part of inference.
**Honesty caveat (added after a second run):** trust the M4
number, not the "54×" first reported for M5. The M4 idle was
4.45 W (genuinely quiet) and its matmul2d µJ/GFLOP repeats
(~4.6–5.1k across runs → the calibration wrote 4615 Measured).
The M5 idle was 20.6 W — NOT truly idle (something was running),
so idle-subtraction is noisy there: a second run gave 12 385
µJ/GFLOP vs the first 6 630, a 2× spread. The RATIO (~47–54×) is
roughly stable because GPU and CPU are measured under the same
baseline, but the M5 ABSOLUTE is unreliable. Per-box Measured
calibrations written: M4 = 4615, M5 = 12385 µJ/GFLOP (the placer
uses each box's own; the seeded Estimate is 6000). Lesson: a box
is only "quiet" if its idle draw says so — check it, don't assume.
**Throughput (512×4096×4096, mpp_bt vs threaded-NEON):**
```
M4 Max (quiet) 3240 GFLOP/s 14.0×
M5 Max (quiet) 4073 GFLOP/s 12.3×
M3 Ultra (load) 3022 GFLOP/s 10.2×
```
Portable across M3/M4/M5; the speedup is largest on M4 (slower
CPU baseline), the absolute GFLOP/s highest on M5.
**Token-drift sweep — 9/9 IDENTICAL.** NemotronH-4B, Granite-4.0-
h-micro, Falcon-H1-0.5B × 3 diverse long prompts each, OFF vs ON:
every output token-for-token identical. The ~5% bf16-activation
rounding flips no argmax on anything tested.
So the prefill path is correct (full-output shape sweep), fast
(10–14×), ~50× more energy-efficient, and output-safe across 3
families × 3 prompts. Still OPT-IN (`JOULECLAW_MPP_PREFILL=1`):
it is a real precision change and the substrate default stays the
exact path, but the case for flipping it on is now strong.
NOTE: the ledger still prices matmul2d ops with the OLD
MPSMatrix-based metal-gpu calibration; the measured 5–7 mJ/GFLOP
above is its true cost and should overlay the placement table —
a calibration-integration follow-up.
## matmul2d prefill DEFAULT-ON + 5/5 family-safe + q/k/v windows (wave 93)
Resolving the four open items from the prefill arc.
**Default-on decision: ON when the fabric is on** (opt OUT with
`JOULECLAW_MPP_PREFILL=0`). Rationale: turning the fabric on IS
the documented "caller is choosing joules over bit-exactness"
opt-in (the AMX path already rides that contract); a ~47× energy
win that is token-identical across all five families fits it
exactly, and fabric-OFF (the real default) stays byte-exact. The
opt-out keeps the more-precise resident-F16 GPU path available.
**Family token-safety: 5/5 (13/13 prompt pairs IDENTICAL).**
NemotronH-4B, Granite-h-micro, Falcon-H1 (×3 prompts each) +
North-Mini-Code-30B-A3B and Mellum2-12B-A2.5B (×2 each), default
vs opt-out: every output byte-for-byte identical. The bf16-
activation rounding flips no argmax across topologies (dense +
hybrid-Mamba + MoE) and prompt styles (Q&A, prose, code).
**q/k/v windows wired (`matmul_bf16_auto_multi`).** The fused
prefill windows (q/k/v, expert stages) now route each job
through matmul2d too, not just the single-weight `proj_m` path —
so the whole prefill projection set gets the win. All-or-nothing
per window; a fallback recomputes via the resident path.
**M4 quiet standard-member calibration** (idle 5.65 W, clean) for
context — prefill-batch (m=16) µJ/GFLOP: cpu-neon 335 836, AMX
29 523, metal-gpu (resident MPSMatrix F16) 13 253. So matmul2d at
~4 615 is **~2.9× lower energy than even the resident-F16 MPS
path**, ~6× under AMX, ~73× under NEON. Written to the M4 box's
calibration; the M3-Ultra dev calibration stays loaded-box (can't
quiet the dev box).
Verified default-on on Nemotron prefill: metal-mpp carries 99% of
projection GFLOPs by default; `=0` falls back to the resident
path; output identical either way. Backend 19 + loader 285 green
(fabric default-on AND opt-out AND fabric-off).
## matmul2d prefill WIRED IN — token-identical, opt-in (wave 90)
The fast non-square GEMM is now actually used by the model
forwards, and it's the first real prefill acceleration the M5
arc has delivered.
`matmul_bf16_auto` (the central projection dispatch every family
calls via `proj_m`) now routes bias-free, M≥32 GEMMs through
`matmul_bf16_mpp_bt` when **`JOULECLAW_MPP_PREFILL=1`**. Off by
default — matmul2d rounds activations to bf16 (~5% per-projection
rel error), so the operator opts into faster-but-lower-precision
prefill. M≥32 keeps the pad-to-64 waste under the GPU win; decode
(M=1) is untouched.
**Real-model A/B, NVIDIA-Nemotron-3-Nano-4B, M3 Ultra:** a long
coherent few-shot prompt ("…capital of Germany? Answer:") gives
the SAME output OFF and ON — `" Berlin. Question: What is the
capital of Canada? Answer"` — with matmul2d carrying **100% of
the prefill projection GFLOPs** (242.8 GFLOP). The ~5% activation
rounding did not flip a single argmax (also identical on a second
prompt). So prefill runs on the ~4–11× Metal-4 tensor path with
no observed token drift.
M5 Max throughput (vs threaded-NEON, wave 89): 4–11× on real
projection shapes. Decode still uses the existing bandwidth-bound
paths (matmul2d needs M%64 / pad, pointless for M=1).
**Cross-family token check (M3 Ultra, same long few-shot
prompt), all OFF==ON:**
- NemotronH 4B → " Berlin. … Canada? Answer" (gpu 100% of GFLOP)
- Granite-4.0-h-micro → " Berlin. … Australia" (gpu 100%)
- Falcon-H1-0.5B → " Berlin. … Italy" (gpu 57.8%)
So across three topologies the bf16-activation prefill path is
output-identical to the f32-activation baseline. Kept OPT-IN
anyway (one prompt each; a borderline argmax could still flip on
some input, and the substrate's default is the higher-precision
path) — the operator turns it on for the 4–11× prefill speedup.
Two real, integrated M5/Metal-4 wins now: wave 82 (2× decode, f16
MPS, on by default) + wave 90 (4–11× prefill, Metal-4 tensor op,
opt-in).
## NON-SQUARE matmul2d CRACKED — 3.6–10× faster prefill GEMM (wave 89)
The one-more-attempt paid off: the matmul2d kernel now does a
correct, fast NON-SQUARE GEMM in ONE dispatch — no tiling.
The missing piece was in Apple's example HOST code
(`matmul.swift`, fetched from the repo), which computes the
dispatch grid *conditionally on M>N vs M≤N*. That clue, plus a
single-element probe, revealed the fix: **declare the output
tensor TRANSPOSED — `dextents(N, M)` instead of `(M, N)`.** The
op then writes `O[m,n]` to `out[m*N+n]` (the natural row-major
read) for arbitrary shapes. The non-transposed C is correct
*only* for square M==N — which is exactly why waves 84–88 saw
"square-only." Constraints are now just M%64, N%32, K%32.
Verified `WRONG=0` over the FULL output across a non-square
shape sweep (128×64, 128×256, 256×128, 192×96, 64×256,
320×128, …) on the M3 Ultra dev box.
`matmul_bf16_mpp_bt` rewritten to a SINGLE dispatch (transpose
weight `[N,K]→[K,N]` cached, pad M to %64, one matmul2d). The
per-block overhead that made wave 88 slow is gone. Measured vs
threaded-NEON on M3 Ultra, realistic projection shapes:
```
M×N×K mpp_bt GFLOP/s NEON speedup
128×4096×4096 954 265 3.6×
256×4096×4096 1672 294 5.7×
256×11008×4096 2121 285 7.4×
512×4096×4096 3022 297 10.2×
```
So prefill projections are **3.6–10× faster** on the Metal-4
tensor path than the CPU path, and the speedup grows with M
(more tokens) and N. Caveat: matmul2d rounds activations to
bf16 → ≈5% rel error vs the f32-activation NEON path — a
precision-for-speed trade, acceptable for prefill (same class
as the existing f16-resident GPU path).
NOT yet wired into the family forwards — doing so changes
prefill numerics (the bf16-activation rounding), so it needs a
real-model output check (token drift) before flipping it on.
That is the next step; the primitive + wrapper are correct,
fast, and tested.
## Square-tiling makes matmul2d correct for non-square — but 3× SLOWER than CPU (wave 88)
The square-tiling wrapper works and is fully validated, and it
still doesn't help. Honest end of this thread (for now).
`matmul_bf16_mpp_bt` now tiles a non-square `out[M,N]` into
S×S=128² SQUARE blocks, each a square matmul2d call over a
zero-padded `[S,K]` activation tile and a `[K,S]` weight-column
tile. **Correctness: validated against the NEON oracle over the
FULL output across a SHAPE SWEEP** — square, non-square, and
non-tile-aligned (130×300, 192×448, 300×130) all pass. So
non-square GEMM via this kernel is finally correct.
**But it is ~3× SLOWER than the existing threaded-NEON CPU
path** (M3 Ultra, realistic projection shapes):
```
M×N×K mpp_bt GFLOP/s threaded-NEON speedup
128×4096×4096 94 275 0.34×
256×4096×4096 95 288 0.33×
256×11008×4096 76 279 0.27×
512×4096×4096 95 292 0.33×
```
The kernel hits ~24 TFLOP/s at large SQUARE shapes, but tiling
into 128² blocks destroys it: per block there is a CPU-side
strided weight-column extraction and a SEPARATE command buffer,
so a single projection becomes dozens of small dispatches +
copies. It's overhead-bound (~95 GFLOP/s regardless of shape),
not compute-bound. On top of that, matmul2d rounds activations
to bf16 → ~5% relative error vs the f32-activation NEON path.
**Decision: NOT wired into the families.** The wrapper stays as
a correct, tested primitive, but it currently loses to the CPU
path, so using it would make prefill slower and less precise.
To ever win it needs all three: (1) GPU-RESIDENT bf16 weights
(no per-call re-upload / per-block extraction), (2) all blocks
in ONE command buffer, (3) larger square tiles (256/512) — i.e.
essentially the correct NON-SQUARE kernel (one dispatch, no
tiling), which still needs Apple's host grid that I haven't
derived. Net deliverable to the substrate from the whole M5
matmul2d arc: **a fast correct SQUARE GEMM primitive, and zero
prefill acceleration.**
## matmul2d is SQUARE-ONLY — not usable for prefill yet (wave 87)
A second, harder correction (the wave-86 fix was still wrong).
A full-output **shape sweep** showed the kernel is correct ONLY
for **square M==N** (verified `WRONG=0` at 128², 192², 256²,
384², …, 2048² — including non-powers-of-2) and wrong for EVERY
non-square shape (128×256, 256×128, 192×128, 128×160 … all
~95% wrong). Wave 86's "correct for M≥128 & N≥128" was itself
based on shapes that happened to be square; it was still an
overclaim. The benchmarked ~2–5× throughput numbers stand —
but only for square GEMMs.
**Consequence: this cannot accelerate prefill.** Real
projection GEMMs are non-square (M = prompt tokens ≠ N =
hidden), so `matmul_bf16_mpp_bt` now requires M==N and **falls
back for all real prefill** — it provides zero acceleration to
the substrate today. The cause is MY grid/slice mapping, not
the MPP op: the op supports non-square (Apple's example does
128×64), but the correct host grid for the non-square case is
not in the example's README and I have not derived it. Both
`matmul_bf16_mpp` and the wrapper are constrained to square
M==N (M%64≥128, K%32) and documented as such; the backend test
asserts square-correct over the full output AND that a
non-square call is rejected.
Net honest status of the M5/Metal-4 matmul2d arc: a **correct,
fast SQUARE bf16 GEMM** (~4× simdgroup, portable M3+M5,
full-output verified) that is **not yet a general GEMM and not
yet useful to the loader**. Next requires the non-square host
mapping (Apple sample code with the dispatch grid, or a square-
tiling wrapper that blocks a non-square problem into square
sub-GEMMs — feasible but adds per-block dispatch + strided
weight slicing).
## matmul2d: full-output validation + prefill wrapper (wave 86)
A methodology correction and the loader bridge.
**Validation was corner-only — fixed.** Waves 84–85 verified
the matmul2d output by checking only the top-left 8×8 corner.
That was insufficient: a prefill-wrapper test at M=64 then
showed **96% of elements wrong**. Re-checking the FULL output
(every element) across the benchmarked shapes shows the wave-84
conclusion still HOLDS — `WRONG=0 / max_abs=0.0` at 256²×512,
512²×2048, 1024²×4096, and **2048²×4096 (all 4.2 M elements)** —
but the kernel is **correct only for M≥128 and N≥128** (with
M%64, N%32, K%32). Smaller M or N mis-tile and give wrong
results; the corner check happened to sit in the one region
that stays right. Bench, backend test, and wrapper test now all
assert over the full output. Lesson logged: GEMM correctness
must be checked across all tiles, never a corner.
**Prefill wrapper** `fabric_dispatch::matmul_bf16_mpp_bt`
(`out[M,N] = a[M,K]·w[N,K]ᵀ`): transposes the loader's `[N,K]`
weight to the `[K,N]` the kernel wants (once, cached by ptr),
pads M up to a multiple of 64, dispatches `matmul_bf16_mpp`,
copies the live rows back, and records the GFLOPs in the
attribution ledger. Returns false → caller falls back when the
fabric is off, the GPU member is uncertified, the shape is
outside M≥128/N≥128, or the device is pre-Metal-4. Tested
against the NEON oracle over the full output (looser tolerance:
matmul2d rounds activations to bf16, a deliberate
precision-for-speed trade). Not yet called by the families —
wiring it into a real prefill path + measuring is next.
## M5 Neural-Accelerator matmul: CRACKED — correct + ~5× (wave 84)
The matmul2d GEMM now works, bit-exact, on the M5 GPU Neural
Accelerator (`mpp_gemm_bench.rs`). The unblock came from Apple's
own example (github liuliu/example_matmul_metal4, found by web
search) and was a single wrong token: the descriptor's K must be
**`dynamic_length_v<int>`**, NOT `dynamic_extent`/`0`. The wrong
value silently caps the K-sum at one ~32-wide tile (the bug that
defeated every header-derived attempt in waves 83–83d). The rest
of the recipe is plain: standard `C[M,N]=A[M,K]·B[K,N]` (no
transpose, `tensor_inline` over the raw buffers), `slice()` for
the tile origin, ONE `run()` that loops K internally,
`execution_simdgroups<4>`, descriptor tile `(64, 32)`.
The layout was pinned with single-element probes (set one input
element to 1, see where the output lights up): A is effectively
`[M,K]`, B `[K,N]`, C `[M,N]`. Constraints: M%64, N%32 (N must be
≥128 — N=64 mis-tiles), K%32; M=1 decode is NOT a valid shape, so
this is a **prefill / batched** accelerator, not a decode-GEMV one
(consistent with the wave-67 finding that decode is bandwidth-
bound).
**Measured on M5 Max, every shape `max_abs = 0.0` (bit-exact vs
the f32 CPU reference over bf16 inputs):**
```
matmul2d (Neural Accel) simdgroup_matrix (Metal 3)
512²×2048 ~2.3 TFLOP/s
1024²×4096 ~9.3 TFLOP/s
2048²×4096 ~16–25 TFLOP/s ~4.8 TFLOP/s (peak)
```
So **beyond-2× is real: ~2–5× the simdgroup path depending on
shape, and bit-correct** (throughput varies with box load). This
SUPERSEDES the withdrawn wave-83 figures, which came from
partial-K (incorrect) kernels — these numbers are from a kernel
verified `max_abs = 0.0`.
**Correction to the "Neural-Accelerator" framing: this is a
Metal-4 win, not an M5 exclusive.** The same kernel runs
bit-correct on the **M3 Ultra** dev box at ~13–20 TFLOP/s — the
same ~4× over its own simdgroup ~4.8. So the headline win is the
**MPP `matmul2d` tensor-op path itself, portable across M3 Ultra
and M5 Max**, not specifically the M5 GPU Neural Accelerators.
Whether the M5 NA adds anything *beyond* M3's matrix units is NOT
shown by these loaded-box numbers (M3 ~13–20, M5 ~16–25 TFLOP/s
overlap within run-to-run noise) and would need a quiet-box A/B
to isolate. The portability is good news: the win applies to the
whole Apple-silicon Mac fabric with Metal 4, including the dev
box where tests run — not stranded on the one M5 host.
Not yet wired into the loader. Next: a bf16-native resident
prefill path that dispatches the projection GEMMs through this
kernel (and a real µJ/GFLOP calibration of it), then attribution.
## M5 Neural-Accelerator matmul: reachable + fast, not yet correct (wave 83)
Chasing "beyond 2×" on the M5 (wave 82 got 2× for free on the
existing f16 MPS path). Findings, in `backend-apple/examples/`:
- **bf16 `simdgroup_matrix` GEMM** (`bf16_gemm_bench.rs`):
bit-correct (max_rel 0.0, f32 accumulation), but **ties M3**
— ~4.78 TFLOP/s on M5 vs ~4.80 on M3 Ultra. So
`simdgroup_matrix` is the Metal-3 matrix-unit path and does
NOT engage M5's GPU Neural Accelerators. A real, correct,
bf16-native kernel — just not the accelerator.
- **The accelerator IS the Metal-4 Metal-Performance-
Primitives path** `mpp::tensor_ops::matmul2d`
(`mpp_gemm_bench.rs`). Confirmed reachable from our pure-Rust
+ runtime-MSL stack; the full API was mapped by reading the
on-device framework headers
(`matmul2d_descriptor(M,N,K,transL,transR,relaxed,mode)` with
M or N a multiple of 16, `execution_simdgroups<N>`, tensor
`slice<>()`, `run`, cooperative tensors with explicit
zero-init).
- **Contraction DECODED + verified correct at a square tile**
(`mpp_diag.rs`, one-hot + full-K probes): the op computes
`C[a,b] = Σ_c L[a,c]·R[c,b]` (L's dim1 contracted against R's
dim0). For `C[m,n]=Σ_k A[m,k]·B[n,k]`: L = A`[M,K]`, R = Bᵀ
via `transpose_right` on the `[N,K]` weight, C `[M,N]`. At
32×32×32 the output is exact (within bf16 rounding).
- **Non-square (K≠M): layout SOLVED, full-K reduction NOT.**
Two corrections to earlier drafts, both from reading the
actual on-device headers:
- `static_slice` is **fiction** — it appears only in Apple's
MPP example *comment*, never in `metal_tensor`. The real
primitive is templated **`slice<Extents...>(indices...)`**:
the template params ARE the static tile extents. So the
earlier "needs host `MTLTensor`/`tensor_handle`" framing
(waves 83b/83c) was WRONG; and there is no `setTensor` in
the Metal API at all, so the "binding-layer blocker" was a
misdiagnosis.
- With templated `slice<TM,K>` on full in-kernel tensors and
the correct layout (A`[M,K]` + `transpose_right` on
B`[N,K]` → C`[M,N]`), a SINGLE `run()` produces the right
dominant output structure at non-square (a one-hot A row
lands in the right C row) — but the K-sum **caps at one
~32-wide hardware tile** (full-K-512 test gave 32, not 512)
and bleeds into neighbouring rows. Multi-`run()` K
accumulation — both device-memory output and the
cooperative-tensor idiom (zero-init → loop → store) —
yields all-zero.
- Net: the contraction/layout is understood and correct for
K ≤ one tile; the multi-tile K-reduction idiom is the
unresolved core and could NOT be derived from headers
alone. Next step needs Apple MPP sample code / docs — not
more blind kernel iteration, and not binding work.
**CORRECTION to an earlier draft of this entry:** the
"~7.4–12 TFLOP/s, beyond 2×" figures were measured on kernels
that did NOT sum the full K (partial work), so they OVERSTATE
throughput and are withdrawn. A valid beyond-2× number needs
the correct tiled kernel (host `MTLTensor`) first. What is
honestly established: the accelerator path is reachable and
the matmul is correct at a square tile; the throughput win is
plausible but not yet validly measured.
Honest status: reachable + semantics solved + correct at a
square tile; correct non-square tiling and a valid throughput
number are the remaining work. Not integrated into the
substrate.
## Cross-silicon: the substrate on an M5 Max (wave 82)
The pure-Rust substrate is portable across Apple-silicon
generations with zero code changes. Cloned onto an M5 Max
(40-core GPU, 128 GB, macOS 26.5.1, Metal 4), it builds in
~30 s and decodes Falcon-H1-0.5B **token-identical** to the
M3 Ultra dev box — at **~42 ms/step vs ~84 ms**, a clean 2×
on identical code. The existing MPS f16 path already rides
M5's faster GPU; no `MTLTensor` / BF16-native / Neural-
Accelerator work was needed to get the speedup.
Measured M5 calibration (IOReport, idle-subtracted, **0.67 W
idle** — a genuinely quiet box):
```
prefill-batch (m=16) decode-gemv (m=1)
local/metal-gpu 21.8 mJ/GFLOP 118 mJ/GFLOP
local/amx 107 mJ/GFLOP 941 mJ/GFLOP
local/cpu-neon 1031 mJ/GFLOP 1150 mJ/GFLOP
```
Falcon-0.5B decode attribution on M5 (measured): GPU's
9.5 GFLOP costs **0.2 J**, CPU's 17.6 GFLOP costs 18.1 J —
the GPU member is ~50–90× more efficient per GFLOP, which is
the whole reason the placer routes the big GEMMs there.
**Honest caveat — NOT a fair energy A/B yet.** The standing
M3 Ultra calibration was captured on a LOADED box (100 W
idle); idle-subtraction over a 100 W baseline is a different
regime than over 0.67 W. The 2× LATENCY result is clean
(same binary, both boxes), but a clean energy comparison
needs a quiet M3 Ultra re-calibration. Recorded as a number
to earn, not a number to quote.
The MTLTensor/BF16-native lift is now "can we go further on
M5," not "is M5 usable" — the floor is already 2×.
## The ledger was under-crediting the GPU (wave 81b)
The attribution counted only the four GEMM weights in the
attention block — the on-device `attn_decode` compute (score
= q·kᵀ over the cache, softmax, weighted sum over v) was real
GPU work credited to nobody. ≈ 4·n_q·hd·seq flops per layer
per token, now recorded as two GEMM-equivalents of
`(n_q, seq, hd)` so the same measured µJ/GFLOP prices it.
Magnitude is small at short context (North 48-tok: GPU
302.7 → 303.8 GFLOP, share 87.7 → 87.8 %) but it grows
linearly with sequence length, and silently dropping it
inflated the CPU's apparent share in every prior wave's
numbers. Honest accounting over flattering accounting.
## Attention block reaches Granite-H — all five families on-device (wave 81)
Granite-H is the last family in. Its attention is NoPE with
a muP `attention_multiplier` folded into a q pre-scale
(`q × attention_multiplier·√d` so the decode kernel's `1/√d`
cancels to `(q·k)·multiplier`). That is neither a rope nor a
norm — it's a fourth qk stage, `ScaleQk { q_scale, k_scale }`,
a one-line MSL kernel that scales q and k per kind with no
rotation. Granite's 4 attention layers (the rest are Mamba)
run as one command buffer each; the device cache coexists
with the MoE expert windows that already owned most of this
model's GPU GFLOPs.
Verified token-identical on BOTH micro (dense) and h-tiny
(MoE), baseline vs fabric. h-tiny attribution:
```
local/cpu-neon: 208 ops, 7.4 GFLOP (16.9%), 28.1 J projected
local/metal-gpu: 15376 ops, 36.7 GFLOP (83.1%), 5.9 J projected
```
**The qk-stage taxonomy is now closed over the set** — every
family's attention front-half is one of:
| stage | rope | norm | scale | family |
|-----------------|-----------------|------|------------------|--------------|
| `Skip` | none (NoPE) | no | none | NemotronH; Cohere2 full layers |
| `NormRotateHalf`| rotate-half | yes | none | Mellum |
| `InterleavedRope`| interleaved | no | none | Cohere2 sliding |
| `RotateHalf` | rotate-half | no | q/k post-scale | Falcon-H1 |
| `ScaleQk` | none | no | q/k scale only | Granite-H |
Five MSL kernels back the whole-block path: {silu·mul,
rmsnorm, qknorm_rope, rope_interleaved, rope_rotate_half,
scale_qk, kv_append, attn_decode}. Each rope/scale variant
has a ring-wrap CPU-reference test (backend suite now 18).
## Attention block reaches Cohere2/North — interleaved rope (wave 79)
The qk stage of the fused block is now a three-way enum:
`Skip` (NoPE), `NormRotateHalf` (Mellum), and a new
`InterleavedRope` — a second small MSL kernel implementing
the Cohere convention (pair `(x[2i], x[2i+1])` rotates by
`position · inv_freq[i]`, no per-head norm). Cohere2/North
adopts the block on ALL its layers: sliding layers ride the
ring cache with interleaved rope; full-attention layers are
NoPE and use `Skip`; `force_rope` dense-prefix layers follow
`uses_rope` per layer. RoPE is absolute and applied before
caching, so the device ring IS the sliding mask — the same
argument that justified the CPU shadow's front-trim.
Correctness pinned two ways: a ring-wrap CPU-reference test
of the interleaved path (backend suite), and
live North Mini Code 30B-A3B — token-identical to the CPU
baseline, coherent 48-token completion. Attribution:
```
local/cpu-neon: 8544 ops, 42.3 GFLOP (12.3%), 159.7 J projected
local/metal-gpu: 61452 ops, 302.7 GFLOP (87.7%), 48.9 J projected
```
Wall-clock: early steps pay the one-time f16 residency
conversion of each layer's q/k/v/o (first ~20 steps climbed
to ~1.6 s); steady state settles to ~356–488 ms vs ~393 ms
CPU-only on the same loaded box — wall parity, at a third of
the projected joules. The MoE expert windows already owned
most of this model's GPU GFLOPs; wave 79's win here is
energy, not latency.
Falcon-H1 remains the one family outside the block: its μP
multiplier suite scales q/k/v outputs per weight, which the
window has no hook for yet (cleanest path: per-job alpha in
the cached MPS kernels, keyed by (m, n, k, alpha_bits)).
## Attention block reaches NemotronH + the encode race (wave 78)
The wave-77b fused block generalizes: the qk-norm+rope stage
is now `Option`al, and NemotronH — NoPE, no QK-norm — is the
first adopter (`qk = None`: the rope kernel never encodes,
q/k flow raw into the cache). Its 4 attention layers each run
as one command buffer against a device-resident f16 KV cache;
non-rolling, so at device capacity the layer retires to CPU
and the always-maintained shadow carries on with full history.
Live (Nemotron-3-Nano-4B, loaded box): token-identical
output, ~80-85 ms/step fabric-on vs ~91 ms CPU-only —
```
local/cpu-neon: 37 ops, 7.9 GFLOP (17.6%), 29.9 J projected
local/metal-gpu: 423 ops, 37.0 GFLOP (82.4%), 6.0 J projected
```
GPU ops = 4 attn layers × 4 weights per step + prefill GEMMs;
the placer correctly keeps the sub-GFLOP Mamba/MLP GEMVs on
CPU (below the measured ~20 GFLOP solo crossover).
**Env-knob footgun, recorded the hard way**: the first run
inherited `JOULECLAW_PAR_MIN_WEIGHT_MB=128` (right for Mellum
on a loaded box, where the GPU carries nearly everything) and
clocked ~380 ms/step — the knob had single-threaded all of
NemotronH's sub-128 MB Mamba/MLP weights, which here are most
of the per-token CPU work. Same binary without the override:
~83 ms. The knob tunes the CPU remainder, and the remainder's
share differs per family.
**Parallel-encode race fixed**: backend tests SIGSEGV'd when
the harness ran them on multiple threads — bisected to
concurrent ENCODE of cached MPS kernel objects (the mutexes
guarded the cache MAPS, not the kernels; pre-existing in
77b, reproduced on the unmodified tree). One `encode_lock`
in the context now serializes encode+commit at every
dispatch entry. Free in production (single dispatcher);
5× parallel suite runs green.
## The whole attention block on-device (wave 77b)
Mellum's decode attention is now ONE command buffer per
layer: rmsnorm → q/k/v GEMMs → qk-norm+rope → KV append →
attention → o_proj, against DEVICE-RESIDENT f16 KV ring
caches (rolling on the 21 sliding layers, capacity-bounded
on the full layers). Two new kernels: kv_append and a
two-pass-softmax decode attention (one threadgroup per
q-head, GQA mapping, score vector in threadgroup memory,
ring indexing). The kernel set: {silu·mul, rmsnorm,
qknorm_rope, kv_append, attn_decode}.
Discipline that keeps every tier correct mid-stream: the
CPU shadow cache is appended from the readback every token
(k/v rows ≈ 4 KB); after a CPU prefill the device cache
BACKFILLS from the shadow (direct shared-memory writes);
any fused failure marks the layer dead for the session and
the CPU tiers continue seamlessly. Correctness pinned by a
multi-token CPU-reference test INCLUDING a ring wrap.
Live (Mellum, loaded box): identical text, steps down to
~155 ms between load spikes, attribution:
```
local/cpu-neon: 2879 ops, 24.0 GFLOP (26.4%), 90.4 J projected
local/metal-gpu: 11776 ops, 66.7 GFLOP (73.6%), 10.8 J projected
```
Per decode layer: one attention cb + one MoE cb. The CPU's
remaining per-token work: residual adds, router top-k,
embedding gather, sampling — and the receipt.
## Attention front half on-GPU (wave 77a)
Third compute kernel: fused per-head QK-norm + rotate-half
RoPE (one threadgroup per head; the inv_freq table is a
device buffer, so YaRN rides for free). Mellum's window is
now `[rmsnorm → q/k/v GEMMs → qk-norm → rope]` — ONE
command buffer, q and k returning ready-to-cache. Verified
live: text and attribution identical to wave 76; chain
correctness pinned vs a CPU reference (GEMMs + per-head
norm + rotation).
Kernel set: {silu·mul, rmsnorm, qknorm_rope}. The remaining
CPU steps per attention block are the KV append, the
attention kernel itself, and o_proj — the wave-77b targets
(device-resident KV cache is the prerequisite).
## RMSNorm joins the GPU — norm→q/k/v in one cb (wave 76)
Second compute kernel on the GPU member: a single-
threadgroup tree-reduction RMSNorm in MSL, writing the
normed activation as f16 in place — the layer input
uploads ONCE as f32 and the q/k/v GEMMs read the on-device
normed value directly. One command buffer for
norm → projections; the CPU-side normed buffer and all
three activation casts disappear from the fused path.
Correctness pinned vs a CPU reference of the chain;
three-tier fallback intact (norm window → CPU norm +
wave-71 window → CPU batch).
Live (Mellum, loaded box): correct text, decode dipping to
~190 ms/token between load spikes, GPU at 64.8% of GFLOPs
for 9.5 J projected vs CPU 120 J.
The kernel set is now {silu·mul, rmsnorm}; each addition
widens the single-cb span. Remaining for whole-layer-on-
device: QK-norm, RoPE, attention, and keeping h resident
across the residual adds.
## Whole-layer MoE window — one command buffer (wave 75)
`gemm_moe_window_f16`: gate/up GEMMs → an on-GPU silu·mul
kernel (12 lines of MSL, writing f16 so the down-GEMMs read
it in place) → down GEMMs, all in ONE command buffer. The
intermediate never returns to the CPU; the only readback is
the per-pick output rows. Mellum tries this first and falls
back to the two-window path, which itself falls back to the
CPU batch scope — three tiers, all bit-or-tolerance
verified.
Live: identical text and identical attribution to wave 72
(same op counts, half the command buffers per MoE layer);
the cb-merge gain is within load noise on this box today —
recorded as structure shipped, wall-clock claim deferred to
a quiet-box measurement.
## Fused windows complete the family set (wave 74)
NemotronH's attention layers get the q/k/v window; and
Falcon-H1's PARALLEL topology yields the widest window in
the set — in_proj + q/k/v, FOUR ops off the same layer
input (with their distinct μP pre-scales as per-job
activations), one command buffer. Falcon's tiny 0.5B
projections sit below the 2 MiB window floor on this
checkpoint, so its window lowers to the single CPU batch
scope — the structure is in place for the larger Falcon-H1
sizes where the floor passes. Both verified live with
their established outputs (Nemotron " Paris.\"] output",
Falcon " Paris. It is known for its historical").
All five families now share the fused-window decode shape
wherever their topology allows; dependent projections
(o_proj, out_proj, MLP down) remain sequential by data
flow — a GPU-side activation/attention op is the lever
that would widen windows further.
## Fused windows reach all three MoE families (wave 73)
Cohere2 (North) and Granite h-tiny's decode expert stages
lower onto the same `matmul_bf16_auto_multi` windows as
Mellum — per-tensor experts (North) and fused-tensor expert
slices (Granite) both ride the path; the fabric-off
fallback stays the wave-63 single CPU batch scope, bit-for-
bit. Verified live: h-tiny " Paris. Paris is the largest
city in France…" (~180–220 ms/token under fabric), North
" Paris, and" (decode improving as residency warms; its
1.5 MB down-projections sit below the 2 MiB window floor —
partial coverage, recorded rather than hidden).
## Expert FFNs join the fused windows (wave 72)
Mellum's decode MoE stages — 16 gate/up GEMMs, CPU silu,
8 down GEMMs — lower onto `matmul_bf16_auto_multi`: two
GPU command buffers per layer when the experts are F16-
resident, falling back to the wave-63 single CPU batch
scope otherwise (the fallback preserves that shape exactly
— never one dispatch per job).
Steady state (loaded box, fabric ON, 24 GiB residency,
24 tokens):
```
decode ~270–460 ms/token (load-dependent)
local/cpu-neon: 3747 ops, 36.2 GFLOP (28.7%), 136.5 J projected
local/metal-gpu: 17412 ops, 89.9 GFLOP (71.3%), 14.5 J projected
```
71% of the work on the GPU for ~10% of the projected
energy — the attribution line the fabric arc was built to
produce. Early tokens pay the one-time expert residency
conversion (~22 GiB f16 across 64×3×28 matrices, lazily on
first routing). Remaining knob: two cbs per MoE layer
(silu splits the stages); a GPU activation op would fuse
them to one.
## Fused dispatch windows — q/k/v in one command buffer (wave 71)
`gemm_bt_resident_f16_multi` encodes MANY GEMMs into ONE
command buffer; `matmul_bf16_auto_multi` is the fused
dispatch window (all-or-nothing F16 residency, 2 MiB floor
for window members vs 64 MiB for solo ops — the cb cost
amortizes across the window). Mellum's decode q/k/v — three
projections off the same normed input — ride one cb.
Live (loaded box, fabric ON, floor 128):
```
decode: ~161 ms/token (arc start, wave 62: 2.8 s — 17×)
local/cpu-neon: 2851 ops, 27.6 GFLOP (77.0%), 104.1 J projected
local/metal-gpu: 596 ops, 8.2 GFLOP (23.0%), 1.3 J projected
```
THE BUG THE BRING-UP CAUGHT: first fused run produced
fluent garbage (" Estab Estab…"). Not the cb batching — a
same-shape-twice-in-one-cb test exonerated kernel-object
reuse — but the hand-rolled f32→f16 SUBNORMAL encoder
shifting 10 bits too far: every weight in [6e-8, 6.1e-5]
(plentiful in q/k/v, absent in lm_head, which is why wave
70 looked fine) collapsed to garbage. Now pinned by
known-answer bit patterns including the subnormal range.
Also: the F16 upload no longer REFUSES tensors for
sub-subnormal stragglers — they flush to zero (bounded
6e-8 absolute, standard f16 underflow); refusal is
reserved for overflow.
## Half-width GPU residents + the BF16 negative result (wave 70)
Context: WWDC26 / the M5 cycle put BF16 tensors front and
center (Metal 4 `MTLTensor`, `MTLTensorDataType.bfloat16`,
M5 GPU Neural Accelerators unlocked in macOS 26.2, ~4× M4
LLM peak via MLX). This box is an M3 Ultra on macOS 26.5 —
no Neural Accelerators, but the API surface applies.
PROBED FACT (cost a SIGABRT to learn): classic
`MPSMatrixMultiplication` hard-asserts on BF16 inputs —
"Input data type must be one of Float32, Float16, Int8,
Int16". BF16-native GEMM lives on the Metal-4
MTLTensor / MPSGraph surface (future bindings lift), and
the assert is an abort, NOT a catchable ObjC exception:
capability on this API is gated by knowledge, not
probe-by-attempt.
The assertion message hands over the win available today:
**F16 residents.** Weights convert BF16 → F16 once at
upload — EXACT in range (bf16's 7 mantissa bits widen into
f16's 10; the only approximation in the path is the per-call
f32→f16 activation cast) — for HALF the resident bytes and
half the per-call GPU streaming of the f32 path. Conversion
REFUSES any weight outside f16's faithful range
(|v| > 65504 or < 2⁻²⁴) and the loader falls back to the
f32 resident — honest fallback, never silent truncation.
Mellum2 lm_head resident: 0.9 GiB f32 → 0.45 GiB f16,
identical generated text, GPU certification tolerance
unchanged (the strict abs+rel check still passes).
When M5 hosts join the fleet, the Metal-4 tensor path is
the upgrade that reads checkpoint BF16 with no conversion
at all — and lights the Neural Accelerators. The fabric's
probe-per-host doctrine already handles the heterogeneity.
## Fabric attribution — the run carries its breakdown (wave 68)
Every dispatched matmul records (member, GFLOPs) in an
attribution ledger priced by the SAME calibration figures
the placer used — attribution and placement cannot drift
apart. `ledger_snapshot()` is serializable; the decode
examples print it per run.
First live attribution (Mellum2, fabric ON, 6 tokens):
```
local/cpu-neon: 3159 ops, 29.8 GFLOP (91.6%), 112.5 J projected
local/metal-gpu: 6 ops, 2.7 GFLOP ( 8.4%), 0.4 J projected
```
The receipt itself shows the next lever: the GPU carried
8.4% of the work for 0.3% of the projected energy — the
~3 000 small CPU ops (experts, q/o at m=1) are where the
joules live, and they need command-buffer batching before
the GPU can take them (one cb per 4-MFLOP expert op would
cost more than it saves; the ledger is how we know).
## Measured placement — the fabric runs on this machine's joules (wave 67)
`fabric_calibrate` (--features rayon,fabric-meter) runs the
same BF16 matmul workloads on every serving member under
the IOReport SoC counter (idle-subtracted — a MODEL on a
hardware reading; the tool prints the caveat and records
the method in the output file). `Fabric::discover()` loads
`~/.jouleclaw/fabric-calibration.json` and overlays the
measured µJ/GFLOP as `Measured` provenance — placement
runs on this machine's numbers, not a table written for
another one.
First calibration (loaded box — idle read 100 W with
parallel sessions running; RATIOS are the signal):
| op | member | ms/op | mJ/op |
|---|---|---:|---:|
| decode GEMV (m=1, lm_head class) | NEON | 43.0 | 924 |
| | AMX | 73.3 | 2 375 |
| | **GPU (resident)** | **2.8** | **242** |
| prefill batch (m=16) | NEON | 28.5 | 1 140 |
| | AMX | 6.2 | 262 |
| | **GPU (resident)** | **1.4** | **49** |
Wave 66's open question, answered by a meter: the GPU wins
decode GEMV on BOTH axes — 15× wall-clock AND 3.8× joules
vs NEON — and prefill by 20×/23×. AMX is honestly the worst
decode member (the per-call widen) and a solid middle for
batch. The energy-optimal member was the GPU all along;
now the placer knows it from measurement, not assertion.
## Metal GPU member certified + placed (wave 66)
The third local fabric member goes live. `backend-apple`
gains a RESIDENT-WEIGHT path: cached device+queue, weights
widened once into shared MTLBuffers (UMA — the GPU reads in
place, results are CPU-visible at command-buffer
completion), `transposeRight` GEMM so weights stay in their
natural `[n,k]` layout, and — the fix that mattered —
**MPS kernel objects cached per shape**: building
`MPSMatrixMultiplication` per call cost ~0.9 s on a loaded
box and single-handedly erased the GPU win in the first
cut. Encode is cheap; pipeline setup is not.
Certification: `gpu_member_matches_neon_oracle` (same
abs+rel contract as AMX). `jouleclaw-fabric` flips
`local/metal-gpu` to ToleranceCertified citing it, and the
placer's GPU dispatch floor is CALIBRATED to the measured
~20 GFLOP AMX↔MPS crossover (~100 mJ effective — the
placer must reproduce the measured crossover or it is
fiction). An uncertified-member synthetic test pins that
the gate stays structural.
Loaded-box A/B (fabric OFF → ON), Mellum2, identical text:
| | OFF | ON |
|---|---|---|
| prefill (6 tok) | 15.4 s | 6.7 s (2.3×) |
| steady decode | ~1.2 s/tok | ~1.2 s/tok (neutral) |
Decode wall-clock is NEUTRAL under load with the lm_head
resident on GPU — the decode question is now JOULES at
equal latency (GPU GEMV at ~20 W vs 10 CPU cores at ~5 W
each), which requires the IOReport meter wave to answer
honestly. No claim until measured.
## Fabric placement — the forwards become hardware-aware (wave 65)
`jouleclaw-fabric` gives the runtime its compute awareness:
detect local silicon (NEON / AMX / Metal — probed live, with
honest verified flags), enumerate remote reach from
credentials (CUDA fleets, LPU/WSE/RDU/Tenstorrent — presence
of keys, never claimed as verified), and place each op by
projected µJ. All five model families' projections now route
through `fabric_dispatch::matmul_bf16_auto`; with
`JOULECLAW_FABRIC=1` (+ `--features fabric`), batch-shaped
matmuls run on the AMX matrix unit via Accelerate
`sgemm_f32_bt`, with NEON row-major BF16→f32 tile widening.
Same-load A/B (fabric OFF → ON):
| model prefill | OFF | ON | gain |
|---|---|---|---|
| Granite h-micro, 15 tok | 5 992 ms | 2 949 ms | 2.0× |
| Mellum2, 6 tok | 12 567 ms | 4 396 ms | 2.9× |
Identical generated text. Numerics contract is per-member:
NEON stays the bit-exact oracle (fabric OFF default is
byte-identical to wave 64); the AMX member is certified
against the oracle to abs+rel tolerance — sgemm accumulation
order differs, and the docs say so instead of pretending.
Lesson the first attempt taught: a scalar TRANSPOSED widen
made fabric-ON 2.3× SLOWER than no AMX at all — strided
stores ate the GEMM win. NEON row-major widen +
transposed-B sgemm (`sgemm_f32_bt`) flipped it to 2-3×
faster. The widen is the product; the GEMM is table stakes.
## Decode dispatch floor (wave 62)
Mellum2 decode exposed a dispatch pathology: ~800 threaded
matmul calls per token (24 small expert projections × 28
layers + attention), each paying a rayon pool wakeup. On an
OVERSUBSCRIBED box (parallel build/agent sessions; wakeups
balloon to ~25 ms of sys time each under park/unpark
contention) that overhead dwarfed the math:
| config | Mellum2 decode |
|---|---|
| 10 threads, no floor | ~2.8 s/token (21.5 s sys vs 8.1 s user) |
| RAYON_NUM_THREADS=1 | ~650 ms/token |
| 16 MiB floor (new default) | small expert matmuls inline |
| `JOULECLAW_PAR_MIN_WEIGHT_MB=128` | **~0.5 s/token** (only lm_head fans out) |
`matmul_bf16_threaded` now runs single-thread NEON inline
below a weight-bytes floor (default 16 MiB ≈ 1 ms of
single-core streaming — an order of magnitude above a
healthy dispatch, so quiet-box behavior of the existing
families is unchanged; their big projections stay
threaded). On a loaded box raise the env knob. All numbers
above were measured under heavy external load — treat them
as the loaded-box regime, not the model's ceiling.
## Mellum2 / JetBrains 12B-A2.5B (wave 61)
Fifth topology consumer: JetBrains/Mellum2-12B-A2.5B-Instruct
(released 2026-05-26; Qwen3-MoE attention lineage + Laguna
block per the reference modular file). New ground vs the
North bring-up: per-head **QK-norm** before RoPE,
**per-layer-type RoPE** — YaRN (factor 16 over an 8192
window, explicit attention_factor on cos/sin) on the 7
full-attention layers, default RoPE on the 21 sliding
layers (window only 1024) — and the **Qwen3 gating
lineage**: softmax over all 64 router logits → top-8 →
renormalize. Every layer is sparse. YaRN inv_freq blend is
spot-checked against the reference formula in tests;
prefill parity is bit-identical across the window trim AND
the YaRN layer.
Smoke: "The capital of France is" → " Paris. \nParis is
located in". ~2.7 s/token cold off the external USB drive
(22.6 GiB, 5 shards); warm decode untested.
Tokenizer: second pre-tokenizer gap in two waves — Mellum
uses `Digits` (isolate each digit) + `ByteLevel
{ use_regex: true }` (the GPT-2 split applied internally)
instead of explicit `Split` entries. Both now map onto the
chained-split machinery from wave 60.
## North Mini Code on the CPU: decode 3.3–5.2× and prefill 5.9–10.6× against base at its default thread count, bit-identical (2026-11)
The pure-Rust CPU path (`--features rayon`) of North Mini Code 1.0
(Cohere2-MoE, 30B total / 3B active, BF16 safetensors), made
faster without changing a bit of any logit. Measured with
`examples/north_bench.rs` (prints every step's token id and a hash
of all 262144 logits' bits), base = `main` before this change. The
ratios in the title are median against median in the table under
**Speed**, with base at rayon's default thread count (every logical
CPU) and the new code at its own default; that section also gives
base at 12 threads and says what was not compared.
**Where the time went** (`JOULECLAW_PROF=1`, base, M3 Ultra under
load, ~600-token prompt): prefill 45.3 s = attention 36% (every
head on one thread, a scalar add chain per score), experts 50%
(one expert after another), q/k/v + o 11%; decode 212 ms/token =
attention 26%, q/k/v 20%, o_proj 20%, experts 28%, lm_head 3%.
lm_head streams its 1 GB in ~7 ms (~140 GB/s) while each layer's
16–50 MB projections moved 12–40 GB/s: decode was bound by its ~7
dispatches per layer (three of them single-threaded GEMVs) and a
GEMV kernel held to ~16–20 GB/s per core by its two accumulator
chains, not by memory bandwidth. Base decode under load was also
fastest at 8 threads (94 ms) and slowest at the default 32
(174 ms).
**What changed** (each output keeps its exact reduction order):
- the BF16 kernel computes blocks of 4 activation rows × 2 weight
rows, 2 × 4 or 1 × 4 (up to 16 accumulator chains instead of 2)
with a 1 KiB prefetch;
- decode runs three dispatches per layer (q/k/v + router;
attention heads + expert gate/up; o_proj + expert down) of
~256 KiB row chunks on a pool whose threads stay awake between
dispatches (`spin_pool`);
- attention runs per head (decode) and per (head, 16 query rows)
(prefill) in parallel, with a bit-exact NEON score kernel;
- prefill runs the experts in parallel and the projections as
64 × 64 output tiles; RoPE angles are computed once per position;
- `forward_extend` batches tokens into a state that already holds
a context, bit-identical to feeding them one decode step at a
time;
- threads: one per performance core, at most 16
(`JOULECLAW_THREADS` overrides).
**Identity.** Five prompts (a raw completion, Python, OpenQASM, a
596-token and a 2698-token Rust prompt), 72 greedy tokens each:
the same token ids and the same hash of every step's logits as
base, on an M3 Ultra and on an M4 Max, and the same on both
machines; every repeat of the timing runs below gave that one hash
sequence too. Unit tests pin the kernels and both forward passes
against the old code bit for bit.
**What is re-checked without the checkpoint.**
`src/cohere2_moe_reference.txt` holds logit hashes of three synthetic
Cohere2-MoE checkpoints, generated by base's `forward_prompt` and
`forward_token` (with its one-row BF16 kernel) on macOS arm64, Linux
arm64 and Linux x86_64. The three platforms give different bits, so
each has its own lines. One checkpoint is wide enough that decode
splits its projections into several row chunks and prefill runs the
2-D tiles. The test `stored_reference::logits_match_the_stored_reference`
recomputes the hashes with the reference passes, the CPU passes and
`forward_extend`, at the default thread count and at 1 and 3 threads.
CI runs it in the Linux arm64 workspace job. That job's container has
glibc 2.35, and the Linux arm64 lines were made with 2.39: with 2.35's
libm loaded in its place on the host that made them, the test gives
the stored lines too, and CI's own first run of it was pending when
this was written. The macOS lines are checked by running the tests on
a Mac. `examples/cohere2_reference.rs` generates the lines, at commit
30c4cf1ee, for a platform not yet in the file. **What still needs the checkpoint:** identity at North's
own sizes (hidden 2048, 128 experts with 8 routed, a 262144-row
lm_head, contexts past the 4096-token window) and on the five prompts
above, whose text and hash sequences are not stored, and every speed
figure here. The per-run logs are not in the repository.
**Speed.** Medians of 4 runs (the 72-token identity run and three
48-token timing runs; base at 12 threads had only the three), base
and new interleaved in the same conditions, each at its default
thread count unless the column says otherwise: base at rayon's
default, every logical CPU (32 on the M3 Ultra, 16 on the M4 Max),
the new code at one thread per performance core (16, its cap, on the
M3 Ultra; 12 on the M4 Max). M3 Ultra (24P+8E): shared, load average
30–130, with another North benchmark and CI running. M4 Max
(12P+4E): lightly loaded (load average 7–29).
| | M3 Ultra base, 32 thr | M3 Ultra base, 12 thr | M3 Ultra new, 16 thr | M4 Max base, 16 thr | M4 Max base, 12 thr | M4 Max new, 12 thr |
|---|---|---|---|---|---|---|
| decode at ~600 context | 267 ms | 168 ms | **54 ms** | 116 ms | 106 ms | **35 ms** |
| decode at ~2700 context | 405 ms | 364 ms | **80 ms** | 246 ms | 224 ms | **48 ms** |
| prefill, 596 tokens | 9.9 tok/s | 12.0 tok/s | **91 tok/s** | 17.8 tok/s | 19.8 tok/s | **105 tok/s** |
| prefill, 2698 tokens | 5.9 tok/s | 4.5 tok/s | **63 tok/s** | 9.0 tok/s | 9.2 tok/s | **79 tok/s** |
Paired run-for-run ratios against base at its default thread count
(base / new, all four pairs of each cell). M3 Ultra decode 4.3–4.8×
at ~600 and 5.0–6.6× at ~2700, apart from one pair in each column,
20.2× and 21.2×, where base's run took 3.6× and 5.8× its median
step (967 ms at load 71, 2358 ms at load 131); those two runs are
inside the table's medians.
Prefill 5.4–10.3× and 9.7–11.8×. M4 Max decode 3.3–3.5× at ~600
apart from the identity run's pair, 2.0× (the new code's median step
was 59.7 ms in that run, 34–37 ms in the others), and 5.1–6.3× at
~2700; prefill 5.2–6.8× and 7.0–9.0×.
Against base at 12 threads (the three timing pairs): M3 Ultra decode
2.9–3.8× and 3.3–5.8×, prefill 5.9–10.6× and 11.9–14.1×; M4 Max
decode 3.1× and 4.5–5.2×, prefill 5.8–6.1× and 8.4–9.5×. Base at 8
threads, its fastest in the thread sweep above (94 ms), was not run
in pairs with the new code: against this table's 54 ms that is about
1.7×, but measured under other load, so it is not a ratio this
benchmark establishes.
A 69-token follow-up turn fed into a ~630-token context
(`north_bench --then` and `--then-seq`): `forward_extend` 1.23 s
(M3 Ultra) / 0.79–0.80 s (M4 Max), against 3.3–4.1 s / 2.4 s one
`forward_token` at a time on the new code, and an estimated ~18 s at
base's decode speed (69 × its 267 ms median step; not measured);
same logits. A caller gets the first figure only by
feeding its turn through `forward_extend`; one that feeds
`forward_token` per token gets the second.
**Did not help, or not enough:** more threads (base and new were
both slower above 16 under load); for decode, running each step
inside a rayon pool rather than handing each of its loops to the
pool (no clear win once measured in-process; decode's loops now run
on the spin pool, while prefill, whose projections and experts are
rayon loops, does run inside its pool, `in_cpu_pool`); a rayon pool
of any size against the spin pool for decode (2.5–4.4× slower under
load: 164–465 ms against 65–106 on an M3 Ultra; on a quiet M4 Max
40–42 ms against 34 with the same 12 threads, 18–24% slower);
prefetch distances other than 1 KiB (256 B–4 KiB swept).
**Next levers:** the sliding layers trim their KV cache by moving
the whole window each token past 4096 positions (a ring buffer
removes ~0.6 GB of copies per token there); decode now moves ~6.5
GB of weights per token near what the CPU side reads from memory,
so the next step is fewer bytes (narrower weights, which would
change logits) or several sequences sharing each weight read; and
prefill experts are bound by the 4 × 2 NEON block's arithmetic.
## Cohere2-MoE / North Mini Code (wave 60)
Fourth topology, first attention-only one:
CohereLabs/North-Mini-Code-1.0 (30B total / 3B active)
decodes end-to-end. 49 layers in a 1-full-per-4 pattern
(sliding window 4096, ROLLING per-layer KV cache), Cohere
INTERLEAVED RoPE applied only on sliding (+ forced
dense-prefix) layers — full-attention layers carry no
position encoding. MoE is 128 tiny experts, top-8,
**sigmoid** gating (unnormalized — pinned by test). Block
is Cohere's parallel residual (one norm, attn + mlp summed
into one add). lm_head is the tied 262K×2048 embedding
table. Prefill reuses the wave 54/56 recipe (batched
projections + expert grouping), bit-identical parity
pinned across the rolling-window trim with a window-3 tiny
config.
Smoke: "The capital of France is" → " Paris, and the
capital of the United". Decode ~5 s/token cold — 57 GiB
over 49 shards streamed from the external USB drive; this
checkpoint is a correctness trophy, not a speed one.
Tokenizer: Cohere's tokenizer.json exposed a real bug —
the pre-tokenizer is a SEQUENCE of Split regexes (digit
grouping, then the GPT-style split), and the loader used
only the FIRST, dropping all non-digit text (the prompt
encoded to just BOS). Splits now chain (matches AND gaps
survive, Isolated behavior); the existing byte-equality
suites still pass.
## Falcon-H1 decode (wave 58)
Third hybrid topology: Falcon-H1-0.5B-Instruct runs
attention AND Mamba2 **in parallel inside every layer**
(NemotronH interleaves across layers; Granite stacks mixer
+ shared MLP). First RoPE hybrid here (`rope_theta = 1e11`,
rotate-half), no gated RMSNorm (plain `silu(z)` gate), and
a μP multiplier suite (embedding / ssm in+out / attention
in+out / key / five-section in_proj / mlp gate+down /
lm_head). Wave 47's conv/SSD primitives and the wave 21
attention kernel drop in unchanged; prefill follows the
wave 54 recipe with RoPE applied per position
(bit-identical parity pinned, including continued decode —
which would catch any RoPE position misalignment).
| model | decode | prefill |
|---|---|---|
| Falcon-H1-0.5B (36 layers, h=1024) | ~90 ms/token | ~49 ms/token |
Smoke: "The capital of France is" → " Paris. It is known
for its historical" — correct fluent text on the FIRST run
of the new architecture; the shared primitives carried it.
## NemotronH batched prefill (wave 54)
`forward_prompt` consumes the whole prompt in one pass:
projections run as one `m = T` matmul per weight (the
recurrent conv/SSD parts stay sequential per token — no
weight traffic there), attention prefills causal with
`seq_q = seq_kv = T`. Same per-row kernels → the last
position's logits and the resulting state are
BIT-IDENTICAL to sequential decode (pinned by test).
16-token prompt, rayon build, real 4B checkpoint:
| path | per-token | throughput |
|---|---|---|
| sequential decode | ~80 ms | 12.5 tok/s |
| batched prefill | ~42 ms | 23.8 tok/s |
The enabler was a threading-axis fix in
`matmul_bf16_threaded`: parallel-over-m made every thread
stream the WHOLE weight matrix (traffic = threads ×
weights); for `m < n / (2·threads)` it now parallelizes
over n (weights read once in total) and scatters the
per-chunk results. Prefill's remaining cost is the
sequential SSD recurrence — the chunked SSD form is the
next lever if long-prompt prefill ever dominates. (A
parallel-over-heads SSD path exists but is gated above
today's shapes: at 96×80×128 ≈ 1 MFLOP/layer, rayon
dispatch eats the gain — measured, not assumed.)
## Granite-4.0-H decode (waves 52-53)
Same harness (`examples/granite_decode.rs`), same prompt,
rayon builds, real IBM checkpoints:
| model | shape | per-token | continuation |
|---|---|---|---|
| granite-4.0-h-micro | 3B dense, 36 M + 4 attn | ~95 ms | " Paris. Paris is located in the northern" |
| granite-4.0-h-tiny | 7B-A1B MoE, 64 experts top-6 | ~200 ms | " Paris. Paris is the largest city in" |
h-micro pays more per parameter than Nemotron because every
layer carries a shared MLP (two residual sub-blocks). h-tiny
adds the routed-expert walk: router + 6 × (2 small expert
matmuls) per layer; expert matrices are sliced straight out
of the fused `[64, ·, ·]` mmap tensors, zero copies. The
small per-expert matmuls amortize rayon poorly — per-token
cost is router-dispatch latency, not bandwidth, so this is
the first decode shape where batching experts (not threads)
is the next lever.
Wave 55 adds `forward_prompt` (same recipe as NemotronH's
wave 54, bit-identical parity pinned for dense AND MoE):
h-micro prefill lands ~39 ms/token (~2.5× over its decode).
h-tiny initially got NO prefill speedup — its per-token
expert FFNs are inherently m=1 (each position routes to
its own experts), so batching the dense projections barely
moved the total.
Wave 56 closes that gap with expert batching: prompt
tokens are grouped by chosen expert, each routed expert's
weights stream ONCE for its whole token batch, and the
per-(token, pick) outputs are accumulated back in the
token's original pick order — so the f32 accumulation
sequence, and therefore the result, stays bit-identical
to the per-token walk (the MoE parity test pins it).
| h-tiny prefill, 15 tokens | total | per token |
|---|---|---|
| wave 55 (per-token experts) | ~3.0 s | ~200 ms |
| wave 56 (expert-batched) | ~1.4 s | ~94 ms (2.1×) |
Decode (single token) is untouched — there is no batch to
group at m=1.
## What this still leaves on the table
- **f16 accumulation via `cblas_sgemm` family + half-
precision variants.** Apple Accelerate exposes
`cblas_hgemm`-like paths via the `vDSP_*` family; not as
obvious as sgemm. Future wave.
- **Multi-thread fan-out** — Accelerate is already
multi-threaded internally for large enough sizes; rayon
on top would help when many independent matmuls fire
concurrently (e.g., multi-head attention with separate
heads per worker).
- **Tile + transpose for the m=1 case** to recover the
large-k single-token throughput that drops to 47 GFLOPS
at 4096². Either better data layout or
`cblas_sgemv` (single-vector specialization).
Each is its own bench-driven wave. The substrate primitives
(prefetch, tier store, mmap advice) are already in place;
the wins above prove they don't get in the way of any SIMD /
AMX / multi-thread acceleration.
## TierStore hot-path latency
`cargo bench --bench tier_store`. Same dev box:
| Operation | Time |
|---|---:|
| `pin_hot(first_tensor)` | **293 ns** |
| `bytes_borrow` (zero-copy &[u8]) | **10.6 ns** |
| `promote_to_warm(first_tensor)` idempotent | ~10 ns |
The `bytes_borrow` 10.6 ns is the hot path during inference —
every kernel call goes through it. Tiny.
## What these numbers DON'T capture
- **Cold-cache TTFT** for the `WillNeed` policy itself.
Measured from a warm box; the page cache absorbed the
earlier load. For real cold-load measurements: reboot or use
Linux `drop_caches`. The 2.27 s `All` on ZAYA1 is the right
order-of-magnitude for an actually-cold load.
- **SIMD kernel performance** (NEON / AVX-512 / AMX). All
numbers above are scalar reference. SIMD is a follow-on.
- **Multi-thread scaling.** All single-threaded; rayon over
the m-loop is straightforward to add.
- **GPU overlay performance.** Wave 5 (Metal UMA) and wave 9
(CUDA UVA) shipped but require a Metal compute pipeline /
CUDA kernel above them — measured by the consumer, not the
substrate.
## Reproducing
```bash
cd jouleclaw/jouleclaw-rs
# Load any GGUF + see advise + classify behaviour:
cargo run --release -p jouleclaw-loader-gguf --example load_model -- \
path/to/model.gguf
# Cold-load vs full-advise comparison:
for p in none top-n:32 all; do
cargo run --release -p jouleclaw-loader-gguf --example load_model -- \
path/to/model.gguf --policy=$p
done
# Kernel benches:
cargo bench -p jouleclaw-loader-gguf --bench matmul_q8_0
cargo bench -p jouleclaw-loader-gguf --bench mmap_strategy
cargo bench -p jouleclaw-loader-gguf --bench tier_store
```
All numbers above were captured against models in
`/Volumes/macos_4TB_external_1/neuronexus_ai/model_library/`
on the original dev box. Reproducing requires GGUF files in
the same formats; any standard llama.cpp / HuggingFace
distribution will do.