Fix eval tests not capturing server launch failures (#18886)

This commit is contained in:
Alison Shao
2026-02-18 07:59:03 +08:00
committed by GitHub
parent e02a9bec8d
commit 34d975b18f
4 changed files with 137 additions and 95 deletions
@@ -1730,30 +1730,44 @@ def _validate_weights_after_download(
return True return True
def _get_lock_file_path(model_name_or_path: str) -> str: 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. Generate a unique lock file path for download coordination.
Uses file-based locking (fcntl.flock) to ensure only one process downloads In CI environments where multiple containers share an NFS-mounted HF cache,
while others wait. This works regardless of how processes are spawned the lock file is placed on the shared cache directory so ALL containers
(mp.Process, torchrun, etc.). 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: Args:
model_name_or_path: Model identifier model_name_or_path: Model identifier
cache_dir: HF cache directory (None to use default)
Returns: Returns:
Path to the lock file Path to the lock file
""" """
# Create a unique hash based on model name only (not cache_dir)
# This ensures all processes coordinate on the same lock regardless of
# cache_dir configuration differences between processes
key_hash = hashlib.sha256(model_name_or_path.encode()).hexdigest()[:16] key_hash = hashlib.sha256(model_name_or_path.encode()).hexdigest()[:16]
# Use /dev/shm (shared memory filesystem) for lock files because: # In CI, place lock on the shared HF cache directory so that ALL containers
# 1. It's always local to the machine (not NFS) # sharing the same NFS-mounted cache coordinate downloads.
# 2. It properly supports file locking # /dev/shm is container-local and doesn't prevent cross-container races.
# 3. It's shared across all processes on the same machine try:
# Fall back to /tmp if /dev/shm doesn't exist 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"): if os.path.isdir("/dev/shm"):
return f"/dev/shm/sglang_download_lock_{key_hash}" return f"/dev/shm/sglang_download_lock_{key_hash}"
return f"/tmp/sglang_download_lock_{key_hash}" return f"/tmp/sglang_download_lock_{key_hash}"
@@ -1826,13 +1840,10 @@ def ci_download_with_validation_and_retry(
This function handles the download of model weights in CI environments, This function handles the download of model weights in CI environments,
with automatic validation and retry logic for handling corrupted downloads. with automatic validation and retry logic for handling corrupted downloads.
Uses file-based locking (fcntl.flock) to prevent HuggingFace hub race Uses filelock.FileLock on the shared HF cache directory to coordinate
conditions where multiple processes try to download simultaneously, downloads across all processes AND all containers sharing the same
causing .incomplete file conflicts. Only one process downloads at a time; NFS-mounted cache. Only one process downloads at a time; others wait
others wait for the lock then use the cached result. for the lock then use the cached result.
This approach works regardless of how processes are spawned (mp.Process,
torchrun, etc.) since it doesn't rely on environment variables.
Args: Args:
model_name_or_path: The model name or path model_name_or_path: The model name or path
@@ -1848,8 +1859,7 @@ def ci_download_with_validation_and_retry(
Raises: Raises:
RuntimeError: If download fails after max_retries attempts RuntimeError: If download fails after max_retries attempts
""" """
import fcntl import filelock
import huggingface_hub.constants import huggingface_hub.constants
from huggingface_hub import snapshot_download from huggingface_hub import snapshot_download
from tqdm.auto import tqdm from tqdm.auto import tqdm
@@ -1859,36 +1869,69 @@ def ci_download_with_validation_and_retry(
kwargs["disable"] = True kwargs["disable"] = True
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Use file-based locking to serialize downloads across all processes # Use filelock on the shared HF cache directory to coordinate downloads
# This prevents HF hub race conditions with .incomplete files # across all processes AND all containers sharing the same NFS mount.
lock_file_path = _get_lock_file_path(model_name_or_path) # This prevents cross-container .incomplete file race conditions.
lock_file_path = _get_lock_file_path(model_name_or_path, cache_dir)
# Log lock file path for debugging
logger.info( logger.info(
"[CI Download] Process %d using lock file: %s", "[CI Download] Process %d using lock file: %s",
os.getpid(), os.getpid(),
lock_file_path, lock_file_path,
) )
# Create lock file if it doesn't exist # filelock.FileLock handles creation, acquisition, and release cleanly.
lock_file = open(lock_file_path, "w") # 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)
try:
# Acquire exclusive lock - blocks until lock is available
# This ensures only one process downloads at a time
logger.info( logger.info(
"[CI Download] Process %d waiting to acquire lock for %s", "[CI Download] Process %d waiting to acquire lock for %s",
os.getpid(), os.getpid(),
model_name_or_path, model_name_or_path,
) )
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
with lock:
logger.info( logger.info(
"[CI Download] Process %d ACQUIRED lock for %s", "[CI Download] Process %d ACQUIRED lock for %s",
os.getpid(), os.getpid(),
model_name_or_path, model_name_or_path,
) )
# Now we have exclusive access - perform download with retry logic # 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 hf_folder = None
for attempt in range(max_retries): for attempt in range(max_retries):
try: try:
@@ -1907,12 +1950,11 @@ def ci_download_with_validation_and_retry(
max_workers=1, max_workers=1,
) )
except (FileNotFoundError, OSError) as e: except (FileNotFoundError, OSError) as e:
# Cross-container race condition: another container on the same # Race condition: .incomplete file was moved/deleted by another
# host moved/deleted the .incomplete file while we were using it. # process. With NFS-level locking this should be rare, but can
# This happens when multiple CI containers share an NFS-mounted # still happen if lock acquisition fails on some NFS setups.
# HF cache but have separate /dev/shm lock namespaces.
logger.warning( logger.warning(
"[CI Download] Process %d hit download race condition " "[CI Download] Process %d hit download error "
"(attempt %d/%d) for %s: %s: %s", "(attempt %d/%d) for %s: %s: %s",
os.getpid(), os.getpid(),
attempt + 1, attempt + 1,
@@ -1921,19 +1963,21 @@ def ci_download_with_validation_and_retry(
type(e).__name__, type(e).__name__,
e, e,
) )
_cleanup_incomplete_blobs(model_name_or_path, cache_dir)
if attempt < max_retries - 1: if attempt < max_retries - 1:
backoff = 2**attempt # Backoff: 10s, 20s, 40s. Clean only the stale
# .incomplete files (not active ones from other processes).
backoff = 10 * (2**attempt)
logger.info( logger.info(
"[CI Download] Retrying in %ds...", "[CI Download] Cleaning up .incomplete files and "
"retrying in %ds...",
backoff, backoff,
) )
_cleanup_incomplete_blobs(model_name_or_path, cache_dir)
time.sleep(backoff) time.sleep(backoff)
continue continue
raise RuntimeError( raise RuntimeError(
f"Download failed for {model_name_or_path} after " f"Download failed for {model_name_or_path} after "
f"{max_retries} attempts due to cross-container race " f"{max_retries} attempts due to download errors. "
f"condition (.incomplete file conflicts). "
f"Last error: {type(e).__name__}: {e}" f"Last error: {type(e).__name__}: {e}"
) from e ) from e
@@ -1963,16 +2007,6 @@ def ci_download_with_validation_and_retry(
# Should never reach here, but return hf_folder just in case # Should never reach here, but return hf_folder just in case
return hf_folder return hf_folder
finally:
# Always release the lock
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
lock_file.close()
logger.info(
"[CI Download] Process %d RELEASED lock for %s",
os.getpid(),
model_name_or_path,
)
def ci_validate_and_clean_hf_cache(model_path: str) -> None: def ci_validate_and_clean_hf_cache(model_path: str) -> None:
""" """
+6 -2
View File
@@ -259,18 +259,21 @@ class NightlyBenchmarkRunner:
avg_spec_accept_length = None avg_spec_accept_length = None
model_description = f"{model_path}" + (f" ({variant})" if variant else "") model_description = f"{model_path}" + (f" ({variant})" if variant else "")
process = None
try:
# Launch server # Launch server
process = popen_launch_server( process = popen_launch_server(
model=model_path, model=model_path,
base_url=self.base_url, base_url=self.base_url,
other_args=other_args or [], other_args=other_args or [],
timeout=( timeout=(
timeout if timeout is not None else DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH timeout
if timeout is not None
else DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH
), ),
env=env, env=env,
) )
try:
# Generate filenames # Generate filenames
profile_path_prefix, json_output_file = self.generate_profile_filename( profile_path_prefix, json_output_file = self.generate_profile_filename(
model_path, variant model_path, variant
@@ -311,6 +314,7 @@ class NightlyBenchmarkRunner:
finally: finally:
# Always clean up server process # Always clean up server process
if process is not None:
kill_process_tree(process.pid) kill_process_tree(process.pid)
def _get_spec_accept_length(self) -> Optional[float]: def _get_spec_accept_length(self) -> Optional[float]:
@@ -11,7 +11,6 @@ from sglang.test.test_utils import (
DEFAULT_MODEL_NAME_FOR_NIGHTLY_EVAL_FP8_TP2, DEFAULT_MODEL_NAME_FOR_NIGHTLY_EVAL_FP8_TP2,
DEFAULT_MODEL_NAME_FOR_NIGHTLY_EVAL_TP1, DEFAULT_MODEL_NAME_FOR_NIGHTLY_EVAL_TP1,
DEFAULT_MODEL_NAME_FOR_NIGHTLY_EVAL_TP2, DEFAULT_MODEL_NAME_FOR_NIGHTLY_EVAL_TP2,
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST, DEFAULT_URL_FOR_TEST,
ModelLaunchSettings, ModelLaunchSettings,
check_evaluation_test_results, check_evaluation_test_results,
@@ -20,6 +19,10 @@ from sglang.test.test_utils import (
write_results_to_json, write_results_to_json,
) )
# Nightly eval tests run large models (up to 70B+ params) that may need
# downloading on cache miss. Use a longer timeout than the default 600s.
NIGHTLY_EVAL_SERVER_TIMEOUT = 1800
register_cuda_ci(est_time=3600, suite="nightly-eval-text-2-gpu", nightly=True) register_cuda_ci(est_time=3600, suite="nightly-eval-text-2-gpu", nightly=True)
MODEL_SCORE_THRESHOLDS = { MODEL_SCORE_THRESHOLDS = {
@@ -72,19 +75,19 @@ class TestNightlyGsm8KEval(unittest.TestCase):
for model_setup in self.models: for model_setup in self.models:
with self.subTest(model=model_setup.model_path): with self.subTest(model=model_setup.model_path):
other_args = list(model_setup.extra_args) other_args = list(model_setup.extra_args)
error_message = None process = None
if model_setup.model_path == "meta-llama/Llama-3.1-70B-Instruct": if model_setup.model_path == "meta-llama/Llama-3.1-70B-Instruct":
other_args.extend(["--mem-fraction-static", "0.9"]) other_args.extend(["--mem-fraction-static", "0.9"])
try:
process = popen_launch_server( process = popen_launch_server(
model=model_setup.model_path, model=model_setup.model_path,
other_args=other_args, other_args=other_args,
base_url=self.base_url, base_url=self.base_url,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH, timeout=NIGHTLY_EVAL_SERVER_TIMEOUT,
) )
try:
args = SimpleNamespace( args = SimpleNamespace(
base_url=self.base_url, base_url=self.base_url,
model=model_setup.model_path, model=model_setup.model_path,
@@ -103,19 +106,17 @@ class TestNightlyGsm8KEval(unittest.TestCase):
) )
is_first = False is_first = False
# 0.0 for empty latency, None for no error
all_results.append( all_results.append(
(model_setup.model_path, metrics["score"], 0.0, error_message) (model_setup.model_path, metrics["score"], 0.0, None)
) )
except Exception as e: except Exception as e:
# Capture error message for the summary table
error_message = str(e) error_message = str(e)
# Still append result with error info (use None for N/A metrics to match else clause)
all_results.append( all_results.append(
(model_setup.model_path, None, None, error_message) (model_setup.model_path, None, None, error_message)
) )
print(f"Error evaluating {model_setup.model_path}: {error_message}") print(f"Error evaluating {model_setup.model_path}: {error_message}")
finally: finally:
if process is not None:
kill_process_tree(process.pid) kill_process_tree(process.pid)
try: try:
+10 -7
View File
@@ -7,7 +7,6 @@ from sglang.srt.utils import kill_process_tree
from sglang.test.ci.ci_register import register_cuda_ci from sglang.test.ci.ci_register import register_cuda_ci
from sglang.test.run_eval import run_eval from sglang.test.run_eval import run_eval
from sglang.test.test_utils import ( from sglang.test.test_utils import (
DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH,
DEFAULT_URL_FOR_TEST, DEFAULT_URL_FOR_TEST,
ModelEvalMetrics, ModelEvalMetrics,
ModelLaunchSettings, ModelLaunchSettings,
@@ -16,6 +15,10 @@ from sglang.test.test_utils import (
write_results_to_json, write_results_to_json,
) )
# Nightly eval tests run large models that may need downloading on cache miss.
# Use a longer timeout than the default 600s.
NIGHTLY_EVAL_SERVER_TIMEOUT = 1800
register_cuda_ci(est_time=7200, suite="nightly-eval-vlm-2-gpu", nightly=True) register_cuda_ci(est_time=7200, suite="nightly-eval-vlm-2-gpu", nightly=True)
MODEL_THRESHOLDS = { MODEL_THRESHOLDS = {
@@ -70,15 +73,16 @@ class TestNightlyVLMMmmuEval(unittest.TestCase):
for model in self.models: for model in self.models:
model_path = model.model_path model_path = model.model_path
error_message = None
with self.subTest(model=model_path): with self.subTest(model=model_path):
process = None
try:
process = popen_launch_server( process = popen_launch_server(
model=model_path, model=model_path,
base_url=self.base_url, base_url=self.base_url,
other_args=model.extra_args, other_args=model.extra_args,
timeout=DEFAULT_TIMEOUT_FOR_SERVER_LAUNCH, timeout=NIGHTLY_EVAL_SERVER_TIMEOUT,
) )
try:
args = SimpleNamespace( args = SimpleNamespace(
base_url=self.base_url, base_url=self.base_url,
model=model_path, model=model_path,
@@ -106,16 +110,15 @@ class TestNightlyVLMMmmuEval(unittest.TestCase):
model_path, model_path,
metrics["score"], metrics["score"],
metrics["latency"], metrics["latency"],
error_message, None,
) )
) )
except Exception as e: except Exception as e:
# Capture error message for the summary table
error_message = str(e) error_message = str(e)
# Still append result with error info (use None for N/A metrics to match else clause)
all_results.append((model_path, None, None, error_message)) all_results.append((model_path, None, None, error_message))
print(f"Error evaluating {model_path}: {error_message}") print(f"Error evaluating {model_path}: {error_message}")
finally: finally:
if process is not None:
kill_process_tree(process.pid) kill_process_tree(process.pid)
try: try: