Skip to content

feat(qwen36): the dense trunk places itself -- COLI_PLACE=auto, priced in bytes saved per byte of VRAM - #1361

Merged
JustVugg merged 10 commits into
JustVugg:devfrom
kreuzzelg:qwen36-auto-place
Sep 7, 2026
Merged

JustVugg merged 10 commits into
JustVugg:devfrom
kreuzzelg:qwen36-auto-place

Conversation

@kreuzzelg

@kreuzzelg kreuzzelg commented Sep 5, 2026 •

Copy link
Copy Markdown
Contributor

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 9d7c00b to bd6c4c7. 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 99 does 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_PLACE list 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_PLACE and 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 table qt_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_init prices them against the experts they would displace, in bytes saved on the memory bus per token, per byte of VRAM:

  • a dense component is read every token: value 1.0 per byte;
  • a routed expert is read with the probability p_e a token routes to it -- its heat share when 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 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_PLACE behaviour
unset or auto automatic
off nothing placed: experts only, the behaviour before this PR
a list, e.g. lmhead=0,dnproj=0:20+1:20,experts=0 obeyed as written; the trunk's bytes are now charged to that device's expert budget, which the list never did (the 0.7 GB above was the discovery)

Every decision prints a [place] line with the numbers that made it; qt_stats names 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.h gains a counting coli_cuda_matmul stub and a settable free-VRAM figure.

Calibration (Qwen3.6-35B-A3B per-row int4, 200-token decode, same prompt throughout; Threadripper 3945WX, 12 threads)

off auto
RTX 3070 (8 GB), cold 44 % hit / 8.64 tok/s 36 % / 9.62 +11 %
RTX 3070, warm 95.0 % / 9.63 90.6 % / 12.92 +34 %
RTX 3070, budget capped at 5 GB (a 6 GB card's share), warm 88.9 % / 9.50 81.2 % / 13.15 +38 %
Quadro RTX 4000 (8 GB), warm 93.6 % / 11.09 88.4 % / 16.55 +49 %
both cards, experts on both, warm 100 % / 10.79 100 % / 14.80 +37 %
reference: Ollama 0.32.5, same model Q4_K_M, same prompt, both cards, 57 % CPU / 43 % GPU 20.3 warm (21.3 cold)

All 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), so off on 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 auto because the host int8 copies stay as the CPU fallback; dnout and attnproj are in the table but not yet offered.

Verified

  • six tier tests green (test_qwen36_tier_{int8,multidev,shutdown,invariants,autoplace,int8_engine}), the invariants and autoplace tests clean under ASan+UBSan
  • CPU build and CUDA=1 build; the calibration above is the CUDA build on the real container
  • the R4 commits arrive without the binaries the fork commit accidentally carried, and with qt_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.

kreuzzelg and others added 9 commits September 6, 2026 22:26
…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
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
@kreuzzelg

Copy link
Copy Markdown
Contributor Author

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; cudaGetDeviceProperties says the opposite on this machine (CUDA orders devices fastest-first, nvidia-smi by PCI bus, and the two disagree here). So the rows labelled "RTX 3070" were measured on the Quadro RTX 4000 and the row labelled "Quadro RTX 4000" on the RTX 3070. The numbers themselves stand; I have edited the table in the description and fixed docs/qwen36-cuda-tier.md in this branch (7841f02). It also removes the odd explanation I gave for the Quadro being the faster card: it was the 3070, as one would expect.

One thing worth knowing beyond my mislabelling: coli plan names devices by nvidia-smi index and exports COLI_GPU=<index>, while the engine's tier selects by CUDA ordinal. On a machine with two unequal cards those can differ, as here, and coli chat --gpu 1 then lands on the other card. Exporting CUDA_DEVICE_ORDER=PCI_BUS_ID in the planner's environment would make the two agree; I can open a small PR for that if you want it.

While re-measuring I also found that the run line in the docs (OMP_PROC_BIND=close without OMP_PLACES) makes libgomp pack the threads onto SMT siblings on this box: 12 threads on 6 cores. With OMP_PLACES=cores the same one-card cold run goes from 11.0 to 14.4 tok/s (+30 %). The calibration numbers above were all taken without it, so they are conservative; I will re-measure the table with it and update the docs in a follow-up rather than mixing the two here.

@JustVugg
JustVugg merged commit 6443bba into JustVugg:dev Sep 7, 2026
25 checks passed
@JustVugg JustVugg mentioned this pull request Sep 13, 2026
kreuzzelg added a commit to kreuzzelg/colibri that referenced this pull request Oct 1, 2026
…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>
kreuzzelg added a commit to kreuzzelg/colibri that referenced this pull request Oct 2, 2026
…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>
kreuzzelg added a commit to kreuzzelg/colibri that referenced this pull request Oct 3, 2026
…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>
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