Skip to content

perf(qwen36): int8-during-load dense weights, SIMD f16/bf16 conversion, wire slot_ensure_int8 onto #1271's unpack_int4_to_int8 - #1716

Merged
JustVugg merged 5 commits into
JustVugg:devfrom
jtinbergen:sse41-tier-upstream-next-v2
Sep 24, 2026
Merged

JustVugg merged 5 commits into
JustVugg:devfrom
jtinbergen:sse41-tier-upstream-next-v2

Conversation

@jtinbergen

@jtinbergen jtinbergen commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Title: perf(qwen36): int8-during-load dense weights, SIMD f16/bf16 conversion, wire slot_ensure_int8 onto #1271's unpack_int4_to_int8

Summary

  • add a QW struct + load_tq(): dense weights (attn q/k/v/o, MoE gate,
    shared-expert gate/up/down, DeltaNet in-proj qkv/z + out-proj, lm_head) are
    quantized to int8 immediately after each matrix is read during load, freeing
    that matrix's f32 staging buffer before the next one loads -- instead of
    loading every dense matrix as f32 first (peaking at the full f32 footprint)
    and quantizing in one pass afterward. Replaces the old pointer-keyed
    g_qdw[]/qdw_register() post-load registry with QW values embedded
    directly on each weight field; matmul_d dispatches on QW.q != NULL
    directly, no scan needed.
  • add SIMD tiers (AVX2/SSE4.1/scalar) for f16/bf16 -> f32 tensor-block
    conversion in c/st.h, wired into st_read_f32/st_read_slice_f32.
  • slot_ensure_int8 now calls qwen36: vectorise the int4 expert unpack (2.10x CPU decode, bit-exact) #1271's already-existing, already-vectorized
    unpack_int4_to_int8() (three times, once per g4/u4/d4 buffer --
    these are three independently-malloc'd buffers here, unlike qwen36: vectorise the int4 expert unpack (2.10x CPU decode, bit-exact) #1271's one
    contiguous raw buffer in load_expert_merged) instead of its own
    separate, unvectorized scalar nibble-decode loop. Builds on qwen36: vectorise the int4 expert unpack (2.10x CPU decode, bit-exact) #1271, doesn't
    compete with it -- qwen36: vectorise the int4 expert unpack (2.10x CPU decode, bit-exact) #1271 already replaced that loop in load_expert_merged,
    but left slot_ensure_int8's own copy untouched; this closes that gap. No
    new vectorization written.

Side effect of removing g_qdw[]: dev moved substantially while this sat
unmerged, and grew two features against the registry this PR replaces --
a GPU-tier dense-trunk-placement mechanism (qt_trunk_offer/qdw_bytes/
qdw_place/trunk_probe_gpu_wins, covering lmhead/dnproj and later
dnout/attnproj/shexp) and a dense-trunk integer-dot path (idot.h:
COLI_DENSE_IDOT/COLI_DENSE_BITS/COLI_DENSE_INT4, int8-activation x
int8- or int4-block-weight integer GEMV). Both looked a weight up in
g_qdw[] by pointer identity to get its already-quantized rows/scales and,
for the int4 path, its planar int4 copy. Reconciled by extending QW
itself with the int4-planar fields (q4/sg/ng) g_qdw carried, giving
qw_quantize/load_tq a tag parameter for COLI_DENSE_INT4's
per-component selection, and rewriting every one of those lookups
(matmul_d, qdw_bytes, qdw_place, trunk_probe_gpu_wins) to read the
QW pointer directly -- same behavior, no registry needed since each QW
already carries its own state. Two of dev's own new tests
(test_qwen36_dense_idot, test_qwen36_trunk_dense) exercise this; both
are included here, ported from the raw-pointer/registry API they were
written against to the QW dispatch this PR replaces it with.

Scope

