Skip to content

BreezeTTS: opt-in fused depth graph for CUDA (perf_mode=cuda_perf) - #1090

Merged
0xShug0 merged 6 commits into
0xShug0:mainfrom
Fishballllllll:breeze-fused-depth-graph
Oct 26, 2026
Merged

0xShug0 merged 6 commits into
0xShug0:mainfrom
Fishballllllll:breeze-fused-depth-graph

Conversation

@Fishballllllll

@Fishballllllll Fishballllllll commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

How this was made: most of the code and the investigation behind this PR were done by an AI coding assistant (Claude Code), with me directing the work and checking the results. I validated it as described below, but it may still contain mistakes that neither of us caught, so please review it with that in mind. The speedup is real on my machine; I'm happy to rerun anything, share the scripts and logs, or rework the change.

Summary

  • New session option breeze_tts.perf_mode=off|cuda_perf (default off), following the Drama3 / MiniMax Music3 pattern, with three individual options it presets: breeze_tts.fused_depth, breeze_tts.direct_prefill and breeze_tts.skip_cache_round (all default false). Explicit options override the preset; other backends ignore them.
  • With the default off, BreezeTTS runs the same code path as on main: output is byte-identical, and speed and peak VRAM match main.
  • With cuda_perf, CUDA output is also byte-identical to main for the same seed, peak VRAM is no higher than main's, and offline RTF is 1.82x faster at CFG 4 (0.802 → 0.442) and 1.70x faster at CFG 1 (0.577 → 0.340).

Problem

On CUDA, most of a BreezeTTS frame was spent outside the actual GPU work:

  • The depth decoder ran the 15 residual codebooks of each frame as separate graph launches. After every codebook the logits went back to the host for sampling and the next embedding came back up. Because its cache offsets kept changing, ggml-cuda could not replay a captured CUDA graph for it.
  • Offline generation prefilled the backbone, exported the KV state to host floats and imported it back into the decode cache. In my timing logs, starting the decode from that state (breeze_tts.ar.start_decode_ms) took 460–680 ms per request. The streaming path has prefilled straight into the decode cache since Improve Qwen3 and Breeze TTS streaming and Breeze longform voice consistency #858.

Change

All model code is in src/models/breeze_tts/ (generator.cpp, session.cpp), plus include/engine/models/breeze_tts/generator.h, model_specs/breeze_tts.json and docs/models/breeze_tts.md. Each of the three options below is off unless set explicitly or through cuda_perf, and takes effect on CUDA only.

breeze_tts.fused_depth: BreezeFusedDepthRuntime builds the whole depth loop of a frame as one fixed-shape ggml graph. The K/V of earlier codebooks are carried inside the graph, and the CFG mix, temperature, top-k/top-p and sampling run on the device. Each frame is one graph launch plus a download of 15 tokens. It matches the per-codebook path exactly:

  • Codebooks after the first attend over the in-graph K/V with the same flash attention the per-codebook path uses for cached decoding (DecoderPrefixAttentionMode::FlashWithPrefix).
  • Sampling uses the per-codebook sampler's own torch-CUDA exponential draws. The host computes them with torch_cuda_tensor_iterator_exponential_element_at_offset at the same seed and RNG offsets, uploads -log(E) per token, and the graph picks argmax(logits / T - log E). The torch RNG offsets advance exactly as before.
  • When the fused graph is enabled, the per-codebook depth runtimes are not created, and the graph embeds tokens from the backbone's own token-embedding tensor when it holds the exact F32/F16/BF16 values (quantized tables still get an F32 copy).

breeze_tts.direct_prefill: offline generation prefills into the decode cache, as streaming already does. This is the same computation without the host round trip. With it off, offline prefill goes through the same export/import calls as on main.

breeze_tts.skip_cache_round: with the bf16 reference path, an F16 cache and flash attention, K/V rows are already bf16-rounded before they are written, and an F16 cache stores those values exactly. Re-rounding the whole cache after each update is therefore an identity, and this option skips it.

What changed since your review

