feat: reveal all hidden shapes with Shift+H, release SAM3 VRAM on idle and on demand (REQ-191, REQ-192)

This commit is contained in:
asus committed 2026-10-07 11:55:00 +07:00
1 parent f5be7880b0
commit fd608848bf
16 files changed
+290 -20

No files matched your search

+25
View File
@@ -0,0 +1,25 @@
"""SAM3 engine control routes (REQ-192)."""
from fastapi import APIRouter, HTTPException
from backend import jobs
router = APIRouter(prefix="/api/sam3", tags=["sam3"])
@router.post("/release")
def release() -> dict:
"""Free the SAM3 model's VRAM now — never while the card is busy.
The same non-blocking `gpu_lock` rule as an interactive assist (REQ-065):
a job or assist in flight wins, and the caller is told to try again.
"""
if not jobs.gpu_lock.acquire(blocking=False):
raise HTTPException(409, "GPU busy — assist or job in flight, try again in a moment")
try:
from backend import sam3_engine
released = sam3_engine.release_engine()
return {"released": released, "model_loaded": sam3_engine.engine_is_loaded()}
finally:
jobs.gpu_lock.release()
+3 -1
View File
@@ -17,7 +17,8 @@ from fastapi.middleware.cors import CORSMiddleware
from backend import archive_index, config, db, jobs
from backend.api import (batches, counting_bench as counting_bench_routes,
datasets as dataset_routes, jobs as job_routes,
live_count as live_count_routes, models, projects, review, triage)
live_count as live_count_routes, models, projects, review,
sam3, triage)
@asynccontextmanager
@@ -47,6 +48,7 @@ app.add_middleware(
app.include_router(projects.router)
app.include_router(batches.router)
app.include_router(review.router)
app.include_router(sam3.router)
app.include_router(models.router)
app.include_router(job_routes.router)
app.include_router(triage.router)
+67 -5
View File
@@ -1,7 +1,9 @@
"""SAM3 text-prompted detection, wrapped for reuse across labeling jobs.
The model is expensive to build (weights come from the gated HuggingFace repo
`facebook/sam3`), so it is loaded once per process and kept resident.
`facebook/sam3`), so it is loaded once per process and kept resident — until it
has sat unused for `SAM3_IDLE_UNLOAD_S` seconds (REQ-192), the model is dropped
so the card is free for anything else.
The important performance detail: `Sam3Processor.set_image()` runs the vision
backbone, while `set_text_prompt()` only runs the (much cheaper) grounding head
@@ -12,6 +14,7 @@ once per image and loop the prompts over that same state.
import os
import sys
import threading
import time
from dataclasses import dataclass, field
from typing import List, Optional
@@ -25,6 +28,13 @@ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..",
from sam3.model.sam3_image_processor import Sam3Processor
from sam3.model_builder import build_sam3_image_model
IDLE_UNLOAD_S = float(os.environ.get("SAM3_IDLE_UNLOAD_S") or 1200)
"""Seconds without any SAM3 use before the watcher frees the model (REQ-192).
Default 20 minutes. `0` disables it: the model then stays resident until
training releases it (REQ-065) or the process exits.
"""
@dataclass
class Detection:
@@ -73,6 +83,7 @@ class Sam3Engine:
`thresholds` overrides the confidence per prompt (REQ-181); still one
`set_image` for the whole call — only the grounding head sees the change."""
_touch()
processor = Sam3Processor(self.model, device=self.device)
processor.confidence_threshold = threshold
@@ -124,6 +135,7 @@ class Sam3Engine:
every class because `state["geometric_prompt"]` survives `set_text_prompt`
— without the reset, one class's boxes would leak into the next class.
"""
_touch()
processor = Sam3Processor(self.model, device=self.device)
processor.confidence_threshold = threshold
@@ -152,6 +164,7 @@ class Sam3Engine:
def open_state(self, image: Image.Image):
"""Run the vision backbone once and hand back the reusable state."""
_touch()
with torch.autocast(self.device, dtype=self.autocast_dtype):
return self.processor.set_image(image)
@@ -166,8 +179,9 @@ class Sam3Engine:
Exemplars are boxes in normalized cxcywh with a positive/negative flag.
The prompt set is always replayed from empty because SAM3 only supports
appending geometric prompts — that's how undo is implemented.
appending geometric prompts — that's how undo is implemented.
"""
_touch()
self.processor.confidence_threshold = threshold
exemplars = exemplars or []
@@ -239,6 +253,15 @@ class Sam3Engine:
_engine: Optional[Sam3Engine] = None
_engine_lock = threading.Lock()
_last_used = 0.0
_watcher_lock = threading.Lock()
_watcher_running = False
def _touch() -> None:
"""Note SAM3 is in use, so the idle watcher counts from now (REQ-192)."""
global _last_used
_last_used = time.monotonic()
def get_engine() -> Sam3Engine:
@@ -262,6 +285,8 @@ def get_engine() -> Sam3Engine:
raise RuntimeError(
f"{exc} (Available VRAM: {curr_free:.1f} GB)"
) from exc
_touch()
_ensure_watcher()
return _engine
@@ -271,11 +296,13 @@ def engine_is_loaded() -> bool:
def release_engine() -> bool:
"""Drop the model and free its VRAM (REQ-065).
"""Drop the model and free its VRAM (REQ-065, REQ-192).
SAM3 holds ~3.4 GB resident. On a 6 GB card that is most of the memory a
training run needs, so the two must never be loaded at once. The next job
that needs SAM3 rebuilds it from the local cache in about 12 seconds.
training run needs, so the two must never be loaded at once. Training calls
this before it starts; the idle watcher and `POST /api/sam3/release` call it
too, and the next call that needs SAM3 rebuilds it from the local cache in
about 12 seconds.
"""
global _engine
import gc
@@ -288,3 +315,38 @@ def release_engine() -> bool:
if torch.cuda.is_available():
torch.cuda.empty_cache()
return True
def _ensure_watcher() -> None:
"""Start the one idle-unload thread, once (REQ-192)."""
global _watcher_running
with _watcher_lock:
if _watcher_running or IDLE_UNLOAD_S <= 0:
return
_watcher_running = True
threading.Thread(target=_watch_idle, name="sam3-idle-unload", daemon=True).start()
def _watch_idle() -> None:
"""Free the model after IDLE_UNLOAD_S seconds without use (REQ-192).
Takes `jobs.gpu_lock` without blocking: a job or an interactive assist holds
it for as long as it is on the card, so a busy GPU just postpones the
release to the next tick — same one-holder rule as REQ-065.
"""
from backend import jobs
tick = max(1.0, min(60.0, IDLE_UNLOAD_S))
while True:
time.sleep(tick)
if _engine is None:
continue
with _engine_lock:
if _engine is None or time.monotonic() - _last_used < IDLE_UNLOAD_S:
continue
if not jobs.gpu_lock.acquire(blocking=False):
continue
try:
release_engine()
finally:
jobs.gpu_lock.release()