Skip to content

Commit 7af1a1e

Browse files
tecnomanuclaude
andcommitted
feat(server): evict cached model sessions to keep RAM bounded
The Electron app was growing past 20 GB because every model the user touched stayed loaded in `_SESSIONS` forever. Three knobs to fix that: - Idle TTL: a background sweeper unloads sessions that have not been used in `RBL_MODEL_IDLE_TTL` seconds (default 600). The pinned model is exempt — it is the one the user is actively working with. - /set_default_model: when the UI changes the global model dropdown, the previous one is evicted from RAM immediately so we don't pile up BiRefNet + ISNet + U2Net at once. - /remove?transient=true: per-image overrides (reprocess) load their model but do not steal the pinned default. The idle TTL reclaims them later. Plus /unload_model for manual freeing without touching the on-disk cache, and /health now reports the pinned model + TTL. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent a7fecc1 commit 7af1a1e

3 files changed

Lines changed: 309 additions & 3 deletions

File tree

server.py

Lines changed: 148 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636

3737
import io
3838
import os
39+
import gc
3940
import glob
4041
import json
4142
import time
@@ -177,6 +178,12 @@ def _pkg_version() -> str:
177178
MAX_UPLOAD_MB = int(os.environ.get("MAX_UPLOAD_MB", "30"))
178179
MAX_UPLOAD_BYTES = MAX_UPLOAD_MB * 1024 * 1024
179180

181+
# How long an idle (unused) model session stays in RAM before being evicted.
182+
# Set to 0 to disable the background evictor entirely.
183+
MODEL_IDLE_TTL = int(os.environ.get("RBL_MODEL_IDLE_TTL", "600"))
184+
# How often the evictor wakes up to check for idle models.
185+
MODEL_EVICTOR_INTERVAL = int(os.environ.get("RBL_MODEL_EVICTOR_INTERVAL", "30"))
186+
180187
# Execution providers for onnxruntime. CPU is the reliable default (CoreML hangs
181188
# on some models on Apple Silicon). Override with REMBG_PROVIDERS if you want to
182189
# experiment, e.g. "CoreMLExecutionProvider,CPUExecutionProvider".
@@ -225,13 +232,68 @@ def download_progress(name: str):
225232
_SESSIONS: dict[str, object] = {}
226233
_MODEL_STATE: dict[str, str] = {} # name -> "loading" | "ready" | "error"
227234
_MODEL_ERROR: dict[str, str] = {}
235+
_LAST_USED: dict[str, float] = {} # name -> monotonic timestamp of last touch
228236
_LOAD_LOCKS: dict[str, asyncio.Lock] = {}
237+
# The "pinned" model is the one the UI currently shows as the active default.
238+
# It is exempt from idle-TTL eviction. /set_default_model rotates this; an
239+
# explicit per-image override via /remove?transient=true does NOT.
240+
_PINNED_MODEL: str = DEFAULT_MODEL
229241
# Serializes inference (processing queue). Created lazily inside the running
230242
# event loop: on Python 3.9 an asyncio.Lock() built at import time binds to the
231243
# wrong loop and raises "got Future attached to a different loop" under uvicorn.
232244
_INFER_LOCK: asyncio.Lock | None = None
233245

234246