Loading-time-only changes: what gets computed and stored once weights are
resident is unchanged. No new numerical behavior anywhere in this PR --
unlike the earlier SSE4.1 GEMV tier (#1239), nothing here needed a tolerance
exception. All three sub-changes are exact reorderings/vectorizations of
already-existing integer or quantization math, memcmp-exact verified against
the scalar/non-streamed behavior they replace -- except f16/bf16 conversion,
which is a closed-form recompute (also exhaustively verified, not sampled).

Measured

No experiment manifest attached: CONTRIBUTING.md's manifest requirement
(c/experiment_manifest.py, docs/experiments/manifest.example.json) is
built around samples.tok_s -- end-to-end decode throughput on a real model,
matching every existing manifest in docs/experiments/ (OMP team sizing,
expert I/O, sparse routing -- all changes that move tokens/second during
generation). This PR changes neither: decode-time behavior and speed are
unchanged (see Scope above), and both measured effects -- peak memory during
load and load-time conversion speed -- are one-time loading-phase costs, not
a tokens/second figure. Forcing them into a tok_s field would misrepresent
what changed. Measured instead with the project's general benchmark-reporting
requirements (commit, exact commands, hardware/storage, warm-up policy, run
count, median throughput).

Real model: load time + peak RSS

qwen36-i4-gs64 (35B), Intel Core Ultra 7 155H (16C/22T, hybrid P/E), 61 GiB
RAM, local ext4 SSD (no network storage). Baseline = origin/dev (a2e578c,
this PR's merge-base); patched = this PR (2c7979f). Machine load during
measurement: 1-minute average 4.3-4.8 (not an idle box).

cd c
SNAP=<qwen36-i4-gs64 path> SERVE=1 OMP_NUM_THREADS=16 \
  /usr/bin/time -v timeout 60 ./qwen36 256 4

Peak RSS via /usr/bin/time -v's "Maximum resident set size" (ru_maxrss,
a true high-water mark, unaffected by whether free()'d pages are later
handed back to the OS). 5 runs per build, alternated, after one untimed
warm-up run per build to equalize page-cache state (no passwordless
cache-drop available on this box):

load time peak RSS
dev (a2e578c) median 7.6s (5-run spread 7.5-7.9s) median 11.03 GB (11569216-11569980 KB)
this PR (2c7979f) median 7.8s (5-run spread 7.6-8.0s) median 4.83 GB (5061968-5062980 KB)

~2.29x less peak memory on the real model. Load time is a wash --
medians within run-to-run noise of each other.

Why this differs from the synthetic micro-benchmark below

The synthetic number (~4.61x, bench_load_tq_peak_rss) is real but measures
something narrower: ru_maxrss around 48 uniform synthetic 4096x4096
matrices in isolation, nothing else in the arena. On the real model, dev's
peak (11.03 GB) already exceeds its own "RSS after load" (9.23 GB, printed
once every f32 original is freed) -- a real transient spike during the
post-hoc quantization pass, but a smaller multiple than the isolated
microbenchmark implies, most likely because free() does not hand every
page back to the OS immediately and the real model's allocation pattern (291
matrices of varied, mostly-modest size, interleaved with everything else the
loader allocates) differs from the synthetic one's. On this PR's branch,
peak and post-load RSS are identical (4.83 GB both) on the real model --
confirming the streamed quantize-then-free pattern never spikes above its
own steady state, which is the actual mechanism and the reason the synthetic
benchmark is directionally right even where its exact ratio doesn't
reproduce.

Synthetic micro-benchmarks (dense-load peak RSS + f16/bf16 SIMD)

Build:

cd c
make tests/bench_load_tq_peak_rss tests/bench_st_f16_bf16_simd

Both benchmarks are self-contained/synthetic (no real model file needed) at
realistic Qwen3.6 dense-matrix scale.

for run in 1 2 3 4 5; do
  ./tests/bench_load_tq_peak_rss old
  ./tests/bench_load_tq_peak_rss new
  ./tests/bench_st_f16_bf16_simd
done