Thanks for testing it. Both trade-offs you saw were real, and I could reproduce them once I measured like you did: at the default guidance_scale 1, and with peak VRAM sampled finely (my earlier 200 ms nvidia-smi sampling missed the peaks).

  • Defaults: everything this PR adds is now behind perf_mode, off by default. That includes direct prefill and the cache-round skip, which were unconditional in the first revision (and direct prefill applied to every backend); they are now separate options, CUDA only.
  • Recognition accuracy: two differences in the fused graph made its tokens diverge from the per-codebook path. Its later codebooks used eager attention instead of the per-codebook path's flash attention, and it sampled with its own std::mt19937 Gumbel noise instead of the torch-CUDA stream. Both are fixed as described above. cuda_perf now reproduces main byte for byte, so accuracy is unchanged by construction.
  • Peak VRAM: the original revision measured +13.5% over main on the longest requests. About half came from keeping the decode graphs resident between requests. That saved at most ~2.5% RTF, so I removed it: graphs are released after each request, as on main. The other half came from the fused graph itself: a second 256 MB F32 copy of the audio embedding table, and the per-codebook depth runtimes that were still created alongside it. Both are gone. Peak VRAM with cuda_perf is now within 0.4% of main on the longest requests and below main on the 10-request set.

Validation

Setup: RTX 5090 Laptop GPU (24 GB, sm_120, driver 616.92), WSL2 Ubuntu 24.04, CUDA 12.8, conda-forge g++ 13.4. main (2f6b42d) and this branch (based on efdf97e) built with the same compiler and flags. The two commits between them (#1085, #1086) do not change BreezeTTS: #1085 adds a KV-cache import option that only VoxCPM2 enables.

cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_CUDA_ARCHITECTURES=120 \
  -DENGINE_ENABLE_CUDA=ON -DAUDIOCPP_MODEL_SET=custom -DAUDIOCPP_MODELS=breeze_tts
ninja -C build audiocpp_cli

Requests: audiocpp_cli --task tts --family breeze_tts --backend cuda --request-sequence <file> --metrics, one warm-up request excluded. The 10-request set is Mandarin instruction-controlled voice cloning (one 6 s reference clip, seed 1234, default sampling, guidance_scale 1 or 4). The long set is the 8 longest of a 47-request set (32–93 characters, guidance_scale 4). Warm RTF is sum(wall_ms) / sum(audio_duration_ms). Peak VRAM is the device's used memory sampled through NVML about every 0.2 ms over the whole process, including loading and warm-up.

Speed, peak VRAM and byte identity (Q8_0, 10 requests, all runs back to back)

main this PR, off (default) this PR, cuda_perf
CFG 4, warm RTF 0.802 0.805 0.442 (1.82x)
CFG 4, peak VRAM 5817 MiB 5827 MiB 5161 MiB
CFG 4, byte-identical to main 10/10 10/10
CFG 1, warm RTF 0.577 0.602 0.340 (1.70x)
CFG 1, peak VRAM 5195 MiB 5199 MiB 5181 MiB
CFG 1, byte-identical to main 10/10 10/10

off runs the same code as main; the CFG 1 difference is within this laptop's run-to-run variation (main itself measured 0.572–0.594 at CFG 1 across runs the same day).

Further byte-identity checks with cuda_perf

These were run on earlier commits of this branch that compute the same thing with cuda_perf (the later commits only release the decode graphs after each request and move direct prefill and the cache-round skip behind options):

  • BF16 package, 10 requests at CFG 1 and at CFG 4: 10/10 each.
  • Q8_0, the full 47-request set at CFG 4: 47/47.
  • Q8_0, the long set: 8/8 (one commit before the last).

Peak VRAM on the long set (Q8_0, CFG 4)

Peak VRAM vs main
main 6235 MiB
original revision of this PR 7075 MiB +13.5%
this PR, cuda_perf 6257 MiB +0.4%

Not tested

  • Only one machine (RTX 5090 Laptop, WSL2). I could not test other NVIDIA GPUs, and I'd be grateful if you could rerun it on yours.
  • HIP, Vulkan, Metal and CPU take none of the new paths (the options are CUDA only), so they run the same code as main; I did not rebuild or run them for this revision.
  • MUSA counts as BackendType::Cuda in audio.cpp, so the options would also take effect there, but I have no MUSA device.
  • Not tested: the Q4_0 package, multi-chunk longform text, concurrent server requests, streaming with cuda_perf, and Windows builds.
  • This PR adds no unit test. On this revision I ran the existing tests that touch the BreezeTTS spec and options, model_spec_system_test and attention_fallback_test, in the CUDA build, and both pass; I did not run the full ctest.

🤖 Generated with Claude Code

On CUDA, the 15 depth-decoder codebook steps of a frame now run as a
single ggml graph with an in-graph KV state and on-device top-k/top-p
Gumbel-max sampling, instead of one graph launch and host round trip
per codebook. The semantic sampler's torch RNG offsets are advanced as
the per-step path would, so the semantic stream is unchanged.

The offline path now prefills the backbone straight into the decode
cache, as the streaming path already does since 0xShug0#858, which removes
the per-request KV export/import. On CUDA the backbone and depth
graphs are also kept between requests, and the whole-cache bf16
re-rounding after each cache update is skipped when the K/V rows are
already bf16-rounded, the cache is F16 and flash attention is used
(an identity there; output is bit-identical).

Other backends keep the per-codebook depth path, the reference
rounding and the per-request graph release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@0xShug0

0xShug0 commented Oct 25, 2026

Copy link
Copy Markdown
Owner

@Fishballllllll Thanks for the PR! I'm actually backporting optimizations right now: VoxCPM2 (#1047), Qwen3-TTS (#1055), YuE2 (#1056), Drama3 (#1068), ACE-Step (#1073), MiniMax Music3 (#1077).

Please follow the same patterns and add an option breeze_tts.fused_depth, default false. You can also add perf_mode with a cuda_perf preset that enables fused_depth in this PR. In my tests, the tradeoff includes both recognition accuracy and peak VRAM usage (>12%), so I'd keep it disabled by default.

Changing defaults can unexpectedly break configurations that currently work. Main's defaults are at least compatibility baseline. I've changed optimization defaults before and quickly received regression reports (#1016 #1071).

Fishballllllll and others added 4 commits October 25, 2026 19:04
Add breeze_tts.perf_mode=off|cuda_perf with breeze_tts.fused_depth and
breeze_tts.keep_decode_graphs, following the fish_drama3 and minimax_music3
controls. Both default to off, so CUDA keeps the per-codebook depth path and
releases decode graphs after each request unless the preset or an explicit
option enables them.

The fused graph now uses flash attention for the codebooks that attend over
its in-graph K/V, like the per-codebook path's cached decode, and embeds from
the device token embedding when it holds the f32 table's exact values instead
of uploading a second f32 copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Feed the fused graph the per-codebook sampler's own torch exponential draws,
indexed by token, so cuda_perf picks the same tokens as the per-codebook path
for the same seed. Skip the per-codebook depth runtimes when the fused graph
replaces them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Drop keep_decode_graphs. Keeping the backbone and depth graphs resident
between requests held the largest request's buffers and raised peak VRAM
by about 7% for at most a few percent of speed, so cuda_perf now enables
only the fused depth graph and frees the graphs like main does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Offline direct prefill and skipping the identity bf16 re-rounding of the
F16 KV cache were always on. They do not change output, but they change
the default execution path, so make them opt-in like fused_depth:
breeze_tts.direct_prefill and breeze_tts.skip_cache_round, both false by
default and enabled by perf_mode=cuda_perf, CUDA only. With them off,
offline prefill goes through the same export/import path as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Fishballllllll Fishballllllll changed the title BreezeTTS: decode each depth frame in one CUDA graph BreezeTTS: opt-in fused depth graph for CUDA (perf_mode=cuda_perf) Oct 26, 2026
@Fishballllllll

Copy link
Copy Markdown
Contributor Author

@0xShug0 Thanks for testing it and for pointing me to the other cuda_perf PRs. I've updated this PR to follow the same pattern:

  • breeze_tts.perf_mode=off|cuda_perf, default off. Everything this PR adds is now behind it: fused_depth, plus direct_prefill and skip_cache_round for the two smaller changes that used to be always on. With off, BreezeTTS runs exactly the code path it runs on main (byte-identical output, same speed), on every backend.
  • The two trade-offs you saw were real, and I reproduced both once I measured at the default guidance_scale 1 with fine-grained VRAM sampling:
    • Accuracy: the fused graph used eager attention for the later codebooks and its own Gumbel noise. It now uses the same flash attention and the same torch-CUDA random draws as the per-codebook path, so cuda_perf is byte-identical to main for the same seed (Q8_0 and BF16, CFG 1 and 4).
    • Peak VRAM: the original revision was +13.5% on my longest requests. Resident decode graphs (removed) and a duplicate embedding table plus unused per-codebook runtimes (removed) accounted for it. Now +0.4% on the same requests, and at or below main on the 10-request set.
  • Speed with cuda_perf on my RTX 5090 Laptop: 0.802 → 0.442 RTF at CFG 4 (1.82x), 0.577 → 0.340 at CFG 1 (1.70x).

The PR description has the full numbers and how I measured them. To try it: --session-option breeze_tts.perf_mode=cuda_perf.

If it's useful, I can also mention cuda_perf in the BreezeTTS Quick Start, but I'm just as happy to leave that until it has been tried on other GPUs.

@0xShug0

0xShug0 commented Oct 26, 2026

Copy link
Copy Markdown
Owner

@Fishballllllll I've decided to include all three of your PRs in the next release, so if any issues come up, I'll just fix them directly.

@0xShug0
0xShug0 merged commit 062c770 into 0xShug0:main Oct 26, 2026
@0xShug0

0xShug0 commented Oct 26, 2026

Copy link
Copy Markdown
Owner

@Fishballllllll Thanks! PR merged. The updated PR works great! Since the parity and VRAM concerns have been addressed, I’ll run more WER checks and may enable it by default before the release.

@Fishballllllll

Copy link
Copy Markdown
Contributor Author

@0xShug0 Thanks so much for merging it and for considering it as a default! And thanks for building audio.cpp and for the careful review. Working on this PR taught me a lot. I’m just getting started with open source and GitHub, so this was a really valuable first experience.

If your WER checks or any other testing turn up a problem, just ping me and I’m happy to fix it. Beyond these PRs, I’d also love to help with other work on the project to keep learning, so if there’s anything you’d like a hand with, feel free to point me to it.

I also have a few more ideas for BreezeTTS that might make cuda_perf another 20–30% faster (a rough, untested estimate). If you think it’s worth it, I can work on them over the next few days and send a follow-up PR, in time for this release if it hasn’t gone out yet, or for the next one otherwise.

I’m also rebasing #1091 and #1092 onto the latest main to resolve the conflicts, and will push the updated branches shortly.

@0xShug0

0xShug0 commented Oct 26, 2026

Copy link
Copy Markdown
Owner

@Fishballllllll You've done a great job, especially for someone just getting started with open source. Really glad to have you contributing to audio.cpp!

The BreezeTTS optimization ideas sound interesting. Looking forward to the follow-up PR! No rush for the next release, which is planned for tomorrow... Depending on the scope of the changes, we can work toward a more systematic way to integrate optimizations that aren’t model-local. For example, we introduced exec_plan in the last release to organize backend- and hardware-specific execution paths. #922. It can help if you want to bypass GGML and implement a component directly in CUDA .cu.

@Fishballllllll

Copy link
Copy Markdown
Contributor Author

@0xShug0 Thank you, that really means a lot! I’ll take a look at #922 and see how the follow-up could fit into it.

I’m a bit busy with school right now, so the next PR might take a little while, but I’ll work on it when I can and keep you posted.

Thanks again!

cunba-ai pushed a commit to cunba-ai/audio.cpp that referenced this pull request Oct 28, 2026
…d staging) + Earshot VAD + BreezeTTS CUDA opts + Echo-TTS CFG + /health version

Upstream commits 899d84a..71f42b8 (22):
- fix HIP VMM access updates for growing pools (0xShug0#1059)
- gguf: numeric array metadata in one bulk fread (0xShug0#1072)
- Echo-TTS optional Joint-CFG (0xShug0#773); Sway time scheduling later
- validated HIP VMM defaults in Arch AMD installer (0xShug0#1080)
- HIP argsort extended to large rows (0xShug0#1076)
- Irodori-TTS MeanFlow support (0xShug0#1083)
- assets: direct uploads preserved in weight-norm tensor views (0xShug0#1081)
- HIP device weight uploads staged through bounded host buffer (0xShug0#1070)
- empty/null speech voice values treated as omitted (0xShug0#1086)
- redundant VoxCPM2 KV cache staging/reset avoided (0xShug0#1085)
- Drama3 device sampling default off (0xShug0#1087)
- Studio LoRA controls + shared adapter groups (0xShug0#1088)
- Arena styling alignment (0xShug0#1089)
- Earshot VAD native C++ port (0xShug0#1095)
- ASR VAD chunking loads any built-in VAD family (0xShug0#1096)
- /health reports build version + git commit (0xShug0#1097)
- BreezeTTS opt-in fused depth graph CUDA (0xShug0#1090); bf16 rounding fusion in rms_norm/add/SwiGLU (0xShug0#1091); batch-2 CFG decode (0xShug0#1092); option names normalized
- ci: best-effort HIP test release binaries (0xShug0#1100)
- Echo-TTS Sway time scheduling (0xShug0#1105)
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