unsloth/studio/backend/utils/model_ttl_settings.py
Daniel Han 80c15643cf studio: add a configurable idle TTL that auto-unloads an unused model
A loaded GGUF model stayed resident until a manual unload or process exit, with
no way to set an idle timeout. Add an idle TTL (seconds): when set, a background
task started by the app lifespan unloads the model after it has been idle that
long. 0 disables eviction, preserving the historical behavior.

- utils/model_ttl_settings.py: the setting (app_settings key), the
  UNSLOTH_MODEL_IDLE_TTL env default, and validation (0 to one week).
- LlamaCppBackend tracks last-activity: refreshed on load, on every generation
  request entry, and on every streamed chunk, so a long generation (e.g. a
  minutes-long reasoning stream) is never evicted mid-flight; idle_seconds is
  None when nothing is loaded.
- main.py runs the eviction loop in the lifespan and cancels it on shutdown.
- GET/PUT /api/settings/model-ttl to read/set the TTL at runtime; the response
  also reports the loaded model's current idle and time-to-eviction.

The request-path activity ping goes through a small _note_idle_activity() guard
so a backend that does not implement the hook degrades gracefully instead of
turning a served request into a 500.

Eviction is made race-safe. unload_model sets _cancel_event, which an in-flight
load of a different model watches, so a naive evict could abort that load. The
evictor now goes through LlamaCppBackend.evict_if_idle, which re-checks idle and
in-flight under _serial_load_lock (the lock load_model holds for its whole
duration) and unloads atomically, so it can never run concurrently with a load
or unload. An in-flight request counter (request_in_flight, applied to the chat
and tool generators) additionally blocks eviction while a request is active,
covering the window before the first streamed token when per-chunk activity has
not started refreshing yet.
2026-06-22 15:39:13 +00:00

71 lines
2.5 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Idle TTL for a loaded inference model.
A loaded GGUF model otherwise stays resident until a manual unload or process
exit. When the idle TTL is set (> 0), a background task unloads the model after
it has been idle (no generation activity) for that many seconds. 0 disables
eviction, preserving the historical behavior. The value can be set at startup
via the ``UNSLOTH_MODEL_IDLE_TTL`` env var or at runtime via
``PUT /api/settings/model-ttl``.
"""
from __future__ import annotations
import os
from typing import Any
MODEL_IDLE_TTL_SETTING_KEY = "model_idle_ttl_seconds"
DEFAULT_MODEL_IDLE_TTL_SECONDS = 0 # 0 = disabled (never auto-evict)
MIN_MODEL_IDLE_TTL_SECONDS = 0
MAX_MODEL_IDLE_TTL_SECONDS = 7 * 24 * 60 * 60 # one week ceiling
MODEL_IDLE_EVICTION_POLL_SECONDS = 30.0
_ENV_VAR = "UNSLOTH_MODEL_IDLE_TTL"
def _coerce_ttl_seconds(value: Any) -> int | None:
if isinstance(value, bool):
return None
try:
parsed = int(value)
except (TypeError, ValueError):
return None
if parsed < MIN_MODEL_IDLE_TTL_SECONDS or parsed > MAX_MODEL_IDLE_TTL_SECONDS:
return None
return parsed
def default_model_idle_ttl_seconds() -> int:
"""The startup default: the env override when valid, else disabled (0)."""
env_value = _coerce_ttl_seconds(os.environ.get(_ENV_VAR))
return env_value if env_value is not None else DEFAULT_MODEL_IDLE_TTL_SECONDS
def validate_model_idle_ttl_seconds(value: Any) -> int:
parsed = _coerce_ttl_seconds(value)
if parsed is None:
raise ValueError(
"Model idle TTL must be a whole number of seconds from "
f"{MIN_MODEL_IDLE_TTL_SECONDS} to {MAX_MODEL_IDLE_TTL_SECONDS} (0 disables it)."
)
return parsed
def get_model_idle_ttl_seconds() -> int:
"""Effective TTL: the stored setting when valid, else the startup default."""
try:
from storage.studio_db import get_app_setting
stored = get_app_setting(MODEL_IDLE_TTL_SETTING_KEY, None)
except Exception:
stored = None
parsed = _coerce_ttl_seconds(stored)
return parsed if parsed is not None else default_model_idle_ttl_seconds()
def set_model_idle_ttl_seconds(value: Any) -> int:
parsed = validate_model_idle_ttl_seconds(value)
from storage.studio_db import upsert_app_settings
upsert_app_settings({MODEL_IDLE_TTL_SETTING_KEY: parsed})
return parsed