Skip to content

perf(cpu_x86): qwen35 Q8_0 prefill ~20-25 tok/s on Zen 5, no thread scaling, GEIST_FORCE_ISA has no effect #504

Closed

Description

@geisten

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 against main.
  • Build: make lib TARGET=linux MODE=release GEMM_PROVIDER=native CC=gcc-14. Default BACKENDS=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, sha256 0ad885ffd4bb022fc4f0d33a3308fa108ef8613159d3b3a67e23abca056b7a6c.

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:

  1. 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_load is 0.03 s.
  2. Almost no thread scaling: 1 → 16 threads gives only ~11 % for prefill and 0 % for decode.
  3. Default thread count hurts. With OMP_NUM_THREADS unset (32 SMT threads) prefill is ~15 % slower and decode ~2× slower than with 1 thread. This looks like oversubscription or SMT contention.
  4. GEIST_FORCE_ISA is a no-op for this model. Full inference in HELIO with avx512_vnni, avx2 and scalar gives identical output and identical timings (prefill 14.2 / 14.2 / 14.1 s). Either the Q8_0 / qwen35 path 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 qwen35 may 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 on cpu_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 on cpu_x86 is 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_ISA either 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

  1. geisten commented on Sep 27, 2026

    @geisten
    OwnerAuthor

    Full timing harness used for the table above:

    #define _POSIX_C_SOURCE 200809L
    #include "geist.h"
    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #include <time.h>
    static double now(void) { struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t); return t.tv_sec + t.tv_nsec / 1e9; }
    static void run(struct geist_model *m, struct geist_backend *be, const char *label, const char *prompt) {
        struct geist_session_opts o = {0};
        struct geist_session *s = nullptr;
        double a = now();
        if (geist_session_create(m, be, &o, &s) != GEIST_OK) exit(2);
        double b = now();
        if (geist_session_set_prompt(s, prompt) != GEIST_OK) exit(3);
        double c = now();
        geist_token_t tok;
        for (int i = 0; i < 8; i++) if (geist_session_decode_step(s, &tok) != GEIST_OK) exit(4);
        double d = now();
        printf("%-12s prompt_bytes=%zu session_create=%.2fs set_prompt=%.2fs decode8=%.2fs\n", label, strlen(prompt), b - a, c - b, d - c);
        geist_session_destroy(s);
    }
    int main(int argc, char **argv) {
        struct geist_backend *be = nullptr; struct geist_model *m = nullptr;
        double a = now();
        if (geist_backend_create("auto", nullptr, nullptr, &be) != GEIST_OK) return 1;
        if (geist_model_load(argv[1], be, &m) != GEIST_OK) return 1;
        printf("model_load=%.2fs backend=%s arch=%s\n", now() - a, geist_backend_name(be), geist_model_arch(m));
        static char big[4000]; big[0] = 0;
        while (strlen(big) < 2400) strcat(big, "Die Station braucht Energie und Reparaturen. ");
        run(m, be, "short#1", "Hallo, wie geht es dir?");
        run(m, be, "short#2", "Hallo, wie geht es dir?");
        run(m, be, "long~600tok", big);
        run(m, be, "long#2", big);
    }

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

  2. geisten commented on Sep 27, 2026

    @geisten
    OwnerAuthor

    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_mN for 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 whole for (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:

    • Batched or GEMV-per-token? Technically one call over the whole prefill batch (dequant is amortized once per output row, reused across all m input rows) — but the arithmetic is scalar double with zero threading, so it behaves like a GEMV-per-token in wall-clock terms.
    • Thread scaling: confirmed absent — the hot loop has no #pragma omp at all. Whatever scaling you saw (~11%) comes from other, smaller parallel regions elsewhere in the forward pass, not this one.
    • GEIST_FORCE_ISA no-op: confirmed — this path never reaches the cpu_x86 ISA-tier dispatcher (the W4A8/W8A8 dispatcher kernel_w4a8.c uses); it bypasses it entirely via the un-overridden cpu_scalar fallback.
    • Not specific to qwen35's DeltaNet layers — every FFN/attention projection weight in a Q8_0-quantized model of any architecture hits the same path on cpu_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 over n_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.

  3. geisten commented on Sep 27, 2026

    @geisten
    OwnerAuthor

    Re-measured on current main (7a0b421) with the same harness, same host and model. No change:

    OMP_NUM_THREADS long prompt (2430 B) set_prompt decode 8 tokens
    1 23.97 s 0.87 s
    16 21.41 s 0.88 s
    unset (32) 22.15 s 1.41 s

    So the issue is present on both v0.11.0 and main.

  4. geisten commented on Oct 1, 2026

    @geisten
    OwnerAuthor

    Additional end-to-end data from a consumer (pinned 7a0b421, qwen3.5-0.8b-q8_0.gguf, GEMM_PROVIDER=native, TARGET=linux with -march=x86-64-v3 -fopenmp), one action request ≈ 700 prompt tokens + ~11 decoded tokens:

    Host Prefill P50 / P95 Decode per token
    Apple M1 Max (cpu_neon, Accelerate) 2.5 s / 2.6 s ~25 ms
    Ryzen 9 9950X, 16 CPUs (cpu_x86) 16.1 s / 17.4 s ~200 ms

    Observations that match the root cause above and add one point:

    • max_threads 2 / 4 / 8 / 16: no change in prefill or decode (within noise).
    • GEMM_PROVIDER=openblas (OpenBLAS 0.3.30, OpenMP) instead of native: no change either, so the dense fp32 sgemm is not the bottleneck.
    • Decode is ~8× slower too, not only prefill — consistent with Q8_0 linear_m1 also falling through to cpu_scalar on 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_m1 and linear_mN (AVX2 tier first, VNNI optional) would cover it; until then switching the model to Q4_K is our workaround candidate.

  5. geisten commented on Oct 1, 2026

    @geisten
    OwnerAuthor

    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 honours GEIST_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=scalar still runs the AVX2 Q8_0 kernel (AVX2 is the build baseline); it only chooses between VNNI and AVX2.


    Generated by Claude Code

  6. geisten commented on Oct 3, 2026

    @geisten
    OwnerAuthor

    I re-measured this on the Zen 5 box (Ryzen 9 9950X, the self-hosted geist-vulkan runner) with vulkan-ab (run, via #591).

    Setup:

    • Backend cpu_x86.
    • Synthetic Q8_0 model from the smollm2-360m-q8_0 preset. The real Qwen3.5 GGUF can't be downloaded there.
    • 7a0b421 (the commit the table above was measured on) against current main.
    • 6 cycles, with a control copy of the baseline binary.
    7a0b421 main change
    prefill, 128 tokens 4225 ms (30 tok/s) 49 ms (2600 tok/s) −98.8 %, 6/6
    prefill, 512 tokens 16969 ms (30 tok/s) 196 ms (2610 tok/s) −98.8 %, 6/6
    decode, 16 tokens 1070 ms 81 ms (5 ms/token) −92.4 %, 6/6

    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:

    • Batching: yes.
    • Kernel tier: VNNI or AVX2. GEIST_FORCE_ISA picks between them; scalar still runs AVX2, the build baseline.
    • Thread count: decode uses one thread per physical core.

    Closing. If HELIO still sees a slow first reply on the real model, please reopen with the new numbers.


    Generated by Claude Code

  7. added a commit that references this issue on Oct 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions