Repository navigation
perf(cpu_x86): qwen35 Q8_0 prefill ~20-25 tok/s on Zen 5, no thread scaling, GEIST_FORCE_ISA has no effect #504
Description
Summary
On Linux x86_64 (Ryzen 9 9950X, Zen 5, AVX-512 + VNNI), prefill of Qwen3.5-0.8B Q8_0 (arch=qwen35, backend cpu_x86) runs at roughly 20–25 tokens/s and barely scales with threads. Prefill time grows linearly with prompt length. A ~2.4 KB German prompt (est. 500–600 tokens) takes 21–25 s. GEIST_FORCE_ISA has no measurable effect on this model, not even scalar.
Found while hardening CI for HELIO. The game's first NPC reply takes ~15 s, almost all of it prefill. On a Steam Deck (Zen 2, 4 cores, AVX2 only) this will be considerably worse.
Environment
- geistlib v0.11.0, commit
6781d425e4d9ac9ec4bf5fa7da9fa383be7e5d58(pinned by HELIO). Not yet re-checked againstmain. - Build:
make lib TARGET=linux MODE=release GEMM_PROVIDER=native CC=gcc-14. DefaultBACKENDS=cpu_x86 cpu_scalar,-march=x86-64-v3. - Ubuntu 26.04 container, GCC 14. Host: AMD Ryzen 9 9950X (16C/32T), 64 GB RAM.
- Model:
Qwen3.5-0.8B-Q8_0.gguf(unsloth), 811,843,840 bytes, sha2560ad885ffd4bb022fc4f0d33a3308fa108ef8613159d3b3a67e23abca056b7a6c.
Measurements (public API only)
The harness uses geist_backend_create("auto") → geist_model_load → geist_session_create (zeroed opts) → geist_session_set_prompt (timed) → 8× geist_session_decode_step (timed). Each prompt runs twice; the second run is shown.
OMP_NUM_THREADS |
short prompt (23 B) set_prompt | long prompt (2430 B) set_prompt | decode 8 tokens |
|---|---|---|---|
| 1 | 0.44 s | 24.07 s | 0.86 s |
| 4 | 0.41 s | 21.79 s | 0.86 s |
| 16 | 0.41 s | 21.49 s | 0.88 s |
| unset (32) | 0.63 s | 25.46 s | 1.61–1.86 s |
Observations:
- Prefill is not a one-time cost. It scales with prompt length. A second identical prompt on a fresh session is just as slow, and
model_loadis 0.03 s. - Almost no thread scaling: 1 → 16 threads gives only ~11 % for prefill and 0 % for decode.
- Default thread count hurts. With
OMP_NUM_THREADSunset (32 SMT threads) prefill is ~15 % slower and decode ~2× slower than with 1 thread. This looks like oversubscription or SMT contention. GEIST_FORCE_ISAis a no-op for this model. Full inference in HELIO withavx512_vnni,avx2andscalargives identical output and identical timings (prefill 14.2 / 14.2 / 14.1 s). Either the Q8_0 /qwen35path does not go through the W4A8/W8A8 dispatcher, or the scalar tier is used regardless.
For comparison, llama.cpp on comparable 16-core Zen hardware usually prefills a 0.8B Q8_0 model at hundreds to thousands of tokens/s. I did not measure llama.cpp on this machine.
Hypotheses (unverified)
- The prefill for
qwen35may run token by token (decode-style GEMV per token) instead of as a batched GEMM over the prompt. That could come from the recurrent Gated DeltaNet layers forcing a sequential path, or from Q8_0 not having a batched int8 kernel oncpu_x86. - OpenMP parallel regions may be too fine-grained for a 0.8B model, so sync overhead dominates.
Reproduction
// gcc-14 -std=c23 -O2 -fopenmp -I include prefill.c libgeist.a -lm
struct geist_backend *be; struct geist_model *m; struct geist_session *s;
geist_backend_create("auto", nullptr, nullptr, &be);
geist_model_load(argv[1], be, &m);
struct geist_session_opts o = {0};
geist_session_create(m, be, &o, &s);
// time this with a ~2.4 KB prompt:
geist_session_set_prompt(s, prompt);I have the full harness (~40 lines) and can attach it.
Requested
- Confirm whether
qwen35+ Q8_0 prefill oncpu_x86is batched, and which kernel tier it actually uses - Explain or fix the missing thread scaling, and choose a sensible default thread count (physical cores?)
- Make
GEIST_FORCE_ISAeither cover this path or document that it does not (it is useful for AVX2 / Steam Deck testing) - Re-measure on current
main, and add a prefill tokens/s number for x86 Linux to the performance matrix (bench: populate a same-protocol model × system performance matrix #364)
Related: #456 (macOS prefill/decode vs llama.cpp), #295 (int8 mN GEMM for prefill).
Activity
geisten commentedon Sep 27, 2026
Full timing harness used for the table above:
Build:
gcc-14 -std=c23 -O2 -fopenmp -I include prefill.c <build>/libgeist.a -lm -o prefill· Run:OMP_NUM_THREADS=N ./prefill Qwen3.5-0.8B-Q8_0.ggufgeisten commentedon Sep 27, 2026
Reproduced on current `main` (commit `7a0b421`), same GGUF (`qwen3.5-0.8b-q8_0.gguf`, matches your sha256), `OMP_NUM_THREADS=16`:
```
pp128 19.8 t/s (6.48 s)
pp512 20.3 t/s (25.2 s)
```
Confirms your report exactly — throughput is flat regardless of prompt length (linear time growth, no batching benefit).
Root cause
`cpu_x86_resolve_weight` (`src/backends/cpu_x86/backend.c:430`) only overrides
linear_m1/linear_mNfor Q4_K, Q6_K and I2_S. Every other dtype — including Q8_0 — falls through to `cpu_scalar_resolve_weight`, whose `cpu_scalar_w_quant_mN` (`src/backends/cpu_scalar/weight_resolve.c:209`) is explicitly the reference-correctness kernel: no SIMD,double-precision scalar accumulation, and no OpenMP at all — the wholefor (j = 0; j < n_out; j++)loop is single-threaded. This is a documented, intentional gap, not a regression: the comment right above the dtype switch in `cpu_x86_resolve_weight` says outright "M>1 stays on cpu_scalar's slow path for now — Phase 1b wires the W4A8 prefill kernel." Q8_0 was never Phase-1b'd.This answers all four checklist items:
minput rows) — but the arithmetic is scalardoublewith zero threading, so it behaves like a GEMV-per-token in wall-clock terms.#pragma ompat all. Whatever scaling you saw (~11%) comes from other, smaller parallel regions elsewhere in the forward pass, not this one.GEIST_FORCE_ISAno-op: confirmed — this path never reaches the cpu_x86 ISA-tier dispatcher (the W4A8/W8A8 dispatcherkernel_w4a8.cuses); it bypasses it entirely via the un-overriddencpu_scalarfallback.qwen35's DeltaNet layers — every FFN/attention projection weight in a Q8_0-quantized model of any architecture hits the same path oncpu_x86.Fix scope
A real fix means a genuine cpu_x86 Q8_0 prefill kernel: block-symmetric int8, a natural fit for AVX-512 VNNI (
vpdpbusd, same primitive already used for Q4_K→W4A8 and Q6_K→W8A8) with an AVX2 fallback tier, plus OpenMP overn_out(matching the pattern in #499's attention kernel and the existing W4A8/W8A8 files). Comparable scope to the existing W8A8 kernel work, not a small patch — same family of fix as #295 (int8 mN GEMM for prefill), which is likely the right issue to fold this into rather than duplicating tracking.geisten commentedon Sep 27, 2026
Re-measured on current
main(7a0b421) with the same harness, same host and model. No change:OMP_NUM_THREADSSo the issue is present on both v0.11.0 and
main.cpu_x86: generic multi-threaded linear for dtypes without a native ke…
cpu_x86: decode on physical cores when SMT is on (region hooks)
geisten commentedon Oct 1, 2026
Additional end-to-end data from a consumer (pinned
7a0b421,qwen3.5-0.8b-q8_0.gguf,GEMM_PROVIDER=native,TARGET=linuxwith-march=x86-64-v3 -fopenmp), one action request ≈ 700 prompt tokens + ~11 decoded tokens:Observations that match the root cause above and add one point:
max_threads2 / 4 / 8 / 16: no change in prefill or decode (within noise).GEMM_PROVIDER=openblas(OpenBLAS 0.3.30, OpenMP) instead ofnative: no change either, so the dense fp32 sgemm is not the bottleneck.linear_m1also falling through tocpu_scalaron x86.Impact for us: on weaker x86 hardware (Steam Deck class, Zen 2 4C/8T) a single request will likely exceed a 30 s budget with Q8_0. A cpu_x86 Q8_0 kernel for both
linear_m1andlinear_mN(AVX2 tier first, VNNI optional) would cover it; until then switching the model to Q4_K is our workaround candidate.geisten commentedon Oct 1, 2026
Likely fixed in code by #512 (merged 2026-09-29, after the 7a0b421 measurements): native Q8_0 kernels on cpu_x86 (AVX2 m1/mN with OpenMP plus AVX-512 VNNI,
linear_q8_0.c), the VNNI tile choice now honoursGEIST_FORCE_ISA(linear_q8_0.c:249-265), and decode runs one thread per physical core (threads.c).Remaining before closing: re-run the Qwen3.5-0.8B Q8_0 harness on main ≥ bce22ae on the Zen 5 box and add the number to #364. Note that
GEIST_FORCE_ISA=scalarstill runs the AVX2 Q8_0 kernel (AVX2 is the build baseline); it only chooses between VNNI and AVX2.Generated by Claude Code
geisten commentedon Oct 3, 2026
I re-measured this on the Zen 5 box (Ryzen 9 9950X, the self-hosted
geist-vulkanrunner) withvulkan-ab(run, via #591).Setup:
cpu_x86.smollm2-360m-q8_0preset. The real Qwen3.5 GGUF can't be downloaded there.7a0b421(the commit the table above was measured on) against current main.The control stays within ±11 %, which is the resolution of this run.
The old prefill rate was flat, about 30 tok/s whatever the prompt length, matching what this issue reported. Main now prefills about 2600 tok/s and does it in batches.
#512's native Q8_0 kernels cover every Q8_0 projection whatever the architecture, so qwen35 goes through the same kernels. The checklist items are covered as follows:
GEIST_FORCE_ISApicks between them;scalarstill runs AVX2, the build baseline.Closing. If HELIO still sees a slow first reply on the real model, please reopen with the new numbers.
Generated by Claude Code
ci(vulkan-ab): default to #504's case, Q8_0 on the Zen 5 CPU before a…