Repository navigation
test(qwen36): tier invariants on the fake backend, and the reservation leak they found - #1360
Merged
Merged
Conversation
kreuzzelg
force-pushed
the
qwen36-tier-invariants
branch
from
September 5, 2026 22:16
d0ecbf1 to
3e4488d
Compare
This was referenced Sep 5, 2026
kreuzzelg
force-pushed
the
qwen36-tier-invariants
branch
from
September 5, 2026 23:03
3e4488d to
541f003
Compare
kreuzzelg
force-pushed
the
qwen36-tier-invariants
branch
from
September 6, 2026 16:27
2996ef8 to
269d440
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
kreuzzelg
force-pushed
the
qwen36-tier-invariants
branch
from
September 6, 2026 20:27
269d440 to
bdeb338
Compare
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.
Merged
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.
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.hin the plain CPU build — no GPU, no toolkit — so the next drift fails inmake test-c(and undertest-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 drainedused == 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, sousedmust not move), and across the path where a planned expert is reported without weights.2. Shutdown wakes every waiter at once. All four
cv_takesleepers — 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_shutdownhas to bring every one of them home, under a watchdog thread rather thanalarm()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_fillreserves budget and setsplanned=1.qt_note_plannedreturned 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, andif(resident||queued||planned) continuenever reconsidered the expert. #1331 was this leak for every expert of an int8 container — the class survived its fix.Fix, 19 lines:
qt_note_plannedhands the reservation back when it receives no weights;tier_warmstartreports every planned expert, with or without them.Verified
test_qwen36_tier_invariantswith the fixtest_qwen36_ctx,make qwen36make test-c(the full suite, ~120 binaries)Two notes from writing it, kept in the file:
queued=1until its upload returns. The test waits for both.test_qwen36_tier_multidevchecks residency right afterqt_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_ROWSconstant and the int8 warmstart-line / docs correction I suggested on #1344 — they belong there, and here they would collide.🤖 Generated with Claude Code