Fix bounded checkpoint prefetching and buffered drop-cache handling (#29156)

This commit is contained in:
Mohammad Miadh Angkad
2026-06-29 21:49:16 +00:00
committed by GitHub
parent 5556631789
commit 6c018eb4d1
2 changed files with 227 additions and 34 deletions
+57 -31
View File
@@ -67,7 +67,7 @@ from sglang.utils import is_in_ci
try: try:
from fastsafetensors import SafeTensorsFileLoader, SingleGroup from fastsafetensors import SafeTensorsFileLoader, SingleGroup
except ImportError as e: except ImportError:
SafeTensorsFileLoader = SingleGroup = None SafeTensorsFileLoader = SingleGroup = None
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -814,10 +814,12 @@ def _prefetch_all_checkpoints(
naturally adapts to any RAM size — even if the full checkpoint does naturally adapts to any RAM size — even if the full checkpoint does
not fit in page cache, the prefetch thread stays ahead of the loader. not fit in page cache, the prefetch thread stays ahead of the loader.
""" """
import asyncio
import threading import threading
import time import time
if num_threads < 1:
raise ValueError("weight loader prefetch num_threads must be >= 1")
# Use node-local rank so that each node independently prefetches the # Use node-local rank so that each node independently prefetches the
# full checkpoint into its own page cache. Global rank would split files # full checkpoint into its own page cache. Global rank would split files
# across nodes, but page cache is not shared across nodes. # across nodes, but page cache is not shared across nodes.
@@ -842,40 +844,60 @@ def _prefetch_all_checkpoints(
num_threads, num_threads,
) )
async def _prefetch_all() -> None: def _prefetch_all() -> None:
semaphore = asyncio.Semaphore(num_threads)
completed = 0 completed = 0
next_log_pct = 10 next_log_pct = 10
async def prefetch_one(path: str) -> None: def record_complete() -> None:
nonlocal completed, next_log_pct nonlocal completed, next_log_pct
try:
async with semaphore:
await asyncio.to_thread(_prefetch_checkpoint_file, path)
completed += 1
if total_for_rank > 0 and next_log_pct <= 100:
pct = 100 * completed / total_for_rank
if pct >= next_log_pct:
logger.info(
"Rank %d: prefetching checkpoint files: %d%% (%d/%d)",
local_rank,
next_log_pct,
completed,
total_for_rank,
)
next_log_pct += 10
except Exception:
logger.warning(
"Failed to prefetch checkpoint file %r.",
path,
exc_info=True,
)
await asyncio.gather(*(prefetch_one(p) for p in my_files)) completed += 1
if total_for_rank > 0 and next_log_pct <= 100:
pct = 100 * completed / total_for_rank
while pct >= next_log_pct and next_log_pct <= 100:
logger.info(
"Rank %d: prefetching checkpoint files: %d%% (%d/%d)",
local_rank,
next_log_pct,
completed,
total_for_rank,
)
next_log_pct += 10
with concurrent.futures.ThreadPoolExecutor(max_workers=num_threads) as executor:
file_iter = iter(my_files)
pending: Dict[concurrent.futures.Future, str] = {}
for path in itertools.islice(file_iter, num_threads):
pending[executor.submit(_prefetch_checkpoint_file, path)] = path
while pending:
done, _ = concurrent.futures.wait(
pending,
return_when=concurrent.futures.FIRST_COMPLETED,
)
for future in done:
path = pending.pop(future)
try:
future.result()
except Exception:
logger.warning(
"Failed to prefetch checkpoint file %r.",
path,
exc_info=True,
)
finally:
record_complete()
next_path = next(file_iter, None)
if next_path is not None:
pending[
executor.submit(_prefetch_checkpoint_file, next_path)
] = next_path
def _run_prefetch() -> None: def _run_prefetch() -> None:
start = time.perf_counter() start = time.perf_counter()
asyncio.run(_prefetch_all()) _prefetch_all()
elapsed = time.perf_counter() - start elapsed = time.perf_counter() - start
logger.info( logger.info(
"Rank %d: prefetching checkpoint files into page cache " "Rank %d: prefetching checkpoint files into page cache "
@@ -1081,7 +1103,7 @@ def buffered_multi_thread_safetensors_weights_iterator(
# Seed the buffer. # Seed the buffer.
for st_file in itertools.islice(file_iter, buffer_size): for st_file in itertools.islice(file_iter, buffer_size):
pending.append(executor.submit(_load_file, st_file)) pending.append((st_file, executor.submit(_load_file, st_file)))
with tqdm( with tqdm(
total=len(hf_weights_files), total=len(hf_weights_files),
@@ -1091,18 +1113,22 @@ def buffered_multi_thread_safetensors_weights_iterator(
position=tqdm._get_free_pos(), position=tqdm._get_free_pos(),
) as pbar: ) as pbar:
while pending: while pending:
future = pending.popleft() st_file, future = pending.popleft()
state_dict = future.result() state_dict = future.result()
del future # let GC reclaim the Future's internal result del future # let GC reclaim the Future's internal result
# Replenish: submit the next file to keep the buffer full. # Replenish: submit the next file to keep the buffer full.
next_file = next(file_iter, None) next_file = next(file_iter, None)
if next_file is not None: if next_file is not None:
pending.append(executor.submit(_load_file, next_file)) pending.append((next_file, executor.submit(_load_file, next_file)))
for name in sorted(state_dict.keys()): for name in sorted(state_dict.keys()):
yield name, state_dict[name] yield name, state_dict[name]
del state_dict del state_dict
if drop_cache_after_load:
# DONTNEED reduces page-cache pressure after copying weights,
# but later mmap-backed tensor access may fault pages again.
_drop_file_cache_after_load(st_file)
pbar.update(1) pbar.update(1)
@@ -8,12 +8,15 @@ to weights loaded without prefetch.
import os import os
import tempfile import tempfile
import unittest import unittest
from concurrent.futures import Future
from unittest.mock import patch from unittest.mock import patch
import safetensors.torch import safetensors.torch
import torch import torch
from sglang.srt.model_loader.weight_utils import ( from sglang.srt.model_loader.weight_utils import (
_prefetch_all_checkpoints,
buffered_multi_thread_safetensors_weights_iterator,
safetensors_weights_iterator, safetensors_weights_iterator,
) )
from sglang.test.ci.ci_register import register_cpu_ci from sglang.test.ci.ci_register import register_cpu_ci
@@ -21,8 +24,40 @@ from sglang.test.ci.ci_register import register_cpu_ci
register_cpu_ci(est_time=10, suite="base-a-test-cpu") register_cpu_ci(est_time=10, suite="base-a-test-cpu")
class TestPrefetchWeightsIdentical(unittest.TestCase): class _InlineThread:
"""Verify that loading with prefetch yields identical weights to without.""" def __init__(self, target, daemon=None):
self.target = target
self.daemon = daemon
def start(self):
self.target()
class _InlineExecutor:
def __init__(self, max_workers):
self.max_workers = max_workers
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def submit(self, fn, *args, **kwargs):
future = Future()
try:
future.set_result(fn(*args, **kwargs))
except Exception as exc:
future.set_exception(exc)
return future
def _wait_all(fs, return_when):
return set(fs), set()
class TestPrefetchCheckpoints(unittest.TestCase):
"""Verify coordinated checkpoint prefetch behavior."""
def _create_safetensors_files(self, tmpdir, num_shards=3): def _create_safetensors_files(self, tmpdir, num_shards=3):
"""Create real safetensors files with known tensor content.""" """Create real safetensors files with known tensor content."""
@@ -44,12 +79,144 @@ class TestPrefetchWeightsIdentical(unittest.TestCase):
paths = self._create_safetensors_files(tmpdir) paths = self._create_safetensors_files(tmpdir)
without = dict(safetensors_weights_iterator(paths, prefetch=False)) without = dict(safetensors_weights_iterator(paths, prefetch=False))
with_pf = dict(safetensors_weights_iterator(paths, prefetch=True)) with (
patch("threading.Thread", _InlineThread),
patch("concurrent.futures.ThreadPoolExecutor", _InlineExecutor),
patch("concurrent.futures.wait", side_effect=_wait_all),
):
with_pf = dict(safetensors_weights_iterator(paths, prefetch=True))
self.assertEqual(set(without.keys()), set(with_pf.keys())) self.assertEqual(set(without.keys()), set(with_pf.keys()))
for name in without: for name in without:
torch.testing.assert_close(without[name], with_pf[name]) torch.testing.assert_close(without[name], with_pf[name])
def test_prefetch_rejects_invalid_thread_count(self):
with self.assertRaisesRegex(ValueError, "num_threads"):
_prefetch_all_checkpoints(["dummy.safetensors"], num_threads=0)
@patch("torch.distributed.is_initialized", return_value=False)
def test_prefetch_keeps_bounded_pending_window(self, _):
paths = [f"model-{i:05d}.safetensors" for i in range(20)]
pending_sizes = []
submitted_paths = []
class RecordingExecutor(_InlineExecutor):
def submit(self, fn, path):
submitted_paths.append(path)
return super().submit(fn, path)
def record_pending_size(fs, return_when):
pending_sizes.append(len(fs))
return _wait_all(fs, return_when)
with (
patch("threading.Thread", _InlineThread),
patch("concurrent.futures.ThreadPoolExecutor", RecordingExecutor),
patch("concurrent.futures.wait", side_effect=record_pending_size),
patch("sglang.srt.model_loader.weight_utils._prefetch_checkpoint_file"),
):
_prefetch_all_checkpoints(paths, num_threads=4)
self.assertEqual(submitted_paths, paths)
self.assertLessEqual(max(pending_sizes), 4)
@patch("torch.distributed.is_initialized", return_value=False)
def test_prefetch_logs_failed_futures(self, _):
paths = ["bad.safetensors"]
def fail_prefetch(path):
raise OSError(f"failed {path}")
with (
patch("threading.Thread", _InlineThread),
patch("concurrent.futures.ThreadPoolExecutor", _InlineExecutor),
patch("concurrent.futures.wait", side_effect=_wait_all),
patch(
"sglang.srt.model_loader.weight_utils._prefetch_checkpoint_file",
side_effect=fail_prefetch,
),
patch("sglang.srt.model_loader.weight_utils.logger.warning") as warning,
):
_prefetch_all_checkpoints(paths, num_threads=1)
warning.assert_called_once()
self.assertEqual(
warning.call_args.args[0],
"Failed to prefetch checkpoint file %r.",
)
self.assertEqual(warning.call_args.args[1], paths[0])
self.assertTrue(warning.call_args.kwargs["exc_info"])
@patch("torch.distributed.is_initialized", return_value=False)
def test_prefetch_progress_logs_all_crossed_buckets(self, _):
paths = [f"model-{i:05d}.safetensors" for i in range(3)]
with (
patch("threading.Thread", _InlineThread),
patch("concurrent.futures.ThreadPoolExecutor", _InlineExecutor),
patch("concurrent.futures.wait", side_effect=_wait_all),
patch("sglang.srt.model_loader.weight_utils._prefetch_checkpoint_file"),
patch("sglang.srt.model_loader.weight_utils.logger.info") as log_info,
):
_prefetch_all_checkpoints(paths, num_threads=1)
progress_pcts = [
call.args[2]
for call in log_info.call_args_list
if call.args
and call.args[0] == "Rank %d: prefetching checkpoint files: %d%% (%d/%d)"
]
self.assertEqual(progress_pcts, list(range(10, 101, 10)))
@patch("torch.distributed.is_initialized", return_value=True)
def test_prefetch_uses_node_local_rank_partitioning(self, _):
paths = [f"model-{i:05d}.safetensors" for i in range(10)]
loaded_paths = []
class FakeWorldGroup:
local_rank = 1
local_size = 3
world_size = 99
with (
patch("threading.Thread", _InlineThread),
patch("concurrent.futures.ThreadPoolExecutor", _InlineExecutor),
patch("concurrent.futures.wait", side_effect=_wait_all),
patch(
"sglang.srt.model_loader.weight_utils.get_world_group",
return_value=FakeWorldGroup(),
),
patch(
"sglang.srt.model_loader.weight_utils._prefetch_checkpoint_file",
side_effect=loaded_paths.append,
),
):
_prefetch_all_checkpoints(paths, num_threads=2)
self.assertEqual(sorted(loaded_paths), sorted(paths[1::3]))
@patch("torch.distributed.is_initialized", return_value=False)
def test_buffered_loader_drops_cache_after_each_loaded_shard(self, _):
with tempfile.TemporaryDirectory() as tmpdir:
paths = self._create_safetensors_files(tmpdir, num_shards=3)
with patch(
"sglang.srt.model_loader.weight_utils._drop_file_cache_after_load"
) as drop_cache:
loaded = list(
buffered_multi_thread_safetensors_weights_iterator(
paths,
max_workers=2,
drop_cache_after_load=True,
)
)
self.assertEqual(len(loaded), 6)
self.assertEqual(
[call.args[0] for call in drop_cache.call_args_list],
paths,
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()