File size: 22,052 Bytes
a66a9c7
 
290f61d
a66a9c7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290f61d
a66a9c7
 
290f61d
 
a66a9c7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290f61d
a66a9c7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290f61d
a66a9c7
 
 
 
 
 
 
 
 
 
 
 
 
290f61d
a66a9c7
 
290f61d
a66a9c7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
# OldIron kernel waypoints (AVX1 / Z820)

Engine baseline today (measured, HP Z820, 35B-A3B IQ3_S): **7.45 t/s CPU decode**, which is
**11.96 GB/s of weight traffic = 16% of the box's 74.9 GB/s** β€” and 22.5 GMAC/s aggregate, i.e.
**~1.4 GMAC/s per core**. Neither the memory wall (46.7 t/s) nor the compute wall is being reached: the
engine's quantised matvec is the limiter.

| # | waypoint | state | measured effect |
|---|---|---|---|
| **W1** | **AVX1 W4A8 GEMV kernel** (`oiw4_gemv_v1.c`) | **done** | **4.4 GMAC/s/core streaming from DRAM; 7.5 (1 row) / 17.6–18.1 (2 rows) L1-resident; exact-integer match vs scalar reference** |
| W2 | NUMA placement (interleave / per-socket shard) | measured, not integrated | **+59% aggregate**: 44.4 β†’ **70.8 GMAC/s**, 27.8 β†’ 44.2 GB/s |
| W3 | GGUF β†’ OIW4 converter + engine integration | next | target **~20–24 t/s** (3Γ— today) at the measured kernel rate |
| W4 | prefill GEMM (int8, register-blocked) | not started | today 28.5 t/s prefill on the CPU lane |
| W5 | per-layer bit-width / vocab pruning on top of W3 | not started | lm_head is ~14% of per-token bytes |
| W6 | batch decode (GEMV β†’ GEMM amortisation) | not started | MoE: bytes/token fall with batch |

## W1 detail β€” what made it fast (and what didn't)

v0 (per-block horizontal reduction + scalar activation sums): **3.2 GMAC/s/core**, exact.
v1 (fixed): **8.9 GMAC/s/core** 1-row, **18.1** with 2 rows sharing activation loads.
The two fixes that mattered, in order:

1. **No horizontal reduction in the loop.** Keep 4 int32 lanes, convert to fp32, multiply by the block
   scale, add into a `__m128` accumulator; reduce once per row. The zero-point correction
   (`-8Β·sum(a_block)`) is folded into a single lane β€” legal because all lanes land in the same accumulator.
2. **Precompute per-block activation sums once per GEMV** (they are shared by every row) instead of a
   32-iteration scalar loop per block.

ISA check on the built kernel: `vfmadd = 0`, integer-on-`%ymm` = 0 β†’ AVX1-clean, as required.

## W1c β€” the DRAM/NUMA wall (this is what gates the engine)

65536Γ—2048 matrix (64 MB packed + 16 MB scales, deliberately larger than L3):

| placement | 8 threads | 16 threads |
|---|---|---|
| default malloc (all pages on node 0), threads on both sockets | 40.3 GMAC/s Β· 25.2 GB/s | 44.4 GMAC/s Β· 27.8 GB/s |
| `numactl --interleave=all` | 54.0 Β· 33.8 | **70.8 GMAC/s Β· 44.2 GB/s** |
| one socket, fully local | 32.1 Β· 20.1 | 52.6 Β· 32.9 (8 cores) |

**Interleaving is +59% for one flag.** It is also the reason the 16-thread default arm is *worse per core*
than 8 threads: the second socket reads everything over QPI at ~13 GB/s.

Projection at the measured 70.8 GMAC/s and 44.2 GB/s (3.0 GMAC and 1.60 GB per token):
compute ceiling β‰ˆ **23.6 t/s**, memory β‰ˆ **27.6 t/s** β†’ a realistic **~20–24 t/s**, i.e. **~3Γ— the 7.45 t/s**
the engine does today, before any prefill work.


## W3 β€” first engine integration (patched `ggml_vec_dot_q4_0_q8_0`)

Replaced ggml's AVX1 branch with the W1 structure (unsigned nibbles straight into `maddubs`, zero point
removed **lane-wise** so there is no in-loop horizontal reduction, 4 accumulators, one reduce per call).
Built kernel still ISA-clean. Same model, same arm (`-ngl 0 -nopo 1 -t 24`, CPU lane):

