Skip to content

test(qwen36): tier invariants on the fake backend, and the reservation leak they found - #1360

Merged
JustVugg merged 2 commits into
JustVugg:devfrom
kreuzzelg:qwen36-tier-invariants
Sep 7, 2026
Merged

JustVugg merged 2 commits into
JustVugg:devfrom
kreuzzelg:qwen36-tier-invariants

Conversation

@kreuzzelg

Copy link
Copy Markdown
Contributor

Stacked on #1344 (crichalchemist's three tier fixes). Its three commits show in this diff until it lands; the one commit of ours is d0ecbf1. I will rebase the moment #1344 merges.

Why

#1339, #1340 and #1341 were each found by reading, and #1344 gives each a regression test that pins its scenario. This adds the rules those scenarios are instances of, checked directly against tests/qwen36_fake_cuda.h in the plain CPU build — no GPU, no toolkit — so the next drift fails in make test-c (and under test-asan) before anyone reads the tier again. The tier is mine (#713); two of the three bugs were mine; these are the tests I should have shipped with it.

The three rules

1. Budget accounting balances. On every device used <= budget, and once the queue is drained used == resident × exp_bytes: 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 — 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 the test runs on MinGW. 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 ASan this is a fuzz for the #1339 class.

What rule 1 found, and the fix

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 (if (planned[gi] && wg)). The bytes stayed out of the budget for the life of the process, and if(resident||queued||planned) continue never reconsidered the expert. #1331 was this leak for every expert of an int8 container — the class survived its fix.

Fix, 19 lines: qt_note_planned hands the reservation back when it receives no weights; tier_warmstart reports every planned expert, with or without them.

Verified

test_qwen36_tier_invariants with the fix ok; ASan+UBSan: 0 diagnostics
the same test without the fix 8 FAIL, all the leak; 0 sanitizer diagnostics — a red test, not a crash
the four #1344 tests, test_qwen36_ctx, make qwen36 unchanged, ok
make test-c (the full suite, ~120 binaries) ALL PASS, 8 m 43 s

Two notes from writing it, 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 — fix(pilot): the prefetch worker outlives the Model it dereferences #1277's class — in the test before it could flag anything in the tier. Static state now, disarmed after a successful shutdown.
  • "Queue empty" is not "uploader done": the last dequeued expert is still queued=1 until its upload returns. The test waits for both. test_qwen36_tier_multidev checks residency right after qt_fill_wait() and passes on timing; worth the same two-line wait there, @crichalchemist, if you are touching the file for the watchdog anyway.

Not in here on purpose: the QT_MAX_ROWS constant and the int8 warmstart-line / docs correction I suggested on #1344 — they belong there, and here they would collide.

🤖 Generated with Claude Code

kreuzzelg and others added 2 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
@kreuzzelg
kreuzzelg force-pushed the qwen36-tier-invariants branch from 269d440 to bdeb338 Compare September 6, 2026 20:27
@JustVugg
JustVugg merged commit 9f25fe8 into JustVugg:dev Sep 7, 2026
25 checks passed
crichalchemist added a commit to crichalchemist/colibri that referenced this pull request Sep 7, 2026
Brings the Vulkan expert tier onto the tier as it stands after JustVugg#1344,
JustVugg#1360, the fp8 streaming mode (979025c), the resident dense trunk
(868852a, 85c90c4), automatic placement (ff13134) and the cudaMalloc-
granularity accounting (d0a382d, 40ff645).

Resolution, all inside the backend shim that this branch introduced:

- be_fp8_set_lut: CUDA publishes the e4m3 table; Vulkan returns 0, so
  qt_init_fp8 lands on the CPU path with the existing message.
- be_trunk_upload / be_trunk_matmul: the resident lm_head and DeltaNet
  projections go through the shim. CUDA maps them to tensor_upload(fmt=1)
  and coli_cuda_matmul; Vulkan refuses (one stderr line) and the pieces
  stay on the CPU, because backend_vulkan has no matmul over an
  already-uploaded tensor yet.
- The init sequence keeps upstream's affinity widening around be_init
  and the single-device short-circuit around the COLI_GPUS/COLI_GPU
  parsing; the budget reads QT_BUDGET_ENV and be_mem_info.
- ybuf (the Vulkan take target) is allocated next to the per-device
  replica buffer, whose sizing is upstream's (JustVugg#1339).
- docs/qwen36-cuda-tier.md is restored as upstream has it (it grew the
  placement calibration meanwhile); docs/qwen36-tier.md now covers only
  what differs on Vulkan and points there for the mechanics.

Verified on macOS 13 x86_64 (2017 iMac, i7-7700K): the CPU build, the seven fake-CUDA tier tests
and the four other qwen36 tests build and pass; qwen36 and the two
Vulkan tier tests build with VK=1 against MoltenVK on the Radeon Pro 580, and the tier
initialises on that GPU (the test budget is adjusted in the next
commit).
JustVugg pushed a commit that referenced this pull request Sep 12, 2026
…esident

qt_fill_wait waited for the upload queue to drain (qn == 0). The uploader
frees the ring slot when it dequeues an entry, before it calls the backend,
so the queue is empty while the last expert is still being copied: the
caller sees it queued=1, resident=0. The engine's warmstart frees the RAM
int8 copy of every planned expert right after qt_fill_wait, trusting that
they are all in VRAM. #1360 met this as a one-in-fifteen failure of
test_qwen36_tier_multidev and covered it in the test with a poll.

Count what is in flight, not what is queued: enqueue_locked increments
G.inflight, the uploader decrements it only after the slot is resident
(or the upload failed, or the entry was abandoned at shutdown) and
broadcasts cv_take; qt_fill_wait blocks on inflight. Same lock, same
condition variable, no new wakeups on the hot path.

tests/test_qwen36_tier_fill_wait.c makes the race deterministic: the fake
backend gains fake_upload_hook, the test sleeps 3 ms per tensor in it, and
reads queued/resident under G.mx the instant the wait returns. Before the
fix it fails every run (5/5, all three assertions); after, 10/10. The poll
in test_qwen36_tier_multidev is removed, and the test passes 20/20 without
it. fill_wait, multidev and shutdown are clean under ASan+UBSan.
@JustVugg JustVugg mentioned this pull request Sep 13, 2026
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