qwen36: Vulkan expert tier, and staged device-local uploads for cards without Resizable BAR - #1338
crichalchemist wants to merge 27 commits into
Conversation
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
…loads 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
a5872f5 to
5e4ffda
Compare
|
Rebased onto current 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 ( The one test change: since d0a382d an expert is charged at allocator granularity, so Verified locally: the CPU build and the eleven other qwen36 test binaries; and, new since the first push, (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).
b0503af to
2bebf00
Compare
…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.
|
Token-exact end to end on real Vulkan hardware, not only the unit tests. Setup: the
One thing worth knowing for anyone repeating this: on the stock 16-token reference, The two |
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.
|
Pushed 241f7f3: the Vulkan shim now has a gate that runs without a device. It is collected by Also note #1390, which carries the |
|
Worth another forward-merge. The real conflicts are exactly where your shim lives: 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.
|
Forward-merged onto Against merge-base What the conflicts were. #1390 landed with reworded comments, so this branch's copy of the Two things the clean merge hid. Neither is a defect on
One latent item, not a live bug: Evidence, re-measured on the merged code rather than carried over.
Token-exact on the Radeon Pro 580 via MoltenVK (macOS 13 x86_64 — the same Polaris10 silicon as the RX 580 rows in
Stronger than the match count: all five 16-token runs, including 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 ( 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 Under the Khronos validation layer. Everything above was then run again with
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 ( |
|
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 ( What the layer did flag is in the harness and on One caveat for anyone repeating this: SDK 1.4.357.1's submit-time synchronization validation segfaults inside the layer at |
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.
|
Pushed one more commit: Found while A/B-ing on the real Evidence on this box, same container and prompt, 128 greedy tokens against the CPU ids:
|
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.
|
Forward-merged onto current How the conflicts were resolved
Validation (iMac 2017, Radeon Pro 580, MoltenVK, macOS 13, x86_64)
|
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.
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=1builds the existingqwen36_tier.cagainst 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 isVK_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 whenCOLI_VULKANis 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 (orCOLI_VK_STAGED=1), resident weights now go to a DEVICE_LOCAL-only arena through a host staging buffer andvkCmdCopyBuffer. 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.
-DVK_TEST), mapped vs staged: 50/50maxrelvalues identical; staged batched int4 matmuls ~4× faster on this card (0.17 vs 0.66 ms/matmul).tests/test_qwen36_tier_vk(part ofmake check; skips withoutVK=1or a device): GPU output vs CPU int4 reference maxrel 6.7e-06, misses return no mask bit, residency frozen after warmstart.HEAT_FILEon runs 2–3 and the mapped arm, GPU clocks not pinned (no root):COLI_VK_STAGED=0)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.VK=1build (done manually: there is noqwen36-tiny-checktarget).make checkgreen;make qwen36,make qwen36 VK=1,make colibri VK=1build with 0 warnings from the touched files. CI's Vulkan job now also buildsqwen36 VK=1and runs the tier gate on Lavapipe.Compatibility
ldd qwen36links no libvulkan withoutVK=1).VK=1is opt-in.qt_fill_waitcould 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_waitreturns strictly later.vkCmdFillBufferon 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 withCOLI_VK_STAGED=1.qt_issuestridesG.is_xbydi*8*Dwhile it is sized32*D, so a two-GPU CUDA run with a wide top-k can overrun;qt_shutdownnever signalscv_take, so a thread parked inqt_fill_waitwould not seeth_stop.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/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