Skip to content

perf(qwen35-cuda): decode GEMV at 223–504 GB/s vs llama.cpp MMVQ 776–894 — Q5_K 3.5×, Q4_K/Q6_K 1.75× slower (79% of the 4B decode gap) #5221

Open

Description

@noahgift

Follow-up from the #4220 sweep (issuecomment-6127623356). Qwen3.5 decode on CUDA runs at 0.47–0.56× ollama on every size, 0.8B through 27B. The ratio does not improve at 27B, so launch overhead is not the main cause; the cause is kernel bandwidth.

Measured: nsys kernel sums, Qwen3.5-4B Q4_K_M, RTX 4090, 255 decode steps on each engine

weight type (bytes in file) apr kernel apr ms apr GB/s llama.cpp kernel llama.cpp ms llama.cpp GB/s apr slower by
Q4_K (1.361 GB; 131 tensors) mwv_q4k_gemv 760.4 456 mul_mat_vec_q<12> 436.1 799 1.74×
Q5_K (0.519 GB; 48 × attn_qkv) q5k_gemv_warp_reduce 593.9 223 mul_mat_vec_q<13> 171.3 776 3.47×
Q6_K (0.842 GB; 16 × ffn_down, 5 × attn_v, tied LM head) mwv_q6k_gemv 425.8 504 mul_mat_vec_q<14> 241.1 894 1.77×
Q8_0 (4 MB; ssm_alpha/ssm_beta) q8_0_gemv_warp_reduce 87.1 — (7.1 µs/call) mul_mat_vec_q<8> 23.4 — (1.9 µs/call) 3.7×

Totals:

  • All GPU kernels: apr 2423 ms, about 9.5 ms per token; llama.cpp 1160 ms, about 4.5 ms per token.
  • GEMV kernels make up 996 ms (79%) of the 1263 ms gap.
  • The Q5_K kernel alone is 423 ms of it, the largest single line. It is also the only weight type still on the old warp_reduce kernel; Q4_K and Q6_K run on mwv.

What this ticket is

Raise the decode GEMV kernels toward llama.cpp's MMVQ bandwidth on the Qwen3.5 decode path, in this order:

  1. Q5_K: move to the multi-warp (mwv) shape, or to a DP4A shape.
  2. Q4_K / Q6_K: close the 1.75× gap.
  3. Q8_0: the small-matrix calls are latency-bound; fuse or batch the alpha/beta projections.

Acceptance:

  • A before/after nsys kernel table like the one above, from the pinned binary (version + sha).
  • apr serve decode tok/s against ollama, measured with scripts/crux_ollama_parity.sh (on spike/4220-crux-ollama-parity).
  • A correctness falsifier: greedy text identical, or a stated tolerance.

Not in scope:

Other findings from the same trace:

  • The Gated DeltaNet recurrence is the other 20% of the gap. gdn_delta_rule_recurrence takes 273.7 ms (44.7 µs/call); llama.cpp's gated_delta_net_cuda<128> takes 15.6 ms (2.5 µs/call), so apr is 17.5× slower there. Tracked separately.
  • apr profile refuses qwen35. It goes through the dense loader and fails with "Load it with apr run", so this profile used nsys.

Refs #4220. Parent epic #3598.

Activity

  1. noahgift commented on Oct 12, 2026

    @noahgift
    MemberAuthor

    Receipt: multi-warp Q5_K GEMV, branch perf/5221-qwen35-decode-gemv-bw @ 848961b (branch-only, no PR)

    Change. On the decode path, q5k_gemv_into now dispatches a new mwv_q5k_gemv kernel (MultiWarpQ5KGemvKernel, 3 warps by default, MWV_WARPS override) when k % 256 == 0. Before this, the single-warp q5k_gemv_warp_reduce was the slowest per-byte GEMV in the Qwen3.5 decode step. The old kernel still handles any other K.

    Kernel time. Qwen3.5-4B-Q4_K_M, RTX 4090, greedy, 256 tokens, measured with nsys -t cuda --cuda-graph-trace=node and cuda_gpu_kern_sum:

    before (03d4af1) after (848961b)
    Q5_K GEMV per call 48.5 µs (q5k_gemv_warp_reduce, about 223 GB/s) 21.9 µs (mwv_q5k_gemv, about 494 GB/s), 2.21×
    all kernels per token 9.50 ms 8.28 ms (−12.9%)

    MWV_WARPS sweep (all kernels, ms over the run).

    warps total q5k µs q4k µs q6k µs
    2 2201.5 23.1 23.0 86.8
    3 2118.7 21.9 22.4 78.2
    4 2096.7 22.8 22.5 75.7
    8 2341.1 28.5 24.5 94.8

    The default stays at 3. Warp count is not the lever. The remaining gap to llama.cpp's Q5_K (about 776 GB/s) is load granularity: the kernel loads one byte at a time where the Q4_K mwv vectorized kernel issues u32 loads. That vectorized Q5_K version is the next step.

    End to end (crux_ollama_parity.sh, the same GGUF on the same host, 3 × 128-token decode):

    decode-1 decode-2 decode-3 ratio vs ollama
    apr 03d4af1 102.3 102.4 102.4 0.51× (ollama 197.8–199.8)
    apr 848961b 118.4 118.1 118.5 0.60× (ollama 193.6–198.2)

    That is +15.5% decode tok/s. The greedy 256-token text is byte-identical before and after; only the timing line differs.

    Tests.

    • aprender-gpu --features cuda: the mwv_q5k tests pass 6/6. They cover the build at 4B shapes, the parameters, the shared-memory size, barrier safety for every warp count, the kernel name and the layout offsets.
    • aprender-serve --features cuda --lib -- q5k gemv_entry_name: 122/122 pass.
    • Two new golden GPU tests run q5k_gemv_into on the multi-warp path against gguf-py values: a one-hot test over super-blocks 0, 5 and 10, and a ramp dot-product test.
    • Mutation check: forcing the sub-block index to 0 makes both golden tests fail. The file was restored afterwards.

    The doc comment in q5k/mod.rs gives the wrong Q5_K layout (qs at 16, qh at 144). The ggml layout is d, dmin, scales[12], qh[32] at 16, qs[128] at 48; the code and the new kernel follow ggml.

  2. added this to the 0.71.1 milestone on Oct 12, 2026
  3. noahgift commented on Oct 14, 2026

    @noahgift
    MemberAuthor

    merged into #5305 (master ticket for epic #3598; triage rule 7b, 2026-10-14). It is a checklist row there; reopen this issue when work on it starts or its PR opens.

  4. noahgift commented on Oct 14, 2026

    @noahgift
    MemberAuthor

    Moved: this sub-ticket is now a row in #5320 (Qwen3.5 CUDA decode speed: close the GEMV and DeltaNet gaps to llama.cpp), split out of #5305 by theme.

  5. modified the milestones: 0.71.1, 0.73.0 on Oct 27, 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

    P1High priority

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions