Skip to content

Commit 6c528fb

Browse files
committed
Fix/adjust diffusion: round 27 P1 + P2 batch for PR unslothai#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.
1 parent e17aea6 commit 6c528fb

4 files changed

Lines changed: 78 additions & 7 deletions

File tree

studio/backend/core/inference/diffusion.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1516,6 +1516,20 @@ def _release_chat_backend_for_diffusion() -> None:
15161516
diffusion ``load_model`` bails out instead of double-owning VRAM
15171517
(round 17 P1 #2).
15181518
"""
1519+
# Round 27 P1 #2: helper / advisor GGUF loads run on a PRIVATE
1520+
# LlamaCppBackend so the global llama check below cannot see them.
1521+
# Refuse the diffusion handoff while a helper / advisor still owns
1522+
# its private backend so we do not allocate FLUX VRAM on top.
1523+
try:
1524+
from utils.datasets.llm_assist import helper_advisor_busy
1525+
except Exception:
1526+
pass
1527+
else:
1528+
if helper_advisor_busy():
1529+
raise RuntimeError(
1530+
"AI Assist (helper / advisor GGUF) is still using the GPU. "
1531+
"Wait for it to finish before loading a diffusion image model."
1532+
)
15191533
# 1. GGUF chat backend (llama-server subprocess). We unload when
15201534
# EITHER is_loaded is True (resident model) OR is_active is
15211535
# True (mid-download / startup) OR loading_model_identifier is

studio/backend/routes/inference.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,24 @@ async def _release_chat_for(workload: str) -> None:
531531
start. Conversely, the standard chat-load path releases only
532532
the llama side.
533533
"""
534+
# Round 27 P1 #2: helper / advisor GGUF loads run on a PRIVATE
535+
# LlamaCppBackend (round 26 P1 #1) so the global llama checks
536+
# below do not see them. Refuse the handoff while a helper /
537+
# advisor still owns its private backend so a new GPU workload
538+
# does not allocate on top of helper VRAM and OOM.
539+
try:
540+
from utils.datasets.llm_assist import helper_advisor_busy
541+
except Exception:
542+
pass
543+
else:
544+
if helper_advisor_busy():
545+
raise HTTPException(
546+
status_code = 503,
547+
detail = (
548+
f"AI Assist (helper / advisor GGUF) is still using the "
549+
f"GPU. Wait for it to finish before starting {workload}."
550+
),
551+
)
534552
await _release_llama_for(workload)
535553
await _release_safetensors_chat_for(workload)
536554

studio/backend/utils/datasets/llm_assist.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import textwrap
2121
import threading
2222
import time
23+
from collections import Counter
2324
from itertools import islice
2425
from typing import Any, Optional
2526

@@ -37,9 +38,15 @@
3738
# caused chat-evict races and finally-eviction bugs and still left
3839
# delete-cache blind because helper/advisor publish prefixed
3940
# identifiers the guard could not match). Expose loading repo ids
40-
# through a thread-safe set so DELETE /api/models/delete-cached can
41-
# block while a helper or advisor still owns the cache.
42-
_HELPER_ADVISOR_ACTIVE_REPOS: set[str] = set()
41+
# through a thread-safe Counter so DELETE /api/models/delete-cached
42+
# can block while a helper or advisor still owns the cache.
43+
#
44+
# Round 27 P1 #1: must refcount, not a plain set. A helper and an
45+
# advisor (or two concurrent helpers) often share the default repo
46+
# unsloth/gemma-4-E2B-it-GGUF. With a set, the first finally call
47+
# discarded the repo while the second invocation was still loading,
48+
# and the delete-cache guard then let rmtree race the live mmap.
49+
_HELPER_ADVISOR_REFCOUNT: Counter[str] = Counter()
4350
_HELPER_ADVISOR_LOCK = threading.Lock()
4451

4552

@@ -51,21 +58,33 @@ def helper_advisor_owns_repo(repo_id: str) -> bool:
5158
return False
5259
needle = repo_id.lower()
5360
with _HELPER_ADVISOR_LOCK:
54-
return needle in _HELPER_ADVISOR_ACTIVE_REPOS
61+
return _HELPER_ADVISOR_REFCOUNT.get(needle, 0) > 0
62+
63+
64+
def helper_advisor_busy() -> bool:
65+
"""Round 27 P1 #2: True if ANY helper/advisor load is in flight.
66+
Used by diffusion / training / export release paths so they do
67+
not allocate on top of the helper's VRAM while it owns its
68+
private LlamaCppBackend instance."""
69+
with _HELPER_ADVISOR_LOCK:
70+
return sum(_HELPER_ADVISOR_REFCOUNT.values()) > 0
5571

5672

5773
def _register_helper_advisor_repo(repo_id: str) -> None:
5874
if not repo_id:
5975
return
6076
with _HELPER_ADVISOR_LOCK:
61-
_HELPER_ADVISOR_ACTIVE_REPOS.add(repo_id.lower())
77+
_HELPER_ADVISOR_REFCOUNT[repo_id.lower()] += 1
6278

6379

6480
def _unregister_helper_advisor_repo(repo_id: str) -> None:
6581
if not repo_id:
6682
return
83+
needle = repo_id.lower()
6784
with _HELPER_ADVISOR_LOCK:
68-
_HELPER_ADVISOR_ACTIVE_REPOS.discard(repo_id.lower())
85+
_HELPER_ADVISOR_REFCOUNT[needle] -= 1
86+
if _HELPER_ADVISOR_REFCOUNT[needle] <= 0:
87+
_HELPER_ADVISOR_REFCOUNT.pop(needle, None)
6988

7089

7190
def _strip_think_tags(text: str) -> str:

studio/frontend/src/features/images/images-page.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,18 @@ export function ImagesPage() {
148148
void refreshStatus();
149149
}, [refreshStatus]);
150150

151+
// Round 27 P2: when the backend is mid-load (is_loading=true) the
152+
// status label froze at "Loading..." until the user clicked
153+
// Refresh. Auto-poll every 2 s while a load is in flight so the
154+
// UI tracks real backend progress.
155+
useEffect(() => {
156+
if (!status?.is_loading) return;
157+
const id = window.setInterval(() => {
158+
void refreshStatus();
159+
}, 2000);
160+
return () => window.clearInterval(id);
161+
}, [status?.is_loading, refreshStatus]);
162+
151163
const handleLoad = useCallback(async () => {
152164
setBusy("loading");
153165
try {
@@ -207,6 +219,12 @@ export function ImagesPage() {
207219
toast.error("Failed to unload image model", {
208220
description: err instanceof Error ? err.message : String(err),
209221
});
222+
// Round 27 P2: a partial unload (subprocess refused to terminate,
223+
// 503 from the backend) used to leave the UI showing the old
224+
// "Loaded:" label even though the backend state was half torn
225+
// down. Refresh so the button states match reality (mirrors
226+
// handleLoad above which always re-fetches on catch).
227+
await refreshStatus();
210228
} finally {
211229
setBusy("idle");
212230
}
@@ -491,6 +509,7 @@ export function ImagesPage() {
491509
<div className="flex flex-col gap-1">
492510
<Label>Steps: {steps}</Label>
493511
<Slider
512+
aria-label="Inference steps"
494513
min={1}
495514
max={60}
496515
step={1}
@@ -501,6 +520,7 @@ export function ImagesPage() {
501520
<div className="flex flex-col gap-1">
502521
<Label>Guidance: {guidance.toFixed(1)}</Label>
503522
<Slider
523+
aria-label="Guidance scale"
504524
min={0}
505525
max={15}
506526
step={0.1}
@@ -554,7 +574,7 @@ export function ImagesPage() {
554574
data-testid="diffusion-result-image"
555575
/>
556576
<figcaption className="text-xs text-muted-foreground">
557-
{r.width}x{r.height} - {r.num_inference_steps} steps - g={r.guidance_scale.toFixed(1)}
577+
{r.width}x{r.height} - {r.num_inference_steps} steps - g={(r.guidance_scale ?? 0).toFixed(1)}
558578
{/* Prefer seed_str (full uint64 precision) since the
559579
numeric seed gets rounded by JSON.parse above
560580
Number.MAX_SAFE_INTEGER and would otherwise

0 commit comments

Comments
 (0)