qwen36: vectorise the int4 expert unpack (2.10x CPU decode, bit-exact) - #1271
Conversation
Every expert cache miss unpacks a packed-int4 expert to int8 in the slot, and the
loop doing it was indexed by ELEMENT:
for (i = 0; i < want_w; i++) {
uint8_t byte = raw[i >> 1];
int8_t v = (i & 1) ? ((byte >> 4) & 0xF) : (byte & 0xF);
if (v & 8) v -= 16;
s->g[i] = v;
}
want_w is 3 * inter * hidden = 6,291,456 for Qwen3.6-35B-A3B, so that is 6.29M
iterations per miss, each reloading raw[i>>1], doing an i&1 select and taking a
branch.
Two changes. Walking BYTES and sign-extending by shifting removes the branch.
Vectorising then needs an INTERLEAVING store -- the two nibble streams are
consecutive in the output -- which is why no compiler reaches it from the scalar
form; checking the disassembly after the branchless rewrite showed zero vector
registers. Written explicitly it emits vst2q on NEON and vpunpcklbw/vpunpckhbw
on AVX2, with the scalar form kept as the tail and the portable fallback.
Bit-exact by construction, not by tolerance: this is integer, so unlike the float
reductions in JustVugg#442 there is no reassociation question. Verified identical to the
original branching form over all 256 byte values, at every length around a vector
boundary, and on a full-size 6.29M-value random expert, on both AVX2 and NEON.
Standalone kernel, single-threaded: 11.84 -> 0.18 ms on an i9-12900K, 3.19 -> 0.08
ms on an Apple M4 Pro.
End to end, Qwen3.6-35B-A3B int4-gs64, i9-12900K (avx-vnni, no AVX-512), Debian
trixie, gcc 14.2, Samsung 990 Pro ext4, container fully in page cache, cap 32,
N_NEW=48, MemoryMax=40G, three runs per rung, median:
original 1.66 tok/s 28.7s decode TTFT 7.84s
branchless 2.60 tok/s 18.5s TTFT 5.21s
vectorised 3.49 tok/s 13.8s TTFT 3.93s = 2.10x
SNAP=$M TOK=$M/tokenizer.json N_NEW=48 ./qwen36 32 4 prompt.txt
Expert miss count is 6824 in every one of those runs, to the unit, and the hit
rate never moves: placement behaviour is untouched, only what a miss costs.
Generated text is character-identical on the real model, and the tiny fixture
scores the same as before the change.
Located rather than guessed: decode time on this model is a straight line in miss
count, time = 11.1s + 2.62ms * misses, predicting LRU, PILOT and pinned configs
within 1.1%, which put ~60% of decode in the per-miss path. Squeezing the cgroup
from MemoryMax 40G to 15G against a 22 GB container moved it by -0.015 ms/miss,
i.e. not at all, which said that cost was CPU rather than I/O. After the change
the same fit reads 10.9s fixed + 0.43 ms/miss -- fixed compute unchanged, as it
must be, and the per-miss term down 6.1x. The unpack is now 21% of decode instead
of 62%.
…d nothing Capping the cgroup from 40G to 15G against a 22 GB container left decode flat, and I read that as evidence the per-miss cost was CPU rather than I/O. It is not. Bracketing the runs against /proc/diskstats shows 9.49 GB of device reads at every memory level, identical to two decimals: the cap never produced a differential, because even 40G cannot hold a 22 GB container alongside ~12 GB of anonymous memory. Every rung was equally uncached, so there was no contrast to measure. The conclusion survives on other evidence -- vectorising an integer unpack gave 2.10x end to end, which a storage-bound workload cannot do, and the 9.49 GB is ~1.6s of a 28.4s decode and is essentially the one-time dense load. But the argument as written did not support it, and the same reasoning had already gone into PR JustVugg#1271 and a comment on JustVugg#864, both now corrected.
There was a problem hiding this comment.
@lornecodes, I felt something weird because I only see SSE4.1, SSE3 or SSE2 intrinsics in the AVX2 path.
| #if defined(__AVX2__) | ||
| const __m128i m4 = _mm_set1_epi8(0x0F), e8 = _mm_set1_epi8(8); | ||
| for (; b + 16 <= nb; b += 16) { | ||
| __m128i by = _mm_loadu_si128((const __m128i *)(raw + b)); | ||
| __m128i lo = _mm_and_si128(by, m4); | ||
| __m128i hi = _mm_and_si128(_mm_srli_epi16(by, 4), m4); /* 16-bit shift: mask after */ | ||
| lo = _mm_sub_epi8(_mm_xor_si128(lo, e8), e8); | ||
| hi = _mm_sub_epi8(_mm_xor_si128(hi, e8), e8); | ||
| _mm_storeu_si128((__m128i *)(out + 2 * b), _mm_unpacklo_epi8(lo, hi)); | ||
| _mm_storeu_si128((__m128i *)(out + 2 * b + 16), _mm_unpackhi_epi8(lo, hi)); | ||
| } |
There was a problem hiding this comment.
🤔 Why you didn't take advantage of 256 bits for AVX2 though? We need an SSE4.1/SSE4.2/SSSE3 path here.
|
Merging. I verified the bit-exactness claim independently rather than reading it: a standalone harness comparing your Kernel timing on my box, gcc -O2 -mavx2: 11.01 ms to 1.00 ms. Your machine's before-figure (11.84 ms) lines up with mine closely enough that the comparison is the same one. Also checked: only The part I want on the record is how you found it. A fit of decode time against miss count, The 9.49 GB of device reads from @OPS-NeoRetro's sequencing was right and has been followed: #1232 is merged, and #1239 has been reviewed — it is red on two defects in its own test scaffolding (a memcmp assertion that only holds when the compiler contracts FMA, and |
Every expert cache miss in
qwen36.cunpacks a packed-int4 expert to int8 in the slot, and the loop doing it was indexed by element:want_wis3 * inter * hidden= 6,291,456 for Qwen3.6-35B-A3B — 6.29M iterations per miss, each reloadingraw[i>>1], doing ani&1select, and taking a branch.Two changes, in order. Walking bytes and sign-extending by shifting removes the branch. Vectorising then needs an interleaving store, because the two nibble streams land consecutively in the output — which is why no compiler reaches it from the scalar form. Checking the disassembly after the branchless rewrite confirmed zero vector registers in the loop. Written explicitly it emits
vst2qon NEON andvpunpcklbw/vpunpckhbwon AVX2, with the scalar form kept as the tail and the portable fallback.Bit-exactness
Integer throughout, so unlike the float reductions in #442 there is no reassociation question — this is exact by construction rather than by tolerance. In vectors the signed value of a nibble
nis(n ^ 8) - 8; the scalar tail gets the same result by casting the nibble into the top four bits and arithmetic-shifting back down.Verified identical to the original branching form (not merely to the intermediate one): over all 256 byte values, at every length around a vector boundary, and on a full-size 6.29M-value random expert, on both AVX2 and NEON.
Measurements
Standalone kernel, single-threaded: 11.84 → 0.18 ms on an i9-12900K, 3.19 → 0.08 ms on an Apple M4 Pro.
End to end — Qwen3.6-35B-A3B int4-gs64 (
Kreuzzelg/qwen36-35b-a3b-colibri-i4-gs64), i9-12900K (avx-vnni, no AVX-512), Debian trixie, gcc 14.2, Samsung 990 Pro ext4, container fully in page cache,cap 32,N_NEW=48,MemoryMax=40G, three runs per rung, median:2.10x. The expert miss count is 6824 in every one of those runs, to the unit, and the hit rate never moves — placement behaviour is untouched, only what a miss costs. Generated text is character-identical on the real model, and the tiny fixture scores the same as before the change.
How it was located rather than guessed
Decode time on this model is a straight line in miss count —
time = 11.1s + 2.62ms * missespredicts LRU, PILOT and pinned configurations within 1.1% — which put ~60% of decode inside the per-miss path and pointed here.After the change the same fit reads
10.9s fixed + 0.43 ms/miss— fixed compute unchanged, as it must be for a patch touching only the unpack, and the per-miss term down 6.1x. The unpack is now 21% of decode instead of 62%, so the CPU bottleneck for this engine has moved to the matmul and attention path.That the per-miss cost was CPU rather than I/O is confirmed most simply by this patch working: vectorising an integer unpack cannot produce 2.10x on a workload bound by storage. Independently, bracketing the runs against
/proc/diskstatsshows 9.49 GB of device reads per run — about 1.6 s at this drive's measured 5.78 GB/s, so ~6% of the original 28.4 s decode, and essentially accounted for by the one-time dense-weight load (~9.25 GB) rather than by expert misses.Possibly of interest for #939 (very slow cache warmup), since warmup is all misses; and as a datapoint for #442, where the same class of win is blocked on float reassociation that does not apply here.
Checks
make checkon the 12900K: 704 tests, OK (skipped=35)dev(all pre-existing, in the JSON emission path)