[FEAT] Support fast engine recovery through weight cache - #27139
Conversation
Implement a persistent Weight Cache Daemon that holds post-quantized, TP-sharded model weights in GPU memory. On engine restart, the new engine process maps or copies weights from the daemon via CUDA IPC handles, reducing restart time from minutes to sub-second. New files: - weight_cache/protocol.py: CacheConfig validation, socket protocol - weight_cache/daemon.py: WeightCacheDaemon process - weight_cache/ipc_loader.py: IpcModelLoader (BaseModelLoader subclass) - docs/references/weight_cache_design.md: Design document Key changes: - LoadFormat.IPC_CACHE enum value + weight_cache_mode/socket in LoadConfig - --weight-cache-mode/--weight-cache-socket CLI args in ServerArgs - get_model_loader() dispatches to IpcModelLoader for IPC_CACHE format - Engine._launch_weight_cache_daemons() for daemon mode startup - ModelRunner.load_model() wires weight_cache into LoadConfig Two mapping modes: - Copy mode (default): import IPC handle, copy_() to own allocation, release - Zero-copy mode: param.data points directly to IPC-mapped GPU memory Config validation ensures TP/DP/quant/dtype mismatches trigger fallback to disk loading. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
All daemon processes must join the same NCCL distributed group with world_size=tp_size and rank=tp_rank. Previously each daemon initialized with world_size=1 which failed when tp_size > 1. Changes: - daemon.py: Accept dist_init_method param, use world_size=tp_size and rank=tp_rank in init_distributed_environment - engine.py: Allocate a shared port via PortArgs and pass dist_init_method to all daemon processes so they form one process group - CLI: Add --dist-init-method argument for manual daemon launch Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Unit tests (test_weight_cache.py): CacheConfig matching, socket protocol, LoadFormat dispatch, cross-process CUDA IPC, config mismatch detection Registered for CI with register_cuda_ci - E2E test (test_weight_cache_e2e.py): Launches real daemons with TP support, loads model, exports IPC handles, fetches and imports tensors from client Placed in test/manual/ as it requires GPU and model weights Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ize loading - Fix AttributeError: ModelConfig doesn't have 'architectures' directly, use 'hf_config.architectures' instead (both daemon.py and ipc_loader.py) - Add torch.set_num_threads(1) to reduce CPU thread contention during multi-process model loading (matching ModelRunner behavior) - Pass model_loader_extra_config to ServerArgs for proper weight loading configuration (multithread, prefetch settings) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…k load When the weight cache daemon is unavailable and IpcModelLoader falls back to DefaultModelLoader, it was passing LoadFormat.IPC_CACHE which DefaultModelLoader._prepare_weights() doesn't recognize, causing: ValueError: Unknown load_format: LoadFormat.IPC_CACHE Now _fallback_load creates a new LoadConfig with LoadFormat.AUTO and preserves other relevant fields (download_dir, model_loader_extra_config, tp_rank). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…el loading process_weights_after_loading() mutates hf_config.quantization_config (e.g. adding computed scale fields), causing the daemon to produce a different quant_config_hash than the engine which reads the original config before loading. Move CacheConfig construction before loader.load_model() so both sides hash the same original config. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…_loader.utils The import was incorrectly from sglang.srt.utils, but the function is defined in sglang.srt.model_loader.utils. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… attribute Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… protocol - IpcModelLoader zero-copy mode now initializes model on meta device (no GPU memory), then maps IPC tensors directly as param.data. This eliminates the 2x GPU memory overhead when daemon and engine share the same GPU. - Copy mode sends "release" command to daemon after copying weights, allowing daemon to free GPU memory. - Daemon handles "release" command to free model and state entries. - Skip process_weights_after_loading in zero-copy mode since daemon already includes post-quantization parameters in state_dict. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…-persistent buffers - IpcModelLoader: iterate over ALL daemon entries (not just model params/buffers) to include post-quantization parameters (e.g. FP8 weight_scale) that exist in the daemon but not in the meta-device model. Use _set_module_tensor() to replace params/buffers via setattr instead of param.data assignment (which fails on meta tensors due to incompatible dispatch keys). - IpcModelLoader: materialize remaining meta tensors individually instead of using model.to() or model.to_empty(), which would re-allocate IPC-mapped tensors and cause OOM. - IpcModelLoader: raise RuntimeError in daemon mode if daemon unavailable (fallback to disk would cause OOM since both processes share the same GPU). - Daemon: export non-persistent buffers (e.g. rotary embedding cos_sin_cache) in addition to state_dict entries, so the engine can fully reconstruct model state via zero-copy IPC. - ModelRunner: compute socket path using gpu_id (not tp_rank) since daemon binds by gpu_id. Disable CPU weight backup in zero-copy IPC mode. - Loader: pass weight_cache_mode to IpcModelLoader constructor. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…m/buffer dicts The zero-copy IPC loading loop was calling dict(model.named_parameters()) on every iteration to validate shape/dtype, which re-traverses the entire model tree 1578 times. Pre-building the dicts once before the loop changes lookup from O(n) per iteration to O(1), eliminating ~3.5s of overhead. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Implement CUDA graph cache to avoid re-capturing all batch sizes on
every server restart. On first start, capture normally and serialize
graph metadata (kernel functions, parameters, topology) to disk.
On subsequent starts, reconstruct the graph from metadata with
patched addresses, skipping the 2 warmup + 1 capture forward passes
per batch size.
Key components:
- cuda_graph_inspector.py: Graph node inspection via cuda.bindings.driver
- inspect_cuda_graph(): walks nodes, extracts kernel params, dependencies
- get_kernel_name()/get_kernel_module(): cuFuncGetName/cuFuncGetModule (CUDA 12.4+)
- CUFunctionRegistry: resolves CUfunction -> (module, name) via Triton
CompiledKernel scanning and cuModuleGetFunction
- BufferAddressRegistry: maps data_ptr -> symbolic name for address patching
- Safe kernel param reading via /proc/self/mem
- cuda_graph_serializer.py: Serialization and reconstruction
- save_graph_cache()/load_graph_cache(): JSON-based with version validation
- reconstruct_cuda_graph(): builds new cudaGraph_t via cuGraphAddKernelNode
- _ReconstructedCUDAGraph: replay wrapper via cuGraphLaunch
- Memcpy/memset node reconstruction support
- cuda_graph_runner.py: SGLang integration
- --cuda-graph-cache-dir server argument
- keep_graph=True for inspection after capture
- _try_load_from_cache()/_save_to_cache() for cache I/O
- Cache key includes model hash, batch size, GPU arch, driver version
- input_buffers.py: named_buffers() for address registry
Verified on H20 GPU: capture -> inspect -> serialize -> reconstruct ->
replay produces identical output. cuFuncGetName and cuModuleGetFunction
round-trip works for both PyTorch (mangled) and Triton (unmangled) kernels.
Remaining: intermediate tensor address translation, output buffer handling,
end-to-end SGLang model test.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…slation Key discovery: all intermediate tensor addresses from the same CUDA graph memory pool shift by a single constant offset across process restarts. This enables fast graph reconstruction without re-capture by computing one offset. Changes: - Add pool_base_addr to GraphMetadata and SerializableGraphMetadata - Detect pool base from first intermediate tensor during graph inspection - Add pool_offset parameter to reconstruct_cuda_graph() and node builders - Apply pool_offset to intermediate/unknown pointers during reconstruction - Add compute_pool_offset() utility function - Restructure capture flow: compute pool offset after first capture, then load remaining batch sizes from cache with the offset - Add _load_cached_pool_base() and _compute_pool_offset() to CudaGraphRunner - Add _pool_offset field to track computed offset across batch sizes Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- test_pool_offset_standalone.py: Validates single-offset property, pool base detection, address translation, and same-pool determinism - test_triton_pool_offset.py: Validates with Triton kernels (no intermediates case) All tests pass on H20 GPU with PyTorch 2.11.0+cu130. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a persistent Weight Cache Daemon and a CUDA Graph serialization mechanism to enable fast engine restarts by caching post-quantized weights and CUDA graph metadata. The feedback highlights a few critical issues and optimizations, including a potential NameError in input_buffers.py due to a missing import, a native resource leak in cuda_graph_serializer.py if graph reconstruction fails, a misleading error message in engine.py during daemon startup failures, and a performance bottleneck in cuda_graph_inspector.py caused by excessive driver API calls on scalar arguments.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
|
/rerun-failed-ci |
| port_args, | ||
| scheduler_init_result, | ||
| subprocess_watchdog, | ||
| _weight_cache_daemon_procs, |
There was a problem hiding this comment.
in next follow up: we can leave stale .sock/.ready files in edge cases
|
Hi @QiuMike @liusy58, this is great to see! We have a similar proposal in another RFC here: #27310 which would utilize something similar we have built in Dynamo (see https://github.com/ai-dynamo/dynamo/tree/main/lib/gpu_memory_service). So it seems like we are converging on a design. A few questions: In our design, we instead have the engine do all of the weight loading. Depending on whether the daemon already has weights or not, it will either exercise the regular model loading path (if it doesn't), or use the meta model initialization + IPC import (if it does). We actually had an old prototype of the same architecture as this implementation. However, the weight daemon is actively holding a (CUDA) context because it does the loading of the weights - but there can be some classes of errors where holding onto a context causes the process to be poisoned, which means it needs to be killed (e.g. NVLink failure). If the daemon simply gives out CUDA allocations, no CUDA context is actually necessary allowing the daemon to resilient to more classes of failures. Another benefit is you don't need to deal with "fake sharding" in the weight daemon so it's less complex. We've implemented an early version of active standby failover already within Dynamo, and one of the gaps here I see is how you would go about pause/resume with this architecture. Ultimately the secondary (we call this the "shadow" in Dynamo) would need to consume as few GPU resources as possible, so we would need to make sure the KV cache is unmapped, and free as many buffers as possible, while sharing the same set of weights. I'm wondering whether you have any thoughts on your approach to this problem with this architecture. One way to handle this is via a custom TMS backend: fzyzcjy/torch_memory_saver#79 The meta-model instantiation path is quite brittle. I see here that there are a few things that need to be hard-coded (some tensors are views of others and need to be rewired to the same storage, etc) that are in |
|
/rerun-failed-ci |
Absolutely, let's collaborate on iterating this feature. |
|
/rerun-failed-ci |
# Conflicts: # python/sglang/srt/model_executor/model_runner_components/weight_updater.py
…#27139) Signed-off-by: Michael Qiu <qiudayu.qdy@antgroup.com> Co-authored-by: liusy58 <liusy58@linux.alibaba.com> Co-authored-by: Alex Nails <alex.nails@radixark.ai>

