Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:38:16 +08:00

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)