| weights | engine | decode | note |
|---|---|---|---|
| IQ3_S 3.66 bpw | stock | **7.45 t/s** | |
| Q4_0 4.50 bpw | stock | 6.99 Β± 0.47 t/s | simpler format is *slower* β€” 23% more bytes |
| Q4_0 4.50 bpw | **patched kernel** | **7.93 Β± 0.02 t/s** | **+13.5% over stock Q4_0**, coherent |

So the kernel change is real but worth 13.5%, not the ~3Γ— the standalone kernel promised. The op profile
below says why.

### Op profile (instrumented ggml; `OLDIRON_OPPROF=1`, 32 tokens, 24 threads)

| op | ms | % | calls |
|---|---|---|---|
| MUL_MAT | 2021.4 | **52.9%** | 13685 |
| MUL_MAT_ID (routed experts) | 915.8 | **24.0%** | 4200 |
| GET_ROWS | 429.0 | **11.2%** | 3605 |
| CONCAT | 119.2 | 3.1% | 1050 |
| GATED_DELTA_NET | 89.0 | 2.3% | 1050 |
| UNARY / SCALE / RMS_NORM / ADD / MUL | 151.0 | 4.0% | ~40k |
| FLASH_ATTN_EXT | 23.1 | 0.6% | 350 |
| CPY / ARGSORT / SSM_CONV / GLU / SOFT_MAX | 58.0 | 1.5% | ~9k |

Matvecs are **77%** of decode β†’ the kernel is the right target. Everything else (DeltaNet, attention,
norms) is ~10%, so the "attention overhead" hypothesis is dead.

### Why the patch only bought 13.5%: two tensors are off the fast path

Tensor-type audit of the Q4_0 file (`kernels/gguf_types.py`):

| tensor | type | params |
|---|---|---|
| everything else | Q4_0 (34.1 B params) | on the patched kernel |
| **`output.weight` (lm_head)** | **Q6_K** | **508.6 M β€” 417 MB read every token, on the old q6_K path** |
| `ffn_gate_inp` routers | F32 | 22.2 M β€” 89 MB/token on the fp32 path |

Per-token weight traffic β‰ˆ **2.19 GB** (1.69 GB MoE active + 0.42 GB lm_head + 0.09 GB routers).
At 7.93 t/s that is 17.4 GB/s β€” still only 23% of the 74.9 GB/s ceiling, so traffic is not the wall:
the *kernel* is, and two of the three biggest matvecs are still running on unpatched, slower paths.

**Next:** put `output.weight` on Q4_0 (one quantize flag) and re-measure; then decide between patching
`q6_K` or keeping the lm_head at 4 bits.


## Compiler back ends, measured on the Z820 (W1 kernel, identical source and flags)

`-O3 -march=sandybridge -mavx -msse4.2`, all three verified AVX1-clean, all three producing exact-integer matches:

| back end | 1-row | 2-row (the variant that matters) |
|---|---|---|
| **gcc 13.3.0** | 8.93 | **18.15 / 17.92 GMAC/s** |
| clang 18.1.3 | 8.93 | 16.52 / 16.36 (βˆ’9%) |
| Intel icx 2026.2 | 7.74 | 15.33 / 15.19 (βˆ’15.5%) |
| gcc `-O2` / `-O3 -funroll-loops` | same | 70.2 / 69.5 GMAC/s *on a Zen4 host* β€” i.e. flag choice is worth <1% |

**gcc is the right compiler and is already in use.** The two LLVM back ends unroll harder and lose on a 16-register
ISA where the 2-row loop is register-starved. clang also has no `libomp` on the Z820 (needs `-fopenmp=libgomp`), and
icx exists only on the build host. Compiler lever: closed β€” the alternatives are 0 to βˆ’15%, never better.

## NUMA: +6% in the engine, but not reachable

| placement / load mode | decode |
|---|---|
| mmap + default (what ships) | **7.83–7.93 t/s** |
| `--load-mode none` + default | 6.80 Β± 0.03 |
| `--load-mode none` + `numactl --interleave=all` | 7.21 Β± 0.19 (**+6.0%** vs the same mode) |

