Files

812 lines
29 KiB
Python

"""CPU-side Hugging Face snapshot staging for Modal LLM profiles."""
from __future__ import annotations
import json
import logging
import math
import os
import shutil
import time
from contextlib import contextmanager
from dataclasses import asdict, dataclass, replace
from pathlib import Path
from typing import Any, Callable, Iterator, Mapping
from uuid import uuid4
if __package__:
from .llm_profiles import LLMModelProfile, get_llm_profile, load_llm_profiles
from .snapshot_lease import (
_DEFAULT_LEASE_HEARTBEAT_STALE_SECONDS,
_local_lease_owner_is_alive,
_process_start_identity,
_snapshot_lease as _generic_snapshot_lease,
)
else: # pragma: no cover - the stable cloud entrypoint imports top-level modules.
from llm_profiles import LLMModelProfile, get_llm_profile, load_llm_profiles
from snapshot_lease import (
_DEFAULT_LEASE_HEARTBEAT_STALE_SECONDS,
_local_lease_owner_is_alive,
_process_start_identity,
_snapshot_lease as _generic_snapshot_lease,
)
logger = logging.getLogger(__name__)
_COMPLETE_MARKER_FILENAME = ".comfy-modal-llm-complete.json"
_DEFAULT_MINIMUM_FREE_DISK_BYTES = 8 * 1024**3
_SNAPSHOT_ALLOW_PATTERNS = (
"*.safetensors",
"*.safetensors.index.json",
"*.model",
"*.tiktoken",
"added_tokens.json",
"chat_template*",
"config.json",
"generation_config.json",
"hf_quant_config.json",
"merges.txt",
"preprocessor_config.json",
"processor*",
"special_tokens_map.json",
"tokenizer*",
"video_preprocessor_config.json",
"vocab*",
)
_TOKENIZER_ALLOW_PATTERNS = tuple(
pattern for pattern in _SNAPSHOT_ALLOW_PATTERNS if "safetensors" not in pattern
)
@dataclass(frozen=True)
class StagedModelSnapshot:
"""Describe a model snapshot made visible on the shared Modal Volume."""
profile_id: str
repository: str
revision: str
path: str
downloaded: bool
elapsed_seconds: float
@dataclass(frozen=True)
class LLMStagingProgress:
"""Describe one numeric Hugging Face snapshot-staging update."""
stage: str
message: str
value: float | None = None
maximum: float | None = None
unit: str | None = None
indeterminate: bool = False
model_reference: str | None = None
StagingProgressCallback = Callable[[LLMStagingProgress], None]
@dataclass(frozen=True)
class ResolvedStagedModelProfile:
"""Describe one resolved immutable profile and its staged snapshot."""
requested_reference: str
profile_id: str
repository: str
revision: str
backend: str
quantization_method: str
artifact_bytes: int
manifest_path: str | None
manifest_created: bool
security_scan_complete: bool
path: str
downloaded: bool
resolve_elapsed_seconds: float
elapsed_seconds: float
def to_dict(self) -> dict[str, Any]:
"""Return JSON-compatible staging metadata."""
return asdict(self)
def _snapshot_tqdm_class(
progress_callback: StagingProgressCallback,
) -> type[Any]:
"""Build a tqdm subclass that forwards Hugging Face file progress."""
from tqdm.auto import tqdm
class SnapshotProgressTqdm(tqdm):
"""Mirror snapshot_download's aggregate file bar to the caller."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Initialize the Hugging Face bar and report its initial state."""
super().__init__(*args, **kwargs)
self._report_progress()
def update(self, n: float | None = 1) -> bool | None:
"""Forward every aggregate file-count update."""
updated = super().update(n)
self._report_progress()
return updated
def _report_progress(self) -> None:
"""Publish the current tqdm count with compact user-facing copy."""
maximum = float(self.total) if self.total is not None else None
value = float(self.n)
description = str(
getattr(self, "desc", None) or "Downloading model snapshot"
).strip()
progress_callback(
LLMStagingProgress(
stage="download",
message=description,
value=value,
maximum=maximum,
unit=str(getattr(self, "unit", None) or "files"),
indeterminate=maximum is None,
)
)
return SnapshotProgressTqdm
def model_snapshot_path(storage_root: str | Path, profile: LLMModelProfile) -> Path:
"""Return the shared path, reusing a valid legacy profile path in place."""
resolved_root = Path(storage_root).resolve()
canonical_path = resolved_root / profile.storage_relative_path()
if canonical_path.exists():
return canonical_path
model_root = resolved_root / "llm_models"
for legacy_path in sorted(model_root.glob(f"*/{profile.revision}")):
if legacy_path != canonical_path and _legacy_snapshot_matches(
legacy_path,
profile,
):
logger.info(
"Reusing legacy Modal LLM weight path repository=%s revision=%s "
"path=%s.",
profile.repository,
profile.revision,
legacy_path,
)
return legacy_path
return canonical_path
def _marker_path(snapshot_path: Path) -> Path:
"""Return the completion marker path for one staged snapshot."""
return snapshot_path / _COMPLETE_MARKER_FILENAME
def _required_model_paths(
snapshot_path: Path,
profile: LLMModelProfile,
) -> tuple[Path, ...]:
"""Return every required model artifact inside one snapshot."""
model_filename = profile.backend_option("model_filename")
if model_filename is None:
return (snapshot_path / "config.json",)
filenames = [str(model_filename)]
mmproj_filename = profile.backend_option("mmproj_filename")
if mmproj_filename is not None:
filenames.append(str(mmproj_filename))
return tuple(snapshot_path / filename for filename in filenames)
def _tokenizer_source(profile: LLMModelProfile) -> tuple[str, str] | None:
"""Return an optional separately pinned tokenizer snapshot source."""
repository = profile.backend_option("tokenizer_repository")
revision = profile.backend_option("tokenizer_revision")
if repository is None and revision is None:
return None
normalized_repository = str(repository or "").strip()
normalized_revision = str(revision or "").strip().lower()
if "/" not in normalized_repository or len(normalized_revision) != 40:
raise ValueError(
f"Modal LLM profile {profile.profile_id!r} has an invalid separately "
"pinned tokenizer source."
)
return normalized_repository, normalized_revision
def _snapshot_has_required_artifacts(
snapshot_path: Path,
profile: LLMModelProfile,
) -> bool:
"""Return whether model and optional tokenizer artifacts are complete."""
if not all(
required_path.is_file()
for required_path in _required_model_paths(snapshot_path, profile)
):
return False
return (
_tokenizer_source(profile) is None
or (snapshot_path / "tokenizer_config.json").is_file()
)
def _model_allow_patterns(profile: LLMModelProfile) -> tuple[str, ...]:
"""Return a bounded allowlist for this profile's primary repository."""
model_filename = profile.backend_option("model_filename")
if model_filename is None:
return _SNAPSHOT_ALLOW_PATTERNS
selected_filenames = [str(model_filename)]
mmproj_filename = profile.backend_option("mmproj_filename")
if mmproj_filename is not None:
selected_filenames.append(str(mmproj_filename))
return (*_TOKENIZER_ALLOW_PATTERNS, *selected_filenames)
def _read_marker(snapshot_path: Path) -> dict[str, Any] | None:
"""Return a valid completion marker, if present."""
marker_path = _marker_path(snapshot_path)
try:
payload = json.loads(marker_path.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError, OSError):
return None
return payload if isinstance(payload, dict) else None
def is_model_snapshot_staged(
storage_root: str | Path, profile: LLMModelProfile
) -> bool:
"""Return whether the requested immutable snapshot is completely staged."""
snapshot_path = model_snapshot_path(storage_root, profile)
marker = _read_marker(snapshot_path)
return bool(
marker
and marker.get("repository") == profile.repository
and marker.get("revision") == profile.revision
and _snapshot_has_required_artifacts(snapshot_path, profile)
)
def _write_marker(snapshot_path: Path, profile: LLMModelProfile) -> None:
"""Atomically record that an immutable model snapshot is complete."""
marker_path = _marker_path(snapshot_path)
temporary_path = marker_path.with_suffix(".tmp")
marker_payload = {
"marker_version": 2,
"profile_id": profile.profile_id,
"repository": profile.repository,
"revision": profile.revision,
"completed_at_unix": time.time(),
}
temporary_path.write_text(
json.dumps(marker_payload, sort_keys=True, separators=(",", ":")),
encoding="utf-8",
)
os.replace(temporary_path, marker_path)
def _legacy_snapshot_matches(
snapshot_path: Path,
profile: LLMModelProfile,
) -> bool:
"""Return whether an old profile-keyed directory has these exact weights."""
marker = _read_marker(snapshot_path)
return bool(
marker
and marker.get("repository") == profile.repository
and marker.get("revision") == profile.revision
and _snapshot_has_required_artifacts(snapshot_path, profile)
)
@contextmanager
def _snapshot_lease(
snapshot_path: Path,
*,
progress_callback: StagingProgressCallback | None = None,
model_label: str,
owner_id: str,
) -> Iterator[None]:
"""Adapt generic lease wait messages to LLM staging progress events."""
def report_wait(message: str) -> None:
"""Publish one generic lease wait through the staging callback."""
if progress_callback is not None:
progress_callback(
LLMStagingProgress(
stage="waiting_for_download",
message=message,
indeterminate=True,
)
)
with _generic_snapshot_lease(
snapshot_path,
progress_callback=report_wait if progress_callback is not None else None,
model_label=model_label,
owner_id=owner_id,
):
yield
def _snapshot_existing_bytes(snapshot_path: Path) -> int:
"""Return bytes already present for a resumable model snapshot."""
total = 0
if not snapshot_path.exists():
return total
for candidate in snapshot_path.rglob("*"):
try:
if candidate.is_file() and not candidate.name.endswith(".lock"):
total += candidate.stat().st_size
except (FileNotFoundError, OSError):
continue
return total
def _minimum_free_disk_bytes() -> int:
"""Return the configured free-space reserve retained after staging."""
raw_value = os.getenv("COMFY_MODAL_LLM_MIN_FREE_DISK_GB")
if raw_value is None:
return _DEFAULT_MINIMUM_FREE_DISK_BYTES
value_gb = float(raw_value)
if not math.isfinite(value_gb) or value_gb < 0:
raise ValueError("COMFY_MODAL_LLM_MIN_FREE_DISK_GB must not be negative.")
return int(value_gb * 1024**3)
def _preflight_snapshot_capacity(
snapshot_path: Path,
profile: LLMModelProfile,
) -> tuple[int, int, int]:
"""Require room for missing artifacts plus a post-download safety reserve."""
snapshot_path.parent.mkdir(parents=True, exist_ok=True)
free_bytes = shutil.disk_usage(snapshot_path.parent).free
existing_bytes = _snapshot_existing_bytes(snapshot_path)
remaining_bytes = max(0, profile.artifact_bytes - existing_bytes)
required_bytes = remaining_bytes + _minimum_free_disk_bytes()
if free_bytes < required_bytes:
raise RuntimeError(
"Insufficient disk space to stage LLM profile "
f"{profile.profile_id!r}: {free_bytes / 1024**3:.2f} GiB free, "
f"{remaining_bytes / 1024**3:.2f} GiB still required, and "
f"{_minimum_free_disk_bytes() / 1024**3:.2f} GiB must remain free."
)
return free_bytes, remaining_bytes, required_bytes
def stage_model_profile(
profile_id: str,
storage_root: str | Path,
*,
profile: LLMModelProfile | None = None,
snapshot_download: Callable[..., str] | None = None,
progress_callback: StagingProgressCallback | None = None,
model_reference: str | None = None,
owner_id: str | None = None,
) -> StagedModelSnapshot:
"""Download one pinned curated or generated snapshot on a CPU worker."""
profile = profile or get_llm_profile(profile_id, storage_root=storage_root)
if profile.profile_id != profile_id:
raise ValueError(
f"Stage request id {profile_id!r} does not match profile "
f"{profile.profile_id!r}."
)
model_label = model_reference or profile.profile_id
def report(progress: LLMStagingProgress) -> None:
"""Attach the user-facing model reference to one staging event."""
if progress_callback is not None:
progress_callback(replace(progress, model_reference=model_label))
snapshot_path = model_snapshot_path(storage_root, profile)
started_at = time.perf_counter()
report(
LLMStagingProgress(
stage="snapshot_check",
message=f"Checking staged snapshot for {model_label}",
indeterminate=True,
)
)
if is_model_snapshot_staged(storage_root, profile):
logger.info(
"Reusing staged Modal LLM profile=%s revision=%s path=%s.",
profile.profile_id,
profile.revision,
snapshot_path,
)
report(
LLMStagingProgress(
stage="cached",
message=f"Using staged snapshot for {model_label}",
value=1,
maximum=1,
unit="model",
)
)
return StagedModelSnapshot(
profile_id=profile.profile_id,
repository=profile.repository,
revision=profile.revision,
path=str(snapshot_path),
downloaded=False,
elapsed_seconds=time.perf_counter() - started_at,
)
with _snapshot_lease(
snapshot_path,
progress_callback=report,
model_label=model_label,
owner_id=owner_id or f"pid-{os.getpid()}",
):
if is_model_snapshot_staged(storage_root, profile):
report(
LLMStagingProgress(
stage="cached",
message=(
f"Using snapshot staged by another worker for {model_label}"
),
value=1,
maximum=1,
unit="model",
)
)
return StagedModelSnapshot(
profile_id=profile.profile_id,
repository=profile.repository,
revision=profile.revision,
path=str(snapshot_path),
downloaded=False,
elapsed_seconds=time.perf_counter() - started_at,
)
free_bytes, remaining_bytes, _required_bytes = _preflight_snapshot_capacity(
snapshot_path,
profile,
)
report(
LLMStagingProgress(
stage="disk_check",
message=(
f"Storage ready for {model_label}: "
f"{free_bytes / 1024**3:.1f} GiB free, "
f"{remaining_bytes / 1024**3:.1f} GiB remaining"
),
value=free_bytes,
maximum=free_bytes,
unit="bytes",
)
)
if snapshot_download is None:
from huggingface_hub import (
snapshot_download as huggingface_snapshot_download,
)
snapshot_download = huggingface_snapshot_download
report(
LLMStagingProgress(
stage="download_preparing",
message=f"Preparing download for {model_label}",
indeterminate=True,
)
)
snapshot_path.mkdir(parents=True, exist_ok=True)
logger.info(
"Staging Modal LLM profile=%s repository=%s revision=%s to %s.",
profile.profile_id,
profile.repository,
profile.revision,
snapshot_path,
)
download_options: dict[str, Any] = {
"repo_id": profile.repository,
"revision": profile.revision,
"local_dir": str(snapshot_path),
"token": os.getenv("HF_TOKEN") or os.getenv("HUGGING_FACE_HUB_TOKEN"),
"allow_patterns": _model_allow_patterns(profile),
}
if progress_callback is not None:
download_options["tqdm_class"] = _snapshot_tqdm_class(report)
resolved_path = Path(snapshot_download(**download_options)).resolve()
if resolved_path != snapshot_path.resolve():
raise RuntimeError(
f"Hugging Face staged {profile.profile_id!r} at unexpected path "
f"{resolved_path}; expected {snapshot_path.resolve()}."
)
tokenizer_source = _tokenizer_source(profile)
if tokenizer_source is not None:
tokenizer_repository, tokenizer_revision = tokenizer_source
tokenizer_options: dict[str, Any] = {
"repo_id": tokenizer_repository,
"revision": tokenizer_revision,
"local_dir": str(snapshot_path),
"token": download_options["token"],
"allow_patterns": _TOKENIZER_ALLOW_PATTERNS,
}
if progress_callback is not None:
tokenizer_options["tqdm_class"] = _snapshot_tqdm_class(
report
)
tokenizer_path = Path(snapshot_download(**tokenizer_options)).resolve()
if tokenizer_path != snapshot_path.resolve():
raise RuntimeError(
f"Hugging Face staged tokenizer for {profile.profile_id!r} at "
f"unexpected path {tokenizer_path}; expected "
f"{snapshot_path.resolve()}."
)
if not _snapshot_has_required_artifacts(snapshot_path, profile):
raise RuntimeError(
f"Staged Modal LLM profile {profile.profile_id!r} is missing its "
"required model or tokenizer artifacts."
)
_write_marker(snapshot_path, profile)
report(
LLMStagingProgress(
stage="staged",
message=f"Model snapshot ready for {model_label}",
value=1,
maximum=1,
unit="model",
)
)
result = StagedModelSnapshot(
profile_id=profile.profile_id,
repository=profile.repository,
revision=profile.revision,
path=str(snapshot_path),
downloaded=True,
elapsed_seconds=time.perf_counter() - started_at,
)
logger.info("Completed Modal LLM staging: %s", asdict(result))
return result
def _persist_supplied_profile(
model_reference: str,
storage_root: str | Path,
supplied: Mapping[str, Any],
) -> tuple[LLMModelProfile, str | None, bool, bool]:
"""Validate planner metadata and persist a generated manifest remotely."""
raw_profile = supplied.get("profile", supplied)
if not isinstance(raw_profile, Mapping):
raise ValueError(
f"Resolved LLM profile for {model_reference!r} must contain an object."
)
profile = LLMModelProfile.from_mapping(raw_profile)
_validate_supplied_profile_reference(model_reference, profile)
if profile.source != "generated":
return profile, None, False, True
if __package__:
from .llm_profiles import generated_profile_manifest_path
else: # pragma: no cover - stable cloud entrypoint imports top-level modules.
from llm_profiles import generated_profile_manifest_path
manifest_path = generated_profile_manifest_path(storage_root, profile.profile_id)
manifest_payload = _supplied_profile_manifest_payload(
model_reference,
profile,
supplied,
)
existing_scan = _existing_supplied_profile_scan(
manifest_path,
raw_profile,
)
if existing_scan is not None:
return profile, str(manifest_path), False, existing_scan
manifest_path.parent.mkdir(parents=True, exist_ok=True)
temporary_path = manifest_path.with_suffix(f".{uuid4().hex}.tmp")
temporary_path.write_text(
json.dumps(manifest_payload, sort_keys=True, separators=(",", ":")),
encoding="utf-8",
)
os.replace(temporary_path, manifest_path)
return profile, str(manifest_path), True, bool(
manifest_payload["security_scan_complete"]
)
def _supplied_profile_manifest_payload(
model_reference: str,
profile: LLMModelProfile,
supplied: Mapping[str, Any],
) -> dict[str, Any]:
"""Build the generated manifest persisted from trusted planner metadata."""
security_scan_complete = supplied.get("security_scan_complete", False)
if not isinstance(security_scan_complete, bool):
raise ValueError(
f"Resolved LLM profile for {model_reference!r} has an invalid "
"security scan state."
)
return {
"schema_version": profile.schema_version,
"compatibility_policy_version": profile.compatibility_policy_version,
"requested_reference": model_reference,
"resolved_at_unix": time.time(),
"security_scan_complete": security_scan_complete,
"profile": profile.to_mapping(),
}
def _existing_supplied_profile_scan(
manifest_path: Path,
raw_profile: Mapping[str, Any],
) -> bool | None:
"""Return an existing matching manifest's scan state, or None if absent."""
if manifest_path.is_file():
try:
existing = json.loads(manifest_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as exc:
raise RuntimeError(
f"Resolved LLM manifest {manifest_path} is unreadable: {exc}"
) from exc
if not isinstance(existing, Mapping) or existing.get("profile") != raw_profile:
raise RuntimeError(
f"Resolved LLM profile collision at {manifest_path}."
)
return bool(existing.get("security_scan_complete", False))
return None
def _validate_supplied_profile_reference(
model_reference: str,
profile: LLMModelProfile,
) -> None:
"""Reject planner metadata that does not describe its requested reference."""
curated_profile = load_llm_profiles().get(model_reference)
if curated_profile is not None:
if profile != curated_profile:
raise ValueError(
f"Resolved metadata for curated profile {model_reference!r} does "
"not match the checked-in profile."
)
return
if model_reference.startswith("hf-"):
if profile.profile_id != model_reference:
raise ValueError(
f"Resolved metadata profile id {profile.profile_id!r} does not "
f"match requested id {model_reference!r}."
)
return
if profile.source != "generated":
raise ValueError(
f"Resolved metadata for Hugging Face reference {model_reference!r} "
"must contain a generated profile."
)
if __package__:
from .llm_resolver import HuggingFaceModelReference
else: # pragma: no cover - stable cloud entrypoint imports top-level modules.
from llm_resolver import HuggingFaceModelReference
parsed = HuggingFaceModelReference.parse(model_reference)
if profile.repository != parsed.repository:
raise ValueError(
f"Resolved metadata repository {profile.repository!r} does not match "
f"requested repository {parsed.repository!r}."
)
requested_revision = (parsed.requested_revision or "").lower()
if len(requested_revision) == 40 and requested_revision != profile.revision:
raise ValueError(
f"Resolved metadata revision {profile.revision!r} does not match "
f"requested revision {requested_revision!r}."
)
def _resolve_profile_for_staging(
model_reference: str,
storage_root: str | Path,
supplied_profile: Mapping[str, Any] | None = None,
) -> tuple[LLMModelProfile, str | None, bool, bool]:
"""Resolve one curated, generated, or Hugging Face model reference."""
if supplied_profile is not None:
return _persist_supplied_profile(
model_reference,
storage_root,
supplied_profile,
)
curated_profiles = load_llm_profiles()
if model_reference in curated_profiles:
return curated_profiles[model_reference], None, False, True
if model_reference.startswith("hf-"):
return (
get_llm_profile(model_reference, storage_root=storage_root),
None,
False,
True,
)
if __package__:
from .llm_resolver import resolve_model_profile
else: # pragma: no cover - stable cloud entrypoint imports top-level modules.
from llm_resolver import resolve_model_profile
resolved = resolve_model_profile(model_reference, storage_root)
return (
resolved.profile,
resolved.manifest_path,
resolved.manifest_created,
resolved.security_scan_complete,
)
def resolve_and_stage_model_references(
model_references: list[str],
storage_root: str | Path,
*,
progress_callback: StagingProgressCallback | None = None,
resolved_profiles: Mapping[str, Mapping[str, Any]] | None = None,
owner_id: str | None = None,
) -> list[ResolvedStagedModelProfile]:
"""Resolve and stage model references on any CPU-backed remote worker."""
results: list[ResolvedStagedModelProfile] = []
for model_reference in model_references:
supplied_profile = (resolved_profiles or {}).get(model_reference)
if progress_callback is not None:
is_hugging_face_reference = (
"/" in model_reference and not model_reference.startswith("hf-")
)
progress_callback(
LLMStagingProgress(
stage=(
"resolved_metadata"
if supplied_profile is not None
else "metadata"
if is_hugging_face_reference
else "profile_resolution"
),
message=(
f"Using planner-resolved metadata for {model_reference}"
if supplied_profile is not None
else f"Inspecting Hugging Face metadata for {model_reference}"
if is_hugging_face_reference
else f"Resolving model profile {model_reference}"
),
indeterminate=True,
model_reference=model_reference,
)
)
resolve_started_at = time.perf_counter()
(
profile,
manifest_path,
manifest_created,
scan_complete,
) = (
_resolve_profile_for_staging(
model_reference,
storage_root,
supplied_profile,
)
if supplied_profile is not None
else _resolve_profile_for_staging(model_reference, storage_root)
)
resolve_elapsed_seconds = time.perf_counter() - resolve_started_at
staged = stage_model_profile(
profile.profile_id,
storage_root,
profile=profile,
progress_callback=progress_callback,
model_reference=model_reference,
owner_id=owner_id,
)
results.append(
ResolvedStagedModelProfile(
requested_reference=model_reference,
profile_id=staged.profile_id,
repository=staged.repository,
revision=staged.revision,
backend=profile.backend,
quantization_method=profile.quantization_method,
artifact_bytes=profile.artifact_bytes,
manifest_path=manifest_path,
manifest_created=manifest_created,
security_scan_complete=scan_complete,
path=staged.path,
downloaded=staged.downloaded,
resolve_elapsed_seconds=resolve_elapsed_seconds,
elapsed_seconds=staged.elapsed_seconds,
)
)
return results
__all__ = [
"LLMStagingProgress",
"ResolvedStagedModelProfile",
"StagedModelSnapshot",
"is_model_snapshot_staged",
"model_snapshot_path",
"resolve_and_stage_model_references",
"stage_model_profile",
]