Skip to content

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

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

crichalchemist wants to merge 15 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.mddocs/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.

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.

2 participants