Interleave genuinely helps (**+6.0%**, same load mode), but the anonymous load mode costs ~13%, so the only way to
collect it is interleaved placement of the **mmap'd page cache** β€” which needs a cold cache (`drop_caches`, root) or
a fresh copy of the file. The microbenchmark's +59% does not transfer, because in the engine the matvec is 77% of
the time and is not purely bandwidth-bound.

Note the earlier `--interleave=all` run that showed no effect was **invalid**: the file was already in page cache,
and `numactl` cannot re-place pages that were faulted under a different policy.


## NUMA placement: definitively no effect (and the "74.9 GB/s ceiling" framing was wrong)

Materialised a second copy of the weights **while running under `numactl --interleave=all`**, so the copy's
page-cache pages inherit the interleave policy β€” the only honest way to test placement, since `numactl` cannot
re-place pages faulted earlier. Same file contents, same engine, same thread count, two interleaved rounds:

| arm | round 1 | round 2 |
|---|---|---|
| default placement | 3.13 | 3.13 |
| interleaved copy | 3.13 | 3.15 |

Identical to within 0.02 t/s. `numastat` also shows `FilePages` already split ~50/50 (13241 / 13292 MiB), so the
premise "all weights live on node 0" was never true for a cached file. **NUMA is eliminated.**

### Correction to the headroom estimate

I had been quoting "16% of the 74.9 GB/s ceiling" and therefore "3-5x headroom". That 74.9 GB/s figure is a
**sequential** streaming measurement. Measured against what this access pattern can actually reach:

| arm | threads | weight traffic |
|---|---|---|
| engine, Qwen3-14B dense | 1 | 3.7 GB/s |
| engine | 8 | 24 GB/s |
| engine | 16 | **28 GB/s** |
| pure W4A8 kernel, sequential 64 MB matrix | 16 | **44 GB/s** |

The engine is within **1.6x** of the pure kernel on the same silicon, and its 77% matvec share means closing that
gap entirely would buy ~1.35x end-to-end, not 3-5x. Thread scaling is also respectable rather than broken
(7.7x from 16 threads vs the kernel's 9.4x) β€” the collapse past 8 threads is partly the machine, not the engine.

**Consequence for the plan:** the kernel path is close to exhausted. Multiples now have to come from doing *fewer
bytes of work per token*, not from making the matvec faster:
1. **batch decode** β€” for an MoE, one weight read serves several tokens: the only lever with a >2x ceiling here
2. lower bits on the tensors that dominate traffic
3. vocabulary pruning (the 248k lm_head is ~20% of per-token bytes)


## Speculative decoding: net loss on this box (7.3 -> 6.8 t/s)

Draft = Qwen3.5-0.8B Q4_K_M (532 MB, same 248k vocab family as the target), target = Qwen3.6-35B-A3B,
same prompt, temp 0, t=24, `-n 32`:

| arm | generation |
|---|---|
| baseline | **7.3 t/s** |
| with `-md` draft | **6.8 t/s** (-7%) |

The draft reads ~0.5 GB/token (18 ms against a 137 ms target step), and this model emits a long thinking
preamble whose tokens are not predictable enough to accept drafts at a rate that repays that cost. Speculation
is *mechanically* available (`-md`, `--spec-draft-*`) and may pay on highly repetitive output, but it does not
pay on this workload. Do not ship it as a default.

## Where the headroom actually is (revised, after five eliminations)

| lever | result |
|---|---|
| kernel (W4A8, patched `vec_dot_q4_0_q8_0`) | **+13.5%** at fixed format, **+6.4%** net over the best prior config β€” the only proven gain |
| compiler choice (gcc vs clang vs icx) | gcc already best; alternatives -9% / -15.5% |
| NUMA interleave / placement | 0% (3.13 vs 3.13); page cache already balanced across nodes |
| lm_head Q6_K -> Q4_0 | 0% (7.83 vs 7.93, within noise) |
| speculative decoding | -7% |
| engine vs pure kernel | engine at 28 GB/s, kernel at 44 GB/s -> closing it entirely is ~1.35x on a 77% matvec share |

Decode is within ~1.4x of what this silicon does for this access pattern; prefill (pp128 29-30 t/s = ~87 GMAC/s)
is at roughly half the measured int8 compute floor, so ~2x is the ceiling there. Multiples beyond ~1.5x are not
available from the engine: they require changing the bytes per token (batch serving, lower bits, vocabulary), or a
different model.

## Operational trap: `llama-cli` under tmux writes nothing

Every `llama-cli` run redirected to a file inside a tmux session produced a **0-byte log** and no timing line,
while the identical command over plain ssh works (verified: "Prompt 22.3 t/s | Generation 7.3 t/s"). tmux is fine
for `llama-bench` and for long shells; capture CLI *output* over a plain ssh pipe.


## The hoisted-correction GEMM: big in isolation, a 40% loss in situ (reverted)

The isolated prototype was unambiguous, on the Z820 itself, core-pinned and verified:

| kernel shape | GMAC/s |
|---|---|
| 1 row, correction inline (= what ggml's vec_dot did) | 3.28 |
| 1 row, correction hoisted | 4.05 (+23%) |
| 4 rows, correction hoisted | 6.99 (+113%) |

Implemented for real: mul_mat precomputes `8*sum(a_block)` once per activation column into thread-local
scratch, arms a pointer around the row loop only (so `mul_mat_id` can never read stale sums), and the kernel
reads it with one `movd`. Correctness held (coherent output). Speed did not:

| build | pp128 | tg48 |
|---|---|---|
| W3 kernel (shipped) | 29.50 | **7.93** |
| W3 + hoist + 4-row batching | 29.46 | **4.67** |

A 40% regression, reproduced three times (4.62 / 4.67 / 4.68), with the machine verified idle (load 0.28,
42 C, full clock) and the weights verified cache-warm (5.8 GB/s). Profile moved the wrong way everywhere:
MUL_MAT 63 -> 91 ms/token, MUL_MAT_ID 28.6 -> 80 ms/token. Reverted; 7.70-7.93 restored.

**Why the isolated number did not transfer.** The prototype amortised the hoist over 512 contiguous rows per
call. The engine makes ~1.9M `vec_dot` calls per token across 428 small matvecs, so the per-column precompute
is paid per tiny call, and the extra pointer + branch cost the 4-accumulator schedule its registers. Lesson:
a kernel win measured on one big contiguous matrix does not imply a win at 428 small ones - gate on the
engine, not the microbenchmark.

## Instrumentation traps found the hard way

- `OLDIRON_OPPROF=1` costs ~1.6x (19.0 s of op time for 96 tokens vs 12.3 s). Never quote a rate from a
  profiler run, and never compare a profiler run against a clean one.
- **CMake does not rebuild on a compile-definition change.** Flipping `GGML_CPU_REPACK` rewrote flags.make but
  left every object in place, silently producing a build that did not match its cache. Force it: delete the
  target's `*.o` and touch the sources. This produced a fake "repack off" result that cost a debug cycle.
- `llama-cli` under tmux writes a 0-byte log and no timing line; the same command over a plain ssh pipe works.
  `llama-bench` is fine under tmux.


## Shape census: where the MACs actually live, and the corrected prefill ceiling

Counts only, no clocks, so the rates stay honest (verified: 7.87 tg / 29.93 pp with the census on, i.e. free).

| workload | batching width | share of its MACs |
|---|---|---|
| MUL_MAT (dense projections) | 257+ tokens, 770 calls | **93.8%** |
| MUL_MAT (dense) | 1 token (decode), 19171 calls | 6.2% |
| MUL_MAT_ID (MoE experts) | **5-16 tokens, 4.08M calls** | **84.8%** |
| MUL_MAT_ID | 17-64 tokens | 6.9% |
| MUL_MAT_ID | 1 token | 5.4% |

Two things follow. First, the dense path is genuinely GEMM-shaped (512 tokens per call) - a blocked GEMM
would apply. Second, the MoE path is not: 4.08M separate calls at ~8 tokens per expert. Any GEMM has to be
tuned for 8-16 columns, not 512, and the per-call overhead is paid 4.08M times.

**Corrected prefill ceiling.** Total MACs in a pp512 run are ~2.5 TMAC, because the *dense* attention and
projection matrices (not the 3B-active MoE) dominate prefill. At 29.9 t/s that is ~145 GMAC/s against the
measured 170 GMAC/s int8 floor = **85-90%**. The earlier "52% of ceiling, so 2x available" was wrong: it
assumed prefill cost scales with active parameters. It does not. A GEMM rewrite is worth ~10-15%, not 2x,
which is why it is not worth days on this hardware.

## Long context barely costs prefill throughput

| op | pp512 | pp4096 |
|---|---|---|
| MUL_MAT | 43.1% | 41.6% |
| MUL_MAT_ID | 36.8% | 36.3% |
| CONCAT | 10.0% | 9.5% |
| FLASH_ATTN_EXT | 1.1% | **6.9%** |
| GATED_DELTA_NET | 2.9% | 2.8% |

pp4096 = 28.05 t/s vs pp512 = 29.93 t/s: **6% slower per token at 8x the context**. Attention grows 6x in
share but matmul still dominates, so the hybrid DeltaNet keeps long-context prefill cheap. For a 2k-token
prompt that is ~70 s, and it does not degrade much at 32k.

## The one clearly removable cost left: CONCAT (10% of prefill)

25 s of 266 s at pp4096, 480 calls, pure data movement. Implementation copies one row at a time
(`memcpy` of `row_size`) and parallelises only over `i2`: `for (i2 = ith; i2 < ne02; i2 += nth)`, with the
token loop serial inside each thread. If `ne02` is small (heads), most threads idle while each does thousands
of small strided copies - consistent with ~52 ms per call. Fixing this needs the real tensor shapes, which is
the next measurement, not a guess.


## CONCAT was a profiler artifact; the real cost is cross-expert imbalance

The CONCAT shape is `out[515, 8192]` from `a[3, 8192]` + `b[512, 8192]`, dim 0, with **ne02 = ne03 = 1**. The
upstream loop `for (i2 = ith; i2 < ne02; i2 += nth)` therefore ran entirely on thread 0 while 23 threads idled.
Parallelising the copy across (i1, i2) made it correct and 24x more parallel - and changed nothing:

| | before | after |
|---|---|---|
| CONCAT in profile | 3297 ms, 10.0% | **3035 ms, 10.0%** |
| pp512 | 29.97 | 29.92 |
| pp4096 | 28.05 | 28.00 |

So the copy was never the cost. The op profile brackets each node including the barrier wait at its start, so
the straggler time from the *previous* node (the MoE matmul) is attributed to whichever node follows - CONCAT
in this graph. The 10% is cross-expert load imbalance: experts carry wildly unequal token counts, and the
loop over experts is the scheduling unit, so chunk-level work stealing inside one expert cannot fix it.
Raising chunk granularity by dropping the `ggml_is_numa()` term made prefill unchanged (29.91/28.07) and
**decode 7% worse** (7.87 -> 7.31) - upstream's NUMA preference is correct here. Reverted.

Capturing that last 10% needs a flattened (expert, row-chunk) work queue, i.e. a real scheduling rewrite in
`mul_mat_id`, with a ceiling of ~10% and no guarantee. Not attempted.

## Final shipped state (HP Z820, 24 threads, 35B weights)

| test | value |
|---|---|
| tg48 | **7.86 +- 0.08** t/s |
| pp128 / pp512 / pp4096 | 29.70 / 29.9 / 28.0 t/s |

Shipped kernel is the W3 AVX1 q4_0 dot (+13.5% at fixed format, +6.4% net over the best prior config).
Everything else tried today was measured and rejected: hoisted correction (-40%), 4-token GEMM batching
(inseparable from the hoist that broke it), speculative decoding (-7%), NUMA interleave (0%), lm_head
requantisation (0%), alternate compilers (-9%/-15.5%), NUMA chunking (-7% decode). Long-context prefill is at
85-90% of the measured int8 ceiling, so the platform's compute headroom is genuinely near spent.


## Best configuration for long context on the HP Z820 (GPU + CPU split)

The CPU lane is at its ISA ceiling; the 16 GB card is not. Sweeping `-ncmoe` (MoE layers kept on the CPU),
all at `-t 24`, Q4_0 re-quant weights:

| -ncmoe | pp4096 (bench) | tg48 (short ctx) | peak VRAM @32k ctx | verdict |
|---|---|---|---|---|
| 8 | 358.6 | 68.3 | 15795 @4k -> **no KV room** | 32k OOM |
| **10** | 291.8 | 60.9 | **15339** | **fastest that holds 32k** |
| 12 | 248.1 | 58.4 | 14465 | safe, 1.4 GB spare |
| 14 | 214.8 | 53.6 | 13601 | conservative |
| CPU only | 29.9 | 7.86 | - | fallback |

Measured at a real 32k context with a 140 KB prompt (not llama-bench's short context):

| config (@32k) | prompt | generation |
|---|---|---|
| **ncmoe=10, t=24, f16 KV** | **832.9 t/s** | **52.5 t/s** |
| ncmoe=10 + KV q8_0 | 839.2 | 48.3 (-8%) |
| ncmoe=10, t=16 | 810.9 | 46.0 (-12%) |
| ncmoe=12, t=24 | 731.7 | 51.6 |

**Recommended:** `-ngl 99 -ncmoe 10 -t 24 -c 32768` -> 52.5 tok/s generation, 833 tok/s prompt at 32k
context, 15339 MiB peak. That is **6.7x the CPU lane's decode and 29x its prefill**. Only 508 MiB spare, so
32k is the ceiling with this split; drop to `-ncmoe 12` (51.6 tok/s, 1.4 GB spare) if headroom matters more
than 2%. KV cache quantisation is a net loss here (-8%): the hybrid DeltaNet KV is only ~12 KB/token, so it is
already small enough that q8 mainly adds dequant work in attention.

Left on the table deliberately: ncmoe 8 is 30% faster in prefill but cannot allocate a 32k KV, and ncmoe 6/4
cannot even load the weights (16665 / 15801 MiB against a 15847 MiB card).


## MoE scheduler investigation: closed, no win available on AVX1

### The scheduler is already balanced

`ggml_compute_forward_mul_mat_id` iterates experts in the outer loop and has **every thread visit every
expert**, splitting each expert's rows across threads. Because the expert loop is shared, uneven token counts
per expert do not imbalance anything - the row split is proportional to work, and all threads do the same
experts in the same order. The residual skew is `ceil(nr0/nth)` rounding plus SMT siblings being slower than
full cores on this 24-thread-on-16-core setup: a ~5% ceiling, not the 10% the CONCAT profile implied. The one
lever that looked reachable (`ggml_is_numa()` forcing one chunk per thread) was measured: prefill flat, decode
7% worse. Nothing to win here.

### The real finding: Q4_0 silently disables ggml's tiled GEMM - and that is CORRECT here

`ggml_tiled_supported()` (enabled by default via `GGML_CPU_TILED_MM`) accepts only K-quants:
Q6_K, Q5_K, Q4_K, Q3_K, Q2_K, IQ4_XS, IQ2_*, IQ3_XXS, **IQ3_S**, IQ1_*. **Q4_0 is absent from the list**, so
every Q4_0 matmul - dense included, and dense is 93.8% of prefill MACs at 257+ columns - falls through to
`vec_dot`. Since re-quantising to Q4_0 was what enabled the W3 kernel, this looked like a large opportunity:
switch to a K-quant and get a hand-blocked GEMM for the prefill case.

Measured, same engine, same box, idle (load 1.03), weights warm:

| weights | path | pp512 | pp4096 | tg48 |
|---|---|---|---|---|
| **Q4_0 (W3)** | vec_dot | **29.9** | **28.0** | **7.86** |
| IQ3_M (IQ3_S/IQ3_XXS) | **tiled GEMM** | 19.00 | **15.74** | 7.24 |

The tiled path is **44% slower at prefill**. The reason is in the source: the kernel is built for
`__AVX2__`/`__AVX512VNNI__`, and on an AVX1-only build it compiles the generic path - so the "better" GEMM is
the worse kernel on Sandy Bridge. Q4_0 + vec_dot + the W3 kernel wins on both decode and prefill, and the
quantisation choice is now justified by prefill measurement as well as decode.

### Measurement hazard worth recording

A stale `llama-bench` survived an ssh timeout and kept running for 23 minutes, silently competing with three
subsequent measurements (one produced a nonsense 1.31 t/s). Always confirm `pgrep` is empty between arms, and
re-measure anything surprising on a verified-idle box.

## Honest limits of this result

- The kernel is a **GEMV** (batch 1). Prefill is a different kernel (GEMM) and is not addressed by W1.
- 4.25 bpw for this format vs 3.66 for IQ3_S: the weight file grows from 15.7 GiB to ~19 GiB. Fits 32 GB,
  but it is a size regression that buys speed.
- Numbers are one socket's worth of silicon under `nice -n 19`; treat Β±5% as noise and do not compare
  against arms measured on other days without re-running.