jagat-primitive-org commited on
Commit
c7550ff
·
verified ·
1 Parent(s): 9a21f58

Upload worker_image_quant.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. worker_image_quant.py +913 -0
worker_image_quant.py ADDED
@@ -0,0 +1,913 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
+ """Dedicated CPU-offload process for PLE embedding layers.
4
+
5
+ This module implements a standalone process that:
6
+ 1. Loads only the :class:`PleOffloadLayer` weights into CPU memory.
7
+ 2. Accepts per-step computation requests from GPU worker processes.
8
+ 3. Runs ``forward_impl()`` on CPU, copies results to every TP worker's GPU
9
+ output buffer for the requesting DP rank, and signals the corresponding
10
+ IPC semaphore.
11
+
12
+ The TP workers within one DP rank receive identical inputs, so the CPU result
13
+ is computed once per DP rank and fanned out to all of its TP ranks.
14
+
15
+ Class structure mirrors the GPU worker pattern in multiproc_executor.py:
16
+
17
+ PleOffloadWorkerHandle -- handle held by the spawning GPU worker
18
+ PleOffloadWorker -- process lifecycle and READY handshake
19
+ PleOffloadRunner -- owns weights and serves inference requests
20
+ """
21
+
22
+ import contextlib
23
+ import json
24
+ import mmap as _mmap
25
+ import multiprocessing.process
26
+ import pickle
27
+ import signal
28
+ import tempfile
29
+ import threading
30
+ from collections.abc import Iterable
31
+ from dataclasses import dataclass
32
+ from multiprocessing.connection import Connection
33
+ from typing import Any, cast
34
+
35
+ import msgspec
36
+ import torch
37
+ import torch.distributed as dist
38
+ import zmq
39
+
40
+ import vllm.envs as envs
41
+ from vllm.config import VllmConfig, set_current_vllm_config
42
+ from vllm.distributed.parallel_state import (
43
+ ensure_model_parallel_initialized,
44
+ init_distributed_environment,
45
+ )
46
+ from vllm.logger import init_logger
47
+ from vllm.model_executor.layers.ple_offload_layer import (
48
+ CpuGpuSemaphore,
49
+ PleOffloadLayer,
50
+ mark_as_offload_worker,
51
+ )
52
+ from vllm.model_executor.model_loader import get_model_loader
53
+ from vllm.model_executor.model_loader.default_loader import DefaultModelLoader
54
+ from vllm.model_executor.model_loader.dummy_loader import DummyModelLoader
55
+ from vllm.model_executor.model_loader.utils import (
56
+ initialize_model,
57
+ process_weights_after_loading,
58
+ )
59
+ from vllm.model_executor.model_loader.weight_utils import initialize_dummy_weights
60
+ from vllm.utils.system_utils import decorate_logs, get_mp_context
61
+ from vllm.utils.torch_utils import set_default_torch_dtype
62
+ from vllm.v1.ple_offload.protocol import (
63
+ _PLE_OFFLOAD_REQUEST_DECODER,
64
+ PleOffloadRegistration,
65
+ PleOffloadRequest,
66
+ )
67
+
68
+ logger = init_logger(__name__)
69
+
70
+
71
+ @dataclass
72
+ class PleOffloadOutputTarget:
73
+ """GPU output destination and semaphore for one TP worker."""
74
+
75
+ tp_rank: int
76
+ gpu_output_buffer: torch.Tensor # IPC-mapped GPU buffer for this TP worker
77
+ sem: CpuGpuSemaphore # semaphore paired with gpu_output_buffer
78
+ copy_stream: torch.cuda.Stream
79
+
80
+
81
+ @dataclass
82
+ class PleOffloadInputBuffers:
83
+ """Shared-memory input buffers registered for one DP rank."""
84
+
85
+ input_ids_buf: torch.Tensor # int32 (max_num_tokens,)
86
+ query_start_loc_buf: torch.Tensor # int32 (max_num_reqs + 1,)
87
+ ngram_context_buf: torch.Tensor | None # int32 (max_num_reqs, ngram_context_len)
88
+
89
+
90
+ @dataclass
91
+ class PleOffloadWorkerHandle:
92
+ """Resources owned by the GPU worker that spawned the offload process."""
93
+
94
+ proc: Any
95
+ death_writer: Connection | None
96
+ ready_pipe_reader: Connection | None
97
+
98
+ def close(self) -> None:
99
+ """Release all process resources. Safe to call more than once."""
100
+ if self.ready_pipe_reader is not None:
101
+ self.ready_pipe_reader.close()
102
+ self.ready_pipe_reader = None
103
+ if self.death_writer is not None:
104
+ self.death_writer.close()
105
+ self.death_writer = None
106
+ # First allow the child to exit after observing the closed death pipe.
107
+ if self.proc.is_alive():
108
+ self.proc.join(timeout=5)
109
+ # Fall back to SIGTERM if graceful shutdown times out.
110
+ if self.proc.is_alive():
111
+ self.proc.terminate()
112
+ self.proc.join(timeout=5)
113
+ # Use SIGKILL as the final fallback for a stuck child.
114
+ if self.proc.is_alive():
115
+ self.proc.kill()
116
+ self.proc.join(timeout=5)
117
+
118
+
119
+ def _init_offload_distributed() -> None:
120
+ """Initialize the single-rank Gloo world required by TP-aware layers."""
121
+ if dist.is_initialized():
122
+ return
123
+
124
+ # VocabParallelEmbedding reads the TP process group during construction.
125
+ # The offload process owns the full embedding table, so it uses an isolated
126
+ # TP1/PP1 Gloo world and never joins the GPU workers' NCCL groups.
127
+ store_dir = tempfile.mkdtemp(prefix="vllm_ple_offload_")
128
+ init_distributed_environment(
129
+ world_size=1,
130
+ rank=0,
131
+ distributed_init_method=f"file://{store_dir}/store",
132
+ local_rank=0,
133
+ backend="gloo",
134
+ )
135
+ # initialize_model_parallel reads the active VllmConfig in the current
136
+ # vLLM version. Explicitly configure DP1/TP1/PP1 to match the isolated
137
+ # world, regardless of any DP environment variables inherited from the GPU
138
+ # worker. The real DP/TP configuration is used later for model construction,
139
+ # registration, and request routing.
140
+ offload_config = VllmConfig()
141
+ offload_parallel_config = offload_config.parallel_config
142
+ offload_parallel_config.data_parallel_size = 1
143
+ offload_parallel_config.data_parallel_size_local = 1
144
+ offload_parallel_config.data_parallel_rank = 0
145
+ offload_parallel_config.data_parallel_rank_local = 0
146
+ offload_parallel_config.data_parallel_index = 0
147
+ offload_parallel_config.tensor_parallel_size = 1
148
+ offload_parallel_config.pipeline_parallel_size = 1
149
+ offload_parallel_config.prefill_context_parallel_size = 1
150
+ offload_parallel_config.decode_context_parallel_size = 1
151
+ offload_parallel_config.world_size = 1
152
+ offload_parallel_config.nnodes = 1
153
+ offload_parallel_config.node_rank = 0
154
+ with set_current_vllm_config(offload_config):
155
+ ensure_model_parallel_initialized(
156
+ tensor_model_parallel_size=1,
157
+ pipeline_model_parallel_size=1,
158
+ backend="gloo",
159
+ )
160
+ logger.info(
161
+ "Distributed environment initialized (backend=gloo, rank=0, world_size=1)."
162
+ )
163
+
164
+
165
+ class PleOffloadWorker:
166
+ """Manage process creation, READY handshake, and the child entry point."""
167
+
168
+ READY_STR = "READY"
169
+
170
+ @staticmethod
171
+ def make_process(
172
+ vllm_config: VllmConfig,
173
+ num_workers: int,
174
+ ipc_addr: str,
175
+ ) -> PleOffloadWorkerHandle:
176
+ """Spawn one CPU offload process for all local DP and TP workers."""
177
+ context = get_mp_context()
178
+ ready_reader, ready_writer = context.Pipe(duplex=False)
179
+ death_reader, death_writer = context.Pipe(duplex=False)
180
+ proc = context.Process(
181
+ target=PleOffloadWorker.proc_main,
182
+ kwargs={
183
+ "vllm_config": vllm_config,
184
+ "num_workers": num_workers,
185
+ "ipc_addr": ipc_addr,
186
+ "ready_pipe": (ready_reader, ready_writer),
187
+ "death_pipe": death_reader,
188
+ },
189
+ name="PleOffloadWorker",
190
+ daemon=True,
191
+ )
192
+
193
+ # Python normally forbids a daemon WorkerProc from spawning children.
194
+ # vLLM owns this process through death_pipe and explicit shutdown, so
195
+ # temporarily clear the daemon flag while the child is created.
196
+ parent = multiprocessing.process._current_process # type: ignore[attr-defined]
197
+ saved_daemon = parent._config.get("daemon")
198
+ parent._config["daemon"] = False
199
+ try:
200
+ proc.start()
201
+ finally:
202
+ parent._config["daemon"] = saved_daemon
203
+ ready_writer.close()
204
+ return PleOffloadWorkerHandle(
205
+ proc=proc,
206
+ death_writer=death_writer,
207
+ ready_pipe_reader=ready_reader,
208
+ )
209
+
210
+ @staticmethod
211
+ def wait_for_ready(handle: PleOffloadWorkerHandle) -> None:
212
+ """Wait until weights and all GPU registrations are ready to serve."""
213
+ reader = handle.ready_pipe_reader
214
+ if reader is None:
215
+ return
216
+ if not reader.poll(envs.VLLM_PLE_OFFLOAD_READY_TIMEOUT):
217
+ raise TimeoutError(
218
+ "PLE offload worker did not become ready within "
219
+ f"{envs.VLLM_PLE_OFFLOAD_READY_TIMEOUT}s."
220
+ )
221
+ try:
222
+ message = reader.recv()
223
+ except EOFError as error:
224
+ raise RuntimeError("PLE offload worker exited during startup") from error
225
+ finally:
226
+ reader.close()
227
+ handle.ready_pipe_reader = None
228
+ if message.get("status") != PleOffloadWorker.READY_STR:
229
+ raise RuntimeError(
230
+ "PLE offload worker failed during startup: "
231
+ f"{message.get('error', 'unknown error')}"
232
+ )
233
+ layer_names = message["layer_names"]
234
+ logger.info(
235
+ "Worker ready - %d PleOffloadLayer(s): %s",
236
+ len(layer_names),
237
+ layer_names,
238
+ )
239
+
240
+ @staticmethod
241
+ def proc_main(
242
+ vllm_config: VllmConfig,
243
+ num_workers: int,
244
+ ipc_addr: str,
245
+ ready_pipe: tuple[Connection, Connection],
246
+ death_pipe: Connection,
247
+ ) -> None:
248
+ """Load PLE weights, accept registrations, and run the request loop."""
249
+ decorate_logs("PleOffloadWorker")
250
+ ready_reader, ready_writer = ready_pipe
251
+ ready_reader.close()
252
+ shutdown_event = threading.Event()
253
+
254
+ def monitor_parent() -> None:
255
+ try:
256
+ death_pipe.recv()
257
+ except EOFError:
258
+ logger.info("Parent exited, shutting down.")
259
+ shutdown_event.set()
260
+
261
+ def handle_signal(_signum: int, _frame: object) -> None:
262
+ shutdown_event.set()
263
+
264
+ signal.signal(signal.SIGTERM, handle_signal)
265
+ signal.signal(signal.SIGINT, handle_signal)
266
+ threading.Thread(
267
+ target=monitor_parent,
268
+ daemon=True,
269
+ name="PleOffloadDeathMonitor",
270
+ ).start()
271
+
272
+ zmq_context: zmq.Context | None = None
273
+ pull_socket: zmq.Socket | None = None
274
+ try:
275
+ # The flag lets PleOffloadLayer subclasses execute their complete
276
+ # constructors instead of becoming empty GPU-worker placeholders.
277
+ mark_as_offload_worker()
278
+
279
+ # Initialize Gloo before installing the real VllmConfig. This keeps
280
+ # the CPU process in an isolated rank-zero, world-size-one group.
281
+ _init_offload_distributed()
282
+
283
+ # Model components read the active VllmConfig while the meta model
284
+ # is constructed, so keep the context around runner initialization.
285
+ with set_current_vllm_config(vllm_config):
286
+ runner = PleOffloadRunner(vllm_config)
287
+
288
+ zmq_context = zmq.Context()
289
+ pull_socket = zmq_context.socket(zmq.PULL)
290
+ pull_socket.bind(ipc_addr)
291
+ logger.info(
292
+ "Bound IPC address %s; waiting for %d GPU worker registration(s).",
293
+ ipc_addr,
294
+ num_workers,
295
+ )
296
+
297
+ # READY means that the process can immediately serve requests. Wait
298
+ # for every DP/TP worker to register before notifying the parent.
299
+ runner.accept_registrations(pull_socket, num_workers)
300
+ ready_writer.send(
301
+ {
302
+ "status": PleOffloadWorker.READY_STR,
303
+ "layer_names": sorted(runner.layer_names),
304
+ }
305
+ )
306
+ ready_writer.close()
307
+ ready_writer = None # type: ignore[assignment]
308
+
309
+ runner.busy_loop(pull_socket, shutdown_event)
310
+ except Exception as error:
311
+ logger.exception("Unexpected failure in PLE offload worker.")
312
+ if ready_writer is not None:
313
+ with contextlib.suppress(Exception):
314
+ ready_writer.send({"status": "FAILURE", "error": repr(error)})
315
+ raise
316
+ finally:
317
+ if pull_socket is not None:
318
+ pull_socket.close(linger=0)
319
+ if zmq_context is not None:
320
+ zmq_context.term()
321
+ if ready_writer is not None:
322
+ ready_writer.close()
323
+ death_pipe.close()
324
+
325
+
326
+ def _ple_disk_shard_of(mapped_name: str) -> str | None:
327
+ """"<layer>.a.b.shard_3.weight" -> "<layer>.a.b" (the parameter the shard fills)."""
328
+ import re
329
+
330
+ m = re.match(r"^(.*)\.shard_\d+\.weight$", mapped_name)
331
+ return m.group(1) if m else None
332
+
333
+
334
+ class _PleQuantTable:
335
+ """Shard-mmapped quantized n-gram table; gathers dequantize to BF16."""
336
+
337
+ ROWS_PER_SHARD = 2_500_012
338
+
339
+ def __init__(self, quant_dir: str, total_rows: int, width: int) -> None:
340
+ import json
341
+ import os
342
+
343
+ from safetensors import safe_open
344
+
345
+ meta = json.load(open(os.path.join(quant_dir, "META.json")))
346
+ self.layout = meta["layout"]
347
+ assert meta["rows"] == total_rows and meta["width"] == width, (
348
+ f"sidecar built for {meta['rows']}x{meta['width']}, "
349
+ f"table is {total_rows}x{width}"
350
+ )
351
+ n_shards = meta["shards"]
352
+ assert n_shards * self.ROWS_PER_SHARD == total_rows, "non-uniform shards"
353
+ self._q, self._s = [], []
354
+ for n in range(n_shards):
355
+ f = safe_open(os.path.join(quant_dir, f"shard_{n}.safetensors"),
356
+ framework="pt")
357
+ key = "weight_fp8" if "e4m3" in self.layout else "weight_i4"
358
+ self._q.append(f.get_tensor(key))
359
+ self._s.append(f.get_tensor("weight_scale"))
360
+ self.width = width
361
+ logger.info("PLE quant table: %s, %d shards mmapped from %s",
362
+ self.layout, n_shards, quant_dir)
363
+
364
+ def gather_into(self, ids: torch.Tensor, out: torch.Tensor) -> None:
365
+ ids = ids.long()
366
+ shard = ids // self.ROWS_PER_SHARD
367
+ local = ids - shard * self.ROWS_PER_SHARD
368
+ order = torch.argsort(shard)
369
+ s_sorted, l_sorted = shard[order], local[order]
370
+ uniq, counts = torch.unique_consecutive(s_sorted, return_counts=True)
371
+ pos = 0
372
+ for s, c in zip(uniq.tolist(), counts.tolist()):
373
+ sel = l_sorted[pos:pos + c]
374
+ rows = self._dequant(s, sel)
375
+ out[order[pos:pos + c]] = rows.to(out.dtype)
376
+ pos += c
377
+
378
+ def _dequant(self, s: int, sel: torch.Tensor) -> torch.Tensor:
379
+ if "e4m3" in self.layout:
380
+ q = self._q[s].index_select(0, sel).to(torch.float32)
381
+ return q * self._s[s].index_select(0, sel)[:, None]
382
+ packed = self._q[s].index_select(0, sel)
383
+ lo = (packed & 0xF).to(torch.int16)
384
+ hi = (packed >> 4).to(torch.int16)
385
+ nib = torch.stack((lo, hi), dim=-1).view(packed.shape[0], self.width)
386
+ scale = self._s[s].index_select(0, sel).to(torch.float32)
387
+ g = self.width // scale.shape[1]
388
+ return (nib.to(torch.float32) - 8) * scale.repeat_interleave(g, dim=1)
389
+
390
+
391
+ def _ple_quant_dir() -> str | None:
392
+ import os
393
+
394
+ return os.environ.get("VLLM_PLE_QUANT_DIR") or None
395
+
396
+
397
+ def _ple_quant_attach(layer_name: str, layer: torch.nn.Module,
398
+ quant_dir: str) -> str | None:
399
+ """Swap the layer's table for a sidecar-backed quant store.
400
+
401
+ Returns the stubbed parameter's name, or None when the layer has no
402
+ parameter large enough to be a table (>= 1 GiB).
403
+ """
404
+ named = sorted(layer.named_parameters(), key=lambda kv: kv[1].numel(), reverse=True)
405
+ if not named or named[0][1].numel() * named[0][1].element_size() < (1 << 30):
406
+ return None
407
+ pname, param = named[0]
408
+ rows, width = param.shape
409
+ owner = layer
410
+ parts = pname.split(".")
411
+ for p in parts[:-1]:
412
+ owner = getattr(owner, p)
413
+ owner._ple_quant = _PleQuantTable(quant_dir, rows, width)
414
+ # Stub before anything writes the parameter: the original 95 GB allocation
415
+ # is lazy virtual memory and stays unmaterialized.
416
+ getattr(owner, parts[-1]).data = torch.empty(0, width, dtype=param.dtype)
417
+ logger.info("PLE quant: %s.%s stubbed, gathers served from sidecar.",
418
+ layer_name, pname)
419
+ return pname
420
+
421
+
422
+ def _ple_disk_dir() -> str | None:
423
+ import os
424
+
425
+ return os.environ.get("VLLM_PLE_DISK_OFFLOAD_DIR") or None
426
+
427
+
428
+ _PLE_DISK_MAPS: dict[str, object] = {}
429
+
430
+
431
+ def _disk_backed_tensor(path: str, shape: tuple[int, ...], dtype: torch.dtype,
432
+ writable: bool) -> torch.Tensor:
433
+ """Map ``path`` as a tensor of ``shape``/``dtype``.
434
+
435
+ numpy has no bfloat16, so the file is mapped with a same-width integer dtype
436
+ and reinterpreted. ``writable`` selects a shared read-write mapping (first
437
+ boot, shard writes must reach the file) versus copy-on-write (steady state).
438
+ MADV_RANDOM is applied either way: gathers are random-access and readahead
439
+ only evicts useful pages.
440
+ """
441
+ import numpy as np
442
+
443
+ _NP = {torch.bfloat16: (np.uint16, torch.uint16), torch.float16: (np.uint16, torch.uint16),
444
+ torch.float32: (np.uint32, torch.uint32), torch.float8_e4m3fn: (np.uint8, torch.uint8)}
445
+ np_dtype, torch_int = _NP[dtype]
446
+ arr = np.memmap(path, dtype=np_dtype, mode="r+" if writable else "c", shape=shape)
447
+ with contextlib.suppress(Exception):
448
+ arr._mmap.madvise(_mmap.MADV_RANDOM) # noqa: SLF001 - numpy has no public madvise
449
+ _PLE_DISK_MAPS[path] = arr
450
+ return torch.from_numpy(arr).view(dtype)
451
+
452
+
453
+ def _ple_disk_attach(layer_name: str, layer: torch.nn.Module,
454
+ disk_dir: str) -> tuple[str, bool] | None:
455
+ """Swap the layer's largest parameter (the n-gram table) for a disk-backed map.
456
+
457
+ Returns ``(param_name, file_complete)`` or ``None`` when the layer has no
458
+ parameter large enough to be worth spilling (>= 1 GiB).
459
+ """
460
+ import os
461
+
462
+ named = sorted(layer.named_parameters(), key=lambda kv: kv[1].numel(), reverse=True)
463
+ if not named or named[0][1].numel() * named[0][1].element_size() < (1 << 30):
464
+ return None
465
+ pname, param = named[0]
466
+ shape, dtype = tuple(param.shape), param.dtype
467
+ nbytes = param.numel() * param.element_size()
468
+ os.makedirs(disk_dir, exist_ok=True)
469
+ base = os.path.join(disk_dir, layer_name.replace("/", "_") + "." + pname)
470
+ bin_path, done_path = base + ".bin", base + ".done.json"
471
+
472
+ complete = False
473
+ if os.path.exists(done_path) and os.path.exists(bin_path) and os.path.getsize(bin_path) == nbytes:
474
+ meta = json.load(open(done_path))
475
+ complete = meta.get("shape") == list(shape) and meta.get("dtype") == str(dtype)
476
+ if not complete:
477
+ with contextlib.suppress(FileNotFoundError):
478
+ os.remove(done_path)
479
+ with open(bin_path, "ab") as f:
480
+ f.truncate(nbytes)
481
+
482
+ mapped = _disk_backed_tensor(bin_path, shape, dtype, writable=not complete)
483
+ # Replace the parameter data in place; module structure and names are unchanged,
484
+ # so load_weights and the gather path are untouched.
485
+ owner = layer
486
+ parts = pname.split(".")
487
+ for p in parts[:-1]:
488
+ owner = getattr(owner, p)
489
+ getattr(owner, parts[-1]).data = mapped
490
+ logger.info(
491
+ "PLE disk offload: %s.%s -> %s (%.1f GiB, %s)",
492
+ layer_name, pname, bin_path, nbytes / (1 << 30),
493
+ "reusing finished file" if complete else "first boot, writing through",
494
+ )
495
+ return pname, complete
496
+
497
+
498
+ def _ple_disk_finalize(layer_name: str, layer: torch.nn.Module, pname: str,
499
+ disk_dir: str) -> None:
500
+ """Flush the written mapping, record completion, and remap copy-on-write."""
501
+ import os
502
+
503
+ owner = layer
504
+ parts = pname.split(".")
505
+ for p in parts[:-1]:
506
+ owner = getattr(owner, p)
507
+ param = getattr(owner, parts[-1])
508
+ base = os.path.join(disk_dir, layer_name.replace("/", "_") + "." + pname)
509
+ arr = _PLE_DISK_MAPS.get(base + ".bin")
510
+ if arr is not None:
511
+ with contextlib.suppress(Exception):
512
+ arr.flush()
513
+ json.dump({"shape": list(param.shape), "dtype": str(param.dtype)},
514
+ open(base + ".done.json", "w"))
515
+ param.data = _disk_backed_tensor(base + ".bin", tuple(param.shape), param.dtype,
516
+ writable=False)
517
+ logger.info("PLE disk offload: %s.%s finalized and remapped copy-on-write.",
518
+ layer_name, pname)
519
+
520
+
521
+ class PleOffloadRunner:
522
+ """Own all discovered PLE tables and serve every local DP rank."""
523
+
524
+ def __init__(self, vllm_config: VllmConfig) -> None:
525
+ self.vllm_config = vllm_config
526
+ self._clamp_input_ids = (
527
+ getattr(vllm_config, "speculative_config", None) is not None
528
+ )
529
+ # name -> PleOffloadLayer (CPU)
530
+ self._layers: dict[str, PleOffloadLayer] = {}
531
+ # dp_rank -> layer_name -> one destination per TP rank
532
+ self._worker_targets: dict[int, dict[str, list[PleOffloadOutputTarget]]] = {}
533
+ # Each (dp_rank, layer_name) pair owns a separate pinned scratch buffer.
534
+ # Sharing one buffer is unsafe because an asynchronous H2D copy may still
535
+ # be reading it when another layer or DP rank starts writing.
536
+ self._pinned_bufs: dict[int, dict[str, torch.Tensor]] = {}
537
+ # Shared-memory inputs are registered once per DP rank by TP rank zero.
538
+ self._input_bufs: dict[int, PleOffloadInputBuffers] = {}
539
+ self._load_weights()
540
+
541
+ @property
542
+ def layer_names(self) -> list[str]:
543
+ """Return PleOffloadLayer names in model traversal order."""
544
+ return list(self._layers)
545
+
546
+ def _load_weights(self) -> None:
547
+ """Load only :class:`PleOffloadLayer` subtrees into CPU memory.
548
+
549
+ Strategy:
550
+ 1. Build the entire model on ``meta`` so non-offloaded parameters use no
551
+ physical memory. PleOffloadLayer constructors explicitly target CPU.
552
+ 2. Discover all PleOffloadLayer modules from the complete model.
553
+ 3. Stream the checkpoint through a prefix filter so only matching PLE
554
+ tensors are materialized and passed to ``model.load_weights``.
555
+ 4. Run post-load processing only on the CPU-owned PLE subtrees.
556
+ """
557
+ model_config = self.vllm_config.model_config
558
+ load_config = self.vllm_config.load_config
559
+
560
+ # Step 1: build complete structure, while only PLE subtrees allocate CPU
561
+ # memory. All transformer, MoE, and vision parameters remain on meta.
562
+ logger.info("Initializing model structure for PLE weight discovery ...")
563
+ model_dtype = cast(torch.dtype, model_config.dtype)
564
+ with set_default_torch_dtype(model_dtype), torch.device("meta"):
565
+ model = initialize_model(
566
+ vllm_config=self.vllm_config,
567
+ model_config=model_config,
568
+ )
569
+
570
+ # Step 2: preserve named_modules DFS order so CPU execution follows the
571
+ # same layer order as the GPU model forward.
572
+ offload_layers = {
573
+ name: module
574
+ for name, module in model.named_modules()
575
+ if isinstance(module, PleOffloadLayer)
576
+ }
577
+ if not offload_layers:
578
+ raise RuntimeError(
579
+ "VLLM_PLE_CPU_OFFLOAD is enabled, but no PleOffloadLayer "
580
+ "was found in the initialized model"
581
+ )
582
+ logger.info(
583
+ "Found %d PleOffloadLayer(s): %s",
584
+ len(offload_layers),
585
+ sorted(offload_layers),
586
+ )
587
+ offload_prefixes = tuple(f"{name}." for name in offload_layers)
588
+
589
+ quant_dir = _ple_quant_dir()
590
+ disk_dir = _ple_disk_dir() if quant_dir is None else None
591
+ disk_attached: dict[str, str] = {}
592
+ disk_complete_params: set[str] = set()
593
+ disk_complete_tables: tuple[str, ...] = ()
594
+ if quant_dir is not None:
595
+ table_prefixes = []
596
+ for layer_name, layer in offload_layers.items():
597
+ pname = _ple_quant_attach(layer_name, layer, quant_dir)
598
+ if pname is None:
599
+ continue
600
+ full = f"{layer_name}.{pname}"
601
+ disk_complete_params.add(full)
602
+ table_prefixes.append(full.rsplit(".", 1)[0])
603
+ disk_complete_tables = tuple(table_prefixes)
604
+ if disk_dir is not None:
605
+ table_prefixes = []
606
+ for layer_name, layer in offload_layers.items():
607
+ attached = _ple_disk_attach(layer_name, layer, disk_dir)
608
+ if attached is None:
609
+ continue
610
+ pname, complete = attached
611
+ disk_attached[layer_name] = pname
612
+ if complete:
613
+ full = f"{layer_name}.{pname}"
614
+ disk_complete_params.add(full)
615
+ # "...ngram_embedding.weight" -> "...ngram_embedding": the module
616
+ # whose shard_N.weight checkpoint tensors fill this table.
617
+ table_prefixes.append(full.rsplit(".", 1)[0])
618
+ # Shard tensors that land inside an already-finished table are not
619
+ # re-read from the checkpoint: mapping the file replaces them.
620
+ disk_complete_tables = tuple(table_prefixes)
621
+
622
+ # Step 3: filter checkpoint tensors before model.load_weights(). The
623
+ # conditional-generation checkpoint uses HF names such as
624
+ # ``model.language_model.*`` while named_modules exposes mapped vLLM
625
+ # names such as ``language_model.model.*``. Apply the model mapper only
626
+ # for matching, then yield the original pair so load_weights performs
627
+ # its normal single mapping pass.
628
+ mapper = getattr(model, "hf_to_vllm_mapper", None)
629
+ matched_checkpoint_tensors = 0
630
+
631
+ def offload_only_iter(
632
+ weights: Iterable[tuple[str, torch.Tensor]],
633
+ ) -> Iterable[tuple[str, torch.Tensor]]:
634
+ nonlocal matched_checkpoint_tensors
635
+ for weight_name, tensor in weights:
636
+ mapped_name: str | None = weight_name
637
+ if mapper is not None:
638
+ mapped_names = mapper.apply_list([weight_name])
639
+ mapped_name = mapped_names[0] if mapped_names else None
640
+ if mapped_name is not None and mapped_name.startswith(offload_prefixes):
641
+ matched_checkpoint_tensors += 1
642
+ if disk_complete_tables:
643
+ table = _ple_disk_shard_of(mapped_name)
644
+ if table is not None and table.startswith(disk_complete_tables):
645
+ continue
646
+ yield weight_name, tensor
647
+
648
+ loader = get_model_loader(load_config)
649
+ if isinstance(loader, DummyModelLoader):
650
+ logger.info(
651
+ "Initializing dummy weights for %d PleOffloadLayer(s) ...",
652
+ len(offload_layers),
653
+ )
654
+ for layer in offload_layers.values():
655
+ initialize_dummy_weights(layer, model_config)
656
+ elif isinstance(loader, DefaultModelLoader):
657
+ all_weights = loader.get_all_weights(model_config, model)
658
+ loaded_params = model.load_weights(offload_only_iter(all_weights))
659
+ if matched_checkpoint_tensors == 0:
660
+ raise RuntimeError(
661
+ "PLE offload checkpoint filter matched no weights for "
662
+ f"layers: {sorted(offload_layers)}"
663
+ )
664
+
665
+ expected_offload_params = {
666
+ f"{layer_name}.{param_name}"
667
+ for layer_name, layer in offload_layers.items()
668
+ for param_name, _ in layer.named_parameters()
669
+ }
670
+ loaded_offload_entries = {
671
+ name for name in loaded_params if name.startswith(offload_prefixes)
672
+ }
673
+ loaded_expected_params = expected_offload_params.intersection(loaded_params)
674
+ missing_offload_params = sorted(
675
+ expected_offload_params.difference(loaded_expected_params)
676
+ - disk_complete_params
677
+ )
678
+ if missing_offload_params:
679
+ raise RuntimeError(
680
+ "PLE offload checkpoint did not load all materialized "
681
+ f"parameters: {missing_offload_params}"
682
+ )
683
+ logger.info(
684
+ "PLE offload matched %d checkpoint tensor(s), loaded %d "
685
+ "offload entries, and verified %d/%d materialized "
686
+ "parameter(s) for layers: %s",
687
+ matched_checkpoint_tensors,
688
+ len(loaded_offload_entries),
689
+ len(loaded_expected_params),
690
+ len(expected_offload_params),
691
+ sorted(offload_layers),
692
+ )
693
+ else:
694
+ raise NotImplementedError(
695
+ "PLE offload requires the default or dummy model loader, got "
696
+ f"{type(loader).__name__}"
697
+ )
698
+
699
+ # Step 4: post-load processing is restricted to CPU-owned PLE modules;
700
+ # the remainder of the model is still on meta and must not be visited.
701
+ for layer in offload_layers.values():
702
+ process_weights_after_loading(layer, model_config, torch.device("cpu"))
703
+
704
+ if disk_dir is not None:
705
+ for layer_name, pname in disk_attached.items():
706
+ if f"{layer_name}.{pname}" not in disk_complete_params:
707
+ _ple_disk_finalize(layer_name, offload_layers[layer_name],
708
+ pname, disk_dir)
709
+
710
+ self._layers.update(offload_layers)
711
+ del model
712
+ logger.info("PLE weight loading complete.")
713
+
714
+ def accept_registrations(
715
+ self,
716
+ pull_socket: zmq.Socket,
717
+ num_workers: int,
718
+ ) -> None:
719
+ """Receive every local DP/TP worker's IPC and shared-memory buffers."""
720
+ logger.info("Waiting for %d GPU worker registration(s) ...", num_workers)
721
+ registrations: list[PleOffloadRegistration] = []
722
+ for index in range(num_workers):
723
+ item = pickle.loads(pull_socket.recv())
724
+ if not isinstance(item, PleOffloadRegistration):
725
+ raise RuntimeError(
726
+ "Expected PleOffloadRegistration during setup, got "
727
+ f"{type(item).__name__} ({index + 1}/{num_workers})"
728
+ )
729
+ registrations.append(item)
730
+ logger.info(
731
+ "GPU worker %d registered (dp_rank=%d, tp_rank=%d, layers=%s).",
732
+ item.worker_id,
733
+ item.dp_rank,
734
+ item.tp_rank,
735
+ sorted(item.gpu_output_buffers),
736
+ )
737
+
738
+ dp_size = self.vllm_config.parallel_config.data_parallel_size
739
+ tp_size = self.vllm_config.parallel_config.tensor_parallel_size
740
+ if num_workers != dp_size * tp_size:
741
+ raise RuntimeError(
742
+ f"Expected {dp_size * tp_size} registrations for DP={dp_size}, "
743
+ f"TP={tp_size}, got {num_workers}"
744
+ )
745
+
746
+ registrations_by_dp: dict[int, list[PleOffloadRegistration]] = {}
747
+ for registration in registrations:
748
+ registrations_by_dp.setdefault(registration.dp_rank, []).append(
749
+ registration
750
+ )
751
+ if set(registrations_by_dp) != set(range(dp_size)):
752
+ raise RuntimeError(
753
+ f"Expected DP ranks {set(range(dp_size))}, "
754
+ f"got {set(registrations_by_dp)}"
755
+ )
756
+ for dp_rank, dp_registrations in registrations_by_dp.items():
757
+ tp_ranks = {registration.tp_rank for registration in dp_registrations}
758
+ if tp_ranks != set(range(tp_size)):
759
+ raise RuntimeError(
760
+ f"DP rank {dp_rank} expected TP ranks {set(range(tp_size))}, "
761
+ f"got {tp_ranks}"
762
+ )
763
+
764
+ for registration in registrations:
765
+ if set(registration.gpu_output_buffers) != set(self.layer_names):
766
+ raise RuntimeError(
767
+ "Registered PLE layers do not match CPU layers: "
768
+ f"registered={sorted(registration.gpu_output_buffers)}, "
769
+ f"cpu={sorted(self.layer_names)}"
770
+ )
771
+ targets_for_dp = self._worker_targets.setdefault(registration.dp_rank, {})
772
+ for layer_name, gpu_buffer in registration.gpu_output_buffers.items():
773
+ target = PleOffloadOutputTarget(
774
+ tp_rank=registration.tp_rank,
775
+ gpu_output_buffer=gpu_buffer,
776
+ sem=CpuGpuSemaphore.from_ipc_tensor(
777
+ registration.sem_flag_tensors[layer_name]
778
+ ),
779
+ copy_stream=torch.cuda.Stream(device=gpu_buffer.device),
780
+ )
781
+ targets_for_dp.setdefault(layer_name, []).append(target)
782
+ # All TP ranks in one DP group receive the same input, so buffers
783
+ # registered by TP rank zero are sufficient for that DP rank.
784
+ if registration.tp_rank == 0:
785
+ self._input_bufs[registration.dp_rank] = PleOffloadInputBuffers(
786
+ input_ids_buf=registration.input_ids_buf,
787
+ query_start_loc_buf=registration.query_start_loc_buf,
788
+ ngram_context_buf=registration.ngram_context_buf,
789
+ )
790
+
791
+ if set(self._input_bufs) != set(range(dp_size)):
792
+ raise RuntimeError(
793
+ "TP rank zero did not register PLE input buffers for every DP "
794
+ f"rank: expected={set(range(dp_size))}, got={set(self._input_bufs)}"
795
+ )
796
+
797
+ config = self.vllm_config.model_config.hf_text_config
798
+ max_tokens = self.vllm_config.scheduler_config.max_num_batched_tokens
799
+ embedding_dim = int(config.ple_embed_dim)
800
+ for dp_rank, layer_targets in self._worker_targets.items():
801
+ self._pinned_bufs[dp_rank] = {}
802
+ for layer_name, targets in layer_targets.items():
803
+ if len(targets) != tp_size:
804
+ raise RuntimeError(
805
+ f"PLE layer {layer_name} for DP rank {dp_rank} received "
806
+ f"{len(targets)} targets, expected {tp_size}"
807
+ )
808
+ targets.sort(key=lambda target: target.tp_rank)
809
+ self._pinned_bufs[dp_rank][layer_name] = torch.empty(
810
+ max_tokens,
811
+ embedding_dim,
812
+ dtype=self._layers[layer_name].get_offload_output_dtype(
813
+ self.vllm_config.model_config.dtype
814
+ ),
815
+ pin_memory=True,
816
+ )
817
+ logger.info(
818
+ "Registrations complete (dp_size=%d, tp_size=%d, layers=%s).",
819
+ dp_size,
820
+ tp_size,
821
+ sorted(self.layer_names),
822
+ )
823
+
824
+ @torch.inference_mode()
825
+ def busy_loop(
826
+ self,
827
+ pull_socket: zmq.Socket,
828
+ shutdown_event: threading.Event,
829
+ ) -> None:
830
+ """Decode and batch available requests by DP rank until shutdown."""
831
+ logger.info("Busy-loop started.")
832
+ poller = zmq.Poller()
833
+ poller.register(pull_socket, zmq.POLLIN)
834
+ while not shutdown_event.is_set():
835
+ if pull_socket not in dict(poller.poll(timeout=100)):
836
+ continue
837
+
838
+ requests = []
839
+ try:
840
+ requests.append(_PLE_OFFLOAD_REQUEST_DECODER.decode(pull_socket.recv()))
841
+ while True:
842
+ requests.append(
843
+ _PLE_OFFLOAD_REQUEST_DECODER.decode(
844
+ pull_socket.recv(zmq.NOBLOCK)
845
+ )
846
+ )
847
+ except zmq.Again:
848
+ pass
849
+ except msgspec.DecodeError as error:
850
+ raise RuntimeError("Unexpected PLE offload request") from error
851
+
852
+ self._handle_requests(requests)
853
+
854
+ def _handle_requests(self, requests: list[PleOffloadRequest]) -> None:
855
+ """Run requests layer-first so each DP rank can resume promptly."""
856
+ requests_by_dp: dict[int, PleOffloadRequest] = {}
857
+ for request in requests:
858
+ if request.dp_rank not in self._worker_targets:
859
+ logger.warning(
860
+ "No PLE output targets for dp_rank=%d; skipping request.",
861
+ request.dp_rank,
862
+ )
863
+ continue
864
+ if request.dp_rank in requests_by_dp:
865
+ logger.warning(
866
+ "Duplicate PLE request for dp_rank=%d; skipping duplicate.",
867
+ request.dp_rank,
868
+ )
869
+ continue
870
+ requests_by_dp[request.dp_rank] = request
871
+
872
+ # Speculative placeholders are not vocabulary IDs. Normalize each DP
873
+ # input once before all PLE layers consume the shared buffer.
874
+ if self._clamp_input_ids:
875
+ for dp_rank, request in requests_by_dp.items():
876
+ self._input_bufs[dp_rank].input_ids_buf[
877
+ : request.num_tokens
878
+ ].clamp_min_(0)
879
+
880
+ for layer_name, layer in self._layers.items():
881
+ for dp_rank, request in requests_by_dp.items():
882
+ targets = self._worker_targets[dp_rank][layer_name]
883
+
884
+ # The CPU must not overwrite a GPU output buffer until its
885
+ # previous result has been consumed. The GPU runner resets the
886
+ # flag after the complete model forward.
887
+ for target in targets:
888
+ target.copy_stream.synchronize()
889
+ target.sem.wait_reset(target.copy_stream)
890
+
891
+ input_bufs = self._input_bufs[dp_rank]
892
+ ngram_context = (
893
+ input_bufs.ngram_context_buf[: request.num_reqs]
894
+ if input_bufs.ngram_context_buf is not None
895
+ else None
896
+ )
897
+ result = layer.forward_impl(
898
+ input_bufs.input_ids_buf[: request.num_tokens],
899
+ input_bufs.input_ids_buf[: request.num_tokens],
900
+ input_bufs.query_start_loc_buf[: request.num_reqs + 1],
901
+ ngram_context,
902
+ output_buffer=self._pinned_bufs[dp_rank][layer_name],
903
+ )
904
+
905
+ # The result is identical on every TP rank in this DP group.
906
+ # Each copy stream signals only after its DMA completes.
907
+ slices = tuple(slice(0, size) for size in result.shape)
908
+ for target in targets:
909
+ with torch.cuda.stream(target.copy_stream):
910
+ target.gpu_output_buffer[slices].copy_(
911
+ result[slices], non_blocking=True
912
+ )
913
+ target.sem.signal(target.copy_stream)