247+
def _touch(model_name: str) -> None:
248+
_LAST_USED[model_name] = time.monotonic()
249+
250+
251+
def _evict(model_name: str) -> bool:
252+
"""Drop a loaded session from RAM. Does NOT touch the on-disk .onnx file."""
253+
if model_name not in _SESSIONS:
254+
return False
255+
_SESSIONS.pop(model_name, None)
256+
_LAST_USED.pop(model_name, None)
257+
_MODEL_STATE.pop(model_name, None)
258+
_MODEL_ERROR.pop(model_name, None)
259+
gc.collect()
260+
log.info("Unloaded model %s from RAM", model_name)
261+
return True
262+
263+
264+
def _evictor_sweep() -> list[str]:
265+
"""One pass of the idle-TTL eviction logic. Returns the names evicted.
266+
267+
Pure (no awaits) so it can be unit-tested without driving the event loop.
268+
"""
269+
if MODEL_IDLE_TTL <= 0:
270+
return []
271+
now = time.monotonic()
272+
evicted: list[str] = []
273+
for name in [n for n in list(_SESSIONS) if n != _PINNED_MODEL]:
274+
last = _LAST_USED.get(name, now)
275+
if now - last > MODEL_IDLE_TTL and _evict(name):
276+
evicted.append(name)
277+
return evicted
278+
279+
280+
async def _evictor_loop() -> None:
281+
"""Background task: drops loaded models that have been idle past the TTL.
282+
283+
The pinned model is never evicted by the timer — it's the one the user is
284+
actively working with. Everything else (overrides, leftovers from a model
285+
switch) is fair game.
286+
"""
287+
while True:
288+
try:
289+
await asyncio.sleep(MODEL_EVICTOR_INTERVAL)
290+
_evictor_sweep()
291+
except asyncio.CancelledError:
292+
raise
293+
except Exception: # noqa: BLE001
294+
log.exception("Model evictor loop error (continuing)")
295+
296+
235297
def get_infer_lock() -> asyncio.Lock:
236298
global _INFER_LOCK
237299
if _INFER_LOCK is None:
@@ -262,11 +324,13 @@ async def ensure_session(model_name: str):
262324
"""
263325
_check_model(model_name)
264326
if model_name in _SESSIONS:
327+
_touch(model_name)
265328
return _SESSIONS[model_name]
266329

267330
lock = _LOAD_LOCKS.setdefault(model_name, asyncio.Lock())
268331
async with lock:
269332
if model_name in _SESSIONS:
333+
_touch(model_name)
270334
return _SESSIONS[model_name]
271335
_MODEL_STATE[model_name] = "loading"
272336
_MODEL_ERROR.pop(model_name, None)
@@ -282,6 +346,7 @@ async def ensure_session(model_name: str):
282346
raise
283347
_SESSIONS[model_name] = session
284348
_MODEL_STATE[model_name] = "ready"
349+
_touch(model_name)
285350
log.info("Model %s ready in %.1fs", model_name, time.time() - t0)
286351
return session
287352

@@ -292,6 +357,32 @@ async def ensure_session(model_name: str):
292357

293358
app = FastAPI(title="remove-background-local", version=APP_VERSION)
294359

360+
_EVICTOR_TASK: asyncio.Task | None = None
361+
362+
363+
@app.on_event("startup")
364+
async def _start_evictor() -> None:
365+
global _EVICTOR_TASK
366+
if MODEL_IDLE_TTL > 0 and _EVICTOR_TASK is None:
367+
_EVICTOR_TASK = asyncio.create_task(_evictor_loop())
368+
log.info(
369+
"Model evictor running: idle TTL %ds, check every %ds (pinned=%s)",
370+
MODEL_IDLE_TTL, MODEL_EVICTOR_INTERVAL, _PINNED_MODEL,
371+
)
372+
373+
374+
@app.on_event("shutdown")
375+
async def _stop_evictor() -> None:
376+
global _EVICTOR_TASK
377+
if _EVICTOR_TASK is not None:
378+
_EVICTOR_TASK.cancel()
379+
try:
380+
await _EVICTOR_TASK
381+
except (asyncio.CancelledError, Exception): # noqa: BLE001
382+
pass
383+
_EVICTOR_TASK = None
384+
385+
295386
if STATIC_DIR.exists():
296387
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
297388

@@ -311,7 +402,9 @@ async def health():
311402
"ok": True,
312403
"version": APP_VERSION,
313404
"default_model": DEFAULT_MODEL,
405+
"pinned_model": _PINNED_MODEL,
314406
"loaded_models": list(_SESSIONS.keys()),
407+
"idle_ttl_seconds": MODEL_IDLE_TTL,
315408
}
316409

317410

@@ -364,11 +457,55 @@ async def _bg():
364457
return {"model": model, "state": state_of(model), "size_mb": MODEL_SIZES_MB.get(model)}
365458

366459

460+
@app.post("/unload_model")
461+
async def unload_model(model: str = Form(...)):
462+
"""Drop a loaded model from RAM without touching its on-disk .onnx cache.
463+
464+
Useful for freeing memory when a model is no longer needed. The next
465+
request that uses it will reload it from the local cache (no re-download).
466+
"""
467+
_check_model(model)
468+
unloaded = _evict(model)
469+
return {"model": model, "unloaded": unloaded, "state": state_of(model)}
470+
471+
472+
@app.post("/set_default_model")
473+
async def set_default_model(model: str = Form(...), warmup: bool = Form(False)):
474+
"""Pin a new active default model and evict every other loaded model.
475+
476+
The UI calls this when the user changes the model in the main dropdown so
477+
that switching from BiRefNet to ISNet (or vice-versa) doesn't leave both
478+
sitting in RAM. Per-image overrides (/remove with transient=true) do NOT
479+
use this — they go through the idle TTL instead.
480+
"""
481+
global _PINNED_MODEL
482+
_check_model(model)
483+
_PINNED_MODEL = model
484+
evicted = []
485+
for name in list(_SESSIONS):
486+
if name != model and _evict(name):
487+
evicted.append(name)
488+
if warmup and model not in _SESSIONS and state_of(model) != "loading":
489+
async def _bg():
490+
try:
491+
await ensure_session(model)
492+
except Exception: # noqa: BLE001
493+
pass
494+
asyncio.create_task(_bg())
495+
return {
496+
"pinned": _PINNED_MODEL,
497+
"evicted": evicted,
498+
"loaded": list(_SESSIONS.keys()),
499+
"state": state_of(model),
500+
}
501+
502+
367503
@app.post("/delete_model")
368504
async def delete_model(model: str = Form(...)):
369505
"""Delete a model's cached .onnx file from disk and unload it from memory."""
370506
_check_model(model)
371507
_SESSIONS.pop(model, None)
508+
_LAST_USED.pop(model, None)
372509
_MODEL_STATE.pop(model, None)
373510
_MODEL_ERROR.pop(model, None)
374511
path = model_file(model)
@@ -397,8 +534,15 @@ async def remove_background(
397534
alpha_matting_foreground_threshold: int = Form(240),
398535
alpha_matting_background_threshold: int = Form(10),
399536
alpha_matting_erode_size: int = Form(10),
537+
transient: bool = Form(False),
400538
):
401-
"""Process an image and return a PNG with a transparent background."""
539+
"""Process an image and return a PNG with a transparent background.
540+
541+
When ``transient`` is true the request is treated as a one-off override:
542+
the model is loaded if needed but the pinned default is left alone, so the
543+
idle TTL will reclaim this model later. Used for per-image reprocessing
544+
with a different model from the UI.
545+
"""
402546
# Validate size
403547
raw = await image.read()
404548
if len(raw) == 0:
@@ -418,8 +562,9 @@ async def remove_background(
418562
raise HTTPException(status_code=400, detail=f"Invalid image: {exc}")
419563

420564
log.info(
421-
"Processing %s (%dx%d, %.1f KB) with model=%s",
565+
"Processing %s (%dx%d, %.1f KB) with model=%s%s",
422566
image.filename, img.width, img.height, len(raw) / 1024, model,
567+
" (transient)" if transient else "",
423568
)
424569

425570
# Loads/downloads the model if needed (in a worker thread).
@@ -444,6 +589,7 @@ async def remove_background(
444589
elapsed = time.time() - t0
445590

446591
png_bytes = await run_in_threadpool(_encode_png, out)
592+
_touch(model)
447593
log.info("Done in %.2fs", elapsed)
448594

449595
headers = {

static/index.html

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,12 @@ <h2 id="install-title">Setting up rm.background local</h2>
380380
// ===================== Model status API ==================================
381381
async function getStatus(model) { const r = await fetch("/model_status?model=" + encodeURIComponent(model)); if (!r.ok) throw new Error("status check failed"); return r.json(); }
382382
async function warmup(model) { const fd = new FormData(); fd.append("model", model); await fetch("/warmup", { method: "POST", body: fd }); }
383+
async function pinDefaultModel(model, warm) {
384+
// Tells the server this is the active default; the server evicts any other
385+
// loaded model from RAM so we don't pile up BiRefNet + ISNet + U2Net at once.
386+
const fd = new FormData(); fd.append("model", model); if (warm) fd.append("warmup", "true");
387+
try { await fetch("/set_default_model", { method: "POST", body: fd }); } catch {}
388+
}
383389
async function waitForModel(model, onTick) {
384390
let s = await getStatus(model); if (s.state === "ready") return s;
385391
await warmup(model);
@@ -404,7 +410,7 @@ <h2 id="install-title">Setting up rm.background local</h2>
404410
opt.className = "dd-opt" + (key === selectedModel ? " sel" : "") + (avail ? "" : " disabled");
405411
const na = avail ? "" : `<span class="o-na">Not downloaded</span>`;
406412
opt.innerHTML = `<span class="o-main"><span class="o-title">${info2.title}<span class="o-size">~${SIZES[key]} MB</span>${na}</span><span class="o-tag">${info2.tagline || ""}</span></span><span class="o-check">${ICON_CHECK}</span>`;
407-
if (avail) opt.addEventListener("click", () => { selectedModel = key; closeDropdown(); renderModelDropdown(); });
413+
if (avail) opt.addEventListener("click", () => { selectedModel = key; closeDropdown(); renderModelDropdown(); pinDefaultModel(key, false); });
408414
else opt.title = "Download it on the Models page first";
409415
menu.appendChild(opt);
410416
}
@@ -518,6 +524,8 @@ <h2 id="install-title">Setting up rm.background local</h2>
518524
if (st.state !== "ready") { job.state = "loading-model"; renderAll(); setStatus(`downloading ${job.model}…`, true); await waitForModel(job.model); }
519525
job.state = "processing"; renderAll(); setStatus("processing…", true);
520526
const fd = new FormData(); fd.append("image", job.file); fd.append("model", job.model);
527+
// A one-off reprocess with a non-default model: keep the global pinned model alive.
528+
if (job.transient || job.model !== selectedModel) fd.append("transient", "true");
521529
if ($("am-enabled").checked) { fd.append("alpha_matting", "true"); fd.append("alpha_matting_foreground_threshold", $("am-fg").value); fd.append("alpha_matting_background_threshold", $("am-bg").value); fd.append("alpha_matting_erode_size", $("am-erode").value); }
522530
const r = await fetch("/remove", { method: "POST", body: fd });
523531
if (!r.ok) { let d = "Server error"; try { d = (await r.json()).detail || d; } catch {} throw new Error(d); }
@@ -531,6 +539,8 @@ <h2 id="install-title">Setting up rm.background local</h2>
531539
function reprocessJob(job, newModel) {
532540
if (!confirm(`Reprocess "${job.name}" with ${INFO[newModel]?.title || newModel}?\n\nThis replaces the current result for this image.`)) return;
533541
job.model = newModel; job.state = "queued"; job.err = null;
542+
// Per-image override: load the model but do not steal the pinned default.
543+
job.transient = newModel !== selectedModel;
534544
if (job.outUrl) { URL.revokeObjectURL(job.outUrl); job.outUrl = null; } job.outBlob = null; job.ms = null; job.outKB = null;
535545
renderAll(); pump();
536546
}

0 commit comments

Comments
 (0)