Skip to content

qwen36: Vulkan expert tier, and staged device-local uploads for cards without Resizable BAR - #1338

Open
crichalchemist wants to merge 27 commits into
JustVugg:devfrom
crichalchemist:qwen36-vulkan-tier
Open

crichalchemist wants to merge 27 commits into
JustVugg:devfrom
crichalchemist:qwen36-vulkan-tier

Conversation

@crichalchemist

@crichalchemist crichalchemist commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Two things, one PR because the second is what makes the first measurable on most older discrete cards.

Qwen3.6 Vulkan expert tier. make qwen36 VK=1 builds the existing qwen36_tier.c against the shared Vulkan backend. The placement logic (warmstart, heat file, upload queue, hit/miss accounting) is unchanged; a compile-time shim of twelve one-line wrappers selects CUDA or Vulkan (#if COLI_CUDA / #elif COLI_VULKAN, CUDA wins if both). On Vulkan the tier is single-device and fills once at warmstart: the Vulkan weight arena never reclaims a freed slice, so runtime LFRU swaps would leak one expert of VRAM each. Heat still accumulates and saves, so the second run starts hot. Budget is VK_EXPERT_GB (auto = device budget minus 1 GB). The engine names its backend in the banner and, on a build without the tier, says so once when COLI_VULKAN is set instead of silently ignoring it (refs #894).

Staged device-local uploads in backend_vulkan.c. Without Resizable BAR the HOST_VISIBLE|DEVICE_LOCAL type is a ~256 MB window and everything past it silently spills to system RAM, so the tier reports resident experts that every access fetches over PCIe. When the host-visible slice is under a quarter of VRAM (or COLI_VK_STAGED=1), resident weights now go to a DEVICE_LOCAL-only arena through a host staging buffer and vkCmdCopyBuffer. Scratches, the KV mirror and readbacks keep their memory types. Queue submits and arena allocation are mutex-protected so the tier's upload thread is safe next to the decode thread.

Validation

Hardware: AMD Radeon RX 580 8 GB (Polaris, gfx803, 256 MB host-visible BAR), Mesa 25.2.8 RADV, i7-7700K, 62 GB RAM, Ubuntu 24.04. First validation of the Vulkan backend on Polaris.

  • Backend exactness harness (-DVK_TEST), mapped vs staged: 50/50 maxrel values identical; staged batched int4 matmuls ~4× faster on this card (0.17 vs 0.66 ms/matmul).
  • New gate tests/test_qwen36_tier_vk (part of make check; skips without VK=1 or a device): GPU output vs CPU int4 reference maxrel 6.7e-06, misses return no mask bit, residency frozen after warmstart.
  • Engine, Qwen3.6-35B-A3B int4-gs64, 15-token prompt, N_NEW=64, 8 threads, greedy, frozen HEAT_FILE on runs 2–3 and the mapped arm, GPU clocks not pinned (no root):
arm tok/s TTFT VRAM-resident VRAM hit
CPU only 0.63 44.7 s — —
Vulkan, cold heat 6.40 1.50 s 3,663 / 10,240 36.1 %
Vulkan, warm heat (2 runs) 6.38 / 5.60 1.48 / 1.50 s 3,655 / 10,240 96.8 %
Vulkan, mapped path (COLI_VK_STAGED=0) 2.44 2.48 s 3,655 / 10,240 96.8 %

Runs 2 and 3 are token-identical; CPU vs GPU output is token-identical on this prompt. Measured on the tree rebased onto current dev (tier commit bb16ab3); an earlier measurement of the same code before the rebase read 7.5 / 4.2 tok/s for the warm and mapped arms, so treat the absolute numbers as ±15 % (clocks unpinned) and the CPU→Vulkan and mapped→staged ratios as the result.

  • Tiny-fixture token-exact check 16/16 on both the plain and the VK=1 build (done manually: there is no qwen36-tiny-check target).
  • make check green; make qwen36, make qwen36 VK=1, make colibri VK=1 build with 0 warnings from the touched files. CI's Vulkan job now also builds qwen36 VK=1 and runs the tier gate on Lavapipe.

Compatibility

  • Default CPU build unchanged and dependency-free (ldd qwen36 links no libvulkan without VK=1). VK=1 is opt-in.
  • CUDA tier: allocation-identical (the Vulkan take buffer is compiled out), but one shared-logic fix affects it: the uploader decremented the queue count at dequeue, before the upload ran, so qt_fill_wait could return before the last upload finished while the engine frees the RAM int8 copies right after. Deterministic on the slower staged path (5/5), latent on CUDA. Now an in-flight completion counter; qt_fill_wait returns strictly later.
  • Finding I could not resolve: with staged uploads, results computed from a freshly created device-local block differ slightly and non-deterministically unless the block is filled once with vkCmdFillBuffer on creation. The fill value is irrelevant (0x00 and 0xFF measured identical), so it is the first GPU-side touch of the allocation, not a read of filler bytes. The fill is in; the cause is open (no GPU validation layers on my box). Repro: skip the fill and run the harness twice with COLI_VK_STAGED=1.
  • Pre-existing, not fixed here (happy to file issues): qt_issue strides G.is_x by di*8*D while it is sized 32*D, so a two-GPU CUDA run with a wide top-k can overrun; qt_shutdown never signals cv_take, so a thread parked in qt_fill_wait would not see th_stop.
  • Rebased onto current dev (f58a267). fix(qwen36): the VRAM tier now promotes int8 experts instead of reserving for nothing #1334's int8 expert promotion is kept verbatim and its uploads go through the shim as fmt 1 on both backends; the int8-on-Vulkan path, which neither side had alone, gets its own gate (test_qwen36_tier_vk_int8, runs on Lavapipe in CI). The whole validation above was re-run on the rebased tree.
  • Docs: docs/qwen36-cuda-tier.md → docs/qwen36-tier.md (link updated), docs/vulkan.md, docs/ENVIRONMENT.md (COLI_VK_STAGED, VK_EXPERT_GB), CHANGELOG.

🤖 Generated with Claude Code

https://claude.ai/code/session_019BacNGNxAJ1M57UdYE2M3N

Copilot AI lite review requested due to automatic review settings September 4, 2026 15:06

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.

crichalchemist and others added 12 commits September 4, 2026 10:13
Prepares the backend for a second thread uploading weights while the decode
thread submits expert groups (qwen36 tier). No behaviour change on one thread.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BacNGNxAJ1M57UdYE2M3N
Picks a DEVICE_LOCAL-only memory type and a non-BAR staging type, creates the
upload command buffer/fence, and reports the mode. Uploads still use the mapped
path; the next commit switches them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BacNGNxAJ1M57UdYE2M3N
Scratches, KV mirror and readbacks keep their memory types. New device-local
blocks are filled once with vkCmdFillBuffer before use: without it, results
computed from a fresh block differ slightly and non-deterministically; with
it (any fill value — 0x00 and 0xFF measured identical) all 50 harness cases
are byte-identical in both modes on RX 580 (gfx803). The fill value being
irrelevant means the mechanism is the first GPU-side touch of the
allocation, not a read of filler bytes; the underlying cause is unresolved
and reported as a finding.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BacNGNxAJ1M57UdYE2M3N
Same lookup colibri.c and kimi_k3.c each carry privately; the qwen36 tier uses
this one. The two existing copies are left for a later cleanup.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BacNGNxAJ1M57UdYE2M3N
No behaviour change on CUDA: the placement logic now calls ten be_* operations
and a Vulkan implementation slots in next. Header guard admits COLI_VULKAN.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BacNGNxAJ1M57UdYE2M3N
Routed experts are served from VRAM through coli_vk_expert_group_issue/take.
Budget via VK_EXPERT_GB (auto = device budget minus 1 GB). No runtime LFRU
swaps: the Vulkan arena never reclaims a freed slice, so residency is decided
at warmstart (HEAT_FILE order). test_qwen36_tier_vk checks GPU-vs-CPU output
and the fill-once rule; it skips without VK=1 or a device.

Also fixes a pre-existing race in the shared uploader: qt_fill_wait() checked
G.qn (the ring-buffer depth, freed at dequeue time) instead of tracking real
upload completion, so it could return before the last queued expert's
be_upload() had actually finished. Harmless on fast synchronous CUDA copies,
but on staged Vulkan uploads (submit + fence wait) the window was wide enough
to fail deterministically -- confirmed by tracing be_upload() completions
against the test's residency check, and by COLI_VK_STAGED=0 (no staging
submit) passing cleanly with the unmodified logic. Added a separate
G.inflight counter, incremented on enqueue and decremented only when a
queued item's upload actually completes, and switched qt_fill_wait() to
wait on it instead of G.qn.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BacNGNxAJ1M57UdYE2M3N
The banner names CUDA or Vulkan, and a build without the tier says so once
when COLI_VULKAN is set instead of ignoring it (refs JustVugg#894).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BacNGNxAJ1M57UdYE2M3N
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BacNGNxAJ1M57UdYE2M3N
…ing cleanup, docs

- qwen36_tier: header identity and qt_fill_wait contract state completion,
  not dequeue; G.ybuf allocated only on the single-device (Vulkan) build;
  qt_take logs once and clears the count when a take fails.
- backend_vulkan: stage scratch handles nulled on free/failure (no double
  free at shutdown on OOM); COLI_VK_STAGED accepts exactly 0/1, else auto;
  small-BAR hint only when =0 was set; COLI_VK_SPIN_US read once at init.
- docs: CUDA-only semantics labelled in the tier doc; COLI_VK_DEV noted;
  Polaris validation moved out of "Limits"; ENVIRONMENT row for
  COLI_VK_STAGED; CI tier gate uses pipefail and explains the skip policy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BacNGNxAJ1M57UdYE2M3N
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_019BacNGNxAJ1M57UdYE2M3N
Since JustVugg#1334 the tier promotes int8 experts, and on a Vulkan build they are
served through coli_vk_tensor_ensure(fmt 1) — a path neither JustVugg#1334 nor the
Vulkan tier exercised on its own. Run it on a real device with the same three
checks the int4 gate runs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019BacNGNxAJ1M57UdYE2M3N
@crichalchemist

crichalchemist commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor Author

Rebased onto current dev (merge commit 3fb950d, plus one test commit b0503af); GitHub reports the PR mergeable again.

What the merge had to absorb, all inside the backend shim this branch introduces: the fp8 streaming mode, the resident lm_head and DeltaNet projections, automatic placement, and the cudaMalloc-granularity accounting. The shim gained three entry points (be_fp8_set_lut, be_trunk_upload, be_trunk_matmul). On CUDA they map to the existing calls; on Vulkan they refuse with one stderr line and those pieces stay on the CPU path, since backend_vulkan has no matmul over an already-uploaded tensor wired to the tier yet. docs/qwen36-cuda-tier.md is restored as dev has it; docs/qwen36-tier.md now covers only what differs on Vulkan.

The one test change: since d0a382d an expert is charged at allocator granularity, so test_qwen36_tier_vk's hand-computed two-expert budget admitted zero. Same adjustment 823caa8 made to the other tier tests.

Verified locally: the CPU build and the eleven other qwen36 test binaries; and, new since the first push, make qwen36 VK=1 against MoltenVK on macOS 13 x86_64 (2017 iMac, Radeon Pro 580 — the same Polaris10 silicon as the RX 580 rows in the doc, here under Metal rather than RADV), where both Vulkan tier tests run for real (not the skip path) and pass in int4 and int8 mode. Here the Vulkan (Lavapipe) and Qwen3.6 oracle jobs are green; the remaining jobs are still running as I write this.

(Edited: an earlier version of this comment said "Apple GPU"; the device is the AMD Radeon Pro 580.)

Ready for review whenever someone has the time.

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).
…tprint