5 runs, alternated (old/new/old/new per run, not 5x old then 5x new), median

  • spread reported. Same hardware, commit dd1cf4e, machine load 3.0-3.6.

Peak RSS during load (getrusage(RUSAGE_SELF).ru_maxrss, N=48 matrices x
4096x4096 f32, 64 MiB each):

  • old pattern (load all f32, then quantize): median 3842 MiB (5-run
    spread 3841-3843)
  • new pattern (QW/load_tq, streamed): median 834 MiB (5-run spread
    833-834)
  • ~4.61x less peak memory in this isolated setting (78% reduction) -- see
    above for why the real-model number is lower.

f16 -> f32 SIMD conversion (N=4,194,304 elements, 8 MiB source):

  • median 2.01x faster (5-run spread 1.94x-2.11x) -- removes the scalar
    branching that blocked auto-vectorization.

bf16 -> f32 SIMD conversion:

  • median 1.00x (5-run spread 0.95x-1.06x) -- no real gain,
    memory-bandwidth-bound; bf16 is exact zero-pad widening in every tier, so
    this result is architecturally expected regardless of hardware. Kept and
    reported honestly per CONTRIBUTING.md's performance-reporting requirement
    (transparency, not filtering for wins) rather than dropped silently.

slot_ensure_int8 -> unpack_int4_to_int8: no new benchmark -- no new
vectorization written, pure reuse of #1271's already-measured, already-existing
kernel on a second call site. See #1271 for that kernel's own 2.10x
end-to-end number.

Not claimed by this PR: while running this work, bench_qwen36_dense_batch
also measured 2.3-2.9x speedup on the dense-int8-GEMV-batch kernel
(matmul_q_batch) and the shared-expert-batch plumbing -- that kernel
already exists in dev, independent of this PR; this diff only changes the
call site (g_qdw[i].q -> w->q), not the kernel itself. That win doesn't
belong to this work and isn't claimed here.

Verification

  • test_st_f16_bf16_simd (+ _sse41 variant, forced onto SSE4.1): all
    65,536 possible f16 and bf16 bit patterns, exhaustively (not sampled),
    every tier, plus odd-offset/length tail slices. memcmp-exact.
  • test_qwen36_slot_int8: regression oracle = a verbatim copy of the removed
    old scalar loop, compared byte-for-byte across 5 shapes (tiny, exact AVX2
    vector boundary, vector+scalar-tail, odd real-expert-shaped, large
    2048x512 real-expert-scale) plus a re-entrancy check. Verified on AVX2 and
    forced-scalar builds.
  • test_qwen36_dense_batch, test_qwen36_dense_idot, test_qwen36_trunk_dense:
    exercise and byte-/relative-compare the QW-based shared-expert
    quantize/free lifecycle, the dense integer-dot path, and GPU-tier
    dense-trunk placement (fake-CUDA backend) against their f32/CPU
    references, all now going through QW dispatch. All three ported here
    from the raw-pointer API they were written/merged against.
  • make check: pass, 1390 tests, 0 new warnings.
  • qwen36 tiny oracle, token-exact (make_qwen36_tiny.py --ref-mode full +
    convert_qwen36.py --ebits 8, COLI_DENSE_I8=0 as the existing CI job
    runs it): 16/16 at cap=1, cap=2, cap=8.
  • make test-asan (ASan + UBSan): clean. Caught two real bugs during this
    work, both fixed: an uninitialized QW.w in a ported test that qw_free
    then freed (double free, actually caught by a plain build before
    sanitizers even ran), and a pre-existing latent bug in this PR's own
    test_qwen36_slot_int8.c -- an "odd, real-expert-shaped" case picked
    interxhidden dimensions that were both odd, making the total nibble
    count odd, which (a) under-allocated the packed test buffer by one byte
    and (b) overran the oracle's output buffer by one nibble on its last
    iteration. Real containers guarantee an even total (SIMD/GS64-aligned
    dims), so the shape cannot occur in production; fixed by picking
    dimensions that keep the "odd, irregular" character without an odd
    total, plus a check pinning that invariant.
  • token-exact oracle (SNAP=./glm_tiny TF=1 ./colibri 64 16 16): 32/32
    teacher-forcing + 20/20 greedy -- unchanged, since this PR only changes
    loading-time memory shape and conversion speed, never what gets computed.

