Skip to content

fix(qwen36 tier): qt_fill_wait returns only when the last upload is resident - #1390

Merged
JustVugg merged 1 commit into
JustVugg:devfrom
crichalchemist:qwen36-tier-fill-wait
Sep 12, 2026
Merged

JustVugg merged 1 commit into
JustVugg:devfrom
crichalchemist:qwen36-tier-fill-wait

Conversation

@crichalchemist

Copy link
Copy Markdown
Contributor

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_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 to the device: 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.

The fix

Count what is in flight, not what is queued. enqueue_locked increments G.inflight; the uploader decrements it only once 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, 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.c makes 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 reads queued/resident under G.mx the instant the wait returns.

before after
test_qwen36_tier_fill_wait fails 5/5 (all three assertions) passes 10/10
test_qwen36_tier_multidev, poll removed — passes 20/20
fill_wait, multidev, shutdown under ASan+UBSan — clean

The poll bdeb338 added to the multidev test is removed: the wait is now the guarantee. The other tier tests and make qwen36 are unchanged and green.

Relation to the other open PRs

Verified on macOS 13 x86_64 (2017 iMac), Apple clang, fake CUDA backend.

…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.
Copilot AI lite review requested due to automatic review settings September 7, 2026 21:37

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@kreuzzelg kreuzzelg left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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; a fake_upload_hook that fails the third tensor of the last expert would cover it in a few lines. Fine as a follow-up.
  2. qwen36_tier.h:142 has the non-CUDA stub static 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.

@JustVugg
JustVugg merged commit 2dac79e into JustVugg:dev Sep 12, 2026
25 checks passed
trigger2k20 pushed a commit to trigger2k20/colibri that referenced this pull request Sep 13, 2026
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.
@JustVugg JustVugg mentioned this pull request Sep 13, 2026
crichalchemist added a commit to crichalchemist/colibri that referenced this pull request Sep 17, 2026
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.
@crichalchemist
crichalchemist deleted the qwen36-tier-fill-wait branch September 23, 2026 19:24
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.

4 participants