Repository navigation
perf(qwen36): int8-during-load dense weights, SIMD f16/bf16 conversion, wire slot_ensure_int8 onto #1271's unpack_int4_to_int8 - #1716
Conversation
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).
|
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 |
|
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 What does not build is dev's new |
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).
|
Pushed the rebase onto current Also ran the three checks you asked for, against Tiny oracle, token-exact (
Load time + peak RSS on the real 35B (
~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 ( |
|
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. |
Title:
perf(qwen36): int8-during-load dense weights, SIMD f16/bf16 conversion, wire slot_ensure_int8 onto #1271's unpack_int4_to_int8Summary
QWstruct +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) arequantized 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 withQWvalues embeddeddirectly on each weight field;
matmul_ddispatches onQW.q != NULLdirectly, no scan needed.
conversion in
c/st.h, wired intost_read_f32/st_read_slice_f32.slot_ensure_int8now calls qwen36: vectorise the int4 expert unpack (2.10x CPU decode, bit-exact) #1271's already-existing, already-vectorizedunpack_int4_to_int8()(three times, once perg4/u4/d4buffer --these are three independently-
malloc'd buffers here, unlike qwen36: vectorise the int4 expert unpack (2.10x CPU decode, bit-exact) #1271's onecontiguous
rawbuffer inload_expert_merged) instead of its ownseparate, 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. Nonew vectorization written.
Side effect of removing
g_qdw[]:devmoved substantially while this satunmerged, 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, coveringlmhead/dnprojand laterdnout/attnproj/shexp) and a dense-trunk integer-dot path (idot.h:COLI_DENSE_IDOT/COLI_DENSE_BITS/COLI_DENSE_INT4, int8-activation xint8- 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
QWitself with the int4-planar fields (
q4/sg/ng)g_qdwcarried, givingqw_quantize/load_tqatagparameter forCOLI_DENSE_INT4'sper-component selection, and rewriting every one of those lookups
(
matmul_d,qdw_bytes,qdw_place,trunk_probe_gpu_wins) to read theQWpointer directly -- same behavior, no registry needed since eachQWalready carries its own state. Two of dev's own new tests
(
test_qwen36_dense_idot,test_qwen36_trunk_dense) exercise this; bothare included here, ported from the raw-pointer/registry API they were
written against to the
QWdispatch 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) isbuilt 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_sfield would misrepresentwhat 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 GiBRAM, local ext4 SSD (no network storage). Baseline =
origin/dev(a2e578c,this PR's merge-base); patched = this PR (
2c7979f). Machine load duringmeasurement: 1-minute average 4.3-4.8 (not an idle box).
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 laterhanded 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):
dev(a2e578c)2c7979f)~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 measuressomething narrower:
ru_maxrssaround 48 uniform synthetic 4096x4096matrices in isolation, nothing else in the arena. On the real model,
dev'speak (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 everypage 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_simdBoth benchmarks are self-contained/synthetic (no real model file needed) at
realistic Qwen3.6 dense-matrix scale.
5 runs, alternated (old/new/old/new per run, not 5x old then 5x new), median
dd1cf4e, machine load 3.0-3.6.Peak RSS during load (
getrusage(RUSAGE_SELF).ru_maxrss, N=48 matrices x4096x4096 f32, 64 MiB each):
spread 3841-3843)
QW/load_tq, streamed): median 834 MiB (5-run spread833-834)
above for why the real-model number is lower.
f16 -> f32 SIMD conversion (N=4,194,304 elements, 8 MiB source):
branching that blocked auto-vectorization.
bf16 -> f32 SIMD conversion:
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 newvectorization 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_batchalso measured 2.3-2.9x speedup on the dense-int8-GEMV-batch kernel
(
matmul_q_batch) and the shared-expert-batch plumbing -- that kernelalready exists in
dev, independent of this PR; this diff only changes thecall site (
g_qdw[i].q->w->q), not the kernel itself. That win doesn'tbelong to this work and isn't claimed here.
Verification
test_st_f16_bf16_simd(+_sse41variant, forced onto SSE4.1): all65,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 removedold 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-expertquantize/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
QWdispatch. All three ported herefrom the raw-pointer API they were written/merged against.
make check: pass, 1390 tests, 0 new warnings.make_qwen36_tiny.py --ref-mode full+convert_qwen36.py --ebits 8,COLI_DENSE_I8=0as the existing CI jobruns it): 16/16 at cap=1, cap=2, cap=8.
make test-asan(ASan + UBSan): clean. Caught two real bugs during thiswork, both fixed: an uninitialized
QW.win a ported test thatqw_freethen 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 pickedinterxhiddendimensions that were both odd, making the total nibblecount 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.
SNAP=./glm_tiny TF=1 ./colibri 64 16 16): 32/32teacher-forcing + 20/20 greedy -- unchanged, since this PR only changes
loading-time memory shape and conversion speed, never what gets computed.