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:
Daniel Han-Chen 2026-05-25 13:39:40 +00:00
commit 6c528fb013
4 changed files with 78 additions and 7 deletions

View file

@ -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

View file

@ -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)

View file

@ -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:

View file

@ -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