Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
59932cf
Add model auto-unload controls
Aug 17, 2026
543f485
Schedule auto-unload after model load
Aug 17, 2026
ec7a1b4
unload to cpu
Aug 17, 2026
5cb79f5
add timeout time in log message
Aug 17, 2026
e8e4abc
Add model auto-unload controls
Aug 17, 2026
e222136
Schedule auto-unload after model load
Aug 17, 2026
26edc3b
Log model auto-unload timeout
Aug 17, 2026
330de43
simplify readme
Aug 17, 2026
a911923
Merge branch 'auto_unload_on_timeout' of github.com:brycehenson/Kokor…
Aug 17, 2026
eb6d911
reduce readme diff
Aug 17, 2026
e910a76
remove added newline
Aug 17, 2026
d621ff0
handle USE_GPU=true MODEL_UNLOAD_STRATEGY=cpu_cache
Aug 17, 2026
1d3e6ea
Merge branch 'remsky:master' into auto_unload_on_timeout
brycehenson Aug 20, 2026
241f98d
Refine model auto-unload handling
Aug 20, 2026
c999f13
Merge branch 'auto_unload_on_timeout' into cpu_cache_for_unload
Aug 20, 2026
3d9efd7
changelog
Aug 20, 2026
df252a8
Merge branch 'auto_unload_on_timeout' into cpu_cache_for_unload
Aug 20, 2026
9af6396
changelog, remove notes, change option name to "move_to_cpu" (from "c…
Aug 20, 2026
190b522
created benchmark script for MODEL_UNLOAD_STRATEGY. Reduce readme diff
Aug 20, 2026
692bd26
extra space in readme
Aug 20, 2026
0a41a3c
remove initial_load_seconds from benchmark_model_unload_strategies.py
Aug 20, 2026
02ecef7
remove newline
Aug 20, 2026
39ed88b
readme Debug Endpoints, clarify that ALLOW_DEV_UNLOAD=true is needed.…
Aug 20, 2026
ae3920f
Merge branch 'auto_unload_on_timeout' into cpu_cache_for_unload
Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ Notable changes to this project will be documented in this file.
Per-PR attribution and contributor credits are published automatically on the corresponding GitHub release page; this file is the curated, human-readable summary.

## [Unreleased]
### Added
- Optional model auto-unload after an idle timeout (`MODEL_AUTO_UNLOAD_TIMEOUT_SECONDS`, default off) to release VRAM. Reloads on the next request. `/dev/model` reports load/idle state and `POST /dev/reload` pre-warms the model, both behind `ALLOW_DEV_UNLOAD`.
- `MODEL_UNLOAD_STRATEGY=move_to_cpu` moves model weights from GPU to system RAM on unload, so reloads are faster than the default `destroy` strategy, which reloads from disk.

### Fixed
- Native Windows installs (`start-cpu.ps1` etc) no longer need a C++ toolchain: `pyopenjtalk-plus` (a drop-in fork with prebuilt Windows wheels) replaces `pyopenjtalk` on win32 only (#508, proposed by @siliconfps). Needs a recent `uv`. Linux, macOS, and Docker are unchanged.

Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,11 @@ Key Performance Metrics:

Floor is host + CUDA context. Reproduce with `uv run --extra benchmarks assorted_checks/benchmarks/benchmark_model_unload.py` from `examples/`.

`POST /dev/reload` reloads the model and `GET /dev/model` reports model load state and auto-unload settings. Set `ALLOW_DEV_UNLOAD=true` to expose these controls.

To automatically unload the model after an idle timeout, set `MODEL_AUTO_UNLOAD_TIMEOUT_SECONDS` to a positive number of seconds. The default `0` disables auto-unload.
Set `MODEL_UNLOAD_STRATEGY=move_to_cpu` to move model weights from GPU to system RAM on unload for faster reload while still clearing GPU memory. The default `destroy` strategy releases model objects completely and will load from disk.

### Transcription roundtrip (WER/CER)

End-to-end roundtrip: synthesize with Kokoro, transcribe the result back with [`faster-whisper`](https://github.com/SYSTRAN/faster-whisper), compare to the source text. Scripts and data live under `examples/assorted_checks/test_transcription/`.
Expand Down Expand Up @@ -759,7 +764,9 @@ System state and resource usage, for debugging exhaustion or performance issues.
- `/debug/threads` - Get thread information and stack traces
- `/debug/storage` - Disk usage per mounted partition
- `/debug/system` - Get system information (CPU, memory, GPU)
- `/dev/model` - Get model load state and auto-unload timing. Off by default; set `ALLOW_DEV_UNLOAD=true` to enable
- `POST /dev/unload` - Release model from VRAM; reloads lazily on next request. Off by default; set `ALLOW_DEV_UNLOAD=true` to enable
- `POST /dev/reload` - Load the model into VRAM. Off by default; set `ALLOW_DEV_UNLOAD=true` to enable

Stability: the `/v1/*` OpenAI-compatible routes are the stable API. `/dev/*` and `/debug/*` are operational helpers, and may change or move behind flags between minor releases.
</details>
Expand Down
6 changes: 5 additions & 1 deletion api/src/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ class Settings(BaseSettings):
allow_local_voice_saving: bool = (
False # Whether to allow saving combined voices locally
)
allow_dev_unload: bool = False # Whether to expose the POST /dev/unload endpoint
allow_dev_unload: bool = False # Whether to expose /dev/model, POST /dev/unload, and POST /dev/reload
model_auto_unload_timeout_seconds: float = (
0.0 # Idle seconds before unloading; 0 disables auto-unload
)
model_unload_strategy: str = "destroy" # "destroy" or "move_to_cpu"
enable_debug_endpoints: bool = (
False # Whether to expose /debug/* host and process introspection routes
)
Expand Down
62 changes: 57 additions & 5 deletions api/src/inference/kokoro_v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ def __init__(self):
# Strictly respect settings.use_gpu
self._device = settings.get_device()
self._model: Optional[KModel] = None
self._model_cpu_cached = False
self._pipelines: Dict[str, KPipeline] = {} # Store pipelines by lang_code
self._voice_cache: Dict[str, torch.Tensor] = {} # Cache voice tensors by path

Expand Down Expand Up @@ -144,12 +145,53 @@ async def load_model(self, path: str) -> None:
self._model = self._model.cuda()
else:
self._model = self._model.cpu()
self._model_cpu_cached = False

except FileNotFoundError:
raise
except Exception as e:
raise RuntimeError(f"Failed to load Kokoro model: {e}")

def _move_model_to_device(self) -> None:
"""Move a CPU-cached model back to the configured inference device."""
if self._model is None or not self._model_cpu_cached:
return

logger.info(f"Moving CPU-cached Kokoro model back to {self._device}")
if self._device == "mps":
self._model = self._model.to(torch.device("mps"))
elif self._device == "cuda":
self._model = self._model.cuda()
torch.cuda.synchronize()
else:
self._model = self._model.cpu()
self._model_cpu_cached = False
logger.info(f"CPU-cached Kokoro model restored to {self._device}")

def restore_to_device(self) -> None:
"""Restore a CPU-cached model to the configured inference device."""
self._move_model_to_device()

def _clear_runtime_caches(self) -> None:
"""Release cached objects that can hold device tensors."""
for pipeline in self._pipelines.values():
del pipeline
self._pipelines.clear()
self._voice_cache.clear()

def _offload_model_to_cpu(self) -> bool:
"""Move the model out of VRAM while retaining weights in system RAM."""
if self._model is None or self._device not in {"cuda", "mps"}:
return False

logger.info("Moving Kokoro model to CPU cache")
self._model = self._model.cpu()
self._model_cpu_cached = True
self._clear_runtime_caches()
self._clear_memory()
logger.info("Kokoro model offloaded to CPU cache and device caches cleared")
return True

def _get_pipeline(self, lang_code: str) -> KPipeline:
"""Get or create pipeline for language code.

Expand Down Expand Up @@ -197,6 +239,7 @@ async def generate_from_tokens(
raise RuntimeError("Model not loaded")

try:
self._move_model_to_device()
# Memory management for GPU
if self._device == "cuda":
if self._check_memory():
Expand Down Expand Up @@ -295,6 +338,7 @@ async def generate(
if not self.is_loaded:
raise RuntimeError("Model not loaded")
try:
self._move_model_to_device()
# Memory management for GPU
if self._device == "cuda":
if self._check_memory():
Expand Down Expand Up @@ -455,18 +499,26 @@ def _clear_memory(self) -> None:
if hasattr(torch.mps, "empty_cache"):
torch.mps.empty_cache()

def unload(self) -> None:
def unload(self, strategy: str = "destroy") -> None:
"""Unload model and free resources."""
if strategy == "move_to_cpu" and self._offload_model_to_cpu():
return

logger.info("Destroying Kokoro model backend state")
if self._model is not None:
del self._model
self._model = None
for pipeline in self._pipelines.values():
del pipeline
self._pipelines.clear()
self._voice_cache.clear()
self._model_cpu_cached = False
self._clear_runtime_caches()
if torch.cuda.is_available():
torch.cuda.empty_cache()
torch.cuda.synchronize()
logger.info("Kokoro model backend state destroyed")

@property
def is_cpu_cached(self) -> bool:
"""Check if model weights are retained in CPU RAM after device unload."""
return self._model_cpu_cached

@property
def is_loaded(self) -> bool:
Expand Down
Loading