[RFC] Fast Recovery Framework for SGLang
Author: Michael Qiu qiudayu.qdy@antgroup.com; Siyu Liu @liusy58
Motivation
SGLang engine restarts are expensive. When an engine crashes, rolls out, or needs to restart for any reason, it must reload model weights from storage into GPU memory, re-initialize the distributed backend, recapture CUDA graphs, and re-warm JIT kernels — a process that takes 3–6+ minutes for large models (e.g., Qwen3-235B FP8 takes ~6.5 minutes on 4×GPU). In production serving environments, this translates directly to:
Beyond crash recovery, the Weight Cache Daemon unlocks a broader set of production scenarios that are impractical today:
This RFC proposes a Fast Recovery Framework with the goal of reducing engine restart time to < 10 seconds (cold restart) and < 1 second (warm standby switch). The framework addresses each startup phase independently, with the weight cache daemon as the first and highest-impact optimization.
Startup Time Breakdown
We profiled a complete SGLang engine startup for Qwen3-235B FP8 on 4×GPU (TP=4):
Key insight: Weight loading from disk dominates at ~79% of total startup time. Even though CUDA graph capture + kernel warmup is the second largest cost at ~15%, it's dwarfed by the 5+ minutes spent on disk I/O. Eliminating weight loading is the single highest-leverage optimization.
Fast Recovery Framework: Multi-Phase Roadmap
The target is < 10s restart time. This requires optimizing every major startup phase:
Phase 1: Weight Cache Daemon (This PR)
Eliminates the dominant bottleneck (~79% of startup). A persistent GPU process holds post-quantized, TP-sharded weights and serves them to new engine instances via CUDA IPC zero-copy mapping. Weight loading drops from ~306s to <1s.
Current implementation supports single-node, TP-only deployments. Each GPU runs one daemon for its TP rank, communicating via Unix domain sockets and CUDA IPC (same-node GPU memory sharing).
Phase 2: CUDA Graph Serialization
Serialize captured CUDA graphs to shared memory or disk. On restart, replay the serialized graphs instead of recapturing. Eliminates the second-largest bottleneck (~9% of startup). Requires pool offset translation for address relocation.
Phase 3: Kernel Warmup Optimization
Persist DeepGEMM JIT-compiled kernels across restarts. Parallelize warmup across batch sizes. Reduces warmup from ~23s to <2s.
Phase 4: Server & Distributed Init Optimization
Lazy tokenizer initialization, NCCL session reuse, config caching. Reduces server init + distributed init from ~22s to <5s.
Phase 5: Multi-Node & DP/EP Support
Extend the Weight Cache Daemon to support multi-node (e.g., TP=16 across 2 machines) and DP/EP deployments. CUDA IPC remains the same-node GPU memory sharing mechanism — this phase adds primary-secondary daemon coordination across nodes.
Phase 6: Enhance robustness
The daemon is simple without any heavy logic, so itself is very robust, but if it been exited by other reasons, the client will still hold the GPU Mem, so if we restart the daemon, we could restore the GPU memory from client ref
ep_rankinCacheConfig), and support partial expert weight loading when only a subset of experts is assigned to a rank.ep_size,ep_rank, andnode_rankfields toCacheConfigfor correct shard identification in EP and multi-node scenarios.--weight-cache-socketconfiguration.Phase 1: Weight Cache Daemon
Architecture
Each GPU runs one daemon process holding only its own TP rank's shard. The daemon caches the complete post-quantization state (after
process_weights_after_loading()), including new parameters likeweight_scale,workspace, repacked weights, etc.Zero-Copy IPC Loading
The engine initializes the model on the meta device (no GPU/CPU memory allocation), then maps each parameter's data pointer directly to the IPC-mapped GPU tensor. The engine and daemon share the same physical GPU memory via CUDA IPC — no copy is needed.
Process Lifecycle
Config Validation (CacheConfig)
Any mismatch in these fields triggers a full disk reload, ensuring correctness:
model_path+model_arch— different modeltp_size+tp_rank— different TP shardingdp_size— different DP strategyquant_method+quant_config_hash— different quantizationdtype— different precisionPerformance Results
Weight Loading: Disk vs IPC Zero-Copy
IPC Loading Path Optimization
During development, the IPC loading path was profiled and optimized:
model.named_parameters()per entryEnd-to-End Startup: Qwen3-235B FP8 (4×GPU, client mode)
Test: disk load vs Weight Cache Daemon client:
Implementation
Maintainability and Safety
This feature is relatively independent and has minimal invasiveness to the overall engine. Additionally, if the daemon process crashes or exits, it will not affect client-side inference. Since GPU memory is not released upon a crash (thanks to CUDA's reference counting), memory is only freed when both the daemon and the client processes exit. Therefore, this approach is very safe.
File Layout
Integration Points
LoadFormat.IPC_CACHEconfigs/load_config.py--weight-cache-modeoff|daemon|clientinserver_args.py--weight-cache-socketIpcModelLoaderBaseModelLoadersubclass inweight_cache/ipc_loader.pyModelRunner.load_model()IpcModelLoaderwhen cache mode is setEngine._launch_weight_cache_daemons()ModelRunner(memory saver)CLI Usage
PR Breakdown (Phase 1)
CI States
Latest PR Test (Base): ✅ Run #30099779370
Latest PR Test (Extra): ✅ Run #30327534784