Skip to the content.

M3-LLAMACPP-RESULTS — fak on Apple Silicon: the NEON Q8 lane, the best model it runs, and the measured gap to llama.cpp

Honest verdict up front. Before this work fak had no SIMD Q8 kernel on arm64 — the int8 dot was amd64-only (AVX2/AVX-512), so on Apple Silicon it fell back to scalar Go and Q8_0 quantization was a net loss: int8 decode was 1.24× slower than f32 (17.7 vs 14.0 ms/tok) despite streaming 3.5× fewer weight bytes. This session added the missing NEON lane (SDOT decode kernel + NEON prefill GEMM) and the pure-Go HuggingFace loaders, which together (a) flip int8 to 1.9× faster than f32 and prefill 5–6× faster, and (b) let fak run a real model — Qwen2.5-1.5B-Instruct, 13× the params of the old SmolLM2-135M — on this box. The result is that fak now lands in the same order of magnitude as llama.cpp’s CPU path (decode ~2.2× behind, prefill ~6.5× behind) instead of not being in the race. It is not at parity: llama.cpp’s hand-tuned ggml kernels, Accelerate BLAS, and Metal backend remain ahead, exactly as LLAMACPP-HEADTOHEAD-RESULTS.md found on Zen5 — the residual is a hand-tuned-assembly / GEMM-tiling / GPU boundary, not an architecture one.

Box: Apple M3 Pro (6P+6E, 36 GB unified, ~150 GB/s). fak = pure-Go in-kernel forward pass + the new arm64 NEON SDOT kernel (internal/model/quant_arm64.{go,s}), no cgo, no GPU. llama.cpp = Homebrew build 8200 (541bf3762), the same machine. Apples-to-apples Q8_0 on both sides. Native go test/go run (this is macOS, not the Windows host where WDAC blocks native exes).

1. The kernel flip (what the NEON lane bought) — SmolLM2-135M, the bit-identity fixture

The arm64 NEON kernel is bit-identical to the scalar reference (TestQdot8NEONMatchesScalar; the per-block int32 SDOT sum is order-independent, the float combine matches gc’s arm64 FMA fusion via a single FMADDS). So this is pure speed, same numbers — argmax-exact vs the HF oracle stays 25/25.

axis (SmolLM2-135M, Q8_0) before (scalar arm64) after (NEON arm64) delta
int8 decode 17.68 ms/tok 7.30 ms/tok 2.42× faster
int8 decode vs f32 decode (14.0 ms/tok) 1.24× slower 1.91× faster the flip
int8 prefill (P=256) 126 tok/s 631 tok/s 5.0× faster

Reproduce: go run ./cmd/q8bench (prints the argmax-exact gate + decode/prefill A/B).

2. The best model fak runs here — Qwen2.5-1.5B-Instruct, loaded in pure Go

fak could previously only run the one checkpoint export_oracle.py (torch) had baked into its custom format. Two pure-Go loaders (no torch) removed that ceiling — the forward pass is already generic Llama/Qwen2 (GQA, RoPE θ, SwiGLU, tied embeddings, Qwen2 qkv-bias):

Qwen2.5-1.5B load (Apple M3 Pro) peak RSS load time decode
regular -hf (f32-resident) 18.8 GB 14.3 s 28.9 tok/s
-lean (quantize-at-load, f32 dropped) 11.1 GB 4.9 s 28.9 tok/s (same Q8 weights)

3. Head-to-head — Qwen2.5-1.5B-Instruct, Q8_0, same machine, 6 threads

axis fak (pure-Go CPU, NEON Q8) llama.cpp CPU (-ngl 0) llama.cpp Metal (-ngl 99)
decode (tg64) 28.9 tok/s (34.6 ms/tok) 63.2 ± 0.5 tok/s 67.5 ± 0.9 tok/s
prefill (pp256) 55.5 tok/s 363.2 ± 4.0 tok/s 1746.8 ± 2.0 tok/s
fak ÷ llama.cpp CPU decode 0.46× · prefill 0.15×

Two facts the numbers make plain:

4. Why the residual gap (and what would close it)

5. What shipped this session (all on darwin/arm64, full go test ./... green)

commit change
feat(model): arm64 NEON Q8_0 kernel qdot8asm (SDOT, FEAT_DotProd; auxv detect on linux), bit-identical to scalar; decode 2.4×
feat(model): pure-Go HF loaders -hf safetensors load + LoadSafetensorsQuant lean load (fits a 7B on 36 GB)
feat(model): arm64 NEON Q8_0 prefill GEMM route the batched GEMM through qdot8asm; prefill 5–6×

6. Reproduce

# kernel flip + argmax-exact gate (SmolLM2 fixture)
go run ./cmd/q8bench

# the best model, lean-loaded, NEON Q8 (Qwen2.5-1.5B-Instruct HF snapshot)
FAK_WORKERS=6 go run ./cmd/modelbench -hf <snapshot> -lean -decode-reps 6 -prefill-reps 3

# llama.cpp side, same GGUF, same machine
llama-bench -m qwen2.5-1.5b-instruct-q8_0.gguf -ngl 0  -t 6 -p 256 -n 64   # CPU
llama-bench -m qwen2.5-1.5b-instruct-q8_0.gguf -ngl 99 -t 6 -p 256 -n 64   # Metal

# the bit-identity / lean-equivalence gates
go test ./internal/model/ -run 'TestQdot8NEONMatchesScalar|TestLoadSafetensorsQuantMatchesRegular' -v

Bottom line

The “needed updates to fak” for this M3 were the missing arm64 NEON Q8 lane (decode + prefill) and the pure-Go HF loaders. With them, fak goes from quantization-is-a-loss to running Qwen2.5-1.5B at ~29 tok/s decode / 55 tok/s prefill in pure Go on CPU, within ~2.2× of llama.cpp’s CPU decode on the identical Q8_0 weights. Full parity remains a register-blocked-GEMM