Fix/adjust diffusion: round 27 P1 + P2 batch for PR #5754
Round 27 findings (Opus parallel concurrency + frontend reviews). Backend P1 fixes: 1. utils/datasets/llm_assist.py: the round 26 helper/advisor active registry used a plain set, so two concurrent helper / advisor loads of the same DEFAULT_HELPER_MODEL_REPO would both set.add() (no-op the second time) and then the first finally set.discard() would underflow the registration while the second call was still mmap'ing the GGUF. Switch to a Counter with proper refcount increment/decrement so the repo stays registered until the last user releases it. 2. routes/inference.py _release_chat_for and core/inference/diffusion.py _release_chat_backend_for_diffusion: helper/advisor GGUF runs on a PRIVATE LlamaCppBackend (round 26 P1 #1), so the global llama checks below could not see them. A user-driven /training/start, /export/load-checkpoint, or /images/load would skip the unload and allocate FLUX VRAM on top of the helper's resident weights, OOMing on 16-24 GB consumer GPUs. Both release paths now consult helper_advisor_busy() and fail 503 (or RuntimeError for the in-backend path) so the user retries instead of double-owning VRAM. Frontend P2 fixes: 3. studio/frontend/src/features/images/images-page.tsx: handleUnload now calls refreshStatus() in the catch path so a partial unload (503 from the backend) does not leave the UI showing a stale "Loaded:" label. Matches the handleLoad pattern. 4. images-page.tsx: when status.is_loading is true, auto-poll refreshStatus every 2 s so the user sees real progress instead of a frozen "Loading..." label until they manually click Refresh. 5. images-page.tsx: aria-label="Inference steps" / "Guidance scale" on the two sliders so screen readers can announce them. 6. images-page.tsx: defensive (r.guidance_scale ?? 0).toFixed(1) in the results caption so a future backend that serialises NaN/None for guidance does not throw at render. Tests: 105 targeted (diffusion + cached_gguf + inference_validation) and 1768 broader backend tests pass locally. Frontend `npm run typecheck` passes.
This commit is contained in:
parent
e17aea6c81
commit
6c528fb013
4 changed files with 78 additions and 7 deletions
|
|
@ -1516,6 +1516,20 @@ def _release_chat_backend_for_diffusion() -> None:
|
||||||
diffusion ``load_model`` bails out instead of double-owning VRAM
|
diffusion ``load_model`` bails out instead of double-owning VRAM
|
||||||
(round 17 P1 #2).
|
(round 17 P1 #2).
|
||||||
"""
|
"""
|
||||||
|
# Round 27 P1 #2: helper / advisor GGUF loads run on a PRIVATE
|
||||||
|
# LlamaCppBackend so the global llama check below cannot see them.
|
||||||
|
# Refuse the diffusion handoff while a helper / advisor still owns
|
||||||
|
# its private backend so we do not allocate FLUX VRAM on top.
|
||||||
|
try:
|
||||||
|
from utils.datasets.llm_assist import helper_advisor_busy
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
if helper_advisor_busy():
|
||||||
|
raise RuntimeError(
|
||||||
|
"AI Assist (helper / advisor GGUF) is still using the GPU. "
|
||||||
|
"Wait for it to finish before loading a diffusion image model."
|
||||||
|
)
|
||||||
# 1. GGUF chat backend (llama-server subprocess). We unload when
|
# 1. GGUF chat backend (llama-server subprocess). We unload when
|
||||||
# EITHER is_loaded is True (resident model) OR is_active is
|
# EITHER is_loaded is True (resident model) OR is_active is
|
||||||
# True (mid-download / startup) OR loading_model_identifier is
|
# True (mid-download / startup) OR loading_model_identifier is
|
||||||
|
|
|
||||||
|
|
@ -531,6 +531,24 @@ async def _release_chat_for(workload: str) -> None:
|
||||||
start. Conversely, the standard chat-load path releases only
|
start. Conversely, the standard chat-load path releases only
|
||||||
the llama side.
|
the llama side.
|
||||||
"""
|
"""
|
||||||
|
# Round 27 P1 #2: helper / advisor GGUF loads run on a PRIVATE
|
||||||
|
# LlamaCppBackend (round 26 P1 #1) so the global llama checks
|
||||||
|
# below do not see them. Refuse the handoff while a helper /
|
||||||
|
# advisor still owns its private backend so a new GPU workload
|
||||||
|
# does not allocate on top of helper VRAM and OOM.
|
||||||
|
try:
|
||||||
|
from utils.datasets.llm_assist import helper_advisor_busy
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
if helper_advisor_busy():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code = 503,
|
||||||
|
detail = (
|
||||||
|
f"AI Assist (helper / advisor GGUF) is still using the "
|
||||||
|
f"GPU. Wait for it to finish before starting {workload}."
|
||||||
|
),
|
||||||
|
)
|
||||||
await _release_llama_for(workload)
|
await _release_llama_for(workload)
|
||||||
await _release_safetensors_chat_for(workload)
|
await _release_safetensors_chat_for(workload)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import re
|
||||||
import textwrap
|
import textwrap
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from collections import Counter
|
||||||
from itertools import islice
|
from itertools import islice
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
|
@ -37,9 +38,15 @@ README_MAX_CHARS = 1500
|
||||||
# caused chat-evict races and finally-eviction bugs and still left
|
# caused chat-evict races and finally-eviction bugs and still left
|
||||||
# delete-cache blind because helper/advisor publish prefixed
|
# delete-cache blind because helper/advisor publish prefixed
|
||||||
# identifiers the guard could not match). Expose loading repo ids
|
# identifiers the guard could not match). Expose loading repo ids
|
||||||
# through a thread-safe set so DELETE /api/models/delete-cached can
|
# through a thread-safe Counter so DELETE /api/models/delete-cached
|
||||||
# block while a helper or advisor still owns the cache.
|
# can block while a helper or advisor still owns the cache.
|
||||||
_HELPER_ADVISOR_ACTIVE_REPOS: set[str] = set()
|
#
|
||||||
|
# Round 27 P1 #1: must refcount, not a plain set. A helper and an
|
||||||
|
# advisor (or two concurrent helpers) often share the default repo
|
||||||
|
# unsloth/gemma-4-E2B-it-GGUF. With a set, the first finally call
|
||||||
|
# discarded the repo while the second invocation was still loading,
|
||||||
|
# and the delete-cache guard then let rmtree race the live mmap.
|
||||||
|
_HELPER_ADVISOR_REFCOUNT: Counter[str] = Counter()
|
||||||
_HELPER_ADVISOR_LOCK = threading.Lock()
|
_HELPER_ADVISOR_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -51,21 +58,33 @@ def helper_advisor_owns_repo(repo_id: str) -> bool:
|
||||||
return False
|
return False
|
||||||
needle = repo_id.lower()
|
needle = repo_id.lower()
|
||||||
with _HELPER_ADVISOR_LOCK:
|
with _HELPER_ADVISOR_LOCK:
|
||||||
return needle in _HELPER_ADVISOR_ACTIVE_REPOS
|
return _HELPER_ADVISOR_REFCOUNT.get(needle, 0) > 0
|
||||||
|
|
||||||
|
|
||||||
|
def helper_advisor_busy() -> bool:
|
||||||
|
"""Round 27 P1 #2: True if ANY helper/advisor load is in flight.
|
||||||
|
Used by diffusion / training / export release paths so they do
|
||||||
|
not allocate on top of the helper's VRAM while it owns its
|
||||||
|
private LlamaCppBackend instance."""
|
||||||
|
with _HELPER_ADVISOR_LOCK:
|
||||||
|
return sum(_HELPER_ADVISOR_REFCOUNT.values()) > 0
|
||||||
|
|
||||||
|
|
||||||
def _register_helper_advisor_repo(repo_id: str) -> None:
|
def _register_helper_advisor_repo(repo_id: str) -> None:
|
||||||
if not repo_id:
|
if not repo_id:
|
||||||
return
|
return
|
||||||
with _HELPER_ADVISOR_LOCK:
|
with _HELPER_ADVISOR_LOCK:
|
||||||
_HELPER_ADVISOR_ACTIVE_REPOS.add(repo_id.lower())
|
_HELPER_ADVISOR_REFCOUNT[repo_id.lower()] += 1
|
||||||
|
|
||||||
|
|
||||||
def _unregister_helper_advisor_repo(repo_id: str) -> None:
|
def _unregister_helper_advisor_repo(repo_id: str) -> None:
|
||||||
if not repo_id:
|
if not repo_id:
|
||||||
return
|
return
|
||||||
|
needle = repo_id.lower()
|
||||||
with _HELPER_ADVISOR_LOCK:
|
with _HELPER_ADVISOR_LOCK:
|
||||||
_HELPER_ADVISOR_ACTIVE_REPOS.discard(repo_id.lower())
|
_HELPER_ADVISOR_REFCOUNT[needle] -= 1
|
||||||
|
if _HELPER_ADVISOR_REFCOUNT[needle] <= 0:
|
||||||
|
_HELPER_ADVISOR_REFCOUNT.pop(needle, None)
|
||||||
|
|
||||||
|
|
||||||
def _strip_think_tags(text: str) -> str:
|
def _strip_think_tags(text: str) -> str:
|
||||||
|
|
|
||||||
|
|
@ -148,6 +148,18 @@ export function ImagesPage() {
|
||||||
void refreshStatus();
|
void refreshStatus();
|
||||||
}, [refreshStatus]);
|
}, [refreshStatus]);
|
||||||
|
|
||||||
|
// Round 27 P2: when the backend is mid-load (is_loading=true) the
|
||||||
|
// status label froze at "Loading..." until the user clicked
|
||||||
|
// Refresh. Auto-poll every 2 s while a load is in flight so the
|
||||||
|
// UI tracks real backend progress.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!status?.is_loading) return;
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
void refreshStatus();
|
||||||
|
}, 2000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [status?.is_loading, refreshStatus]);
|
||||||
|
|
||||||
const handleLoad = useCallback(async () => {
|
const handleLoad = useCallback(async () => {
|
||||||
setBusy("loading");
|
setBusy("loading");
|
||||||
try {
|
try {
|
||||||
|
|
@ -207,6 +219,12 @@ export function ImagesPage() {
|
||||||
toast.error("Failed to unload image model", {
|
toast.error("Failed to unload image model", {
|
||||||
description: err instanceof Error ? err.message : String(err),
|
description: err instanceof Error ? err.message : String(err),
|
||||||
});
|
});
|
||||||
|
// Round 27 P2: a partial unload (subprocess refused to terminate,
|
||||||
|
// 503 from the backend) used to leave the UI showing the old
|
||||||
|
// "Loaded:" label even though the backend state was half torn
|
||||||
|
// down. Refresh so the button states match reality (mirrors
|
||||||
|
// handleLoad above which always re-fetches on catch).
|
||||||
|
await refreshStatus();
|
||||||
} finally {
|
} finally {
|
||||||
setBusy("idle");
|
setBusy("idle");
|
||||||
}
|
}
|
||||||
|
|
@ -491,6 +509,7 @@ export function ImagesPage() {
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<Label>Steps: {steps}</Label>
|
<Label>Steps: {steps}</Label>
|
||||||
<Slider
|
<Slider
|
||||||
|
aria-label="Inference steps"
|
||||||
min={1}
|
min={1}
|
||||||
max={60}
|
max={60}
|
||||||
step={1}
|
step={1}
|
||||||
|
|
@ -501,6 +520,7 @@ export function ImagesPage() {
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<Label>Guidance: {guidance.toFixed(1)}</Label>
|
<Label>Guidance: {guidance.toFixed(1)}</Label>
|
||||||
<Slider
|
<Slider
|
||||||
|
aria-label="Guidance scale"
|
||||||
min={0}
|
min={0}
|
||||||
max={15}
|
max={15}
|
||||||
step={0.1}
|
step={0.1}
|
||||||
|
|
@ -554,7 +574,7 @@ export function ImagesPage() {
|
||||||
data-testid="diffusion-result-image"
|
data-testid="diffusion-result-image"
|
||||||
/>
|
/>
|
||||||
<figcaption className="text-xs text-muted-foreground">
|
<figcaption className="text-xs text-muted-foreground">
|
||||||
{r.width}x{r.height} - {r.num_inference_steps} steps - g={r.guidance_scale.toFixed(1)}
|
{r.width}x{r.height} - {r.num_inference_steps} steps - g={(r.guidance_scale ?? 0).toFixed(1)}
|
||||||
{/* Prefer seed_str (full uint64 precision) since the
|
{/* Prefer seed_str (full uint64 precision) since the
|
||||||
numeric seed gets rounded by JSON.parse above
|
numeric seed gets rounded by JSON.parse above
|
||||||
Number.MAX_SAFE_INTEGER and would otherwise
|
Number.MAX_SAFE_INTEGER and would otherwise
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue