Skip to content

Vulkan backend: Japanese generation often never emits EOS and runs to the frame cap (CUDA/CPU do not reproduce) #10

Open

Description

@CHIBITeK365

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 is eoa_id → eos_frame = max(0, step - max_eoa) → countdown = ncb + 1) matches python/zonos2/tts/sequence.py::_check_eos exactly.
  • 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

  1. GNBS711 commented on Nov 9, 2026

    @GNBS711

    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.cpp prebuilt zonos2-windows-x64-vulkan.zip, release 0.5.2. The CPU and Vulkan runs below use the same binary (--cpu vs --gpu).
    • CUDA = built from current main (ggml 3af5f57), CUDA 12.6, MSVC 14.42, sm_89.
    • Windows 11, RTX 4060 Ti 8 GB. Vulkan banner: 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).
    • One prompt, one quant, one GPU, so treat this as a data point, not a full study.

    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)

    1. Determinism: 3 identical Vulkan runs give identical WAV hashes (same for 2 CUDA runs and 2 CPU runs). So this is not a race, unlike **Vulkan backend on RADV/RDNA3: wrong pitch, no EOS, and non-deterministic output — CPU backend is bit-identical** #11.
    2. Speaker embedding: --save-speaker on CPU, Vulkan and CUDA gives bit-identical vectors (cos 1.0, max|diff| 0). With --speaker emb.npy computed on CPU, Vulkan still degrades. So it is not the encoder or the conditioning.
    3. Frame 0 already differs on Vulkan (first two codebooks), CPU and CUDA agree with each other:
    run CPU CUDA Vulkan
    sampling, seed 123 568 778 568 778 698 804
    greedy 698 778 698 778 568 804
    short prompt, default seed 568 804 568 804 568 778

    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. --validate CPU vs Vulkan, 100-token prompt (171 tensors):

    • Layers 0-3 match closely (residual cos >= 0.99995; layer 3 ffn_out per-row cos >= 0.995).
    • Layer 4: attn_out_4, ffn_in_4 and router_states_4 still match (per-row cos >= 0.9988), but ffn_out_4 does 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).
    • Everything after layer 4 inherits this; final logits cos 0.84.
    • gguf-dump: blk.3 and blk.4 have identical tensor types (Q4_K experts, F16 spine). The only listed difference is blk.4.router_eda_scale, which blk.3 does not have.
    • Limitation: only 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 --validate also 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

    • Non-streaming ("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-cpu works around it. Same family as the --max 6000 allocation failure reported above.
    • zonos2-cli --validate ids.npy out_dir ... prints "wrote 171 tensors to out_dir" even if out_dir does not exist, and nothing is written. Creating the directory first fixes it.
    • Windows CUDA build gotchas: CMake picked CUDA 11.8, which fails with MSVC 14.42 (STL1002); -T cuda=<path to 12.6> fixed it. The ggml DLLs end up in build-cuda\bin\Release and must be copied next to the exe. A prebuilt Windows CUDA binary would be a big help.
    • On this GPU, Vulkan and CUDA decode at about the same speed (~170 vs ~161 frames/s), so CUDA costs nothing here.

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions