TTSKit: decode-path performance improvements for Qwen3 streaming#504
TTSKit: decode-path performance improvements for Qwen3 streaming#504mjfrey wants to merge 5 commits into
Conversation
For stateful models the MLState buffers already received this step's key/value updates during prediction, but the decode path still materialized the update tensors into the host-side cache arrays on every step. Only the cache position and attention masks need to advance. Adds KVCache.update() for that and a unit test covering the position/mask bookkeeping.
The key and value MLTensor outputs are independent, but the async update paths materialized them sequentially — two round trips to the compute unit per decode step. Use async let so both toMLMultiArray() calls run concurrently in update(keyTensor:valueTensor:), updateStateCache, and updateWithHiddenContext.
The initializer converted the NSString through Array(String), which walks Swift extended grapheme clusters and re-expands them to UTF-16. This is needlessly expensive while loading large tokenizer vocabularies, and can lose the exact NSString representation the type exists to preserve. Copy the underlying UTF-16 code units directly with getCharacters(_:range:).
GreedyTokenSampler computed softmax probabilities over the entire vocabulary and then selected the top k. Selecting the top-k logits first and renormalizing is mathematically equivalent but avoids materializing full-vocabulary probabilities on the host every step, which profiling showed as a measurable per-step cost during streaming TTS generation.
|
Thanks for the submission @mjfrey! Were you able to quantify the optimizations in terms of speed up or memory usage? |
There was a problem hiding this comment.
Pull request overview
This PR optimizes several hot-path operations in Qwen3 streaming TTS decoding/sampling to reduce per-step overhead (cache updates, tensor materialization, tokenizer string handling, and sampling).
Changes:
- Avoids unnecessary host-side work for stateful decode steps by only advancing KV cache position/masks.
- Materializes independent key/value tensors concurrently via
async letto reduce serialized latency. - Improves sampling performance by selecting top‑k logits before softmax and computing the (renormalized) distribution on the reduced set.
- Speeds up tokenizer vocabulary loading by copying UTF‑16 code units directly from
NSString. - Adds a unit test covering stateful KV cache position/mask advancement.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| Tests/TTSKitTests/TTSKitUnitTests.swift | Adds a unit test for stateful KVCache.update() mask/position advancement. |
| Sources/TTSKit/Utilities/Sampling.swift | Adds a top‑k-before-softmax sampling fast path for multi-head tensor sampling. |
| Sources/TTSKit/Utilities/KVCache.swift | Runs key/value tensor materialization concurrently in several update paths. |
| Sources/TTSKit/Qwen3TTS/Qwen3CodeDecoder.swift | Skips tensor materialization for stateful cache updates (advances only position/masks). |
| Sources/ArgmaxCore/External/Hub/BinaryDistinct.swift | Optimizes BinaryDistinctString.init(NSString) by directly copying UTF‑16 code units. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
@ZachNagengast Yes — A/B on an Apple silicon Mac (CLI, v1.0.0 vs v1.0.0 + these changes only, same seed/text/models, W8A16 0.6B, temperature 0.7 / top-k 20, 3 runs each):
Throughput and memory are unchanged on the Mac because generation there is ANE-bound — these are host-CPU savings, aimed at phone-class devices where the decode loop shares CPU and memory bandwidth with the host app. The original motivation was an Instruments capture on an A19 iPhone during streaming synthesis, which attributed ~16% of captured cycles to the sampling path. |
- Guard the stateful cache fast-path on cache.isStateful so a mismatched external-cache KVCache still receives its key/value writes. - Fall back to the top-scoring index when the top-k weight sum is zero or non-finite instead of trapping in Float.random. - Bind test mask buffers with the arrays' actual counts rather than a hard-coded capacity.
Four small optimizations to the Qwen3 TTS decode hot path, found while profiling on-device streaming synthesis in a production app (A19 iPhone, 12Hz 0.6B models):
KVCache.update()for that, with a unit test covering the position/mask bookkeeping.async letruns bothtoMLMultiArray()calls concurrently inupdate(keyTensor:valueTensor:),updateStateCache, andupdateWithHiddenContext.BinaryDistinctString.init(NSString)copies UTF-16 code units directly. Converting throughArray(String)walks Swift extended grapheme clusters and re-expands them to UTF-16 — needlessly expensive while loading large tokenizer vocabularies, and it can lose the exact NSString representation the type exists to preserve.getCharacters(_:range:)copies the code units as-is.Measurements
A/B on an Apple silicon Mac (CLI, v1.0.0 vs v1.0.0 + these changes only, same seed/text/models, W8A16 0.6B, temperature 0.7 / top-k 20, 3 runs each):
End-to-end throughput is unchanged on the Mac because generation there is ANE-bound; the host-side savings are aimed at phone-class devices, where the decode loop shares CPU and memory bandwidth with the host app. The original motivation was an Instruments capture on an A19 iPhone during streaming synthesis, which attributed ~16% of captured cycles to the sampling path that these changes shrink.
All changes are behavior-preserving.
swift buildand the TTSKit unit tests (86) pass; the changes have been validated in continuous day-to-day on-device streaming use.