Vulkan from upstream llama.cpp#
1bit serve --device vulkan runs a llama-server built from upstream
llama.cpp (MIT), pinned to its latest
release in third_party/llama.cpp-vulkan, plus the few upstream pull requests we
carry until upstream merges them ("Carried upstream PRs" below). It is a separate tree from
third_party/llama.cpp, which stays on the llama.cpp + hrx-system pair AMD
tests together for HRX (HRX).
Two pins, because the two routes stay current in different ways:
| Route | Source | Pinned to | Moved by |
|---|---|---|---|
--device vulkan |
third_party/llama.cpp-vulkan (1bit-MONSTER/llama.cpp 1bit/vulkan-upstream) |
ggml-org's latest release + carried upstream PRs | bump-llama-vulkan.yml, daily |
--device hrx |
third_party/llama.cpp + third_party/hrx-system |
AMD's tested pair (ROCm/ggml-staging-automation) | bump-hrx.yml, daily |
A new model architecture reaches the Vulkan route the day upstream releases it,
without waiting for AMD's pair to move. Qwen3.8-Flash-Next (qwen4exp) is the
case that prompted this: upstream added it on 2026-08-27, and AMD's pinned
llama.cpp (f1a0aca141de) cannot load it.
Carried upstream PRs#
1bit/vulkan-upstream is ggml-org's release plus these commits. On each new release
bump-llama-vulkan.yml rebases them onto it; one that upstream has merged becomes
empty and drops out, and one that no longer applies stops the bump for a hand rebase.
The commit the engine pinned before is kept as the tag vulkan-upstream-<sha12>.
| Upstream PR | What it adds | Carried as |
|---|---|---|
| ggml-org#28243 (Daniel Han, Ryan Monsurate) | Qwen3.8-Flash-Next's NextN/MTP draft head (--spec-type draft-mtp), and the fix for -md loading the target model instead of the draft file |
62484fba on v0.5.0 |
| ggml-org#21412 (Echo Labs) | Zyphra's Zamba2 (Mamba-2 layers plus shared transformer blocks, per-layer LoRA adapters merged at conversion) and its converter | 5ce79a56 |
ours: vulkan: SSM_SCAN for d_state 64 |
Vulkan's Mamba-2 scan had pipelines only for a 128/256 state, so Zamba2-2.7B/7B (state 64) ran the scan on the CPU in every layer; two test-backend-ops cases with their shapes | a32f0d6f |
ours: vulkan: SSM_SCAN for Mamba-1 |
Vulkan's scan only handled Mamba-2, so every Mamba-1 layer (Mamba, Falcon-Mamba, Zamba v1, BlackMamba) ran on the CPU; a per-channel kernel with the state in registers (d_state 16), three test-backend-ops cases | adb2af5d |
ours: model: Zamba v1 |
Zyphra's Zamba-7B-v1: Mamba-1 layers split into two heads (per-head x_proj/dt_proj, B/C as scan groups) plus one shared transformer block, stored once; converter de-interleaves in_proj | 5319c0e6 |
ours: gguf-py: LlamaHfVocab scores from the merge ranks |
upstream converter bug: models with only a SentencePiece-style BPE tokenizer.json (Zamba, Zamba2, and others) got every token score -1000, so merges were picked arbitrarily (Zamba-7B-v1: 318/1500 wikitext lines tokenized like HF); now -rank of the producing merge: 1500/1500 |
ee6e4e0d |
ours: model: BlackMamba |
Zyphra's BlackMamba (1.5B, 2.8B): Mamba-1 layers alternating with a Switch MoE (sigmoid top-1 router with bias, gated erf-GELU experts), biased final LayerNorm; the converter reads the Megatron checkpoint and uses the built-in GPT-NeoX vocab | f6261222 |
ours: convert: Zyphra Zamba2-VL |
Zamba2-VL (1.2B, 2.7B, 7B): the Zamba2 language model to a text GGUF (vocab padded to the embedding's 32,064 rows, and a chat template that takes llama.cpp's string content) and Qwen2.5-VL's vision tower to an mmproj (temporal patch 1: the second patch conv is zeros); mtmd runs it unchanged | f8132a7f |
ours: qwen4exp MTP: reduced draft vocabulary |
Flash-Next's MTP head can draft from part of the vocabulary: a K-row head plus a d2t id map, logits scattered back to the full vocabulary (src/models/qwen4exp-draft-vocab.{h,cpp}); files made by tools/mtp_draft_vocab.py, see 1bit serve |
5ed92e99 |
Each upstream PR is reviewed line by line before it is pinned. #28243: it touches only the qwen4exp model, its
converter, and two lines of the shared speculative decoding (the -md path fix, and
KV sharing kept to gemma4-assistant drafts, as v0.5.0 already did). A draft head can
only change speed: the target model checks every drafted token.
Build#
Needs the Vulkan headers, loader and glslc.
git submodule update --init --depth 1 third_party/llama.cpp-vulkan
cmake -B build -G Ninja -DONEBIT_VULKAN=ON
cmake --build build
ONEBIT_VULKAN and ONEBIT_HRX build side by side: --device vulkan then uses
the upstream build and --device hrx the HRX build. Without ONEBIT_VULKAN,
--device vulkan falls back to the HRX build's Vulkan backend, as before.
ONEBIT_LLAMA_SERVER still overrides both.
Verified (Strix Halo)#
v0.5.0 7fe450e19305:
| Model | Result |
|---|---|
tests/serve_e2e.sh (Qwen3-0.6B Q4_K_M) |
PASS (serve_e2e_vulkan_upstream) |
| Qwen3.8-27B UD-Q4_K_XL | "The capital of France is Paris.", 12.2 tok/s |
Qwen3.8-Flash-Next UD-Q4_K_XL (--ctx-size 8192) |
"The capital of France is Paris.", 22.0 tok/s; the HRX pin fails to load it |
v0.5.0 + ggml-org#28243 (62484fba), llama-server built from the branch:
| Model | Result |
|---|---|
| Qwen3-0.6B Q4_K_M | "The capital of France is Paris.", 315-331 tok/s (unchanged) |
Qwen3.8-27B UD-Q4_K_XL + --mtp (Q4_0 head) |
35.0 / 28.1 / 32.2 tok/s code / prose / short; draft acceptance 188/200, 171/252, 6/6, the same as v0.5.0 |
v0.5.0 + #28243 + #21412 + the d_state-64 scan (a32f0d6f); Zamba2 converted with the carried converter (convert_hf_to_gguf.py --outtype q8_0), test-backend-ops SSM_SCAN 14/14 on Vulkan0:
Zamba2 (Q8_0, llama-bench -p 256 -n 64) |
pp256 | tg64 | chat |
|---|---|---|---|
| 1.2B-instruct | 3166 | 89.8 | "The capital of France is Paris." |
| 2.7B-instruct | 1541 (was 614) | 45.2 (was 4.3: scan on the CPU) | correct |
| 7B-Instruct (2 shared blocks, RoPE) | 523 (was 29) | 17.1 (was 2.1) | correct, 16.7 tok/s |
Mamba-1 scan (adb2af5d), state-spaces/mamba-370m-hf F16, llama-bench -p 256 -n 64: pp256 228 → 6736 tok/s, tg64 25.8 → 173.5 tok/s; greedy completions identical to the CPU-scan build; test-backend-ops SSM_SCAN 17/17.
Zamba v1 (ee6e4e0d), Zyphra/Zamba-7B-v1 Q8_0: teacher-forced top-1 vs transformers FP32 96/96 on three prompts; greedy continuations identical to the reference's; pp256 543, tg64 14.9 tok/s; 2 graph splits (all compute on Vulkan0: each layer's two-head scan runs on the Mamba-1 Vulkan kernel from adb2af5d). GGUFs of Zamba or Zamba2 converted before ee6e4e0d have the broken token scores: reconvert them.
BlackMamba (f6261222), Zyphra/BlackMamba-1.5B: teacher-forced top-1 vs Zyphra's own PyTorch code (FP32, CPU) 96/96 on three prompts, identical greedy text and prompt tokens; F16 pp256 7180 / tg64 250 tok/s, Q8_0 pp256 10136 / tg64 369 tok/s. No HF architectures in its config.json: the converter routes on mamba_moe_layers.
Zamba2-VL (f8132a7f): images through 1bit serve --mmproj (1bit serve).
python third_party/llama.cpp-vulkan/convert_hf_to_gguf.py --remote Zyphra/Zamba2-VL-2.7B --outtype q8_0 --outfile zamba2-vl-2.7b-Q8_0.gguf
python third_party/llama.cpp-vulkan/convert_hf_to_gguf.py --remote Zyphra/Zamba2-VL-2.7B --mmproj --outtype f16 --outfile mmproj-zamba2-vl-2.7b-F16.gguf
1bit serve -m zamba2-vl-2.7b-Q8_0.gguf --mmproj mmproj-zamba2-vl-2.7b-F16.gguf --device vulkan --ctx-size 8192
The vision embeddings were checked against transformers' Qwen2.5-VL vision tower in FP32, loaded with Zamba2-VL-2.7B's weights and fed by its own image processor: 192 tokens × 2560, mean per-token cosine 0.99989 on the CPU and 0.99944 on Vulkan0. The test image is a red square, a blue circle and the text "HELLO 42":
| Zamba2-VL (text Q8_0, mmproj F16) | Answer |
|---|---|
| 1.2B | the shapes, colours and layout right; reads the text as "HElo 42" |
| 2.7B | "A red square and a blue circle with the text "HElo 42" underneath."; 40-41 tok/s decode |
| 7B | "a red square on the left, a blue circle on the right, and the text "HELLO 42" in black" |
The 1.2B and 2.7B misread the text the same way on the CPU and on Vulkan, so the misreading comes from the models, not from the port.
GGUFs made before llama.cpp #17 carry a chat
template that left each image inside the user turn instead of in front of it, as Zyphra's
template has it. llama-server marks images with <__media_<id>__>, an id random per server, and
that template knew only <__media__>. Reconvert those GGUFs, or serve them with
--chat-template-file. With the new template the image starts at the same prompt position as in
Zyphra's processor.
Large models need --ctx-size: without it llama-server allocates the KV cache
for the model's full trained context (262,144 tokens for Qwen3.8), which does not
fit next to Flash-Next's 104 GiB of weights.
Measured quant sweet spots for Qwen3.8 on this route are on the wiki.
ZAYA1 (Zyphra), from our llama.cpp#
ZAYA1 is Zyphra's MoE: 8.8B parameters, 760M active per
token. Every layer runs CCA attention, where q and k pass through short convolutions over time,
then a top-1 router that picks one of 16 experts or a skip expert. Upstream llama.cpp has no ZAYA:
its draft (ggml-org/llama.cpp#23112) was closed on 2026-09-05. ZAYA lives in our llama.cpp,
third_party/llama.cpp (1bit-MONSTER/llama.cpp, PR #2),
and runs there on Vulkan, HRX and ROCm (HIP). 1bit serve --device vulkan reads the GGUF's
general.architecture, and runs an architecture that only our llama.cpp has on that build's
Vulkan0 even when the upstream build is present; --device hrx runs it on HRX0.
python third_party/llama.cpp/convert_hf_to_gguf.py <Zyphra/ZAYA1-8B> --outtype f16 --outfile zaya1-8b-f16.gguf
build/hrx/llama/bin/llama-quantize zaya1-8b-f16.gguf zaya1-8b-Q4_K_M.gguf Q4_K_M
1bit serve -m zaya1-8b-Q4_K_M.gguf --device vulkan --ctx-size 8192
ZAYA GGUFs converted before 2026-09-27 tokenize chat turns wrongly. Their <|im_start|> (id 105)
and <eos> were stored as ordinary tokens, so llama.cpp spelled each turn marker out as seven text
tokens and chat answers went off-template ("12 + 30" gave "22"). The converter marks them as
control tokens since llama.cpp #27, and every
ZAYA GGUF on the 1bit-MONSTER Hugging Face org was fixed in place the same day (the tensors are
unchanged). Re-download, or reconvert. The teacher-forced checks above never saw it, because they
feed token IDs directly.
Convert with this converter: it writes the grouped convolution's weights tap-major, which the graph expects, so GGUFs made by other converters do not load.
Verified on Strix Halo (Radeon 8060S):
| Check | Result |
|---|---|
Against transformers' ZayaForCausalLM in FP32, 96 teacher-forced positions |
the F16 GGUF picks the same top token at 95 (the other is a 0.07-nat tie; transformers' own BF16 run matches FP32 at 91) |
test-llama-archs -a zaya |
Vulkan and ROCm match the CPU (NMSE 9.6e-8 and 8.1e-14); save and reload bit-exact |
tests/serve_e2e.sh, Q4_K_M |
PASS on --device vulkan and --device hrx |
Q4_K_M (5.17 GiB), by device:
| Device | Prefill (pp512) | Decode (tg128) | Perplexity* |
|---|---|---|---|
| Vulkan0 | 3,437 tok/s | 93.0 tok/s | 21.57 |
| ROCm0 | ~2,400 tok/s | 61.5 tok/s | 21.78 |
| HRX0 | 2,137 tok/s | 47.9 tok/s | 21.65 |
F16 on Vulkan0: 1,138 tok/s prefill, 45.9 tok/s decode, perplexity 20.59.
* 512-token chunks over this repository's docs (PORTING.md, hrx.md, README.md), for comparing quants and devices, not models. ROCm's Q4 matmuls quantize activations to 8 bits, hence its slightly higher figure.
Vulkan decodes fastest, so auto and --device vulkan stay the default for ZAYA. The engine's
--device rocm server is built from ROCmFPX's tree, which has no ZAYA; the ROCm numbers above are
our llama.cpp built with GGML_HIP=ON for gfx1151.
HRX0 decoded at 25.5 tok/s until llama.cpp #24: ggml-hrx sent
seven of ZAYA's ops per layer to the CPU (the grouped-conv matmul, the router's softmax, top-k and
gather, and a few copies), 641 graph splits per decoded token. New HRX kernels for those ops (see
HRX) make the decode graph one split, and decode 1.9x faster. The perplexity moves
from 21.55 to 21.65, which is kernel rounding amplified by top-1 routing: with the new kernels turned off
(GGML_HRX_DISABLE_DISPATCH) the build reproduces the old figure exactly. Vulkan and the CPU are unchanged.
What it took, besides the port: CCA's grouped convolution runs as one batched matmul per tap
(as one small matmul per group it held Vulkan decode at 50 tok/s), and the graph avoids what
HRX and ROCm lacked: copies into part of a state row, concatenating strided views, l2_norm,
and a two-tap ssm_conv kernel on ROCm.
ZAYA1-74B-preview#
ZAYA1-74B-preview is the same design at 74.8B parameters and 60 layers. It alternates sliding-window layers (a 4,097-token window, rope theta 1e4) with full-attention layers (theta 1e7). The converter writes the window, the per-layer pattern and the second rope base (llama.cpp #11). ZAYA1-8B has no sliding layers and is unchanged: its Q4_K_M perplexity matches to four decimals, and its F16 still matches transformers FP32 at 95 of 96 teacher-forced positions.
python third_party/llama.cpp/convert_hf_to_gguf.py --remote Zyphra/ZAYA1-74B-preview --outtype q8_0 --outfile zaya1-74b-Q8_0.gguf
build/hrx/llama/bin/llama-quantize --allow-requantize zaya1-74b-Q8_0.gguf zaya1-74b-Q4_K_M.gguf Q4_K_M
1bit serve -m zaya1-74b-Q4_K_M.gguf --device vulkan --ctx-size 8192
--remote streams the safetensors from Hugging Face, so the 150 GB BF16 checkpoint never has to
fit on disk. The Q8_0 file is 79.7 GB, and requantizing it to Q4_K_M needs --allow-requantize.
GGUFs made with --remote before llama.cpp #14 have no chat template, because the download
skipped chat_template.jinja. Reconvert them, or serve them with --chat-template-file.
Verified on Strix Halo, HF revision bc8eb466, Q4_K_M (42.6 GiB, 45.7 GB):
| Check | Result |
|---|---|
tests/serve_e2e.sh, --device vulkan --ctx-size 8192 |
PASS |
| Chat with the model's own template (code, prose, a one-line fact) | coherent answers |
llama-bench, Vulkan0 |
prefill (pp512) 505.8 tok/s, decode (tg128) 35.4 tok/s |
There is no reference check for the 74B: a 74B FP32 transformers run needs about 300 GB of memory, which does not fit on Strix Halo.
ZAYA1-base and ZAYA1-reasoning-base: legacy checkpoints#
ZAYA1-base, ZAYA1-reasoning-base and Zyphra's *-legacy repos keep the original Megatron-style
checkpoint:
- attention and MoE are separate half-layers;
- sizes live in per-layer lists;
- experts are stored one by one.
transformers cannot load them. The converter maps them to the transformers layout itself
(llama.cpp #14). Converting
Zyphra/ZAYA1-8B-legacy gives a GGUF whose 1,283 tensors are byte-identical to the one from
Zyphra/ZAYA1-8B, and the model metadata is identical too.
Both base models are the 8B shape (40 layers, 16 experts), with rope theta 1e6 and a 32,768-token context. Checked on Strix Halo, Q4_K_M (5.56 GB), on Vulkan0:
| Model | Perplexity* (wikitext-2 test, 60 × 512) | Decode |
|---|---|---|
| ZAYA1-base | 8.55 ± 0.17 | |
| ZAYA1-reasoning-base | 10.15 ± 0.22 | 89-91 tok/s (chat) |
| ZAYA1-8B, for comparison | 32.13 ± 1.08 |
* Raw-text perplexity favors base models; the post-trained ZAYA1-8B scores higher on it. These figures are not comparable with the docs-based perplexity in the table above. ZAYA1-reasoning-base answers chat prompts with its own template, thinking first.
Until llama.cpp #15, ZAYA returned wrong
results whenever a ubatch held more than one sequence: llama-perplexity at its default batch,
and 1bit serve answering concurrent requests. At four sequences per ubatch, ZAYA1-8B F16's
wikitext perplexity was 58,259 against 27.89 with one; now both are 27.89 on the CPU.
ZAYA1-VL-8B: images#
ZAYA1-VL-8B is ZAYA1 with Qwen2.5-VL's vision tower and two additions (llama.cpp #18):
- Vision-only LoRA. Rank-8 LoRA on CCA's q, k, both value projections and o_proj, and rank 32 on every expert, used on image tokens only. mtmd decodes each image as its own ubatch of embeddings, so that ubatch takes the LoRA and text ubatches run the ZAYA1 graph unchanged.
- Bidirectional attention within an image. The mmproj key
clip.vision.decode_non_causalmakes mtmd decode the image non-causally. The whole image must fit one ubatch, so1bit serve --mmprojpasses-b 4096 -ub 4096(Qwen2.5-VL caps an image at 4,096 tokens).
python third_party/llama.cpp/convert_hf_to_gguf.py <Zyphra/ZAYA1-VL-8B> --outtype f16 --outfile zaya1-vl-8b-F16.gguf
python third_party/llama.cpp/convert_hf_to_gguf.py <Zyphra/ZAYA1-VL-8B> --mmproj --outtype f16 --outfile mmproj-zaya1-vl-8b-F16.gguf
1bit serve -m zaya1-vl-8b-F16.gguf --mmproj mmproj-zaya1-vl-8b-F16.gguf --device vulkan --ctx-size 8192
Checked on Strix Halo against Zyphra's own code (their transformers branch zaya1-vl, FP32 on
the CPU), teacher-forced on llama.cpp's greedy answers to three image questions (101 tokens).
The prompts are identical: 208 tokens, with the image at the same position.
| Image decode | Reference | Top-1 agreement |
|---|---|---|
| causal (the flag set false) | Zyphra's eager path | 100/101 |
| bidirectional (the default) | eager, with the image block bidirectional | 100/101 |
Zyphra's flash-attention path, which its paper describes, makes everything up to two tokens past the image bidirectional. llama.cpp decodes the image bidirectionally and the tokens around it causally.
On a synthetic image (a red square, a blue circle, "HELLO 42") the model reads the text exactly, and on a photo it recognises the New York Times front page of the moon landing. F16 decodes at 38-51 tok/s on Vulkan0. ZAYA1-8B is unchanged: 95/96 teacher-forced, the same wikitext perplexity.
OPT, GPT-Neo, CodeGen and GPT-J, from our llama.cpp#
Upstream llama.cpp has no model code for these four older families (for gptj it has only
the name). Our llama.cpp ports them (PR #8,
engine #138). 1bit serve --device vulkan routes them to that build's Vulkan0, the same way
it routes ZAYA.
Each port was checked against transformers FP32: teacher-forced top-1 agreement over 3 prompts of 32 tokens, and one prompt of 340 tokens.
| Model | Top-1 agreement | Fixed on the way |
|---|---|---|
| facebook/opt-125m | 96/96 | tokenizer checksum |
| EleutherAI/gpt-neo-125m | 96/96 | no attention scaling, as in transformers |
| Salesforce/codegen-350M-mono | 96/96 (was 0/96) | the (query, value, key) order of qkv_proj; the lm_head bias |
| tiny-random-GPTJForCausalLM | 89/89 | lm_head bias, mask buffers, tokenizer checksum |