564 commits of drift since this branch's base (04ee745), including
PR JustVugg#1239's own eventual merge into dev. Resolved beyond the textual
conflicts (Makefile test registration, matmul_d call-site & fixes for
the QW-struct signature change): dev independently grew a GPU-tier
dense-trunk placement feature (qt_trunk_offer/qt_lmhead_init/
qt_dnproj_init) against the old g_qdw[]/qdw_register() registry, which
this branch's QW/load_tq redesign had already replaced. Rewrote that
feature's three lookup sites to read the QW struct fields directly
(m.lm_head.q/.sc/.I/.O etc.) instead of scanning a now-removed
registry by pointer identity -- same semantics, no registry needed
since each QW carries its own state. Also fixed one call site
(the echo-logit lm_head matmul) that used m->lm_head without the `&`
the new matmul_d(..., const QW*, ...) signature requires; git's merge
had no conflict marker there since dev added it independently.
Verified: qwen36/colibri/olmoe/qwen38 all compile clean under
-Wall -Wextra, make check green (1228 tests), and the three PR2-owned
correctness gates (test_qwen36_slot_int8, test_st_f16_bf16_simd[_sse41])
included and passing.
test_qwen36_slot_int8.c, test_st_f16_bf16_simd.c,
bench_st_f16_bf16_simd.c and bench_load_tq_peak_rss.c are part of
this branch's dense-int8-during-load/f16-bf16-SIMD/int4-unpack-wiring
work, already relied on by the Makefile, but sat as untracked files
in the worktree instead of a commit. Left the disposable scratch
fixtures (qwen36_tiny_pr2/, qwen36_tiny_pr2_i8/, ref_qwen36_pr2.json)
untracked -- nothing committed references them.
The lmhead GPU-init comment still described a "qdw entry keyed by
m.lm_head" -- accurate before this branch removed the g_qdw[] registry,
misleading now that the lookup reads the QW struct directly.
dev independently grew a "dense trunk integer dot products" feature
(idot.h: int8-activation x int8/int4-block-weight integer GEMV,
COLI_DENSE_IDOT/COLI_DENSE_BITS/COLI_DENSE_INT4) on top of the old
g_qdw[]/qdw_register() registry this branch's QW/load_tq redesign
already replaced, plus extended the GPU-tier dense-trunk placement
(qt_trunk_offer/qdw_bytes/qdw_place) to cover dnout/attnproj/shexp
beyond the lmhead/dnproj this branch already adapted.

Reconciled by extending QW itself with the int4-planar fields
(q4/sg/ng) g_qdw carried, folding qdw_register_as's int4-packing logic
into qw_quantize (now taking a `tag` for COLI_DENSE_INT4 component
selection), and rewriting matmul_d/qdw_bytes/qdw_place/
trunk_probe_gpu_wins to read those fields directly off the QW pointer
instead of scanning a registry -- same behavior dev's own tests
(test_qwen36_dense_idot, test_qwen36_trunk_dense, both new) verify,
now against the QW-struct dispatch rather than g_qdw. Ported both new
test files to build/compare through QW instead of raw float* +
qdw_register. Also fixed one caught-live bug: test_qwen36_dense_idot's
qw_quantize call left QW.w uninitialized, which qw_free then freed --
found via a plain build (double free), not sanitizers.

