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:
1 parent
f5be7880b0
commit
fd608848bf
16 files changed
+290
-20
No files matched your search
@@ -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
@@ -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
@@ -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()
|
||||
Reference in new issue
Block a user