Skip to content

Vulkan backend: expert tier + dense + MLA attention on any Vulkan 1.2 GPU (successor to #84)#418

Open
steve-m wants to merge 37 commits into
JustVugg:devfrom
steve-m:vulkan-backend
Open

Vulkan backend: expert tier + dense + MLA attention on any Vulkan 1.2 GPU (successor to #84)#418
steve-m wants to merge 37 commits into
JustVugg:devfrom
steve-m:vulkan-backend

Conversation

@steve-m

@steve-m steve-m commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Summary

An opt-in Vulkan compute backend (make glm VK=1, runtime COLI_VULKAN=1) that runs the GLM decode compute path on any GPU with a Vulkan 1.2 driver β€” no CUDA, no ROCm. Three tiers, each independently switchable and each falling back to the CPU path on any failure:

  • Pinned VRAM expert tier (COLI_VK_EXPERTS, default 320): the top-N routed experts by .coli_usage heat are uploaded once at startup and served from VRAM with no RAM slot, no disk read, no prefetch β€” issued as one fused async batch per layer (gate+up+siluβ†’down, hidden on-device) that overlaps with the CPU computing the remaining experts. Appears as a new vk bucket in the hit-rate line.
  • Dense projections (COLI_VK_DENSE=1): q_a+kv_a fused into one submit, q_b, the o-projection, and the shared expert as a single fused expert-group submit.
  • MLA absorb attention core (COLI_VK_ATTN=1): one dispatch per layer covering absorbed query β†’ scores β†’ softmax β†’ weighted latent β†’ value rows, fused with the o-projection so the context vector never leaves the GPU. The latent/rope KV lives in a persistent per-layer device mirror appended at ~2.3 KB/token/layer, with the same invalidation points as the CUDA KV shadow (row rewrite, rebind, resize).

This is a continuation of @kryptt's #84 (the original backend skeleton, tensor-resident coli_vk_matmul, and the int4 nibble-8 shader decode are his design β€” thank you!). It addresses the two things asked for there: it is rebased onto current dev, and it ships a CPU-reference exactness harness covering every primitive.

Why

  1. Hardware reach. Vulkan is the only compute stack that runs everywhere: cards ROCm dropped (RX 580/Polaris via RADV), iGPUs, and boxes where installing a vendor stack isn't an option. The backend needs only libvulkan + a Vulkan 1.2 ICD with subgroup arithmetic, and glslc at build time.
  2. It's fast. On an RX 9070 (RDNA4) via Mesa/RADV, the Vulkan expert primitive beats the production ROCm/HIP expert group by ~35%, the attention core beats the HIP kernel by 3.7Γ—, and end-to-end decode is at or above the HIP backend on the same card (numbers below). The llama.cpp folklore that RADV can outrun vendor stacks on AMD holds here once the shaders are properly fused.

Correctness

  • Standalone harness (gcc -O3 -DVK_TEST backend_vulkan.c -o test_vk -lvulkan -lm && ./test_vk shaders/qmatmul.spv): CPU-reference exactness for every primitive β€” int4/int8 GEMV across shapes including the long-row o-projection (I=16384), fused gate+up, the full expert group (sync and async issue/take, verified bit-identical to each other), the q_a+kv_a pair, and the absorb attention core including causal S=2, kv_start windows, int8, and long-context (T=2000) cases. Typical maxrel 1e-5…2e-3 (fp32 reduction order on 6144-long dots).
  • Engine-level: greedy decode with the full stack enabled matches the pure-CPU engine token-for-token on the validation prompt.
  • int4 decodes as offset-binary (nibbleβˆ’8) with the CPU byte layout β€” weights upload with a plain memcpy, no repacking.
  • Default build: all engine hooks are behind #ifdef COLI_VULKAN; make glm without VK=1 compiles the same code as before (plus three always-false locals). make test-c fully green on the branch.

Measured performance

Hardware/software: AMD RX 9070 16 GB (RDNA4/gfx1201, PCIe 4.0 x16 effective), Ryzen 9 3900X (12C/24T Zen2, OMP_NUM_THREADS=12, threads pinned), 64 GB DDR4-3200 dual-rank, GLM-5.2 744B int4 streamed from NVMe (~19 MB/expert). Mesa/RADV 26.1.4, Vulkan 1.4 ICD; ROCm 7.2.4 + HIP for the comparison backend. Model: 78 layers, 256 experts/layer, kv_lora 512, MLA/DSA.

Primitive microbenchmarks (per-call including readback, K-expert int4 MLP 6144β†’2048β†’6144):

Primitive Vulkan (RADV) ROCm/HIP production
Expert group, K=8 0.117 ms/expert 0.179 ms/expert
Expert group, K=32 0.107 ms/expert 0.179 ms/expert
Decode attention core (t_acore, 16 tok Γ— 78 layers) 0.61 s 2.2 s (HIP kernel: 5.7 s CPU: n/a)

End-to-end GLM-5.2 decode (same box, same engine settings, expert-routing top-p 0.7, drafts off, interleaved/batched A/B runs; our fork additionally carries an unrelated dual-SSD-mirror patch on the same base β€” active for both backends in every comparison, so the deltas are backend-attributable):

Generation length Vulkan ROCm/HIP
64 tokens 1.74–1.78 tok/s 1.53–1.56 tok/s
256 tokens 1.53–1.67 tok/s 1.51–1.53 tok/s
512 tokens (sustained, 5.4 min) 1.58 tok/s β€”
Prefill (11–23 tokens) 7.2–8.5 s 8.1–12.4 s

Run-to-run variance on this box is Β±0.1 tok/s; the 64-token advantage (~+13%) narrows to ~+5% at long form as the attention window and expert-I/O share grow for both backends.

The two memory-type rules that made it work (documented in docs/vulkan.md, learned the hard way):

  1. Buffers the CPU reads back must be HOST_CACHED β€” reading write-combined ReBAR VRAM from the CPU runs at ~40 MB/s and was a hidden 5.6Γ— per-call penalty.
  2. Everything else (weights, inputs, the KV mirror) lives HOST_VISIBLE|DEVICE_LOCAL, so uploads are plain memcpys and the GPU reads at VRAM speed.

Integration surface

  • New: c/backend_vulkan.c (~1.3k lines, plain C99 + libvulkan), c/backend_vulkan.h, c/shaders/qmatmul.comp, c/shaders/qmatmul_gate_up.comp, c/shaders/attention_absorb.comp, docs/vulkan.md.
  • Modified: c/glm.c (hooks under #ifdef COLI_VULKAN), c/Makefile (VK=1 block + glslc rule), docs/ENVIRONMENT.md, README.md.
  • Env: COLI_VULKAN, COLI_VK_SHADERS, COLI_VK_EXPERTS, COLI_VK_DENSE, COLI_VK_ATTN.
  • 18 bisectable commits, each building; the history walks port β†’ shader fusion β†’ integration β†’ attention core β†’ tier.

One operational note worth flagging beyond this PR: the engine's OMP self-tune (OMP_WAIT_POLICY=active + spin) is skipped under COLI_CUDA/COLI_METAL but not under other configurations. With 12 pinned spinning threads the async I/O pool starves (measured 28 β†’ 5 GB/s CPU expert bandwidth, ~2.8Γ— end-to-end). docs/vulkan.md tells Vulkan users to set COLI_NO_OMP_TUNE=1; extending the self-tune skip to any GPU backend (or to PIPE=1 runs) may be worth a separate look.

Limits / future work

  • Decode-focused: the expert tier and attention core serve S≀4; prefill keeps the CPU/batched paths (dense projections do run on VK at prefill, which is why prefill got faster).
  • DSA top-k selection, ragged multi-slot serving, and quantized-KV caches fall back to the CPU attention path (same guards as the CUDA absorb).
  • Validated on RDNA4/RADV. The shaders use dynamic subgroup sizes (wave32/64-safe) and Vulkan 1.2 only, so Polaris/gfx803 and other vendors should work β€” testers welcome, especially RX 580 owners.
  • Ready as follow-ups, deliberately not in this PR: an fp8 KV mirror for KV cache quantization: fp8 (KV8) + 4-bit TurboQuant (KV_TQ) on CPU, CUDA, and MetalΒ #399's KV8=1 (already implemented and validated against kv_fp8.h on our side β€” the absorb shader decodes e4m3 in place, 4Γ— less mirror traffic; this PR just cleanly falls back to CPU attention when the f32 cache is absent), cooperative-matrix prefill kernels, and a fully resident-layer pipeline.

How to run

cd c && make glm VK=1
COLI_VULKAN=1 COLI_VK_DENSE=1 COLI_VK_ATTN=1 \
PIN=<model>/.coli_usage PIN_GB=0 COLI_NO_OMP_TUNE=1 \
./coli run "Hello" --topp 0.7

@JustVugg

Copy link
Copy Markdown
Owner

Heads-up: c/glm.c was just split into c/colibri.c + header modules (#391, now merged into dev). This PR touches the old glm.c, so it will need a rebase onto current dev with its changes moved into colibri.c (or the relevant extracted header: quant.h, sample.h, kv_persist.h, telemetry.h, grammar.h). The make glm target still works (alias of make colibri), so no build scripts break. Apologies for the churn β€” ping me if the rebase gets thorny and I can help place the hunks.

@steve-m

steve-m commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current dev β€” the split turned out painless: git tracked glm.c β†’ colibri.c as a rename, so all the engine hooks followed automatically and only the Makefile hunk needed hand-placement (the VK=1 block now hangs off the colibri rule; VK= was added to the build-config stamp). Re-validated after the rebase: default build + make test-c green, the -DVK_TEST harness passes on the RX 9070, and an end-to-end run with the full stack reproduces the reference output. #421 got the same rebase since it also touched the old glm.c. Thanks for the heads-up and the offer β€” no thorny hunks this time.

@steve-m

steve-m commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

pp512/tg128-style numbers, for comparability with the llama.cpp benchmark conventions (same box and config as the tables above; dual-SSD mirror active for both backends; fresh process per run; two runs each):

Workload Vulkan (RADV) ROCm/HIP
pp526 β€” 526-token prompt (target 512) 5.01 / 5.11 tok/s 4.50 / 4.47 tok/s
tg128 1.53 / 1.55 tok/s 1.47 / 1.49 tok/s

Notes: prefill here is expert-streaming-bound (a 512-token batch-union touches nearly every expert of the 744B model), so pp measures the whole engine + disks more than the GPU. The accounting slightly favors HIP β€” Vulkan uploads its dense weights during the first prefill while the CUDA/HIP tier uploads at load, before the prefill timer β€” and Vulkan leads regardless (+13% pp, +4–5% tg). These standard workloads also reproduce much more tightly than free-running generations (≀2% spread), so we'll use them for future comparisons.

@JustVugg

Copy link
Copy Markdown
Owner

Rebase needed: #298 just merged into dev and reworked the quantized-kernel scale handling (fmt=4 per-group scales) β€” your Vulkan kernels will want the same semantics or g64 containers will produce garbage on Vulkan, same bug #298 fixed on CUDA. The conflict is semantic and the backend is yours end-to-end, so it needs your hands; no rush, but flag if you've stopped working on it so we know where it stands.

@steve-m

steve-m commented Jul 20, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current dev (cbbe310, post-#298/#421/#192) and implemented the #298 semantics you flagged β€” plus the format work that landed in dev since the last push made a bigger scope natural.

Grouped scales in the Vulkan kernels (the #298 ask). All three kernels (dense GEMV, fused gate+up, absorb attention) now decode fmt=4 grouped int4 with one scale per gs inputs, applied to the packed-word partial; per-row scaling is skipped exactly as #298 did on CUDA. The group size travels as an explicit parameter into the resident tensor and the push constants. The host gates fmt=4 to gs % 8 == 0 (a packed word never straddles a group) β€” anything else falls back to the CPU path #298 already fixed. And since dev now carries #168, the same treatment covers fmt=5 int3-g64 (fixed g64 groups, two-plane decode) across all three kernels β€” a full GLM int3 container runs the complete Vulkan stack.

Validation. The -DVK_TEST harness gained fmt=4 cases (gs=64 across the real GLM shapes β€” dense, o-proj I=16384, S=8 batch, expert_group, matmul_pair, absorb incl. S=2 causal + kv_start window β€” plus a gs=32 sanity case) and fmt=5 cases; full suite passes on the RX 9070 at maxrel ≀ 5e-4 (absorb ≀ 2e-5). End-to-end greedy runs reproduce the reference output on both a per-row int4 container and an int3-g64 container (step-1 top-5 logits identical to the CPU path).

Also in the refresh, from production use on our box:

  • VRAM pressure-proofing: allocations carry VK_EXT_memory_priority classes (scratches/KV mirror 1.0 > dense 0.75 > expert tier 0.4) and the tier fill stops against VK_EXT_memory_budget while COLI_VK_RESERVE_GB (default 3) stays free for the lazily-allocated dense + KV working set, with an explicit log line. Both are runtime-detected no-ops on drivers without the extensions.
  • Arena suballocation: weight tensors now share 256 MB device-memory blocks instead of two vkAllocateMemory calls per tensor (a large tier was ~5.7k allocations β€” also uncomfortably close to maxMemoryAllocationCount on some drivers).
  • Readiness-ordered CPU share: the expert block computes pipe-ready/resident experts first so a still-loading expert's I/O hides behind their matmuls (ordering hint only; every entry still waits properly). t_ecpu now counts kernel time only, waits go to t_ewait.
  • PROF additions: a VK-BLOCK line (per-block anatomy: classify/issue/take + avg nvk/ncpu) and VK_PROF=1 submit-vs-fence-wait totals across all sync paths.

One measured caveat worth documenting: on our RX 9070/RADV, growing the resident expert tier past ~6 GB slows the other GPU paths' per-dispatch execution (~1.3 s per tier-GB over a 64-token decode; profiled to the fence wait, not the submit call, with eviction/allocation-count/free-memory all ruled out) β€” so the sweep-derived COLI_VK_EXPERTS=320 default stands and the budget stop is a guard, not an invitation to fill the card. Details in the docs.

Numbers on the box are unchanged from the tables above (the refresh is format coverage + hygiene, not a perf change): 64-tok tg on the int3-g64 container now reaches 1.73–1.75 tok/s with the engine-side improvements that landed in dev (#421 mirror + the .qs fold-in we'll propose separately).

@JustVugg

Copy link
Copy Markdown
Owner

Thanks @steve-m β€” this is a big, welcome piece of work, and I want to get it in. Two things before merge, one mechanical and one that only you can provide (I don't have a Vulkan GPU to verify it myself).

1. CI is red for a mechanical reason β€” 9 committed test binaries. The PR added compiled binaries under c/tests/ (test_dsa_select, test_i4_grouped, test_kv_alloc, test_kv_disk, test_kv_fp8, test_logit_nan, test_sample_nan, test_stops, test_topp). On a fresh macOS checkout make sees them as up-to-date and skips rebuilding, then run_tests.py hits OSError: [Errno 8] Exec format error trying to exec a Linux ELF on macOS β€” that's the make check failure. (Same class of thing that hit #421.) Please git rm those 9 files and add them to .gitignore; the .c sources stay. That alone should turn CI green.

2. Because it's a new hardware backend I can't run, I'd like to read validation data before merging β€” this is the bar we hold every backend to. Could you post, from your Vulkan GPU:

  • Correctness first: confirmation the Vulkan path is coherent, ideally a teacher-forcing TF=1 token-exact check vs the CPU oracle on a tiny model (the same 32/32 gate the CPU/CUDA paths pass), or at minimum a real chat transcript that's clearly coherent. For a new backend, correct output matters more than speed β€” cuda+engine: full fmt=4 (grouped int4 gs=64) support + diagnostic harnessΒ #298 was a CUDA path that looked right and produced garbage.
  • Then perf: decode + prefill tok/s with the Vulkan tier vs CPU-only on the same box/prompt, expert hit rate, and which GPU / driver / Vulkan version.

Plan: once CI is green (step 1) and the data checks out (coherent output + tok/s in step 2), I'll review and merge. I've verified the mechanical side is the only CI blocker; the rest is your GPU's numbers. Happy to fix the committed-binary bit for you and push to your branch if you'd rather β€” just say the word.

@steve-m

steve-m commented Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Both items addressed β€” plus one more commit that was in flight.

1. CI / committed binaries: removed from the entire history, not just the tip. All nine entered through one commit (an over-broad git add on my side β€” apologies); the branch is rewritten so no commit in the PR ever contains them, verified per-commit across the range, and c/.gitignore now covers the full current test-binary set (including test_int3, test_tok_o200k, test_pipe_block, which weren't in your list yet) so the class is closed. Force-pushed; a fresh checkout of any commit builds its test binaries from source.

2. Validation from the Vulkan GPU. Hardware: AMD Radeon RX 9070 (RADV GFX1201), Mesa 26.1.4, Vulkan 1.4.354, Linux 6.19. Model: GLM-5.2 744B, per-row int4 container, dual-SSD mirror (#421), full Vulkan stack = dense + absorb attention + 320-expert VRAM tier.

Correctness. The tiny-model TF oracle fixture isn't bundled, so here is the equivalent evidence on the real model:

  • Coherent transcript (sampled, topp 0.7, 96 tokens, full VK stack): "# The Keeper of Tidesmoor Light β€” The foghorn had been malfunctioning again. Eliana pulled her coat tighter around her shoulders and squinted into the gray morning, the lighthouse door whistling shut behind her. The sea churned below the cliffsβ€”a restless, churning darkness of mercury and green-black water…" β€” clean prose throughout.
  • Greedy factual agreement: CPU and Vulkan both answer The capital of France is β†’ "Paris." (continuations diverge after the answer β€” see the honesty note below).
  • Primitive-level exactness is where the rigor lives: the -DVK_TEST harness checks every kernel against a CPU reference (int8/int4/fmt=4/fmt=5, real GLM shapes) at maxrel ≀ 5e-4, and a built-in engine verify mode (COLI_VK_QPREP=2) compares the fused attention chain against the split path per layer on the real 744B weights: all 78 layers within 1.8e-4, kv path bit-exact.
  • Honesty note on token-exactness: on this 744B int4 container, any fp-ordering change shifts deep-layer logits O(1) through MoE-router flips β€” pure-CPU vs CPU-with-different-kernels diverges in continuation exactly like CPU-vs-Vulkan does, with the factual argmax stable. Token-exact TF across backends is achievable on a small well-conditioned model (happy to run the 32/32 gate if you can point me at the glm_tiny generator), but not on this container for any backend pair.

Performance β€” standard workloads (pp526 = 526-token prompt, tg128; fresh process per run, two runs each, same box/prompt/config, only COLI_VULKAN toggled):

Workload Vulkan (full stack) CPU-only
pp526 4.90 / 4.92 tok/s 4.06 / 4.05 tok/s
tg128 1.48 / 1.52 tok/s 1.12 / 1.13 tok/s

+21% prefill, +32–35% decode. Expert hit rate ~80% both (VK serves 15.2–15.5% of lookups from the VRAM tier with zero disk I/O; CPU-only covers the same hit rate from RAM alone β€” the delta is the GPU offload, not cache coverage).

3. New commit since the refresh: single-submit q-prep chain (rmsnorm.comp + coli_vk_attn_qprep): [q_a + kv_a] β†’ on-GPU RMS-norm β†’ q_b recorded under one fence instead of three per layer (the middle roundtrip existed only for the CPU-side norm; the normed latent still returns to the host for the DSA indexer). Sync submits per 64-token decode drop 14.3k β†’ 9.3k, measured βˆ’1.0 s of GPU wait; wall-clock neutral on this I/O-bound box today, and it comes with a kill-switch (COLI_VK_QPREP=0) and the per-layer verify mode above. Validated the same way as the rest: harness cases (S=1/2/11) + the 78-layer real-weight comparison.

@steve-m

steve-m commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current dev (dce7012, v1.1.0) β€” clean replay, one trivial
TEST_BINS union in the Makefile. With #500 merged the stray-binary CI
failure is gone, so this PR's CI should finally run green; happy to have it
re-triggered.

Two new commits since the last push, both from profiling decode on the
RX 9070 box:

vulkan: preload the dense working set before the tier fill β€” the dense
projections (q_a/q_b/kv_a/kv_b/o, shared expert) uploaded lazily on first use,
i.e. AFTER the expert tier had already filled to the VRAM budget. On a
VRAM-tight box the dense working set then spills to GTT: we measured 2.1 GB
spilled and +34% on every absorb-attention call. Preloading dense right before
the tier fill lets the tier self-size through the existing budget stop instead
(spill 2.1 β†’ 0.2 GB, tier sheds only as many experts as the dense set needs).

vulkan: spin-then-block fence waits on the sync paths β€” decode
dispatches are microsecond-scale, so a blocking vkWaitForFences pays wakeup
latency on every roundtrip. All sync-path waits now poll vkGetFenceStatus
for up to COLI_VK_SPIN_US (default 300 Β΅s, 0 reverts to blocking) before
falling back to the blocking wait.

Standard workloads, same box/prompt/config/convention as the tables above
(pp526 + tg128, fresh process per run, two runs each; this branch build):

Workload previous push (posted above) this push
pp526 4.90 / 4.92 tok/s 5.13 / 5.15 tok/s
tg128 1.48 / 1.52 tok/s 1.74 / 1.81 tok/s

Decode attention dropped 26.6 β†’ 15.8 s per tg128 run; what remains of decode
is dominated by expert disk I/O (felt wait 21-24 s β€” also the source of the
r1/r2 spread), which is outside this PR's scope. Disclosure: part of the tg
delta comes from pinning the GPU's DPM performance level
(power_dpm_force_performance_level=high) β€” microsecond-burst duty cycles
never ramp RDNA4's memory clock on their own, worth knowing for anyone
benchmarking this backend; the dense-preload/spin-wait commits are the rest.
A nice side-effect visible in these runs: with the desktop compositor holding
VRAM, the tier now self-sizes below its cap (320 β†’ 296 in r1) through the
existing budget stop with zero GTT spill β€” exactly the failure mode the
preload commit closes.

Validation on this exact branch build (RX 9070, RADV, Mesa 26.1.4): VK=1
clean build, the CPU-ref exactness harness full PASS (absorb fmt 1/2/4/5
incl. the fused absorb+project paths, maxrel ≀ 1.6e-5), engine smoke coherent.

Follow-up we're preparing as a separate PR: a second-device Vulkan expert
tier (COLI_VK_DEV2) that fills another GPU's VRAM with the next-hottest
experts β€” an RX 580 beside the 9070 takes us past 2 tok/s decode on the same
box. Kept out of this PR to keep the review surface stable.

@noobdev-ph

Copy link
Copy Markdown
Contributor

Tested this on RDNA4 (RX 9070 XT / gfx1201, RADV, Mesa 26.1.3) since @JustVugg mentioned not having a Vulkan GPU to verify against β€” it came up and ran correctly first try on a box it had never seen. Nice work.

Two things that should be useful for the merge:

  • The cuda+engine: full fmt=4 (grouped int4 gs=64) support + diagnostic harnessΒ #298 grouped-scale semantics check out on a real g64 container. Ours is a genuine fmt=4 / gs=64 conversion of the full 744B checkpoint, and output was clean and on-topic in 50/50 runs, byte-identical across every configuration tested β€” so the per-group scales look correct on RDNA4 against real quantized weights, not just the reference harness.
  • Resizable BAR turns out to be required on discrete cards, and its absence fails silently. With ReBAR off, HOST_VISIBLE|DEVICE_LOCAL only exists in a 256 MB window, so the tier lands in system RAM while still logging 320 hot experts resident (6.04 GB VRAM) β€” the card sat at 82 MB and it ran slower than the CPU path. Enabling ReBAR took it from 0.11 β†’ 0.24 tok/s. A one-line init warning comparing the heap size against the host-visible+device-local type would save people a very confusing first run.

Also a small one: COLI_VK_SHADERS unset falls back to "shaders/qmatmul.spv" relative to CWD (colibri.c:6955), so it only resolves when launched from c/ β€” and it wants the full path to the .spv file, not the shader directory.

Full numbers, methodology and a ROCm/HIP comparison are in #523 rather than here, to keep this thread focused. There's an open question there about the ~35% faster than ROCm on RDNA4 note in c/Makefile:260 β€” I measured the opposite on this box and am fairly sure I've configured something differently, so I've documented my whole setup and listed what I'd happily re-run. The card and checkpoint are set up here, so just say what would be most useful.

zhaohb added a commit to zhaohb/colibri that referenced this pull request Jul 23, 2026
@steve-m

steve-m commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current dev (e4b5bf3, v1.1.1 + the Inkling merge) β€” clean replay, diff scope still VK-only. Three commits on top, from the field reports in #523 and this thread:

  • ReBAR init warning β€” the discrete-card silent-GTT-fallback @noobdev-ph and @JustVugg both flagged: init now compares the chosen memory type's heap against the device-local heap and warns with the firmware fix named. docs/vulkan.md states the requirement.
  • Shader-path resolution β€” COLI_VK_SHADERS accepts a directory or the .spv; unset, resolves next to the binary; the error names what it tried.
  • PREFETCH=1 residency skip β€” completes the vk_reg_served guard across the one prefetch path that lacked it (off by default).

Also reworded the ~35% faster than ROCm note in c/Makefile/README.md to state what it measured (the VRAM-resident expert-group primitive, per-call) rather than implying end-to-end β€” @noobdev-ph correctly measured the opposite e2e on a storage-bound box.

On the substantive #523 items: the DRAFT confound you identified is exactly it, and I posted the matched DRAFT=0 comparison from our box (both backends) plus the MTP-acceptance data over there β€” short version, VK comes out ~13% ahead full-stack once speculation is off on both arms, and acceptance under the VK tier doesn't collapse, so I don't think the #163 guard needs to extend to Vulkan. Detail + numbers in #523 to keep this thread focused. CI is green.

noobdev-ph added a commit to noobdev-ph/colibri that referenced this pull request Jul 23, 2026
Records the full Vulkan investigation as RESEARCH.md Β§7, plus updated
benchmark table and resume points.

Findings:
- Vulkan is the fastest config on this box: 0.40 tok/s, +18.9% over HIP at
  ~12 GB and +23.7% at ~6 GB (10 runs/arm, no overlap between arms).
- Resizable BAR is mandatory and fails silently without it: the tier lands in
  system RAM while logging "320 hot experts resident (6.04 GB VRAM)" with the
  card at 82 MB. 0.11 -> 0.24 tok/s once enabled.
- MTP is an I/O amplifier: positions per emitted token = 4/(1+3a). At our 33%
  acceptance that is 2.0, matching measured ereq/token 330.1 -> 674.5 (2.04x).
  Costs ~35-40% on BOTH backends, so DRAFT=0 is the single biggest knob here.
- Shrinking the CPU LRU 20x barely moves either backend: the VRAM tiers were
  doing the work, RAM was never masking residency.

Also records the confound that made the first 50-run battery report the
opposite conclusion (COLI_CUDA=1 trips the g_draft guard, so the HIP arms ran
without speculation while the Vulkan arms drafted), and the misuse of
experts/token, which is a router-side counter that cannot show tier behaviour.
Both corrected upstream in JustVugg#523.
steve-m added 8 commits July 25, 2026 20:20
… RX 9070

backend_vulkan.c/.h + shaders/qmatmul.comp: opt-in Vulkan int4/int8 quantized
GEMV via RADV, mirroring coli_cuda_matmul. Shader decodes int4 as nibble-8
(offset-binary), numerically consistent with the CPU path.

Validated on the RX 9070 (RADV GFX1201, Mesa 26.1): the built-in VK_TEST harness
passes all int4/int8 cases vs the CPU reference (maxrel ~1e-4). Bypasses ROCm's
dropped Polaris support -> also targets the RX 580.

Head-to-head naive int4 GEMV on the RX 9070 (same shapes, synchronous per-call):
ROCm 0.115ms vs Vulkan 0.306ms (6144->1536, S=1) -- ROCm ~2.7x faster as-is
(naive shader + host-visible memory + heavy submit overhead; not the tuned
coopmat path that wins in llama.cpp). Next: optimize the shader/backend to reach
or beat ROCm. Not yet wired into glm.c (Makefile/hooks pending).
Optimizations toward matching ROCm on the RX 9070:
- backend: cache descriptor set + resubmit the prerecorded command buffer when
  tensor/shape/scratch are unchanged (single int4 GEMV 6144->1536: 0.306->0.214 ms).
- shader: llama.cpp-style mul_mat_vec (MIT techniques, per-row int4/int8): x staged
  once in shared memory, one subgroup per output row + subgroupAdd (no barrier tree),
  grid-stride rows. Needs SPIR-V 1.3 (glslc --target-env=vulkan1.2). Correct (maxrel ~1e-4).
- VK_TEST: batched throughput probe (N dispatches / one submit).

Honest result (noise-controlled, vs colibri's production coli_cuda_expert_group):
production ROCm ~0.179 ms/expert (fused dual gate+up + down, batched); our unfused VK
~0.260 ms/expert -- ~45% slower. VK wins the short-reduction down-proj, loses the
long-reduction gate/up. Reaching ROCm needs fused dual gate+up + expert batching +
a shape-adaptive reduction.
…pert

New qmatmul_gate_up.comp + coli_vk_gate_up: computes hidden=silu(gate(x))*up(x) in
ONE dispatch, reading x once for both projections (VK equivalent of colibri's
grouped_hidden_w4_dual). Second 6-binding compute pipeline; build_pipeline() helper
refactors the pipeline setup for both. Correct vs CPU ref (maxrel ~9e-5).

The fusion is the win: gate+up drops from ~0.13 ms (two separate matmuls) to ~0.080
ms/expert (fair, 8 DISTINCT experts cycled so weights come from VRAM not L2 β€” added
bench_experts_fair to control for the caching artifact). Full fused expert:
  VK  gate_up ~0.080 + down ~0.050 = ~0.13 ms/expert
  ROCm production coli_cuda_expert_group = 0.179 ms/expert (stable)
-> VK ~25-30% FASTER, holding under cache-controlled measurement (some run variance
0.07-0.10 on gate_up). Reaching this needed the fused dual projection, exactly the
optimization the production HIP path already had and our unfused VK lacked.
…one submit)

The real engine primitive: K experts, fused gate+up+silu then down, hidden staying
on-device, all in one submit. Per-expert descriptor sets (gate_up: 6-binding,
down: 4-binding) sliced into packed x/hidden/y via descriptor offsets; one phase
barrier between gate_up and down. Mirrors coli_cuda_expert_group; correct vs CPU
ref (maxrel ~7e-4 at K<=8).

Honest throughput (RX 9070, K=8, distinct experts):
  GPU-only (resubmit recorded cmd buffer): 0.113 ms/expert -- BEATS ROCm 0.179 (~37%)
  per-call (as-is API):                    1.03  ms/expert -- 5.6x SLOWER than ROCm
The GPU compute is genuinely faster (fused kernel delivers), but the per-call HOST
setup (80 descriptor updates + recording 16 dispatches every call) dominates. The
earlier microbench 0.13 was the GPU-only number; it did not capture per-call cost.

Fix (next): cache descriptor sets + command buffer across calls (hot experts are
reused across tokens), or a bindless/BDA single-dispatch-multi-expert design like
ROCm's grouped kernels. Either drops per-call toward the 0.11 GPU-only floor.
(K=32 maxrel 2e-3, slightly over 1e-3 threshold -- fp accumulation, worth a look.)
…s ROCm

The per-call cost (1.03 ms/expert) was ALL in reading the output back: eg_y/y/h were
allocated in write-combined DEVICE_LOCAL (ReBAR) memory, which the CPU reads at ~40
MB/s. VK_PROF breakdown (K=1): memcpy_x 0.005 | desc 0.027 | record 0.005 | gpu 0.19
| memcpy_y 0.598 ms -- the readback dominated; descriptor updates + recording were
negligible (so no descriptor/command caching needed).

Fix: pick_memtype_cached() (HOST_VISIBLE|HOST_COHERENT|HOST_CACHED) for buffers the CPU
reads back (eg_y, y, h); inputs/hidden stay write-combined (fast CPU writes / GPU-only).

Result (RX 9070, K=8, distinct experts): per-call expert_group 1.03 -> 0.117 ms/expert,
now == the 0.111 GPU-only floor. vs ROCm 0.179 -> VK ~35% FASTER end-to-end, per-call.
The real primitive now beats ROCm; hypothesis confirmed. (K=32 maxrel 2e-3 is fp32
precision on the 6144-elem reduction, fine for greedy argmax.) VK_PROF=1 env-gated.
make glm VK=1 compiles backend_vulkan.o (plain C + vulkan headers), builds the .comp
shaders to .spv via glslc (--target-env=vulkan1.2), links -lvulkan. Independent of
CUDA/HIP (own -DCOLI_VULKAN). Verified on the RX 9070: glm links libvulkan.so.1,
both shaders compiled. glm.c hooks (COLI_VULKAN) come next; default build unchanged.
End-to-end integration of coli_vk_expert_group into the engine:
- QT gains a resident ColiVkTensor *vk (+vk_eligible); qt_vk_reset frees it when a
  slot is reused for another expert (expert_load_impl hook, mirrors qt_cuda_reset),
  so the LRU never computes with stale weights. g_vk_resident tracks the tier size.
- coli_vk_init at startup (COLI_VULKAN=1), shader path COLI_VK_SHADERS.
- moe() VK path (decode S<=4): upload routed int4 experts to VK once (capped by
  COLI_VK_EXPERTS, default 1024), compute the resident ones as one batched
  coli_vk_expert_group (fused gate+up+silu -> down, on-device), CPU-fallback the rest.
- coli_vk_tensor_ensure() backend entry: upload a resident tensor without computing.

Verified on the RX 9070 (make glm VK=1): [VK] expert tier active, greedy decode of
'The capital of France is' -> 'Paris.' CORRECT. Default build (no VK=1) unchanged.

NOTE: a VK-only build has no GPU dense/attention offload (that is the HIP CUDA_DENSE/
COLI_CUDA_ATTN path), so end-to-end tok/s here is CPU-dense-bound (0.06 cold), NOT a
measure of the expert path -- which is the ~35%-faster-than-ROCm primitive. A hybrid
HIP-dense + VK-experts build, or a VK dense/attn port, is the next step for throughput.
…d expert on Vulkan

vk_matmul_qt(t,y,x,S) routes a resident int4/int8 matmul_qt through coli_vk_matmul
(uploads the weight once into t->vk, then reuses it). Wired, with CPU fallback, into
the decode attention projections (q_a, q_b, kv_a, o) and the shared expert
(sh_gate/up/down). Env COLI_VK_DENSE=1.

Together with the expert tier, a Vulkan-only machine now runs experts + dense
projections + shared expert on the GPU; only the MLA attention core (absorb/softmax/
RoPE, small in latent space) and I/O stay on CPU. Verified on the RX 9070
(make glm VK=1, COLI_VULKAN=1 COLI_VK_DENSE=1): greedy 'The capital of France is'
-> 'Paris.' correct. Default build unchanged.

NEXT for a fully-GPU Vulkan path: a dedicated MLA attention-core compute shader
(scores/softmax/weighted-values/RoPE) β€” the last CPU-bound piece.
steve-m added 26 commits July 25, 2026 20:20
…pert (1 submit each)

CORRECTNESS: qmatmul.comp staged x into shared xsh[6144] unconditionally; the
o-projection's input row is H*vh = 16384, so every VK dense o-proj since the
dense port computed with a truncated/undefined activation tail β€” deterministic,
so greedy output was plausible and stable, which masked it. Rows with
I > 6144 now skip staging and read x from the storage buffer (uniform branch,
coalesced; harness case fmt=2 I=16384 O=6144 added, maxrel 2e-4). With the fix
the VK build's greedy output matches pure CPU exactly ('Paris.') β€” the earlier
divergence was this bug, not fp tie-breaks. gate_up/expert_group get D<=6144
host guards (their shader shares the pattern; engine dims are within bounds).

PERF (toward HIP parity):
- coli_vk_attention_absorb_project: absorb + resident o-projection in ONE
  submit, ctx stays on-device (was: absorb submit + ctx readback + o submit).
  Harness: fused 0.51-0.53 ms/call vs 0.73 unfused absorb ALONE, maxrel <= 7e-5.
- Shared expert now runs as coli_vk_expert_group(count=1): fused gate+up+silu
  -> down, hidden on-device, one fence instead of three matmul submits.
- expert_group harness threshold 3e-3 for K>=32 (documented fp32 accumulation
  on the 6144-length double reduction; was a standing false FAIL at 2e-3).
Both projections read the same x row(s): coli_vk_matmul_pair stages x once,
records both dispatches, and waits one fence β€” replacing two full submit+wait
roundtrips per layer per token. Harness-validated (maxrel 4.4e-5, 0.123 ms/pair
vs ~0.35 for two singles); engine greedy still matches pure CPU ('Paris.').
Also: expert_group harness threshold 3e-3 at any K (the fp32 accumulation
lives in the 6144-length reduction chain, not in the expert count).
…ch overlapped with CPU rows

Replaces the LRU-slot-tied tier (which uploaded on the decode critical path,
churned with evictions, and computed ALL routed experts on the GPU while 12
cores idled β€” measured slower than CPU-only experts).

- vk_registry_fill(): at startup, upload the top-COLI_VK_EXPERTS experts by
  persistent usage history into a (layer,eid) registry, decoupled from cache
  slots β€” stable residency like the HIP VRAM tier (RX 9070: 256 experts,
  4.83 GB, 1.0s; pinned RAM slots feed uploads directly, the rest stream
  through one transient slot).
- coli_vk_expert_group_issue/_take: the group gets its own command buffer +
  fence and splits into submit-and-return / join, so moe() ISSUES the GPU
  batch, computes the CPU share concurrently, drains the pipe + loads the
  GPU-side slots (cache invariants unchanged), then takes and accumulates.
  Sync coli_vk_expert_group (shared expert, harness) wraps the same path.
- vk_active now keys on a non-empty registry; empty (no history/budget 0)
  falls back to the normal parallel CPU loop.

Harness: issue/take validated bit-identical to the sync path; full PASS.
Engine: 'Paris.' correct with the tier active.
…etch entirely

The resolve phase now classifies registry-resident experts FIRST (before
pin/LRU): they take no cache slot, dispatch no load, and skip the LRU
recency bump β€” so they age out of RAM and free capacity for the CPU-served
experts, the same effect CUDA_RELEASE_HOST gives the HIP tier. The pilot
worker, cross-layer lookahead, and next-block readahead treat registry
residency like RAM residency (decode only; prefill still loads normally
since the tier serves S<=4). Counted as a new 'vk' bucket in the hit-rate
split. The group block computes GPU entries straight from the registry
(no ESlot), attributes its CPU share to t_ecpu/rows and only the take-wait
to t_egpu, and on device-loss falls back via a transient ws slot load.
…ath was inverted

Without a RAM pin every candidate loads transiently, and the inverted check
skipped every SUCCESSFUL load (pin-fed fills masked it: the pins covered the
whole top-256). PIN_GB=0 + COLI_VK_EXPERTS=256 now fills from disk in ~1.6s.
Also: bail after 64 consecutive load failures, diagnostics on the first few.
256-384 measured flat within noise (1.73-1.78 tok/s); 320 is the best median
with ~2.3 GB VRAM headroom left for the long-context KV mirror.
Upstream PR JustVugg#399 (KV8 fp8 / TQ4 quantized latent KV) leaves Lc/Rc NULL when a
quantized tier is active β€” the mirror sync would deref NULL. Guard on the
arrays themselves (env-independent), falling back to the CPU attention path;
an fp8-aware VK mirror (upload Lc8+scale, decode e4m3 in the shader, 4x less
mirror traffic) is the follow-up once JustVugg#399 merges.
Upstream JustVugg#168 (merged as 5e42e70) now carries the engine int3-g64 support this
commit used to port, textually near-identical β€” including the uring_finalize_load
qt_resolve_fmt fix we carried separately. What remains ours is the Vulkan side:
vk_matmul_qt/pair accept fmt 5, the absorb kv_b/o and shared-expert paths feed
fmt-5 tensors, and vk_registry_fill passes the true fmt through xf/d.fmt instead
of assuming int4.
qmatmul/qmatmul_gate_up/attention_absorb learn the 24B-per-64-group two-plane
layout: lanes stride the 16-value low-plane words, each word's partial dot is
multiplied by its group scale before the subgroup reduction, and the per-row
scale multiply is skipped. The absorb query pass stops pre-folding the kv_b
row scale into q for fmt=5 (scales vary along K) and applies the (row,group)
scale per element instead. upload_tensor accepts fmt=5 (rows are already
word-aligned: ceil(I/64)*24), sizes the scale buffer O*ceil(I/64) via
scale_floats() mirrored in tensor_free's accounting, and the expert-group
prep validates up/down fmts (down may differ per-projection; phase 2 pushes
downs[0]->fmt). Engine gates opened: vk_matmul_qt/pair, absorb kv_b+o, shared
expert, and the pinned tier registry (int4/int3 accepted, fmt passed through).
VK_TEST: fmt=5 cases across dense (incl. tail group + unstaged o_proj shape),
fused gate_up, expert group K=8/32, absorb (+causal window), int3-vs-int4
fair throughput lines. Shaders validated against glslangValidator vulkan1.2.
- sample.h: COLI_LOGIT_DUMP=1 prints top-5 (id:logit) per pick_tok step β€”
  the tool that separated backend error from fp tie-flips (VK matched CPU
  logits to ~1e-4 at every matched step; run-to-run token divergence turned
  out to be engine-wide threading jitter, present on int4 CPU-only too).
- harness: fmt=5 matmul_pair case (the q_a+kv_a decode path) and count=1
  expert group (the shared-expert shape) β€” the two production paths the
  first harness round left uncovered.
- tier messages made format-neutral (int4/int3-g64).
… β€” classify the VK-path call sites

demand=1 on the moe CPU-share miss load (same semantics as the upstream miss
path: routing-driven, FASE A snapshot valid); demand=0 on the device-lost
VK-fallback reload (bookkeeping for vk-served experts diverges from the
snapshot's assumptions β€” DISK-CLASS leaves it unclassified, per its own doc)
and on the startup tier fill (matches the PIN-load sites).
pipe_ready() (non-blocking peek of the load-done flag; URING reports not-ready
and defers to the wait) lets the block's CPU share compute loaded/resident
experts FIRST, so a still-loading expert's I/O hides behind their matmuls
instead of head-of-line blocking. Ordering is a hint only β€” every entry still
pipe_waits/loads in its body. Measured neutral on a mirror config (waits
already near their floor) and aimed at wait-heavy setups (single disk: felt
waits 20-37s). t_ecpu now counts kernel time only, like the default path
(it used to swallow the in-loop waits); waits land in t_ewait.
classify / issue / take+accumulate wall plus avg nvk/ncpu per block, printed
under PROF next to t_ecpu/t_ewait/t_egpu β€” enough to see where the big-tier
regression (420: 1.44 vs 950: 1.20 with LOWER felt wait) actually spends its
extra 9 s: submit overhead growth, take waits, or outside the block entirely
(dense/absorb under VRAM pressure).
…t-capped tier fill

The big-tier regression was never the tier mechanics (VK-BLOCK: classify+issue+
take <=0.3s/run) β€” it was decode attention degrading with VRAM occupancy (7.8s
at 7.6 GB resident -> 15.1s at 11.6 -> 17.8s at 15.2, identical workload): once
expert weights crowd the heap, the kernel evicts and re-migrates buffers under
the per-token attention submits.

Two runtime-detected extensions fix the two halves:
- VK_EXT_memory_priority: allocations now carry an eviction class β€” scratches
  and the KV mirror pin at 1.0 (they ride every submit), dense weights 0.75,
  and the engine brackets the expert-tier fill at 0.4. An oversubscribed heap
  sheds cold tier experts instead of thrashing attention.
- VK_EXT_memory_budget: vk_registry_fill checks the device-local budget every
  8 uploads and stops while COLI_VK_RESERVE_GB (default 3) is still free for
  the lazily-allocated dense + KV mirror + staging, logging the stop. Reserve
  0 disables the stop; without the extension the count cap applies as before.

Both are no-ops on drivers without the extensions.
… submit tax

Isolation matrix + harness probes pinned the big-tier regression precisely:
per-submit driver cost grows ~linearly with the number of distinct device-memory
objects the queue actively references (~0.35 ms/submit extra at a 950-expert
tier's ~5.7k allocations), hitting every synchronous fence-waited path β€” the
dense projections (4-5 submits/layer, accounted in t_attn) most of all. Decode
attention: 7.9s @420 experts -> 17.6s @950; flat when dense+attn run on CPU;
IDLE allocations are free (ballast probe: absorb and dense GEMV unchanged under
5000 dummy allocations); eviction exonerated (2.9 GB free showed the same tax,
scaling continuous in tier size, no budget-edge cliff).

upload_tensor now binds wbuf/sbuf into shared 256 MB arena blocks (alignment-
respecting bump allocator, memory-priority tag carried over) instead of one
vkAllocateMemory per buffer: a 950-expert tier + dense drops from ~7k memory
objects to ~60. Arena slices are not reclaimed per-tensor (registry/dense live
for the process; the rare fill-failure free leaks its slice β€” tensor mem
handles stay VK_NULL_HANDLE, vkFreeMemory's documented no-op). Arenas are
unmapped/freed at shutdown. Scratches and the KV mirror stay standalone (few,
constant count).
… semantics

The merged JustVugg#298 gave the CUDA kernels per-group scale handling for fmt=4 β€”
without the same semantics a g64 container on Vulkan would decode with per-row
scales (the exact bug JustVugg#298 fixed). All three shaders gain a fmt==4 branch:
int4 nibble decode with one scale per gs inputs, applied to the packed-word
partial (host gates gs to multiples of 8 so a word never straddles a group;
per-row scaling is skipped like fmt=5). Group size flows as an explicit
parameter through the upload-triggering entries into ColiVkTensor and the
push constants; engine call sites pass QT.gs and the VK gates accept fmt=4
via VK_FMT_OK (word-aligned gs only β€” anything else stays on the CPU path,
which JustVugg#298 already fixed).

Harness: ref helpers generalized to runtime group size (g_ref_gs); fmt=4
cases at gs=64 across the real shapes (dense, o-proj, batch, expert_group,
matmul_pair, absorb incl. S=2 causal + window) plus a gs=32 sanity case.
The attention section paid three synchronous fence roundtrips per layer per
token (pair, q_b, absorb+o); the middle one existed only because the q-latent
RMS norm ran on the CPU between q_a and q_b. A 40-line rmsnorm.comp (one
workgroup per row; fp32 vs the CPU's f64 accumulate differs ~1e-7 at D=1536)
lets coli_vk_attn_qprep record the whole chain in one command buffer with
compute barriers: [q_a + kv_a] -> norm -> q_b, one fence. q, the kv latent,
and the NORMED q latent return to the host (the DSA indexer consumes the
latter from QR; RoPE + the canonical KV append stay CPU-side). Norm weights
upload once per layer (KV-mirror pattern). The engine tries the chain first
and falls back whenever rmsnorm.spv is absent, formats mismatch, or the call
fails; COLI_VK_QPREP=0 forces the split path, =2 runs both and prints
per-layer chain-vs-split divergence.

Validation: harness chain cases vs a matmul->rmsnorm->matmul CPU reference
(int8/int4, S=1/2/11, real GLM shapes; threshold 1e-2 β€” two quantized
reductions + the norm through cancellation-heavy random rows compound ~10x a
single GEMV, same reasoning as the expert_group 3e-3). Engine COLI_VK_QPREP=2
on the real container: all 78 layers within 1.8e-4 of the split path, kv
bit-exact; decode-context logits match to 3e-5. Step-1 logits shift O(1) with
the chain on β€” measured to be the container's sensitivity to ANY fp-order
change, not a chain defect: pure-CPU vs split-VK step-1 logits differ ~4.0 on
the same prompt with the same stable argmax.
The ~8 GB of dense weights (attention projections, absorb kv_b, o-proj,
shared expert) uploaded lazily on the first forwards β€” after the expert
tier had already filled to its budget. On a 16 GB card the late arrivals
overflowed to GTT (measured 2.1 GB spilled with a 320-expert int4 tier)
and every per-token attention submit paid PCIe latency: absorb core
1.69 -> 2.26 ms/call. Claiming the dense set at startup lets the
budget-capped tier fill self-size to what actually fits: zero spill,
decode attention 13.1 -> 9.4 s per tg64, +7% decode.
Every sync submit's vkWaitForFences pays a scheduler wake (~50-150 us)
on signal; the engine fences ~2 submits per layer per token. Poll
vkGetFenceStatus for a short budget first (default 300 us, the common
decode dispatch completes in 0.5-2 ms), then fall back to the blocking
wait. The spinning thread is stalled on the GPU result anyway.
COLI_VK_SPIN_US=0 restores the pure blocking wait.
… to the binary

Field report JustVugg#523 (RX 9070 XT): with Resizable BAR disabled the
HOST_VISIBLE|DEVICE_LOCAL combination exists only in a ~256 MB BAR window;
the driver then places tier allocations in system RAM while the resident-
experts log looks healthy, and the run comes out slower than CPU-only with
nothing indicating why. Compare the chosen memory type's heap against the
largest DEVICE_LOCAL heap at init and say so up front.

Also from the same report: the COLI_VK_SHADERS fallback was CWD-relative,
so the engine only started from inside c/. Resolve the default next to the
binary (/proc/self/exe), accept a directory as well as the .spv path, and
name what was tried in the startup error. Makefile note on the ROCm
comparison now states what was measured (the VRAM-resident expert-group
primitive, per-call) instead of implying end-to-end tok/s.
…hmarking notes

The benchmarking section captures the two defaults that silently skew any
Vulkan-vs-CUDA/HIP comparison (DRAFT auto-resolves to 0 under COLI_CUDA
per JustVugg#163 but stays 3 elsewhere β€” the arms then run different decode loops;
and DPM never ramps for microsecond-burst dispatches), plus what the
experts-loaded/token stat actually counts.
The eroute readahead (last forward's routing, hinted at layer entry) was the
one prefetch path without a vk_reg_served check. Tier-served experts are
never demand-read, so their pages never stay warm: on a RAM-tight streaming
box the hint re-fetches the same hot experts from disk each time cache
pressure evicts them β€” pure wasted bandwidth on the box class JustVugg#523 measured
on. Prefill (S>4) keeps the hint: the tier only serves decode, so those
bytes are still needed.
The README stated "measured faster than ROCm on RDNA4" unqualified; a careful
third party (JustVugg#523) measured the opposite end-to-end on a storage-bound box (the
gap turned out to be a DRAFT/speculation confound, but the unqualified claim was
the problem). Lead with the unassailable point β€” Vulkan is the only backend for
cards ROCm dropped β€” and call the RDNA4 speed "competitive", pointing at the
docs' benchmarking notes for the honest, config-dependent picture.
@steve-m

steve-m commented Jul 25, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current dev (21e7a35) β€” clean replay, diff scope unchanged (13 files, VK-only). No functional changes since the last push; this is purely the rebase that clears the merge conflict.

One thing worth noting from it: dev picked up the fmt=6 E8/IQ3 container (#465, #501) in the meantime. The Vulkan paths are unaffected by construction β€” every VK entry point is gated on the VK_FMT_OK allowlist (fmt 1/2/5 plus grouped int4), so fmt=6 tensors stay on the CPU path instead of reaching a shader that can't decode them. A VK fmt=6 kernel would be separate work.

Validation on this exact branch build (RX 9070, RADV, Mesa 26.1.4):

  • VK=1 clean build; all four shaders compile to SPIR-V under --target-env=vulkan1.2
  • CPU-ref exactness harness full PASS β€” fmt 1/2/4/5, the fused gate+up pair, and the absorb / absorb+project paths
  • make test green: 197 Python tests (13 skipped) plus the C suite
  • Engine smoke on a real GLM int4 container, coherent output with the VK tier serving

The fmt=5 branches decoded one value at a time into a single accumulator: 16
products chained through one FMA dependency, a per-value `i < I` bounds test,
and (in qmatmul) a per-value staged/unstaged select on a uniform condition.
That left the branch ALU-latency-bound rather than bandwidth-bound -- int3
moved 12.5% fewer bytes than int4 yet ran 1.41x SLOWER per expert.

Every container the converter emits pads I to whole 64-groups, so the common
path now drops the bounds test, hoists the uniform select out of the loop (the
int8/int4 branches already did this), pre-shifts the high-plane word so its bits
start at bit 0, and accumulates into four independent lanes. The bounds-checked
loop stays for ragged dims.

RX 9070 (RADV, Mesa 26.1.4), VK_TEST harness, medians of 3 runs:

  expert_group fmt=5, 32 experts      0.1450 -> 0.0667 ms/expert   2.17x
  fused gate_up int3, 8 experts       0.0929 -> 0.0486 ms/expert   1.91x
  matmul_pair fmt=5 6144->(2048,576)  0.217  -> 0.149  ms/call     1.46x
  absorb fmt=5 K=512 T=300            0.791  -> 0.688  ms/call     1.15x

int3 now decodes FASTER than int4 on the same shapes (int4 expert_group/32 is
0.1038 ms/expert), so the container's byte saving finally converts to speed on
the GPU path instead of being eaten by the unpack.

Exactness harness PASS. Four-way accumulation is also slightly more accurate
than the 16-deep serial chain (expert_group maxrel 1.07e-4 -> 7.83e-5).

End-to-end on this box the win is invisible, and the profile says why: the tier
is issued ASYNC and overlaps the CPU expert share, so `routed GPU critical` is
0.027s of an 8.71s M2.7 decode (0.3%), tier straggler 89.69x. M2.7 stays
I/O-bound (31% of decode waiting on expert reads; 4.79/4.57 -> 4.76/4.86 tok/s,
inside run-to-run spread). The kernel win lands where the GPU is the critical
path -- a larger tier share -- and it removes decode cost as an argument against
fitting more experts per VRAM byte.
zhaohb added a commit to zhaohb/colibri that referenced this pull request Jul 26, 2026
…A blocks

Takes Qwen3.5-35B-A3B GPTQ-Int4 decode on the Arc B390 from 0.82 to 11.71 tok/s
(all experts + attention + GDN on the iGPU).

- fmt=6 expert-group bug: the shared expert is dense-quantized (fmt=2) but was
  folded into the fmt=6 routed-expert group; the fused gate_up shader decodes the
  whole batch with one fmt, so the mismatch rejected the ENTIRE group and dropped
  every routed expert to CPU. moe_token now folds the shared expert in only when
  its fmt matches (l->gsg.fmt == c->expert_fmt). 0.82 -> 7.09 tok/s.
- shared expert rides the routed group: vk_dense quantizes it as fmt=6 when the
  container is fmt=6 (VkDense gained gs; quant_rows/vkd_make/mm_dense handle the
  grouped-asym format), removing ~80 mm_dense submits/token. 7.09 -> 7.59.
- fused output->projection: coli_vk_gqa_project / coli_vk_gdn_project record the
  attention/GDN kernel + its o/out-projection matmul in ONE submit via a barrier;
  the intermediate ctx/y stays in GPU-only scratch (mirrors JustVugg#418 absorb_project).
  7.59 -> 9.79.
- fused whole GDN input block: gdn_conv.comp (causal conv1d + SiLU with a device-
  resident ring updated in-kernel) + gdn_delta_cv.comp (delta rule reading the raw
  conv output, q/k L2-norm folded in-shader). coli_vk_gdn_full chains gqkv/gz
  matmul -> conv1d -> qknorm+delta -> out-proj as 5 dispatches in one submit; only
  the tiny b/a->decay/beta stay on the CPU. COLI_VK_GDN_FUSE=0 keeps the staged
  path. 9.79 -> 11.71.

All stages validated token-identical to the CPU/staged path. Microbenches:
test_vk_gdn_fused.c (conv relerr 2e-8, delta y 3.6e-7). Diagnostics: qwen.c prints
"[VK] routed experts: tier-served N", VK_EG_DBG=1 prints expert-group reject reasons.
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