Merge branch 'codex/review-oom-order' into codex/pr-queue-integration
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -24,6 +24,7 @@ the frozen-backend fallback mirror it for their toolchains.
|
||||
- Saved transcriptions with missing or invalid timestamps now remain readable (#1799) — thanks @yunaremaia and @tvbht!
|
||||
- Copying a saved transcription now uses the shared clipboard helper and reports failed copies accurately (#1803) — thanks @tvbht!
|
||||
|
||||
- Voice reference preparation reclaims allocator memory before one bounded retry, then reports persistent GPU out-of-memory failures (#1811)
|
||||
|
||||
## [0.5.2] — 2026-09-02
|
||||
|
||||
|
||||
@@ -566,7 +566,12 @@ def _get_clone_prompt(
|
||||
):
|
||||
"""Return a cached/precomputed ``VoiceClonePrompt`` for
|
||||
(ref_audio, ref_text, preprocess_prompt), or ``None`` to fall back to the
|
||||
inline ref path. Never raises.
|
||||
inline ref path.
|
||||
|
||||
Raises only on a device OOM that survives a cache-drop retry (#1790): the
|
||||
inline path is the same allocation on the same device, so falling back to
|
||||
it after an OOM cannot succeed and has been observed taking the whole
|
||||
process down instead. Every other failure still falls back silently.
|
||||
|
||||
``store=False`` still *reads* the cache (a hit is free) but never inserts:
|
||||
it exists for single-use references — a dub's per-segment ref clips are each
|
||||
@@ -596,10 +601,43 @@ def _get_clone_prompt(
|
||||
ref_audio, ref_text=ref_text, preprocess_prompt=preprocess_prompt
|
||||
)
|
||||
except Exception as e: # noqa: BLE001 — fall back, never break synthesis
|
||||
logger.warning(
|
||||
"voice-clone prompt precompute failed; using inline ref: %s", e
|
||||
)
|
||||
return None
|
||||
# #1790/#1777: a GPU OOM is the one failure this fallback cannot
|
||||
# absorb. `generate()`'s inline ref path runs the SAME encode on the
|
||||
# SAME device — the docstring above says so, because producing
|
||||
# identical output is the point — so returning None after an OOM
|
||||
# guarantees a second OOM moments later, on a device with even less
|
||||
# headroom than the first attempt found. Both reporters' backends
|
||||
# then died with a Windows access violation (exit code
|
||||
# -1073741819) seconds after this exact log line, mid-generation on
|
||||
# a GPU that had just refused an 86 MiB allocation.
|
||||
#
|
||||
# An OOM here is also the most recoverable kind: the allocator is
|
||||
# typically holding reserved-but-unallocated blocks (#1790's own
|
||||
# log reports 90 MiB reserved against an 86 MiB request). Drop them
|
||||
# and try once more. If it still will not fit, raise — the failure
|
||||
# layer turns a device OOM into the actionable GPU_OOM message
|
||||
# ("close other GPU-heavy apps or unload models…"), which is a far
|
||||
# better answer than walking into a native fault.
|
||||
from core.failure import is_gpu_oom
|
||||
|
||||
if is_gpu_oom(e):
|
||||
logger.warning(
|
||||
"voice-clone prompt precompute hit a device OOM (%s) — "
|
||||
"releasing allocator caches and retrying once", e,
|
||||
)
|
||||
try:
|
||||
from services.model_manager import free_vram
|
||||
free_vram()
|
||||
except Exception: # noqa: BLE001 — reclaim is best-effort
|
||||
logger.debug("VRAM reclaim before OOM retry failed", exc_info=True)
|
||||
prompt = model.create_voice_clone_prompt(
|
||||
ref_audio, ref_text=ref_text, preprocess_prompt=preprocess_prompt
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"voice-clone prompt precompute failed; using inline ref: %s", e
|
||||
)
|
||||
return None
|
||||
if store:
|
||||
_prompt_disk_save(key, prompt)
|
||||
if not store:
|
||||
|
||||
@@ -152,3 +152,80 @@ def test_single_use_flood_does_not_evict_reused_prompts(tmp_path):
|
||||
assert m.calls == before, (
|
||||
"the speaker prompt was evicted by single-use segment refs and re-encoded"
|
||||
)
|
||||
|
||||
|
||||
# ── A device OOM is not a fallback-able failure (#1790/#1777) ───────────────
|
||||
#
|
||||
# `generate()`'s inline ref path runs the SAME encode on the SAME device — the
|
||||
# whole point of the precompute is that its output is identical — so returning
|
||||
# None after an OOM guarantees a second OOM moments later, on a device with
|
||||
# even less headroom than the first attempt found. Two reporters' backends died
|
||||
# with a Windows access violation (exit code -1073741819) seconds after this
|
||||
# fallback logged, mid-generation on a GPU that had just refused an 86 MiB
|
||||
# allocation. Drop the allocator's reserved-but-unallocated blocks and retry
|
||||
# once; if it still will not fit, raise so the failure layer can say
|
||||
# "close other GPU-heavy apps or unload models" instead of walking into a
|
||||
# native fault.
|
||||
|
||||
|
||||
class _OomModel:
|
||||
"""Raises a torch-shaped OOM for the first `fails` calls, then succeeds."""
|
||||
|
||||
class OutOfMemoryError(RuntimeError):
|
||||
pass
|
||||
|
||||
def __init__(self, fails=1):
|
||||
self.calls = 0
|
||||
self.fails = fails
|
||||
|
||||
def create_voice_clone_prompt(self, ref_audio, ref_text=None, preprocess_prompt=True):
|
||||
self.calls += 1
|
||||
if self.calls <= self.fails:
|
||||
raise self.OutOfMemoryError(
|
||||
"CUDA out of memory. Tried to allocate 86.00 MiB. GPU 0 has a "
|
||||
"total capacity of 11.91 GiB of which 1.98 GiB is free."
|
||||
)
|
||||
return f"PROMPT::{ref_audio}"
|
||||
|
||||
|
||||
def test_oom_frees_vram_and_retries_once(tmp_path, monkeypatch):
|
||||
freed = []
|
||||
monkeypatch.setattr(
|
||||
"services.model_manager.free_vram", lambda: freed.append(m.calls), raising=False
|
||||
)
|
||||
m = _OomModel(fails=1)
|
||||
got = tb._get_clone_prompt(m, _wav(tmp_path), "hello")
|
||||
assert got == f"PROMPT::{_wav(tmp_path)}"
|
||||
assert m.calls == 2, "the OOM must be retried, not fallen back from"
|
||||
assert freed == [1], "allocator caches must be dropped before the retry"
|
||||
|
||||
|
||||
def test_oom_that_survives_the_retry_raises_instead_of_falling_back(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("services.model_manager.free_vram", lambda: None, raising=False)
|
||||
m = _OomModel(fails=2)
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
tb._get_clone_prompt(m, _wav(tmp_path), "hello")
|
||||
from core.failure import is_gpu_oom
|
||||
|
||||
assert is_gpu_oom(excinfo.value), "the OOM must reach the failure layer intact"
|
||||
assert m.calls == 2, "exactly one retry — not an unbounded loop"
|
||||
|
||||
|
||||
def test_a_reclaim_failure_does_not_mask_the_retry(tmp_path, monkeypatch):
|
||||
# Making room is best-effort: if it throws, the retry still happens, because
|
||||
# the reason we are here is that falling back cannot work.
|
||||
def _boom():
|
||||
raise RuntimeError("no allocator")
|
||||
|
||||
monkeypatch.setattr("services.model_manager.free_vram", _boom, raising=False)
|
||||
m = _OomModel(fails=1)
|
||||
assert tb._get_clone_prompt(m, _wav(tmp_path), "hi") is not None
|
||||
assert m.calls == 2
|
||||
|
||||
|
||||
def test_non_oom_failures_still_fall_back_silently(tmp_path):
|
||||
# The existing contract for every other error is unchanged: None, so the
|
||||
# caller uses the inline ref — which for a non-memory fault may well work.
|
||||
m = _StubModel(fail=True)
|
||||
assert tb._get_clone_prompt(m, _wav(tmp_path), "hello") is None
|
||||
assert m.calls == 1
|
||||
|
||||
Reference in New Issue
Block a user