Skip to content

moss-infer-vulkan: the Vulkan fallback program, with Linux and Windows packages - #8

Merged
xycld merged 46 commits into
mossimage-cpp:mainfrom
CloverIris:vulkan-backend
Nov 4, 2026
Merged

xycld merged 46 commits into
mossimage-cpp:mainfrom
CloverIris:vulkan-backend

Conversation

@CloverIris

@CloverIris CloverIris commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

What

moss-infer-vulkan: MOSS-Image text to image on Vulkan, a standalone program for GPUs the CUDA engine does not support (AMD, Intel, older NVIDIA cards). It started from CloverIris's cooperative-matrix GEMM bring-up (the first two commits) and now runs the whole pipeline. The frontend chooses which program to run; the CUDA engine (src/, include/, its CMake and presets) is unchanged.

  • Program and package. vulkan/ is its own CMake project, built without CUDA. It compiles in the engine's tokenizer, seed noise, schedule and PNG writer, so a seed gives the same inputs as on CUDA. CPack makes mossinfer-VERSION-linux-vulkan-x86_64.tar.gz and mossinfer-VERSION-windows-vulkan-x86_64.zip. It needs a Vulkan driver, not CUDA or an NVIDIA driver; the loader is opened at run time.
  • Flags. Those of moss-infer generate that the backend runs, with the same defaults, at --tier reference. Anything else (LoRAs, img2img, other samplers, lossy tiers, ...) is refused by name with exit status 2. --list-devices says which kernels each GPU gets; progress goes to stderr one line per event.
  • Three kernel paths (the default is the first the device runs; --kernels picks one):
    • coop: bf16 VK_KHR_cooperative_matrix GEMMs with the block's epilogues, flash attention, TF32-exact VAE convolutions.
    • coop-f16: the same kernels on f16 fragments, for devices with cooperative matrices but no bf16 ones (RADV gives bf16 only on RDNA4, so RDNA3 gets this path). Each GEMM row of both operands is scaled by a power of two before the exact bf16-to-f16 conversion and scaled back in the epilogue; on the 4090 its blocks, text encoder and DiT step 0 match coop exactly. The VAE runs plain on it.
    • plain: register-blocked compute kernels with no cooperative matrices, 16-bit arithmetic or particular subgroup size; it also passes on llvmpipe.
  • Memory. The text encoder streams its layers from the files. DiT blocks that do not fit in VRAM stay in host memory and are copied on the transfer queue while the previous block runs (bit-identical to the all-in-VRAM run). A VAE decode the device cannot hold is refused before any work starts.
  • Watchdog. Long ops run in bounded submits, so a slow GPU does not trip Windows' display driver watchdog.
  • CI. build-vulkan.yml installs the Vulkan SDK 1.4.357.0 (pinned, SHA-256 checked), builds both packages, and on Linux runs the backend's kernel checks on lavapipe under the Khronos validation layer. ci.yml and release.yml call it; a release requires both Vulkan archives.
  • Docs. docs/vulkan.md for users, vulkan/README.md for the checks and kernels, one sentence in the README. The original research and design notes are dropped from the tree (still in this PR's history).

Accuracy

Against the official PyTorch pipeline's golden data (1024x1024, tests/golden/g_fridge_1024), coop path:

Stage Result
Text encoder, last hidden state cosine 0.99988 / 0.99980 (prompt / negative); torch itself with one kernel swapped: 0.99989 / 0.99983
DiT, step 0 cosine 0.999976 / 0.999983; the CUDA engine's own test asks for 0.99988
DiT blocks against the CUDA engine 96-98.6 % identical bits (plain: 69-84 %), cosine >= 0.9999999
VAE decode of the golden latent 69.4 dB PSNR, 99.25 % identical bytes, never off by more than 1 (as the CUDA engine's reference decode)

The whole 50-step image keeps the composition and the handwritten Chinese text; its final latent is at cosine 0.974 to the reference (CUDA engine: 0.992). That is the sampler magnifying any difference, not an error: the same Vulkan steps fed torch's own text embeddings instead of the Vulkan encoder's (0.9998 apart) end 0.971 apart from each other.

Not run

  • On an AMD or Intel GPU. Every result here comes from an RTX 4090 Laptop GPU (all three paths) and from lavapipe in CI (plain), so coop-f16 has not run on the GPUs it is for.
  • Speed on a quiet GPU. Every timing so far was taken on a throttled laptop GPU (35 W cap), so none is quoted.

Known limits

  • Intel GPUs likely get the plain kernels: the coop kernels need a subgroup size of 32.
  • The VAE decodes in one piece: about 1.6 GiB at 1024x1024, 6 GiB at 2048x2048.

CloverIris and others added 30 commits November 2, 2026 10:20
Two documents for a possible Vulkan compute backend:

- docs/vulkan-backend-research.md: device audit on the RTX 2080 (Vulkan 1.4.357,
  cooperative-matrix shapes measured with a probe: f16 16x16x16 with f32 accumulate,
  int8 16x16x32, bf16 reported but emulated on Turing; 48 KiB shared memory,
  memory heaps), prior art (ggml-vulkan, stable-diffusion.cpp, ncnn, IREE),
  performance model against the measured CUDA numbers, numerics, risks.
- docs/vulkan-backend-design.md: two-phase plan (standalone runner first, then a
  runtime/kernel abstraction), module layout and build integration, runtime design
  (memory, queues, dispatch), kernel plan for the DiT/text encoder/VAE, GEMM and
  attention tiling for 48 KiB, quantized formats, planner/CLI/server integration,
  test strategy and milestones.

Research only: no engine code changes.
- docs/vulkan-backend-{research,design}.md: device audit on the RTX 2080 (Vulkan 1.4.357,
  cooperative-matrix shapes, 48 KiB shared memory, heaps), prior art, performance model, and
  a two-phase design for a Vulkan backend.
- tools/vulkan_gemm: a standalone tool (not part of the engine build) that runs an f16
  cooperative-matrix GEMM at the engine's shapes, checks it against a CPU reference and times
  it. Measured on the RTX 2080: 1e-5 max error at 64^3, ~8-9 TFLOPS at 3072^3 (memory-bound;
  shared-memory staging is the next step).
- tools/vulkan_gemm/probe.py: lists the device's cooperative-matrix shapes and memory heaps
  through vulkan-1.dll, no SDK needed.

No engine code changes.
Tool: requires full 32-wide subgroups and checks the device (shape, features, subgroup size) instead of
assuming them; picks the first usable device; barriers between upload, dispatches and download; enables the
features the shader declares, including vulkanMemoryModel, which was missing; runs free M through padded
tiles, so the engine's real shapes run; checks several tiles, band tails and row tails, and exits non-zero on
a failed check; correct fp16 conversions (exhaustively compared with the compiler's); probe.py works on Linux.

Notes: Vulkan is the fallback for hardware the engine does not otherwise support. Measured against cuBLASLt on
one GPU. Corrected DiT and text encoder shapes, attention tiles that fit 48 KiB, and stale facts.
…e GEMM

Standalone vulkan/ replaces tools/vulkan_gemm. It stays outside src/ because the engine's build globs that
directory, so default builds do not change.

The coopmat GEMM stages A and B in shared memory and writes through it, so M and N are free and the down GEMM
nearly doubles. Devices without the fast path get a plain compute GEMM that needs only 64-bit integers and
buffer device addresses, and takes bf16 as well as f16. Checks pass on an RTX 4090 Laptop on both paths and on
llvmpipe, with core validation clean.
…eplays

RMSNorm with modulation, QK norm and RoPE, varlen attention with online softmax, and the GEMM's plain, gated
residual and SiTU-GLU epilogues, all bf16 with the engine's rounding points. They need no cooperative matrices,
16-bit support or particular subgroup size. Known-answer checks pass on an RTX 4090 Laptop and on llvmpipe, with
core validation clean.

Shaders other than the cooperative-matrix one now compile for Vulkan 1.2: Vulkan 1.2 devices reject the newer
SPIR-V the build was producing for them.
vulkan_block_real runs a block's plain compute kernels on real weights and activations and compares each output
with the engine's, from files captured from its forward, so it needs no CUDA. At the 1024x1024 golden case the
cosine is 0.999997 or better on blocks 1, 22 and 41, with no NaN.
…ues and flash attention

Devices with VK_KHR_shader_bfloat16 fragments run the block's GEMMs as 128x128 tiles with their bf16, gated residual and
SiTU-GLU epilogues, and attention as a flash attention over 32-key steps that rescales the output only when a row's
maximum grew. One block of 8484 tokens on an RTX 4090 Laptop: 1.39 s on the plain path, 169 ms here. Against the
engine on real blocks 1, 22 and 41: 96 to 99 % same bits, cosine 0.999999919 or better.
…op from a golden case

dit.h runs MOSSImageTransformer on the block kernels with every weight resident: the time embedding and shared
modulation, the text refiner, the joint sequences of several rows with per-row skips, and the head. New kernels: GEMMs
with a bias alone or before a residual add, RMSNorm without modulation, SiLU and SiTU over separate tensors.
vulkan_dit_run feeds a golden case's text-encoder outputs and noise through the 50-step CFG loop. Step 0 of
g_fridge_1024 against the PyTorch pipeline on the coop path: cosine 0.999976 (cond) and 0.999983 (uncond), where the
engine's own test asks for 0.99988.
…e statistics on request

The plain path's GEMM stages fp32 tiles of 128 rows by 8 k in two shared-memory stages (17 KiB) and gives each of 256
invocations an 8x8 block of outputs with fused multiply-adds; it still needs no subgroups and no 16-bit arithmetic.
Measured back to back with the old one on a throttled RTX 4090 Laptop (35 W cap, 660-780 MHz): 5 to 8 times faster on
the DiT's shapes. MI_VK_PIPELINE_STATS=1 prints each pipeline's registers and spills where the driver reports them.
Each workgroup of 128 invocations takes 32 query rows of one head through 32-key steps staged in shared memory (30 KiB,
so llvmpipe's 32 KiB still fits), with S and acc_o register-blocked 2 rows by 4 keys and 2 rows by 16 columns, and the
softmax of a row split over four invocations that meet through shared memory. No subgroup operations or 16-bit
arithmetic. Same steps as FlashAttention; passes the replay check on an RTX 4090 Laptop and on llvmpipe. One plain
block back to back with the previous kernels on the throttled GPU: 14.4 s before, 2.9 s now (attention 3.5 s to 0.5 s).
…perative matrices and plain compute

Decodes the golden 1024x1024 latent to within 1 of the reference PNG on 0.75 % of the bytes on both paths
(PSNR 69.4 dB); each kernel is checked against a host replay.
cmake/modules/vulkan.cmake builds vulkan/ into the engine with -DMOSSINFER_VULKAN=ON (off by default). The CLI takes
--backend cuda|vulkan, --vulkan-device and --vulkan-kernels; with vulkan it refuses every CUDA-only flag and every
request the backend cannot run (other samplers and schedules, LoRAs, img2img, hires, control, accelerators), reports
the device and kernels it runs, and computes the token ids, the seed's initial noise and the schedule with the engine's
host code before calling vulkan/generate.h. generate.cpp joins the library with the text encoder and the VAE.
DitOptions::device_budget keeps the first blocks resident and the rest in mapped host memory, each copied into one
device slot right before it runs; generate() sizes the budget from VK_EXT_memory_budget (else the device-local heap)
minus the run's activations and a 512 MiB reserve. With a 4 GiB budget on g_fridge_1024 (15 of 42 blocks resident,
5.7 GiB in host memory) step 0 has the resident run's bits.
TextEncoder runs Qwen3_5Model(input_ids).last_hidden_state for several sequences packed along the rows: GatedDeltaNet
layers (conv1d+SiLU, gates, the engine's chunked gated delta rule in fp32 split into four kernels that fit 32 KiB of
shared memory), full gated attention (q/k norm with RoPE, causal GQA attention at head_dim 256), the MLP, with the
GEMMs on BlockKernels and residual adds as its gated-residual epilogue with a gate of ones. Weights stream from the
safetensors files: a reader thread fills one of two host staging buffers per layer, the layer is copied to one device
buffer, and work is submitted in pieces of bounded size. The new kernels need no cooperative matrices, 16-bit arithmetic
or subgroup size, so both paths share them.

Against the official pipeline's goldens (g_fridge_1024) on an RTX 4090 Laptop, cooperative-matrix path: last_hidden
cosine 0.9998972 (cond) and 0.9998012 (uncond), against 0.9998949 and 0.9998333 for torch itself with one kernel
swapped. Known-answer checks of each kernel pass on that GPU and on llvmpipe, with core validation clean.
DiT block ops and the text refiner's linears run in token ranges of at most DitOptions::submit_gflop (200 GFLOP on the
cooperative-matrix path, 20 on the plain one), attention in query ranges, so a slow device's submits stay under the
Windows display watchdog; g_fridge_1024's step 0 keeps its bits at 5 GFLOP per submit. vk_api.h compiles against the
headers without prototypes and resolves the core functions from libvulkan.so.1 / vulkan-1.dll on first use, so a
binary with the backend starts on a machine without Vulkan and says so only when asked for a device.
…onv_out, per-op profile

The plain tile reads its operands from shared memory as vectors and prefetches the next step during the current one;
outputs of at most 4 channels take one invocation per pixel. --bench times the decoder's heaviest convolutions and
--profile times every op in its own submit.
Device gains asynchronous copies on a transfer-only queue family (the copy engine) where the device has one, else on the
compute queue: copy_async returns a ticket, gpu_wait makes the next run() wait for it through a semaphore, host_wait
blocks until it completed. Buffers they touch are created with `transfer` set and shared concurrently by both families.
The text encoder copies layer k + 1 into a second device buffer this way while layer k computes, and reads each layer
from the files in 16 MiB chunks on four threads.

On an RTX 4090 Laptop that other processes kept busy, a 420 MiB staging copy took 35-125 ms on the transfer queue
against 410-780 ms on the compute queue; the g_fridge_1024 encode went from 29 s to 7.2 s, now bound by reading
16 GB from a mostly cold drive at 2.5 GB/s. Outputs are bit-identical; core validation stays clean.
Resolved: one safetensors reader (the text encoder's File and read_index, with Index for whole model directories built on
them), the text encoder's npy reader with a writer added, both runtime.h additions, and the text encoder's sources in the
library ahead of the tools switch. The loader table gains the fence and semaphore functions its async copies use.
Resolved: the VAE reads through the shared safetensors File (read_f32 added), and its sources join the one library with
generate.cpp, so the whole text-to-image path builds.
…ious block computes

Two device slots alternate; each streamed block's copy is issued while the block before it runs and waited for on the
device by its first op. g_fridge_1024 with a 4 GiB budget (14 of 42 blocks resident): the bits of the resident run,
and the copies' share of a step down from about 9.3 s to 0.1-0.2 s on the throttled laptop GPU.
…it runs

The CLI requires --tier; the Vulkan backend runs the bf16 model files as the reference tier does and refuses high and
turbo. First image: 512x512, 20 steps, seed 7 on an RTX 4090 Laptop, 45.97 dB PSNR against the CUDA backend's image
of the same command (mean pixel difference 0.8).
One workgroup of 64 now takes 16 query rows of a head and walks key tiles of 16 held in shared memory with the Q tile,
rows padded against bank conflicts, 25 KiB in all; each invocation keeps 4 output dims of all 16 rows. The online
softmax is unchanged except for its step, 16 keys instead of 32. On an RTX 4090 Laptop, attention over the 4096-token
golden prompt took 0.71 s for the 8 layers instead of 2.97 s, and the encode 8.4 s instead of 11.5 s.

The README gains the text encoder's section: results against the goldens on both paths next to torch's own deviation,
and times.
- DiT streaming: with an odd number of streamed blocks, the prefetch of the first one wrapped into the slot the last one
  was about to run from, so the last block ran on another block's weights. A prefetch into the running block's slot now
  waits until that block's runs are done, and the slot logic runs once per block around all of its runs. With 27 blocks
  streamed (a 4.25 GiB budget), g_fridge_1024's step 0 has the resident run's bits.
- Elementwise ops dispatch a 2-D grid past 65535 workgroups (the text refiner's SiTU over a 2048+ token prompt).
- Buffers past the device's maxBufferSize / maxMemoryAllocationSize are refused with a message; generate() checks the
  VAE's workspace against them and the free memory before the text encoder runs, and skips the VAE without a decode.
- The final latents reach the caller before the decode, so --latent-out survives a failed decode.
- Model files open by their wide name on Windows; paths reach the readers as std::filesystem::path.
- mossinfer_vulkan compiles with hidden visibility: its entry-point-named pointers are not exported.
- The coop path also requires compute subgroup vote and shuffle; attention_coop syncs the subgroup before the rescale
  overwrites S's staging.
- Device::run frees its command buffer when recording throws; Dit's destructor does not throw; --vulkan-device rejects
  indices below -1 and names a missing one; internal checks no longer exit as usage errors.
xycld added 12 commits November 2, 2026 08:57
…gine's build is unchanged again

The frontend chooses the backend, so the fallback is its own program: vulkan/ builds moss-infer-vulkan without CUDA,
with the engine's host sources it shares with moss-infer (tokenizer, initial noise, schedule, PNG writer) compiled in,
and packages it with CPack (mossinfer-VERSION-PLATFORM-vulkan-ARCH). Its flags are moss-infer generate's that the
backend runs; anything else is refused by name with exit status 2; --list-devices says which kernels each device gets.
The --backend vulkan integration in moss-infer and cmake/modules/vulkan.cmake are removed: src/ and the engine's CMake
match the base again. 512x512, 20 steps, seed 7: 1.96 s/step on the RTX 4090 Laptop.
ci.yml and release.yml call build-vulkan.yml next to the CUDA flavours. The release's package step exempts the Vulkan
archives from the -system-cuda pairing and requires both of them; the publish globs already take them.
docs/vulkan.md and vulkan/README.md describe what exists; the notes stay readable in PR mossimage-cpp#8's history.
@xycld xycld changed the title vulkan: research notes and a cooperative-matrix f16 GEMM bring-up tool moss-infer-vulkan: the Vulkan fallback program, with Linux and Windows packages Nov 3, 2026
xycld added 4 commits November 3, 2026 04:29
- CMake finds the Vulkan headers and glslc only (the SDK's Linux archive has no libvulkan.so, and FindVulkan required it;
  nothing links the loader) and checks that the headers define VK_KHR_shader_bfloat16.
- te_attention.comp: NEG_INF is a macro; glslc 2026.3 rejects a global const initialized by uintBitsToFloat.
Built with that SDK's glslc and headers, and its validation layer clean on lavapipe for all three checks.
…runs every check

Vulkan lets fma round twice, and lavapipe on LLVM 15 (Ubuntu 22.04) does: the fma polynomials of exp and log1p then
land up to 17 fp32 ULPs off (about 1e-6 relative) against the 8 the check allowed. The lavapipe step now runs all three
checks before failing.
LunarG's server stalled one Windows download for 34 minutes before breaking it off; curl retries, and the SDK is
cached per runner image and version.
… devices without bf16 ones

Mesa's RADV exposes bf16 fragments only on RDNA4, so RDNA3 and other devices with f16 matrix cores ran the plain
kernels, about 5x slower. The coop GEMM and attention shaders now also build with F16 (gemm_f16_coop, attention_f16_coop);
BlockPath::kCoopF16 runs them, `--kernels coop-f16` picks it, and the default order is coop, coop-f16, plain.

The operands stay bf16 in memory and convert as they are staged. row_scale.comp finds, per row of each GEMM's A and B,
the power of two that puts the row's largest finite value in [2^15, 2^16), where every bf16 value is an f16 value; the
GEMM scales rows by it on load and the accumulators back (one combined exponent, two exact halves). Every product is
then the bf16 one. Attention converts q, k and v unscaled and feeds P as bf16(p) * 2^15, undone with 1 / row_sum.
On the RTX 4090 Laptop: real DiT blocks 98.14 / 96.16 / 98.56 % same bits against the engine, the text encoder's golden
cosines and step 0 (0.999976 / 0.999985) equal coop's; 1024x1024 step 8.71 s vs 8.70 s, 512x512 step 2.11 s vs 1.97 s.
The VAE runs plain on it (its coop kernels need bf16 fragments). Scaling only A rows was not enough: about 0.1-0.4 %
of the weights are below 2^-14, which the driver flushed.

Also:
- tanh_safe: below |x| = 1/16 the series, as 1 - exp(-2|x|) cancelled to 0 under ~2^-25 (wrong bits for tiny SiTU
  inputs on every path).
- vulkan_block_check: a GEMM case whose rows span 2^-90 to 2^100, judged with per-row floors; "same" compares values.
- moss-infer-vulkan: an explicit --kernels the device cannot run is refused before anything loads (exit 2);
  --list-devices and vulkan_check --list name the kernels a device gets.
@xycld
xycld merged commit 559e3d8 into mossimage-cpp:main Nov 4, 2026
8 checks passed
@xycld xycld mentioned this pull request Nov 4, 2026
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