manager: don't strand VRAM or churn the live model on eviction

Two eviction fixes folded into 0.1.30:

- VRAM eviction: a diffusers pipeline under device_map/accelerate offload
  (balanced/disk/model/sequential) or bitsandbytes quantization REJECTS
  .to('cpu') — the naive move silently left the weights resident and stranded
  VRAM, feeding the OOM death-spiral where the next load OOMs on a near-full
  card. Route pipelines (those exposing `components`) through the thorough
  _free_pipeline_vram (remove accelerate hooks -> drop component refs ->
  empty_cache); plain non-pipeline models still use a simple .to('cpu').

- RAM eviction: never unload the LIVE model (active_in_vram OR current_model_key)
  that a request loop keeps reusing. An offloaded pipeline's host RAM IS the live
  model, so evicting it between same-model requests just forces an immediate
  reload — churning VRAM/RAM and re-stranding device_map weights while freeing
  nothing lasting. The last-resort active-model eviction now fires only for a
  STALE active model (no longer the current one).
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RdMufYvtTbtGDWsiZVoXce
parent cc6db025
...@@ -3391,9 +3391,18 @@ class MultiModelManager: ...@@ -3391,9 +3391,18 @@ class MultiModelManager:
try: try:
if hasattr(model_obj, 'cleanup'): if hasattr(model_obj, 'cleanup'):
model_obj.cleanup() model_obj.cleanup()
elif hasattr(model_obj, 'components'):
# Diffusers pipeline. device_map/accelerate-offloaded pipelines
# (balanced/disk/model/sequential) AND quantized (bitsandbytes)
# pipelines REJECT `.to('cpu')` — a naive move silently leaves the
# weights resident and strands VRAM (the OOM death spiral where the
# next load OOMs on a near-full card). Use the thorough free
# (remove accelerate hooks → drop component refs → empty_cache);
# it also correctly frees a plain full-GPU pipeline.
from codai.api.video import _free_pipeline_vram
_free_pipeline_vram(model_obj)
elif hasattr(model_obj, 'to'): elif hasattr(model_obj, 'to'):
# Diffusers pipeline: move all components to CPU explicitly # Non-pipeline model with no device_map: a plain move frees it.
# before dropping the reference so VRAM is freed promptly.
model_obj.to('cpu') model_obj.to('cpu')
except Exception as e: except Exception as e:
print(f" Warning during eviction of '{key}': {e}") print(f" Warning during eviction of '{key}': {e}")
...@@ -3497,8 +3506,17 @@ class MultiModelManager: ...@@ -3497,8 +3506,17 @@ class MultiModelManager:
if self._get_process_ram_gb() <= target_gb: if self._get_process_ram_gb() <= target_gb:
return return
_before = self._get_process_ram_gb() _before = self._get_process_ram_gb()
# Never RAM-evict the LIVE model — the one a request loop keeps reusing.
# The video/image path tracks it via current_model_key (it does NOT set
# active_in_vram), and an offloaded pipeline's host RAM IS the live model,
# so unloading it between same-model requests just forces an immediate
# reload — churning VRAM/RAM (and stranding device_map weights) while
# freeing nothing that doesn't come right back. Other idle models are still
# fair game. (Independent of offload mode: in full-GPU mode the live model
# uses little host RAM, so protecting it costs nothing.)
_live = {self.active_in_vram, self.current_model_key}
for key in self._lru_order(): for key in self._lru_order():
if key == self.active_in_vram: if key in _live:
continue continue
if self._get_process_ram_gb() <= target_gb: if self._get_process_ram_gb() <= target_gb:
break break
...@@ -3508,12 +3526,15 @@ class MultiModelManager: ...@@ -3508,12 +3526,15 @@ class MultiModelManager:
print(f"RAM eviction: unloading '{key}' to free host RAM " print(f"RAM eviction: unloading '{key}' to free host RAM "
f"(RSS {self._get_process_ram_gb():.1f} GB > cap target {target_gb:.1f} GB)") f"(RSS {self._get_process_ram_gb():.1f} GB > cap target {target_gb:.1f} GB)")
self._evict_one(key) self._evict_one(key)
# Last resort: the active model, only if idle. # Last resort: a STALE active model (one no longer the live/current model)
# if idle. The current/live model is never evicted here — that churn is
# exactly what we're avoiding.
if (self._get_process_ram_gb() > target_gb and self.active_in_vram if (self._get_process_ram_gb() > target_gb and self.active_in_vram
and self.active_in_vram in self.models): and self.active_in_vram in self.models
and self.active_in_vram != self.current_model_key):
_active = self.active_in_vram _active = self.active_in_vram
if not self._is_key_busy(_active) and self._wait_until_idle(_active): if not self._is_key_busy(_active) and self._wait_until_idle(_active):
print(f"RAM eviction: unloading active model '{_active}' to free host RAM") print(f"RAM eviction: unloading stale active model '{_active}' to free host RAM")
self._evict_one(_active) self._evict_one(_active)
self.active_in_vram = None self.active_in_vram = None
_freed = _before - self._get_process_ram_gb() _freed = _before - self._get_process_ram_gb()
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment