feat: add coordinated checkpoint prefetch for network filesystem loading (#20843)
This commit is contained in:
@@ -163,6 +163,7 @@ class Envs:
|
||||
SGLANG_USE_MODELSCOPE = EnvBool(False)
|
||||
SGLANG_SORT_WEIGHT_FILES = EnvBool(False)
|
||||
SGLANG_DISABLED_MODEL_ARCHS = EnvTuple(tuple())
|
||||
SGLANG_PREFETCH_BLOCK_SIZE_MB = EnvInt(16)
|
||||
|
||||
# Logging Options
|
||||
SGLANG_LOG_GC = EnvBool(False)
|
||||
|
||||
@@ -506,9 +506,10 @@ class DefaultModelLoader(BaseModelLoader):
|
||||
hf_weights_files,
|
||||
)
|
||||
elif use_safetensors:
|
||||
weight_loader_disable_mmap = (
|
||||
get_global_server_args().weight_loader_disable_mmap
|
||||
)
|
||||
server_args = get_global_server_args()
|
||||
weight_loader_disable_mmap = server_args.weight_loader_disable_mmap
|
||||
weight_loader_prefetch = server_args.weight_loader_prefetch_checkpoints
|
||||
prefetch_num_threads = server_args.weight_loader_prefetch_num_threads
|
||||
|
||||
if self.load_config.load_format == LoadFormat.FASTSAFETENSORS:
|
||||
weights_iterator = fastsafetensors_weights_iterator(
|
||||
@@ -521,10 +522,15 @@ class DefaultModelLoader(BaseModelLoader):
|
||||
"num_threads", self.DEFAULT_NUM_THREADS
|
||||
),
|
||||
disable_mmap=weight_loader_disable_mmap,
|
||||
prefetch=weight_loader_prefetch,
|
||||
prefetch_num_threads=prefetch_num_threads,
|
||||
)
|
||||
else:
|
||||
weights_iterator = safetensors_weights_iterator(
|
||||
hf_weights_files, disable_mmap=weight_loader_disable_mmap
|
||||
hf_weights_files,
|
||||
disable_mmap=weight_loader_disable_mmap,
|
||||
prefetch=weight_loader_prefetch,
|
||||
prefetch_num_threads=prefetch_num_threads,
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
@@ -39,6 +39,7 @@ from sglang.srt.configs.model_config import ModelConfig
|
||||
from sglang.srt.distributed import (
|
||||
get_tensor_model_parallel_rank,
|
||||
get_tensor_model_parallel_world_size,
|
||||
get_world_group,
|
||||
)
|
||||
from sglang.srt.layers.dp_attention import get_attention_tp_rank
|
||||
from sglang.srt.layers.quantization import QuantizationConfig, get_quantization_config
|
||||
@@ -68,6 +69,18 @@ except ImportError as e:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Block size for sequential checkpoint prefetch reads (page cache warming).
|
||||
_PREFETCH_BLOCK_SIZE = None
|
||||
|
||||
|
||||
def _get_prefetch_block_size() -> int:
|
||||
global _PREFETCH_BLOCK_SIZE
|
||||
if _PREFETCH_BLOCK_SIZE is None:
|
||||
from sglang.srt.environ import envs
|
||||
|
||||
_PREFETCH_BLOCK_SIZE = envs.SGLANG_PREFETCH_BLOCK_SIZE_MB.get() * 1024 * 1024
|
||||
return _PREFETCH_BLOCK_SIZE
|
||||
|
||||
|
||||
# use system-level temp directory for file locks, so that multiple users
|
||||
# can share the same lock without error.
|
||||
@@ -700,16 +713,127 @@ def np_cache_weights_iterator(
|
||||
yield name, torch.from_numpy(param)
|
||||
|
||||
|
||||
def _prefetch_checkpoint_file(file_path: str) -> None:
|
||||
"""Prefetch a checkpoint file into the OS page cache.
|
||||
|
||||
Reads the file sequentially in 16 MB blocks so the kernel caches its pages
|
||||
before workers load the same file via mmap.
|
||||
"""
|
||||
with open(file_path, "rb") as f:
|
||||
while f.read(_get_prefetch_block_size()):
|
||||
pass
|
||||
|
||||
|
||||
def _prefetch_all_checkpoints(
|
||||
sorted_files: List[str],
|
||||
num_threads: int = 4,
|
||||
) -> None:
|
||||
"""Start prefetching checkpoint files into page cache in a background thread.
|
||||
|
||||
When multiple ranks on the same node load the same checkpoint (e.g.
|
||||
DP-attention), each rank independently mmaps the same files, causing
|
||||
redundant NFS/Lustre reads. By distributing the prefetch across ranks
|
||||
(each rank reads 1/Nth of the shards), the total network I/O is reduced
|
||||
from N * checkpoint_size to 1 * checkpoint_size, with subsequent
|
||||
mmap accesses hitting the shared OS page cache.
|
||||
|
||||
The prefetch runs in a background thread so that loading can start
|
||||
immediately and benefit from pages that have already been cached,
|
||||
rather than blocking until all files are prefetched. This pipelining
|
||||
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.
|
||||
"""
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
|
||||
# Use node-local rank so that each node independently prefetches the
|
||||
# full checkpoint into its own page cache. Global rank would split files
|
||||
# across nodes, but page cache is not shared across nodes.
|
||||
if torch.distributed.is_initialized():
|
||||
world_group = get_world_group()
|
||||
local_rank = world_group.local_rank
|
||||
local_world_size = world_group.local_size or world_group.world_size
|
||||
else:
|
||||
local_rank = 0
|
||||
local_world_size = 1
|
||||
|
||||
my_files = sorted_files[local_rank::local_world_size]
|
||||
total_for_rank = len(my_files)
|
||||
|
||||
logger.info(
|
||||
"Rank %d: prefetching %d/%d checkpoint shards into page cache "
|
||||
"(background, %d local ranks sharing the work, %d threads per rank)...",
|
||||
local_rank,
|
||||
total_for_rank,
|
||||
len(sorted_files),
|
||||
local_world_size,
|
||||
num_threads,
|
||||
)
|
||||
|
||||
async def _prefetch_all() -> None:
|
||||
semaphore = asyncio.Semaphore(num_threads)
|
||||
completed = 0
|
||||
next_log_pct = 10
|
||||
|
||||
async def prefetch_one(path: str) -> None:
|
||||
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))
|
||||
|
||||
def _run_prefetch() -> None:
|
||||
start = time.perf_counter()
|
||||
asyncio.run(_prefetch_all())
|
||||
elapsed = time.perf_counter() - start
|
||||
logger.info(
|
||||
"Rank %d: prefetching checkpoint files into page cache "
|
||||
"finished in %.2fs",
|
||||
local_rank,
|
||||
elapsed,
|
||||
)
|
||||
|
||||
threading.Thread(target=_run_prefetch, daemon=True).start()
|
||||
|
||||
|
||||
def safetensors_weights_iterator(
|
||||
hf_weights_files: List[str],
|
||||
disable_mmap: bool = False,
|
||||
prefetch: bool = False,
|
||||
prefetch_num_threads: int = 4,
|
||||
) -> Generator[Tuple[str, torch.Tensor], None, None]:
|
||||
"""Iterate over the weights in the model safetensor files."""
|
||||
enable_tqdm = (
|
||||
not torch.distributed.is_initialized() or torch.distributed.get_rank() == 0
|
||||
)
|
||||
|
||||
sorted_files = sorted(hf_weights_files)
|
||||
|
||||
if prefetch and not disable_mmap:
|
||||
_prefetch_all_checkpoints(sorted_files, num_threads=prefetch_num_threads)
|
||||
|
||||
for st_file in tqdm(
|
||||
hf_weights_files,
|
||||
sorted_files,
|
||||
desc="Loading safetensors checkpoint shards",
|
||||
disable=not enable_tqdm,
|
||||
bar_format=BAR_FORMAT,
|
||||
@@ -821,6 +945,8 @@ def buffered_multi_thread_safetensors_weights_iterator(
|
||||
hf_weights_files: List[str],
|
||||
max_workers: int,
|
||||
disable_mmap: bool = False,
|
||||
prefetch: bool = False,
|
||||
prefetch_num_threads: int = 4,
|
||||
) -> Generator[Tuple[str, torch.Tensor], None, None]:
|
||||
"""Multi-threaded safetensor loader with bounded memory via a sliding window.
|
||||
|
||||
@@ -828,6 +954,9 @@ def buffered_multi_thread_safetensors_weights_iterator(
|
||||
max_workers loading concurrently + 1 prefetched and ready to yield.
|
||||
Peak CPU RAM ≈ (max_workers + 2) × shard_file_size.
|
||||
"""
|
||||
sorted_files = sorted(hf_weights_files)
|
||||
if prefetch and not disable_mmap:
|
||||
_prefetch_all_checkpoints(sorted_files, num_threads=prefetch_num_threads)
|
||||
enable_tqdm = (
|
||||
not torch.distributed.is_initialized() or torch.distributed.get_rank() == 0
|
||||
)
|
||||
@@ -845,7 +974,7 @@ def buffered_multi_thread_safetensors_weights_iterator(
|
||||
buffer_size = max_workers + 1
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
file_iter = iter(hf_weights_files)
|
||||
file_iter = iter(sorted_files)
|
||||
pending: collections.deque = collections.deque()
|
||||
|
||||
# Seed the buffer.
|
||||
|
||||
@@ -722,6 +722,8 @@ class ServerArgs:
|
||||
# For model weight update and weight loading
|
||||
custom_weight_loader: Optional[List[str]] = None
|
||||
weight_loader_disable_mmap: bool = False
|
||||
weight_loader_prefetch_checkpoints: bool = False
|
||||
weight_loader_prefetch_num_threads: int = 4
|
||||
remote_instance_weight_loader_seed_instance_ip: Optional[str] = None
|
||||
remote_instance_weight_loader_seed_instance_service_port: Optional[int] = None
|
||||
remote_instance_weight_loader_send_weights_group_ports: Optional[List[int]] = None
|
||||
@@ -6234,6 +6236,20 @@ class ServerArgs:
|
||||
action="store_true",
|
||||
help="Disable mmap while loading weight using safetensors.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--weight-loader-prefetch-checkpoints",
|
||||
action="store_true",
|
||||
help="Prefetch checkpoint files into OS page cache before loading. "
|
||||
"Each rank prefetches a fraction of the shards, reducing total "
|
||||
"network I/O on shared filesystems (NFS/Lustre) from N*checkpoint "
|
||||
"to 1*checkpoint. Recommended for models on network storage.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--weight-loader-prefetch-num-threads",
|
||||
type=int,
|
||||
default=ServerArgs.weight_loader_prefetch_num_threads,
|
||||
help="Number of threads per rank for checkpoint prefetching (default: 4).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--remote-instance-weight-loader-seed-instance-ip",
|
||||
type=str,
|
||||
|
||||
Reference in New Issue
Block a user