Repository navigation
Conversation
bcc035e to
bb5bc5e
Compare
… a Zen 3) bf16_rows_dot_multi held its per-token sums in __m256 acc[MAXT] indexed by a variable. With eight 256-bit accumulators live GCC also unrolled the column loop and left the whole array in memory, so every token's FMA read and wrote a stack slot (vmovaps 0xc0(%rsp) ... vfmadd213ps 0xe0(%rsp)). On a Ryzen 7 5700X3D one layer's router [512 x 2560] at 4 tokens took 0.41 ms against 0.25 ms for the AVX1 copy in kq_avx1.cpp that kept its accumulators in registers, so every AVX2 CPU ran the slower of its two kernels (router_dot_parity --cpu 2, median of 3). Pragma GCC unroll 1 on the column loop changes nothing; the variable-indexed array is what forces the array into memory. Four tokens per pass, in four named accumulators. A row above four tokens is read a second time, from L1: nt=8 measures 0.37 ms, the same as the spilled form. On the same CPU the AVX2 kernel goes 0.41 -> 0.19 ms at 4 tokens and nt=2 0.36 -> 0.16 ms, and it now runs 1.3x ahead of the AVX1 copy instead of 1.6x behind it. The loop body carries no stack slot any more (was 21 memory ops per iteration). Untouched: the arithmetic and the reduction. Each token's FMA chain still runs over the same columns in the same order and ends in the same hsum_float_8, so every sum is bit-identical - the output for nt 1/2/4/8 at cols 2560/5120 hashes the same before and after (FNV-1a over the whole out[]), and router_dot_parity reports 0 failures with the AVX2 rel error unchanged at 3.16e-07. Left alone: the expert row kernels (iq_avx2.cpp, q2_avx2.cpp, kq256_gu_rows, kq256_rows) and the AVX1 path. The iq_avx2_parity bench over four format pairs x 1..8 tokens moved within 1.6% (noise), and the CPU parity tests (8 targets) pass. The router stays what it was, an estimate for which experts to prefetch. CUDA and HIP do not compile this file; the SYCL port does, and it builds there under icpx 2026.1 with no new warnings and no spills either. The AVX1 copy in kq_avx1.cpp kept a comment saying it reads its rows "exactly as the AVX2 kernel does"; with the row loop chunked four tokens at a time that is no longer the AVX2 kernel's shape, so the clause is dropped there. Comment only - no code change.
bb5bc5e to
fd0387f
Compare
…cumulators in registers (2.1x on a Zen 3) cpu: the AVX2 router dot keeps its accumulators in registers (2.1x on a Zen 3) bf16_rows_dot_multi held its per-token sums in __m256 acc[MAXT] indexed by a variable. With eight 256-bit accumulators live GCC also unrolled the column loop and left the whole array in memory, so every token's FMA read and wrote a stack slot (vmovaps 0xc0(%rsp) ... vfmadd213ps 0xe0(%rsp)). On a Ryzen 7 5700X3D one layer's router [512 x 2560] at 4 tokens took 0.41 ms against 0.25 ms for the AVX1 copy in kq_avx1.cpp that kept its accumulators in registers, so every AVX2 CPU ran the slower of its two kernels (router_dot_parity --cpu 2, median of 3). Pragma GCC unroll 1 on the column loop changes nothing; the variable-indexed array is what forces the array into memory. Four tokens per pass, in four named accumulators. A row above four tokens is read a second time, from L1: nt=8 measures 0.37 ms, the same as the spilled form. On the same CPU the AVX2 kernel goes 0.41 -> 0.19 ms at 4 tokens and nt=2 0.36 -> 0.16 ms, and it now runs 1.3x ahead of the AVX1 copy instead of 1.6x behind it. The loop body carries no stack slot any more (was 21 memory ops per iteration). Untouched: the arithmetic and the reduction. Each token's FMA chain still runs over the same columns in the same order and ends in the same hsum_float_8, so every sum is bit-identical - the output for nt 1/2/4/8 at cols 2560/5120 hashes the same before and after (FNV-1a over the whole out[]), and router_dot_parity reports 0 failures with the AVX2 rel error unchanged at 3.16e-07. Left alone: the expert row kernels (iq_avx2.cpp, q2_avx2.cpp, kq256_gu_rows, kq256_rows) and the AVX1 path. The iq_avx2_parity bench over four format pairs x 1..8 tokens moved within 1.6% (noise), and the CPU parity tests (8 targets) pass. The router stays what it was, an estimate for which experts to prefetch. CUDA and HIP do not compile this file; the SYCL port does, and it builds there under icpx 2026.1 with no new warnings and no spills either. The AVX1 copy in kq_avx1.cpp kept a comment saying it reads its rows "exactly as the AVX2 kernel does"; with the row loop chunked four tokens at a time that is no longer the AVX2 kernel's shape, so the clause is dropped there. Comment only - no code change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0157fjixQD2nRxvCvtXq66DD
q5_1_dot and q8_0_dot in kq_avx2.cpp held their per-token sums in the same runtime-indexed __m256 acc[MAXT] that made bf16_rows_dot_multi spill: with the array live the compiler leaves it in memory, and every token's FMA reads and writes a stack slot. Both are now template <int NT>, dispatched 1..4 and, past that, run four tokens per pass with the row's bytes re-read from L1 - the same idiom the router change above and kq256_gu_rows already use. Ryzen 7 9800X3D (Zen 5), GCC 15.2.0, build/native_expert_parity --synthetic (ggml-quantized weights and activations), one thread pinned to core 0, at the verify window's four tokens: Q5_1 down rows [2560 x 640]: 190-197 us -> 155-156 us (1.24x) Q8_0 down rows [2560 x 640]: 135 us -> 104 us (1.30x) Same sums: 0 of 36 token-sets differ in any bit against ggml's own vec_dot over 1..8 tokens, both before and after; native_expert_parity reports 0 failures. Left alone: q4k_dot keeps its runtime-nt array. Its token accumulators are not what spills - the unpatched function carries 8 ymm stack ops for the sums against 24 (q5_1) and 59 (q8_0); what q4k_dot spills is its weight-side unpack arrays (q4l/q4h/scale_l/scale_h), which token chunking does not touch and re-does. The same change measured +5% at four tokens and -4..-7% at six to eight, so it is a wash at the window and a small regression above it, and q4k is the gate/up format that runs most. The AVX1 path, the iq_avx2/q2_avx2 rows and the engine's dispatch are untouched. Backends: CUDA and HIP do not compile this file. The SYCL port does; not built on this machine (no icpx here) - the router change in this branch built there. Tests: native_expert_parity --synthetic q4_K/q5_1 and q4_K/q8_0 (0 failures, 0 bits differ), iq_avx2_parity, iq_avx1_parity, q8k_quant_parity, router_dot_parity - all exit 0.
|
Tested on an RTX 4060 Laptop GPU (8 GB VRAM), Ryzen 7 7735HS, 62 GB RAM, Ubuntu 24.04 (no swap), CUDA 13.0 / driver 580.178.04. On 8 GB VRAM the Qwen3.6 family's experts don't fit, so I exercised its q36 path (decode on the CPU pool, 0 expert slots): Correctness: bit-exact — the greedy output hash on a fixed smoke prompt is unchanged before/after (sha256 Performance: flat on this path, as expected. The 2.1x router and +20-24% expert-row gains are on the main engine's CPU kernels, which I didn't run here. |
|
Thanks Alex. We ported both commits as they are (authorship kept). Router dot and the Q5_1/Q8_0 rows give the same bits as before in our identity gates (10/10 prompts and 4K/20K prompt dumps identical on a 3060, an A4000 and an R9700 box). On our boxes (GCC 13.3) the router dot goes 0.168 -> 0.153 ms on a 5600X, 0.159 -> 0.154 on a Zen 4, 0.216 -> 0.212 on a Zen 2 (the 2x you saw needs the spilling compiler), and Q5_1/Q8_0 rows at 4 tokens gain about 4-5%. It is default with no switch since it cannot change an answer. |
Upstream 0.1.45 took in ten of the PRs we carried: Niko1221#567 (prompt encoder), Niko1221#1164 (sibling borrow), Niko1221#1166, Niko1221#1368, Niko1221#1367, Niko1221#1120 (split overlap), Niko1221#1912, Niko1221#1888, Niko1221#1949, Niko1221#1305. Their versions are kept and our copies dropped: verify.cpp's duplicate prelaunch/handoff block, verify_kernels.hpp, the qsa prompt kernels, gr_upmix_parity, the tokenizer and its tests (tools/test_strata_tokenizer_upstream.py is gone: upstream's test file is the one now), the second quantize_scatter declaration. README credits updated. Pool parts kept on top: - conversation cache: upstream renamed the borrow guard (pin -> guard); clear() resets it. - prefill: the remote_next hand-off beside upstream's hand-off pieces/worker (a pool stage has no pieces: the whole chunk goes to the workers). - generate.cpp: --pool-affinity measure over a node's own layers; the node's dense-range load beside STRATA_HC_REQ8; the carve on primary_device over the pool range; checkpoints keep pool_id (STRATA_CKPT_POOL/THREADS' parallel save is skipped on a pool PC); the workers' retain after eviction and after a resume; the borrow/restore of parked conversations on the workers with upstream's --conversation-cache-park-gain; Niko1221#652's emit count in upstream's merged windows. - expert_source: our budget retry beside upstream's lend budget. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0157fjixQD2nRxvCvtXq66DD
|
Thanks for the pull request. Your change is already in If part of your work did not make it in and you want it considered, open a new pull request on top of |
Title
cpu(bug): the AVX2 dot kernels keep their accumulators in registers (2.1x router; +20-24% expert rows at 4 tokens)
Issue: none — no existing issue covers this (I searched upstream for router dot / AVX2 / spill / kq_avx2; the open CPU-pool ones, #921 #1549 #1913 #1923, are about other problems). Happy to file one and link it.
Summary
bf16_rows_dot_multi(the routing-aware prefetch's router dot,kq_avx2.cpp) held its per-token sums in__m256 acc[MAXT]indexed by a variable. With eight 256-bit accumulators live, neither GCC nor clang keeps them in registers — the array-indexed accumulator is a shape no mainstream compiler undoes by itself: GCC unrolls the column loop and leaves the whole array in memory, and clang spills it too (its object carries the same stack-slot FMA plus a per-rowmemset/memcpycall). Every token's FMA then read and wrote a stack slot. On a Ryzen 7 5700X3D that made the AVX2 kernel slower than the AVX1 copy written for pre-Haswell CPUs, and every AVX2 CPU calls the AVX2 one (expert_source.cpp:2271). The same effect measures on a Zen 5 Ryzen 7 9800X3D (2.06x on GCC, 3.2x on clang), so this is every AVX2 CPU on both compilers, not one GCC version's codegen on one core.Same sums, same tokens, four named accumulators instead of the array:
Ryzen 7 5700X3D (Zen 3, AVX2, no AVX-512, no AVX-VNNI), one layer's router
[512 experts x 2560 embeddings], 4 tokens, one thread pinned to core 2,router_dot_parity --cpu 2, median of 3:bf16_rows_dot_multibf16_rows_dot_multi_avx1(untouched)out[], nt 1/2/4/8 x cols 2560/5120)Ryzen 7 9800X3D (Zen 5, AVX2 + AVX-512), same bench, GCC 15.2.0 (the build's default compiler on this PC), one thread pinned to core 0, median of 3:
bf16_rows_dot_multibf16_rows_dot_multi_avx1(untouched)router_dot_parityfailuresThe compiler is not the variable — the array-indexed accumulator is. Both spill before the change (9800X3D, this file's exact flags
-O3 -DNDEBUG -mavx2 -mfma -mf16c, 4 tokens pinned to core 0):clang's unpatched object shows the same shape at the instruction level —
vfmadd213ps 0x60(%rsp,%rsi,1),%ymm0,%ymm1followed byvmovaps %ymm1,0x60(%rsp,%rsi,1)— so neither compiler recovers the registers itself, and on the Zen 5 box the clang build is slower still because of the per-rowmemset/memcpycall. With the accumulators in named registers both compilers land on the identical 0.124 ms, which is the point: the source shape, not the compiler, was the limit. It is not a flag problem either: the build carries no-march=native(the CPU kernel files get explicit per-file ISA flags), and adding it would not changekq_avx2.cpp's codegen.The expert-row dots: the same shape, the same fix
q5_1_dotandq8_0_dot(the Q5_1 and Q8_0 down-projection rows,kq_avx2.cpp) held their per-token sums in the same runtime-indexed__m256 acc[MAXT]. Both are nowtemplate <int NT>, dispatched 1..4 and chunked four tokens at a time past that - the same idiom the router change andkq256_gu_rowsalready use. Measured the same way (9800X3D, GCC 15.2.0,native_expert_parity --syntheticwith ggml-quantized weights and activations, one thread on core 0), at the verify window's four tokens:q5_1_dotq8_0_dotSums unchanged: 0 of 36 token-sets differ in any bit against ggml's own
vec_dotover 1..8 tokens, both before and after;native_expert_parity: 0 failures.Why
q4k_dotis left alone. The Q4_K gate/up dot keeps its runtime-nt array on purpose. Its token accumulators are not what spills - the unpatched function carries 8 ymm stack ops for the sums, against 24 (q5_1) and 59 (q8_0); what it spills is its weight-side unpack arrays (q4l/q4h/scale_l/scale_h), which token chunking does not touch and re-does. The same change measured +5% at four tokens and -4..-7% at six to eight, so it is a wash at the window and a small regression above it, and q4k is the gate/up format that runs most.What changed
src/kernels/cpu/kq_avx2.cpp:bf16_rows_dot_multi(the router), andq5_1_dot+q8_0_dot(the Q5_1 and Q8_0 down-projection rows); plus one comment line inkq_avx1.cpp.for (int t0 = 0; t0 < nt; t0 += 4)), the same idiomkq256_gu_rowsalready uses in this file, and each pass keeps its sums in four named__m256accumulators (a0..a3) instead of a variable-indexed__m256 acc[MAXT]. Four live accumulators fit in registers alongside the weight vector, so the column loop can unroll without the array going to memory — under GCC and clang alike.#pragma GCC unroll 1on the column loop is not part of the change: it changes nothing. GCC keeps the column unroll and the spills either way; the variable-indexed array is what forces the array into memory.What it leaves alone. The arithmetic and the reduction. Each token's FMA chain still runs over the same columns in the same order and ends in the same
hsum_float_8, so every sum is bit-identical. The AVX1 kernel's code,q4k_dot(see above), the other expert row kernels (iq_avx2.cpp,q2_avx2.cpp,kq256_gu_rows,kq256_rows) and the engine's dispatch are untouched — the singlekq_avx1.cppline is its comment only, which had said the AVX1 kernel reads its rows "exactly as the AVX2 kernel does" and is no longer true once the AVX2 kernel chunks its row loop four tokens at a time. The router stays what it was — an estimate for which experts to prefetch, not part of the model's output.Extra Notes
How I checked:
out[]hashes the same (FNV-1a) before and after for nt 1/2/4/8 at cols 2560 and 5120, 8 of 8.router_dot_parityreports 0 failures with the AVX2 rel error against the double reference unchanged at 3.16e-07.ctest -R 'router_dot|iq_avx|q8k_quant|q2_bitplane'— 100% tests passed, 0 failed out of 8.iq_avx2_parity --benchrun over four format pairs x 1..8 tokens (255 MB of experts streamed from DRAM, one thread) moved within 1.6% across all 64 medians, which is noise; the expert path does not call this function.native_expert_parity --synthetic q4_K/q5_1andq4_K/q8_0report0 of 36 token-sets differin any bit and 0 failures over 1..8 tokens;iq_avx2_parity,iq_avx1_parity,q8k_quant_parityandrouter_dot_parityall exit 0. One thread pinned to core 0 throughout.sycl/CMakeLists.txt:176), andstrata_kernels_cpubuilds there under icpx 2026.1 (exit 0, no new warnings, and its object has no spills either). The expert-row change added since has not been through that SYCL build (no icpx on the measuring machine); it is the same file and the same kind of change.The benchmark, from a source build (CMake fetches the pinned llama.cpp commit for ggml-cpu when
STRATA_GGML_DIRis not given):This change, one thread pinned to one core (the first table is core 2 on the 5700X3D, the second core 0 on the 9800X3D; any core works, the medians move a little):
Both print
router_dot_parity: 0 failures. The compiler comparison, in a second build folder with the same file flags:The expert rows, for the no-regression check:
--cpu Npins inside the tool as well,--reps N(default 5) sets the repetitions behind each median,--pairs iq3_s/iq4_nl,...picks the format pairs and--mb 256the expert bytes streamed from DRAM per pair.