Skip to content

fix(models): give back all the memory an idle unload claims to free - #2677

Closed
shivanshtalwar0 wants to merge 2 commits into
debpalash:mainfrom
januscaler:fix/idle-unload-leaks
Closed

shivanshtalwar0 wants to merge 2 commits into
debpalash:mainfrom
januscaler:fix/idle-unload-leaks

Conversation

@shivanshtalwar0

Copy link
Copy Markdown

Summary

After the idle timeout, GET /model/status reports {"status":"idle","loaded":false} while the backend still holds the voice model's VRAM (~3 GB seen on an RTX 5090 Docker host, image :stable). unload_shared_model() clears model_manager.model, but other holders keep the memory: the cached engine adapters, torch.compile / CUDA-graph state, FlashInfer's module context, the wav2vec2 aligners, and the pyannote pipeline.

Changes

  • Cached adapters no longer own the shared model. OmniVoiceBackend cached in tts_backend._active_instance (/v1/audio/speech) or _ENGINE_INSTANCES (engine self-test, explicit-engine /generate, worker assignments) stored the model on its first generate and kept it. On a normal server nothing sweeps _ENGINE_INSTANCES. The adapter now runs on whatever model_manager holds, via a new acquire_resident_model(). Explicit-model per-call views keep their model as before. This covers every place an adapter is cached, including stream sockets and retired engines, without sweeping each one.
  • The warm adapter path touches the idle clock. It skipped get_model(), so steady /v1/audio/speech traffic looked idle. idle_worker unloaded the model under it, the adapter kept generating on the orphan, and the next native generate loaded a second copy.
  • Compiled state is reset on unload, on the thread that captured it. torch._dynamo.reset() releases the code caches and the reduce-overhead CUDA-graph pools. Inductor's cudagraph trees are thread-local: on torch 2.8, reset_cudagraph_trees() from any other thread raises AssertionError and leaves the cached backends in place. So the reset runs on the compiled-inference thread ([Bug] Voice clone: first render is perfect, second render onward has static noise and slow playback (Windows) #315), waits behind any render still on it, and is bounded at 5 s because idle_worker unloads on the event loop. An eager model doesn't reset Dynamo, so other engines keep their compile caches.
  • FlashInfer's module _CTX, which holds the last attention workspace, is cleared on that same thread on unload.
  • Aligners: WhisperXBackend.unload() cleared a per-instance _align_cache that nothing ever filled, while the real cache, asr_backend._ALIGN_CACHE, kept every loaded aligner for the life of the process. Unload (WhisperX and MLX Whisper) and idle_worker now release the loaded aligners. "No aligner for this language" entries are kept so the next run doesn't probe again.
  • Diarization: the pyannote pipeline gets an idle clock and an idle release in idle_worker.
  • Docs: the idle-release lists in docs/performance.md and docs/remote-workers.md and the aligner notes in the WhisperX / MLX Whisper engine pages now cover these releases.

Type

  • 🐛 Bug fix
  • ✨ New feature
  • ♻️ Refactor
  • 📝 Documentation
  • 🧪 Tests
  • 🔧 CI / Build
  • 🚀 Release prep

Testing

  • tests/test_idle_unload_releases_memory.py: 24 tests. 21 fail on main and pass here. The other 3 guard unchanged behaviour (cold load through the manager, explicit-model views, eager unload leaves Dynamo alone).
  • Checked against real torch 2.8 (CPU): a caller-thread torch._dynamo.reset() asserts out and keeps the cached Inductor backends, while unload_shared_model() resets and flushes on compiled-infer with zero backends left. Not yet re-measured on a CUDA host.
  • pytest backend/tests/: 472 passed.
  • pytest tests/: 10,131 passed. The 18 failures on the macOS dev machine fail identically on main. 14 come from the MPS OmniVoice sidecar and pass with OMNIVOICE_DEVICE=cpu, which matches the Linux CI routing.
  • scripts/check_commit_identities.py: clean.

