Skip to content

fix(qwen36): three CUDA expert-tier bugs (#1339, #1340, #1341) - #1344

Merged
JustVugg merged 4 commits into
JustVugg:devfrom
crichalchemist:qwen36-tier-fixes
Sep 6, 2026
Merged

JustVugg merged 4 commits into
JustVugg:devfrom
crichalchemist:qwen36-tier-fixes

Conversation

@crichalchemist

@crichalchemist crichalchemist commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

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.c against a fake CUDA backend (tests/qwen36_fake_cuda.h, factored out of the existing test_qwen36_tier_int8.c), so they run in the plain CPU build and in CI.

Notes for review: with int8 weights kept, the tier's slot pointer aliases a live engine slot; that is safe because qt_init requires cap == n_experts, so a planned slot is never recycled. The cv_take broadcast makes a benign post-shutdown enqueue reachable if a caller races qt_note_* against qt_shutdown (previously that caller blocked forever); left as is to keep the diff surgical. qt_shutdown still frees none of G's allocations (pre-existing, untouched).

Validation

  • make -C c check (OK, skipped=35) and make -C c test-asan (clean under ASan+UBSan)
  • CUDA changes were tested with 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=native passes; the four tier tests pass; make qwen36 CUDA=1 CUDA_ARCH=native builds 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.
  • Performance claims include hardware, commands, and repeatable measurements (no performance claims)

RED evidence, each against the tree without its fix:

  • qwen36 CUDA tier: qt_issue overruns G.is_x with two or more GPUs (stride 8*D into a 32*D buffer) #1339: test_qwen36_tier_multidev under ASan with the old stride:
    =================================================================
    ==1336655==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x525000002100 at pc 0x7b42016fb303 bp 0x7ffeb9de4710 sp 0x7ffeb9de3eb8
    WRITE of size 256 at 0x525000002100 thread T0
        #2 0x5d36d942a1fc in qt_issue tests/../qwen36_tier.c:450
        #3 0x5d36d942c61f in main tests/test_qwen36_tier_multidev.c:76
    0x525000002100 is located 0 bytes after 8192-byte region [0x525000000100,0x525000002100)
    SUMMARY: AddressSanitizer: heap-buffer-overflow ../../../../src/libsanitizer/sanitizer_common/sanitizer_common_interceptors_memintrinsics.inc:115 in memcpy
    
  • qwen36 CUDA tier: qt_shutdown never signals cv_take, so pthread_join can hang if a group is open #1340: test_qwen36_tier_shutdown hangs in qt_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).
  • qwen36 CUDA tier: int8 containers free the weights the tier still points at (regression from #1334) #1341: test_qwen36_tier_int8_engine against the tree with the extraction but the old free condition:
    int8 container
      ok   every planned int8 expert reached VRAM
      ok   three uploads per planned expert (gate, up, down)
      FAIL a planned int8 expert still has weights for the CPU fallback
      FAIL those weights are the bytes the loader wrote
      FAIL the pointer the tier kept still targets the live int8 block
    int4 container
      ok   every planned int4 expert reached VRAM
      ok   int4 still drops the int8 copy right after staging (peak RSS)
      ok   slot_ensure_int8 rebuilds it from the packed g4/u4/d4
    FAILED 3
    

Build: 0 warnings under the Makefile's flags for the tests and make qwen36.

Compatibility

  • The default CPU build remains dependency-free
  • No model files, generated binaries, or benchmark artifacts are included

Fixes #1339, fixes #1340, fixes #1341.

🤖 Generated with Claude Code

https://claude.ai/code/session_019BacNGNxAJ1M57UdYE2M3N

crichalchemist and others added 3 commits September 4, 2026 12:47
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
Copilot AI lite review requested due to automatic review settings September 4, 2026 19: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.

@JustVugg

JustVugg commented Sep 5, 2026

Copy link
Copy Markdown
Owner

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. test_qwen36_tier_int8_engine.c catches it: include qwen36.c, build an in-memory int8 model with no container behind it so expert_get takes its hit path, drive tier_warmstart() against the fake backend. That is the right answer and I had stopped one step short of it. Factoring qwen36_fake_cuda.h out so all three tier tests share it is the move that made it possible.

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 e->g — an fp8 container, say — has no reason to read expert_is_int4 as a statement about ownership, and would reintroduce this exact bug without noticing.

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.

@JustVugg

JustVugg commented Sep 5, 2026

Copy link
Copy Markdown
Owner

CI is red on Windows UCRT64, and it is the test rather than the fix:

tests/test_qwen36_tier_shutdown.c:115: error: 'SIGALRM' undeclared
tests/test_qwen36_tier_shutdown.c:116: error: implicit declaration of function 'alarm'

alarm/SIGALRM are POSIX and MinGW has neither. Everything else is green, and the tier code itself builds there.

I hit exactly this two days ago on #1336 with pipe/dup2, so the shape is familiar. A watchdog thread is the portable equivalent and keeps the test meaningful on Windows instead of skipping it there:

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 #ifndef _WIN32: on #1336 the Windows job — once the test could actually build there — found a real semantic difference in my own code that Linux could not see. A skipped test on the platform you are least able to check locally is the one you most want running.

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.

@kreuzzelg

Copy link
Copy Markdown
Contributor

#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 dev rather than against the description; notes below, two confirmations and three things I would still change.

Confirmations

#1340, the abandon path holds up against the tier's accounting. An LFRU swap is enqueued with v_eid >= 0, so enqueue_locked never reserves budget for it (!reserved && v_eid<0 is false) — abandoning it needs no G.used correction, and the patch correctly makes none. The two flags it restores are the two that qt_lfru_tick_locked and enqueue_locked changed: the victim's resident (cleared before enqueue) and the newcomer's queued. The victim still holds its tensors, so resident=1 is the truthful state. And the race the test cannot pin — whether the uploader has reached the cv_take wait when shutdown fires — is handled in both orders: if it is parked, the broadcast wakes it into the abandon branch; if it has not arrived yet, !G.th_stop fails and it skips the wait into the same branch. Nothing depends on the poll in the test having caught it parked.

The other three cv_take waiters exit cleanly too. qt_note_planned and qt_note_block wake into enqueue_locked, which returns 0 on a full queue — qt_note_planned then returns its reservation via the existing s->planned branch. qt_fill_wait simply returns with qn > 0, which at shutdown is what we want.

#1339 sizing: G.ndev is final at the allocation (the have < ndev clamp is thirty lines above it), and home() never returns a device index at or beyond ndev, so ndev * 32 * D is the whole address space qt_issue can touch.

Three things I would still change

1. Name the row limit once. After this patch the literal 32 stands in seven places that must agree: is_k[QT_MAX_DEV][32], K>32 in qt_issue, tg/tu/td[QT_MAX_DEV][32], rows[32], the is_x_floats computation and the stride. #1339 was three of those disagreeing. A QT_MAX_ROWS next to QT_MAX_DEV, used in all seven, plus one line in the multidev test that ties the stride to the array the rows are indexed by —

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. "[qtier] warmstart (parallel): all %d experts in RAM (int8 only for non-residents)" was true because the int8 copy of every resident was freed. With #1341 that free is int4-only, so on an int8 container every resident keeps its weights and the parenthesis is wrong — and so is the RSS saving docs/qwen36-cuda-tier.md quotes (40 → 29 GB), which is an int4 number. Both should say so: the tier's RSS advantage is a property of packed containers, an int8 container pays full residency and gets VRAM speed only. Better a line that says "int8 container: all experts stay in RAM" than one that promises a saving the reader will not see.

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 G.is_x, device 1's at G.is_x + 32*D — which is the property the stride literal encodes and the one assertion that would have failed on the old code even with a single row on device 1.

On the test infrastructure

Because TEST_RULES collects every tests/test_* rule, all four land in make test-c without a list edit, and test-asan rebuilds that suite under ASan/UBSan in the Sanitizers job. So the multidev test is also an ASan detector for #1339 from the day it merges, and the tier finally has a memory-safety gate that does not need a GPU. I will build the placement work I proposed in #1040 on qwen36_fake_cuda.h rather than on a second harness — thank you for factoring it out.

With the watchdog in place of alarm this is good to go from where I sit.

kreuzzelg added a commit to kreuzzelg/colibri that referenced this pull request Sep 5, 2026
…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
kreuzzelg added a commit to kreuzzelg/colibri that referenced this pull request Sep 5, 2026
…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
kreuzzelg added a commit to kreuzzelg/colibri that referenced this pull request Sep 5, 2026
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 added a commit to kreuzzelg/colibri that referenced this pull request Sep 5, 2026
(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.
@JustVugg

JustVugg commented Sep 6, 2026

Copy link
Copy Markdown
Owner

Pushed the MinGW fix to your branch as one commit on top of yours, so nothing of yours moved: test_qwen36_tier_shutdown.c now bounds qt_shutdown() with a detached watchdog thread instead of alarm()/SIGALRM, which MinGW does not have. The test already used pthread and nanosleep, so this adds no dependency. Same 10 s, same failure line, same _exit(1).

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 dev since 1.10.1 and this PR is the fix for the use-after-free it introduced (#1341). The next release must not ship one without the other, and this test was the only thing standing between them. If you would rather carry the change yourself, revert my commit and push yours; either is fine.

kreuzzelg added a commit to kreuzzelg/colibri that referenced this pull request Sep 6, 2026
…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
kreuzzelg added a commit to kreuzzelg/colibri that referenced this pull request Sep 6, 2026
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 added a commit to kreuzzelg/colibri that referenced this pull request Sep 6, 2026
(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.)
@mfethe1

mfethe1 commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

All three changes are in, plus the watchdog. I don't have push access to
qwen36-tier-fixes, so they're in a cross-fork PR against that branch rather than
pushed directly: crichalchemist#1 (ef3ac1e). @crichalchemist — merge,
cherry-pick, or apply by hand, whichever suits. Two of your requests turned out to be
measurably complementary rather than overlapping, which is worth writing down.

1. QT_MAX_ROWS, and why your check #1 and your check #3 are both needed

QT_MAX_ROWS now stands in all seven places (is_k, the K> clamp, tg/tu/td,
rows[], the topk clamp message, is_x_floats, the qt_issue stride), and the
multidev test carries your sizeof assertion plus a placement pin.

I built both and then deliberately broke the tree in each of the two drift directions,
three repeats each, to check neither assertion was decoration:

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:

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.

@JustVugg
JustVugg merged commit e950536 into JustVugg:dev Sep 6, 2026
25 checks passed
@JustVugg JustVugg mentioned this pull request Sep 6, 2026
@mfethe1

mfethe1 commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Verified on merged dev (e9505363): all four commits landed and all three fixes are intact in the tree.

Built and ran the tier suite against the fake-CUDA harness on darwin/arm64:

test_qwen36_tier_int8          ok
test_qwen36_tier_multidev      ok   (2 devices, per-device budgets)
test_qwen36_tier_shutdown      ok   (thread watchdog, no hang)
test_qwen36_tier_int8_engine   OK   (3/3: planned int4 reach VRAM,
                                     int8 copy dropped after staging,
                                     slot_ensure_int8 rebuilds from packed g4/u4/d4)

@JustVugg your SIGALRM→thread swap in fa8142d7 is the right call — the watchdog fires correctly here and the test is now portable to MinGW without the signal dependency. Thanks for pushing it onto the branch rather than bouncing the PR back.

@kreuzzelg the #1340 shutdown fix reads well in final form — pthread_cond_broadcast(&G.cv_take) alongside the existing signal(&G.cv) at qwen36_tier.c:514, so the qt_note_planned/qt_fill_wait waiters on the shared condvar all observe th_stop and pthread_join can't hang.

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.

@JustVugg JustVugg mentioned this pull request Sep 6, 2026
kreuzzelg pushed a commit to kreuzzelg/colibri that referenced this pull request Sep 6, 2026
kreuzzelg added a commit to kreuzzelg/colibri that referenced this pull request Sep 6, 2026
…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
kreuzzelg added a commit to kreuzzelg/colibri that referenced this pull request Sep 6, 2026
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 added a commit to kreuzzelg/colibri that referenced this pull request Sep 6, 2026
(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.)
crichalchemist pushed a commit to crichalchemist/colibri that referenced this pull request Sep 7, 2026
…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).
crichalchemist added a commit to crichalchemist/colibri that referenced this pull request Sep 7, 2026
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.
crichalchemist pushed a commit to crichalchemist/colibri that referenced this pull request Sep 7, 2026
…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.
crichalchemist added a commit to crichalchemist/colibri that referenced this pull request Sep 7, 2026
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.
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 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).
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 added a commit that referenced this pull request Sep 12, 2026
qwen36 tier: QT_MAX_ROWS, placement pin, ownership-based int8 free (follow-up to #1344)
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.

5 participants