Skip to content

feat: batch a request's chunks through turbo in one forward pass (~2x) - #161

Open
malammar wants to merge 4 commits into
devnen:mainfrom
malammar:upstream-pr/chunk-batching
Open

feat: batch a request's chunks through turbo in one forward pass (~2x)#161
malammar wants to merge 4 commits into
devnen:mainfrom
malammar:upstream-pr/chunk-batching

Conversation

@malammar

Copy link
Copy Markdown

Builds on #160. The first two commits are that PR — synthesize_batch() reuses the
bounded conds cache it introduces. Review the third commit for this change, or merge #160
first and I'll rebase.

Summary

/tts already splits long text into chunks that all share a single voice, then synthesizes
them one at a time. T3.inference_turbo is written with batch dimensions throughout and
uses 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:

sequential 5.12s  ->  batched 2.62s  =  1.96x

T3 decode alone scales further, at almost no VRAM cost — 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 it, default 4. Batch 8 leaves only ~0.6 GiB free on a 6 GB card, and
those measurements used short test strings — real chunks build longer KV caches.

Falls back rather than changing behaviour

synthesize_batch() returns (None, None) and /tts runs the existing per-chunk path when:

  • the model is not turbo — only inference_turbo takes a batch;
  • a seed was requested — sequential chunks each re-seed from the same value, while a
    batch draws every row from one generator, so seeded output would stop being reproducible.
    Reproducibility beats speed here;
  • there is one chunk, or batching is disabled.

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_turbo breaks only when every row
emits 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 < 6561
filter 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

s3gen still runs per row. It accepted a (2,T) batch in testing, but only with
equal-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

  • Gating verified: single chunk, seeded request, and non-turbo all decline and fall back.
  • Output verified against the sequential path on identical input: one wav per chunk, sane
    durations and peaks, and rows confirmed distinct (not a duplicated row).
  • Per-row stop truncation verified — without it, rows that finish early produce trailing
    gibberish.
  • Timing measured with both paths warm.

malammar and others added 4 commits July 24, 2026 23:35
_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>
@malammar

Copy link
Copy Markdown
Author

Self-reviewed and pushed a follow-up commit before this is ready to look at:

  1. Conds race: _resolve_conds() was setting chatterbox_model.conds and letting 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. Unguarded index: synthesize_batch() could in principle hand back a wavs list shorter than the input list (not observed, but not prevented either), and server.py indexed it unconditionally — an IndexError/500 instead of the intended fallback to per-chunk synthesis. Now refuses (None, None) on a length mismatch, with a bounds-checked index in server.py as a second line of defense.

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).

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.

1 participant