Checklist

  • I've tested this locally
  • Every commit author has signed the CLA (the CLA check tells you how)
  • I've updated relevant documentation (if applicable)
  • No local machine paths, logs, or personal env details in this PR
  • Maintained version files are in sync (if an owner-requested bump): root package.json, pyproject.toml, backend/core/version.py, and lockfiles
  • If this PR changes runtime behavior, the regression fixture at tests/fixtures/omnivoice_data/ still loads green on the smoke-matrix CI job (macOS + Windows + Linux)

/model/status reported idle while the backend still held ~3 GB of VRAM.
unload_shared_model() cleared model_manager.model, but other holders kept
the memory:

- Cached OmniVoiceBackend adapters (_active_instance for /v1/audio/speech,
  _ENGINE_INSTANCES for self-test, explicit-engine /generate and worker
  assignments) stored the model on first generate. They now run on
  whatever model_manager holds, through acquire_resident_model(), which
  also touches the idle clock the cached path used to skip.
- torch.compile's caches and reduce-overhead CUDA-graph pools are reset
  on unload, on the compiled-inference thread that captured them
  (cudagraph trees are thread-local; a reset from another thread asserts).
  FlashInfer's module context is cleared there too.
- wav2vec2 aligners in _ALIGN_CACHE are released by WhisperX/MLX unload
  and on idle; WhisperX was clearing a per-instance dict nothing filled.
- The pyannote pipeline is released on idle.
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Thank you for contributing to VoiceStudio. Before this pull request can merge, everyone who contributed to it must sign the Contributor License Agreement 1.0 once. You keep your copyright; the agreement lets Yupcha Softwares Private Limited, the company that maintains VoiceStudio, ship your work in both the AGPL-3.0 app and commercial builds.

Still to sign: @shivanshtalwar0, @shivanshtalwar00

To sign, post this as a new comment on its own line:

I have read the VoiceStudio CLA 1.0 and I hereby sign it.

Comment recheck to run the check again.

@coderabbitai

coderabbitai Bot commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Review in Change Stack →

✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@shivanshtalwar0

Copy link
Copy Markdown
Author

Closing: opened against the wrong repository by mistake.

@greptile-apps

greptile-apps Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Retrigger

[High risk] Fixes memory leaks in model unload and idle release paths.

Prevent idle cleanup from dropping busy aligners and diarization pipelines before merging.

Fix All in Claude CodeFindings

  1. P1 Busy models get loaded twice ▶
  2. P2 FlashInfer memory stays held ▶
Summary

Changes shared-model ownership and adds idle cleanup for compiled inference state, FlashInfer, aligners, and diarization.

  • Busy aligners and diarization pipelines can be evicted from their caches, allowing overlapping jobs to load duplicate copies.
  • FlashInfer cleanup misses state left after fallback or RAM offload.

Reviews (1) · Last reviewed commit: "docs(changelog): note the idle-unload me..." · Reviewed by Greptile

Comment on lines +4595 to +4599
now = time.monotonic() if now is None else now
if _diar_pipeline is None or now - _diar_last_used < idle_s:
return False
logger.info("Idle timeout reached. Releasing the speaker diarization pipeline.")
return unload_diarization_pipeline()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Busy models get loaded twice

release_idle_diarization_pipeline() drops the cached pipeline while a long dub is still using it, and release_idle_align_models() does the same during long alignment. If another transcription starts after the configured timeout, it loads a second copy while the first job retains the original, increasing memory use and risking an out-of-memory failure. Track active users of both caches and start their idle clocks when the last user finishes.

Fix in Claude Code

if model is None:
return False
compiled = getattr(getattr(model, "llm", None), "_orig_mod", None) is not None
flashinfer = getattr(model, "_fi_graph_cache", None) is not None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 FlashInfer memory stays held

Checking only _fi_graph_cache skips FlashInfer cleanup after a runtime fallback or RAM offload, because _unapply_flashinfer() removes that attribute without clearing _CTX. The module can still hold the last attention wrapper and GPU tensors, so the new unload path leaves that memory behind. Track outstanding FlashInfer state separately from the current patch, and clear it on the inference thread before flushing.

Fix in Claude Code

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