ASan/UBSan (make test-asan) additionally caught a real bug in our own
test_qwen36_slot_int8.c: the "odd, real-expert-shaped" case picked
inter=37 x hidden=41 (both odd), making ng/nd odd, which (a)
under-allocated random_packed's buffer by one byte (ng/2 truncates)
and (b) old_unpack -- a deliberately-unmodified verbatim copy of the
pre-JustVugg#1271 scalar loop -- unconditionally writes two nibbles per
iteration and overruns by one on an odd total. Real containers
guarantee even inter*hidden (SIMD/GS64-aligned dims), so this shape
cannot occur in production; fixed by picking inter=37 x hidden=42
(odd x even = even) instead of "fixing" the intentionally-verbatim
oracle, plus a CHECK pinning the even-length invariant so a future
dimension change can't reintroduce it silently.

Verified: qwen36/colibri/olmoe/qwen38 compile clean under -Wall
-Wextra, make check green (1237 tests), make test-asan clean under
ASan+UBSan (was independently confirmed by the user running it
directly after this session's own background runs kept getting
killed by something in the local environment, unrelated to this
worktree).
@JustVugg

Copy link
Copy Markdown
Owner

Thank you, and welcome. This one is for after 1.12.1, not because of anything wrong in it but because of where it lands: it changes the default load path of qwen36 (int8 during load) and the shared st.h conversion used by every engine, on top of the integer-dot trunk that merged this week (#1664, #1674, #1677), and it currently conflicts with dev. For a change on the default path the bar here is: the qwen36 tiny oracle token-exact, test_st_f16_bf16_simd bit-exact against the scalar conversion for every f16/bf16 bit pattern including NaN and subnormals, and the load time and peak RSS measured before and after on the real 35B with the machine named. Please rebase on dev once the release is out and post those numbers; the benches you added are the right tools for it.

@JustVugg

Copy link
Copy Markdown
Owner

Tried to get CI running for you: GitHub does not start the workflows while the PR conflicts with dev, so I merged dev into it locally. The three textual conflicts in qwen36.c are mechanical (dev's #1674 and #1677 moved the attention and DeltaNet projections to batched CUDA calls in the same lines; keeping dev's calls with your &l->x arguments resolves them), and on that merge qwen36 builds and test_qwen36_dense_idot, test_qwen36_trunk_dense, test_qwen36_dense_batch, test_qwen36_slot_int8, test_st_f16_bf16_simd and test_qwen36_tier_withdraw pass.

What does not build is dev's new tests/test_qwen36_dnproj_batch.c (#1677): it builds its layer with plain float * weights and qdw_register/g_qdw, which your QW struct replaces. That test belongs to your port, since you know the new registry, so I have not pushed the merge. Please rebase on dev, port that test to QW, and push; CI will then start on its own and I will approve the run. The bar for the default path stays as I wrote above (tiny oracle token-exact, SIMD conversion bit-exact, load time and peak RSS on the real 35B).

Reconciled 3 mechanical conflicts in qwen36.c, all the same shape
JustVugg described on PR JustVugg#1716: dev's JustVugg#1674/JustVugg#1677 moved the attention
q/k/v/o projections and the DeltaNet input projections from per-token
qtd()/qt_dnproj_matmul to batched qtd_batch()/qt_dnproj_matmul_batch
CUDA calls, on the same lines this branch's QW redesign touches. Kept
dev's batched calls, added the `&` on the matmul_d fallback arguments
(l->q -> &l->q etc.) their new const QW* signature requires. Makefile
conflict was purely additive (new SSE4.1 test-binary registrations).

Ported dev's new tests/test_qwen36_dnproj_batch.c (JustVugg#1677) from the
raw-pointer/qdw_register API to QW -- same shape as the two prior
ports (test_qwen36_dense_idot, test_qwen36_trunk_dense): dn_qkv/dn_z
quantized straight into their QW fields via qw_quantize (replacing
qdw_register + reading g_qdw[0]/g_qdw[1] for the combined q[]/sc[]
upload buffer), dn_out left with only .w set since the pre-QW test
never registered it either (always took the f32 matmul_d fallback,
identically on both sides of the cpu/gpu comparison so which path it
takes doesn't matter here), cleanup via qw_free instead of raw free()
+ the removed g_qdw_n registry sweep.

Verified: qwen36/colibri/olmoe/qwen38 compile clean under -Wall
-Wextra, the ported test passes standalone (259 calls -> 2, output
and recurrent state agree across cpu/gpu and all failure-injection/
split-boundary cases), make check green (1390 tests).
@jtinbergen

Copy link
Copy Markdown
Contributor Author

Pushed the rebase onto current dev (2c7979f) with test_qwen36_dnproj_batch.c ported to QW -- thank you for the diagnosis, the three conflicts were exactly what you described (dev's #1674/#1677 batched calls, resolved with &l->x on the matmul_d fallback arguments). make check and make test-asan both clean on the result.

Also ran the three checks you asked for, against origin/dev (a2e578c, this PR's parent) as the baseline:

Tiny oracle, token-exact (make_qwen36_tiny.py --ref-mode full + convert_qwen36.py --ebits 8, COLI_DENSE_I8=0 as the existing CI job does it): 16/16 at cap=1, cap=2, cap=8.

test_st_f16_bf16_simd: bit-exact against the scalar conversion for all 65,536 f16 and bf16 bit patterns (NaN/subnormals included) -- already part of make check.

Load time + peak RSS on the real 35B (qwen36-i4-gs64, Intel Core Ultra 7 155H, local ext4 SSD, SNAP=... SERVE=1 OMP_NUM_THREADS=16 ./qwen36 256 4, peak via /usr/bin/time -v "Maximum resident set size", 5 runs alternated after one untimed warm-up, machine load 4.3-4.8 during the run):

load time peak RSS
dev (a2e578c) median 7.6s (7.5-7.9) median 11.03 GB (11569216-11569980 KB)
this PR (2c7979f) median 7.8s (7.6-8.0) median 4.83 GB (5061968-5062980 KB)

~2.29x less peak memory. Load time is a wash -- essentially identical medians, within run-to-run noise.

Worth flagging directly: the PR description currently cites ~4.61x from a synthetic micro-benchmark (bench_load_tq_peak_rss, 48 synthetic 4096x4096 matrices measuring ru_maxrss around the load-and-quantize pattern in isolation). That number is real but measures something narrower than this one -- and I want to be upfront that it doesn't reproduce on the actual model, rather than let two different numbers for "the same thing" sit in the same PR unexplained. Digging into why: on dev, ru_maxrss on the real model peaks during the post-hoc quantization pass at 11.03 GB, higher than the 9.23 GB "RSS after load" the engine itself reports once quantization finishes and every f32 original is freed -- a real transient spike, but a smaller one than the isolated microbenchmark's ratio implies, likely because free() doesn't hand every page back to the OS immediately and the real model's allocation pattern (291 matrices of varied, mostly-modest size, interleaved with everything else the loader already allocates) differs from the synthetic one (48 uniform 64 MiB matrices, nothing else in the arena). On this PR's branch, peak and post-load RSS are identical (4.83 GB both), confirming the streamed quantize-then-free pattern never spikes above its own steady state -- which is the mechanism, and matches the microbenchmark on that count. I'll update the PR body to lead with this real-model number and keep the synthetic one as a secondary, explicitly-scoped data point rather than the headline.

@JustVugg

Copy link
Copy Markdown
Owner

Measured on our side too, on the real Qwen3.6-35B int4-gs64 (16-core server, heavily loaded at the time, so the load times are noise): perplexity on our 504-token chunk 1.7022 on dev and 1.7022 with this PR, and the 200-token generation is byte-identical (same md5). RSS after load 9.23 GB on dev, 4.83 GB here; the peak of the whole run is the same (17.6 GB), because the experts dominate it. That is the no-regression evidence I asked for, thank you for porting the test and for being upfront about the synthetic 4.6x. Merging once CI is green; it goes into 1.12.1.

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.

2 participants