Since d0a382d the tier charges an expert at the device allocator's
granularity (six allocations, 8 KiB minimum each), not by payload. At
the test geometry that is 49152 bytes per expert in both int4 and int8
mode, so the hand-computed 21474/26843-byte budgets admitted nothing and
the test failed with resident=0. One budget for both modes now, sized to
admit two experts and not three, with the arithmetic in the comment.
Same adjustment upstream made to its own tier tests in 823caa8.

Passes on MoltenVK over a Radeon Pro 580 (macOS 13 x86_64) in both modes; maxrel 6.8e-6 / 4.6e-6.
@crichalchemist

Copy link
Copy Markdown
Contributor Author

Token-exact end to end on real Vulkan hardware, not only the unit tests.

Setup: the ci.yml Qwen3.6 tiny oracle (make_qwen36_tiny.py --ref-mode full, convert_qwen36.py), run through make qwen36 VK=1 on the branch as it now stands (278a267), on a Radeon Pro 580 under MoltenVK (macOS 13 x86_64; the same Polaris10 silicon as the RX 580 rows in docs/qwen36-tier.md, which is this machine booted into Linux). COLI_DENSE_I8=0 SNAP=qwen36_tiny_c COLI_VULKAN=1 VK_EXPERT_GB=auto ./qwen36 8 8 qwen36_tiny/ref_full.json, cap 8 = n_experts so the tier engages.

run tokens residency VRAM hit rate
CPU baseline 16/16 — —
Vulkan, warmstart, staged uploads (default) 16/16 64/64 100 %
Vulkan, mapped path (COLI_VK_STAGED=0) 16/16 64/64 100 %
Vulkan, int4 conversion (--ebits 4) 16/16 64/64 100 %
Vulkan, QT_NO_WARMSTART=1, 400-token synthetic reference n/a (ids are synthetic) 64/64 after fill 86 % during the fill

One thing worth knowing for anyone repeating this: on the stock 16-token reference, QT_NO_WARMSTART=1 reports 0 uploads. That is the process finishing in ~8 ms, before the uploader thread lands a single staged copy, not a tier fault; the 400-token row is the same mode given time to fill. The no-warmstart path also never promotes an int8 container on any backend, because the decode path in qwen36.c only calls qt_note for experts that have a packed g4 copy — pre-existing and outside this PR.

The two test_qwen36_tier_vk* unit tests run for real on the same device (not the skip path) and pass in both modes; the CPU build and the eleven other qwen36 test binaries are unchanged.

test_qwen36_tier_vk proves the numerics against a real Vulkan device and
skips everywhere else -- which is every CI runner but the Lavapipe job.
The shim's control flow had no gate that runs in make check or under the
sanitizers on Linux, macOS and Windows.

tests/qwen36_fake_vulkan.h is the Vulkan twin of qwen36_fake_cuda.h: it
defines every coli_vk_* symbol the tier links against and records what it
receives, with knobs for the budget extension, the issue result and the
take result. tests/test_qwen36_tier_vk_fake.c drives the shim through it:
single device (COLI_GPUS ignored), VK_EXPERT_GB budget and the 4 GB
fallback without VK_EXT_memory_budget, int4-gs64 reaching the backend as
fmt 4 / grp 64 at half a byte per element, fill-once (a far hotter
non-resident never evicts a resident, 64 ticks, no swap, no free),
issue/take through the tier's ybuf with the weighted accumulation checked
lane by lane, a failed take leaving the output alone and closing the
group, and the two refusals -- fp8 streaming and the resident trunk --
landing on the CPU path without touching the backend.

Two mutants confirm the assertions bite: a take that always fails trips
the accumulation check; QT_SWAPS=1 on the Vulkan shim trips seven. Clean
under ASan+UBSan. -UCOLI_CUDA on the rule because the shim prefers CUDA
when both are defined.
@crichalchemist

Copy link
Copy Markdown
Contributor Author

Pushed 241f7f3: the Vulkan shim now has a gate that runs without a device. tests/qwen36_fake_vulkan.h is the Vulkan twin of qwen36_fake_cuda.h (every coli_vk_* symbol the tier links against, recording what it receives), and test_qwen36_tier_vk_fake drives the shim through it: single device with COLI_GPUS ignored, the VK_EXPERT_GB budget and its 4 GB fallback without VK_EXT_memory_budget, int4-gs64 arriving as fmt 4 / grp 64 at half a byte per element, fill-once under 64 ticks with a far hotter non-resident (no swap, no free), issue/take through ybuf with the weighted accumulation checked lane by lane, a failed take leaving the output alone, and the fp8 and trunk refusals landing on the CPU path without touching the backend.

It is collected by TEST_RULES, so it lands in make check and test-asan on Linux, macOS and Windows alongside the CUDA fake tests; the numerics keep their real-device gate in test_qwen36_tier_vk. Two mutants (a take that always fails; QT_SWAPS=1 on the Vulkan shim) trip one and seven assertions respectively, and it is clean under ASan+UBSan.

Also note #1390, which carries the qt_fill_wait in-flight fix on its own against dev; once it lands I will rebase this branch onto it and drop the copy here.

@JustVugg

Copy link
Copy Markdown
Owner

Worth another forward-merge. The real conflicts are exactly where your shim lives: c/qwen36_tier.c has moved 3 commits and c/qwen36_tier.h 2, and those three are #1388 (QT_MAX_ROWS, placement pin, ownership-based int8 free), #1404 (int8 experts on the decode path) and #1390 (qt_fill_wait residency). Plus c/Makefile 30 and c/qwen36.c 8.

Not trivial, but you absorbed an identical round on 2026-09-07 already. Nothing has overtaken it either: #1513 is VK RAM reservation on integrated GPUs and #1454 is the HIP path, neither is a Vulkan expert tier for qwen36.

The token-exact results on real Radeon hardware and the device-free fake-Vulkan gate are what make this reviewable without the card in hand. Please keep both through the rebase.

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.
… tier

The VK=1 arm of the tier condition sets QWEN36_TIER_SRC = qwen36_tier.c, so
each target that links it needs $(VK_OBJ) too; on dev VK=1 left that variable
empty, so the gap was invisible there. dev's qwen38 rule and the two qwen36
tokenizer tests reached this branch without it, and `make qwen38 VK=1` failed
at link with undefined coli_vk_* symbols.

Same shape as the qwen36 engine rule: $(VK_OBJ) $(VK_SPV) in the prerequisites
of the engine, $(VK_OBJ) on the link line.
dev added generic resident dense matrices (qt_dense_init / qt_dense_matmul,
addressed by a handle rather than by name) for the Qwen3.8 trunk. The merge
routed them through the same be_trunk_* shim as lm_head, so on Vulkan they
refuse and the matrices stay on the CPU; nothing covered that.

Extends the existing trunk-refusal section of the device-free gate: the
handle is refused, no handle is registered, the matmul returns 0 and the
backend is never touched. Mutating the Vulkan be_trunk_upload to claim
success trips both new assertions.
qt_shutdown joined the uploader and called be_shutdown() with every
resident expert still uploaded. On CUDA the device reset reclaimed them
unseen; on Vulkan nothing does, and vkDestroyDevice reports each weight
and scale buffer as leaked (VUID-vkDestroyDevice-device-05137: 12 VkBuffer
for the two resident experts of test_qwen36_tier_vk under the Khronos
validation layer). Free every slot's three tensors first; the uploader is
already stopped, so nothing races the slot.

The two JustVugg#1340 tests pinned the victim of an abandoned swap as still
holding its tensor after qt_shutdown returned. That observable is gone
once shutdown releases every resident expert, so they now pin
abandonment through what still tells it apart from an executed swap --
no upload was driven and the incoming expert is neither queued nor
resident -- and pin the release itself. Disabling the abandonment branch
fails both.
coli_vk_shutdown walked g_warena, the mapped host-visible chain, and left
g_warena_dl -- the device-local chain that staged uploads fill -- to
vkDestroyDevice, which reported its block as a leaked VkDeviceMemory under
the validation layer. The chain is never mapped, so free without unmapping.
build_pipeline hands back the descriptor pool it creates; the rmsnorm
chain took it into a local and dropped it, and coli_vk_shutdown never
destroyed pipe_nrm, plyt_nrm, dsl_nrm, shader_nrm or qprep_pool either.
Under the Khronos validation layer that is nine leaked objects on every
run that loads rmsnorm.spv. Keep the pool as G.dpool_nrm and tear the set
down next to the attention one.

Predates this branch; fixed here because the branch already edits this
function and a clean layer run is part of its evidence.
A portability implementation lists VK_KHR_portability_subset, and the
spec then requires it in ppEnabledExtensionNames
(VUID-VkDeviceCreateInfo-pProperties-04451). MoltenVK does, so the
validation layer flagged every device creation on macOS. Conformant
drivers never list it: RADV, NVIDIA and Lavapipe are untouched. Both
devices; the name is spelled out because its macro sits behind
VK_ENABLE_BETA_EXTENSIONS, and on the second device the check rides
inside the existing VK_EXT_memory_budget block, which every current
header defines.

Predates this branch; fixed here for the same reason as the shutdown
leaks -- a clean layer run on MoltenVK is part of this branch's evidence.
@crichalchemist

Copy link
Copy Markdown
Contributor Author

Forward-merged onto dev (merge commit 032d8df), and the PR is mergeable again. Six follow-up commits since, described below.

Against merge-base f18a54a with dev at cc756a6: the three PRs you named are 6 commits on c/qwen36_tier.c and 4 on c/qwen36_tier.h, plus 13 on c/qwen36.c and 49 on c/Makefile. Eleven conflict hunks across four files; c/qwen36.c merged clean.

What the conflicts were. #1390 landed with reworded comments, so this branch's copy of the qt_fill_wait fix is gone in favour of yours, as promised. #1388's QT_MAX_ROWS replaces the literal 32 in the issue path and the per-device row arrays, while the backend-neutral QtTensor stays, since your side of those lines still spells ColiCudaTensor. In c/Makefile, your 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 your ifneq over CUDA/CUDA_DLL/HIP/HIP_DLL with the VK=1 branch kept after it. docs/qwen36.md keeps your expert-kernel section and the ROCm paragraph with the VK=1 sentence restored.

Two things the clean merge hid. Neither is a defect on dev; both are the cost of this branch making qwen36_tier.c compile two ways, and both were this branch's to absorb.

  1. The dense-trunk block for the Qwen3.8 trunk — qt_dense_init, qt_dense_matmul, dense_free_all — calls coli_cuda_tensor_upload / coli_cuda_matmul / coli_cuda_tensor_free directly. On dev that is simply correct: the file is only ever compiled with CUDA. Here it meant the file no longer built without it, and nothing conflicted because the calls landed in a region this branch had not touched. They now go through the same be_trunk_upload / be_trunk_matmul / be_free shim as lm_head and the DeltaNet projections, so on Vulkan qt_dense_init returns -1 and qwen38_core.h keeps the weight on the CPU, as it already does for a failed upload. coli_cuda_group_stats is left alone — it is already inside #ifdef COLI_CUDA.

  2. make qwen38 VK=1 failed at link with undefined coli_vk_*. This branch's VK=1 arm is what makes QWEN36_TIER_SRC non-empty — on dev it is empty under VK=1 — so the qwen38 rule and the two qwen36 tokenizer tests needed $(VK_OBJ) the moment they met this branch. Fixed in c161d58 for every target that links the tier source. The CI Vulkan job builds only qwen36 and the two test_qwen36_tier_vk* binaries with VK=1, so it could not have reached this on either side; one of the tokenizer tests had the same gap on this branch since before the last merge.

One latent item, not a live bug: G.ybuf was the last literal 32 in the file and is now sized by QT_MAX_ROWS. It is correct at the current value — it would only overflow if that constant were raised, which is the defect #1339 was filed to prevent.

Evidence, re-measured on the merged code rather than carried over.

make check passes: Python suite 970 tests, OK (skipped=105), and make -C c test-c exits 0. Clean at 0 warnings for make qwen36, make qwen36 VK=1, and make qwen38 VK=1. CI was green on all 28 checks at the merge head a1a8dfd; the six follow-ups below are pushed with this comment and re-run it. (Locally the suite shows two warnings that are on dev unchanged and only appear on an x86_64 Mac — qwen38_core.h workers and test_olmoe_matmul_q.c failures; #1599 carries those, out of this PR's scope.)

Token-exact on the Radeon Pro 580 via MoltenVK (macOS 13 x86_64 — the same Polaris10 silicon as the RX 580 rows in docs/qwen36-tier.md, that box booted into Linux). Every run below logs [VK] ready: AMD Radeon Pro 580, so none of them quietly fell back to the CPU:

run tokens residency VRAM hit rate
CPU baseline 16/16 — —
Vulkan, warmstart, staged uploads (default) 16/16 64/64 100 %
Vulkan, mapped path (COLI_VK_STAGED=0) 16/16 64/64 100 %
Vulkan, int4 conversion (--ebits 4) 16/16 64/64 100 %

Stronger than the match count: all five 16-token runs, including QT_NO_WARMSTART=1, emit a byte-identical C engine : token-id line, so the backends agree with each other and not merely with the reference.

One row from my earlier comment is out of date since #1404. I noted then that an int8 container never promotes on the decode path — true at the time, and the reason #1391 was filed. #1404 fixed it, and it works through the Vulkan tier: on a 256-token reference (make_qwen36_tiny.py --max-new 256), QT_NO_WARMSTART=1 fills from cold to 64/64 resident at a 58.3 % hit rate, against 64/64 and 100 % for the warm run, with both emitting identical tokens. On the stock 16-token reference it still reports 0 uploads — the process finishes before the uploader lands a staged copy — so anyone repeating this should use the longer reference for anything about fill.

The device-free gate you asked me to keep is intact and now also covers the dense handles: the handle is refused, none is registered, the matmul returns 0 and the backend is never touched. Mutating the Vulkan be_trunk_upload to claim success trips both new assertions. To be clear about what that evidence covers — qwen36 never calls qt_dense_*, so the token-exact runs above say nothing about the dense conversion; the fake gate plus that mutation is its evidence.

Under the Khronos validation layer. Everything above was then run again with VK_LAYER_KHRONOS_validation injected by the loader (LunarG SDK 1.4.357.1, no code change), which sees what token-exactness cannot. It reported exactly two things, identically on every run, and both are now fixed — four small commits, two of them this branch's and two that predate it:

  • VUID-vkDestroyDevice-device-05137, 21 leaked objects at shutdown. Twelve were this branch's: qt_shutdown joined the uploader and tore the backend down with every resident expert still uploaded — CUDA's device reset had been hiding it — and the staged device-local arena chain was never freed (71991f6, c81a933). Releasing them at shutdown took away the observable your two qwen36 CUDA tier: qt_shutdown never signals cv_take, so pthread_join can hang if a group is open #1340 tests used — the abandoned swap's victim still holding its tensor after qt_shutdown returned — so both now pin abandonment through what still tells it apart from an executed swap (no upload driven, the incoming expert neither queued nor resident) and pin the release itself; disabling the abandonment branch fails both. On CUDA this frees explicitly what the device reset used to reclaim. The other nine predate the branch: the rmsnorm pipeline set and its two descriptor pools were never destroyed, one of the pools being a local that build_pipeline handed back and the caller dropped (cb70b7a). Fixed here because the branch already edits coli_vk_shutdown and a clean layer run is part of its evidence.
  • VUID-VkDeviceCreateInfo-pProperties-04451: a portability implementation must have VK_KHR_portability_subset enabled, and MoltenVK is one. Also predates the branch; enabled on both devices when advertised, a no-op on RADV, NVIDIA and Lavapipe, which never list it (bcdee83).

After those, both tier tests and the int8, int4, mapped and no-warmstart oracles report zero validation errors, warnings and VUIDs, still token-exact, still on the Radeon. The Lavapipe job could run the layer too (apt install vulkan-validationlayers, one env var); I have not touched CI in this PR, but would add it if you want it.

@crichalchemist

Copy link
Copy Markdown
Contributor Author

One more layer, since the last comment covered only the core checks: the same six runs — both tier tests and the int8, mapped, int4 and 256-token no-warmstart oracles — under the Khronos synchronization validation (validate_sync, duplicates unlimited) on the Radeon Pro 580 via MoltenVK. Zero hazards in every one, and the C engine : token-id lines are byte-identical to the layer-free runs. So the uploader thread's staged copies, the expert-group issue/take path and the shutdown release are hazard-free as far as the layer can see.

What the layer did flag is in the harness and on dev, not in this branch: write-after-write in the harness's own throughput loops and six buffers coli_vk_shutdown never freed. Those are in a separate PR against dev so this one stays about the tier; the eight objects that PR leaves alone are the rmsnorm pipeline set and pools that cb70b7a here already destroys.

One caveat for anyone repeating this: SDK 1.4.357.1's submit-time synchronization validation segfaults inside the layer at vkDeviceWaitIdle when qt_shutdown runs from atexit — after the run is complete, but before stdio flushes, so the match lines vanish. Run the engine under a pty or set VK_LAYER_SYNCVAL_SUBMIT_TIME_VALIDATION=false; the tier tests do not hit it.

xf_mode() disabled the shared expert_ffn.h kernel only under COLI_CUDA=1.
Under COLI_VULKAN=1 it stayed on, so every slot was allocated pw-only
(g and g4 NULL), tier_warmstart offered NULL weights and uploaded nothing
behind a "N in VRAM" line that counts the plan, qt_issue returned an
empty mask, and the tier's int8 CPU fallback dereferenced e->g == NULL on
the first token (qwen36.c:2266 -> matmul_q_gs). slot_ensure_int8() could
not rebuild the block without g4. The tiny fixture never reaches this:
its group size keeps the planar kernel off.

Same gate for both backends. On the real int4-gs64 container through
MoltenVK (Radeon Pro 580): bcdee83 exit 139 at token 1; with this commit
3302/10240 uploads, 128/128 greedy ids against the CPU run, exit 0.
Docs: the QWEN_EXPERT_KERNEL row and the qwen36 doc sentence say CUDA or
Vulkan.
@crichalchemist

Copy link
Copy Markdown
Contributor Author

Pushed one more commit: xf_mode() now turns the planar-int4 expert_ffn.h kernel off under COLI_VULKAN=1 the way it already did under COLI_CUDA=1.

Found while A/B-ing on the real qwen36-35b-a3b-colibri-i4-gs64 container (Radeon Pro 580 through MoltenVK, COLI_VULKAN=1 VK_EXPERT_GB=auto): bcdee83 segfaulted on the first token. lldb put it in the tier's int8 CPU fallback, matmul_qe(g, xs, e->g, …) at qwen36.c:2266 → matmul_q_gs with q == NULL. The chain: the gate was CUDA-only, so under Vulkan xf_mode() stayed on and every slot was allocated pw-only (g, g4 NULL); tier_warmstart offered NULL weights (if (!wg) continue, and the "N in VRAM" line counts the plan, not uploads); qt_issue returned an empty mask; the int8 fallback dereferenced NULL, and slot_ensure_int8() could not rebuild without g4. The tiny fixture never sees it because its group size keeps the planar kernel off, which is why the oracle passed here on 2026-09-07.

Evidence on this box, same container and prompt, 128 greedy tokens against the CPU ids:

  • bcdee83: exit 139 at token 1 (also with the CACHE_ROUTE branch reverted: pristine reproduces).
  • bcdee83 + QWEN_EXPERT_KERNEL=0: 3302/10240 uploads, 128/128 ids, 2.42 tok/s, VRAM hit rate 32.1%.
  • this commit, no workaround: 3302/10240 uploads, 128/128 ids, exit 0 (3.47 tok/s, VRAM hit rate 32.1%). make check on the branch: exit 0, the same 10 pre-existing warnings as bcdee83..

dev carries the same CUDA-only gate, so the bug is latent there and would go live with this PR; the fix is on this branch for that reason. Docs: the QWEN_EXPERT_KERNEL row in docs/ENVIRONMENT.md and the sentence in docs/qwen36.md now say "CUDA or Vulkan".

Conflicts, all where the backend shim meets dev's CUDA tier work:

- qt_shutdown: dev's JustVugg#1678 now drains open groups and frees the resident
  experts and G.slot. That supersedes this branch's release loop, which ran
  after free(G.slot) and would have dereferenced NULL. Dev's release is kept
  and routed through be_take/be_free; G.ybuf is now freed there and on the
  qt_init failure path (JustVugg#1683's goto chain).
- qt_take: dev's two-phase drain (no partial contribution when a device
  fails) with be_take in place of coli_cuda_expert_group_take.
- qt_dense_matmul_batch / qt_dnproj_matmul_batch (JustVugg#1674, JustVugg#1677):
  be_trunk_matmul takes the row count S; Vulkan still refuses.
- Clean merges that bypassed the shim: the uploader's partial-upload free
  (JustVugg#1680's reclaim) and dense_free_all now use be_free.
- The "[CUDA] mode: routed experts" literal (JustVugg#1533) is CUDA-only, so a
  Vulkan binary is not read as a CUDA build by doctor.py.
- qwen36.c: the mixed-layout refusal also names COLI_VULKAN=1.
- Tests: dev's shutdown checks read !G.slot, so the abandoned-swap pins
  keep only the upload counter; the fake-Vulkan gate now pins that
  shutdown frees every resident expert through coli_vk_tensor_free.
- Makefile: dev's prerequisites, with $(VK_OBJ) on every target that
  links the tier source, including dev's new qwen36 test targets.
- backend_vulkan.c: both shutdown blocks (staging/upload handles here,
  JustVugg#1603's qprep and norm buffers from dev).
Dev's JustVugg#1682 made a failed collection stop inference loudly, but the message
says CUDA; the same path runs under the Vulkan tier.
This branch declares the real qt_* entry points under COLI_VULKAN, and
NOCUDA_CFLAGS keeps that define, so test_qwen38_idot, _native_weights
and _tokenizer failed to link under VK=1. Same fix as c161d58 for the
qwen36 targets; under CUDA=1 the NOCUDA flags make qwen36_tier.c empty.
@crichalchemist

Copy link
Copy Markdown
Contributor Author

Forward-merged onto current dev (tip a2e578c), head is now 27188fa. It's a merge rather than a rebase, so the earlier review threads still line up.

How the conflicts were resolved

Validation (iMac 2017, Radeon Pro 580, MoltenVK, macOS 13, x86_64)

  • make check at 27188fa: passes, 0 compiler warnings.
  • make test-c VK=1: every target that still fails also fails on unmodified dev (33422c3). Those are colibri.c tests and kimi rules that don't link backend_vulkan.o under VK=1. I'll send that as a separate PR, since it isn't this branch's.
  • test_qwen36_tier_vk and test_qwen36_tier_vk_int8 on the device: pass.
  • Tiny Qwen3.6 oracle (16 tokens; int8, int8 with COLI_VK_STAGED=0, int4): 16/16 against torch, ids identical to the CPU run. At 256 tokens, CPU and Vulkan both match torch on 70/256, with identical ids. The Vulkan run's VRAM hit rate was 97.6 %.
  • Khronos core validation: 0 VUIDs, errors or warnings across the tier tests and the four oracle modes.
  • Sync validation: 0 hazards. With VK_LAYER_SYNCVAL_SUBMIT_TIME_VALIDATION=true, the layer itself segfaults at exit inside SyncValidator::PostCallRecordDeviceWaitIdle (null deref in SyncExecScope::MakeSrc, called from coli_vk_shutdown's vkDeviceWaitIdle). With submit-time validation off, the same run is clean and 16/16.
  • Real 35B int4 gs64 container, 64 greedy tokens:
    • With COLI_DENSE_IDOT=0 QWEN_DENSE_BATCH=0, the merged head's Vulkan run, the pre-merge head's (48ac375) Vulkan run and both heads' CPU runs produce identical ids, 64/64.
    • Under dev's defaults (COLI_DENSE_IDOT=1), the Vulkan run diverges from the CPU run at the first generated token.
    • Per-layer dumps (DUMP_LAYERS) locate the difference. The Vulkan run is bit-identical to CPU with QWEN_EXPERT_KERNEL=0 (the int8-unpack path the tier uses for misses) until the first GPU-computed expert. From there the end-of-stack drift (cos 0.989) is the same size as the drift between the two CPU expert kernels under the same trunk.
    • So I read it as the integer-dot trunk amplifying small differences, not the tier computing something different.
    • Caveat: the tiny-oracle and validation-layer runs above used COLI_DENSE_I8=0, so they don't exercise COLI_DENSE_IDOT.

Kenneth-Javier added a commit to Kenneth-Javier/colibri that referenced this pull request Sep 26, 2026
The VK_OBJ test errored in setUpClass on the macOS runner: it read
$(CC) and $(EXE) through `make --eval`, and /usr/bin/make on macOS is
GNU Make 3.81, which predates --eval (3.82). Its stdout was empty; the
rest of the suite passed (1479 run, 156 skipped, this one error).

$(EXE) now comes from make's own database (`make -pn`), which 3.81
prints the same way, and the compiler is simply the first word of each
link line.

Two holes in reading `make -n` go with it:
- recipe continuations are printed as written, so the five link
  commands that span lines (test_deepseek_v4 and the four
  segment/edge adapter tests) were never checked. They are folded
  first now.
- a rule that writes a differently named file (fuzz-rans,
  bench-omp-grain, the dsv4 CUDA tests, glm53-metal-check) was skipped,
  because its output was not a rule name. Every printed command that
  compiles a .c into an output without -c is checked now, and such a
  rule is reported by its output name if it ever needs $(VK_OBJ).

The scan covers 204 link lines on dev plus this branch (198 before).
None of the added ones calls the backend, so the results against the
linker are unchanged:
- Linux (WSL Ubuntu 24.04, make 4.3): dev fails with the thirteen
  rules; this branch passes, and the 60 lines that call the backend are
  the 60 that link backend_vulkan.o; with JustVugg#1338 merged only
  test_qwen36_slot_int8 is reported. About 22 s.
- Windows (make 4.4.1): the same, with twelve on dev, since bench_idot
  cannot preprocess without ARCH=native. About 20 s.
- dev + JustVugg#1758 + this branch, conflicts resolved: passes together with
  JustVugg#1758's test_makefile_deps and writes no .d file.

Not verified locally: GNU Make 3.81 itself. The macOS runner is the
check.

This branch has not been deployed

No deployments
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.

3 participants