Skip to content

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
JustVugg merged 9 commits into
JustVugg:devfrom
kreuzzelg:qwen38-cuda-tier
Sep 14, 2026
Merged

JustVugg merged 9 commits into
JustVugg:devfrom
kreuzzelg:qwen38-cuda-tier

Conversation

@kreuzzelg

@kreuzzelg kreuzzelg commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

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.c as Qwen3.6, in two parts.

1. Expert tier, fp8 streaming mode (qt_init_fp8). Experts keep streaming from disk into
the per-layer RAM LRU (cap); the tier holds copies of the hot ones in VRAM as a third stage
above it and computes those on the GPU with #817's grouped_*_f8w kernels (e4m3 bytes + the
checkpoint's 128x128 block scales, no conversion). Staging copies slab and scales at qt_note
time -- 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_issue
after routing, the CPU computes only the non-resident experts in route order, qt_take
before 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 qwen36
lmhead/dnproj calls are thin wrappers now. coli plan --gpu prices the trunk
(4.0 GB int8 trunk + 2.1 GB hot tier).

3. A backend fix the trunk uncovered (b86e55e): coli_cuda_matmul staged its input and
output in the same x/y device buffers as the expert group, which runs asynchronously on
its own stream between qt_issue and qt_take. qwen38 computes the shared expert in that
window, so with the trunk in VRAM every dense GEMV overwrote the in-flight group's input and
its output landed where qt_take reads -- no CUDA error, only wrong tokens, worse the more
experts 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; the Speed line divides by
the 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-FP8 as
published, 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 to
back with nothing else on the machine. Greedy text identical to the BF16 CPU run in every
configuration (100 tokens).

CPU only experts only, one card trunk, one card, no experts trunk + experts, one card trunk + experts, two cards
VRAM: trunk / experts -- 0 / 6.1 GB 4.0 / 0.1 GB 4.0 / 2.1 GB 1.8 + 2.2 / 4.3 + 4.3 GB
VRAM share of routed experts -- 43 % 2 % 22 % 52 %
dense trunk (resident-mm), ms/token 284 285 52 63 89
routed-expert GEMV on the CPU 179 118 182 151 93
expert reads (page cache) 168 164 162 160 145
decode, ms/token 744 754 557 523 466
decode tok/s 1.34 1.33 1.80 1.91 2.14

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 int8
error 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=close without OMP_PLACES packs
libgomp's threads onto SMT siblings; OMP_PLACES=cores gave +6 % on qwen38 and +30 % on
qwen36 (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 own main() on the FP8 fixture
against 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 computes
fmt 1 from the uploaded bytes, oracle within limits, every offer placed, every handle
released), CI steps qwen38-tiny-fp8-check and qwen38-tier-engine-check; the planner
test 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_output pinned qwen38 as CPU-only (--gpu
refused up front); with supports_accelerator it asserts the accelerated engines' contract
instead (--gpu needs the CUDA build on a CPU-only binary). Re-run after the rebase on
6f9117f: greedy text identical over 100 tokens, 1.87 tok/s, same residency. While measuring
I 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

… 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
kreuzzelg force-pushed the qwen38-cuda-tier branch 2 times, most recently from fb39662 to af86fc1 Compare September 13, 2026 22:03
kreuzzelg and others added 8 commits September 14, 2026 00:04
…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
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
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)
@JustVugg

Copy link
Copy Markdown
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.

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