94057c3d3e
PR Test (NPU) / check-changes (push) Has been cancelled
PR Test (NPU) / pr-gate (push) Has been cancelled
PR Test (NPU) / set-image-config (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-1-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (0) (push) Has been cancelled
PR Test (NPU) / stage-b-test-2-npu-a2 (1) (push) Has been cancelled
PR Test (NPU) / stage-b-test-4-npu-a3 (push) Has been cancelled
PR Test (NPU) / stage-b-test-16-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-1-npu-a3 (push) Has been cancelled
PR Test (NPU) / multimodal-gen-test-2-npu-a3 (push) Has been cancelled
PR Test (Arm64) / pr-gate (push) Has been cancelled
PR Test (Arm64) / check-changes (push) Has been cancelled
PR Test (Arm64) / build-test (push) Has been cancelled
PR Test (sgl-router) / gate (push) Has been cancelled
PR Test (sgl-router) / tier-1 — lint (push) Has been cancelled
PR Test (sgl-router) / tier-2 — build + test (push) Has been cancelled
PR Test (sgl-router) / tier-3 — docker (placeholder) (push) Has been cancelled
PR Test (sgl-router) / tier-3 — k8s integration (push) Has been cancelled
PR Test (sgl-router) / tier-3 — e2e (push) Has been cancelled
PR Test (sgl-router) / finish (push) Has been cancelled
PR Test (NPU) / single-node-poc (map[name:qwen3_6_27b_w8a8_1p_in64k_out1k_50ms runner:linux-aarch64-a3-2 test_case:test/registered/ascend/performance/qwen3_6_27b/test_npu_qwen3_6_27b_w8a8_1p_in64k_out1k_50ms.py test_type:perf]) (push) Has been cancelled
PR Test (NPU) / pr-test-npu-finish (push) Has been cancelled
PR Test (Xeon) / pr-gate (push) Has been cancelled
PR Test (Xeon) / check-changes (push) Has been cancelled
PR Test (Xeon) / build-test (, xeon-gnr, base-b-test-cpu) (push) Has been cancelled
PR Test (XPU) / check-changes (push) Has been cancelled
PR Test (XPU) / pr-gate (push) Has been cancelled
PR Test (XPU) / stage-a-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / wait-for-stage-a (push) Has been cancelled
PR Test (XPU) / stage-b-test-1-gpu-xpu (push) Has been cancelled
PR Test (XPU) / finish (push) Has been cancelled
CI Model Inventory / build-inventory (push) Has been cancelled
Lint / lint (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Compilation Check (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Manual Policy (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark - Request Processing (push) Has been cancelled
PR Benchmark (SMG Components) / Benchmark Summary (push) Has been cancelled
PR Test (SMG) / build-wheel (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on windows (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (x86_64 - auto) (push) Has been cancelled
PR Test (SMG) / python-unit-tests (push) Has been cancelled
PR Test (SMG) / unit-tests (push) Has been cancelled
PR Test (SMG) / benchmarks (push) Has been cancelled
PR Test (SMG) / chat-completions (push) Has been cancelled
PR Test (SMG) / chat-completions-4gpu (push) Has been cancelled
PR Test (SMG) / e2e (push) Has been cancelled
PR Test (SMG) / docker-build-test (push) Has been cancelled
PR Test (SMG) / k8s-integration (push) Has been cancelled
PR Test (SMG) / finish (push) Has been cancelled
PR Test (SMG) / summarize-benchmarks (push) Has been cancelled
Release SGLang Model Gateway Docker Image / publish (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on macos (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - auto) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (aarch64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / build on linux (x86_64 - musllinux_1_1) (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Build SDist (push) Has been cancelled
Release SGLang Model Gateway to PyPI / Upload to PyPI (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (aarch64, 12.9, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu129-matrix (x86_64, 12.9, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu129 (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (aarch64, 13.0, 3.10, arm-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / build-cu130-matrix (x86_64, 13.0, 3.10, x64-kernel-build-node) (push) Has been cancelled
Release SGLang Kernels / release-cu130 (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 700) (push) Has been cancelled
Release SGLang Kernels / build-rocm-matrix (3.10, 720) (push) Has been cancelled
Release SGLang Kernels / release-rocm700 (push) Has been cancelled
Release SGLang Kernels / release-rocm720 (push) Has been cancelled
Release SGLang Kernels / build-musa43 (43, 3.10) (push) Has been cancelled
Release SGLang Kernels / release-musa43 (push) Has been cancelled
2100 lines
77 KiB
Python
2100 lines
77 KiB
Python
"""
|
|
CI-specific weight validation and cache cleanup utilities.
|
|
|
|
This module contains validation and cleanup logic that is ONLY used in CI environments.
|
|
These functions handle:
|
|
- Validating safetensors files for corruption
|
|
- Checking for missing shards in sharded models
|
|
- Cleaning up corrupted files (selective or full cache deletion)
|
|
- Automatic retry logic for corrupted downloads
|
|
- Validating config/tokenizer files completeness to enable offline mode
|
|
|
|
For regular users, weight_utils.py provides simple download functionality without
|
|
the overhead of validation and automatic cleanup. The CI-specific behavior is
|
|
gated by is_in_ci() checks in weight_utils.py.
|
|
"""
|
|
|
|
import glob as glob_module
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import tempfile
|
|
import time
|
|
from typing import List, Optional, Tuple
|
|
|
|
import safetensors
|
|
|
|
from sglang.srt.utils import log_info_on_rank0
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Validation marker version - increment when validation logic changes
|
|
# v2: Added trust_remote_code module validation (modeling_*.py must exist in snapshot)
|
|
# v3: Added remote file existence checks for hf_quant_config.json
|
|
# v5: Invalidate all previous markers to force fresh validation
|
|
VALIDATION_MARKER_VERSION = "5"
|
|
|
|
|
|
def _remote_file_exists(
|
|
repo_id: str, filename: str, revision: Optional[str], allow_remote_check: bool
|
|
) -> Optional[bool]:
|
|
"""
|
|
Check if a file exists on Hugging Face Hub for a specific revision.
|
|
|
|
Args:
|
|
repo_id: Repository ID (e.g., "meta-llama/Llama-2-7b-hf")
|
|
filename: File name to check (e.g., "hf_quant_config.json")
|
|
revision: Git revision (commit hash, branch, or tag). None means default branch.
|
|
allow_remote_check: Whether remote checks are allowed (e.g., CI validation phase)
|
|
|
|
Returns:
|
|
True if file exists on hub, False if it doesn't exist, None if we cannot determine
|
|
(network error or remote check not allowed - be conservative and assume incomplete)
|
|
"""
|
|
if not allow_remote_check:
|
|
logger.debug(
|
|
"Remote check disabled for %s/%s, returning None (unknown)",
|
|
repo_id,
|
|
filename,
|
|
)
|
|
return None
|
|
|
|
try:
|
|
from huggingface_hub import HfApi
|
|
|
|
api = HfApi()
|
|
exists = api.file_exists(repo_id=repo_id, filename=filename, revision=revision)
|
|
logger.debug(
|
|
"Remote file check: %s/%s (revision=%s) exists=%s",
|
|
repo_id,
|
|
filename,
|
|
revision or "default",
|
|
exists,
|
|
)
|
|
return exists
|
|
except Exception as e:
|
|
# Network errors, auth issues, repo not found, etc.
|
|
# Return None (unknown) - caller will treat as optional
|
|
logger.debug(
|
|
"Failed to check remote file existence for %s/%s (revision=%s): %s. "
|
|
"Will treat as optional.",
|
|
repo_id,
|
|
filename,
|
|
revision or "default",
|
|
e,
|
|
)
|
|
return None
|
|
|
|
|
|
def _get_validation_marker_path(snapshot_dir: str) -> Optional[str]:
|
|
"""
|
|
Get the path to validation marker file for a snapshot.
|
|
|
|
Marker is stored in /tmp to avoid permission issues with HF cache directory.
|
|
Marker key is sha256(snapshot_dir) to avoid any collisions regardless of
|
|
model_name_or_path format.
|
|
|
|
Args:
|
|
snapshot_dir: Path to snapshot directory
|
|
|
|
Returns:
|
|
Path to marker file or None if snapshot_dir is invalid
|
|
"""
|
|
if not snapshot_dir or not os.path.isdir(snapshot_dir):
|
|
return None
|
|
|
|
# Normalize path to avoid marker misses due to trailing slashes or symlinks
|
|
# realpath resolves symlinks, rstrip removes trailing slashes
|
|
normalized_dir = os.path.realpath(snapshot_dir).rstrip("/")
|
|
|
|
# Use sha256 of normalized snapshot_dir path as unique key
|
|
# This avoids any collision issues with repo naming or snapshot hash reuse
|
|
dir_hash = hashlib.sha256(normalized_dir.encode("utf-8")).hexdigest()[:12]
|
|
|
|
# Store in /tmp with directory hash
|
|
return f"/tmp/sglang_hf_validation_{dir_hash}.json"
|
|
|
|
|
|
def _get_per_run_marker_dir() -> str:
|
|
"""
|
|
Get the directory for per-run validation markers.
|
|
|
|
These markers are specific to the current CI run and are not shared across
|
|
runners. They are stored in a temporary directory that is cleaned up after
|
|
the run completes.
|
|
|
|
Returns:
|
|
Path to per-run marker directory
|
|
"""
|
|
# Prefer RUNNER_TEMP (GitHub Actions) or TMPDIR, fallback to /tmp
|
|
base_dir = os.environ.get("RUNNER_TEMP", os.environ.get("TMPDIR", "/tmp"))
|
|
marker_dir = os.path.join(base_dir, "sglang_ci_offline_markers")
|
|
os.makedirs(marker_dir, exist_ok=True)
|
|
return marker_dir
|
|
|
|
|
|
def _get_per_run_marker_path(snapshot_dir: str) -> Optional[str]:
|
|
"""
|
|
Get the path to per-run validation marker file for a snapshot.
|
|
|
|
Per-run markers are specific to the current CI run and are not shared
|
|
across runners. This prevents cross-runner cache state pollution.
|
|
|
|
Args:
|
|
snapshot_dir: Path to snapshot directory
|
|
|
|
Returns:
|
|
Path to per-run marker file or None if snapshot_dir is invalid
|
|
"""
|
|
if not snapshot_dir or not os.path.isdir(snapshot_dir):
|
|
return None
|
|
|
|
normalized_dir = os.path.realpath(snapshot_dir).rstrip("/")
|
|
dir_hash = hashlib.sha256(normalized_dir.encode("utf-8")).hexdigest()[:12]
|
|
|
|
marker_dir = _get_per_run_marker_dir()
|
|
return os.path.join(marker_dir, f"{dir_hash}.json")
|
|
|
|
|
|
def _read_per_run_marker(snapshot_dir: str) -> Optional[dict]:
|
|
"""
|
|
Read per-run validation marker for a snapshot.
|
|
|
|
Args:
|
|
snapshot_dir: Path to snapshot directory
|
|
|
|
Returns:
|
|
Marker dict if exists and valid, None otherwise
|
|
"""
|
|
marker_path = _get_per_run_marker_path(snapshot_dir)
|
|
if not marker_path or not os.path.exists(marker_path):
|
|
return None
|
|
|
|
try:
|
|
with open(marker_path, "r", encoding="utf-8") as f:
|
|
marker = json.load(f)
|
|
|
|
# Validate marker structure
|
|
if not isinstance(marker, dict):
|
|
return None
|
|
|
|
required_keys = ["timestamp", "model_id", "snapshot_hash", "validation_passed"]
|
|
if not all(k in marker for k in required_keys):
|
|
return None
|
|
|
|
if marker.get("validation_passed") is not True:
|
|
return None
|
|
|
|
return marker
|
|
|
|
except Exception as e:
|
|
logger.debug("Failed to read per-run marker from %s: %s", marker_path, e)
|
|
return None
|
|
|
|
|
|
def _write_per_run_marker(
|
|
snapshot_dir: str, model_id: str, required_files: Optional[list] = None
|
|
) -> None:
|
|
"""
|
|
Write per-run validation marker for a snapshot.
|
|
|
|
Args:
|
|
snapshot_dir: Path to snapshot directory
|
|
model_id: Model identifier
|
|
required_files: List of required files that were validated
|
|
"""
|
|
marker_path = _get_per_run_marker_path(snapshot_dir)
|
|
if not marker_path:
|
|
logger.debug("Cannot write per-run marker: invalid snapshot_dir")
|
|
return
|
|
|
|
from datetime import datetime
|
|
|
|
snapshot_hash = os.path.basename(snapshot_dir)
|
|
|
|
marker = {
|
|
"timestamp": datetime.utcnow().isoformat() + "Z",
|
|
"model_id": model_id,
|
|
"snapshot_hash": snapshot_hash,
|
|
"validation_passed": True,
|
|
"required_files": required_files or [],
|
|
}
|
|
|
|
try:
|
|
marker_dir = os.path.dirname(marker_path)
|
|
os.makedirs(marker_dir, exist_ok=True)
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w",
|
|
encoding="utf-8",
|
|
dir=marker_dir,
|
|
delete=False,
|
|
suffix=".tmp",
|
|
) as f:
|
|
temp_path = f.name
|
|
json.dump(marker, f, indent=2)
|
|
|
|
os.replace(temp_path, marker_path)
|
|
logger.debug("Wrote per-run marker to %s", marker_path)
|
|
except Exception as e:
|
|
logger.warning("Failed to write per-run marker to %s: %s", marker_path, e)
|
|
try:
|
|
if "temp_path" in locals() and os.path.exists(temp_path):
|
|
os.remove(temp_path)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _remove_per_run_marker(snapshot_dir: str) -> None:
|
|
"""
|
|
Remove per-run validation marker for a snapshot.
|
|
|
|
Args:
|
|
snapshot_dir: Path to snapshot directory
|
|
"""
|
|
marker_path = _get_per_run_marker_path(snapshot_dir)
|
|
if marker_path and os.path.exists(marker_path):
|
|
try:
|
|
os.remove(marker_path)
|
|
logger.debug("Removed per-run marker: %s", marker_path)
|
|
except Exception as e:
|
|
logger.warning("Failed to remove per-run marker %s: %s", marker_path, e)
|
|
|
|
|
|
def _read_validation_marker(snapshot_dir: str) -> Optional[dict]:
|
|
"""
|
|
Read validation marker for a snapshot.
|
|
|
|
Args:
|
|
snapshot_dir: Path to snapshot directory
|
|
|
|
Returns:
|
|
Marker dict with keys: version, validated_at, validation_passed
|
|
None if marker doesn't exist or is invalid or validation_passed is not True
|
|
"""
|
|
marker_path = _get_validation_marker_path(snapshot_dir)
|
|
if not marker_path:
|
|
return None
|
|
|
|
if not os.path.exists(marker_path):
|
|
return None
|
|
|
|
try:
|
|
with open(marker_path, "r", encoding="utf-8") as f:
|
|
marker = json.load(f)
|
|
|
|
# Validate marker structure
|
|
if not isinstance(marker, dict):
|
|
return None
|
|
|
|
required_keys = ["version", "validated_at", "validation_passed"]
|
|
if not all(key in marker for key in required_keys):
|
|
return None
|
|
|
|
# Check version match
|
|
if marker["version"] != VALIDATION_MARKER_VERSION:
|
|
logger.debug(
|
|
"Validation marker version mismatch: %s != %s, will re-validate",
|
|
marker["version"],
|
|
VALIDATION_MARKER_VERSION,
|
|
)
|
|
return None
|
|
|
|
# Explicitly check validation_passed is True (defensive check)
|
|
# Even though we only write markers on success, this guards against
|
|
# manual edits or future code changes
|
|
if marker.get("validation_passed") is not True:
|
|
logger.debug(
|
|
"Validation marker has validation_passed=%s, treating as invalid",
|
|
marker.get("validation_passed"),
|
|
)
|
|
return None
|
|
|
|
return marker
|
|
except (json.JSONDecodeError, OSError) as e:
|
|
logger.debug("Failed to read validation marker at %s: %s", marker_path, e)
|
|
return None
|
|
|
|
|
|
def _write_validation_marker(snapshot_dir: str, passed: bool) -> None:
|
|
"""
|
|
Write validation marker for a snapshot (atomic write).
|
|
|
|
IMPORTANT: We only cache successful validations. Failed validations are NOT
|
|
cached to allow retry after files are downloaded.
|
|
|
|
Args:
|
|
snapshot_dir: Path to snapshot directory
|
|
passed: Whether validation passed
|
|
"""
|
|
if not passed:
|
|
# Don't cache failures - allow retry on next launch
|
|
return
|
|
|
|
marker_path = _get_validation_marker_path(snapshot_dir)
|
|
if not marker_path:
|
|
logger.debug("Cannot write marker: invalid snapshot_dir")
|
|
return
|
|
|
|
from datetime import datetime
|
|
|
|
marker = {
|
|
"version": VALIDATION_MARKER_VERSION,
|
|
"validated_at": datetime.utcnow().isoformat() + "Z",
|
|
"validation_passed": passed,
|
|
}
|
|
|
|
try:
|
|
# Atomic write: write to temp file then os.replace
|
|
marker_dir = os.path.dirname(marker_path)
|
|
os.makedirs(marker_dir, exist_ok=True)
|
|
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w",
|
|
encoding="utf-8",
|
|
dir=marker_dir,
|
|
delete=False,
|
|
suffix=".tmp",
|
|
) as f:
|
|
temp_path = f.name
|
|
json.dump(marker, f, indent=2)
|
|
|
|
# Atomic replace (overwrites existing file if any)
|
|
os.replace(temp_path, marker_path)
|
|
logger.debug("Wrote validation marker to %s (passed=%s)", marker_path, passed)
|
|
except Exception as e:
|
|
logger.warning("Failed to write validation marker to %s: %s", marker_path, e)
|
|
# Clean up temp file if it exists
|
|
try:
|
|
if "temp_path" in locals() and os.path.exists(temp_path):
|
|
os.remove(temp_path)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _validate_json_file(file_path: str, file_name: str) -> bool:
|
|
"""
|
|
Validate that a JSON file exists, is non-empty, and can be parsed.
|
|
|
|
Args:
|
|
file_path: Path to the JSON file
|
|
file_name: Name of the file (for logging)
|
|
|
|
Returns:
|
|
True if the file is valid, False otherwise
|
|
"""
|
|
if not os.path.exists(file_path):
|
|
logger.debug("CI cache validation: %s not found at %s", file_name, file_path)
|
|
return False
|
|
|
|
if not os.path.isfile(file_path):
|
|
logger.warning(
|
|
"CI cache validation: %s is not a file: %s", file_name, file_path
|
|
)
|
|
return False
|
|
|
|
# Check if file is non-empty
|
|
try:
|
|
file_size = os.path.getsize(file_path)
|
|
if file_size == 0:
|
|
logger.warning("CI cache validation: %s is empty: %s", file_name, file_path)
|
|
return False
|
|
except OSError as e:
|
|
logger.warning("CI cache validation: Cannot get size of %s: %s", file_name, e)
|
|
return False
|
|
|
|
# Try to parse JSON
|
|
try:
|
|
with open(file_path, "r", encoding="utf-8") as f:
|
|
json.load(f)
|
|
return True
|
|
except json.JSONDecodeError as e:
|
|
logger.warning(
|
|
"CI cache validation: %s is not valid JSON: %s - %s",
|
|
file_name,
|
|
file_path,
|
|
e,
|
|
)
|
|
return False
|
|
except Exception as e:
|
|
logger.warning(
|
|
"CI cache validation: Failed to read %s: %s - %s",
|
|
file_name,
|
|
file_path,
|
|
e,
|
|
)
|
|
return False
|
|
|
|
|
|
def _validate_config_and_tokenizer_files(
|
|
snapshot_dir: str,
|
|
model_id: Optional[str] = None,
|
|
revision: Optional[str] = None,
|
|
allow_remote_check: bool = False,
|
|
) -> Tuple[bool, List[str]]:
|
|
"""
|
|
Validate that critical config and tokenizer files exist and are valid.
|
|
|
|
This checks for:
|
|
- config.json (required)
|
|
- tokenizer_config.json (required)
|
|
- generation_config.json (optional but validated if present)
|
|
- hf_quant_config.json (conditionally required based on Hub) - for FP4/FP8/ModelOpt
|
|
- quantize_config.json / quant_config.json (optional but validated if present) - for AWQ/GPTQ
|
|
- params.json (optional but validated if present) - for Mistral native format
|
|
- preprocessor_config.json (optional but validated if present) - for vision models
|
|
- trust_remote_code dynamic modules (required if auto_map present in config.json)
|
|
- At least one tokenizer file: tokenizer.json, tokenizer.model, or tiktoken.model
|
|
|
|
Args:
|
|
snapshot_dir: Path to the model snapshot directory
|
|
model_id: Model repository ID (e.g., "meta-llama/Llama-2-7b-hf"), used for remote checks
|
|
revision: Git revision (commit hash), used for remote checks
|
|
allow_remote_check: Whether to check Hub for file existence to determine requirements
|
|
|
|
Returns:
|
|
Tuple of (is_valid, missing_files)
|
|
- is_valid: True if all required files are present and valid
|
|
- missing_files: List of missing or invalid file names
|
|
"""
|
|
missing_files = []
|
|
|
|
# Check required config files
|
|
required_files = [
|
|
"config.json",
|
|
"tokenizer_config.json",
|
|
]
|
|
|
|
for file_name in required_files:
|
|
file_path = os.path.join(snapshot_dir, file_name)
|
|
if not _validate_json_file(file_path, file_name):
|
|
missing_files.append(file_name)
|
|
|
|
# Check optional generation_config.json (validate if exists)
|
|
generation_config_path = os.path.join(snapshot_dir, "generation_config.json")
|
|
if os.path.exists(generation_config_path):
|
|
if not _validate_json_file(generation_config_path, "generation_config.json"):
|
|
missing_files.append("generation_config.json (exists but invalid)")
|
|
|
|
# Check hf_quant_config.json with remote existence check
|
|
# This file is needed for quantized models (FP4/FP8/ModelOpt)
|
|
# Example: nvidia/Llama-3.1-8B-Instruct-FP8, nvidia/DeepSeek-V3-0324-FP4
|
|
hf_quant_config_path = os.path.join(snapshot_dir, "hf_quant_config.json")
|
|
local_hf_quant_exists = os.path.exists(hf_quant_config_path)
|
|
|
|
# Check if file exists on Hub for this revision
|
|
# Only do remote check if model_id looks like a HF repo_id (org/model format)
|
|
# Skip if it's a local path (absolute path or doesn't contain '/')
|
|
remote_hf_quant_exists = None
|
|
is_hf_repo = (
|
|
model_id is not None
|
|
and "/" in model_id
|
|
and not os.path.isabs(model_id)
|
|
and not model_id.startswith("/")
|
|
)
|
|
if is_hf_repo and allow_remote_check:
|
|
remote_hf_quant_exists = _remote_file_exists(
|
|
repo_id=model_id,
|
|
filename="hf_quant_config.json",
|
|
revision=revision,
|
|
allow_remote_check=allow_remote_check,
|
|
)
|
|
|
|
# Apply conditional requirement logic
|
|
if remote_hf_quant_exists is True:
|
|
# Hub has this file for this revision - it's REQUIRED
|
|
if not local_hf_quant_exists:
|
|
missing_files.append(
|
|
f"hf_quant_config.json (required: exists on Hub for revision {revision or 'default'} but missing locally)"
|
|
)
|
|
log_info_on_rank0(
|
|
logger,
|
|
f"Hub has hf_quant_config.json for {model_id} revision {revision or 'default'} "
|
|
f"but local snapshot missing it. Cache incomplete, will not write marker.",
|
|
)
|
|
elif not _validate_json_file(hf_quant_config_path, "hf_quant_config.json"):
|
|
missing_files.append("hf_quant_config.json (exists but invalid)")
|
|
elif remote_hf_quant_exists is False:
|
|
# Hub doesn't have this file - it's OPTIONAL
|
|
# Only validate if it happens to exist locally
|
|
if local_hf_quant_exists:
|
|
if not _validate_json_file(hf_quant_config_path, "hf_quant_config.json"):
|
|
missing_files.append("hf_quant_config.json (exists but invalid)")
|
|
else:
|
|
# remote_hf_quant_exists is None - unknown (network error or remote check disabled)
|
|
# Treat as OPTIONAL - only enforce when we can positively confirm Hub has it
|
|
if local_hf_quant_exists:
|
|
# Local file exists - validate it
|
|
if not _validate_json_file(hf_quant_config_path, "hf_quant_config.json"):
|
|
missing_files.append("hf_quant_config.json (exists but invalid)")
|
|
# If local file missing and remote unknown, just log it - don't block marker
|
|
logger.debug(
|
|
"Cannot verify hf_quant_config.json on Hub for %s (revision=%s), "
|
|
"treating as optional since remote status unknown",
|
|
model_id or "unknown",
|
|
revision or "default",
|
|
)
|
|
|
|
# Check optional quantize_config.json / quant_config.json (validate if exists)
|
|
# These files are needed for AWQ/GPTQ/AutoRound quantized models
|
|
# Example: TheBloke/Llama-2-7B-AWQ, casperhansen/vicuna-7b-v1.5-awq
|
|
for quant_config_name in ["quantize_config.json", "quant_config.json"]:
|
|
quant_config_path = os.path.join(snapshot_dir, quant_config_name)
|
|
if os.path.exists(quant_config_path):
|
|
if not _validate_json_file(quant_config_path, quant_config_name):
|
|
missing_files.append(f"{quant_config_name} (exists but invalid)")
|
|
break # Only need to check one of these
|
|
|
|
# Check optional params.json (validate if exists)
|
|
# This file is needed for Mistral native format models
|
|
# Example: mistralai/Mistral-7B-v0.1
|
|
params_json_path = os.path.join(snapshot_dir, "params.json")
|
|
if os.path.exists(params_json_path):
|
|
if not _validate_json_file(params_json_path, "params.json"):
|
|
missing_files.append("params.json (exists but invalid)")
|
|
|
|
# Check optional preprocessor_config.json (validate if exists)
|
|
# This file is needed for vision/multimodal models
|
|
# Example: llava-hf/llava-1.5-7b-hf, Qwen/Qwen2-VL-7B-Instruct
|
|
preprocessor_config_path = os.path.join(snapshot_dir, "preprocessor_config.json")
|
|
if os.path.exists(preprocessor_config_path):
|
|
if not _validate_json_file(
|
|
preprocessor_config_path, "preprocessor_config.json"
|
|
):
|
|
missing_files.append("preprocessor_config.json (exists but invalid)")
|
|
|
|
# Check for trust_remote_code dynamic module files if needed
|
|
# When auto_map exists in config.json, the model requires custom Python files
|
|
# These files must be present for offline mode to work
|
|
config_path = os.path.join(snapshot_dir, "config.json")
|
|
if os.path.exists(config_path):
|
|
try:
|
|
with open(config_path, "r", encoding="utf-8") as f:
|
|
config = json.load(f)
|
|
|
|
auto_map = config.get("auto_map", {})
|
|
if auto_map and isinstance(auto_map, dict):
|
|
# Extract Python module files from auto_map
|
|
# auto_map format: {"AutoConfig": "configuration_xxx.ConfigClass", ...}
|
|
# We need to check if the .py files exist
|
|
custom_files = set()
|
|
for key, value in auto_map.items():
|
|
if isinstance(value, str) and "." in value:
|
|
# Extract module name (e.g., "configuration_xxx" from "configuration_xxx.ConfigClass")
|
|
module_name = value.split(".")[0]
|
|
custom_files.add(f"{module_name}.py")
|
|
|
|
# Check if all custom files exist in snapshot directory
|
|
# NOTE: Some models (like nvidia/DeepSeek-V3-0324-FP4) have auto_map
|
|
# but don't include modeling_*.py in their repo, relying on transformers
|
|
# to fetch it from the base model. We MUST mark these as missing to
|
|
# prevent offline mode, which would fail to load the dynamic modules.
|
|
for custom_file in custom_files:
|
|
custom_file_path = os.path.join(snapshot_dir, custom_file)
|
|
if not os.path.exists(custom_file_path):
|
|
missing_files.append(
|
|
f"{custom_file} (required for trust_remote_code)"
|
|
)
|
|
logger.debug(
|
|
f"Custom module file not in snapshot: {custom_file} for {snapshot_dir}"
|
|
)
|
|
elif not os.path.isfile(custom_file_path):
|
|
missing_files.append(f"{custom_file} (exists but not a file)")
|
|
except (json.JSONDecodeError, OSError, KeyError) as e:
|
|
# If we can't read config.json, it will be caught by earlier validation
|
|
logger.debug("Failed to check auto_map in config.json: %s", e)
|
|
|
|
# Check for at least one tokenizer file
|
|
tokenizer_files = [
|
|
"tokenizer.json",
|
|
"tokenizer.model",
|
|
"tiktoken.model",
|
|
]
|
|
|
|
tokenizer_found = False
|
|
for tokenizer_file in tokenizer_files:
|
|
tokenizer_path = os.path.join(snapshot_dir, tokenizer_file)
|
|
if os.path.exists(tokenizer_path) and os.path.isfile(tokenizer_path):
|
|
# For tokenizer.json, validate it's proper JSON
|
|
if tokenizer_file == "tokenizer.json":
|
|
if _validate_json_file(tokenizer_path, tokenizer_file):
|
|
tokenizer_found = True
|
|
break
|
|
else:
|
|
# For .model files, just check they're non-empty
|
|
try:
|
|
if os.path.getsize(tokenizer_path) > 0:
|
|
tokenizer_found = True
|
|
break
|
|
except OSError:
|
|
pass
|
|
|
|
if not tokenizer_found:
|
|
missing_files.append("tokenizer file")
|
|
|
|
is_valid = len(missing_files) == 0
|
|
return is_valid, missing_files
|
|
|
|
|
|
def ci_validate_cache_and_enable_offline_if_complete(
|
|
snapshot_dir: str,
|
|
weight_files: List[str],
|
|
model_name_or_path: str,
|
|
) -> bool:
|
|
"""
|
|
Validate local cache completeness (config/tokenizer/weights) and determine
|
|
if offline mode can be safely enabled.
|
|
|
|
This function uses a snapshot-level marker to cache validation results,
|
|
so the heavy validation is done at most once per snapshot per runner.
|
|
|
|
This function checks:
|
|
1. Validation marker (if exists and version matches, skip re-validation)
|
|
2. Config and tokenizer files (config.json, tokenizer_config.json, etc.)
|
|
3. Weight files (safetensors shards, index files, corruption check)
|
|
|
|
If all are present and valid, it returns True to signal that offline
|
|
mode can be safely enabled.
|
|
|
|
IMPORTANT: This should be called BEFORE any HF operations, and if it
|
|
returns True, the caller should set HF_HUB_OFFLINE=1 for the server
|
|
subprocess env ONLY (not global environment).
|
|
|
|
Args:
|
|
snapshot_dir: Path to the model snapshot directory
|
|
weight_files: List of weight file paths to validate (must be non-empty)
|
|
model_name_or_path: Model identifier for logging
|
|
|
|
Returns:
|
|
True if cache is complete and offline mode can be enabled, False otherwise
|
|
"""
|
|
# Guard: weight_files is required
|
|
if not weight_files:
|
|
log_info_on_rank0(
|
|
logger,
|
|
f"CI_OFFLINE: No weight files provided, skip offline, keep online allowed - {model_name_or_path}",
|
|
)
|
|
return False
|
|
|
|
# Fast-path: Check if validation marker exists and is valid
|
|
# We only cache successful validations, so if marker exists, it means cache is complete
|
|
marker = _read_validation_marker(snapshot_dir)
|
|
if marker is not None:
|
|
marker_path = _get_validation_marker_path(snapshot_dir)
|
|
marker_name = os.path.basename(marker_path) if marker_path else "unknown"
|
|
log_info_on_rank0(
|
|
logger,
|
|
f"CI_OFFLINE: Marker hit (marker={marker_name}), skip re-validation, offline mode will be enabled - {model_name_or_path}",
|
|
)
|
|
return True
|
|
|
|
# No marker - perform full validation
|
|
# (Failures are not cached, so we'll retry validation each time until success)
|
|
|
|
# Extract revision (snapshot hash) from snapshot_dir path
|
|
# snapshot_dir format: /path/to/cache/models--org--model/snapshots/<commit_hash>
|
|
revision = os.path.basename(snapshot_dir)
|
|
|
|
# Only allow remote checks if we're not in offline mode
|
|
# This avoids unnecessary API calls and warnings in offline CI environments
|
|
import huggingface_hub.constants
|
|
|
|
allow_remote_check = not huggingface_hub.constants.HF_HUB_OFFLINE
|
|
|
|
log_info_on_rank0(
|
|
logger,
|
|
f"CI_OFFLINE: No marker found, performing full validation "
|
|
f"(snapshot={revision}, allow_remote_check={allow_remote_check}) - {model_name_or_path}",
|
|
)
|
|
|
|
# Validate config and tokenizer files with remote existence checks
|
|
config_valid, missing_config_files = _validate_config_and_tokenizer_files(
|
|
snapshot_dir=snapshot_dir,
|
|
model_id=model_name_or_path,
|
|
revision=revision,
|
|
allow_remote_check=allow_remote_check,
|
|
)
|
|
|
|
if not config_valid:
|
|
log_info_on_rank0(
|
|
logger,
|
|
f"CI_OFFLINE: Missing config/tokenizer files {missing_config_files}, skip offline, keep online allowed - {model_name_or_path}",
|
|
)
|
|
# Don't write marker for failures - allow retry after download
|
|
return False
|
|
|
|
# Validate weight files using existing validation from PR #15216
|
|
# This checks for missing shards, corrupted safetensors, etc.
|
|
weights_valid, error_msg, _ = _validate_sharded_model(snapshot_dir, weight_files)
|
|
if not weights_valid:
|
|
log_info_on_rank0(
|
|
logger,
|
|
f"CI_OFFLINE: Weight validation failed ({error_msg}), skip offline, keep online allowed - {model_name_or_path}",
|
|
)
|
|
# Don't write marker for failures - allow retry after download
|
|
return False
|
|
|
|
log_info_on_rank0(
|
|
logger,
|
|
f"CI_OFFLINE: Cache validation PASSED, offline mode will be enabled - {model_name_or_path}",
|
|
)
|
|
|
|
# Write marker with passed=True for future reuse
|
|
# (Failures are not cached, so this only happens on success)
|
|
_write_validation_marker(snapshot_dir, passed=True)
|
|
return True
|
|
|
|
|
|
def _infer_component_type(component_name: str, component_info: list) -> str:
|
|
"""
|
|
Infer component type from component name and info.
|
|
|
|
Args:
|
|
component_name: Name of the component (e.g., "scheduler", "tokenizer")
|
|
component_info: Component info from model_index.json (e.g., ["diffusers", "SchedulerClass"])
|
|
|
|
Returns:
|
|
Component type string for validation rules
|
|
"""
|
|
# Normalize component name for type detection
|
|
name_lower = component_name.lower()
|
|
|
|
# Infer type based on name
|
|
if "scheduler" in name_lower:
|
|
return "scheduler"
|
|
elif "tokenizer" in name_lower:
|
|
return "tokenizer"
|
|
elif "image_processor" in name_lower:
|
|
return "image_processor"
|
|
elif "feature_extractor" in name_lower:
|
|
return "feature_extractor"
|
|
elif "processor" in name_lower:
|
|
return "processor"
|
|
else:
|
|
# Default to model component (needs config.json + weights)
|
|
return "model"
|
|
|
|
|
|
def _check_component_config(
|
|
component_dir: str, component_type: str
|
|
) -> Tuple[bool, List[str]]:
|
|
"""
|
|
Check if component has required config files based on type.
|
|
|
|
Args:
|
|
component_dir: Path to component directory
|
|
component_type: Type of component (scheduler, tokenizer, processor, model, etc.)
|
|
|
|
Returns:
|
|
Tuple of (has_valid_config, list_of_candidates_tried)
|
|
"""
|
|
if component_type == "scheduler":
|
|
# Scheduler: scheduler_config.json or config.json
|
|
candidates = ["scheduler_config.json", "config.json"]
|
|
for candidate in candidates:
|
|
candidate_path = os.path.join(component_dir, candidate)
|
|
if _validate_json_file(candidate_path, candidate):
|
|
return True, candidates
|
|
return False, candidates
|
|
|
|
elif component_type == "tokenizer":
|
|
# Tokenizer must have actual tokenizer files (not just tokenizer_config.json)
|
|
# Valid combinations:
|
|
# - tokenizer.json
|
|
# - tokenizer.model
|
|
# - vocab.json + merges.txt
|
|
candidates = [
|
|
"tokenizer.json",
|
|
"tokenizer.model",
|
|
"vocab.json+merges.txt",
|
|
]
|
|
|
|
# Check tokenizer.json (validate as JSON)
|
|
tokenizer_json_path = os.path.join(component_dir, "tokenizer.json")
|
|
if _validate_json_file(tokenizer_json_path, "tokenizer.json"):
|
|
return True, candidates
|
|
|
|
# Check tokenizer.model (non-empty file)
|
|
tokenizer_model_path = os.path.join(component_dir, "tokenizer.model")
|
|
if os.path.exists(tokenizer_model_path) and os.path.isfile(
|
|
tokenizer_model_path
|
|
):
|
|
try:
|
|
if os.path.getsize(tokenizer_model_path) > 0:
|
|
return True, candidates
|
|
except OSError:
|
|
pass
|
|
|
|
# Check vocab.json + merges.txt pair
|
|
vocab_path = os.path.join(component_dir, "vocab.json")
|
|
merges_path = os.path.join(component_dir, "merges.txt")
|
|
if _validate_json_file(vocab_path, "vocab.json") and os.path.exists(
|
|
merges_path
|
|
):
|
|
return True, candidates
|
|
|
|
return False, candidates
|
|
|
|
elif component_type in ["processor", "feature_extractor", "image_processor"]:
|
|
# Processor/feature_extractor/image_processor: preprocessor_config.json or config.json
|
|
candidates = ["preprocessor_config.json", "config.json"]
|
|
for candidate in candidates:
|
|
candidate_path = os.path.join(component_dir, candidate)
|
|
if _validate_json_file(candidate_path, candidate):
|
|
return True, candidates
|
|
return False, candidates
|
|
|
|
else:
|
|
# Default model components: config.json
|
|
candidates = ["config.json"]
|
|
config_path = os.path.join(component_dir, "config.json")
|
|
if _validate_json_file(config_path, "config.json"):
|
|
return True, candidates
|
|
return False, candidates
|
|
|
|
|
|
def _check_component_weights(component_dir: str) -> bool:
|
|
"""
|
|
Check if component directory has weight files.
|
|
|
|
Args:
|
|
component_dir: Path to component directory
|
|
|
|
Returns:
|
|
True if weight files found, False otherwise
|
|
"""
|
|
weight_patterns = ["*.safetensors", "*.bin", "*.pt", "*.pth"]
|
|
|
|
for pattern in weight_patterns:
|
|
weight_files = glob_module.glob(os.path.join(component_dir, pattern))
|
|
if weight_files:
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def _format_component_list(components: List[str], max_show: int = 5) -> str:
|
|
"""
|
|
Format component list with truncation.
|
|
|
|
Args:
|
|
components: List of component names
|
|
max_show: Maximum number to show before truncating
|
|
|
|
Returns:
|
|
Formatted string like "comp1, comp2, comp3" or "comp1, comp2, +3 more"
|
|
"""
|
|
if len(components) <= max_show:
|
|
return ", ".join(components)
|
|
else:
|
|
shown = components[:max_show]
|
|
remaining = len(components) - max_show
|
|
return f"{', '.join(shown)}, +{remaining} more"
|
|
|
|
|
|
def _validate_diffusion_model(
|
|
snapshot_dir: str,
|
|
) -> Tuple[bool, Optional[str]]:
|
|
"""
|
|
Validate diffusion model (diffusers pipeline) cache completeness.
|
|
|
|
This validation is based on model_index.json as the single source of truth.
|
|
Error reporting uses coarse-grained error codes unless verbose mode is enabled.
|
|
|
|
Error codes:
|
|
- DIFFUSERS_INVALID_INDEX: model_index.json missing or corrupted
|
|
- DIFFUSERS_INVALID_COMPONENTS: model_index.json has no valid components
|
|
- DIFFUSERS_MISSING_COMPONENT: component directory or config missing
|
|
- DIFFUSERS_MISSING_WEIGHTS: component weights missing
|
|
|
|
Args:
|
|
snapshot_dir: Path to the model snapshot directory
|
|
|
|
Returns:
|
|
Tuple of (is_valid, error_message)
|
|
- (True, None) if validation passed
|
|
- (False, error_code_with_components) if validation failed
|
|
"""
|
|
# Check verbose mode from environment
|
|
verbose = os.environ.get("SGLANG_CI_VALIDATE_VERBOSE") == "1"
|
|
|
|
# 1. Check for model_index.json (required for diffusers models)
|
|
model_index_path = os.path.join(snapshot_dir, "model_index.json")
|
|
if not os.path.exists(model_index_path):
|
|
return False, "DIFFUSERS_INVALID_INDEX: model_index.json not found"
|
|
|
|
# Parse model_index.json
|
|
try:
|
|
with open(model_index_path, "r", encoding="utf-8") as f:
|
|
model_index = json.load(f)
|
|
except (json.JSONDecodeError, OSError) as e:
|
|
if verbose:
|
|
return False, f"DIFFUSERS_INVALID_INDEX: model_index.json parse error - {e}"
|
|
return False, "DIFFUSERS_INVALID_INDEX: model_index.json corrupted"
|
|
|
|
# 2. Extract components (non-underscore keys with list values)
|
|
components = {
|
|
k: v
|
|
for k, v in model_index.items()
|
|
if not k.startswith("_") and isinstance(v, list)
|
|
}
|
|
|
|
if not components:
|
|
return False, "DIFFUSERS_INVALID_COMPONENTS: no valid components defined"
|
|
|
|
# Categorize errors by type
|
|
missing_dirs = []
|
|
missing_configs = []
|
|
missing_configs_verbose = []
|
|
missing_weights = []
|
|
|
|
# 3. Validate each component
|
|
for component_name, component_info in components.items():
|
|
component_dir = os.path.join(snapshot_dir, component_name)
|
|
|
|
# Component directory must exist
|
|
if not os.path.isdir(component_dir):
|
|
missing_dirs.append(component_name)
|
|
continue
|
|
|
|
# Infer component type for validation rules
|
|
component_type = _infer_component_type(component_name, component_info)
|
|
|
|
# Check for required config files based on component type
|
|
has_valid_config, config_candidates = _check_component_config(
|
|
component_dir, component_type
|
|
)
|
|
|
|
if not has_valid_config:
|
|
missing_configs.append(component_name)
|
|
if verbose:
|
|
candidates_str = ", ".join(config_candidates)
|
|
missing_configs_verbose.append(
|
|
f"{component_name} (tried: {candidates_str})"
|
|
)
|
|
continue
|
|
|
|
# 4. Check for weights if component needs them
|
|
# These components don't require weight files (config-only)
|
|
needs_weights = component_type not in [
|
|
"scheduler",
|
|
"tokenizer",
|
|
"processor",
|
|
"feature_extractor",
|
|
"image_processor",
|
|
]
|
|
|
|
if needs_weights:
|
|
has_weights = _check_component_weights(component_dir)
|
|
if not has_weights:
|
|
missing_weights.append(component_name)
|
|
|
|
# 5. Build error message based on categorized errors
|
|
if missing_dirs or missing_configs or missing_weights:
|
|
errors = []
|
|
|
|
if missing_dirs:
|
|
dir_str = _format_component_list(missing_dirs)
|
|
if verbose:
|
|
errors.append(f"DIFFUSERS_MISSING_COMPONENT (dirs): {dir_str}")
|
|
else:
|
|
errors.append(f"DIFFUSERS_MISSING_COMPONENT(dir): {dir_str}")
|
|
|
|
if missing_configs:
|
|
if verbose:
|
|
config_str = "; ".join(missing_configs_verbose)
|
|
errors.append(f"DIFFUSERS_MISSING_COMPONENT (configs): {config_str}")
|
|
else:
|
|
config_str = _format_component_list(missing_configs)
|
|
errors.append(f"DIFFUSERS_MISSING_COMPONENT(cfg): {config_str}")
|
|
|
|
if missing_weights:
|
|
weight_str = _format_component_list(missing_weights)
|
|
errors.append(f"DIFFUSERS_MISSING_WEIGHTS: {weight_str}")
|
|
|
|
return False, " | ".join(errors)
|
|
|
|
return True, None
|
|
|
|
|
|
def validate_cache_with_detailed_reason(
|
|
snapshot_dir: str, weight_files: List[str], model_name_or_path: str
|
|
) -> Tuple[bool, Optional[str]]:
|
|
"""
|
|
Validate cache and return detailed reason for failure.
|
|
|
|
This function performs validation without relying on shared validation markers.
|
|
Used by prevalidate_cached_models.py to provide detailed feedback.
|
|
|
|
Args:
|
|
snapshot_dir: Path to the model snapshot directory
|
|
weight_files: List of weight file paths to validate
|
|
model_name_or_path: Model identifier for logging
|
|
|
|
Returns:
|
|
Tuple of (success, reason):
|
|
- (True, None) if validation passed
|
|
- (False, reason_str) if validation failed with specific reason
|
|
"""
|
|
# Guard: weight_files is required
|
|
if not weight_files:
|
|
return False, "No weight files provided"
|
|
|
|
# Perform full validation and capture failure reasons
|
|
revision = os.path.basename(snapshot_dir)
|
|
|
|
# Read from environment variable instead of huggingface_hub.constants
|
|
allow_remote_check = os.environ.get("HF_HUB_OFFLINE") != "1"
|
|
|
|
# Validate config and tokenizer files
|
|
config_valid, missing_config_files = _validate_config_and_tokenizer_files(
|
|
snapshot_dir=snapshot_dir,
|
|
model_id=model_name_or_path,
|
|
revision=revision,
|
|
allow_remote_check=allow_remote_check,
|
|
)
|
|
|
|
if not config_valid:
|
|
missing_files_str = ", ".join(missing_config_files)
|
|
return False, f"Missing config/tokenizer files: {missing_files_str}"
|
|
|
|
# Validate weight files
|
|
weights_valid, error_msg, _ = _validate_sharded_model(snapshot_dir, weight_files)
|
|
if not weights_valid:
|
|
return False, f"Weight validation failed: {error_msg}"
|
|
|
|
# All validations passed
|
|
return True, None
|
|
|
|
|
|
def validate_cache_lightweight(
|
|
snapshot_dir: str, requires_hf_quant_config: bool = False
|
|
) -> bool:
|
|
"""
|
|
Lightweight runtime validation for cache completeness.
|
|
|
|
This is used during test runs to ensure the current runner's cache
|
|
is complete before enabling offline mode. Much faster than full validation
|
|
as it only checks file existence, not corruption.
|
|
|
|
Args:
|
|
snapshot_dir: Path to the model snapshot directory
|
|
requires_hf_quant_config: If True, hf_quant_config.json must exist
|
|
(required for modelopt quantization)
|
|
|
|
Returns:
|
|
True if cache is complete, False otherwise
|
|
"""
|
|
# Check required config files
|
|
required_files = [
|
|
"config.json",
|
|
"tokenizer_config.json",
|
|
]
|
|
|
|
for fname in required_files:
|
|
if not os.path.exists(os.path.join(snapshot_dir, fname)):
|
|
return False
|
|
|
|
# Check tokenizer files (at least one must exist)
|
|
tokenizer_files = [
|
|
"tokenizer.json",
|
|
"tokenizer.model",
|
|
"tiktoken.model",
|
|
]
|
|
|
|
has_tokenizer = any(
|
|
os.path.exists(os.path.join(snapshot_dir, fname)) for fname in tokenizer_files
|
|
)
|
|
if not has_tokenizer:
|
|
return False
|
|
|
|
# Check for trust_remote_code dynamic module files if needed
|
|
# When auto_map exists in config.json, the model requires custom Python files
|
|
# These files must be present for offline mode to work
|
|
config_path = os.path.join(snapshot_dir, "config.json")
|
|
if os.path.exists(config_path):
|
|
try:
|
|
with open(config_path, "r", encoding="utf-8") as f:
|
|
config = json.load(f)
|
|
|
|
auto_map = config.get("auto_map", {})
|
|
if auto_map and isinstance(auto_map, dict):
|
|
# Extract Python module files from auto_map
|
|
# auto_map format: {"AutoConfig": "configuration_xxx.ConfigClass", ...}
|
|
# We need to check if the .py files exist
|
|
custom_files = set()
|
|
for key, value in auto_map.items():
|
|
if isinstance(value, str) and "." in value:
|
|
# Extract module name (e.g., "configuration_xxx" from "configuration_xxx.ConfigClass")
|
|
module_name = value.split(".")[0]
|
|
custom_files.add(f"{module_name}.py")
|
|
|
|
# Check if all custom files exist in snapshot directory
|
|
for custom_file in custom_files:
|
|
custom_file_path = os.path.join(snapshot_dir, custom_file)
|
|
if not os.path.exists(custom_file_path):
|
|
logger.debug(
|
|
"Custom module file not in snapshot: %s for %s",
|
|
custom_file,
|
|
snapshot_dir,
|
|
)
|
|
return False
|
|
elif not os.path.isfile(custom_file_path):
|
|
logger.debug(
|
|
"Custom module path exists but not a file: %s",
|
|
custom_file_path,
|
|
)
|
|
return False
|
|
except (json.JSONDecodeError, OSError, KeyError) as e:
|
|
# If we can't read config.json, it will be caught by earlier validation
|
|
logger.debug("Failed to check auto_map in config.json: %s", e)
|
|
|
|
# Check for weight files with index self-consistency
|
|
index_path = os.path.join(snapshot_dir, "model.safetensors.index.json")
|
|
has_index = os.path.exists(index_path)
|
|
|
|
if has_index:
|
|
# If index exists, validate that all shards listed in it exist
|
|
try:
|
|
with open(index_path, "r", encoding="utf-8") as f:
|
|
index_data = json.load(f)
|
|
weight_map = index_data.get("weight_map", {})
|
|
if weight_map:
|
|
# Check that all shard files referenced in index exist
|
|
required_shards = set(weight_map.values())
|
|
for shard_name in required_shards:
|
|
shard_path = os.path.join(snapshot_dir, shard_name)
|
|
if not os.path.exists(shard_path):
|
|
logger.debug(
|
|
"Index validation failed: missing shard %s in %s",
|
|
shard_name,
|
|
snapshot_dir,
|
|
)
|
|
return False
|
|
except (json.JSONDecodeError, OSError, KeyError) as e:
|
|
logger.debug("Failed to validate index file %s: %s", index_path, e)
|
|
return False
|
|
else:
|
|
# No index file - check for weight files and validate shard completeness
|
|
safetensors_files = glob_module.glob(
|
|
os.path.join(snapshot_dir, "*.safetensors")
|
|
)
|
|
if not safetensors_files:
|
|
return False
|
|
|
|
# Check shard completeness for sharded models (e.g., model-00001-of-00047.safetensors)
|
|
# Pattern: prefix-NNNNN-of-NNNNN.safetensors
|
|
shard_pattern = re.compile(r"(.*?)-(\d+)-of-(\d+)\.safetensors$")
|
|
shard_groups = {}
|
|
|
|
for f in safetensors_files:
|
|
base_name = os.path.basename(f)
|
|
match = shard_pattern.match(base_name)
|
|
if match:
|
|
prefix = match.group(1)
|
|
shard_id = int(match.group(2))
|
|
total_shards = int(match.group(3))
|
|
group_key = f"{prefix}-of-{total_shards}"
|
|
|
|
if group_key not in shard_groups:
|
|
shard_groups[group_key] = {
|
|
"total": total_shards,
|
|
"found_shards": set(),
|
|
}
|
|
shard_groups[group_key]["found_shards"].add(shard_id)
|
|
|
|
# Validate each shard group has all expected shards
|
|
for group_key, group_info in shard_groups.items():
|
|
total_shards = group_info["total"]
|
|
found_shards = group_info["found_shards"]
|
|
expected_shards = set(range(1, total_shards + 1))
|
|
missing_shards = expected_shards - found_shards
|
|
|
|
if missing_shards:
|
|
logger.debug(
|
|
"Shard validation failed: missing shards %s in %s for %s",
|
|
sorted(missing_shards),
|
|
group_key,
|
|
snapshot_dir,
|
|
)
|
|
return False
|
|
|
|
# Check hf_quant_config.json if required (for modelopt quantization)
|
|
if requires_hf_quant_config:
|
|
hf_quant_path = os.path.join(snapshot_dir, "hf_quant_config.json")
|
|
if not os.path.exists(hf_quant_path):
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def _validate_safetensors_file(file_path: str) -> bool:
|
|
"""
|
|
Validate that a safetensors file is readable and not corrupted.
|
|
|
|
Args:
|
|
file_path: Path to the safetensors file
|
|
|
|
Returns:
|
|
True if the file is valid, False if corrupted
|
|
"""
|
|
try:
|
|
# Attempt to open and read the header
|
|
# This will fail if the file is corrupted or incomplete
|
|
with safetensors.safe_open(file_path, framework="pt", device="cpu") as f:
|
|
# Just accessing the keys validates the header is readable
|
|
_ = list(f.keys())
|
|
return True
|
|
except Exception as e:
|
|
logger.warning(
|
|
"Corrupted safetensors file detected: %s - %s: %s",
|
|
file_path,
|
|
type(e).__name__,
|
|
str(e),
|
|
)
|
|
return False
|
|
|
|
|
|
def _validate_pytorch_bin_file(file_path: str) -> bool:
|
|
"""
|
|
Validate that a PyTorch .bin file is readable and not corrupted.
|
|
|
|
This catches corruption issues like truncated downloads or invalid archives
|
|
that would cause errors like:
|
|
"RuntimeError: PytorchStreamReader failed reading file data/X: invalid header
|
|
or archive is corrupted"
|
|
|
|
Args:
|
|
file_path: Path to the .bin file
|
|
|
|
Returns:
|
|
True if the file is valid, False if corrupted
|
|
"""
|
|
try:
|
|
import torch
|
|
|
|
# Use weights_only=True for security and to avoid executing arbitrary code
|
|
# mmap=False to fully read the file and catch all corruption
|
|
torch.load(file_path, map_location="cpu", weights_only=True, mmap=False)
|
|
return True
|
|
except Exception as e:
|
|
logger.warning(
|
|
"Corrupted PyTorch bin file detected: %s - %s: %s",
|
|
file_path,
|
|
type(e).__name__,
|
|
str(e),
|
|
)
|
|
return False
|
|
|
|
|
|
def _check_index_files_exist(snapshot_dir: str) -> Tuple[bool, Optional[str]]:
|
|
"""
|
|
Check if all files listed in safetensors index files actually exist on disk.
|
|
|
|
This catches cases where the snapshot directory exists but files are missing
|
|
(e.g., due to incomplete downloads or corrupted cache).
|
|
|
|
Args:
|
|
snapshot_dir: Path to the model snapshot directory
|
|
|
|
Returns:
|
|
Tuple of (all_exist, error_message)
|
|
"""
|
|
# Find all safetensors index files
|
|
index_files = [
|
|
f for f in os.listdir(snapshot_dir) if f.endswith(".safetensors.index.json")
|
|
]
|
|
|
|
if not index_files:
|
|
# No index files means it's not a sharded model, skip this check
|
|
return True, None
|
|
|
|
for index_file in index_files:
|
|
index_path = os.path.join(snapshot_dir, index_file)
|
|
|
|
# Check if index file is a broken symlink (exists in listing but blob missing)
|
|
if os.path.islink(index_path) and not os.path.exists(index_path):
|
|
# Broken symlink - clean it up so download can proceed
|
|
try:
|
|
blob_path = os.path.realpath(index_path)
|
|
os.remove(index_path)
|
|
logger.warning(
|
|
"Removed broken index symlink: %s (blob missing)", index_file
|
|
)
|
|
# Also try to remove dangling blob reference if it somehow exists
|
|
if os.path.exists(blob_path):
|
|
os.remove(blob_path)
|
|
except Exception as e:
|
|
logger.error("Failed to remove broken symlink %s: %s", index_file, e)
|
|
return (
|
|
False,
|
|
f"Broken index file symlink: {index_file} (cleaned up, will re-download)",
|
|
)
|
|
|
|
try:
|
|
with open(index_path) as f:
|
|
index_data = json.load(f)
|
|
|
|
weight_map = index_data.get("weight_map", {})
|
|
if not weight_map:
|
|
continue
|
|
|
|
# Check that all files in weight_map exist
|
|
required_files = set(weight_map.values())
|
|
missing_files = []
|
|
|
|
for file_name in required_files:
|
|
file_path = os.path.join(snapshot_dir, file_name)
|
|
# Check both existence and that it's not a broken symlink
|
|
if not os.path.exists(file_path):
|
|
missing_files.append(file_name)
|
|
|
|
if missing_files:
|
|
return (
|
|
False,
|
|
f"Missing {len(missing_files)} file(s) from index {index_file}: {missing_files[:3]}{'...' if len(missing_files) > 3 else ''}",
|
|
)
|
|
|
|
except FileNotFoundError as e:
|
|
# Index file was listed but can't be read - could be race condition or broken state
|
|
logger.warning("Failed to read index file %s: %s", index_file, e)
|
|
return (
|
|
False,
|
|
f"Index file {index_file} unreadable (will re-download)",
|
|
)
|
|
except Exception as e:
|
|
logger.warning("Failed to read index file %s: %s", index_file, e)
|
|
continue
|
|
|
|
return True, None
|
|
|
|
|
|
def _validate_sharded_model(
|
|
snapshot_dir: str, weight_files: List[str]
|
|
) -> Tuple[bool, Optional[str], List[str]]:
|
|
"""
|
|
Validate that all model shards are present and not corrupted.
|
|
|
|
Args:
|
|
snapshot_dir: Path to the model snapshot directory
|
|
weight_files: List of weight file paths
|
|
|
|
Returns:
|
|
Tuple of (is_valid, error_message, corrupted_files)
|
|
- corrupted_files: List of file paths that are corrupted (for selective cleanup)
|
|
"""
|
|
# First, check if all files from the index actually exist
|
|
# This catches missing files that wouldn't be found by glob
|
|
index_check_valid, index_error = _check_index_files_exist(snapshot_dir)
|
|
if not index_check_valid:
|
|
return False, index_error, []
|
|
|
|
# Pattern for sharded files: model-00001-of-00009.safetensors
|
|
shard_pattern = re.compile(r"(.*?)-(\d+)-of-(\d+)\.(safetensors|bin)")
|
|
|
|
# Group files by shard pattern (prefix-*-of-N)
|
|
shard_groups = {}
|
|
for f in weight_files:
|
|
base_name = os.path.basename(f)
|
|
match = shard_pattern.match(base_name)
|
|
if match:
|
|
prefix = match.group(1)
|
|
total_shards_str = match.group(3)
|
|
suffix = match.group(4)
|
|
|
|
group_key = f"{prefix}-of-{total_shards_str}.{suffix}"
|
|
if group_key not in shard_groups:
|
|
shard_groups[group_key] = {
|
|
"prefix": prefix,
|
|
"total": int(total_shards_str),
|
|
"suffix": suffix,
|
|
"found_shards": [],
|
|
"files": [],
|
|
}
|
|
|
|
shard_id = int(match.group(2))
|
|
shard_groups[group_key]["found_shards"].append(shard_id)
|
|
shard_groups[group_key]["files"].append(f)
|
|
|
|
# Track corrupted files for selective cleanup
|
|
corrupted_files = []
|
|
|
|
# Validate each shard group
|
|
for group_key, group_info in shard_groups.items():
|
|
total_shards = group_info["total"]
|
|
found_shards = set(group_info["found_shards"])
|
|
# Shards may be 0-indexed (e.g. inclusionAI/Ring-2.5-1T) or 1-indexed
|
|
# (e.g. deepseek-ai/DeepSeek-V3); both are valid HF conventions.
|
|
min_idx = min(found_shards) if found_shards else 1
|
|
expected_shards = set(range(min_idx, min_idx + total_shards))
|
|
|
|
# Check for missing shards
|
|
missing_shards = expected_shards - found_shards
|
|
if missing_shards:
|
|
return (
|
|
False,
|
|
f"Missing shards in {group_key}: {sorted(missing_shards)}",
|
|
[],
|
|
)
|
|
|
|
# Validate weight files for corruption
|
|
if group_info["suffix"] == "safetensors":
|
|
for f in group_info["files"]:
|
|
if not _validate_safetensors_file(f):
|
|
corrupted_files.append(f)
|
|
elif group_info["suffix"] == "bin":
|
|
for f in group_info["files"]:
|
|
if not _validate_pytorch_bin_file(f):
|
|
corrupted_files.append(f)
|
|
|
|
# Check for required index file for safetensors shards
|
|
if group_info["suffix"] == "safetensors":
|
|
index_file = os.path.join(
|
|
snapshot_dir, f"{group_info['prefix']}.safetensors.index.json"
|
|
)
|
|
if not os.path.exists(index_file):
|
|
return (
|
|
False,
|
|
f"Missing index file: {os.path.basename(index_file)}",
|
|
[],
|
|
)
|
|
|
|
if corrupted_files:
|
|
return (
|
|
False,
|
|
f"Corrupted shard files: {[os.path.basename(f) for f in corrupted_files]}",
|
|
corrupted_files,
|
|
)
|
|
|
|
return True, None, []
|
|
|
|
|
|
def _cleanup_corrupted_files_selective(
|
|
model_name_or_path: str, corrupted_files: List[str]
|
|
) -> int:
|
|
"""
|
|
Selectively remove corrupted files and their blobs to force re-download.
|
|
|
|
This is more efficient than removing the entire model cache as it only
|
|
re-downloads corrupted files rather than the entire model.
|
|
|
|
Args:
|
|
model_name_or_path: Model identifier
|
|
corrupted_files: List of corrupted file paths (symlinks in snapshot)
|
|
|
|
Returns:
|
|
Number of files successfully cleaned up
|
|
"""
|
|
cleaned_count = 0
|
|
|
|
for file_path in corrupted_files:
|
|
try:
|
|
# Resolve symlink to get blob path before deleting symlink
|
|
if os.path.islink(file_path):
|
|
blob_path = os.path.realpath(file_path)
|
|
|
|
# Delete the symlink
|
|
os.remove(file_path)
|
|
logger.info(
|
|
"Removed corrupted symlink: %s", os.path.basename(file_path)
|
|
)
|
|
|
|
# Delete the blob (the actual corrupted data)
|
|
if os.path.exists(blob_path):
|
|
os.remove(blob_path)
|
|
logger.info(
|
|
"Removed corrupted blob: %s", os.path.basename(blob_path)
|
|
)
|
|
|
|
cleaned_count += 1
|
|
elif os.path.exists(file_path):
|
|
# Not a symlink, just delete the file
|
|
os.remove(file_path)
|
|
logger.info("Removed corrupted file: %s", os.path.basename(file_path))
|
|
cleaned_count += 1
|
|
|
|
except Exception as e:
|
|
logger.error(
|
|
"Failed to remove corrupted file %s: %s",
|
|
os.path.basename(file_path),
|
|
e,
|
|
)
|
|
|
|
if cleaned_count > 0:
|
|
logger.warning(
|
|
"Removed %d corrupted file(s) for %s. "
|
|
"These will be re-downloaded on next load.",
|
|
cleaned_count,
|
|
model_name_or_path,
|
|
)
|
|
|
|
return cleaned_count
|
|
|
|
|
|
def _cleanup_corrupted_model_cache(
|
|
model_name_or_path: str, snapshot_dir: str, reason: str
|
|
) -> None:
|
|
"""
|
|
Remove entire corrupted model cache directory to force a clean re-download.
|
|
|
|
This is used when we cannot selectively clean (e.g., missing shards, incomplete
|
|
downloads with unknown affected files).
|
|
|
|
Args:
|
|
model_name_or_path: Model identifier
|
|
snapshot_dir: Path to the snapshot directory
|
|
reason: Reason for cleanup
|
|
"""
|
|
# Navigate up to the model root directory: snapshots/hash -> snapshots -> model_root
|
|
repo_folder = os.path.abspath(os.path.join(snapshot_dir, "..", ".."))
|
|
|
|
try:
|
|
logger.warning(
|
|
"Removing entire cache for %s at %s. Reason: %s",
|
|
model_name_or_path,
|
|
repo_folder,
|
|
reason,
|
|
)
|
|
shutil.rmtree(repo_folder)
|
|
logger.info("Successfully removed corrupted cache directory")
|
|
except Exception as e:
|
|
logger.error(
|
|
"Failed to remove corrupted cache directory %s: %s. "
|
|
"Manual cleanup may be required.",
|
|
repo_folder,
|
|
e,
|
|
)
|
|
|
|
|
|
def ci_validate_and_cleanup_local_snapshot(
|
|
model_name_or_path: str,
|
|
found_local_snapshot_dir: str,
|
|
local_weight_files: List[str],
|
|
) -> bool:
|
|
"""
|
|
CI-specific validation and cleanup for local model snapshots.
|
|
|
|
This function validates the local snapshot and performs automatic cleanup
|
|
if corruption or missing files are detected. This behavior is only appropriate
|
|
for CI environments where we want automatic recovery.
|
|
|
|
Args:
|
|
model_name_or_path: Model identifier for logging
|
|
found_local_snapshot_dir: Path to the local snapshot directory
|
|
local_weight_files: List of weight file paths found in the snapshot
|
|
|
|
Returns:
|
|
True if the snapshot is valid and can be used, False if it was invalid
|
|
and cleanup was performed (caller should re-download)
|
|
"""
|
|
# Check for incomplete files and clean up if found
|
|
repo_folder = os.path.abspath(os.path.join(found_local_snapshot_dir, "..", ".."))
|
|
blobs_dir = os.path.join(repo_folder, "blobs")
|
|
|
|
# Check for incomplete download markers
|
|
incomplete_files = []
|
|
if os.path.isdir(blobs_dir):
|
|
incomplete_files = glob_module.glob(os.path.join(blobs_dir, "*.incomplete"))
|
|
|
|
if incomplete_files:
|
|
log_info_on_rank0(
|
|
logger,
|
|
f"Found {len(incomplete_files)} .incomplete files in {blobs_dir} for "
|
|
f"{model_name_or_path}. Will clean up and re-download.",
|
|
)
|
|
_cleanup_corrupted_model_cache(
|
|
model_name_or_path,
|
|
found_local_snapshot_dir,
|
|
f"Incomplete download detected ({len(incomplete_files)} incomplete files)",
|
|
)
|
|
return False
|
|
|
|
# Validate sharded models and check for corruption
|
|
if local_weight_files:
|
|
is_valid, error_msg, corrupted_files = _validate_sharded_model(
|
|
found_local_snapshot_dir, local_weight_files
|
|
)
|
|
if not is_valid:
|
|
if corrupted_files:
|
|
# Selective cleanup: only remove corrupted files
|
|
log_info_on_rank0(
|
|
logger,
|
|
f"Found {len(corrupted_files)} corrupted file(s) for "
|
|
f"{model_name_or_path}: {error_msg}. "
|
|
"Will selectively clean and re-download only these files.",
|
|
)
|
|
_cleanup_corrupted_files_selective(model_name_or_path, corrupted_files)
|
|
return False
|
|
else:
|
|
# Missing shards (not corruption) - let snapshot_download handle it.
|
|
# IMPORTANT: Do NOT delete the entire cache here, as other processes
|
|
# (TP/EP ranks) may already be loading weights from these files.
|
|
log_info_on_rank0(
|
|
logger,
|
|
f"Validation failed for {model_name_or_path}: {error_msg}. "
|
|
"Will attempt to download missing files.",
|
|
)
|
|
return False
|
|
|
|
# Also validate single (non-sharded) weight files
|
|
for f in local_weight_files:
|
|
base_name = os.path.basename(f)
|
|
# Check if this is a single model file (not sharded)
|
|
# Include adapter_model.safetensors for LoRA adapters
|
|
if base_name in [
|
|
"model.safetensors",
|
|
"pytorch_model.safetensors",
|
|
"adapter_model.safetensors",
|
|
]:
|
|
if not _validate_safetensors_file(f):
|
|
log_info_on_rank0(
|
|
logger,
|
|
f"Corrupted model file {base_name} for {model_name_or_path}. "
|
|
"Will selectively clean and re-download this file.",
|
|
)
|
|
# Selective cleanup for single file
|
|
_cleanup_corrupted_files_selective(model_name_or_path, [f])
|
|
return False
|
|
# Also validate single PyTorch .bin files
|
|
elif base_name in [
|
|
"pytorch_model.bin",
|
|
"model.bin",
|
|
"adapter_model.bin",
|
|
]:
|
|
if not _validate_pytorch_bin_file(f):
|
|
log_info_on_rank0(
|
|
logger,
|
|
f"Corrupted model file {base_name} for {model_name_or_path}. "
|
|
"Will selectively clean and re-download this file.",
|
|
)
|
|
# Selective cleanup for single file
|
|
_cleanup_corrupted_files_selective(model_name_or_path, [f])
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def _validate_weights_after_download(
|
|
hf_folder: str,
|
|
allow_patterns: List[str],
|
|
model_name_or_path: str,
|
|
) -> bool:
|
|
"""
|
|
Validate downloaded weight files to catch corruption early.
|
|
|
|
This function validates safetensors files after download to catch
|
|
corruption issues (truncated downloads, network errors, etc.) before
|
|
model loading fails with cryptic errors. If corruption is found,
|
|
the corrupted files are automatically cleaned up.
|
|
|
|
Args:
|
|
hf_folder: Path to the downloaded model folder
|
|
allow_patterns: Patterns used to match weight files
|
|
model_name_or_path: Model identifier for error messages
|
|
|
|
Returns:
|
|
True if all files are valid, False if corrupted files were found and cleaned up
|
|
"""
|
|
# Find all weight files that were downloaded
|
|
weight_files: List[str] = []
|
|
for pattern in allow_patterns:
|
|
weight_files.extend(glob_module.glob(os.path.join(hf_folder, pattern)))
|
|
|
|
if not weight_files:
|
|
return True # No weight files to validate
|
|
|
|
# Validate weight files (safetensors and .bin)
|
|
corrupted_files = []
|
|
for f in weight_files:
|
|
if f.endswith(".safetensors") and os.path.exists(f):
|
|
if not _validate_safetensors_file(f):
|
|
corrupted_files.append(os.path.basename(f))
|
|
elif f.endswith(".bin") and os.path.exists(f):
|
|
if not _validate_pytorch_bin_file(f):
|
|
corrupted_files.append(os.path.basename(f))
|
|
|
|
if corrupted_files:
|
|
# Clean up corrupted files so next attempt re-downloads them
|
|
_cleanup_corrupted_files_selective(
|
|
model_name_or_path,
|
|
[os.path.join(hf_folder, f) for f in corrupted_files],
|
|
)
|
|
log_info_on_rank0(
|
|
logger,
|
|
f"Downloaded model files are corrupted for {model_name_or_path}: "
|
|
f"{corrupted_files}. The corrupted files have been removed. "
|
|
"Will retry download.",
|
|
)
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def _get_lock_file_path(
|
|
model_name_or_path: str, cache_dir: Optional[str] = None
|
|
) -> str:
|
|
"""
|
|
Generate a unique lock file path for download coordination.
|
|
|
|
In CI environments where multiple containers share an NFS-mounted HF cache,
|
|
the lock file is placed on the shared cache directory so ALL containers
|
|
coordinate on the same lock. This prevents cross-container .incomplete
|
|
file race conditions.
|
|
|
|
Falls back to /dev/shm (container-local) for non-CI or when the cache
|
|
dir is not accessible.
|
|
|
|
Args:
|
|
model_name_or_path: Model identifier
|
|
cache_dir: HF cache directory (None to use default)
|
|
|
|
Returns:
|
|
Path to the lock file
|
|
"""
|
|
key_hash = hashlib.sha256(model_name_or_path.encode()).hexdigest()[:16]
|
|
|
|
# In CI, place lock on the shared HF cache directory so that ALL containers
|
|
# sharing the same NFS-mounted cache coordinate downloads.
|
|
# /dev/shm is container-local and doesn't prevent cross-container races.
|
|
try:
|
|
import huggingface_hub.constants
|
|
|
|
effective_cache_dir = cache_dir or huggingface_hub.constants.HF_HUB_CACHE
|
|
if os.path.isdir(effective_cache_dir):
|
|
lock_dir = os.path.join(effective_cache_dir, ".sglang_locks")
|
|
os.makedirs(lock_dir, exist_ok=True)
|
|
return os.path.join(lock_dir, f"download_{key_hash}.lock")
|
|
except Exception:
|
|
pass
|
|
|
|
# Fallback to container-local lock
|
|
if os.path.isdir("/dev/shm"):
|
|
return f"/dev/shm/sglang_download_lock_{key_hash}"
|
|
return f"/tmp/sglang_download_lock_{key_hash}"
|
|
|
|
|
|
def _cleanup_incomplete_blobs(model_name_or_path: str, cache_dir: Optional[str]) -> int:
|
|
"""
|
|
Remove stale .incomplete files from the model's blobs directory.
|
|
|
|
This is lighter than _cleanup_corrupted_model_cache (which deletes the
|
|
entire cache). We only remove .incomplete files so snapshot_download
|
|
starts fresh on retry, preserving any successfully downloaded blobs.
|
|
|
|
Args:
|
|
model_name_or_path: Model identifier (e.g., "meta-llama/Llama-2-7b-hf")
|
|
cache_dir: HF cache directory (None to use default)
|
|
|
|
Returns:
|
|
Number of .incomplete files removed
|
|
"""
|
|
try:
|
|
import huggingface_hub.constants
|
|
|
|
effective_cache_dir = cache_dir or huggingface_hub.constants.HF_HUB_CACHE
|
|
repo_folder_name = huggingface_hub.constants.REPO_ID_SEPARATOR.join(
|
|
["models", *model_name_or_path.split("/")]
|
|
)
|
|
blobs_dir = os.path.join(effective_cache_dir, repo_folder_name, "blobs")
|
|
|
|
if not os.path.isdir(blobs_dir):
|
|
return 0
|
|
|
|
incomplete_files = glob_module.glob(os.path.join(blobs_dir, "*.incomplete"))
|
|
removed = 0
|
|
for f in incomplete_files:
|
|
try:
|
|
os.remove(f)
|
|
removed += 1
|
|
logger.debug("Removed incomplete blob: %s", os.path.basename(f))
|
|
except OSError as e:
|
|
logger.debug(
|
|
"Failed to remove incomplete blob %s: %s", os.path.basename(f), e
|
|
)
|
|
|
|
if removed > 0:
|
|
logger.warning(
|
|
"Cleaned up %d .incomplete blob(s) for %s in %s",
|
|
removed,
|
|
model_name_or_path,
|
|
blobs_dir,
|
|
)
|
|
return removed
|
|
|
|
except Exception as e:
|
|
logger.debug("Failed to clean up incomplete blobs: %s", e)
|
|
return 0
|
|
|
|
|
|
def ci_download_with_validation_and_retry(
|
|
model_name_or_path: str,
|
|
allow_patterns: List[str],
|
|
ignore_patterns,
|
|
cache_dir: Optional[str],
|
|
revision: Optional[str],
|
|
max_retries: int = 3,
|
|
) -> str:
|
|
"""
|
|
CI-specific download with validation and automatic retry on corruption.
|
|
|
|
This function handles the download of model weights in CI environments,
|
|
with automatic validation and retry logic for handling corrupted downloads.
|
|
|
|
Uses filelock.FileLock on the shared HF cache directory to coordinate
|
|
downloads across all processes AND all containers sharing the same
|
|
NFS-mounted cache. Only one process downloads at a time; others wait
|
|
for the lock then use the cached result.
|
|
|
|
Args:
|
|
model_name_or_path: The model name or path
|
|
allow_patterns: The allowed patterns for weight files
|
|
ignore_patterns: The patterns to filter out weight files
|
|
cache_dir: The cache directory to store model weights
|
|
revision: The revision of the model
|
|
max_retries: Maximum number of download retries if corruption is detected
|
|
|
|
Returns:
|
|
str: The path to the downloaded model weights
|
|
|
|
Raises:
|
|
RuntimeError: If download fails after max_retries attempts
|
|
"""
|
|
import filelock
|
|
import huggingface_hub.constants
|
|
from huggingface_hub import snapshot_download
|
|
from tqdm.auto import tqdm
|
|
|
|
class DisabledTqdm(tqdm):
|
|
def __init__(self, *args, **kwargs):
|
|
kwargs["disable"] = True
|
|
super().__init__(*args, **kwargs)
|
|
|
|
# Use filelock on the shared HF cache directory to coordinate downloads
|
|
# across all processes AND all containers sharing the same NFS mount.
|
|
# This prevents cross-container .incomplete file race conditions.
|
|
lock_file_path = _get_lock_file_path(model_name_or_path, cache_dir)
|
|
|
|
logger.info(
|
|
"[CI Download] Process %d using lock file: %s",
|
|
os.getpid(),
|
|
lock_file_path,
|
|
)
|
|
|
|
# filelock.FileLock handles creation, acquisition, and release cleanly.
|
|
# timeout=-1 means wait indefinitely (another container may be downloading
|
|
# a large model for 30+ minutes).
|
|
lock = filelock.FileLock(lock_file_path, timeout=-1, mode=0o666)
|
|
|
|
logger.info(
|
|
"[CI Download] Process %d waiting to acquire lock for %s",
|
|
os.getpid(),
|
|
model_name_or_path,
|
|
)
|
|
|
|
with lock:
|
|
logger.info(
|
|
"[CI Download] Process %d ACQUIRED lock for %s",
|
|
os.getpid(),
|
|
model_name_or_path,
|
|
)
|
|
|
|
# Re-check if another container already downloaded the model while
|
|
# we were waiting for the lock. This avoids redundant downloads.
|
|
try:
|
|
from sglang.srt.model_loader.weight_utils import (
|
|
_find_local_hf_snapshot_dir_unlocked,
|
|
)
|
|
|
|
cached_path = _find_local_hf_snapshot_dir_unlocked(
|
|
model_name_or_path, cache_dir, allow_patterns, revision
|
|
)
|
|
if cached_path is not None:
|
|
logger.info(
|
|
"[CI Download] Process %d found cached model after "
|
|
"acquiring lock (downloaded by another container): %s",
|
|
os.getpid(),
|
|
cached_path,
|
|
)
|
|
return cached_path
|
|
except Exception as e:
|
|
logger.debug(
|
|
"[CI Download] Re-check for cached model failed (non-fatal): %s", e
|
|
)
|
|
|
|
# Clean up stale .incomplete files from previous failed downloads
|
|
# before starting. Only do this once before the first attempt.
|
|
cleaned = _cleanup_incomplete_blobs(model_name_or_path, cache_dir)
|
|
if cleaned > 0:
|
|
logger.info(
|
|
"[CI Download] Pre-download cleanup: removed %d stale "
|
|
".incomplete file(s) for %s",
|
|
cleaned,
|
|
model_name_or_path,
|
|
)
|
|
|
|
hf_folder = None
|
|
for attempt in range(max_retries):
|
|
try:
|
|
hf_folder = snapshot_download(
|
|
model_name_or_path,
|
|
allow_patterns=allow_patterns,
|
|
ignore_patterns=ignore_patterns,
|
|
cache_dir=cache_dir,
|
|
tqdm_class=DisabledTqdm,
|
|
revision=revision,
|
|
local_files_only=huggingface_hub.constants.HF_HUB_OFFLINE,
|
|
# Force single-threaded downloads to prevent race conditions
|
|
# on NFS. HF hub defaults to max_workers=8, which can cause
|
|
# .incomplete file conflicts when multiple threads operate
|
|
# on the same files
|
|
max_workers=1,
|
|
)
|
|
except (FileNotFoundError, OSError) as e:
|
|
# Race condition: .incomplete file was moved/deleted by another
|
|
# process. With NFS-level locking this should be rare, but can
|
|
# still happen if lock acquisition fails on some NFS setups.
|
|
logger.warning(
|
|
"[CI Download] Process %d hit download error "
|
|
"(attempt %d/%d) for %s: %s: %s",
|
|
os.getpid(),
|
|
attempt + 1,
|
|
max_retries,
|
|
model_name_or_path,
|
|
type(e).__name__,
|
|
e,
|
|
)
|
|
if attempt < max_retries - 1:
|
|
# Backoff: 10s, 20s, 40s. Clean only the stale
|
|
# .incomplete files (not active ones from other processes).
|
|
backoff = 10 * (2**attempt)
|
|
logger.info(
|
|
"[CI Download] Cleaning up .incomplete files and "
|
|
"retrying in %ds...",
|
|
backoff,
|
|
)
|
|
_cleanup_incomplete_blobs(model_name_or_path, cache_dir)
|
|
time.sleep(backoff)
|
|
continue
|
|
raise RuntimeError(
|
|
f"Download failed for {model_name_or_path} after "
|
|
f"{max_retries} attempts due to download errors. "
|
|
f"Last error: {type(e).__name__}: {e}"
|
|
) from e
|
|
|
|
# Validate downloaded files to catch corruption early
|
|
is_valid = _validate_weights_after_download(
|
|
hf_folder, allow_patterns, model_name_or_path
|
|
)
|
|
|
|
if is_valid:
|
|
return hf_folder
|
|
|
|
# Validation failed, corrupted files were cleaned up
|
|
if attempt < max_retries - 1:
|
|
log_info_on_rank0(
|
|
logger,
|
|
f"Retrying download for {model_name_or_path} "
|
|
f"(attempt {attempt + 2}/{max_retries})...",
|
|
)
|
|
else:
|
|
raise RuntimeError(
|
|
f"Downloaded model files are still corrupted for "
|
|
f"{model_name_or_path} after {max_retries} attempts. "
|
|
"This may indicate a persistent issue with the model files "
|
|
"on Hugging Face Hub or network problems."
|
|
)
|
|
|
|
# Should never reach here, but return hf_folder just in case
|
|
return hf_folder
|
|
|
|
|
|
def ci_validate_and_clean_hf_cache(model_path: str) -> None:
|
|
"""
|
|
Validate and clean corrupted safetensors files in HF cache before loading.
|
|
|
|
This function is needed because HFRunner (used in tests) calls transformers'
|
|
from_pretrained() directly, which bypasses SGLang's weight validation.
|
|
Corrupted cached files can cause cryptic errors like "EOF while parsing"
|
|
from safetensors.
|
|
|
|
Only runs in CI to avoid overhead for regular users.
|
|
|
|
Args:
|
|
model_path: Model identifier (e.g., "meta-llama/Llama-2-7b")
|
|
"""
|
|
from sglang.utils import is_in_ci
|
|
|
|
if not is_in_ci():
|
|
return
|
|
|
|
# Skip for local paths
|
|
if os.path.isdir(model_path):
|
|
return
|
|
|
|
try:
|
|
import huggingface_hub.constants
|
|
|
|
# Find the HF cache directory for this model
|
|
cache_dir = huggingface_hub.constants.HF_HUB_CACHE
|
|
repo_folder = os.path.join(
|
|
cache_dir,
|
|
huggingface_hub.constants.REPO_ID_SEPARATOR.join(
|
|
["models", *model_path.split("/")]
|
|
),
|
|
)
|
|
|
|
if not os.path.isdir(repo_folder):
|
|
return
|
|
|
|
# Find snapshot directories
|
|
snapshots_dir = os.path.join(repo_folder, "snapshots")
|
|
if not os.path.isdir(snapshots_dir):
|
|
return
|
|
|
|
# Check each snapshot for corrupted files
|
|
corrupted_files = []
|
|
for snapshot_hash in os.listdir(snapshots_dir):
|
|
snapshot_dir = os.path.join(snapshots_dir, snapshot_hash)
|
|
if not os.path.isdir(snapshot_dir):
|
|
continue
|
|
|
|
# Find all safetensors files
|
|
safetensors_files = glob_module.glob(
|
|
os.path.join(snapshot_dir, "*.safetensors")
|
|
)
|
|
|
|
for sf_file in safetensors_files:
|
|
# Skip broken symlinks (os.path.exists returns False for them)
|
|
if not os.path.exists(sf_file):
|
|
continue
|
|
|
|
if not _validate_safetensors_file(sf_file):
|
|
corrupted_files.append(sf_file)
|
|
|
|
# Also find and validate PyTorch .bin files
|
|
bin_files = glob_module.glob(os.path.join(snapshot_dir, "*.bin"))
|
|
|
|
for bin_file in bin_files:
|
|
# Skip broken symlinks (os.path.exists returns False for them)
|
|
if not os.path.exists(bin_file):
|
|
continue
|
|
|
|
if not _validate_pytorch_bin_file(bin_file):
|
|
corrupted_files.append(bin_file)
|
|
|
|
if corrupted_files:
|
|
logger.warning(
|
|
"HFRunner: Found %d corrupted weight file(s) for %s. "
|
|
"Removing to force re-download.",
|
|
len(corrupted_files),
|
|
model_path,
|
|
)
|
|
_cleanup_corrupted_files_selective(model_path, corrupted_files)
|
|
|
|
except Exception as e:
|
|
# Don't fail if validation itself fails - let HF handle it
|
|
logger.debug("HF cache validation failed (non-fatal): %s", e)
|