Fast Start (#54921)
Signed-off-by: Isotr0py <Isotr0py@outlook.com> Co-authored-by: liusy58 <liusy58@smail.nju.edu.cn> Co-authored-by: Isotr0py <Isotr0py@outlook.com>
siyu committed
Sep 4, 2026 at 21:32 UTC
a69e75b9b6d6a26d90b6790e2eb75b3419a47c16
16 files changed
+1529
-13
tests/model_executor/model_loader/test_weight_cache.py
new
+153
@@ -0,0 +1,153 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""End-to-end tests for the IPC weight cache loader.
4
+
5
+A cold start (no weight cache daemon, so the loader falls back to disk) and
6
+warm restarts (weights mapped from the daemon via CUDA IPC) must both serve
7
+identical outputs.
8
+"""
9
+
10
+import shutil
11
+import subprocess
12
+import sys
13
+import tempfile
14
+import threading
15
+import time
16
+
17
+from vllm import SamplingParams
18
+
19
+MODEL = "Qwen/Qwen3.5-0.8B"
20
+PROMPTS = [
21
+ "Hello, my name is",
22
+ "The capital of France is",
23
+]
24
+DAEMON_TIMEOUT_S = 600
25
+
26
+
27
+class WeightCacheDaemon:
28
+ """Context manager running the real weight cache daemon as a subprocess."""
29
+
30
+ _READY_MARKER = "Weight cache daemon READY"
31
+
32
+ def __init__(self, model: str, tp_size: int):
33
+ # Short base path: Unix socket paths are limited to ~107 characters.
34
+ self.socket_dir = tempfile.mkdtemp(prefix="vllm_ipc_")
35
+ self._cmd = [
36
+ sys.executable,
37
+ "-m",
38
+ "vllm.model_executor.model_loader.weight_cache.daemon",
39
+ "--model",
40
+ model,
41
+ "--tensor-parallel-size",
42
+ str(tp_size),
43
+ "--weight-cache-socket-dir",
44
+ self.socket_dir,
45
+ "--enforce-eager",
46
+ ]
47
+ self._proc: subprocess.Popen | None = None
48
+ self._lines: list[str] = []
49
+
50
+ def __enter__(self) -> "WeightCacheDaemon":
51
+ self._proc = subprocess.Popen(
52
+ self._cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True
53
+ )
54
+ threading.Thread(target=self._drain_stderr, daemon=True).start()
55
+ try:
56
+ self._wait_ready(DAEMON_TIMEOUT_S)
57
+ except Exception:
58
+ self._stop()
59
+ raise
60
+ return self
61
+
62
+ def __exit__(self, *exc_info) -> None:
63
+ self._stop()
64
+
65
+ def _drain_stderr(self) -> None:
66
+ assert self._proc is not None and self._proc.stderr is not None
67
+ for line in self._proc.stderr:
68
+ self._lines.append(line)
69
+
70
+ def _logs(self) -> str:
71
+ return "".join(self._lines)
72
+
73
+ def _wait_ready(self, timeout_s: float) -> None:
74
+ assert self._proc is not None
75
+ deadline = time.monotonic() + timeout_s
76
+ while time.monotonic() < deadline:
77
+ if self._proc.poll() is not None:
78
+ raise RuntimeError(
79
+ f"Weight cache daemon exited with {self._proc.returncode}:\n"
80
+ f"{self._logs()}"
81
+ )
82
+ if self._READY_MARKER in self._logs():
83
+ return
84
+ time.sleep(1.0)
85
+ raise TimeoutError(
86
+ f"Weight cache daemon not ready after {timeout_s}s:\n{self._logs()}"
87
+ )
88
+
89
+ def _stop(self) -> None:
90
+ assert self._proc is not None
91
+ self._proc.terminate()
92
+ try:
93
+ self._proc.wait(timeout=60)
94
+ except subprocess.TimeoutExpired:
95
+ self._proc.kill()
96
+ self._proc.wait()
97
+ shutil.rmtree(self.socket_dir, ignore_errors=True)
98
+
99
+
100
+def generate(vllm_runner, socket_dir: str | None, fallback: bool, **kwargs):
101
+ extra_config = (
102
+ {} if socket_dir is None else {"socket_dir": socket_dir, "fallback": fallback}
103
+ )
104
+ with vllm_runner(
105
+ MODEL,
106
+ load_format="auto" if socket_dir is None else "ipc_cache",
107
+ model_loader_extra_config=extra_config,
108
+ # Greedy outputs are compared across engine restarts; cap the batch
109
+ # size so scheduling cannot change the results.
110
+ max_num_seqs=1,
111
+ **kwargs,
112
+ ) as llm:
113
+ sampling_params = SamplingParams(temperature=0, max_tokens=16, ignore_eos=True)
114
+ return llm.generate(PROMPTS, sampling_params)
115
+
116
+
117
+def test_ipc_cache_cold_start_and_warm_restart(vllm_runner):
118
+ """Cold start falls back to disk; warm restarts load weights via CUDA IPC.
119
+
120
+ All runs must produce outputs identical to a default-loader baseline. The
121
+ warm runs disable the disk fallback, so they only pass if the weights
122
+ really came from the daemon.
123
+ """
124
+ llm_kwargs = dict(
125
+ gpu_memory_utilization=0.3,
126
+ enforce_eager=True,
127
+ # Qwen3.5 is a hybrid mamba model; its mamba cache mode requires
128
+ # chunked prefill.
129
+ enable_chunked_prefill=True,
130
+ )
131
+
132
+ # Baseline: plain disk loading with the default loader.
133
+ baseline_outputs = generate(vllm_runner, None, fallback=True, **llm_kwargs)
134
+ assert all(text for _, texts in baseline_outputs for text in texts)
135
+
136
+ # Cold start: no daemon is serving, so the loader falls back to disk.
137
+ with tempfile.TemporaryDirectory(prefix="vllm_ipc_empty_") as empty_socket_dir:
138
+ cold_outputs = generate(
139
+ vllm_runner, empty_socket_dir, fallback=True, **llm_kwargs
140
+ )
141
+
142
+ with WeightCacheDaemon(MODEL, tp_size=1) as daemon:
143
+ warm_outputs = generate(
144
+ vllm_runner, daemon.socket_dir, fallback=False, **llm_kwargs
145
+ )
146
+ # Warm restart: a second engine lifetime against the same daemon.
147
+ restart_outputs = generate(
148
+ vllm_runner, daemon.socket_dir, fallback=False, **llm_kwargs
149
+ )
150
+
151
+ assert cold_outputs == baseline_outputs
152
+ assert warm_outputs == baseline_outputs
153
+ assert restart_outputs == baseline_outputs
tools/pre_commit/check_forbidden_imports.py
+1
@@ -48,6 +48,7 @@ CHECK_IMPORTS = {
48
"vllm/distributed/device_communicators/shm_object_storage.py",
49
"vllm/distributed/weight_transfer/ipc_engine.py",
50
"vllm/distributed/weight_transfer/clients.py",
51
+ "vllm/model_executor/model_loader/weight_cache/protocol.py",
52
"tests/distributed/test_shm_broadcast.py",
53
"tests/distributed/test_weight_transfer.py",
54
"vllm/utils/hashing.py",
vllm/config/load.py
+4
@@ -38,6 +38,10 @@ class LoadConfig:
38
- "instanttensor" will load the Safetensors weights on CUDA devices using
39
InstantTensor, which enables distributed loading with pipelined prefetching
40
and fast direct I/O.
41
+ - "ipc_cache" will map post-quantized weights from a local weight cache
42
+ daemon via CUDA IPC for fast engine restarts. See
43
+ `vllm/model_executor/model_loader/weight_cache/daemon.py` for how to
44
+ launch the daemon.
45
- "npcache" will load the weights in pytorch format and store a numpy cache
46
to speed up the loading.
47
- "dummy" will initialize the weights with random values, which is mainly
vllm/model_executor/layers/fused_moe/unquantized_fused_moe_method.py
+27
-11
@@ -27,7 +27,11 @@ from vllm.model_executor.layers.fused_moe.oracle.unquantized import (
27
from vllm.model_executor.layers.fused_moe.runner.shared_experts import (
28
SharedExperts,
29
)
30
-from vllm.model_executor.utils import replace_parameter, set_weight_attrs
30
+from vllm.model_executor.utils import (
31
+ is_weights_pre_processed,
32
+ replace_parameter,
33
+ set_weight_attrs,
34
+)
35
from vllm.platforms import current_platform
36
37
if TYPE_CHECKING:
@@ -43,6 +47,8 @@ class UnquantizedFusedMoEMethod(FusedMoEMethodBase, CustomOp):
47
48
# --8<-- [end:unquantized_fused_moe]
49
50
+ supports_pre_processed_weights = True
51
+
52
def __init__(self, moe: FusedMoEConfig):
53
super().__init__(moe)
54
self.unquantized_backend, self.experts_cls = select_unquantized_moe_backend(
@@ -153,16 +159,7 @@ class UnquantizedFusedMoEMethod(FusedMoEMethodBase, CustomOp):
159
# optional w{13,2}_bias references and SwiGLU gate params. Since
160
# weight updates mutate those bias tensors in place, the kernel
161
# does not need to be re-built.
156
- self.moe_quant_config = self.get_fused_moe_quant_config(layer)
157
- assert self.moe_quant_config is not None
158
- assert self.experts_cls is not None
159
- self.moe_kernel = make_unquantized_moe_kernel(
160
- quant_config=self.moe_quant_config,
161
- moe_config=self.moe,
162
- backend=self.unquantized_backend,
163
- experts_cls=self.experts_cls,
164
- routing_tables=layer._expert_routing_tables(),
165
- )
162
+ self._init_moe_kernel(layer)
163
164
if self.unquantized_backend == UnquantizedMoeBackend.CPU:
165
# The CPU experts need the layer itself for the setup that
@@ -170,11 +167,30 @@ class UnquantizedFusedMoEMethod(FusedMoEMethodBase, CustomOp):
167
# it only sees the two weight tensors: padding and prepacking
168
# into the grouped-gemm layout (bias included), and capturing
169
# the router config that monolithic apply() cannot carry.
170
+ assert self.moe_kernel is not None
171
self.moe_kernel.fused_experts.process_weights_after_loading(layer)
172
173
+ def _init_moe_kernel(self, layer: "RoutedExperts") -> None:
174
+ """Build the MoE kernel from the layer's current (shuffled) weights."""
175
+ self.moe_quant_config = self.get_fused_moe_quant_config(layer)
176
+ assert self.moe_quant_config is not None
177
+ assert self.experts_cls is not None
178
+ self.moe_kernel = make_unquantized_moe_kernel(
179
+ quant_config=self.moe_quant_config,
180
+ moe_config=self.moe,
181
+ backend=self.unquantized_backend,
182
+ experts_cls=self.experts_cls,
183
+ routing_tables=layer._expert_routing_tables(),
184
+ )
185
+
186
def process_weights_after_loading(self, layer: "RoutedExperts") -> None:
187
super().process_weights_after_loading(layer)
188
189
+ if is_weights_pre_processed():
190
+ # Weights are already in runtime format; rebuild the kernel only.
191
+ self._init_moe_kernel(layer)
192
+ return
193
+
194
# Padding may allocate a same-shaped strided view. Copy it back instead
195
# of rebinding `.data`, since CUDA graphs capture the parameter storage
196
# address.
vllm/model_executor/layers/linear.py
+2
@@ -163,6 +163,8 @@ class LinearMethodBase(QuantizeMethodBase):
163
class UnquantizedLinearMethod(LinearMethodBase):
164
"""Linear method without quantization."""
165
166
+ supports_pre_processed_weights = True
167
+
168
def __init__(self) -> None:
169
config = get_current_vllm_config_or_none()
170
linear_backend = (
vllm/model_executor/layers/quantization/base_config.py
+7
@@ -27,6 +27,13 @@ class QuantizeMethodBase(ABC):
27
in process_weights_after_loading, reducing peak memory during loading.
28
"""
29
30
+ supports_pre_processed_weights: bool = False
31
+ """
32
+ Whether ``process_weights_after_loading`` supports running under
33
+ ``weights_already_processed``. Methods must skip tensor transforms in
34
+ that mode; the loader driver rejects methods that do not declare support.
35
+ """
36
+
37
@abstractmethod
38
def create_weights(
39
self, layer: torch.nn.Module, *weight_args, **extra_weight_attrs
vllm/model_executor/layers/quantization/fp8.py
+31
-1
@@ -74,7 +74,11 @@ from vllm.model_executor.parameter import (
74
BlockQuantScaleParameter,
75
PerTensorScaleParameter,
76
)
77
-from vllm.model_executor.utils import replace_parameter, set_weight_attrs
77
+from vllm.model_executor.utils import (
78
+ is_weights_pre_processed,
79
+ replace_parameter,
80
+ set_weight_attrs,
81
+)
82
from vllm.platforms import current_platform
83
from vllm.utils.deep_gemm import (
84
is_deep_gemm_supported,
@@ -248,6 +252,8 @@ class Fp8LinearMethod(LinearMethodBase):
252
quant_config: The quantization config.
253
"""
254
255
+ supports_pre_processed_weights = True
256
+
257
def __init__(self, quant_config: Fp8Config):
258
self.quant_config = quant_config
259
self.is_scale_e8m0 = getattr(quant_config, "is_scale_e8m0", False)
@@ -366,6 +372,19 @@ class Fp8LinearMethod(LinearMethodBase):
372
self.use_marlin = isinstance(self.fp8_linear, MarlinFP8ScaledMMLinearKernel)
373
374
def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
375
+ if is_weights_pre_processed():
376
+ # Weights are already in runtime format; the only state tensor
377
+ # export cannot carry is the `input_scale = None` stamp that dynamic
378
+ # activation quantization relies on, since create_weights never
379
+ # registered that parameter.
380
+ if not self.act_q_static and not hasattr(layer, "input_scale"):
381
+ layer.input_scale = None
382
+ # Marlin reads marlin_input_dtype in apply_weights; it lives on the
383
+ # method (not exported with the weights), so restore it here too.
384
+ if self.use_marlin and hasattr(self.fp8_linear, "marlin_input_dtype"):
385
+ self.fp8_linear.marlin_input_dtype = self.marlin_input_dtype
386
+ return
387
+
388
if self.use_marlin:
389
if not self.block_quant:
390
# Canonicalize to (K, N) for the kernel.
@@ -472,6 +491,8 @@ class Fp8MoEMethod(FusedMoEMethodBase):
491
quant_config: The quantization config.
492
"""
493
494
+ supports_pre_processed_weights = True
495
+
496
def __init__(self, quant_config: Fp8Config, layer: RoutedExperts):
497
super().__init__(layer.moe_config)
498
self.quant_config = quant_config
@@ -713,6 +734,10 @@ class Fp8MoEMethod(FusedMoEMethodBase):
734
replace_parameter(layer, f"w13_{self.weight_scale_name}", w13_scale)
735
replace_parameter(layer, f"w2_{self.weight_scale_name}", w2_scale)
736
737
+ self._init_moe_kernel(layer)
738
+
739
+ def _init_moe_kernel(self, layer: RoutedExperts) -> None:
740
+ """Build the MoE kernel from the layer's current (converted) weights."""
741
self.moe_quant_config = self.get_fused_moe_quant_config(layer)
742
assert self.moe_quant_config is not None
743
assert self.experts_cls is not None
@@ -725,6 +750,11 @@ class Fp8MoEMethod(FusedMoEMethodBase):
750
)
751
752
def process_weights_after_loading(self, layer: RoutedExperts) -> None:
753
+ if is_weights_pre_processed():
754
+ # Weights are already in kernel format; rebuild the kernel only.
755
+ self._init_moe_kernel(layer)
756
+ return
757
+
758
# Allow for accessing weights and scales in standard way.
759
w13 = layer.w13_weight
760
w2 = layer.w2_weight
vllm/model_executor/layers/quantization/kv_cache.py
+17
@@ -8,6 +8,7 @@ from vllm.model_executor.layers.quantization.base_config import (
8
QuantizationConfig,
9
QuantizeMethodBase,
10
)
11
+from vllm.model_executor.utils import is_weights_pre_processed
12
from vllm.platforms import current_platform
13
from vllm.utils.torch_utils import is_quantized_kv_cache
14
from vllm.v1.kv_cache_interface import kv_cache_uses_per_token_head_scales
@@ -51,6 +52,8 @@ class BaseKVCacheMethod(QuantizeMethodBase):
52
quant_config: the appropriate QuantizationConfig
53
"""
54
55
+ supports_pre_processed_weights = True
56
+
57
def __init__(self, quant_config: QuantizationConfig):
58
self.quant_config = quant_config
59
@@ -72,6 +75,20 @@ class BaseKVCacheMethod(QuantizeMethodBase):
75
raise RuntimeError(f"{self.__class__.__name__}.apply should not be called.")
76
77
def process_weights_after_loading(self, layer: torch.nn.Module) -> None:
78
+ if is_weights_pre_processed():
79
+ # Scale buffers were exported with post-processed values; drop the
80
+ # consumed scale placeholders and rebuild the host-side copies.
81
+ for name in ("k_scale", "v_scale", "q_scale", "prob_scale"):
82
+ if hasattr(layer, name):
83
+ delattr(layer, name)
84
+ if hasattr(layer, "_k_scale"):
85
+ layer._k_scale_float = layer._k_scale.item()
86
+ layer._v_scale_float = layer._v_scale.item()
87
+ layer._q_scale_float = layer._q_scale.item()
88
+ layer._k_scale_cpu = layer._k_scale.detach().to("cpu")
89
+ layer._v_scale_cpu = layer._v_scale.detach().to("cpu")
90
+ return
91
+
92
# skip if there are no weights to process (for example, weight reloading)
93
if not hasattr(layer, "q_scale"):
94
assert not hasattr(layer, "k_scale")
vllm/model_executor/layers/vocab_parallel_embedding.py
+2
@@ -36,6 +36,8 @@ DEFAULT_VOCAB_PADDING_SIZE = 64
36
class UnquantizedEmbeddingMethod(QuantizeMethodBase):
37
"""Unquantized method for embeddings."""
38
39
+ supports_pre_processed_weights = True
40
+
41
def create_weights(
42
self,
43
layer: torch.nn.Module,
vllm/model_executor/model_loader/__init__.py
+4
@@ -24,6 +24,7 @@ from vllm.model_executor.model_loader.utils import (
24
get_model_architecture,
25
get_model_cls,
26
)
27
+from vllm.model_executor.model_loader.weight_cache.ipc_loader import IpcModelLoader
28
29
logger = init_logger(__name__)
30
@@ -35,6 +36,7 @@ LoadFormats = Literal[
36
"dummy",
37
"fastsafetensors",
38
"instanttensor",
39
+ "ipc_cache",
40
"mistral",
41
"modelexpress",
42
"npcache",
@@ -51,6 +53,7 @@ _LOAD_FORMAT_TO_MODEL_LOADER: dict[str, type[BaseModelLoader]] = {
53
"dummy": DummyModelLoader,
54
"fastsafetensors": DefaultModelLoader,
55
"instanttensor": DefaultModelLoader,
56
+ "ipc_cache": IpcModelLoader,
57
"mistral": DefaultModelLoader,
58
"modelexpress": ModelExpressModelLoader,
59
"npcache": DefaultModelLoader,
@@ -150,6 +153,7 @@ __all__ = [
153
"ModelExpressModelLoader",
154
"DefaultModelLoader",
155
"DummyModelLoader",
156
+ "IpcModelLoader",
157
"RunaiModelStreamerLoader",
158
"ShardedStateLoader",
159
"TensorizerLoader",
vllm/model_executor/model_loader/utils.py
+16
-1
@@ -26,6 +26,7 @@ from vllm.model_executor.model_loader.reload import (
26
)
27
from vllm.model_executor.model_loader.weight_tying import maybe_retie_word_embeddings
28
from vllm.model_executor.models.interfaces import SupportsQuant
29
+from vllm.model_executor.utils import is_weights_pre_processed
30
from vllm.tracing import instrument
31
from vllm.utils.mem_utils import release_device_memory_under_pressure
32
from vllm.utils.platform_utils import is_pin_memory_available
@@ -97,13 +98,27 @@ def initialize_model(
98
def process_weights_after_loading(
99
model: nn.Module, model_config: ModelConfig, target_device: torch.device
100
) -> None:
101
+ """Post-process loaded weights into runtime format.
102
+
103
+ Under ``weights_already_processed`` (weight cache IPC loader), quant
104
+ methods skip tensor transforms and must declare
105
+ ``supports_pre_processed_weights``, otherwise this raises ``RuntimeError``.
106
+ """
107
# Reclaim memory when an explicit lm_head has been
108
# loaded, but it is identical to the input embeddings.
109
maybe_retie_word_embeddings(model, model_config)
110
104
- for _, module in model.named_modules():
111
+ for name, module in model.named_modules():
112
quant_method = getattr(module, "quant_method", None)
113
if isinstance(quant_method, QuantizeMethodBase):
114
+ if (
115
+ is_weights_pre_processed()
116
+ and not quant_method.supports_pre_processed_weights
117
+ ):
118
+ raise RuntimeError(
119
+ f"layer {name or '<root>'}: {type(quant_method).__name__} "
120
+ "does not support pre-processed weights"
121
+ )
122
# When quant methods need to process weights after loading
123
# (for repacking, quantizing, etc), they expect parameters
124
# to be on the global target device. This scope is for the
vllm/model_executor/model_loader/weight_cache/__init__.py
new
+24
@@ -0,0 +1,24 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+
4
+from vllm.model_executor.model_loader.weight_cache.ipc_loader import IpcModelLoader
5
+from vllm.model_executor.model_loader.weight_cache.protocol import (
6
+ CacheConfigMismatchError,
7
+ TensorEntry,
8
+ UnsupportedQuantForIPCError,
9
+ WeightCacheKey,
10
+ WeightCacheUnavailableError,
11
+ check_ipc_quant_support,
12
+ is_ipc_quant_supported,
13
+)
14
+
15
+__all__ = [
16
+ "CacheConfigMismatchError",
17
+ "IpcModelLoader",
18
+ "TensorEntry",
19
+ "UnsupportedQuantForIPCError",
20
+ "WeightCacheKey",
21
+ "WeightCacheUnavailableError",
22
+ "check_ipc_quant_support",
23
+ "is_ipc_quant_supported",
24
+]
vllm/model_executor/model_loader/weight_cache/daemon.py
new
+412
@@ -0,0 +1,412 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""Weight cache daemon for fast engine restarts.
4
+
5
+One daemon process per GPU holds the post-quantized, TP-sharded weights of its
6
+rank in GPU memory and serves CUDA IPC handles to vLLM engines over a Unix
7
+domain socket. Restarting engines map the weights via zero-copy IPC instead of
8
+reloading from disk.
9
+
10
+Launch one daemon per TP rank with a single command:
11
+
12
+ python -m vllm.model_executor.model_loader.weight_cache.daemon \\
13
+ --model /path/to/model --tensor-parallel-size 4
14
+
15
+Engines then load from the daemons with:
16
+
17
+ vllm serve /path/to/model --tensor-parallel-size 4 \\
18
+ --load-format ipc_cache
19
+
20
+Only tensor parallelism is supported; pipeline, data, and expert parallelism
21
+are rejected at launch.
22
+"""
23
+
24
+import contextlib
25
+import fcntl
26
+import multiprocessing
27
+import os
28
+import queue
29
+import signal
30
+import socket
31
+import sys
32
+from collections.abc import Callable
33
+
34
+import torch
35
+
36
+from vllm.config import ParallelConfig, VllmConfig, set_current_vllm_config
37
+from vllm.distributed import (
38
+ ensure_model_parallel_initialized,
39
+ init_distributed_environment,
40
+)
41
+from vllm.logger import init_logger
42
+from vllm.model_executor.model_loader.weight_cache.protocol import (
43
+ TensorEntry,
44
+ WeightCacheKey,
45
+ WeightCacheUnavailableError,
46
+ check_ipc_quant_support,
47
+ ensure_private_socket_dir,
48
+ get_physical_device_id,
49
+ get_socket_path,
50
+ recv_msg,
51
+ send_msg,
52
+ verify_peer_is_owner,
53
+)
54
+from vllm.platforms import current_platform
55
+
56
+logger = init_logger(__name__)
57
+
58
+
59
+def _report_ready(message: str) -> None:
60
+ """Write a readiness message straight to the original stderr descriptor.
61
+
62
+ Loading the model pulls in FlashInfer's CuTeDSL JIT compiler, which swaps
63
+ ``sys.stdout``/``sys.stderr`` for in-memory buffers while compiling kernels
64
+ across worker threads. That save/restore races on the interpreter-global
65
+ streams and can leave them (and vLLM's logging handler, which caches the
66
+ original stream object) detached, silently swallowing everything logged
67
+ afterwards -- including the daemon's readiness announcement. Since operators
68
+ rely on that line to know the daemon is serving, write it directly to file
69
+ descriptor 2 so it bypasses the logging machinery entirely.
70
+ """
71
+ with contextlib.suppress(OSError):
72
+ os.write(2, (message + "\n").encode())
73
+
74
+
75
+def export_entries(
76
+ model: torch.nn.Module,
77
+) -> tuple[dict[str, TensorEntry], dict[str, str]]:
78
+ """Export a model's tensors, preserving tied-parameter aliases.
79
+
80
+ ``named_parameters``/``named_buffers`` are iterated with
81
+ ``remove_duplicate=False`` so tied weights (e.g. ``lm_head.weight`` sharing
82
+ storage with ``embed_tokens.weight``) are not silently dropped. Each unique
83
+ tensor is exported once; every additional name that refers to the same
84
+ tensor object is recorded in the returned alias map so the client can
85
+ re-establish the shared identity instead of allocating uninitialized
86
+ memory for it.
87
+
88
+ Returns:
89
+ A ``(entries, aliases)`` pair where ``entries`` maps a canonical name to
90
+ its ``TensorEntry`` and ``aliases`` maps each duplicate name to its
91
+ canonical name.
92
+ """
93
+ entries: dict[str, TensorEntry] = {}
94
+ aliases: dict[str, str] = {}
95
+ canonical_by_id: dict[int, str] = {}
96
+
97
+ def _add(name: str, tensor: torch.Tensor, kind: str) -> None:
98
+ canonical = canonical_by_id.get(id(tensor))
99
+ if canonical is not None:
100
+ aliases[name] = canonical
101
+ return
102
+ canonical_by_id[id(tensor)] = name
103
+ entries[name] = TensorEntry.from_tensor(tensor, kind)
104
+
105
+ for name, param in model.named_parameters(remove_duplicate=False):
106
+ _add(name, param, "param")
107
+ # named_buffers includes non-persistent buffers (e.g. rotary embedding
108
+ # caches) that state_dict would miss.
109
+ for name, buffer in model.named_buffers(remove_duplicate=False):
110
+ if name in entries or name in aliases:
111
+ continue
112
+ _add(name, buffer, "buffer")
113
+ return entries, aliases
114
+
115
+
116
+class WeightCacheDaemon:
117
+ """Per-GPU process that loads one TP shard and serves CUDA IPC handles."""
118
+
119
+ def __init__(
120
+ self,
121
+ vllm_config: VllmConfig,
122
+ tp_rank: int,
123
+ distributed_init_method: str,
124
+ socket_dir: str | None = None,
125
+ ):
126
+ self.vllm_config = vllm_config
127
+ self.tp_rank = tp_rank
128
+ self.distributed_init_method = distributed_init_method
129
+ self.socket_dir = socket_dir
130
+ self.model: torch.nn.Module | None = None
131
+ self.entries: dict[str, TensorEntry] = {}
132
+ self.aliases: dict[str, str] = {}
133
+ # Fingerprint before loading: process_weights_after_loading may
134
+ # mutate hf_config.quantization_config.
135
+ self.cache_config = WeightCacheKey.from_model_config(
136
+ vllm_config.model_config,
137
+ tp_size=vllm_config.parallel_config.tensor_parallel_size,
138
+ tp_rank=tp_rank,
139
+ )
140
+
141
+ def load_model(self) -> None:
142
+ from vllm.model_executor.model_loader import get_model
143
+
144
+ tp_size = self.cache_config.tp_size
145
+ torch.accelerator.set_device_index(self.tp_rank)
146
+ init_distributed_environment(
147
+ world_size=tp_size,
148
+ rank=self.tp_rank,
149
+ distributed_init_method=self.distributed_init_method,
150
+ local_rank=self.tp_rank,
151
+ backend=current_platform.dist_backend,
152
+ )
153
+ with set_current_vllm_config(self.vllm_config):
154
+ ensure_model_parallel_initialized(tp_size, 1)
155
+ self.model = get_model(vllm_config=self.vllm_config)
156
+ self._export_entries()
157
+ logger.info(
158
+ "Weight cache daemon rank %d cached %d tensors",
159
+ self.tp_rank,
160
+ len(self.entries),
161
+ )
162
+
163
+ def _export_entries(self) -> None:
164
+ assert self.model is not None
165
+ self.entries, self.aliases = export_entries(self.model)
166
+
167
+ def serve_forever(self, ready_callback: Callable[[], None] | None = None) -> None:
168
+ """Serve requests until terminated.
169
+
170
+ The socket is only bound once the model is fully cached, so clients
171
+ get a connection error (and fall back to disk) until the daemon is
172
+ ready.
173
+
174
+ Args:
175
+ ready_callback: Invoked once the socket is bound and listening,
176
+ so the launcher can report overall readiness.
177
+ """
178
+ socket_path = self._socket_path()
179
+ ensure_private_socket_dir(
180
+ os.path.dirname(socket_path), strict_perms=self.socket_dir is None
181
+ )
182
+ # Hold an exclusive per-GPU lock for the daemon's lifetime so a second
183
+ # daemon cannot remove this daemon's live socket and hijack the path.
184
+ lock_fd = self._acquire_gpu_lock(socket_path)
185
+ if os.path.exists(socket_path):
186
+ os.unlink(socket_path)
187
+ server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
188
+ server.bind(socket_path)
189
+ os.chmod(socket_path, 0o600)
190
+ server.listen()
191
+ logger.info(
192
+ "Weight cache daemon rank %d serving on %s", self.tp_rank, socket_path
193
+ )
194
+ print(
195
+ f"Weight cache daemon rank {self.tp_rank} ready: serving on {socket_path}"
196
+ )
197
+ if ready_callback is not None:
198
+ ready_callback()
199
+ try:
200
+ while True:
201
+ conn, _ = server.accept()
202
+ with conn:
203
+ try:
204
+ verify_peer_is_owner(conn)
205
+ self._handle_connection(conn)
206
+ except (ConnectionError, EOFError):
207
+ logger.warning("Client disconnected mid-request")
208
+ except Exception:
209
+ # A single malformed or malicious request must not take
210
+ # down the daemon for every other engine on this GPU.
211
+ logger.exception(
212
+ "Error handling weight cache client; continuing"
213
+ )
214
+ finally:
215
+ server.close()
216
+ if os.path.exists(socket_path):
217
+ os.unlink(socket_path)
218
+ os.close(lock_fd)
219
+
220
+ def _acquire_gpu_lock(self, socket_path: str) -> int:
221
+ """Take an exclusive lock guarding this GPU's socket path.
222
+
223
+ The lock is advisory and released automatically when the daemon exits
224
+ (or crashes), so a stale socket is only ever removed by whoever owns
225
+ the lock. A running daemon holding it makes a second daemon fail fast
226
+ instead of clobbering the live socket.
227
+ """
228
+ lock_fd = os.open(f"{socket_path}.lock", os.O_CREAT | os.O_RDWR, 0o600)
229
+ try:
230
+ fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
231
+ except OSError as e:
232
+ os.close(lock_fd)
233
+ raise WeightCacheUnavailableError(
234
+ f"Another weight cache daemon already owns {socket_path}"
235
+ ) from e
236
+ return lock_fd
237
+
238
+ def _socket_path(self) -> str:
239
+ device_index = torch.accelerator.current_device_index()
240
+ gpu_id = get_physical_device_id(device_index)
241
+ if gpu_id is None:
242
+ gpu_id = device_index
243
+ return get_socket_path(gpu_id, self.socket_dir)
244
+
245
+ def _handle_connection(self, conn: socket.socket) -> None:
246
+ request = recv_msg(conn)
247
+ cmd = request.get("cmd")
248
+ if cmd == "get_state":
249
+ self._handle_get_state(conn, request)
250
+ elif cmd == "release":
251
+ self._handle_release(conn)
252
+ else:
253
+ send_msg(conn, {"status": "error", "message": f"Unknown command {cmd!r}"})
254
+
255
+ def _handle_get_state(self, conn: socket.socket, request: dict) -> None:
256
+ client_config = request.get("cache_config")
257
+ if not isinstance(client_config, WeightCacheKey):
258
+ send_msg(conn, {"status": "error", "message": "Missing cache_config"})
259
+ return
260
+ mismatched = self.cache_config.mismatched_fields(client_config)
261
+ if mismatched:
262
+ logger.warning("WeightCacheKey mismatch on fields: %s", mismatched)
263
+ send_msg(conn, {"status": "mismatch", "fields": mismatched})
264
+ return
265
+ if not self.entries:
266
+ send_msg(conn, {"status": "error", "message": "Weights were released"})
267
+ return
268
+ send_msg(
269
+ conn,
270
+ {
271
+ "status": "ok",
272
+ "entries": self.entries,
273
+ "aliases": self.aliases,
274
+ "gpu_uuid": self._gpu_uuid(),
275
+ },
276
+ )
277
+
278
+ def _handle_release(self, conn: socket.socket) -> None:
279
+ self.entries.clear()
280
+ self.aliases.clear()
281
+ self.model = None
282
+ torch.accelerator.empty_cache()
283
+ logger.info("Weight cache daemon rank %d released cached weights", self.tp_rank)
284
+ send_msg(conn, {"status": "ok"})
285
+
286
+ def _gpu_uuid(self) -> str:
287
+ props = torch.cuda.get_device_properties(
288
+ torch.accelerator.current_device_index()
289
+ )
290
+ return str(props.uuid)
291
+
292
+
293
+def _run_daemon(
294
+ tp_rank: int,
295
+ vllm_config: VllmConfig,
296
+ distributed_init_method: str,
297
+ socket_dir: str | None,
298
+ ready_queue: "multiprocessing.Queue[int]",
299
+) -> None:
300
+ daemon = WeightCacheDaemon(
301
+ vllm_config, tp_rank, distributed_init_method, socket_dir
302
+ )
303
+ daemon.load_model()
304
+ daemon.serve_forever(ready_callback=lambda: ready_queue.put(tp_rank))
305
+
306
+
307
+def _reject_unsupported_parallelism(parallel_config: ParallelConfig) -> None:
308
+ """Reject every parallelism mode other than tensor parallelism."""
309
+ unsupported = {
310
+ "pipeline parallelism": parallel_config.pipeline_parallel_size > 1,
311
+ "data parallelism": parallel_config.data_parallel_size > 1,
312
+ "expert parallelism": parallel_config.enable_expert_parallel,
313
+ }
314
+ for name, enabled in unsupported.items():
315
+ if enabled:
316
+ raise ValueError(
317
+ f"The weight cache daemon only supports tensor parallelism; "
318
+ f"{name} is not supported"
319
+ )
320
+
321
+
322
+def main() -> None:
323
+ from vllm.engine.arg_utils import EngineArgs
324
+ from vllm.utils.argparse_utils import FlexibleArgumentParser
325
+ from vllm.utils.network_utils import get_distributed_init_method, get_open_port
326
+
327
+ parser = FlexibleArgumentParser(
328
+ description="Launch weight cache daemons (one per TP rank)."
329
+ )
330
+ EngineArgs.add_cli_args(parser)
331
+ parser.add_argument(
332
+ "--weight-cache-socket-dir",
333
+ type=str,
334
+ default=None,
335
+ help="Directory for the daemon Unix sockets (default: tempdir).",
336
+ )
337
+ args = parser.parse_args()
338
+ engine_args = EngineArgs.from_cli_args(args)
339
+ vllm_config = engine_args.create_engine_config()
340
+ if vllm_config.load_config.load_format == "ipc_cache":
341
+ raise ValueError(
342
+ "The weight cache daemon itself must load from disk; use the "
343
+ "default --load-format"
344
+ )
345
+ # Checked before loading anything: an unsupported quantization method would
346
+ # otherwise only surface in the engine, after a full load.
347
+ check_ipc_quant_support(vllm_config.model_config, where="daemon")
348
+ parallel_config = vllm_config.parallel_config
349
+ _reject_unsupported_parallelism(parallel_config)
350
+ tp_size = parallel_config.tensor_parallel_size
351
+
352
+ distributed_init_method = get_distributed_init_method("127.0.0.1", get_open_port())
353
+ ctx = multiprocessing.get_context("spawn")
354
+ ready_queue: multiprocessing.Queue[int] = ctx.Queue()
355
+ procs = [
356
+ ctx.Process(
357
+ target=_run_daemon,
358
+ args=(
359
+ rank,
360
+ vllm_config,
361
+ distributed_init_method,
362
+ args.weight_cache_socket_dir,
363
+ ready_queue,
364
+ ),
365
+ name=f"vllm-weight-cache-daemon-{rank}",
366
+ )
367
+ for rank in range(tp_size)
368
+ ]
369
+ for proc in procs:
370
+ proc.start()
371
+
372
+ def _shutdown(signum, frame):
373
+ for proc in procs:
374
+ proc.terminate()
375
+
376
+ signal.signal(signal.SIGINT, _shutdown)
377
+ signal.signal(signal.SIGTERM, _shutdown)
378
+
379
+ ready_ranks: set[int] = set()
380
+ while len(ready_ranks) < tp_size:
381
+ try:
382
+ ready_ranks.add(ready_queue.get(timeout=1.0))
383
+ except queue.Empty:
384
+ dead = [p for p in procs if p.exitcode is not None]
385
+ if dead:
386
+ logger.error(
387
+ "Weight cache daemon rank(s) exited during startup "
388
+ "(exitcodes=%s); shutting down.",
389
+ [p.exitcode for p in dead],
390
+ )
391
+ for proc in procs:
392
+ proc.terminate()
393
+ for proc in procs:
394
+ proc.join()
395
+ sys.exit(max((p.exitcode or 0) for p in procs))
396
+ logger.info_once(
397
+ "===== Weight cache daemon READY: all %d ranks serving in %s =====",
398
+ tp_size,
399
+ args.weight_cache_socket_dir or "the default socket dir",
400
+ )
401
+ _report_ready(
402
+ f"===== Weight cache daemon READY: all {tp_size} rank(s) serving in "
403
+ f"{args.weight_cache_socket_dir or 'the default socket dir'} ====="
404
+ )
405
+
406
+ for proc in procs:
407
+ proc.join()
408
+ sys.exit(max(proc.exitcode or 0 for proc in procs))
409
+
410
+
411
+if __name__ == "__main__":
412
+ main()
vllm/model_executor/model_loader/weight_cache/ipc_loader.py
new
+394
@@ -0,0 +1,394 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""IPC model loader: maps post-quantized weights from a local weight cache
4
+daemon via CUDA IPC instead of loading from disk."""
5
+
6
+import dataclasses
7
+import socket
8
+from copy import copy
9
+
10
+import torch
11
+import torch.nn as nn
12
+
13
+from vllm.config import ModelConfig, VllmConfig
14
+from vllm.config.load import LoadConfig
15
+from vllm.logger import init_logger
16
+from vllm.model_executor.model_loader.base_loader import BaseModelLoader
17
+from vllm.model_executor.model_loader.default_loader import DefaultModelLoader
18
+from vllm.model_executor.model_loader.utils import (
19
+ initialize_model,
20
+ process_weights_after_loading,
21
+)
22
+from vllm.model_executor.model_loader.weight_cache.protocol import (
23
+ CacheConfigMismatchError,
24
+ TensorEntry,
25
+ UnsupportedQuantForIPCError,
26
+ WeightCacheKey,
27
+ WeightCacheUnavailableError,
28
+ check_ipc_quant_support,
29
+ get_physical_device_id,
30
+ get_socket_path,
31
+ recv_msg,
32
+ send_msg,
33
+ verify_socket_owner,
34
+)
35
+from vllm.model_executor.utils import weights_already_processed
36
+from vllm.tracing import instrument
37
+from vllm.utils.torch_utils import set_default_torch_dtype
38
+
39
+logger = init_logger(__name__)
40
+
41
+_CONNECT_TIMEOUT_S = 5.0
42
+_STATE_TIMEOUT_S = 300.0
43
+
44
+
45
+class IpcModelLoader(BaseModelLoader):
46
+ """Loads a model by mapping the weight cache daemon's tensors via CUDA IPC.
47
+
48
+ The model is initialized on the meta device and every parameter/buffer is
49
+ replaced by the daemon's post-quantized tensor, so
50
+ process_weights_after_loading is skipped entirely. In "zero_copy" mode the
51
+ engine shares the daemon's GPU memory; in "copy" mode the tensors are
52
+ cloned into engine-owned memory and the daemon is asked to release its
53
+ cache afterwards.
54
+
55
+ Extra config keys (via --model-loader-extra-config):
56
+
57
+ - socket_path: explicit daemon socket path. Defaults to a per-GPU path
58
+ derived from the physical GPU id.
59
+ - socket_dir: directory containing the daemon sockets.
60
+ - mode: "zero_copy" (default) or "copy".
61
+ - fallback: fall back to disk loading when the daemon is unavailable or
62
+ the fingerprints mismatch (default: True).
63
+ - connect_timeout_s: socket connect timeout (default: 5.0).
64
+ - state_timeout_s: timeout for the weight-transfer request (default: 300.0).
65
+
66
+ Note: in zero-copy mode the weights live in the daemon's CUDA IPC
67
+ allocations, so sleep mode (CuMemAllocator weight offloading) must not be
68
+ used with this loader.
69
+ """
70
+
71
+ def __init__(self, load_config: LoadConfig):
72
+ super().__init__(load_config)
73
+ extra_config = copy(load_config.model_loader_extra_config or {})
74
+ self.socket_path: str | None = extra_config.pop("socket_path", None)
75
+ self.socket_dir: str | None = extra_config.pop("socket_dir", None)
76
+ self.mode: str = extra_config.pop("mode", "zero_copy")
77
+ self.fallback: bool = extra_config.pop("fallback", True)
78
+ self.connect_timeout_s: float = float(
79
+ extra_config.pop("connect_timeout_s", _CONNECT_TIMEOUT_S)
80
+ )
81
+ self.state_timeout_s: float = float(
82
+ extra_config.pop("state_timeout_s", _STATE_TIMEOUT_S)
83
+ )
84
+ if self.mode not in ("zero_copy", "copy"):
85
+ raise ValueError(
86
+ f"Invalid weight cache mode {self.mode!r}, "
87
+ "expected 'zero_copy' or 'copy'"
88
+ )
89
+ if extra_config:
90
+ raise ValueError(
91
+ f"Unexpected extra config keys for load format "
92
+ f"{load_config.load_format}: {sorted(extra_config)}"
93
+ )
94
+
95
+ def download_model(self, model_config: ModelConfig) -> None:
96
+ DefaultModelLoader(self._fallback_load_config()).download_model(model_config)
97
+
98
+ def load_weights(self, model: nn.Module, model_config: ModelConfig) -> None:
99
+ """Best-effort in-place reload for an already-initialized model.
100
+
101
+ Copies daemon tensors into matching parameters/buffers. The model is
102
+ expected to already be in the post-quantized layout (e.g. previously
103
+ loaded through this loader).
104
+ """
105
+ device_index = torch.accelerator.current_device_index()
106
+ entries, _ = self._fetch_entries(model_config)
107
+ params = dict(model.named_parameters())
108
+ buffers = dict(model.named_buffers())
109
+ for name, entry in entries.items():
110
+ target = params.get(name, buffers.get(name))
111
+ source = entry.rebuild(device_index)
112
+ if target is None or target.shape != source.shape:
113
+ logger.warning("Skipping mismatched cached tensor %s", name)
114
+ continue
115
+ target.data.copy_(source)
116
+
117
+ @instrument(span_name="Load model")
118
+ def load_model(
119
+ self, vllm_config: VllmConfig, model_config: ModelConfig, prefix: str = ""
120
+ ) -> nn.Module:
121
+ # Unsupported quantization is a permanent misconfiguration rather than a
122
+ # transient daemon outage, so it is raised even when fallback is on.
123
+ self._check_supported(vllm_config, model_config)
124
+ state_fetched = False
125
+ try:
126
+ entries, aliases = self._fetch_entries(model_config)
127
+ state_fetched = True
128
+ return self._build_model(
129
+ vllm_config, model_config, prefix, entries, aliases
130
+ )
131
+ except (WeightCacheUnavailableError, CacheConfigMismatchError) as e:
132
+ if not self.fallback:
133
+ raise
134
+ logger.warning(
135
+ "Weight cache unusable (%s); falling back to disk loading", e
136
+ )
137
+ except Exception:
138
+ if not self.fallback:
139
+ raise
140
+ logger.exception(
141
+ "Weight cache IPC loading failed; falling back to disk loading"
142
+ )
143
+ # _build_model failed after fetching state without reaching its
144
+ # copy-mode release, so the daemon still holds the full cache;
145
+ # release it so the disk fallback does not OOM against it.
146
+ if state_fetched and self.mode == "copy":
147
+ self._send_release()
148
+ torch.accelerator.empty_cache()
149
+ return self._fallback_load(vllm_config, model_config, prefix)
150
+
151
+ def _build_model(
152
+ self,
153
+ vllm_config: VllmConfig,
154
+ model_config: ModelConfig,
155
+ prefix: str,
156
+ entries: dict[str, TensorEntry],
157
+ aliases: dict[str, str],
158
+ ) -> nn.Module:
159
+ device_config = vllm_config.device_config
160
+ load_device = (
161
+ device_config.device
162
+ if self.load_config.device is None
163
+ else self.load_config.device
164
+ )
165
+ target_device = torch.device(load_device)
166
+ device_index = (
167
+ target_device.index
168
+ if target_device.index is not None
169
+ else torch.accelerator.current_device_index()
170
+ )
171
+ with set_default_torch_dtype(model_config.dtype):
172
+ with torch.device("meta"):
173
+ model = initialize_model(
174
+ vllm_config=vllm_config,
175
+ model_config=model_config,
176
+ prefix=prefix,
177
+ )
178
+ self._apply_entries(model, entries, aliases, device_index)
179
+ # The daemon exports tensors that already went through
180
+ # process_weights_after_loading; re-run it in pre-processed mode
181
+ # so quant methods only rebuild Python-side state (e.g. the MoE
182
+ # kernel). Leftovers are materialized afterwards so that
183
+ # placeholders the daemon-side post-processing consumed are
184
+ # dropped rather than filled with uninitialized memory.
185
+ with weights_already_processed():
186
+ process_weights_after_loading(model, model_config, target_device)
187
+ _materialize_remaining_meta_tensors(
188
+ model, torch.device(target_device.type, device_index)
189
+ )
190
+ if self.mode == "copy":
191
+ self._send_release()
192
+ logger.info(
193
+ "Mapped %d tensors from the weight cache daemon (%s mode)",
194
+ len(entries),
195
+ self.mode,
196
+ )
197
+ return model.eval()
198
+
199
+ @staticmethod
200
+ def _check_supported(vllm_config: VllmConfig, model_config: ModelConfig) -> None:
201
+ check_ipc_quant_support(model_config, where="engine")
202
+ cache_dtype = vllm_config.cache_config.cache_dtype
203
+ if cache_dtype != "auto" and not str(cache_dtype).startswith("fp8"):
204
+ # BaseKVCacheMethod.process_weights_after_loading turns the loaded
205
+ # k/v scale parameters into plain float attributes. For fp8 cache
206
+ # dtypes those are rebuilt from the exported scale buffers when
207
+ # process_weights_after_loading runs in pre-processed mode; other
208
+ # quantized cache dtypes are not verified.
209
+ raise UnsupportedQuantForIPCError(
210
+ f"[weight_cache:engine] kv cache dtype {cache_dtype!r} is not "
211
+ "supported by the weight cache; use --kv-cache-dtype auto."
212
+ )
213
+
214
+ def _apply_entries(
215
+ self,
216
+ model: nn.Module,
217
+ entries: dict[str, TensorEntry],
218
+ aliases: dict[str, str],
219
+ device_index: int,
220
+ ) -> None:
221
+ # remove_duplicate=False keeps tied module aliases reachable by name:
222
+ # a tied lm_head *is* the embedding module, so the deduplicated view
223
+ # would not contain "lm_head" at all.
224
+ modules = dict(model.named_modules(remove_duplicate=False))
225
+ registered: dict[str, torch.Tensor] = {}
226
+
227
+ def _register(name: str, tensor: torch.Tensor, is_param: bool) -> None:
228
+ module_name, _, leaf = name.rpartition(".")
229
+ module = modules.get(module_name)
230
+ if module is None:
231
+ raise RuntimeError(f"Cached tensor {name} has no matching module")
232
+ # Replace via registration rather than param.data assignment,
233
+ # which fails for meta tensors. Entries may also introduce
234
+ # post-quantization tensors absent from the meta model.
235
+ module._parameters.pop(leaf, None)
236
+ module._buffers.pop(leaf, None)
237
+ if is_param:
238
+ obj: torch.Tensor = (
239
+ tensor
240
+ if isinstance(tensor, nn.Parameter)
241
+ else nn.Parameter(tensor, requires_grad=False)
242
+ )
243
+ module.register_parameter(leaf, obj)
244
+ else:
245
+ obj = tensor
246
+ module.register_buffer(leaf, obj)
247
+ registered[name] = obj
248
+
249
+ for name, entry in entries.items():
250
+ tensor = entry.rebuild(device_index)
251
+ if self.mode == "copy":
252
+ tensor = tensor.clone()
253
+ _register(name, tensor, entry.kind == "param")
254
+
255
+ # Re-establish tied-weight aliases by registering the *same* object the
256
+ # canonical name resolved to, so parameter identity (and the tie) is
257
+ # preserved instead of allocating uninitialized memory.
258
+ for alias_name, canonical_name in aliases.items():
259
+ obj = registered.get(canonical_name)
260
+ if obj is None:
261
+ logger.warning(
262
+ "Cached alias %s references missing canonical tensor %s",
263
+ alias_name,
264
+ canonical_name,
265
+ )
266
+ continue
267
+ _register(alias_name, obj, isinstance(obj, nn.Parameter))
268
+
269
+ def _fetch_entries(
270
+ self, model_config: ModelConfig
271
+ ) -> tuple[dict[str, TensorEntry], dict[str, str]]:
272
+ from vllm.distributed import (
273
+ get_tensor_model_parallel_rank,
274
+ get_tensor_model_parallel_world_size,
275
+ )
276
+
277
+ cache_config = WeightCacheKey.from_model_config(
278
+ model_config,
279
+ tp_size=get_tensor_model_parallel_world_size(),
280
+ tp_rank=get_tensor_model_parallel_rank(),
281
+ )
282
+ return self._request_state(cache_config)
283
+
284
+ def _request_state(
285
+ self, cache_config: WeightCacheKey
286
+ ) -> tuple[dict[str, TensorEntry], dict[str, str]]:
287
+ with self._connect(self.state_timeout_s) as conn:
288
+ send_msg(conn, {"cmd": "get_state", "cache_config": cache_config})
289
+ response = recv_msg(conn)
290
+ status = response.get("status")
291
+ if status == "mismatch":
292
+ raise CacheConfigMismatchError(
293
+ f"WeightCacheKey mismatch on fields: {response.get('fields')}"
294
+ )
295
+ if status != "ok":
296
+ raise WeightCacheUnavailableError(
297
+ f"Weight cache daemon error: {response.get('message')}"
298
+ )
299
+ self._check_gpu_uuid(response.get("gpu_uuid"))
300
+ return response["entries"], response.get("aliases", {})
301
+
302
+ def _connect(self, timeout: float) -> socket.socket:
303
+ socket_path = self._resolve_socket_path()
304
+ # The auto-derived per-user directory is locked to 0700 and checked
305
+ # strictly. When the operator explicitly configures a path they own the
306
+ # trust decision, so only ownership/symlink safety is enforced.
307
+ strict_perms = self.socket_path is None and self.socket_dir is None
308
+ try:
309
+ verify_socket_owner(socket_path, strict_perms=strict_perms)
310
+ except OSError as e:
311
+ raise WeightCacheUnavailableError(
312
+ f"Weight cache socket {socket_path} is unavailable: {e}"
313
+ ) from e
314
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
315
+ sock.settimeout(timeout)
316
+ try:
317
+ sock.connect(socket_path)
318
+ except OSError as e:
319
+ sock.close()
320
+ raise WeightCacheUnavailableError(
321
+ f"Cannot connect to weight cache daemon at {socket_path}: {e}"
322
+ ) from e
323
+ return sock
324
+
325
+ def _resolve_socket_path(self) -> str:
326
+ if self.socket_path is not None:
327
+ return self.socket_path
328
+ device_index = torch.accelerator.current_device_index()
329
+ gpu_id = get_physical_device_id(device_index)
330
+ if gpu_id is None:
331
+ raise WeightCacheUnavailableError(
332
+ "Cannot infer the physical GPU id from CUDA_VISIBLE_DEVICES; "
333
+ "pass socket_path via --model-loader-extra-config"
334
+ )
335
+ return get_socket_path(gpu_id, self.socket_dir)
336
+
337
+ def _check_gpu_uuid(self, daemon_uuid: str | None) -> None:
338
+ if daemon_uuid is None:
339
+ return
340
+ props = torch.cuda.get_device_properties(
341
+ torch.accelerator.current_device_index()
342
+ )
343
+ local_uuid = str(props.uuid)
344
+ if daemon_uuid != local_uuid:
345
+ raise CacheConfigMismatchError(
346
+ f"Daemon GPU {daemon_uuid} != engine GPU {local_uuid}; "
347
+ "check the socket path / GPU mapping"
348
+ )
349
+
350
+ def _send_release(self) -> None:
351
+ try:
352
+ with self._connect(self.connect_timeout_s) as conn:
353
+ send_msg(conn, {"cmd": "release"})
354
+ recv_msg(conn)
355
+ except (WeightCacheUnavailableError, ConnectionError, OSError):
356
+ logger.warning("Failed to ask the weight cache daemon to release")
357
+
358
+ def _fallback_load_config(self) -> LoadConfig:
359
+ # DefaultModelLoader must not see load_format="ipc_cache" or the ipc
360
+ # extra config keys.
361
+ return dataclasses.replace(
362
+ self.load_config, load_format="auto", model_loader_extra_config={}
363
+ )
364
+
365
+ def _fallback_load(
366
+ self, vllm_config: VllmConfig, model_config: ModelConfig, prefix: str
367
+ ) -> nn.Module:
368
+ loader = DefaultModelLoader(self._fallback_load_config())
369
+ return loader.load_model(
370
+ vllm_config=vllm_config, model_config=model_config, prefix=prefix
371
+ )
372
+
373
+
374
+def _materialize_remaining_meta_tensors(model: nn.Module, device: torch.device) -> None:
375
+ """Allocate any tensors the daemon did not provide.
376
+
377
+ These are typically parameters removed on the daemon side by
378
+ process_weights_after_loading; they are not expected to be read at
379
+ runtime, so they are left uninitialized.
380
+ """
381
+ for module_name, module in model.named_modules():
382
+ for leaf, param in list(module._parameters.items()):
383
+ if param is not None and param.device.type == "meta":
384
+ logger.warning(
385
+ "Materializing empty parameter %s.%s missing from the weight cache",
386
+ module_name,
387
+ leaf,
388
+ )
389
+ module._parameters[leaf] = nn.Parameter(
390
+ torch.empty_like(param, device=device), requires_grad=False
391
+ )
392
+ for leaf, buffer in list(module._buffers.items()):
393
+ if buffer is not None and buffer.device.type == "meta":
394
+ module._buffers[leaf] = torch.empty_like(buffer, device=device)
vllm/model_executor/model_loader/weight_cache/protocol.py
new
+412
@@ -0,0 +1,412 @@
1
+# SPDX-License-Identifier: Apache-2.0
2
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+"""WeightCacheKey fingerprinting and socket protocol for the weight cache daemon.
4
+
5
+The protocol uses pickle over a Unix domain socket and is only intended for
6
+communication between trusted local processes owned by the same user. The
7
+sockets live in a per-user private directory (mode 0700) and the daemon
8
+restricts the socket file permissions to the owner (0600). Both the daemon and
9
+the engine verify that the directory and socket are owned by the current user
10
+and are not group/world accessible before trusting them, so a different local
11
+user cannot pre-plant a malicious socket at a predictable path.
12
+"""
13
+
14
+import glob
15
+import json
16
+import os
17
+import pickle
18
+import socket
19
+import stat
20
+import struct
21
+import tempfile
22
+from dataclasses import dataclass, fields
23
+from typing import Any
24
+
25
+import torch
26
+
27
+import vllm.version
28
+from vllm.config import ModelConfig
29
+from vllm.utils.hashing import safe_hash
30
+
31
+SOCKET_NAME_TEMPLATE = "vllm_weight_cache_gpu{gpu_id}.sock"
32
+SOCKET_DIR_TEMPLATE = "vllm_weight_cache_{uid}"
33
+
34
+_LEN_STRUCT = struct.Struct("!Q")
35
+# Sanity bound for a single message. IPC handles are tiny; only small
36
+# non-CUDA tensors are ever shipped by value.
37
+MAX_MSG_SIZE = 1 << 34
38
+
39
+
40
+def _current_uid() -> int:
41
+ getuid = getattr(os, "getuid", None)
42
+ return getuid() if getuid is not None else -1
43
+
44
+
45
+class WeightCacheUnavailableError(Exception):
46
+ """Raised when no weight cache daemon is reachable or usable."""
47
+
48
+
49
+class CacheConfigMismatchError(Exception):
50
+ """Raised when the daemon's cached weights don't match the engine."""
51
+
52
+
53
+class UnsupportedQuantForIPCError(Exception):
54
+ """Raised when a quantization method is not verified for IPC weight sharing."""
55
+
56
+
57
+# The daemon exports tensor data only, so sharing is correct just for methods
58
+# whose post-load effect is either fully captured by that data or rebuilt when
59
+# process_weights_after_loading runs under weights_already_processed. Anything
60
+# absent from this registry hard-errors: a method that silently repacks into
61
+# shapes the client cannot reproduce or stamps unrebuildable Python-side state
62
+# would serve wrong numerics. Extend it only after an end-to-end check against
63
+# a disk-loaded baseline.
64
+#
65
+# This check is config-only so the daemon fails fast before a slow full load,
66
+# rather than surfacing the incompatibility only after the engine has built the
67
+# model (where process_weights_after_loading's supports_pre_processed_weights
68
+# guard would eventually catch it).
69
+
70
+
71
+def _quant_config_field(quant_config: Any, key: str) -> Any:
72
+ if quant_config is None:
73
+ return None
74
+ if isinstance(quant_config, dict):
75
+ return quant_config.get(key)
76
+ return getattr(quant_config, key, None)
77
+
78
+
79
+def _fp8_round_trips_via_ipc(quant_config: Any) -> bool:
80
+ """Only block-wise FP8 is verified.
81
+
82
+ Block-wise FP8 preserves the weight shape, while per-tensor FP8 transposes
83
+ ``layer.weight`` during post-processing -- a shape the client's
84
+ meta-initialized model cannot reproduce.
85
+ """
86
+ return _quant_config_field(quant_config, "weight_block_size") is not None
87
+
88
+
89
+# quantization name -> predicate(quant_config) -> True when verified safe.
90
+IPC_QUANT_ALLOWLIST: dict[str | None, Any] = {
91
+ None: lambda _quant_config: True, # unquantized
92
+ "fp8": _fp8_round_trips_via_ipc,
93
+}
94
+
95
+
96
+def is_ipc_quant_supported(quantization: str | None, quant_config: Any) -> bool:
97
+ predicate = IPC_QUANT_ALLOWLIST.get(quantization)
98
+ return False if predicate is None else bool(predicate(quant_config))
99
+
100
+
101
+def check_ipc_quant_support(model_config: ModelConfig, *, where: str) -> None:
102
+ """Hard-error unless the model's quantization is verified for IPC sharing.
103
+
104
+ Args:
105
+ model_config: Model configuration to inspect.
106
+ where: Short tag ("daemon"/"engine") used in the error message.
107
+
108
+ Raises:
109
+ UnsupportedQuantForIPCError: If the quantization method is not on the
110
+ verified allowlist.
111
+ """
112
+ quantization = model_config.quantization
113
+ # Prefer the canonical, nested-aware config (multimodal models keep it under
114
+ # text_config); fall back to the raw hf_config for older code paths.
115
+ quant_config = getattr(
116
+ getattr(model_config, "model_arch_config", None), "quantization_config", None
117
+ )
118
+ if quant_config is None:
119
+ quant_config = getattr(model_config.hf_config, "quantization_config", None)
120
+ if is_ipc_quant_supported(quantization, quant_config):
121
+ return
122
+ verified = ", ".join(
123
+ "unquantized" if name is None else repr(name) for name in IPC_QUANT_ALLOWLIST
124
+ )
125
+ raise UnsupportedQuantForIPCError(
126
+ f"[weight_cache:{where}] quantization {quantization!r} is not verified "
127
+ f"for CUDA IPC weight sharing: its post-load processing may repack "
128
+ f"weights into shapes the client cannot reproduce or stamp Python-side "
129
+ f"state that tensor export cannot carry, which would silently serve "
130
+ f"wrong numerics. Verified: {verified} (FP8 only with weight_block_size "
131
+ f"set, i.e. block-wise). Use the default --load-format for this model."
132
+ )
133
+
134
+
135
+def get_physical_device_id(device_index: int) -> int | None:
136
+ """Map a local CUDA device index to the physical GPU id.
137
+
138
+ Returns None if CUDA_VISIBLE_DEVICES contains non-integer entries
139
+ (e.g. GPU UUIDs), in which case an explicit socket path is required.
140
+ """
141
+ visible = os.environ.get("CUDA_VISIBLE_DEVICES")
142
+ if not visible:
143
+ return device_index
144
+ entries = visible.split(",")
145
+ try:
146
+ return int(entries[device_index])
147
+ except (IndexError, ValueError):
148
+ return None
149
+
150
+
151
+def get_socket_dir(socket_dir: str | None = None) -> str:
152
+ """Return the directory that holds the daemon sockets.
153
+
154
+ When no explicit directory is given, use a per-user private directory
155
+ under the system temp dir so its path is unpredictable to other users and
156
+ can be locked down to mode 0700.
157
+ """
158
+ if socket_dir is not None:
159
+ return socket_dir
160
+ return os.path.join(
161
+ tempfile.gettempdir(), SOCKET_DIR_TEMPLATE.format(uid=_current_uid())
162
+ )
163
+
164
+
165
+def get_socket_path(gpu_id: int, socket_dir: str | None = None) -> str:
166
+ directory = get_socket_dir(socket_dir)
167
+ return os.path.join(directory, SOCKET_NAME_TEMPLATE.format(gpu_id=gpu_id))
168
+
169
+
170
+def ensure_private_socket_dir(directory: str, strict_perms: bool = True) -> None:
171
+ """Create the socket directory (if needed) locked down to the owner.
172
+
173
+ Called by the daemon before binding. Existing directories are re-checked
174
+ and, for the auto-derived path, tightened so a pre-existing world-writable
175
+ directory is rejected.
176
+ """
177
+ os.makedirs(directory, mode=0o700, exist_ok=True)
178
+ if strict_perms:
179
+ os.chmod(directory, 0o700)
180
+ verify_private_dir(directory, strict_perms=strict_perms)
181
+
182
+
183
+def verify_private_dir(directory: str, strict_perms: bool = True) -> None:
184
+ """Verify a directory is a real dir owned by us and not world/group readable.
185
+
186
+ When ``strict_perms`` is False the group/world permission bits are not
187
+ checked; this is used for directories the operator explicitly configured
188
+ (they own the trust decision), while the auto-derived per-user directory is
189
+ always checked strictly.
190
+ """
191
+ info = os.lstat(directory)
192
+ if stat.S_ISLNK(info.st_mode):
193
+ raise WeightCacheUnavailableError(
194
+ f"Refusing to use symlinked socket directory {directory}"
195
+ )
196
+ if not stat.S_ISDIR(info.st_mode):
197
+ raise WeightCacheUnavailableError(f"{directory} is not a directory")
198
+ uid = _current_uid()
199
+ if uid != -1 and info.st_uid != uid:
200
+ raise WeightCacheUnavailableError(
201
+ f"Socket directory {directory} is not owned by the current user"
202
+ )
203
+ if strict_perms and info.st_mode & 0o077:
204
+ raise WeightCacheUnavailableError(
205
+ f"Socket directory {directory} is group/world accessible"
206
+ )
207
+
208
+
209
+def verify_socket_owner(socket_path: str, strict_perms: bool = True) -> None:
210
+ """Verify the socket lives in a private dir and is owned by the current user.
211
+
212
+ Called by the engine before connecting so it never talks to a socket a
213
+ different user could have planted.
214
+ """
215
+ verify_private_dir(os.path.dirname(socket_path), strict_perms=strict_perms)
216
+ info = os.lstat(socket_path)
217
+ if stat.S_ISLNK(info.st_mode):
218
+ raise WeightCacheUnavailableError(
219
+ f"Refusing to connect to symlinked socket {socket_path}"
220
+ )
221
+ uid = _current_uid()
222
+ if uid != -1 and info.st_uid != uid:
223
+ raise WeightCacheUnavailableError(
224
+ f"Socket {socket_path} is not owned by the current user"
225
+ )
226
+
227
+
228
+def verify_peer_is_owner(conn: socket.socket) -> None:
229
+ """Best-effort check that the connecting peer runs as the current user.
230
+
231
+ Uses SO_PEERCRED where available (Linux). Silently returns on platforms
232
+ that do not expose peer credentials.
233
+ """
234
+ so_peercred = getattr(socket, "SO_PEERCRED", None)
235
+ if so_peercred is None:
236
+ return
237
+ try:
238
+ creds = conn.getsockopt(socket.SOL_SOCKET, so_peercred, struct.calcsize("3i"))
239
+ _, peer_uid, _ = struct.unpack("3i", creds)
240
+ except OSError:
241
+ return
242
+ uid = _current_uid()
243
+ if uid != -1 and peer_uid != uid:
244
+ raise PermissionError(f"Rejecting weight cache connection from uid {peer_uid}")
245
+
246
+
247
+def _hash_quant_config(quant_config: Any) -> str:
248
+ if quant_config is None:
249
+ return ""
250
+ if hasattr(quant_config, "to_dict"):
251
+ quant_config = quant_config.to_dict()
252
+ payload = json.dumps(quant_config, sort_keys=True, default=str)
253
+ return safe_hash(payload.encode(), usedforsecurity=False).hexdigest()
254
+
255
+
256
+def _safetensors_header(path: str) -> bytes:
257
+ """Return the raw safetensors header (length prefix + JSON) of a file.
258
+
259
+ The header carries tensor names, dtypes, shapes and byte offsets, so it is
260
+ a content fingerprint of the shard without reading any weight bytes.
261
+ """
262
+ with open(path, "rb") as f:
263
+ size_bytes = f.read(8)
264
+ (header_len,) = struct.unpack("<Q", size_bytes)
265
+ return size_bytes + f.read(header_len)
266
+
267
+
268
+def hash_checkpoint(model: str) -> str | None:
269
+ """Fingerprint checkpoint content from local safetensors metadata.
270
+
271
+ Hashes each shard's safetensors header so a daemon and an engine pointing
272
+ at identical weights in different directories produce the same key. Returns
273
+ None when local safetensors files can't be located (e.g. an undownloaded
274
+ Hugging Face repo id), leaving the caller to fall back to the model path.
275
+ """
276
+ if not os.path.isdir(model):
277
+ return None
278
+ from transformers.utils import SAFE_WEIGHTS_INDEX_NAME
279
+
280
+ from vllm.model_executor.model_loader.weight_utils import (
281
+ filter_duplicate_safetensors_files,
282
+ )
283
+
284
+ files = glob.glob(os.path.join(model, "*.safetensors"))
285
+ if os.path.isfile(os.path.join(model, SAFE_WEIGHTS_INDEX_NAME)):
286
+ files = filter_duplicate_safetensors_files(
287
+ files, model, SAFE_WEIGHTS_INDEX_NAME
288
+ )
289
+ if not files:
290
+ return None
291
+ hasher = safe_hash(b"", usedforsecurity=False)
292
+ for path in sorted(files, key=os.path.basename):
293
+ hasher.update(os.path.basename(path).encode())
294
+ hasher.update(_safetensors_header(path))
295
+ return hasher.hexdigest()
296
+
297
+
298
+@dataclass(frozen=True)
299
+class WeightCacheKey:
300
+ """Fingerprint of the cached weights.
301
+
302
+ Any mismatch between the daemon's and the engine's fingerprint means the
303
+ cached weights cannot be reused and the engine must load from disk.
304
+ """
305
+
306
+ checkpoint: str
307
+ model_arch: str
308
+ tp_size: int
309
+ tp_rank: int
310
+ dtype: str
311
+ quantization: str | None
312
+ quant_config_hash: str
313
+ revision: str | None
314
+ vllm_version: str
315
+
316
+ @classmethod
317
+ def from_model_config(
318
+ cls, model_config: ModelConfig, tp_size: int, tp_rank: int
319
+ ) -> "WeightCacheKey":
320
+ """Build the fingerprint for a model configuration.
321
+
322
+ Must be called before weight loading: process_weights_after_loading
323
+ may mutate hf_config.quantization_config, which would change the hash
324
+ between the daemon and the engine.
325
+
326
+ The checkpoint is identified by a hash of its safetensors metadata when
327
+ the weights are available locally, so a daemon and engine referencing
328
+ identical weights in different directories still match; otherwise it
329
+ falls back to the model path.
330
+ """
331
+ hf_config = model_config.hf_config
332
+ arch = ",".join(getattr(hf_config, "architectures", None) or [])
333
+ quant_config = getattr(hf_config, "quantization_config", None)
334
+ checkpoint = hash_checkpoint(model_config.model) or model_config.model
335
+ return cls(
336
+ checkpoint=checkpoint,
337
+ model_arch=arch,
338
+ tp_size=tp_size,
339
+ tp_rank=tp_rank,
340
+ dtype=str(model_config.dtype),
341
+ quantization=model_config.quantization,
342
+ quant_config_hash=_hash_quant_config(quant_config),
343
+ revision=model_config.revision,
344
+ vllm_version=vllm.version.__version__,
345
+ )
346
+
347
+ def mismatched_fields(self, other: "WeightCacheKey") -> list[str]:
348
+ return [
349
+ f.name
350
+ for f in fields(self)
351
+ if getattr(self, f.name) != getattr(other, f.name)
352
+ ]
353
+
354
+
355
+@dataclass
356
+class TensorEntry:
357
+ """A single cached tensor.
358
+
359
+ CUDA tensors are exported as `torch.multiprocessing` reduction args
360
+ (CUDA IPC handles); non-CUDA tensors are shipped by value.
361
+ """
362
+
363
+ kind: str
364
+ """Either "param" or "buffer"."""
365
+ ipc_args: tuple | None = None
366
+ cpu_tensor: torch.Tensor | None = None
367
+
368
+ @classmethod
369
+ def from_tensor(cls, tensor: torch.Tensor, kind: str) -> "TensorEntry":
370
+ from torch.multiprocessing.reductions import reduce_tensor
371
+
372
+ tensor = tensor.detach()
373
+ if tensor.is_cuda:
374
+ _, ipc_args = reduce_tensor(tensor)
375
+ return cls(kind=kind, ipc_args=ipc_args)
376
+ return cls(kind=kind, cpu_tensor=tensor.cpu())
377
+
378
+ def rebuild(self, device_index: int) -> torch.Tensor:
379
+ if self.ipc_args is None:
380
+ assert self.cpu_tensor is not None
381
+ return self.cpu_tensor
382
+ from torch.multiprocessing.reductions import rebuild_cuda_tensor
383
+
384
+ args = list(self.ipc_args)
385
+ # Index 6 of the args from reduce_tensor is the device index. It must
386
+ # be retargeted to the local index since the daemon and the engine may
387
+ # have different CUDA_VISIBLE_DEVICES mappings.
388
+ args[6] = device_index
389
+ return rebuild_cuda_tensor(*args)
390
+
391
+
392
+def send_msg(sock: socket.socket, obj: Any) -> None:
393
+ payload = pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL)
394
+ sock.sendall(_LEN_STRUCT.pack(len(payload)))
395
+ sock.sendall(payload)
396
+
397
+
398
+def recv_msg(sock: socket.socket) -> Any:
399
+ (length,) = _LEN_STRUCT.unpack(_recv_exact(sock, _LEN_STRUCT.size))
400
+ if length > MAX_MSG_SIZE:
401
+ raise ValueError(f"Message size {length} exceeds limit {MAX_MSG_SIZE}")
402
+ return pickle.loads(_recv_exact(sock, length))
403
+
404
+
405
+def _recv_exact(sock: socket.socket, num_bytes: int) -> bytes:
406
+ buf = bytearray()
407
+ while len(buf) < num_bytes:
408
+ chunk = sock.recv(num_bytes - len(buf))
409
+ if not chunk:
410
+ raise ConnectionError("Socket closed while receiving message")
411
+ buf.extend(chunk)
412
+ return bytes(buf)
vllm/model_executor/utils.py
+23
@@ -3,12 +3,35 @@
3
"""Utils for model executor."""
4
5
import copy
6
+from contextlib import contextmanager
7
+from contextvars import ContextVar
8
from typing import Any
9
10
import torch
11
12
from vllm.utils.torch_utils import is_torch_equal_or_newer
13
14
+_weights_pre_processed: ContextVar[bool] = ContextVar(
15
+ "weights_pre_processed", default=False
16
+)
17
+
18
+
19
+@contextmanager
20
+def weights_already_processed():
21
+ """Mark that weights are already in post-processed (runtime) format, so
22
+ ``process_weights_after_loading`` skips tensor transforms (used by the
23
+ weight cache IPC loader).
24
+ """
25
+ token = _weights_pre_processed.set(True)
26
+ try:
27
+ yield
28
+ finally:
29
+ _weights_pre_processed.reset(token)
30
+
31
+
32
+def is_weights_pre_processed() -> bool:
33
+ return _weights_pre_processed.get()
34
+
35
36
def set_weight_attrs(
37
weight: torch.Tensor,