main
py 1,246 lines 49.3 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Host runtime operations for local snapshots."""
4
5 import argparse
6 import ast
7 import hashlib
8 import importlib.metadata
9 import importlib.util
10 import json
11 import os
12 import platform
13 import select
14 import shutil
15 import signal
16 import socket
17 import stat
18 import struct
19 import subprocess
20 import sys
21 import tempfile
22 import time
23 import urllib.request
24 from collections.abc import Iterable
25 from contextlib import suppress
26 from dataclasses import dataclass
27 from datetime import datetime, timezone
28 from pathlib import Path
29 from typing import NoReturn
30
31 import regex as re
32 from pydantic import ValidationError
33
34 from vllm.snapshot.manifest import (
35 ReadyMarker,
36 SnapshotManifest,
37 SnapshotRuntimeIdentity,
38 _fsync_directory,
39 _validation_path,
40 _write_json_atomic,
41 validate_artifact_root,
42 write_manifest_atomic,
43 )
44 from vllm.snapshot.types import Oracle
45
46
47 class SnapshotCreateError(RuntimeError):
48 """Snapshot creation did not produce a complete artifact."""
49
50
51 class SnapshotRestoreError(RuntimeError):
52 """Snapshot restore failed."""
53
54
55 _COMMON_CRIU_OPTIONS = (
56 "--shell-job",
57 "--ext-unix-sk",
58 "--tcp-established",
59 "--link-remap",
60 "--file-locks",
61 )
62 _TCP_TABLES = (
63 ("AF_INET", Path("/proc/net/tcp")),
64 ("AF_INET6", Path("/proc/net/tcp6")),
65 )
66
67 _CACHE_REMEDY = "recreate the snapshot or restore with the same cache state"
68 # Recorded instead of a digest for files whose content is volatile by design.
69 _CACHE_LOCKED = "locked"
70 # Recorded instead of a digest for a file capture found but could not read.
71 _CACHE_UNREADABLE = "unreadable"
72
73
74 def _generated_cache_prefixes() -> tuple[str, ...]:
75 """Path prefixes of the generated caches an engine process keeps open.
76
77 vLLM redirects Inductor and Triton under ``VLLM_CACHE_ROOT`` when it
78 compiles, but a process can still hold their upstream defaults open.
79 A cache root is resolved and separator-terminated so that it still matches
80 the kernel-canonical descriptor targets under a symlinked root, and so that
81 a sibling directory sharing its name does not. Inductor's default location
82 is a bare prefix instead, because its directory carries a per-user suffix.
83 """
84 import vllm.envs as envs
85
86 inductor_root = os.environ.get("TORCHINDUCTOR_CACHE_DIR")
87 roots = tuple(
88 f"{os.path.realpath(os.path.expanduser(root)).rstrip(os.sep)}{os.sep}"
89 for root in (
90 envs.VLLM_CACHE_ROOT,
91 os.environ.get("TRITON_CACHE_DIR") or "~/.triton/cache",
92 "~/.cache/flashinfer",
93 *((inductor_root,) if inductor_root else ()),
94 )
95 )
96 if inductor_root:
97 return roots
98 return roots + (
99 os.path.join(os.path.realpath(tempfile.gettempdir()), "torchinductor_"),
100 )
101
102
103 def _error_detail(error: BaseException) -> str:
104 message = str(error)
105 return f"{type(error).__name__}: {message}" if message else type(error).__name__
106
107
108 def _close_pidfds(handles: Iterable[tuple[int, int]]) -> None:
109 for _pid, pidfd in handles:
110 with suppress(OSError):
111 os.close(pidfd)
112
113
114 @dataclass(frozen=True)
115 class ProcessInventory:
116 root_pid: int
117 process_tree: tuple[int, ...]
118 cuda_holders: tuple[int, ...]
119 gpu_uuid: str
120 external_cache_files: tuple[tuple[str, str], ...] = ()
121
122
123 @dataclass(frozen=True)
124 class _TcpSocketRecord:
125 family: str
126 local_raw: str
127 remote_raw: str
128 inode: int
129
130
131 def _process_comm(pid: int) -> str:
132 try:
133 return (Path("/proc") / str(pid) / "comm").read_text().strip()
134 except OSError:
135 return "unknown"
136
137
138 def _decode_endpoint(family: str, raw: str) -> str:
139 """Render a ``/proc/net/tcp`` ``HEXADDR:HEXPORT`` field as ``ip:port``.
140
141 The kernel prints the address as little-endian 32-bit words: one for IPv4,
142 four in order for IPv6.
143 """
144 hex_address, _, hex_port = raw.partition(":")
145 port = int(hex_port, 16)
146 if family == "AF_INET":
147 packed = struct.pack("<I", int(hex_address, 16))
148 return f"{socket.inet_ntop(socket.AF_INET, packed)}:{port}"
149 packed = struct.pack(
150 "<4I", *(int(hex_address[index : index + 8], 16) for index in (0, 8, 16, 24))
151 )
152 return f"[{socket.inet_ntop(socket.AF_INET6, packed)}]:{port}"
153
154
155 def _validate_tcp_connections(
156 records: tuple[_TcpSocketRecord, ...], socket_owners: dict[int, int]
157 ) -> None:
158 owned = tuple(record for record in records if record.inode in socket_owners)
159 endpoints = {
160 (record.family, record.local_raw, record.remote_raw) for record in owned
161 }
162 offenders = tuple(
163 record
164 for record in owned
165 if (record.family, record.remote_raw, record.local_raw) not in endpoints
166 )
167 if not offenders:
168 return
169 named = "; ".join(
170 f"pid {socket_owners[record.inode]} "
171 f"({_process_comm(socket_owners[record.inode])}) "
172 f"{_decode_endpoint(record.family, record.local_raw)} -> "
173 f"{_decode_endpoint(record.family, record.remote_raw)}"
174 for record in offenders[:5]
175 )
176 remaining = f" (+{len(offenders) - 5} more)" if len(offenders) > 5 else ""
177 raise SnapshotCreateError(
178 f"snapshot tree has an external established TCP connection: {named}{remaining}"
179 )
180
181
182 class LocalSnapshotTools:
183 """Host-owned CRIU and cuda-checkpoint implementation."""
184
185 def __init__(self) -> None:
186 self.criu = shutil.which("criu") or "criu"
187 self.cuda_checkpoint = shutil.which("cuda-checkpoint") or "cuda-checkpoint"
188 self.nvidia_smi = shutil.which("nvidia-smi") or "nvidia-smi"
189 plugin_dir = os.environ.get("CRIU_CUDA_PLUGIN_DIR", "")
190 self.plugin_dir = Path(plugin_dir) if plugin_dir else None
191 self.shm_dir = Path("/dev/shm")
192 self.timeout_s = float(os.environ.get("VLLM_SNAPSHOT_TIMEOUT_S", "900"))
193 if not 0 < self.timeout_s < float("inf"):
194 raise ValueError("VLLM_SNAPSHOT_TIMEOUT_S must be a positive finite number")
195 self._children: dict[int, subprocess.Popen[bytes]] = {}
196 self._restored_processes: dict[int, tuple[tuple[int, int], ...]] = {}
197
198 def _privileged(self) -> list[str]:
199 return [] if os.geteuid() == 0 else ["sudo", "-n"]
200
201 def _run(
202 self,
203 command: list[str],
204 *,
205 timeout: float | None = None,
206 ) -> subprocess.CompletedProcess[str]:
207 return subprocess.run(
208 command,
209 check=True,
210 capture_output=True,
211 text=True,
212 timeout=timeout or self.timeout_s,
213 )
214
215 def preflight(self, action: str, artifact: Path) -> None:
216 if platform.system() != "Linux" or platform.machine() != "x86_64":
217 raise RuntimeError("snapshot requires Linux x86_64")
218 if action == "restore" and (
219 not callable(getattr(os, "pidfd_open", None))
220 or not callable(getattr(signal, "pidfd_send_signal", None))
221 ):
222 raise RuntimeError("snapshot requires Linux pidfd support")
223 for command in (self.criu, self.cuda_checkpoint, self.nvidia_smi):
224 if shutil.which(command) is None:
225 raise RuntimeError(f"snapshot dependency not found: {command}")
226 if self.plugin_dir is None:
227 raise RuntimeError("CRIU_CUDA_PLUGIN_DIR is required")
228 if not (self.plugin_dir / "cuda_plugin.so").is_file():
229 raise RuntimeError(f"CRIU CUDA plugin not found in {self.plugin_dir}")
230 if os.geteuid() != 0:
231 self._run(["sudo", "-n", "true"], timeout=5)
232 if action == "restore":
233 validate_artifact_root(artifact, creating=False)
234 else:
235 validate_artifact_root(artifact, creating=True)
236
237 def launch_child(
238 self,
239 workdir: Path,
240 engine_argv: tuple[str, ...],
241 ) -> int:
242 command = [
243 sys.executable,
244 "-m",
245 "vllm.snapshot.server",
246 "--ready-file",
247 str(workdir / "ready.json"),
248 "--release-file",
249 str(workdir / "release.json"),
250 "--release-timeout-s",
251 str(self.timeout_s),
252 "--",
253 *engine_argv,
254 ]
255 child_environment = os.environ.copy()
256 child_environment.setdefault("VLLM_WORKER_MULTIPROC_METHOD", "spawn")
257 with (workdir / "child.log").open("wb") as log_file:
258 process = subprocess.Popen(
259 command,
260 env=child_environment,
261 stdin=subprocess.DEVNULL,
262 stdout=log_file,
263 stderr=subprocess.STDOUT,
264 start_new_session=True,
265 )
266 self._children[process.pid] = process
267 return process.pid
268
269 def wait_ready(self, workdir: Path, root_pid: int) -> Oracle:
270 ready_file = workdir / "ready.json"
271 deadline = time.monotonic() + self.timeout_s
272 waitid = os.waitid # type: ignore[attr-defined]
273 while time.monotonic() < deadline:
274 if ready_file.is_file() and ready_file.stat().st_size:
275 try:
276 marker = ReadyMarker.model_validate_json(ready_file.read_text())
277 except ValidationError as error:
278 raise SnapshotCreateError(
279 f"snapshot ready marker invalid: {_validation_path(error)}"
280 ) from error
281 return Oracle(
282 token_ids=marker.token_ids,
283 text=marker.text,
284 sampled_token_logprob=marker.sampled_token_logprob,
285 )
286 process = self._children.get(root_pid)
287 if process is not None and waitid(
288 os.P_PID,
289 root_pid,
290 os.WEXITED | os.WNOHANG | os.WNOWAIT,
291 ):
292 log = (workdir / "child.log").read_text(errors="replace")
293 raise SnapshotCreateError(f"snapshot child exited before ready:\n{log}")
294 try:
295 os.kill(root_pid, 0)
296 except ProcessLookupError as error:
297 log = (workdir / "child.log").read_text(errors="replace")
298 raise SnapshotCreateError(
299 f"snapshot child exited before ready:\n{log}"
300 ) from error
301 time.sleep(0.05)
302 raise SnapshotCreateError("snapshot child did not become ready before timeout")
303
304 def _tree_pids(self, root_pid: int) -> tuple[int, ...]:
305 rows = self._run(["ps", "-eo", "pid=,ppid="]).stdout.splitlines()
306 children: dict[int, list[int]] = {}
307 for row in rows:
308 pid, parent_pid = map(int, row.split())
309 children.setdefault(parent_pid, []).append(pid)
310 stack = [root_pid]
311 found: list[int] = []
312 while stack:
313 pid = stack.pop()
314 if pid in found:
315 continue
316 found.append(pid)
317 stack.extend(children.get(pid, []))
318 return tuple(found)
319
320 def _cuda_process_rows(self) -> tuple[str, ...]:
321 output = self._run(
322 [
323 self.nvidia_smi,
324 "--query-compute-apps=pid,gpu_uuid",
325 "--format=csv,noheader,nounits",
326 ]
327 ).stdout
328 return tuple(line for line in output.splitlines() if line.strip())
329
330 def _cuda_pids(self, rows: tuple[str, ...]) -> tuple[int, ...]:
331 return tuple(sorted({int(line.split(",", 1)[0].strip()) for line in rows}))
332
333 def _descriptor_targets(self, pid: int) -> tuple[str, ...]:
334 descriptor_dir = Path("/proc") / str(pid) / "fd"
335 try:
336 descriptors = tuple(descriptor_dir.iterdir())
337 except FileNotFoundError as error:
338 raise SnapshotCreateError(
339 f"snapshot process exited during descriptor inventory: {pid}"
340 ) from error
341 targets: list[str] = []
342 for descriptor in descriptors:
343 try:
344 targets.append(os.readlink(descriptor))
345 except FileNotFoundError:
346 continue
347 return tuple(targets)
348
349 def _descriptor_inventory(
350 self, process_tree: tuple[int, ...]
351 ) -> tuple[tuple[int, ...], dict[int, int]]:
352 """Collect io_uring holders and each socket inode's owning pid."""
353 io_uring_pids: list[int] = []
354 socket_owners: dict[int, int] = {}
355 for pid in process_tree:
356 targets = self._descriptor_targets(pid)
357 if "anon_inode:[io_uring]" in targets:
358 io_uring_pids.append(pid)
359 for target in targets:
360 if target.startswith("socket:[") and target.endswith("]"):
361 # Shared descriptors name one holder; any of them locates
362 # the connection for the operator.
363 socket_owners.setdefault(int(target[len("socket:[") : -1]), pid)
364 return tuple(io_uring_pids), socket_owners
365
366 def _tcp_records(self) -> tuple[_TcpSocketRecord, ...]:
367 records: list[_TcpSocketRecord] = []
368 for family, table in _TCP_TABLES:
369 if not table.exists():
370 continue
371 for row in table.read_text().splitlines()[1:]:
372 fields = row.split()
373 if fields[3] != "01":
374 continue
375 records.append(
376 _TcpSocketRecord(
377 family=family,
378 local_raw=fields[1],
379 remote_raw=fields[2],
380 inode=int(fields[9]),
381 )
382 )
383 return tuple(records)
384
385 def _cache_fingerprint(self, path: Path) -> str | None:
386 """Digest a cache file, or mark the ones whose content is volatile."""
387 try:
388 if not stat.S_ISREG(path.stat().st_mode):
389 return None
390 except OSError:
391 return None
392 if path.name.endswith(".lock"):
393 return _CACHE_LOCKED
394 try:
395 return self._sha256(path)
396 except OSError:
397 # Restore can only check that this one still exists, so record the
398 # downgrade rather than passing it off as a volatile file.
399 return _CACHE_UNREADABLE
400
401 def _external_cache_files(
402 self, process_tree: tuple[int, ...], artifact: Path
403 ) -> tuple[tuple[str, str], ...]:
404 """Fingerprint the generated-cache files the captured tree holds open.
405
406 CRIU reopens these by path on restore, so a JIT cache that rotates
407 between capture and restore fails the restore permanently. Recording
408 them lets restore fail early and name the file that changed. Files
409 inside the artifact are excluded: they belong to the snapshot itself.
410 Deleted targets are excluded too, because CRIU remaps those links.
411 """
412 prefixes = _generated_cache_prefixes()
413 artifact_prefix = f"{os.path.abspath(artifact)}{os.sep}"
414 candidates: set[str] = set()
415 for pid in process_tree:
416 for target in self._descriptor_targets(pid):
417 if target.endswith(" (deleted)") or target.startswith(artifact_prefix):
418 continue
419 if target.endswith(".lock") or target.startswith(prefixes):
420 candidates.add(target)
421 recorded = (
422 (path, self._cache_fingerprint(Path(path))) for path in sorted(candidates)
423 )
424 return tuple(
425 (path, fingerprint)
426 for path, fingerprint in recorded
427 if fingerprint is not None
428 )
429
430 def inventory(self, root_pid: int, artifact: Path) -> ProcessInventory:
431 process_tree = self._tree_pids(root_pid)
432 cuda_rows = self._cuda_process_rows()
433 cuda_holders = tuple(
434 pid for pid in self._cuda_pids(cuda_rows) if pid in process_tree
435 )
436 if not cuda_holders:
437 raise SnapshotCreateError("snapshot tree has no CUDA-holding process")
438 io_uring_pids, socket_owners = self._descriptor_inventory(process_tree)
439 if io_uring_pids:
440 raise SnapshotCreateError(
441 "snapshot tree owns io_uring state that CRIU cannot dump; set "
442 "kernel.io_uring_disabled=1 before starting an unprivileged "
443 "vLLM process, or use =2 to disable it host-wide "
444 f"(pids: {', '.join(map(str, io_uring_pids))})"
445 )
446 _validate_tcp_connections(self._tcp_records(), socket_owners)
447 gpu_uuid = self._gpu_uuid_for_pids(cuda_holders, cuda_rows)
448 return ProcessInventory(
449 root_pid=root_pid,
450 process_tree=process_tree,
451 cuda_holders=cuda_holders,
452 gpu_uuid=gpu_uuid,
453 external_cache_files=self._external_cache_files(process_tree, artifact),
454 )
455
456 def _gpu_uuid_for_pids(self, pids: tuple[int, ...], rows: tuple[str, ...]) -> str:
457 wanted = set(pids)
458 matches: set[str] = set()
459 try:
460 for line in rows:
461 pid, gpu_uuid = (item.strip() for item in line.split(",", 1))
462 if int(pid) in wanted:
463 matches.add(gpu_uuid)
464 except (TypeError, ValueError) as error:
465 raise SnapshotCreateError(
466 "could not identify the GPU used by the snapshot process tree"
467 ) from error
468 if len(matches) != 1:
469 raise SnapshotCreateError(
470 "snapshot process tree must use exactly one identifiable GPU"
471 )
472 return matches.pop()
473
474 def _criu(
475 self,
476 action: str,
477 artifact: Path,
478 arguments: list[str],
479 ) -> None:
480 if self.plugin_dir is None:
481 raise RuntimeError("CRIU_CUDA_PLUGIN_DIR is required")
482 env = os.environ.copy()
483 env["PATH"] = f"{Path(self.cuda_checkpoint).parent}:{env['PATH']}"
484 env["VLLM_SNAPSHOT_IGNORED_FD_MAP"] = str(artifact / "ignored-nvidia-fds.tsv")
485 env["RIG_CRIU_IGNORED_FD_MAP"] = env["VLLM_SNAPSHOT_IGNORED_FD_MAP"]
486 command = [
487 *self._privileged(),
488 "env",
489 f"PATH={env['PATH']}",
490 f"VLLM_SNAPSHOT_IGNORED_FD_MAP={env['VLLM_SNAPSHOT_IGNORED_FD_MAP']}",
491 f"RIG_CRIU_IGNORED_FD_MAP={env['RIG_CRIU_IGNORED_FD_MAP']}",
492 self.criu,
493 action,
494 "-L",
495 str(self.plugin_dir),
496 *arguments,
497 ]
498 self._run(command)
499
500 def _record_child_log_size(self, artifact: Path) -> None:
501 child_log = artifact / "child.log"
502 size_path = artifact / "child.log.snapshot-size"
503 payload = f"{child_log.stat().st_size}\n".encode()
504 descriptor = os.open(
505 size_path,
506 os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
507 0o600,
508 )
509 with os.fdopen(descriptor, "wb") as output:
510 output.write(payload)
511 output.flush()
512 os.fsync(output.fileno())
513
514 def _link_remap_names(self) -> set[str]:
515 return {
516 path.name
517 for path in self.shm_dir.glob("link_remap.*")
518 if path.name.removeprefix("link_remap.").isdigit()
519 }
520
521 def _capture_link_remaps(self, artifact: Path, names: set[str]) -> None:
522 if not names:
523 return
524 destination_dir = artifact / "link-remaps"
525 destination_dir.mkdir(mode=0o700)
526 for name in sorted(names):
527 source = self.shm_dir / name
528 source_fd = os.open(
529 source,
530 os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0),
531 )
532 try:
533 metadata = os.fstat(source_fd)
534 if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != os.getuid():
535 raise SnapshotCreateError(f"invalid CRIU link remap: {source}")
536 destination_fd = os.open(
537 destination_dir / name,
538 os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
539 0o600,
540 )
541 with os.fdopen(destination_fd, "wb") as destination:
542 with os.fdopen(os.dup(source_fd), "rb") as contents:
543 shutil.copyfileobj(contents, destination)
544 destination.flush()
545 os.fsync(destination.fileno())
546 finally:
547 os.close(source_fd)
548 source.unlink()
549
550 def _rollback_link_remaps(self, created: dict[str, tuple[int, int]]) -> None:
551 for name, identity in created.items():
552 target = self.shm_dir / name
553 try:
554 metadata = target.lstat()
555 except FileNotFoundError:
556 continue
557 if (metadata.st_dev, metadata.st_ino) == identity:
558 target.unlink()
559
560 def _restore_link_remaps(self, artifact: Path) -> dict[str, tuple[int, int]]:
561 source_dir = artifact / "link-remaps"
562 if not source_dir.exists():
563 return {}
564 created: dict[str, tuple[int, int]] = {}
565 try:
566 for source in sorted(source_dir.iterdir()):
567 if (
568 not source.name.removeprefix("link_remap.").isdigit()
569 or source.is_symlink()
570 or not source.is_file()
571 ):
572 raise SnapshotRestoreError(f"invalid saved link remap: {source}")
573 payload = source.read_bytes()
574 target = self.shm_dir / source.name
575 try:
576 descriptor = os.open(
577 target,
578 os.O_WRONLY
579 | os.O_CREAT
580 | os.O_EXCL
581 | getattr(os, "O_NOFOLLOW", 0),
582 0o600,
583 )
584 except FileExistsError as error:
585 try:
586 descriptor = os.open(
587 target,
588 os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0),
589 )
590 except OSError:
591 raise SnapshotRestoreError(
592 f"conflicting CRIU link remap exists: {target}"
593 ) from error
594 with os.fdopen(descriptor, "rb") as existing:
595 metadata = os.fstat(existing.fileno())
596 if (
597 not stat.S_ISREG(metadata.st_mode)
598 or metadata.st_uid != os.getuid()
599 or existing.read() != payload
600 ):
601 raise SnapshotRestoreError(
602 f"conflicting CRIU link remap exists: {target}"
603 ) from error
604 continue
605 metadata = os.fstat(descriptor)
606 created[source.name] = (metadata.st_dev, metadata.st_ino)
607 with os.fdopen(descriptor, "wb") as output:
608 output.write(payload)
609 output.flush()
610 os.fsync(output.fileno())
611 except BaseException:
612 self._rollback_link_remaps(created)
613 raise
614 return created
615
616 def _reset_child_log(self, artifact: Path) -> None:
617 size_path = artifact / "child.log.snapshot-size"
618 descriptor = os.open(
619 size_path,
620 os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0),
621 )
622 with os.fdopen(descriptor, "r") as source:
623 size = int(source.read().strip())
624 if size < 0:
625 raise SnapshotRestoreError("captured child log size is invalid")
626
627 child_log = artifact / "child.log"
628 descriptor = os.open(
629 child_log,
630 os.O_WRONLY | getattr(os, "O_NOFOLLOW", 0),
631 )
632 try:
633 metadata = os.fstat(descriptor)
634 if not stat.S_ISREG(metadata.st_mode) or metadata.st_size < size:
635 raise SnapshotRestoreError("captured child log is invalid")
636 os.ftruncate(descriptor, size)
637 os.fsync(descriptor)
638 finally:
639 os.close(descriptor)
640
641 def dump(self, workdir: Path, inventory: ProcessInventory) -> None:
642 images = workdir / "images"
643 images.mkdir(mode=0o700)
644 fd_map = workdir / "ignored-nvidia-fds.tsv"
645 fd_map.touch(mode=0o600)
646 remaps_before = self._link_remap_names()
647 try:
648 self._criu(
649 "dump",
650 workdir,
651 [
652 "--tree",
653 str(inventory.root_pid),
654 "--images-dir",
655 str(images),
656 "--log-file",
657 "dump.log",
658 *_COMMON_CRIU_OPTIONS,
659 ],
660 )
661 self._capture_link_remaps(
662 workdir,
663 self._link_remap_names() - remaps_before,
664 )
665 except BaseException:
666 for name in self._link_remap_names() - remaps_before:
667 (self.shm_dir / name).unlink(missing_ok=True)
668 raise
669 self._record_child_log_size(workdir)
670
671 def verify_dead(self, inventory: ProcessInventory) -> None:
672 process = self._children.get(inventory.root_pid)
673 if process is not None:
674 process.wait(timeout=10)
675 for pid in inventory.process_tree:
676 try:
677 os.kill(pid, 0)
678 except ProcessLookupError:
679 continue
680 raise SnapshotCreateError(f"process survived CRIU dump: {pid}")
681 self._children.pop(inventory.root_pid, None)
682
683 def _version(self, command: list[str]) -> str:
684 try:
685 result = self._run(command, timeout=10)
686 except subprocess.CalledProcessError as error:
687 return (error.stdout or error.stderr).splitlines()[0]
688 return result.stdout.splitlines()[0] if result.stdout else "unknown"
689
690 def _sha256(self, path: Path) -> str:
691 digest = hashlib.sha256()
692 with path.open("rb") as source:
693 for chunk in iter(lambda: source.read(1024 * 1024), b""):
694 digest.update(chunk)
695 return digest.hexdigest()
696
697 def _plugin_identity(self) -> tuple[tuple[str, str], ...]:
698 if self.plugin_dir is None:
699 return ()
700 return tuple(
701 (f"snapshot_plugin_sha256:{path.name}", self._sha256(path))
702 for path in sorted(self.plugin_dir.glob("*.so"))
703 )
704
705 def _torch_identity(self) -> tuple[str, str]:
706 torch_version = importlib.metadata.version("torch")
707 torch_spec = importlib.util.find_spec("torch")
708 locations = torch_spec and torch_spec.submodule_search_locations
709 if not locations:
710 raise RuntimeError("installed torch package could not be located")
711 version_path = Path(next(iter(locations))) / "version.py"
712 # Parse the version file rather than importing torch: snapshot create's
713 # CLI path stays import-light (vllm/entrypoints/cli/snapshot.py).
714 syntax = ast.parse(version_path.read_text(), filename=str(version_path))
715 cuda_runtime = ""
716 for statement in syntax.body:
717 target = None
718 value = None
719 if isinstance(statement, ast.Assign) and len(statement.targets) == 1:
720 target = statement.targets[0]
721 value = statement.value
722 elif isinstance(statement, ast.AnnAssign):
723 target = statement.target
724 value = statement.value
725 if isinstance(target, ast.Name) and target.id == "cuda" and value:
726 parsed = ast.literal_eval(value)
727 cuda_runtime = str(parsed or "")
728 break
729 return torch_version, cuda_runtime
730
731 def _gpu_identity(self, gpu_uuid: str) -> tuple[str, str, str]:
732 output = self._run(
733 [
734 self.nvidia_smi,
735 "--query-gpu=name,uuid,driver_version",
736 "--format=csv,noheader,nounits",
737 f"--id={gpu_uuid}",
738 ]
739 ).stdout.strip()
740 name, uuid, driver = (item.strip() for item in output.split(",", 2))
741 return name, uuid, driver
742
743 def _artifact_bytes(self, workdir: Path) -> int:
744 return sum(path.stat().st_size for path in workdir.rglob("*") if path.is_file())
745
746 def _environment_identity(self) -> tuple[tuple[str, str], ...]:
747 prefixes = ("VLLM_", "CUDA_", "NCCL_", "TORCH_", "TRITON_")
748 environment = os.environ.copy()
749 environment.setdefault("VLLM_WORKER_MULTIPROC_METHOD", "spawn")
750 selected = tuple(
751 sorted(
752 (key, hashlib.sha256(key.encode() + b"\0" + value.encode()).hexdigest())
753 for key, value in environment.items()
754 if key.startswith(prefixes)
755 and key not in {"VLLM_API_KEY", "VLLM_SNAPSHOT_TIMEOUT_S"}
756 )
757 )
758 return selected + self._plugin_identity()
759
760 def make_manifest(
761 self,
762 args: argparse.Namespace,
763 engine_argv: tuple[str, ...],
764 inventory: ProcessInventory,
765 oracle: Oracle,
766 workdir: Path,
767 ) -> SnapshotManifest:
768 identity = self.current_identity(inventory.gpu_uuid)
769 redacted_argv = list(engine_argv)
770 redact_api_keys = False
771 for index, argument in enumerate(redacted_argv):
772 option, separator, _ = argument.partition("=")
773 normalized_option = option.replace("_", "-")
774 # Parsed argv can contain unambiguous long-option abbreviations.
775 if len(normalized_option) > 2 and "--api-key".startswith(normalized_option):
776 redact_api_keys = not separator
777 if separator:
778 redacted_argv[index] = f"{option}=***"
779 elif len(normalized_option) > 2 and "--hf-token".startswith(
780 normalized_option
781 ):
782 if separator:
783 redacted_argv[index] = f"{option}=***"
784 elif index + 1 < len(redacted_argv):
785 redacted_argv[index + 1] = "***"
786 redact_api_keys = False
787 elif argument.startswith("-"):
788 redact_api_keys = False
789 elif redact_api_keys:
790 redacted_argv[index] = "***"
791 engine_argv = tuple(redacted_argv)
792 revision = str(getattr(args, "revision", None) or "")
793 tokenizer_revision = str(getattr(args, "tokenizer_revision", None) or revision)
794 source_model = str(getattr(args, "model_tag", None) or args.model)
795 from vllm.config.model import get_served_model_name
796
797 served_model_name = get_served_model_name(
798 source_model, getattr(args, "served_model_name", None)
799 )
800 return SnapshotManifest(
801 schema_version=1,
802 boundary="post-engine-init-reloadable-state-released",
803 created_at=datetime.now(timezone.utc).isoformat(),
804 artifact_bytes=self._artifact_bytes(workdir),
805 **identity.model_dump(),
806 model=source_model,
807 served_model_name=str(served_model_name),
808 model_revision=revision,
809 tokenizer_revision=tokenizer_revision,
810 engine_argv=engine_argv,
811 process_tree=inventory.process_tree,
812 cuda_holders=inventory.cuda_holders,
813 external_cache_files=inventory.external_cache_files,
814 oracle_token_ids=oracle.token_ids,
815 oracle_text=oracle.text,
816 oracle_sampled_token_logprob=oracle.sampled_token_logprob,
817 )
818
819 def publish(self, workdir: Path, manifest: SnapshotManifest) -> None:
820 write_manifest_atomic(workdir, manifest)
821 _fsync_directory(workdir.parent)
822
823 def current_identity(self, gpu_uuid: str) -> SnapshotRuntimeIdentity:
824 gpu_name, gpu_uuid, driver_version = self._gpu_identity(gpu_uuid)
825 torch_version, cuda_runtime = self._torch_identity()
826 host_id = Path("/etc/machine-id").read_text().strip()
827 return SnapshotRuntimeIdentity(
828 vllm_version=importlib.metadata.version("vllm"),
829 python_version=platform.python_version(),
830 torch_version=torch_version,
831 cuda_runtime=cuda_runtime,
832 driver_version=driver_version,
833 criu_version=self._version([self.criu, "--version"]),
834 cuda_checkpoint_sha256=self._sha256(Path(self.cuda_checkpoint)),
835 kernel_release=platform.release(),
836 host_id=host_id,
837 gpu_name=gpu_name,
838 gpu_uuid=gpu_uuid,
839 environment=self._environment_identity(),
840 )
841
842 def _read_restored_pid(self, pidfile: Path) -> int:
843 try:
844 payload = self._run([*self._privileged(), "cat", str(pidfile)]).stdout
845 except Exception as error:
846 raise SnapshotRestoreError("restored PID file is missing") from error
847 match = re.fullmatch(r"([1-9][0-9]{0,9})\n?", payload)
848 if match is None:
849 raise SnapshotRestoreError("restored PID file is invalid")
850 restored_pid = int(match.group(1))
851 if restored_pid > 2**31 - 1:
852 raise SnapshotRestoreError("restored PID file is invalid")
853 return restored_pid
854
855 def _process_state(self, pid: int) -> tuple[int, int, int, int]:
856 payload = (Path("/proc") / str(pid) / "stat").read_text()
857 command_end = payload.rfind(")")
858 fields = payload[command_end + 2 :].split()
859 if command_end < 0 or len(fields) < 20:
860 raise ValueError(f"invalid process state for PID {pid}")
861 return (int(fields[1]), int(fields[2]), int(fields[3]), int(fields[19]))
862
863 def _process_states(self) -> dict[int, tuple[int, int, int, int]]:
864 states: dict[int, tuple[int, int, int, int]] = {}
865 for entry in Path("/proc").iterdir():
866 if not entry.name.isdigit():
867 continue
868 pid = int(entry.name)
869 try:
870 states[pid] = self._process_state(pid)
871 except (OSError, ValueError):
872 continue
873 return states
874
875 def _process_command(self, pid: int) -> tuple[str, ...]:
876 payload = (Path("/proc") / str(pid) / "cmdline").read_bytes()
877 return tuple(
878 os.fsdecode(argument) for argument in payload.split(b"\0") if argument
879 )
880
881 def _pidfd_open(self, pid: int) -> int:
882 pidfd_open = getattr(os, "pidfd_open", None)
883 if not callable(pidfd_open):
884 raise SnapshotRestoreError("snapshot requires Linux pidfd support")
885 return pidfd_open(pid)
886
887 def _pidfd_exited(self, pidfd: int) -> bool:
888 poller = select.poll()
889 poller.register(pidfd, select.POLLIN | select.POLLHUP | select.POLLERR)
890 return any(self._pidfd_event_is_exit(event) for _fd, event in poller.poll(0))
891
892 def _pidfd_event_is_exit(self, event: int) -> bool:
893 if event & (select.POLLERR | select.POLLNVAL):
894 raise SnapshotRestoreError("restored process pidfd poll failed")
895 return bool(event & (select.POLLIN | select.POLLHUP))
896
897 def _pin_restored_tree(
898 self,
899 artifact: Path,
900 process_tree: tuple[int, ...],
901 cuda_holders: tuple[int, ...],
902 ) -> None:
903 root_pid = process_tree[0]
904 expected_pids = set(process_tree)
905 expected_cuda_holders = set(cuda_holders)
906 if not expected_cuda_holders or not expected_cuda_holders.issubset(
907 expected_pids
908 ):
909 raise SnapshotRestoreError(
910 "captured CUDA holders are outside the captured process tree"
911 )
912 if root_pid in self._restored_processes:
913 raise SnapshotRestoreError("restored PID already has a pinned transaction")
914 handles: list[tuple[int, int]] = []
915 try:
916 for pid in process_tree:
917 handles.append((pid, self._pidfd_open(pid)))
918 handles_by_pid = dict(handles)
919 if any(self._pidfd_exited(pidfd) for _pid, pidfd in handles):
920 raise SnapshotRestoreError(
921 "restored process exited during verification"
922 )
923
924 root_state = self._process_state(root_pid)
925 if root_state[1:3] != (root_pid, root_pid):
926 raise SnapshotRestoreError(
927 "restored root is not the session and process-group leader"
928 )
929 command = self._process_command(root_pid)
930 release_file = str(artifact / "release.json")
931 if not any(
932 command[index : index + 2] == ("-m", "vllm.snapshot.server")
933 for index in range(len(command) - 1)
934 ) or not any(
935 command[index : index + 2] == ("--release-file", release_file)
936 for index in range(len(command) - 1)
937 ):
938 raise SnapshotRestoreError(
939 "restored root command does not match the snapshot barrier"
940 )
941 if self._process_state(root_pid) != root_state:
942 raise SnapshotRestoreError("restored PID changed during verification")
943 if self._pidfd_exited(handles[0][1]):
944 raise SnapshotRestoreError("restored root exited during verification")
945
946 first_states = self._process_states()
947 live_session = {
948 pid for pid, state in first_states.items() if state[2] == root_pid
949 }
950 if not expected_cuda_holders.issubset(live_session):
951 raise SnapshotRestoreError(
952 "restored session is missing captured CUDA holders"
953 )
954 if live_session != expected_pids:
955 raise SnapshotRestoreError(
956 "restored session does not match the captured process tree"
957 )
958 if first_states[root_pid] != root_state:
959 raise SnapshotRestoreError("restored PID changed during verification")
960 for pid in process_tree[1:]:
961 state = first_states[pid]
962 pidfd = handles_by_pid[pid]
963 if self._process_state(pid) != state or self._pidfd_exited(pidfd):
964 raise SnapshotRestoreError(
965 "restored process tree changed during verification"
966 )
967
968 second_states = self._process_states()
969 second_session = {
970 pid for pid, state in second_states.items() if state[2] == root_pid
971 }
972 if second_session != expected_pids or any(
973 second_states[pid] != first_states[pid] for pid in process_tree
974 ):
975 raise SnapshotRestoreError(
976 "restored process tree changed during verification"
977 )
978 if any(self._pidfd_exited(pidfd) for _pid, pidfd in handles):
979 raise SnapshotRestoreError(
980 "restored process exited during verification"
981 )
982 except BaseException as error:
983 _close_pidfds(handles)
984 if isinstance(error, SnapshotRestoreError):
985 raise
986 if not isinstance(error, Exception):
987 raise
988 raise SnapshotRestoreError(
989 "restored process tree could not be pinned"
990 ) from error
991 self._restored_processes[root_pid] = tuple(handles)
992
993 def _abort_failed_restore(
994 self,
995 artifact: Path,
996 root_pid: int,
997 staged_remaps: dict[str, tuple[int, int]],
998 primary: BaseException,
999 ) -> NoReturn:
1000 secondary: list[str] = []
1001 try:
1002 _write_json_atomic(
1003 artifact / "release.json", {"release": False}, overwrite=True
1004 )
1005 except BaseException as error:
1006 secondary.append(f"abort marker failed: {_error_detail(error)}")
1007 # The marker above is only a request. Terminate and wait for the tree we
1008 # own, before releasing the shared state it may still be using. A tree
1009 # that could not be pinned is not ours to signal.
1010 if root_pid in self._restored_processes:
1011 try:
1012 self.cleanup(root_pid)
1013 except BaseException as error:
1014 secondary.append(f"restore cleanup failed: {_error_detail(error)}")
1015 try:
1016 self._rollback_link_remaps(staged_remaps)
1017 except BaseException as error:
1018 secondary.append(f"remap rollback failed: {_error_detail(error)}")
1019 if secondary:
1020 raise SnapshotRestoreError(
1021 f"snapshot restore failed: {_error_detail(primary)}; "
1022 + "; ".join(secondary)
1023 ) from primary
1024 raise primary
1025
1026 def _verify_external_cache_files(self, manifest: SnapshotManifest) -> None:
1027 """Reject a rotated generated cache before CRIU reopens it by path.
1028
1029 Like the PID probe above this reports a stale host early rather than
1030 guarding it: CRIU stays authoritative for a cache replaced afterwards.
1031 """
1032 for path, fingerprint in manifest.external_cache_files:
1033 target = Path(path)
1034 if not target.is_file():
1035 raise SnapshotRestoreError(
1036 f"external cache file vanished since capture: {path}; "
1037 f"{_CACHE_REMEDY}"
1038 )
1039 if fingerprint in (_CACHE_LOCKED, _CACHE_UNREADABLE):
1040 continue
1041 try:
1042 current = self._sha256(target)
1043 except OSError as error:
1044 raise SnapshotRestoreError(
1045 f"external cache file is unreadable since capture: {path}; "
1046 f"{_CACHE_REMEDY}"
1047 ) from error
1048 if current != fingerprint:
1049 raise SnapshotRestoreError(
1050 f"external cache file changed since capture: {path}; "
1051 f"{_CACHE_REMEDY}"
1052 )
1053
1054 def restore(self, artifact: Path, manifest: SnapshotManifest) -> int:
1055 # This is necessarily a best-effort TOCTOU check. CRIU restore and the
1056 # rollback below remain authoritative if a PID is claimed after it.
1057 for pid in manifest.process_tree:
1058 try:
1059 os.kill(pid, 0)
1060 except ProcessLookupError:
1061 continue
1062 except PermissionError:
1063 pass
1064 except Exception as error:
1065 raise SnapshotRestoreError(
1066 f"captured PID availability probe failed: {pid}"
1067 ) from error
1068 raise SnapshotRestoreError(f"captured PID is already occupied: {pid}")
1069 self._verify_external_cache_files(manifest)
1070 expected_root_pid = manifest.process_tree[0]
1071 (artifact / "release.json").unlink(missing_ok=True)
1072 pidfile = artifact / "restored.pid"
1073 pidfile.unlink(missing_ok=True)
1074 self._reset_child_log(artifact)
1075 staged_remaps = self._restore_link_remaps(artifact)
1076 try:
1077 self._criu(
1078 "restore",
1079 artifact,
1080 [
1081 "--images-dir",
1082 str(artifact / "images"),
1083 "--log-file",
1084 "restore.log",
1085 *_COMMON_CRIU_OPTIONS,
1086 "--restore-detached",
1087 "--pidfile",
1088 str(pidfile),
1089 ],
1090 )
1091 # Pin before reading the PID file so that a failure between restore
1092 # and the read cannot orphan the tree.
1093 self._pin_restored_tree(
1094 artifact, manifest.process_tree, manifest.cuda_holders
1095 )
1096 restored_pid = self._read_restored_pid(pidfile)
1097 if restored_pid != expected_root_pid:
1098 raise SnapshotRestoreError(
1099 "restored PID does not match the captured process tree"
1100 )
1101 except BaseException as error:
1102 self._abort_failed_restore(
1103 artifact, expected_root_pid, staged_remaps, error
1104 )
1105 return restored_pid
1106
1107 def release(self, artifact: Path, host: str | None, port: int) -> None:
1108 if not 1 <= port <= 65535:
1109 raise SnapshotRestoreError(f"snapshot restore port is invalid: {port}")
1110 family = socket.AF_INET6 if host and ":" in host else socket.AF_INET
1111 with socket.socket(family=family, type=socket.SOCK_STREAM) as listener_probe:
1112 listener_probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
1113 try:
1114 listener_probe.bind((host or "", port))
1115 except OSError as error:
1116 address = f"{host or '0.0.0.0'}:{port}"
1117 raise SnapshotRestoreError(
1118 f"snapshot restore address is already in use: {address}"
1119 ) from error
1120
1121 _write_json_atomic(
1122 artifact / "release.json",
1123 {"release": True, "host": host, "port": port},
1124 overwrite=True,
1125 )
1126
1127 def _connect_host(self, host: str | None) -> str:
1128 if not host or host == "0.0.0.0":
1129 return "127.0.0.1"
1130 if host == "::":
1131 return "::1"
1132 return host
1133
1134 def wait_listener(self, root_pid: int, host: str | None, port: int) -> None:
1135 deadline = time.monotonic() + self.timeout_s
1136 connect_host = self._connect_host(host)
1137 while time.monotonic() < deadline:
1138 try:
1139 with socket.create_connection((connect_host, port), timeout=1):
1140 return
1141 except OSError as error:
1142 handles = self._restored_processes.get(root_pid)
1143 if handles is None or self._pidfd_exited(handles[0][1]):
1144 raise SnapshotRestoreError(
1145 "restored process exited before binding HTTP"
1146 ) from error
1147 time.sleep(0.1)
1148 raise SnapshotRestoreError("restored HTTP listener did not become ready")
1149
1150 def request_oracle(
1151 self, host: str | None, port: int, manifest: SnapshotManifest
1152 ) -> Oracle:
1153 connect_host = self._connect_host(host)
1154 url_host = f"[{connect_host}]" if ":" in connect_host else connect_host
1155 payload = json.dumps(
1156 {
1157 "model": manifest.served_model_name,
1158 "prompt": "The capital of France is",
1159 "min_tokens": 1,
1160 "max_tokens": 1,
1161 "temperature": 0,
1162 "seed": 0,
1163 "logprobs": 0,
1164 "return_token_ids": True,
1165 }
1166 ).encode()
1167 request = urllib.request.Request(
1168 f"http://{url_host}:{port}/v1/completions",
1169 data=payload,
1170 headers={"Content-Type": "application/json"},
1171 )
1172 with urllib.request.urlopen(request, timeout=120) as response:
1173 body = json.load(response)
1174 choice = body["choices"][0]
1175 try:
1176 return Oracle(
1177 token_ids=tuple(choice["token_ids"]),
1178 text=choice["text"],
1179 sampled_token_logprob=choice["logprobs"]["token_logprobs"][0],
1180 )
1181 except ValueError as error:
1182 raise SnapshotRestoreError(
1183 "snapshot HTTP canary must return exactly one finite token logprob"
1184 ) from error
1185 except (IndexError, KeyError, TypeError) as error:
1186 raise SnapshotRestoreError(
1187 "snapshot HTTP canary response is missing sampled token logprob"
1188 ) from error
1189
1190 def cleanup(self, root_pid: int) -> None:
1191 handles = self._restored_processes.get(root_pid)
1192 if handles is None:
1193 raise SnapshotRestoreError("no pinned restore transaction for cleanup")
1194 pidfd_send_signal = getattr(signal, "pidfd_send_signal", None)
1195 if not callable(pidfd_send_signal):
1196 raise SnapshotRestoreError("restored process cleanup is incomplete")
1197 try:
1198 for _pid, pidfd in handles:
1199 with suppress(ProcessLookupError):
1200 pidfd_send_signal(pidfd, signal.SIGTERM, None, 0)
1201 time.sleep(min(0.5, self.timeout_s))
1202 for _pid, pidfd in handles:
1203 with suppress(ProcessLookupError):
1204 pidfd_send_signal(pidfd, signal.SIGKILL, None, 0)
1205 except BaseException as error:
1206 raise SnapshotRestoreError(
1207 f"restored process cleanup is incomplete: {_error_detail(error)}"
1208 ) from error
1209
1210 poller = select.poll()
1211 for _pid, pidfd in handles:
1212 poller.register(pidfd, select.POLLIN | select.POLLHUP | select.POLLERR)
1213 pending = {pidfd for _pid, pidfd in handles}
1214 deadline = time.monotonic() + self.timeout_s
1215 while pending:
1216 remaining = deadline - time.monotonic()
1217 if remaining <= 0:
1218 raise SnapshotRestoreError("restored process cleanup is incomplete")
1219 for pidfd, event in poller.poll(max(1, int(remaining * 1000))):
1220 if not self._pidfd_event_is_exit(event):
1221 continue
1222 pending.discard(pidfd)
1223 with suppress(KeyError, OSError):
1224 poller.unregister(pidfd)
1225 self._restored_processes.pop(root_pid)
1226 _close_pidfds(handles)
1227
1228 def complete_restore(self, root_pid: int) -> None:
1229 handles = self._restored_processes.pop(root_pid, None)
1230 if handles is None:
1231 raise SnapshotRestoreError("no pinned restore transaction to complete")
1232 _close_pidfds(handles)
1233
1234 def abort_create(self, root_pid: int) -> None:
1235 process = self._children.get(root_pid)
1236 if process is None:
1237 return
1238 with suppress(ProcessLookupError):
1239 os.killpg(root_pid, signal.SIGKILL)
1240 try:
1241 process.wait(timeout=10)
1242 except subprocess.TimeoutExpired as error:
1243 raise SnapshotCreateError(
1244 f"snapshot child process group {root_pid} survived SIGKILL"
1245 ) from error
1246 self._children.pop(root_pid, None)