fix: bound the voice conditioning cache to stop it exhausting VRAM - #160
Open
malammar wants to merge 2 commits into
Open
fix: bound the voice conditioning cache to stop it exhausting VRAM#160malammar wants to merge 2 commits into
malammar wants to merge 2 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
_conds_cacheinengine.pyis unbounded. It is keyed by(path, mtime, exaggeration)and cleared only byunload_model(), so every distinct reference voice a server ever handles pins GPU tensors for the lifetime of the process.Each entry costs ~175 MB of VRAM. On a 6 GB card that is enough to take the server from healthy to failing every request within an hour of ordinary traffic.
This adds an LRU bound (
CONDS_CACHE_MAX, default 4) and refreshes recency on cache hits so a rotating voice workload evicts least-recently-used rather than by insertion order.How it fails today
Observed on a live deployment: a worker served correctly for ~45 minutes, then began returning 500 for every generation with
CUDA out of memory: Tried to allocate 2.00 MiB, while/api/model-infostill reported{"loaded": true}and/docsstill returned 200.The tell is the size of the failing allocation. Failing to obtain 2 MiB while holding ~260 MB reserved-but-free is not an oversized model, it is a cache that has eaten the working headroom.
Measurements
P106-100 (5.93 GiB usable),
chatterbox-turbo, three 10-minute soaks cycling 28 reference voices at ~45 req/min with text lengths varying 40–520 chars:CONDS_CACHE_MAXModel footprint on a fresh load is 4.09 GiB, so the +1.37 GiB of retained tensors is exactly 8 cached voices × ~175 MB.
Two controls confirm the cache is the cause rather than allocator fragmentation:
PYTORCH_CUDA_ALLOC_CONF=expandable_segments:Truealone does not fix it. It helps a separate fragmentation effect (reserved memory grows while live tensors stay flat), but it cannot free tensors the cache still references.Cost of a smaller cache
A miss only re-encodes the reference audio. Measured on an RTX 4070 against a 3.8 MB reference:
It is a fixed per-request cost that does not scale with output length, so it is a small percentage on anything but very short requests. Cheap relative to 175 MB of VRAM per entry.
Choosing the default
4 entries (~700 MB) leaves a 6 GB card ~1.1 GiB for generation after the model. It is deliberately conservative: an oversized cache takes a card out of service entirely, while an undersized one costs ~0.14s on a miss. Deployments with VRAM to spare can raise it — 16 is comfortable on 12 GB — and orchestration layers can size it per device from
torch.cuda.mem_get_info()at launch.Happy to change the default, or gate the whole thing behind an opt-in flag if you would rather not alter behaviour for existing users. I would suggest against leaving it unbounded even so, since the failure is silent and looks like a hung GPU rather than a cache problem.
Testing
CONDS_CACHE_MAX=0verified directly against the eviction logic.