qwen38: CUDA VRAM tier for Qwen3.8-Flash-Next -- hot experts, and the dense trunk as int8 residents (+43 % on one 8 GB card, +60 % on two) - #1424
Merged
Conversation
kreuzzelg
force-pushed
the
qwen38-cuda-tier
branch
from
September 10, 2026 19:46
7c58a06 to
c5b6e8c
Compare
… expert layout The shipped fixture stores the routed experts in BF16, so nothing that reads the native FP8 path -- q38_native_fp8_expert_tensors, the block-scale bank, the 4.7 MiB slab per expert -- runs against it, and a GPU expert tier for this engine would have no fixture to be exact against without the 185 GB checkpoint. With --fp8-experts every routed expert matrix is quantized to e4m3 with one scale per 128x128 block (weight_block_size [128, 128], scales as BF16 weight_scale_inv, one block per matrix at fixture size), and the reference is generated from the same dequantized values, so ref.json is the arithmetic of the bytes on disk. In memory the experts are the fused gate_up_proj [E, 2I, H] / down_proj [E, H, I] parameters; save_pretrained splits them into the per-expert tensors the release ships, and the rewrite addresses those names. Shared expert, router and everything dense stay BF16 like the release. Verified: the engine loads it as FP8=native and matches 8/8 at cap 1 and 4; Q38_NATIVE_FP8=0 (expanded-f32 path) matches 8/8 as well. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DLj9ctDNPGTsYxgBuDmy5a
kreuzzelg
force-pushed
the
qwen38-cuda-tier
branch
2 times, most recently
from
September 13, 2026 22:03
fb39662 to
af86fc1
Compare
…fixture shards Wires the qwen36 tier into the Qwen3.8 engine. After routing, q38_moe_decode asks the tier which of the token's experts are resident (qt_issue); the CPU loads and computes only the rest, in route order, through the same q38_expert_get_batch / q38_expert_get path with the reduced list, reports each computed native-FP8 slot with qt_note (the tier copies during the call; the slot is recycled by the next token), and qt_take adds the GPU outputs before the shared expert. q38_tier_start runs after model_init: COLI_CUDA=1, native FP8 experts, every layer's block-scale bank resident, then qt_init_fp8 with the e4m3 table; qt_stats prints next to the RAM cache hit rate. The RAM LRU (cap/layer) is unchanged; VRAM is a third stage above it. No heat-file warmstart: measured on the real checkpoint, the hottest set of one prompt covers a different prompt's routes at chance level (4.7 % for one 8 GB card, 9.3 % for two; rank correlation -0.2 per layer), while locality inside a run is strong (decode: LRU 32/layer 55 %, 128/layer 90 %). The tier therefore promotes at qt_note time only. Makefile: qwen38 links qwen36_tier.c and the backend object with CUDA=1, like qwen36; without CUDA the header's inline stubs keep it toolkit-free. Fixture: make_qwen38_tiny.py --fp8-experts now writes two shards plus the index, gate/up and down_proj apart, as the release does. That is what makes a layer's gate/up weight_scale_inv sidecars one compact range and the down sidecars another -- the invariant behind q38_prepare_expert_scale_bank. One file interleaves down/gate/up per expert and sent the engine down the per-matrix fallback, so the tier never came up on the fixture. Tests: tests/test_qwen38_tier_engine.c runs qwen38.c through its own main() on the FP8 fixture against the fake CUDA backend (cap 1, so the RAM slot is recycled right after every qt_note): tier up in fmt 8, LUT published, uploads in gate/up/down triples of one byte per element, hits counted, budget and byte accounting exact, no pointer retained, oracle tokens and logits unchanged. Excluded from TEST_BINS (needs the generated fixture) and run from the new qwen38-tier-engine-check target; qwen38-tiny-fp8-check runs the native and expanded paths against the oracle. Both added to the qwen38-tiny-check CI job. ASan/UBSan clean. Hardware check on the fixture (RTX 3070, COLI_CUDA=1 COLI_GPUS=1): tokens identical to the CPU build at cap 1 and 2, 25 VRAM hits at cap 2. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DLj9ctDNPGTsYxgBuDmy5a
… the docs Planner. qwen38's descriptor sets supports_accelerator=True; `coli plan --gpu` prices a hot tier for it and exports COLI_CUDA/COLI_GPU/CUDA_EXPERT_GB next to COLI_PLAN_CAP. test_resource_plan's qwen38 case asserts the planned device, budget and environment instead of the former CPU-only refusal. Docs. docs/qwen38.md gets a GPU section: what the tier does for this engine, how to build and run it, that heat does not carry over between prompts on this model (held-out 4.7 % / 9.3 % against 5.7 % / 11.5 % chance) while in-run locality is strong (LRU 32/64/128 per layer: 55/79/90 % of decode routes), the VRAM cost per expert at cudaMalloc granularity (the accounting itself is in the tier PR this branch sits on), and where a decode token's time goes. docs/qwen36-cuda-tier.md says the tier serves two engines. The engine test's exp_bytes expectation follows the tier's dev_alloc_footprint like the tier tests do. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DLj9ctDNPGTsYxgBuDmy5a
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DLj9ctDNPGTsYxgBuDmy5a
COLI_TIMERS=1 printed one "total" bank divided by the number of decode forwards. With a 315-token prompt that made the routed-expert line read 930 ms per forward, of which the decode share is about 250 ms (measured per expert: 0.5 ms for the three FP8 GEMVs) -- the rest was the prompt's batched expert work. generate() now snapshots the bank when the prompt's forward is done, and the report prints "prefill" and "decode" banks after "total". No change to the counters themselves. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DLj9ctDNPGTsYxgBuDmy5a
The engine's Speed line divides the generated tokens by the whole generation time, prompt included; the earlier draft of this section had built its budget on that and on the single timer bank. Decode-only figures now: about 0.8 s per token, of which 192 ms routed-expert GEMVs on the CPU; the tier takes 45 % (one card) to 59 % (two cards) of those onto the GPU with identical tokens, and the wall time does not move, because the per-layer round trips and the staging of promoted experts cost what the GEMVs saved. Says so. Also recommends OMP_PLACES=cores (libgomp packs SMT siblings otherwise; +6 % here, +30 % on qwen36). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DLj9ctDNPGTsYxgBuDmy5a
…generic tier dense API Tier. The placer's decision now lives on the offer: qt_place_of(name, layer) answers for any component an engine offered, not only lmhead/dnproj, and auto_place walks every offer (lmhead first, then offer order, which is layer order). New qt_dense_init/qt_dense_matmul/qt_dense_count: an int8 per-row matrix resident on one device, addressed by a handle the engine keeps in its weight -- the lmhead/dnproj mechanism without the tier learning names. Freed at shutdown. tests/test_qwen36_tier_dense.c pins offers by arbitrary name, per-offer placement, budget deduction, fmt-1 upload, handles, refusals. Engine. Every trunk matrix of at least 1 MiB (DeltaNet qkv/z/out, attention q/k/v/o and the QSA indexer, both hyper-connection mixers, shared expert, router, lm_head) is offered before qt_init; what the placer accepts is quantized to int8 per row at start (max|w|/127, OpenMP over rows) and uploaded once. q38_weight_matmul answers S == 1 from the handle; prefill and any failure take the BF16 copy, which stays as the reference. Q38_TRUNK_GPU=0 keeps the trunk on the CPU; the engine test sets it because the fake backend counts matmuls but computes nothing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DLj9ctDNPGTsYxgBuDmy5a
coli_cuda_matmul staged its input and output in the same x/y device buffers the expert group uses (coli_cuda_expert_group_issue), and the group runs on its own non-blocking stream while the engine thread keeps computing between qt_issue and qt_take. qwen38 calls the dense path in that window -- the shared expert's three GEMVs -- so with the trunk resident in VRAM every dense GEMV overwrote the in-flight group's input, and its output landed in the buffer qt_take reads back: no CUDA error, only wrong tokens, worse the more experts were resident (2.6 % VRAM hits: a divergence after a dozen tokens; 19 %: degenerate text from the second word). The dense matvec now has dx/dy of its own; the group keeps x/y. qwen36 never called the dense path inside that window (dnproj sits in the DeltaNet block, lm_head after the last layer), so its outputs were unaffected. Verified on the real checkpoint: with the trunk on the 3070 and the expert tier at 23 % VRAM hits (345 LFRU swaps), the 30-token greedy output on the 315-token prompt is identical to the CPU int8 reference and to the BF16 run. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLtHawRZDYQNsBmANKXGgt
… tier, planner prices the trunk Stage 1 of the dense trunk in VRAM, measured on the real checkpoint after the backend fix (Prompt B 315 + 100 tokens, cap 224, decode ms/token, greedy text identical to the BF16 CPU run in every configuration): CPU BF16 744 = 1.34 tok/s experts only, one card 754 = 1.33 trunk on one card, no experts 557 = 1.80 trunk + experts, one card 523 = 1.91 (+43 %) trunk + experts, two cards 466 = 2.14 (+60 %) Engine: Q38_TRUNK_CPU_INT8=1 keeps the same int8 rows on the CPU and answers decode GEMVs from them -- the quantization's effect on its own, no GPU needed (perplexity, token parity); Q38_TRUNK_MIN_KB and Q38_TRUNK_SKIP choose what is offered; Q38_TRUNK_SELFTEST=1 checks every placed matrix once against the CPU int8 rows (all 553 within 1e-7). The quantizer is one function for both paths; the int8 rows go with the weight. Tests: the fake backend can compute fmt 1 from the uploaded bytes (fake_dense_compute), so the engine test now runs a second pass with every dense matrix of the fixture on the fake tier and demands the oracle within its limits (the cosine is exactly the CPU int8 reference's), every offer placed, every handle released at shutdown. Planner: a trunk_inventory hook on the family descriptor (qwen38: the offered matmul matrices of at least 1 MiB, int8 bytes) feeds analysis["trunk_int8_bytes"]; build_plan takes the trunk out of the first device's VRAM before the experts, reports tiers.vram.trunk_bytes and a "dense trunk as int8 residents" decision, and the plan line reads "4.0 GB int8 trunk + 2.1 GB hot tier". Analysis cache version bumped. Docs: docs/qwen38.md "The dense trunk in VRAM (stage 1)" (mechanism, variables, numerics, the measured table, the staging-buffer finding), docs/qwen36-cuda-tier.md (the generic dense API, the backend fix). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YLtHawRZDYQNsBmANKXGgt
kreuzzelg
force-pushed
the
qwen38-cuda-tier
branch
from
September 13, 2026 22:05
af86fc1 to
1b4d19a
Compare
Edo771977
added a commit
to Edo771977/colibri
that referenced
this pull request
Sep 14, 2026
Integrazione PR upstream: CUDA Qwen3.8 (JustVugg#1424), QLoRA (JustVugg#626), API logprobs/echo (JustVugg#1353)
Owner
|
Reviewed and merging. The backend fix (separate staging buffers for the resident dense matvec, so it no longer shares x/y with the in-flight expert group) is a correctness change worth having on its own; the placer generalisation keeps qwen36's lmhead/dnproj behaviour; everything else is opt-in under COLI_CUDA=1 with the BF16 CPU path as the reference and fallback, priced by the planner and pinned by the fake-tier engine test. One note for the docs later: the int8 trunk is a quantization of the dense path, so the greedy-identical result on 100 tokens is the measurement, not a guarantee, which is why Q38_TRUNK_CPU_INT8=1 and Q38_TRUNK_GPU=0 matter. Thank you for the two-card numbers. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follows up on #1242 ("no Qwen3.8 GPU backend yet") and jazir555's ask on #1250; JustVugg
said in #1242 it is open and, in #1045, that this is item C with B's placement logic inside it.
Nine commits on current
dev(#1344, #1360, #1361 and #1374 are in); rebased 10.09.What it does
Qwen3.8-Flash-Next gets a GPU path through the same
qwen36_tier.cas Qwen3.6, in two parts.1. Expert tier, fp8 streaming mode (
qt_init_fp8). Experts keep streaming from disk intothe per-layer RAM LRU (
cap); the tier holds copies of the hot ones in VRAM as a third stageabove it and computes those on the GPU with #817's
grouped_*_f8wkernels (e4m3 bytes + thecheckpoint's 128x128 block scales, no conversion). Staging copies slab and scales at
qt_notetime -- the engine's RAM slot may be recycled by the next token -- and promotes by heat
(room, or a budget-neutral swap against the coldest resident).
q38_moe_decode:qt_issueafter routing, the CPU computes only the non-resident experts in route order,
qt_takebefore the shared expert. No heat-file warmstart, on purpose (measured: heat does not carry
over between prompts on this model; inside a run the locality is strong).
2. The dense trunk in VRAM (stage 1). The trunk is the largest fixed cost of a decode
token here and it is bandwidth-bound (8 GiB BF16 read per token). The engine offers every
dense matmul matrix of at least 1 MiB to the tier's placer by name and layer (DeltaNet
projections, attention, hyper-connection mixers, shared expert, router, lm_head -- 553
matrices); the placer takes them before the experts (read every token), the engine
quantizes what was accepted to int8 per row at start (2 s, 3.96 GiB on one card) and answers
decode GEMVs from VRAM with one round trip per matmul. Prefill, the DeltaNet recurrence, QSA,
PLE and the CPU fallback stay as they are. The tier side is generic:
qt_trunk_offer(name, layer, bytes)/qt_place_of/qt_dense_init/qt_dense_matmul-- the qwen36lmhead/dnprojcalls are thin wrappers now.coli plan --gpuprices the trunk(
4.0 GB int8 trunk + 2.1 GB hot tier).3. A backend fix the trunk uncovered (
b86e55e):coli_cuda_matmulstaged its input andoutput in the same
x/ydevice buffers as the expert group, which runs asynchronously onits own stream between
qt_issueandqt_take. qwen38 computes the shared expert in thatwindow, so with the trunk in VRAM every dense GEMV overwrote the in-flight group's input and
its output landed where
qt_takereads -- no CUDA error, only wrong tokens, worse the moreexperts were resident. The dense matvec has its own buffers now. qwen36 never called the
dense path inside that window (dnproj in the DeltaNet block, lm_head after the last layer),
so its outputs were unaffected; the tier tests and the R6 calibration are not touched.
Also: a decode timer bank apart from prefill (
COLI_TIMERS=1; theSpeedline divides bythe whole generation time, prompt included, and read like a decode figure), the fixture in
the release's two-shard layout (in one file the scale sidecars interleave and the engine
silently takes the per-matrix fallback), and the diagnostics I needed:
Q38_TRUNK_CPU_INT8=1(the same int8 rows on the CPU: what the quantization alone does,no GPU needed),
Q38_TRUNK_SELFTEST=1(each placed matrix once, GPU against CPU int8),Q38_TRUNK_MIN_KB,Q38_TRUNK_SKIP,Q38_TRUNK_GPU=0.Measured (Threadripper PRO 3945WX 12C, 94 GB, RTX 3070 8 GB + Quadro RTX 4000 8 GB)
Checkpoint:
Qwen/Qwen3.8-Flash-Next-FP8aspublished, read directly (no conversion, no container of our own; the int8 trunk is built at
start in 2 s). The engine test's fixture is synthetic,
make qwen38-tiny-fp8-generate, no download.Prompt of 315 tokens plus 100 generated, cap 224 (the RAM LRU serves 84 % of expert reads),
OMP_PLACES=cores, 12 threads, checkpoint in the page cache, decode bank, five runs back toback with nothing else on the machine. Greedy text identical to the BF16 CPU run in every
configuration (100 tokens).
The expert tier on its own does not move the wall time (it removes GEMV time and spends
about as much on its per-layer round trips and on staging -- the GEMVs were a quarter of the
token); the trunk on its own is +34 %; together +43 % on one card and +60 % on two, where the
placer spreads the trunk by free room and the experts on both. TTFT (183 s, prefill on the
CPU) and peak RSS (60 GB at cap 224) do not change.
Numerics: GPU int8 against the same int8 rows on the CPU, relative error ~1e-7 on all 553
matrices; int8 against BF16, 1 - 3 % per matrix, greedy text identical over 100 tokens;
perplexity on wikitext-2 (8 x 512, 2048 scored tokens): 1.880 with trunk + tier on the GPU against
1.845 BF16 on the CPU (+1.9 %). Probes: the fp8 expert tier is neutral, lm_head contributes nothing,
routers kept in BF16 (
Q38_TRUNK_SKIP=router) recover only 0.3 points (1.875) -- the per-row int8error is spread over the trunk. Group-scaled int8 for the trunk is the follow-up.
Side finding for the docs of both engines:
OMP_PROC_BIND=closewithoutOMP_PLACESpackslibgomp's threads onto SMT siblings;
OMP_PLACES=coresgave +6 % on qwen38 and +30 % onqwen36 (11.0 -> 14.4 tok/s, one card, cold).
Tests
test_qwen36_tier_fp8.c,test_qwen36_tier_dense.c(placement by name, handles, fallback,shutdown),
test_qwen38_tier_engine.c(qwen38.c through its ownmain()on the FP8 fixtureagainst the fake backend; pass 1: experts, cap 1 so the RAM slot is recycled after every
qt_note; pass 2: every dense matrix of the fixture on the fake tier, which now computesfmt 1 from the uploaded bytes, oracle within limits, every offer placed, every handle
released), CI steps
qwen38-tiny-fp8-checkandqwen38-tier-engine-check; the plannertest asserts the trunk bytes and the plan line. ASan/UBSan clean on the engine test; all
tier tests and the Python suites pass.
test_cli_outputpinned qwen38 as CPU-only (--gpurefused up front); with
supports_acceleratorit asserts the accelerated engines' contractinstead (
--gpu needs the CUDA buildon a CPU-only binary). Re-run after the rebase on6f9117f: greedy text identical over 100 tokens, 1.87 tok/s, same residency. While measuringI hit an unrelated crash in
dev's warmstart (#1422, fix in #1423); it does not touch this diff.Not in this PR
Activations on the GPU per layer (stage 2, would also take the experts' round trips away),
prefill on the GPU, an expert arena per device (cudaMalloc granularity costs 22 % of the
fp8 experts' VRAM), a trunk split by layer across two unequal cards (the placer spreads by
free room today), Vulkan/Metal.
Thanks on Claude Code