0ef5fcb1c5
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
399 lines
13 KiB
Python
399 lines
13 KiB
Python
"""Centralized registry for ML model instances.
|
|
|
|
Provides shared access to ML models (sentence transformers, SIGLIP, spaCy, etc.)
|
|
to avoid loading the same model multiple times across different components.
|
|
|
|
This is different from registry.py which stores LLM metadata. This module
|
|
manages actual loaded model instances that consume memory.
|
|
|
|
Model defaults are configured in headroom/models/config.py - change them there
|
|
to switch model variants across the entire codebase.
|
|
|
|
Usage:
|
|
from headroom.models.ml_models import MLModelRegistry
|
|
|
|
# Get shared sentence transformer (loads on first access, uses config default)
|
|
model = MLModelRegistry.get_sentence_transformer()
|
|
embeddings = model.encode(["hello", "world"])
|
|
|
|
# Get SIGLIP for image embeddings
|
|
siglip_model, processor = MLModelRegistry.get_siglip()
|
|
|
|
# Check what's loaded
|
|
print(MLModelRegistry.loaded_models())
|
|
print(f"Memory: {MLModelRegistry.estimated_memory_mb():.1f} MB")
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import gc
|
|
import logging
|
|
from threading import RLock
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from .config import ML_MODEL_DEFAULTS
|
|
|
|
if TYPE_CHECKING:
|
|
pass
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class MLModelRegistry:
|
|
"""Singleton registry for shared ML model instances.
|
|
|
|
Provides lazy-loaded, shared access to ML models across all components.
|
|
This prevents the same model from being loaded multiple times.
|
|
|
|
Thread-safe for concurrent access.
|
|
"""
|
|
|
|
_instance: MLModelRegistry | None = None
|
|
_lock = RLock()
|
|
|
|
def __new__(cls) -> MLModelRegistry:
|
|
if cls._instance is None:
|
|
with cls._lock:
|
|
if cls._instance is None:
|
|
cls._instance = super().__new__(cls)
|
|
cls._instance._init()
|
|
return cls._instance
|
|
|
|
def _init(self) -> None:
|
|
"""Initialize the registry."""
|
|
self._models: dict[str, Any] = {}
|
|
self._model_lock = RLock()
|
|
|
|
@classmethod
|
|
def get(cls) -> MLModelRegistry:
|
|
"""Get the singleton instance."""
|
|
return cls()
|
|
|
|
@classmethod
|
|
def reset(cls) -> None:
|
|
"""Reset the registry (for testing)."""
|
|
with cls._lock:
|
|
if cls._instance is not None:
|
|
cls._instance._models.clear()
|
|
cls._instance = None
|
|
|
|
@classmethod
|
|
def _release_runtime_memory(cls) -> None:
|
|
"""Best-effort cleanup after unloading heavyweight models."""
|
|
gc.collect()
|
|
try:
|
|
import torch
|
|
except ImportError:
|
|
return
|
|
|
|
with contextlib.suppress(Exception):
|
|
if torch.cuda.is_available():
|
|
torch.cuda.empty_cache()
|
|
|
|
mps = getattr(torch, "mps", None)
|
|
if mps is not None and hasattr(mps, "empty_cache"):
|
|
mps.empty_cache()
|
|
|
|
@classmethod
|
|
def unload(cls, key: str) -> bool:
|
|
"""Unload one cached model entry."""
|
|
return bool(cls.unload_many([key]))
|
|
|
|
@classmethod
|
|
def unload_many(cls, keys: list[str]) -> list[str]:
|
|
"""Unload several cached model entries with one runtime cleanup pass."""
|
|
instance = cls.get()
|
|
removed_keys: list[str] = []
|
|
|
|
with instance._model_lock:
|
|
for key in keys:
|
|
if key not in instance._models:
|
|
continue
|
|
value = instance._models.pop(key)
|
|
del value
|
|
removed_keys.append(key)
|
|
|
|
if removed_keys:
|
|
cls._release_runtime_memory()
|
|
return removed_keys
|
|
|
|
@classmethod
|
|
def unload_prefix(cls, prefix: str) -> list[str]:
|
|
"""Unload every cached model entry matching a prefix."""
|
|
instance = cls.get()
|
|
removed_keys: list[str] = []
|
|
|
|
with instance._model_lock:
|
|
for key in list(instance._models):
|
|
if key.startswith(prefix):
|
|
value = instance._models.pop(key)
|
|
del value
|
|
removed_keys.append(key)
|
|
|
|
if removed_keys:
|
|
cls._release_runtime_memory()
|
|
return removed_keys
|
|
|
|
# =========================================================================
|
|
# Sentence Transformers
|
|
# =========================================================================
|
|
|
|
@classmethod
|
|
def get_sentence_transformer(
|
|
cls,
|
|
model_name: str | None = None,
|
|
device: str | None = None,
|
|
) -> Any:
|
|
"""Get a shared SentenceTransformer instance.
|
|
|
|
Args:
|
|
model_name: Model name. If None, uses ML_MODEL_DEFAULTS.sentence_transformer.
|
|
device: Device to use (cuda, mps, cpu). Auto-detected if None.
|
|
|
|
Returns:
|
|
SentenceTransformer model instance.
|
|
"""
|
|
if model_name is None:
|
|
model_name = ML_MODEL_DEFAULTS.sentence_transformer
|
|
|
|
instance = cls.get()
|
|
key = f"sentence_transformer:{model_name}"
|
|
|
|
with instance._model_lock:
|
|
if key not in instance._models:
|
|
logger.info(f"Loading SentenceTransformer: {model_name}")
|
|
from sentence_transformers import SentenceTransformer
|
|
|
|
if device is None:
|
|
device = cls._detect_device()
|
|
|
|
model = SentenceTransformer(model_name, device=device)
|
|
instance._models[key] = model
|
|
logger.info(f"Loaded SentenceTransformer: {model_name} on {device}")
|
|
|
|
return instance._models[key]
|
|
|
|
# =========================================================================
|
|
# SIGLIP (Image Embeddings)
|
|
# =========================================================================
|
|
|
|
@classmethod
|
|
def get_siglip(
|
|
cls,
|
|
model_name: str | None = None,
|
|
device: str | None = None,
|
|
) -> tuple[Any, Any]:
|
|
"""Get shared SIGLIP model and processor.
|
|
|
|
Args:
|
|
model_name: Model name. If None, uses ML_MODEL_DEFAULTS.siglip.
|
|
device: Device to use. Auto-detected if None.
|
|
|
|
Returns:
|
|
Tuple of (model, processor).
|
|
"""
|
|
if model_name is None:
|
|
model_name = ML_MODEL_DEFAULTS.siglip
|
|
|
|
instance = cls.get()
|
|
key = f"siglip:{model_name}"
|
|
|
|
with instance._model_lock:
|
|
if key not in instance._models:
|
|
logger.info(f"Loading SIGLIP: {model_name}")
|
|
from transformers import AutoModel, AutoProcessor
|
|
|
|
if device is None:
|
|
device = cls._detect_device()
|
|
|
|
model = AutoModel.from_pretrained(model_name)
|
|
processor = AutoProcessor.from_pretrained(model_name)
|
|
|
|
# Move to device and set eval mode
|
|
if device != "cpu":
|
|
import torch
|
|
|
|
model = model.to(torch.device(device))
|
|
model.eval()
|
|
|
|
instance._models[key] = (model, processor)
|
|
logger.info(f"Loaded SIGLIP: {model_name} on {device}")
|
|
|
|
result: tuple[Any, Any] = instance._models[key]
|
|
return result
|
|
|
|
# =========================================================================
|
|
# spaCy
|
|
# =========================================================================
|
|
|
|
@classmethod
|
|
def get_spacy(cls, model_name: str | None = None) -> Any:
|
|
"""Get a shared spaCy model.
|
|
|
|
Args:
|
|
model_name: Model name. If None, uses ML_MODEL_DEFAULTS.spacy.
|
|
|
|
Returns:
|
|
spaCy Language model.
|
|
"""
|
|
if model_name is None:
|
|
model_name = ML_MODEL_DEFAULTS.spacy
|
|
|
|
instance = cls.get()
|
|
key = f"spacy:{model_name}"
|
|
|
|
with instance._model_lock:
|
|
if key not in instance._models:
|
|
logger.info(f"Loading spaCy: {model_name}")
|
|
import spacy
|
|
|
|
model = spacy.load(model_name)
|
|
instance._models[key] = model
|
|
logger.info(f"Loaded spaCy: {model_name}")
|
|
|
|
return instance._models[key]
|
|
|
|
# =========================================================================
|
|
# Technique Router (Sequence Classification)
|
|
# =========================================================================
|
|
|
|
@classmethod
|
|
def get_technique_router(
|
|
cls,
|
|
model_path: str | None = None,
|
|
device: str | None = None,
|
|
) -> tuple[Any, Any]:
|
|
"""Get shared technique router model and tokenizer.
|
|
|
|
Args:
|
|
model_path: Path to model (default: chopratejas/technique-router).
|
|
device: Device to use. Auto-detected if None.
|
|
|
|
Returns:
|
|
Tuple of (model, tokenizer).
|
|
"""
|
|
from pathlib import Path
|
|
|
|
instance = cls.get()
|
|
|
|
# Default to HuggingFace model, check for local first
|
|
if model_path is None:
|
|
local_path = Path("headroom/models/technique-router-mini/final/")
|
|
if local_path.exists():
|
|
model_path = str(local_path)
|
|
else:
|
|
model_path = ML_MODEL_DEFAULTS.technique_router
|
|
|
|
key = f"technique_router:{model_path}"
|
|
|
|
with instance._model_lock:
|
|
if key not in instance._models:
|
|
logger.info(f"Loading technique router: {model_path}")
|
|
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
|
|
|
if device is None:
|
|
device = cls._detect_device()
|
|
|
|
tokenizer = AutoTokenizer.from_pretrained(model_path)
|
|
model = AutoModelForSequenceClassification.from_pretrained(model_path)
|
|
|
|
# Move to device and set eval mode
|
|
if device != "cpu":
|
|
import torch
|
|
|
|
model = model.to(torch.device(device))
|
|
model.eval()
|
|
|
|
instance._models[key] = (model, tokenizer)
|
|
logger.info(f"Loaded technique router: {model_path} on {device}")
|
|
|
|
result: tuple[Any, Any] = instance._models[key]
|
|
return result
|
|
|
|
# =========================================================================
|
|
# Utility Methods
|
|
# =========================================================================
|
|
|
|
@classmethod
|
|
def _detect_device(cls) -> str:
|
|
"""Auto-detect the best available device."""
|
|
try:
|
|
import torch
|
|
|
|
if torch.cuda.is_available():
|
|
return "cuda"
|
|
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
|
return "mps"
|
|
except ImportError:
|
|
pass
|
|
return "cpu"
|
|
|
|
@classmethod
|
|
def loaded_models(cls) -> list[str]:
|
|
"""Get list of currently loaded model keys."""
|
|
instance = cls.get()
|
|
with instance._model_lock:
|
|
return list(instance._models.keys())
|
|
|
|
@classmethod
|
|
def is_loaded(cls, key: str) -> bool:
|
|
"""Check if a model is loaded."""
|
|
instance = cls.get()
|
|
with instance._model_lock:
|
|
return key in instance._models
|
|
|
|
@classmethod
|
|
def estimated_memory_mb(cls) -> float:
|
|
"""Estimate total memory used by loaded models."""
|
|
instance = cls.get()
|
|
total = 0.0
|
|
with instance._model_lock:
|
|
for key in instance._models:
|
|
# Extract model name from key (format: "type:model_name")
|
|
model_name = key.split(":", 1)[1] if ":" in key else key
|
|
total += ML_MODEL_DEFAULTS.get_memory_estimate(model_name)
|
|
return total
|
|
|
|
@classmethod
|
|
def get_memory_stats(cls) -> dict[str, Any]:
|
|
"""Get memory statistics for all loaded models."""
|
|
instance = cls.get()
|
|
loaded_models: list[dict[str, Any]] = []
|
|
total_estimated_mb: float = 0.0
|
|
|
|
with instance._model_lock:
|
|
for key in instance._models:
|
|
# Extract model name from key (format: "type:model_name")
|
|
model_name = key.split(":", 1)[1] if ":" in key else key
|
|
size_mb = ML_MODEL_DEFAULTS.get_memory_estimate(model_name)
|
|
loaded_models.append({"key": key, "size_mb": size_mb})
|
|
total_estimated_mb += size_mb
|
|
|
|
return {
|
|
"loaded_models": loaded_models,
|
|
"total_estimated_mb": total_estimated_mb,
|
|
}
|
|
|
|
|
|
# Convenience functions for direct access
|
|
def get_sentence_transformer(
|
|
model_name: str | None = None,
|
|
device: str | None = None,
|
|
) -> Any:
|
|
"""Get a shared SentenceTransformer instance."""
|
|
return MLModelRegistry.get_sentence_transformer(model_name, device)
|
|
|
|
|
|
def get_siglip(
|
|
model_name: str | None = None,
|
|
device: str | None = None,
|
|
) -> tuple[Any, Any]:
|
|
"""Get shared SIGLIP model and processor."""
|
|
return MLModelRegistry.get_siglip(model_name, device)
|
|
|
|
|
|
def get_spacy(model_name: str | None = None) -> Any:
|
|
"""Get a shared spaCy model."""
|
|
return MLModelRegistry.get_spacy(model_name)
|