fix(qwen36): three CUDA expert-tier bugs (#1339, #1340, #1341) - #1344
Conversation
qt_init allocated G.is_x as 32*D floats total (one device's worth), but qt_issue strides each device's block by 8*D floats and then writes up to 32 rows into it. Device di's block starts at 8*di*D but can span 32*D floats, so any di>0 with a full 32-row issue overruns its own slice and the end of the allocation -- silent heap corruption on multi-GPU setups. Add G.is_x_floats, size G.is_x as ndev*32*D floats, and stride each device's block by 32*D (its true max row count) instead of 8*D. Also factors the fake CUDA backend shared by the tier's tests out of test_qwen36_tier_int8.c into tests/qwen36_fake_cuda.h (unchanged behaviour, verified by rerunning it), with fake_ndev and fake_issue_hook hooks the new multi-device test needs. test_qwen36_tier_multidev.c fails to compile against the pre-fix tier (G.is_x_floats does not exist yet) and passes once the fix lands. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019BacNGNxAJ1M57UdYE2M3N
qt_shutdown set G.th_stop and signalled only G.cv, then pthread_join()d the uploader. But the uploader's LFRU victim path -- and qt_note_block, qt_note_planned, qt_fill_wait -- all wait on G.cv_take, which nothing in qt_shutdown ever broadcasts. If a caller issued a group (qt_issue sets G.issue_open=1) and never called qt_take (the only thing that clears it and broadcasts cv_take), a queued LFRU swap parks the uploader on that wait forever and qt_shutdown hangs. Broadcast G.cv_take alongside G.cv when th_stop is set. That alone unparks the wait loop, but the uploader would then fall through to freeing the victim's tensors and uploading into it while a group may still reference them; instead, when th_stop is set and issue_open is still set, abandon the swap: free the staged upload, clear the hot slot's queued flag, and restore the victim's resident flag (it was already cleared by qt_lfru_tick_locked before enqueue) so it stays consistent with the tensor it still holds. test_qwen36_tier_shutdown.c reproduces the sequence with the fake CUDA backend and an alarm(10) watchdog: it prints "FAIL: qt_shutdown hung" and hits the 10s alarm before the fix, returns in single-digit milliseconds after. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019BacNGNxAJ1M57UdYE2M3N
…ugg#1341) The warmstart hands the tier the live RAM weights for an int8 container (`wg = expert_is_int4 ? e->g4 : (const uint8_t *)e->g`), qt_note_planned parks that pointer in the tier's slot, and the next statement freed it unconditionally. The comment justified that with "on LFRU eviction slot_ensure_int8() rematerializes from g4" -- true only for int4. On an int8 container there is no second copy: e->g4 is NULL, so slot_ensure_int8() returns early (`if (s->g || !s->g4) return;`) and the CPU fallback in the decode loop dereferences NULL, while the pointer the tier kept dangles for any later stage(). Free the int8 block only when the container is int4, where g4/u4/d4 remain the source of truth and the peak-RSS win still applies; COLI_KEEP_INT8 keeps its meaning there. The warmstart body moves out of main into tier_warmstart(Model *, int expert_is_int4) -- same code, same messages, the QT_NO_WARMSTART check stays at the call site -- so a test can drive it without main. tests/test_qwen36_tier_int8_engine.c is that test: it includes qwen36.c (the test_qwen36_ctx.c pattern), the shared fake CUDA backend and qwen36_tier.c in one TU, builds an in-memory int8 model with no container behind it (slots pre-populated via slot_ensure_allocated and published in the layer index, so expert_get takes its hit path), runs the warmstart against the fake backend and checks that every planned expert is VRAM-resident, that slot_ensure_int8 still leaves g/u/d with the exact bytes the loader wrote, and that qs(0,eid)->g4 still points at the live block. A second in-memory model with packed int4 slots pins the behaviour the old line existed for: e->g is still dropped after staging and rebuilt bit-exact from g4/u4/d4. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019BacNGNxAJ1M57UdYE2M3N
|
Reviewed, and this supersedes the two PRs I had opened — I closed them. Yours came 16 hours first, covers all three, and does the one thing I had written off as impossible. On that last point specifically. I claimed in my own PR that the caller-side violation of #1341 could not be caught without a GPU, because it lives in the warmstart rather than in the tier. Three commits, one per issue, each with a test that fails before its fix: that is the shape we want. One suggestion, on #1341's condition. You have: if (!keep8 && expert_is_int4 && e->g) { free(e->g); ... }Correct today. What it encodes, though, is the current correspondence between weight format and memory ownership. Whoever adds a third format that also aliases The invariant itself does not mention formats: if (!keep8 && e->g && wg != (const uint8_t *)e->g) { free(e->g); ... }Do not free what you just handed over. A future aliasing format is then correct without anyone remembering; a future format with its own separate copy still frees as before. I verified it across the current two plus two hypothetical formats before suggesting it. Take it or leave it — the PR is good either way and I am not blocking on a one-line preference. If you would rather not touch it, say so and I will send it as a follow-up with your name on the original. CI approved and running. Reviewing the rest of the diff now. |
|
CI is red on Windows UCRT64, and it is the test rather than the fix:
I hit exactly this two days ago on #1336 with static void *deadline(void *unused) {
(void)unused;
struct timespec limit = {5, 0};
nanosleep(&limit, NULL);
fprintf(stderr, "FAIL: qt_shutdown did not return within 5s (#1340)\n");
_exit(1);
}
/* start it detached before qt_shutdown(), let the process exit normally if we get there */Worth keeping it running on Windows rather than Say if you would rather I push that as a commit on a branch here and you pull it, same as I did for #1316. The three fixes read well and I would like them landed. |
|
#1339 and #1340 are mine — the tier landed in #713 with both. Thank you for finding them, and more for the shape of the fix: a fake backend that lets the tier run in the CPU build is exactly what my own tests lacked, which is why these two survived. I read the three commits against current Confirmations#1340, the abandon path holds up against the tier's accounting. An LFRU swap is enqueued with The other three #1339 sizing: Three things I would still change1. Name the row limit once. After this patch the literal check(sizeof G.is_k[0] / sizeof G.is_k[0][0] == 32 &&
G.is_x_floats == (size_t)G.ndev * (sizeof G.is_k[0] / sizeof G.is_k[0][0]) * G.D,
"replica block stride equals the per-device row capacity");— makes the next drift fail here instead of in a driver. 2. The warmstart line and the docs now say something false on int8. 3. One cheap assertion for the mixed batch. The multidev test proves the two blocks are disjoint and inside the buffer. It could also pin where they are — device 0's at On the test infrastructureBecause With the watchdog in place of |
…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
…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
(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.)
alarm()/SIGALRM exist on POSIX and not on MinGW, and the Windows UCRT64 job is what proved it: the test could not build there. The test already uses pthread and nanosleep, so a detached watchdog thread does the same job everywhere it builds: sleep up to 10 s in 100 ms steps, and if qt_shutdown() has not returned by then, print the same line and _exit(1). Verified on Linux: the test passes; a probe with the flag never set exits 1 after 10 s.
|
Pushed the MinGW fix to your branch as one commit on top of yours, so nothing of yours moved: Verified here: the test passes on Linux, and a probe that never sets the flag exits 1 after exactly 10 s, so the watchdog still catches a hang. CI will say whether Windows agrees. Reason for the hurry, stated plainly: #1334 is on |
…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
(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.)
|
All three changes are in, plus the watchdog. I don't have push access to 1.
|
| injected drift | sizeof check | placement pin | in-bounds | disjoint |
|---|---|---|---|---|
stride reverted to 8*D (the #1339 shape) |
fires | fires | fires | fires |
is_k[..][64], stride still 32 |
fires | passes | passes | passes |
The second row is the one I did not expect. If a later edit widens the row array
without touching the stride, the placement pin is silent — device blocks really are
at is_x + di*32*D and really are disjoint; the buffer is just too small for a group
the array now permits. Your sizeof check is the only thing that catches it. And in
the first row the placement pin fires on a single-row device-1 issue where in-bounds
and disjointness both pass. So they detect different things and I kept both, rather
than folding one into the other as I had first intended.
Related: the PR's own in-bounds assertion passes under a too-small stride in the
mixed batch, because undersized blocks stay inside the allocation while overlapping
each other. In-bounds is not a sufficient invariant here — that is the argument for
the placement pin being an assertion rather than a comment.
2. The warmstart line and the docs
You are right, and the docs were worse than the log line. Both now say which container
they are talking about:
- the log line branches on
expert_is_int4—"int8 copy dropped for residents, kept for non-residents"vs"int8 container: all experts keep their weights in RAM"; docs/qwen36-cuda-tier.mdsays the RSS saving is a property of packed containers,
that an int8 container frees nothing since qwen36 CUDA tier: int8 containers free the weights the tier still points at (regression from #1334) #1341 and pays full residency for VRAM
speed, and that the 40 → 29 GB row is an int4 measurement with no int8 analogue.
3. The watchdog
alarm/SIGALRM are gone; a detached thread sleeps 10 s and _exit(1)s with the same
FAIL: qt_shutdown hung message. Same contract, and the intent is that the test runs
on UCRT64 rather than being compiled out — but see the caveat below: I have not built it
there, so that is the goal of the change, not something I have measured.
I checked it is a real oracle before proposing it. Fixed and unfixed trees, alarm and
watchdog harnesses, five runs per cell: identical verdicts in all four cells, 20/20, no
within-cell splits — passing cells 16–150 ms, hanging cells 10046–10270 ms, about two
and a half orders of magnitude apart and non-overlapping. And reverting just the
pthread_cond_broadcast(&G.cv_take) in qt_shutdown on the current tree reproduces the
hang 3/3 under the watchdog at 10.13–10.22 s, so the deadline is load-bearing and not
merely green.
Caveat I want stated plainly: all of the above is macOS/arm64 + Apple clang against
the fake backend. I have not compiled or run this on MinGW/UCRT64 — I removed the
non-portable API, I did not measure the Windows build. Until someone builds it there (I
intend to), the CI job is the only thing that will actually prove the port. Nothing here
is a performance claim either; these are liveness and memory-safety gates only.
Thanks for the review — the #1040 placement work landing on qwen36_fake_cuda.h is
exactly why I pulled it out into a shared header.
|
Verified on merged Built and ran the tier suite against the fake-CUDA harness on darwin/arm64: @JustVugg your SIGALRM→thread swap in @kreuzzelg the #1340 shutdown fix reads well in final form — One note on what this does and doesn't establish: this is the fake-CUDA harness on CPU, so it validates the tier's control flow — per-device replica sizing, shutdown wakeup, int8 slot retention — but not real CUDA behaviour. The multidev test proves the tier keeps per-device state separate; it doesn't prove correctness against actual multi-GPU hardware, which I can't test here. Worth someone running it on a real 2-GPU box before relying on #1339 being fully closed. Nothing outstanding from my side. Thanks both — clean collaboration. |
…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
(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.)
…shutdown watchdog Review response for JustVugg#1344. - QT_MAX_ROWS replaces the bare 32 in all seven sites that must agree (is_k width, K clamp, tg/tu/td, rows[], topk message, is_x_floats, qt_issue stride). JustVugg#1339 was three of these disagreeing. - multidev test gains the sizeof consistency check and a placement pin. These catch different drift directions: widening is_k to 64 while the stride stays 32 is caught ONLY by the sizeof check (placement, in-bounds and disjointness all pass); shrinking the stride below the row capacity is caught by the placement pin where in-bounds is not sufficient. Both kept. - shutdown test: alarm()/SIGALRM are not available on MinGW/UCRT64, so the Windows job could not build it. A detached watchdog thread has the same contract and keeps the test running there. - warmstart log line and docs no longer promise an RSS saving that does not exist on an int8 container (since JustVugg#1341 the free is int4-only).
qwen36 tier: name QT_MAX_ROWS, pin replica-block placement, portable shutdown watchdog. Addresses the review on JustVugg#1344 from kreuzzelg and JustVugg. Conflict in c/tests/test_qwen36_tier_shutdown.c resolved in favour of fa8142d (the thread watchdog already merged upstream); both sides made the same alarm()/SIGALRM -> pthread replacement. Verified on darwin/arm64 against the fake CUDA backend: zero-warning build of qwen36 and the four tier tests, all passing.
…ainer docs Review response for JustVugg#1344, rebased onto dev after the auto-placer (ff13134) moved the is_x allocation below the placement pass. - QT_MAX_ROWS replaces the bare 32 in all seven sites that must agree (is_k width, K clamp, tg/tu/td, rows[], topk message, is_x_floats, qt_issue stride). JustVugg#1339 was three of these disagreeing. - multidev test gains the sizeof consistency check and a placement pin. These catch different drift directions: widening is_k to 64 while the stride stays 32 is caught ONLY by the sizeof check (placement, in-bounds and disjointness all pass); shrinking the stride below the row capacity is caught by the placement pin where in-bounds is not sufficient. Both kept. - warmstart log line and docs no longer promise an RSS saving that does not exist on an int8 container (since JustVugg#1341 the free is int4-only). The shutdown-test watchdog from the original follow-up is already on dev (fa8142d), so that file is untouched here.
The JustVugg#1341 free keyed on expert_is_int4, which encodes today's correspondence between weight format and memory ownership. A third format that also aliases e->g would have no reason to read a format flag as an ownership statement and would reintroduce the use-after-free. State the invariant directly: do not free what was just handed to the tier. int4 handed g4, so the int8 copy is spare and goes; int8 handed e->g itself, so it stays. Same behaviour on both current containers (test_qwen36_tier_int8_engine covers both), correct by construction for the next one. Suggested by JustVugg in the JustVugg#1344 review.
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 darwin/arm64: 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, and the tier initialises on the Apple GPU (the test budget is adjusted in the next commit).
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).
qwen36 tier: QT_MAX_ROWS, placement pin, ownership-based int8 free (follow-up to #1344)
Summary
Three pre-existing bugs in the Qwen3.6 CUDA expert tier, found while porting the tier to Vulkan (#1338) and filed as #1339, #1340, #1341. One commit per issue, each with a test that fails on the tree before its fix. The tests link
qwen36_tier.cagainst a fake CUDA backend (tests/qwen36_fake_cuda.h, factored out of the existingtest_qwen36_tier_int8.c), so they run in the plain CPU build and in CI.qt_issuestrides each device's input block by8*Dfloats into a buffer of32*Dfloats. With two or more GPUs, a full 32-row issue on device 1 writes past the end of the allocation. Fix: allocatendev*32*Dfloats and stride by32*D. The new struct fieldis_x_floatsexists so the test can assert against the real allocation rather than restate the arithmetic.qt_shutdownsignalscvbut nevercv_take, so the uploader parked on the LFRU victim wait (andqt_note_*/qt_fill_waitwaiters) never noticeth_stopandpthread_joinhangs when a group is still open. Fix: broadcastcv_taketoo; the uploader abandons a swap it wakes into during shutdown instead of freeing a victim tensor an in-flight group may still reference.g4. That only holds for int4; int8 has no second copy, so the tier's pointer dangles and the CPU fallback dereferences NULL. Fix: free the int8 copy only for int4 containers. The warmstart is extracted intotier_warmstart()so the engine-level test can drive it withoutmain.Notes for review: with int8 weights kept, the tier's slot pointer aliases a live engine slot; that is safe because
qt_initrequirescap == n_experts, so a planned slot is never recycled. Thecv_takebroadcast makes a benign post-shutdown enqueue reachable if a caller racesqt_note_*againstqt_shutdown(previously that caller blocked forever); left as is to keep the diff surgical.qt_shutdownstill frees none ofG's allocations (pre-existing, untouched).Validation
make -C c check(OK, skipped=35) andmake -C c test-asan(clean under ASan+UBSan)make -C c cuda-test(if applicable) — on a Colab Tesla T4 (driver 580.82.07, CUDA 12.8), commit d937ad2:make -C c cuda-test CUDA_ARCH=nativepasses; the four tier tests pass;make qwen36 CUDA=1 CUDA_ARCH=nativebuilds with 0 warnings; the CI tiny fixture (--ebits 8, int8 experts) decodes 16/16 tokens against the torch reference with the tier off, on (64/64 experts resident, 100 % VRAM hits), and on with a starved budget (CUDA_EXPERT_GB=0.00002: 1/64 resident, 317 CPU misses through the int8 fallback that qwen36 CUDA tier: int8 containers free the weights the tier still points at (regression from #1334) #1341 fixes), tier-on output identical to tier-off. One warning appeared in that run from Colab's older gcc in the engine-test build,qwen36.c:1450 -Waggressive-loop-optimizations, in the pre-existing nibble-unpack tail loop that this PR does not touch; gcc 13 (CI) is clean.RED evidence, each against the tree without its fix:
test_qwen36_tier_multidevunder ASan with the old stride:test_qwen36_tier_shutdownhangs inqt_shutdown; the test's 10 s watchdog fires (FAIL: qt_shutdown hung). With the broadcast but without the drop path, the post-shutdown state checks fail (victim tensor freed, incoming expert uploaded after shutdown began).test_qwen36_tier_int8_engineagainst the tree with the extraction but the old free condition:Build: 0 warnings under the Makefile's flags for the tests and
make qwen36.Compatibility
Fixes #1339, fixes #1340, fixes #1341.
🤖 Generated with Claude Code
https://claude.ai/code/session_019BacNGNxAJ1M57UdYE2M3N