feat: batch a request's chunks through turbo in one forward pass (~2x) - #161
feat: batch a request's chunks through turbo in one forward pass (~2x)#161malammar wants to merge 4 commits into
Conversation
_conds_cache is a plain dict keyed by (path, mtime, exaggeration) and cleared only by unload_model(), so every distinct reference voice pins GPU tensors for the lifetime of the process. Each entry costs ~175 MB of VRAM. Measured on a P106-100 (5.93 GiB) with chatterbox-turbo, three 10-minute soaks cycling 28 reference voices at ~45 req/min: cache size requests failed headroom unbounded 453 406 0.11 GiB 2 89 1 0.10-0.49 GiB 0 88 0 0.28-0.40 GiB Live tensors climbed 4.09 -> 5.46 GiB and stayed there; the card then failed 2 MiB allocations while holding ~260 MB reserved-but-free. The same load with a single voice ran clean, which isolates the cache rather than allocator fragmentation. Larger cards are not immune, only slower to get there: /upload_reference stores each uploaded file under its own path, so every distinct voice a deployment ever serves adds a permanent entry. Adds an LRU bound via CONDS_CACHE_MAX (default 4, ~700 MB). A cache hit now refreshes recency, so a rotating workload evicts least-recently-used rather than by insertion order. Set 0 to disable caching entirely — a miss only re-encodes the reference audio, measured at +0.14s per request, a fixed cost that is ~13% on an 84-character request and within noise by 240 characters. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
synthesize() runs concurrently — server.py dispatches it through loop.run_in_executor() — so several threads touch _conds_cache at once. The cache read was two non-atomic steps (membership check, then index) and the write three (insert, refresh, evict), so a thread could evict a key between another thread's check and its use and raise KeyError. The unbounded version could not hit this: nothing was ever removed, so a key that existed stayed. Adding eviction introduced the failure mode, which makes this a regression in the LRU commit rather than a pre-existing bug. Serializes cache access behind a lock and replaces the check-then-index read with a single locked lookup that also refreshes recency. Verified with 8 threads x 4000 operations over 12 voices at cap 4 (constant eviction): 0 errors, bound held. Also stops a bad CONDS_CACHE_MAX from breaking module import — int() on garbage raised ValueError at import time, which would have failed the server at startup on a typo. Now warns and falls back to the default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/tts already splits long text into chunks that all share one voice, then
synthesized them one at a time. Turbo's decode loop (T3.inference_turbo) is
written with batch dimensions throughout and uses HuggingFace logits processors,
which are batch-correct, so those chunks can go through a single forward pass.
Measured on a P106-100 with chatterbox-turbo, four chunks, both paths warm:
sequential 5.12s -> batched 2.62s = 1.96x
T3 decode alone scales further, and costs almost no extra VRAM because the
weights dominate and only the KV cache grows:
batch T3 time s/item speedup peak VRAM
1 0.98s 0.976 1.00x 4.50 GiB
2 1.15s 0.575 1.70x 4.62 GiB
4 1.75s 0.438 2.23x 4.84 GiB
8 2.76s 0.346 2.82x 5.29 GiB
TTS_BATCH_SIZE caps the batch (default 4; batch 8 leaves only ~0.6 GiB on a 6 GB
card). synthesize_batch() returns (None, None) rather than erroring whenever
batching would change behaviour, and /tts falls back to the per-chunk path:
- model is not turbo (only inference_turbo accepts a batch)
- a seed was requested: sequential chunks each re-seed from the same value
while a batch draws all rows from one generator, so seeded output would stop
being reproducible
- a single chunk, or batching disabled
Two details that are easy to get wrong:
inference_turbo only breaks once EVERY row emits stop on the same step, so rows
that finish early keep sampling. Those trailing tokens fall below the OOV limit
and would survive turbo's own `tokens < 6561` filter as audible gibberish, so
each row is truncated at its first stop token before filtering.
The first batched call on a cold process costs ~10s while CUDA loads kernels for
an unseen batch dimension (13.5s first call vs 2.8s steady state). The cost is
one-time rather than per-shape — after warming at batch 2, fresh batch sizes of 3
and 4 ran at full speed — so warmup_batch() pays it during startup instead of
letting the first real request absorb it.
s3gen still runs per row: it accepted a batch in testing but only with
equal-length token sequences, and real chunks differ in length. T3 decode
dominates the cost anyway.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review of PR devnen#161 found two defects before merge: 1. _resolve_conds() set chatterbox_model.conds and let the caller read it back via a second statement. That attribute is shared process-wide, so a concurrent request could reassign it between the two statements and the batch would generate in the wrong voice. Now returns the Conditionals object directly instead of round-tripping through the shared attribute. 2. synthesize_batch() could hand back a wavs list shorter than the input list (defensive case, not observed) and server.py indexed it unconditionally — an IndexError/500 instead of the intended fallback to per-chunk synthesis. Now refuses (returns None, None) on a length mismatch, and server.py's index is bounds-checked as a second line of defense. Re-verified on chattybox after the fix: 4-chunk batch still correct and 1.88x faster than sequential with both paths warm (4.99s -> 2.65s), all distinctness and duration-sanity checks pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Self-reviewed and pushed a follow-up commit before this is ready to look at:
Re-verified on real hardware (P106-100) after the fix — 4-chunk batch still correct (rows distinct, durations sane) and 1.88x faster than sequential with both paths warm (4.99s -> 2.65s). |
Summary
/ttsalready splits long text into chunks that all share a single voice, then synthesizesthem one at a time.
T3.inference_turbois written with batch dimensions throughout anduses HuggingFace logits processors, which are batch-correct — so those chunks can go
through one forward pass instead of N.
No queueing, no cross-request scheduling, no added latency: the chunks already exist and
already share conditioning by the time the loop starts.
Measured
P106-100,
chatterbox-turbo, four chunks, both paths warmed before timing:T3 decode alone scales further, at almost no VRAM cost — the weights dominate and only the
KV cache grows:
TTS_BATCH_SIZEcaps it, default 4. Batch 8 leaves only ~0.6 GiB free on a 6 GB card, andthose measurements used short test strings — real chunks build longer KV caches.
Falls back rather than changing behaviour
synthesize_batch()returns(None, None)and/ttsruns the existing per-chunk path when:inference_turbotakes a batch;batch draws every row from one generator, so seeded output would stop being reproducible.
Reproducibility beats speed here;
Any exception inside the batched path also falls back, so a failure degrades to today's
behaviour instead of surfacing an error.
Two details worth reviewing closely
Rows that finish early keep sampling.
inference_turbobreaks only when every rowemits stop on the same step, so a short row continues generating after its own stop. Those
trailing tokens fall below the OOV limit and would survive turbo's own
tokens < 6561filter as audible gibberish. Each row is therefore truncated at its first stop token
before filtering.
The first batched call is slow. On a cold process it costs ~10s while CUDA loads kernels
for a batch dimension the model hasn't seen — 13.5s first call vs 2.8s steady state. The
cost is one-time, not per-shape: after warming at batch 2, fresh batch sizes of 3 and 4
ran at full speed.
warmup_batch()pays it during startup so the first real request doesn't.Worth flagging how I found that: my first benchmark compared a cold batched call against a
warm sequential one and reported a 2.6x slowdown. The implementation was fine; the
measurement was not.
Scope
s3genstill runs per row. It accepted a(2,T)batch in testing, but only withequal-length token sequences — real chunks differ in length, so batching that stage needs
padding plus masking. T3 decode dominates the cost, so this was left out deliberately.
Only same-voice batching is implemented. Batching different voices needs per-item
t3_cond, which is a larger change.Testing
durations and peaks, and rows confirmed distinct (not a duplicated row).
gibberish.