Repository navigation
Description
Summary
On the Vulkan backend, Japanese generation frequently fails to emit EOS and runs all the way to the frame cap. The audio stays fluent-sounding but stops being the requested text, and ASR often labels it as a different language (Korean/Vietnamese). Rebuilding the exact same commit with -DGGML_CUDA=ON and changing nothing else cuts the failure rate by ~3.5x and eliminates the language drift entirely.
| backend | runaway | ASR ≠ ja | avg gen |
|---|---|---|---|
| Vulkan | 7/20 (35 %) | 5/20 | 6.2 s |
| CUDA | 2/20 (10 %) | 0/20 | 4.9 s |
| CPU | 1/20 (5 %) | 0/20 | 25.4 s |
Same zonos2-q6_k.gguf, same speaker embedding, same sampling parameters, same two sentences, 20 generations per backend.
I mention this partly because every prebuilt GPU binary in the release assets is Vulkan (zonos2-windows-x64-vulkan.zip, zonos2-linux-x64-vulkan.tar.gz), while docs/INTERNALS.md's numerical-validation table covers CPU and CUDA only — so the shipped GPU path looks like the one path that isn't covered by that table.
Environment
| zonos2.cpp | v0.5.1 (39a4d01) |
| Binary | zonos2-server.exe, requests via POST /tts/generate |
| GPU | NVIDIA RTX 3060 12 GB (sm_86), driver CUDA 12.6 |
| OS | Windows 11 x64 |
| Flags | --batch 1 --dac-threads 1 --gpu --spk spk-encoder.gguf |
| Models | zonos2-{q5_k,q6_k,q8_0}.gguf + dac.gguf from Zyphra/ZONOS2-GGUF |
Vulkan build = the released zonos2-windows-x64-vulkan.zip toolchain; CUDA build = same tree configured with -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=86.
Reproduction
Two Japanese sentences, one per request:
- A (32 chars):
本日は晴天なり。これは音声合成エンジンの長文読み上げテストです。 - B (45 chars):
文章が長くなった場合でも、最後まで正しく安定して音声が生成されるかどうかを確認しています。
{
"text": "<A or B>",
"stream": false,
"format": "wav",
"max_tokens": 1000,
"temperature": 0.85,
"min_p": 0.25,
"speaker_embedding_id": "<uploaded via /tts/speakers>"
}I count a response as runaway when its duration reaches ≥ 90 % of the requested cap (max_tokens * 512 / 44100 s) — i.e. EOS never fired. Expected speech for A is ≈ 4.6 s against an 11.6 s cap, so it is unambiguous, and consecutive runaways come back with byte-identical lengths pinned at the ceiling.
What I ruled out first
Quantization — 10 generations each, all on Vulkan:
| quant | Q8_0 | Q6_K | Q5_K |
|---|---|---|---|
| runaway | 3/10 | 3/10 | 5/10 |
Raising the quant does nothing; lowering it makes things worse, consistent with the lower top-1.
Temperature — 8 generations per setting, Vulkan:
| temperature | 1.15 (default) | 0.85 | 0.7 | 0.55 |
|---|---|---|---|---|
| runaway | 3/8 | 2/8 | 2/8 | 4/8 |
No trend anywhere in the range.
Reference audio — it helps rather than hurts, so this is not a speaker-conditioning problem (4 generations of A, Vulkan):
| conditioning | runaway | ASR language |
|---|---|---|
| reference audio | 1/4 | ja |
| default speaker | 3/4 | vi, ko, ja |
Port logic — I diffed the C++ against the Python reference and found no behavioural difference:
- EOS detection in
zonos2-graph.cpp(last codebook index whose token iseoa_id→eos_frame = max(0, step - max_eoa)→countdown = ncb + 1) matchespython/zonos2/tts/sequence.py::_check_eosexactly. - The sampler matches too: same order (repetition penalty → temperature → top-k → softmax → top-p → min-p) and the top-p/min-p masks are mathematically equivalent to
tts/sampler.py. - Every default matches
server/api_server.py: temperature 1.15, topk 106, top_p 0.0, min_p 0.18, repetition_window 50, repetition_penalty 1.2, repetition_codebooks 8.
That left the compute backend as the only remaining variable, which is where the difference showed up.
In the Q6_K Vulkan run, the three non-Japanese detections were exactly the three runaways (all ko), so "it started speaking another language" and "it never stopped" are the same event: once it overruns EOS it keeps producing plausible audio that is no longer Japanese. (Language detection is faster-whisper base on the returned WAV.)
Given this is an MoE model and the docs already note that small perturbations can flip the router's top-k expert choice and decorrelate the output, a slight numerical difference in the Vulkan kernels seems like a plausible mechanism, though I have not bisected which kernel diverges. Happy to run --validate / models/compare.py against Vulkan if that would be useful — the existing golden-comparison tooling looks like it would localize this quickly.
Secondary: max_tokens is clamped silently
parse_gen_req does out.max_frames = (req_max > 0) ? std::min(req_max, cap) : cap; with cap = s.max_frames (default 2000 ≈ 23 s), and nothing in the response says the request was reduced. A client asking for 4560 frames silently gets 2000, so a client-side "did this hit the cap?" check computed from the requested value misses every runaway. Surfacing the effective cap in the response would make this detectable.
Raising the ceiling isn't a workaround, at least on a 12 GB card: --max 6000 makes DAC decode attempt a ~7 GB allocation and die with vk::Device::allocateMemory: ErrorOutOfDeviceMemory.
Workarounds I'm using
Switching to a CUDA build is what actually helped. On top of that, client-side: split input so each request stays well under the cap, and regenerate on a fresh seed when the returned duration indicates a runaway.
Thanks for open-sourcing this — happy to run further experiments or test a patch.
Activity
GNBS711 commentedon Nov 9, 2026
More data from an NVIDIA/Windows machine (prebuilt release 0.5.2): Vulkan is deterministic here, and the divergence from CPU starts in the MoE output of layer 4.
Setup
zonos2-windows-x64-vulkan.zip, release 0.5.2. The CPU and Vulkan runs below use the same binary (--cpuvs--gpu).fp16: 1 | bf16: 1 | int dot: 1 | matrix cores: NV_coopmat2. CPU: Ryzen 7 5700X3D.zonos2-q4_k.gguf(563 tensors, 4.58 GiB), voice cloning, short pt-BR sentence (~80 chars).Symptom: on Vulkan the cloned audio degrades into artifacts after the first words. CPU and CUDA are clean with the same model, reference and sampling parameters.
What I checked (
zonos2-cli,--seed 123,--max 400)--save-speakeron CPU, Vulkan and CUDA gives bit-identical vectors (cos 1.0, max|diff| 0). With--speaker emb.npycomputed on CPU, Vulkan still degrades. So it is not the encoder or the conditioning.The Vulkan greedy output is almost silent (rms 0.0005 vs 0.063 on CPU).
4. None of these env vars changes frame 0, each checked against the ggml_vulkan startup line:
GGML_VK_DISABLE_COOPMAT2(-> KHR_coopmat),_COOPMAT,_F16(fp16: 0),_FUSION,_BFLOAT16(bf16: 0),_INTEGER_DOT_PRODUCT(int dot: 0). The first four together (matrix cores: none, fp16: 0) still degrade by ear.5.
--validateCPU vs Vulkan, 100-token prompt (171 tensors):ffn_outper-row cos >= 0.995).attn_out_4,ffn_in_4androuter_states_4still match (per-row cos >= 0.9988), butffn_out_4does not: per-row cos median 0.14 (range -0.09..0.55), and it is tiny on Vulkan: row norms 1e-5..1e-7 vs 0.03..1.3 on CPU (roughly 500x to 6e6x smaller, not a uniform factor; max |value| 2.6e-4 vs 0.11).logitscos 0.84.gguf-dump: blk.3 and blk.4 have identical tensor types (Q4_K experts, F16 spine). The only listed difference isblk.4.router_eda_scale, which blk.3 does not have.router_states(the 128-d state) is dumped, not the 16-way router logits/probs, the selected expert ids, the gate weight or the raw expert output. So I can't tell which of those collapses.Ask: could
--validatealso dump, for MoE layers, the router logits, probs, selected ids, gate weight and the expert output before weighting? Or point me to a Vulkan-enabled build that does, and I'll run it. Happy to test patches. A README note that the prebuilt Vulkan path is not covered by the numerical-validation table would help others.Side notes
"stream": false) with the DAC on GPU fails on 8 GB:ggml_gallocr_reserve_n_impl: failed to allocate Vulkan0 buffer of size 3118146432,dac: alloc failed, and the server returns a 44-byte WAV (header only).--dac-cpuworks around it. Same family as the--max 6000allocation failure reported above.zonos2-cli --validate ids.npy out_dir ...prints "wrote 171 tensors to out_dir" even ifout_dirdoes not exist, and nothing is written. Creating the directory first fixes it.-T cuda=<path to 12.6>fixed it. The ggml DLLs end up inbuild-cuda\bin\Releaseand must be copied next to the exe. A prebuilt Windows CUDA binary would be a big help.Cross-link: #11 (AMD/RADV) looks like the same symptom class but there the output is non-deterministic, so it may be a second bug.