Repository navigation
feat(qwen36): the dense trunk places itself -- COLI_PLACE=auto, priced in bytes saved per byte of VRAM - #1361
Conversation
bd6c4c7 to
f913c01
Compare
f913c01 to
1a19f51
Compare
…n leak they found Stacked on JustVugg#1344. Its three regression tests each pin one scenario; this adds the rules those scenarios are instances of, checked directly against tests/qwen36_fake_cuda.h in the plain CPU build: 1. Budget accounting balances. On every device, bytes in use never exceed the budget and, once the queue is drained, equal exactly resident experts x bytes per expert: every reservation is consumed by an upload or handed back. Checked on one and two devices, across an LFRU swap (budget-neutral by construction, so `used` must not move), and across the path where a planned expert is reported without weights. 2. Shutdown wakes every waiter at once. All four cv_take sleepers -- the uploader's victim wait, qt_note_block, qt_note_planned, qt_fill_wait -- are parked simultaneously behind a full queue and an open group; qt_shutdown has to bring every one of them home, under a watchdog thread rather than alarm() so it runs on MinGW too. Afterwards no slot may still read as queued and the abandoned swaps' victim keeps its tensor and its resident flag. 3. Issue geometry under random routing. Random resident sets, random K up to the row limit, one to three devices, 200 seeds each: every device block inside the replica buffer, pairwise disjoint, at its device's slot; the mask names exactly the routed experts that were resident on a device whose issue succeeded; hits + misses add up to everything routed. The row limit is read from the array the rows index (sizeof G.is_k[0] / sizeof G.is_k[0][0]), so the stride cannot drift from it without failing here. Under test-asan this is a fuzz for the JustVugg#1339 class. ## What the first rule found qt_plan_fill reserves budget and sets planned=1; qt_note_planned returned early on NULL weights without undoing either, and the warmstart did not call it at all when the loader came back empty. The bytes stayed out of the budget for the life of the process and "if(resident||queued||planned) continue" never reconsidered the expert. JustVugg#1331 was this leak for every expert of an int8 container; the class survived its fix. Fix: qt_note_planned hands the reservation back when it receives no weights, and tier_warmstart reports every planned expert, with or without them. Without the fix the new test fails eight checks (all this leak) and passes clean under ASan; with it, all green. ## Verified - test_qwen36_tier_invariants: ok; under ASan+UBSan: 0 diagnostics - without the fix: 8 FAIL, 0 sanitizer diagnostics (a red test, not a crash) - the four JustVugg#1344 tests, test_qwen36_ctx and the qwen36 build unchanged: ok - one lesson kept in the file: the watchdog's first draft passed its timeout by pointer into the arming function's frame -- ASan flagged the stack-use-after-return (JustVugg#1277's class) in the test itself before it could flag anything in the tier Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DLj9ctDNPGTsYxgBuDmy5a
qt_fill_wait() returns when the queue is empty, but the expert the uploader
dequeued last is still queued=1 until its upload returns. The residency
check right after it raced that upload and failed about one run in fifteen
locally ("expert did not become resident during warmstart") -- a red that
says nothing about JustVugg#1339. Poll until no slot is queued before asserting.
The same two-line wait is what test_qwen36_tier_invariants uses
(WAIT_IDLE). Noted on JustVugg#1344 as review feedback; carried here so the
stacked PRs stop rolling dice in CI. Drop this commit if JustVugg#1344 takes the
fix first.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DLj9ctDNPGTsYxgBuDmy5a
The lm_head GEMV (248320x2048, one call per token at token END, outside the per-layer latency chain) cost 13.8-14.8 ms/token on CPU — 18% of decode. Park the dense-i8 rows + per-row scales on a CUDA device and run them through coli_cuda_matmul (fmt=1, identical y[o]=acc*sc[o] semantics; greedy output bit-identical across all runs). qt_init removes the lm_head device from the expert PLACEMENT list (not just budget=0: home() would still hash experts onto it and the hit rate collapsed 89.9->49.5% in the first attempt) while keeping its CUDA context; if it is the only device, experts stay and lm_head shares it. Measured (gs64, heat-warm, 200 tokens): CPU lm_head 14.8 ms/tok 81.0 total 11.05 tok/s lm_head on Quadro 9.9 ms/tok 76.2 total 11.69 tok/s lm_head on 3070 3.9 ms/tok 72.7 total 12.21 tok/s <- best, single GPU (experts + lm_head share the 3070; token end is idle time) (cherry picked from commit 734e1e3d77344e3bc9613d6fbc6c752746b6a518)
(cherry picked from commit 4064493, ohne die versehentlich committeten Binaries c/qwen36 und c/tools/bench_dnproj; qt_init-Signatur und Device-Auswahl auf den JustVugg#1344-Stand aufgeloest: die COLI_PLACE-Devices werden nach der COLI_GPUS-/Auto-Auswahl und vor der Leer-Pruefung ergaenzt. tests/qwen36_fake_cuda.h bekommt coli_cuda_matmul als aufzeichnenden Stub, damit die Tier-Tests weiter im CPU-Build linken.)
The hand-written COLI_PLACE list from R4 is a measurement tool; nobody running a 6 GB card should have to work out that 1.2 GB of dense trunk is worth more than 800 experts (JustVugg#1040). The engine knows every size involved, so it decides. Rule: bytes saved on the memory bus per token, per byte of VRAM spent. Dense weights are read every token (value 1.0 per byte); an expert with the probability p_e that a token routes to it -- its heat share when a HEAT_FILE exists, topk/n_experts otherwise -- and the CPU fallback reads the int8 slot, twice the VRAM bytes of an int4 expert (value 2*p_e; 1*p_e on an int8 container). A trunk item goes to the device with the most room if its value beats that of the coldest experts it would push out, i.e. the tail of the heat order that still fits today. Without heat the tail is worth 2*topk/n_experts per byte and the trunk always wins; with heat, a card whose marginal expert is routed on more than every second token keeps its experts -- the R4 measurement, where moving all projections onto one near-full card cost 0.7 GB of hot experts and the 13 ms it had saved. Mechanics: - qt_trunk_offer(component, layer, bytes) before qt_init: the engine offers lmhead once and dnproj per DeltaNet layer, sizes from the same dense-i8 entries the uploads use. No entries (dense-i8 off) -> no offer. - qt_init prices and places, writes the decision into the table qt_place_of() already reads, and subtracts placed bytes from that device's expert budget. The explicit list gets the same subtraction now; with COLI_PLACE="dnproj=0" the projections used to come out of the expert cache unannounced. - COLI_PLACE unset or "auto" -> automatic; a list -> obeyed, auto off; "off" -> nothing placed. In auto mode the R4 role-split reservation is skipped on purpose: one card shares trunk and experts. - Weight format, exp_bytes and the heat table move ahead of the role-split block in qt_init, and the parse state is re-derived per init. tests/test_qwen36_tier_autoplace.c on the fake backend (free VRAM is what the test says): fits -> placed and budget shrinks by exactly the trunk; too big -> CPU; heat verdicts both ways at p_marginal 0.67 and 0.17, with and without a heat file, and "room nobody would use goes to the trunk"; explicit list obeyed and charged; off; two devices greedy by room with per-device budgets; nothing offered -> no-op. All six tier tests green, ASan/UBSan clean on this and the invariants test, CPU and CUDA builds ok. Not yet: dnout/attnproj (offered components other than lmhead/dnproj are ignored), and the calibration on real cards that decides whether the factor 2 and the greedy-by-room device choice survive contact with a Quadro/3070 pair and a 6 GB card. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DLj9ctDNPGTsYxgBuDmy5a
…with the first calibration 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
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DLj9ctDNPGTsYxgBuDmy5a
1a19f51 to
181d722
Compare
cudaGetDeviceProperties says ordinal 0 is the RTX 3070 and ordinal 1 the Quadro RTX 4000 on the calibration box (CUDA orders fastest-first, nvidia-smi by PCI bus, and the two disagree there). The rows labelled "RTX 3070" were measured on the Quadro and the row labelled "Quadro" on the 3070. Numbers unchanged; names swapped; the sentence that explained the Quadro being the faster card is gone, because it was the 3070. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DLj9ctDNPGTsYxgBuDmy5a
|
Correction to the calibration table: the two card names were swapped. I had taken CUDA ordinal 0 for the Quadro RTX 4000 and ordinal 1 for the RTX 3070; One thing worth knowing beyond my mislabelling: While re-measuring I also found that the run line in the docs ( |
…experts=all Two placement details the GPU DeltaNet layer needs. The automatic placer chose the device for each offer by room alone, so a layer's in_proj and out_proj could land on different cards (28 of 30 layers qualified on two cards): now dnout goes to the device its dnproj went to when that device has the room. And a hand-written list that reserves a card for the trunk hands that card's experts to the others (JustVugg#1361), which rules out "trunk on the fast card, experts on both"; experts=all keeps every COLI_GPUS card for the experts, reserved or not. Both in test_qwen36_tier_autoplace. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…experts=all Two placement details the GPU DeltaNet layer needs. The automatic placer chose the device for each offer by room alone, so a layer's in_proj and out_proj could land on different cards (28 of 30 layers qualified on two cards): now dnout goes to the device its dnproj went to when that device has the room. And a hand-written list that reserves a card for the trunk hands that card's experts to the others (JustVugg#1361), which rules out "trunk on the fast card, experts on both"; experts=all keeps every COLI_GPUS card for the experts, reserved or not. Both in test_qwen36_tier_autoplace. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…experts=all Two placement details the GPU DeltaNet layer needs. The automatic placer chose the device for each offer by room alone, so a layer's in_proj and out_proj could land on different cards (28 of 30 layers qualified on two cards): now dnout goes to the device its dnproj went to when that device has the room. And a hand-written list that reserves a card for the trunk hands that card's experts to the others (JustVugg#1361), which rules out "trunk on the fast card, experts on both"; experts=all keeps every COLI_GPUS card for the experts, reserved or not. Both in test_qwen36_tier_autoplace. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Stacked on #1360 (which is stacked on #1344). Until those land, their four commits show in this diff; this PR's own commits are the seven from
9d7c00btobd6c4c7. I will rebase as each lands.Why
sehHeiden's measurement series in #1040 (GTX 1660 Ti, 6 GB): the CUDA expert tier reaches 6.6 tok/s warm and the hit rate stops mattering (33 → 95 % hit buys +13 %), while llama.cpp
--cpu-moe -ngl 99does 19.9 on the same box. The mechanism is placement, not the tier: the tier moves the routed experts, which on this hybrid model are the small part of a token (~190 MB of int8 at 81 % hit), and leaves the dense trunk -- attention, DeltaNet projections, shared expert, lm_head, 1.8 GB of int8 read on every token -- on the CPU. llama.cpp places the mirror image.The hand-written
COLI_PLACElist from my fork proved the point (13.84 → 17.11 tok/s on two cards) but nobody running a 6 GB card should have to work out that 1.2 GB of trunk is worth more than 800 experts. The engine knows every size involved, so now it decides.What this adds
1.
COLI_PLACEand two resident trunk components (the two R4 commits, adapted to #1344): lm_head and the fused per-layer DeltaNet projection (qkv ++ z, one GEMV instead of two) as resident int8 tensors on a CUDA device, and a placement tableqt_place_of(component, layer)every movable piece consults. Bit-identical to the CPU path.2. Automatic placement (
feat(qwen36): automatic placement of the dense trunk). Before the tier decides its budget, the engine offers each trunk component with its size (qt_trunk_offer: lm_head once, dnproj per DeltaNet layer, sizes from the same dense-i8 entries the uploads use).qt_initprices them against the experts they would displace, in bytes saved on the memory bus per token, per byte of VRAM:HEAT_FILEexists,topk / n_expertsotherwise -- and the CPU fallback reads the int8 slot, twice the VRAM bytes of an int4 expert: value 2·p_e per byte (1·p_e on an int8 container).A component goes to the device with the most room if its value beats that of the coldest experts it would push out (the tail of the heat order that still fits). Without heat the tail is worth 2·topk/n_experts per byte (0.06 on the 35B) and the trunk always wins; with heat, a card whose marginal expert is routed on more than every second token keeps its experts -- the R4 case where moving all projections onto one near-full card cost 0.7 GB of hot experts and the 13 ms it had saved.
COLI_PLACEautoofflmhead=0,dnproj=0:20+1:20,experts=0Every decision prints a
[place]line with the numbers that made it;qt_statsnames the trunk share of VRAM so "used > budget" does not read as an overrun.3. Tests on the fake backend (
tests/test_qwen36_tier_autoplace.c, seven cases, free VRAM is whatever the test says): fits → placed and the budget shrinks by exactly the trunk; too big → CPU; heat verdicts both ways (p_marginal 0.67 keeps the experts, 0.17 places the trunk, with and without a heat file, and room nobody would use always goes to the trunk); an explicit list obeyed and charged;off; two devices greedy by room with per-device budgets; nothing offered → no-op.qwen36_fake_cuda.hgains a countingcoli_cuda_matmulstub and a settable free-VRAM figure.Calibration (Qwen3.6-35B-A3B per-row int4, 200-token decode, same prompt throughout; Threadripper 3945WX, 12 threads)
offautoAll sixteen runs produced bit-identical text (
cmp, full output), across cards, budgets, cold and warm, off and auto. The warm rows are the ones that matter: at 95 % hit rate the marginal expert is as valuable as it gets on an 8 GB card, and the trunk still wins by a third; the hit rate drops only 4.4 points for 796 fewer residents because the displaced experts are the coldest of the heat order -- exactly the ones the placer priced as cheap. The smaller or slower the card, the larger the relative win, which is the shape of sehHeiden's problem.Against Ollama on the same box the gap closes from 1.5× (August, two cards, hand-placed) to 1.23× on a single 8 GB card, with Ollama holding its dense weights at ~0.56 bytes/weight (Q4_K_M) against this engine's 1.0 (int8) and using both cards.
What this does not settle: two cards
With experts on both cards the second card paces every layer (the slower
take()gates the chain), sooffon two cards is barely ahead of the Quadro alone (10.79 vs 11.09) despite 100 % hit rate. This version's placer spreads the trunk by free room and leaves the experts on both, reaching 14.80, where the hand-written split -- experts on one card, trunk across both -- reaches 17.11. On two unequal cards the list still wins; the placer's next version has to learn that (experts on one card, trunk on the other, the expert card chosen by a short probe rather than guessed). Said in the docs rather than hidden.Also known: peak RSS is ~2 GB higher under
autobecause the host int8 copies stay as the CPU fallback;dnoutandattnprojare in the table but not yet offered.Verified
test_qwen36_tier_{int8,multidev,shutdown,invariants,autoplace,int8_engine}), the invariants and autoplace tests clean under ASan+UBSanCUDA=1build; the calibration above is the CUDA build on the real containerqt_init's device selection resolved against fix(qwen36): three CUDA expert-tier bugs (#1339, #1340, #1341) #1344 (COLI_PLACE devices are folded in after the COLI_GPUS/auto selection)Thanks to Claude Code.