Skip to content

qwen36: vectorise the int4 expert unpack (2.10x CPU decode, bit-exact) - #1271

Merged
JustVugg merged 2 commits into
JustVugg:devfrom
dawnfield-institute:upstream/qwen36-int4-unpack
Aug 30, 2026
Merged

JustVugg merged 2 commits into
JustVugg:devfrom
dawnfield-institute:upstream/qwen36-int4-unpack

Conversation

@lornecodes

@lornecodes lornecodes commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Every expert cache miss in qwen36.c 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 — 6.29M iterations per miss, each reloading raw[i>>1], doing an i&1 select, 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 vst2q on NEON and vpunpcklbw/vpunpckhbw on 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 n is (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:

SNAP=$M TOK=$M/tokenizer.json N_NEW=48 ./qwen36 32 4 prompt.txt
tok/s decode TTFT
original (branchy scalar) 1.66 28.7 s 7.84 s
branchless scalar 2.60 18.5 s 5.21 s
vectorised 3.49 13.8 s 3.93 s

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 * misses predicts 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/diskstats shows 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 check on the 12900K: 704 tests, OK (skipped=35)
  • Build warnings unchanged: 4 on this branch, the same 4 on pristine dev (all pre-existing, in the JSON emission path)
  • Disassembly confirms the intended instructions on both gcc/AVX2 and clang/NEON

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%.
lornecodes added a commit to dawnfield-institute/colibri that referenced this pull request Aug 29, 2026
…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.

@OPS-NeoRetro OPS-NeoRetro left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lornecodes, I felt something weird because I only see SSE4.1, SSE3 or SSE2 intrinsics in the AVX2 path.

Comment thread c/qwen36.c
Comment on lines +1420 to +1430
#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));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 Why you didn't take advantage of 256 bits for AVX2 though? We need an SSE4.1/SSE4.2/SSSE3 path here.

@OPS-NeoRetro

Copy link
Copy Markdown

@JustVugg, merge #1232 first then take a look at this and #1239

@JustVugg

Copy link
Copy Markdown
Owner

Merging. I verified the bit-exactness claim independently rather than reading it: a standalone harness comparing your unpack_int4_to_int8 against the original branching form (not the intermediate branchless one) over all 256 byte values, every even length from 0 to 1024 so the vector boundary is crossed in both directions, and a full-size 6.49M-value random expert. Identical byte for byte, rerun on the current head after the rebase.

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 qwen36.c is touched, so the blast radius is exactly one engine; token-exact at caps 1, 8 and 16; clean under a genuinely instrumented ASan build; the four qwen36 C tests pass; and the malformed-container refusal still fires.

The part I want on the record is how you found it. A fit of decode time against miss count, 11.1s + 2.62ms * misses predicting LRU, PILOT and pinned configurations within 1.1%, is a measurement that tells you where to look before you look. And re-fitting afterwards to 10.9s + 0.43ms/miss — fixed term unchanged, as it must be for a patch touching only the unpack — is the check most people skip.

The 9.49 GB of device reads from /proc/diskstats, accounted for by the one-time dense load rather than by expert misses, is what makes "this was CPU, not I/O" a demonstration instead of an assertion.

@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 _sse41 targets that pass x86 flags on arm64), both reported there with the isolation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants