fix(qwen36 tier): qt_fill_wait returns only when the last upload is resident - #1390
Conversation
…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. JustVugg#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.
kreuzzelg
left a comment
There was a problem hiding this comment.
Reviewed as the author of the tier and of the #1360 poll this removes. The fix is the right shape and I checked the accounting against every exit of the uploader.
Accounting. Every entry that enqueue_locked counts leaves through exactly one of the two decrements: the abandon-at-shutdown branch (th_stop && issue_open) or the common tail after the three uploads, which also covers the "device genuinely full" failure. qt_shutdown sets th_stop and joins the uploader, and the uploader only returns on qn == 0, so nothing stays in the ring uncounted; a waiter parked in qt_fill_wait at shutdown leaves on th_stop as before. The extra cv_take broadcasts land on while-guarded waits (queue space, issue_open), so spurious wakeups cost nothing. Nothing on the decode path changes.
On real hardware, two cards (RTX 3070 + Quadro RTX 4000, driver 580.173, CUDA 12.0), the PR applied on dev 6f9117f:
| result | |
|---|---|
test_qwen36_tier_fill_wait |
10/10 |
test_qwen36_tier_multidev (poll removed) |
20/20 |
| shutdown, int8_engine, invariants, autoplace, fp8, int8 | all pass |
CI's qwen36_tiny fixture, int8 container (--ebits 8), COLI_CUDA=1, default warmstart, ./qwen36 8 8 ref_full.json |
40/40 runs, 16/16 tokens, uploads 64 / miss(CPU) 0 |
same, int4 container (--ebits 4), ./qwen36 8 4 |
20/20 runs, 16/16 tokens |
One caveat for anyone repeating the hardware runs: on dev as of today the default warmstart segfaults about one run in twelve for a reason unrelated to this PR (ehit_mark's lazy allocation racing in the parallel warmstart, details in #1331). I ran the table above with a local lock on that allocation; without it the crash would look like a tier fault and it is not one.
Two small remarks, neither blocking:
- The new test proves the wait on the success path. The failure path (
ok == 0, the "device genuinely full" branch) decrements too, but no test pins it; afake_upload_hookthat fails the third tensor of the last expert would cover it in a few lines. Fine as a follow-up. qwen36_tier.h:142has the non-CUDA stubstatic inline void qt_fill_wait(void){}; its comment could say the same as the new one at line 116 so the two declarations do not drift.
Approve. Once this is in I will rebase my qwen38 tier branch onto it; the dense-trunk residents there do not go through the uploader, so nothing else to reconcile.
dev went red on `Windows UCRT64` / `make check` right after JustVugg#1390 landed: tests/test_qwen36_tier_fill_wait.c:42:5: error: implicit declaration of function 'setenv'; did you mean 'getenv'? MinGW has no setenv. The tests that include an engine .c inherit compat.h through it, but this one includes qwen36_tier.c, which does not pull it in, so the declaration was never there. Linux never noticed because glibc has setenv, and modern GCC turns an implicit declaration into an error rather than a warning, so the Windows leg is where it surfaced. Same shape as JustVugg#1440, which added the include to the six tests that already had it. Reproduced and fixed against mingw-w64 with the Makefile's own Windows flags plus -Werror=implicit-function-declaration, then every other gated test was cross-compiled the same way: this file is the only one affected. The three that still fail that sweep (test_uring, test_deepseek_v4, test_v4_ownership) are in TEST_EXCLUDE and are not built on the Windows leg at all. compat.h joins the rule's prerequisites too, so editing the shim relinks the test instead of leaving a stale one.
Second forward-merge of this branch. Four files conflicted; the rest of dev merged clean. c/qwen36_tier.c, c/qwen36_tier.h: JustVugg#1390 landed upstream with reworded comments, so the branch's copy of the qt_fill_wait fix is dropped in favour of dev's. JustVugg#1388's QT_MAX_ROWS replaces the literal 32 in the issue path and the per-device row arrays; the backend-neutral QtTensor stays, since dev's side of those lines still spells ColiCudaTensor. The dense-trunk block dev added for the Qwen3.8 trunk (qt_dense_init, qt_dense_matmul, dense_free_all) called coli_cuda_* directly and would not compile without CUDA. It now goes through the same be_trunk_upload / be_trunk_matmul / be_free shim as lm_head and the DeltaNet projections, so on Vulkan it refuses and the matrices stay on the CPU path. coli_cuda_group_stats is left alone: it is already inside #ifdef COLI_CUDA. G.ybuf was the one remaining literal 32 in the file and is now sized by QT_MAX_ROWS. Not a live overflow at the current value, but it is the fourth spelling of the constant JustVugg#1339 was filed to unify. c/Makefile: dev's new prerequisites (expert_ffn.h, omp_tune.h, kv_prefix.h) with this branch's $(VK_OBJ)/$(VK_SPV) re-applied, and the tier condition is dev's ifneq over CUDA/CUDA_DLL/HIP/HIP_DLL with the VK=1 branch kept after it. The three tests/test_qwen36_tier_vk* rules are unchanged. docs/qwen36.md: dev's expert-kernel section and the ROCm paragraph, with the VK=1 sentence and the qwen36-tier.md link restored.
Root-cause fix for the race @kreuzzelg found in #1360 ("queue empty is not uploader done") and covered there on the test side.
The defect
qt_fill_waitwaited 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 to the device: the caller sees itqueued=1, resident=0. The engine's warmstart frees the RAM int8 copy of every planned expert right afterqt_fill_wait, trusting that they are all in VRAM.The fix
Count what is in flight, not what is queued.
enqueue_lockedincrementsG.inflight; the uploader decrements it only once the slot is resident (or the upload failed, or the entry was abandoned at shutdown) and broadcastscv_take;qt_fill_waitblocks oninflight. Same lock, same condition variable, one extra broadcast per completed upload, nothing new on the decode path.The test, in the shape #1344 set
One commit, one test that fails before the fix.
tests/test_qwen36_tier_fill_wait.cmakes the race deterministic instead of one-in-fifteen: the fake backend gains a third hook,fake_upload_hook, the test sleeps 3 ms per tensor in it, and readsqueued/residentunderG.mxthe instant the wait returns.test_qwen36_tier_fill_waittest_qwen36_tier_multidev, poll removedThe poll bdeb338 added to the multidev test is removed: the wait is now the guarantee. The other tier tests and
make qwen36are unchanged and green.Relation to the other open PRs
inflightcounter as part of its Vulkan work (the Vulkan backend has no completion signal of its own, so it needed one). Once this lands I will rebase qwen36: Vulkan expert tier, and staged device-local uploads for cards without Resizable BAR #1338 onto it and drop its copy, which shrinks that PR.Verified on macOS 13 x86_64 (2017 iMac), Apple clang, fake CUDA backend.