[Frontend] Add reusable TP1 initialized-engine snapshots (#51360)

Signed-off-by: Nils Matteson <nilsmatteson@icloud.com> Signed-off-by: Taneem Ibrahim <taneem.ibrahim@gmail.com> Signed-off-by: Andreas Karatzas <Andreas.Karatzas@amd.com> Co-authored-by: Taneem Ibrahim <taneem.ibrahim@gmail.com> Co-authored-by: elehayym <52448798+Yuzu23@users.noreply.github.com> Co-authored-by: Andreas Karatzas <akaratza@amd.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

Nils Matteson committed Sep 21, 2026 at 16:18 UTC 13058faa49f5e4a4d50e1ca562ba0c2fdd73c7bc
16 files changed +3773 -10
.buildkite/scripts/initialized-snapshot-e2e.sh new
+311
@@ -0,0 +1,311 @@
1 +#!/usr/bin/env bash
2 +# SPDX-License-Identifier: Apache-2.0
3 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
4 +set -Eeuo pipefail
5 +readonly MODEL="Qwen/Qwen3-0.6B"
6 +readonly MODEL_REVISION="c1899de289a04d12100db370d81485cdf75e47ca"
7 +readonly EXPECTED_TOKEN_ID="12095"
8 +readonly EXPECTED_TEXT=" Paris"
9 +readonly CLEANUP_RESERVE_S=300
10 +RUN_LIMIT_S="${SNAPSHOT_E2E_RUN_LIMIT_S:-3300}"
11 +[[ "$RUN_LIMIT_S" =~ ^[1-9][0-9]{2,3}$ ]] \
12 + || { echo "SNAPSHOT_E2E_RUN_LIMIT_S must be a bounded decimal integer" >&2; exit 1; }
13 +(( RUN_LIMIT_S >= CLEANUP_RESERVE_S + 60 && RUN_LIMIT_S <= 3300 )) \
14 + || { echo "SNAPSHOT_E2E_RUN_LIMIT_S must be between 360 and 3300" >&2; exit 1; }
15 +readonly RUN_LIMIT_S
16 +readonly HOST_HF_CACHE="${SNAPSHOT_E2E_HOST_HF_CACHE:-}"
17 +HF_CACHE_MOUNT=()
18 +if [[ -n "$HOST_HF_CACHE" ]]; then
19 + [[ "$HOST_HF_CACHE" == /* && -d "$HOST_HF_CACHE" ]] \
20 + || { echo "SNAPSHOT_E2E_HOST_HF_CACHE must be an existing absolute directory" >&2; exit 1; }
21 + HF_CACHE_MOUNT=(--mount "type=bind,source=$HOST_HF_CACHE,target=/e2e/hf,readonly")
22 + PREPARE_ARTIFACT_ROOT='install -d -m 0700 /e2e'
23 +else
24 + PREPARE_ARTIFACT_ROOT='mkdir -m 0700 /e2e && mkdir -m 0700 /e2e/hf'
25 +fi
26 +readonly PREPARE_ARTIFACT_ROOT
27 +if (( $# != 2 )); then
28 + echo "usage: $0 IMAGE_TAG BUILDKITE_COMMIT" >&2
29 + exit 2
30 +fi
31 +readonly IMAGE_TAG="$1"
32 +readonly EXPECTED_COMMIT="$2"
33 +readonly DEADLINE_EPOCH="$(( $(date +%s) + RUN_LIMIT_S ))"
34 +RUN_ID="" CONTAINER_NAME="" CONTAINER_ID=""
35 +GPU_UUID="" PORT_ONE="" PORT_TWO=""
36 +LINK_REMAP_BASELINE=""
37 +SNAPSHOT_PIDS=()
38 +die() {
39 + echo "initialized snapshot E2E: $*" >&2
40 + exit 1
41 +}
42 +
43 +run() {
44 + local label="$1" maximum_s="$2" available limit
45 + shift 2
46 + available="$((DEADLINE_EPOCH - $(date +%s) - CLEANUP_RESERVE_S))"
47 + (( available > 0 )) || die "no time remains before the cleanup reserve"
48 + (( available < maximum_s )) && limit="$available" || limit="$maximum_s"
49 + echo "--- ${label} (timeout ${limit}s)" >&2
50 + timeout --signal=TERM --kill-after=30s "${limit}s" "$@"
51 +}
52 +
53 +link_remaps() {
54 + find /dev/shm -maxdepth 1 -name 'link_remap.*' \
55 + -printf '%p|%D|%i|%m|%s\n' | sort
56 +}
57 +
58 +gpu_pids() {
59 + nvidia-smi --id="$GPU_UUID" --query-compute-apps=pid \
60 + --format=csv,noheader,nounits | sed '/^[[:space:]]*$/d'
61 +}
62 +
63 +port_listeners() {
64 + ss -H -ltn "sport = :$1"
65 +}
66 +
67 +state_clean() {
68 + local pid port output
69 + for pid in "${SNAPSHOT_PIDS[@]}"; do
70 + [[ ! -e "/proc/$pid" ]] || return 1
71 + done
72 + output="$(gpu_pids)" || return 1
73 + [[ -z "$output" ]] || return 1
74 + output="$(link_remaps)" || return 1
75 + [[ "$output" == "$LINK_REMAP_BASELINE" ]] || return 1
76 + for port in "$PORT_ONE" "$PORT_TWO"; do
77 + [[ -n "$port" ]] || continue
78 + output="$(port_listeners "$port")" || return 1
79 + [[ -z "$output" ]] || return 1
80 + done
81 +}
82 +
83 +wait_clean() {
84 + local _attempt
85 + for ((_attempt = 0; _attempt < 60; _attempt++)); do
86 + state_clean && return 0
87 + sleep 1
88 + done
89 + return 1
90 +}
91 +
92 +container_names() {
93 + timeout 15s docker container ls --all --format '{{.Names}}'
94 +}
95 +
96 +cleanup() {
97 + local failed=0 names="" actual="" expected=""
98 + if [[ -n "$CONTAINER_NAME" ]]; then
99 + names="$(container_names 2>/dev/null)" || return 1
100 + if grep -Fxq "$CONTAINER_NAME" <<< "$names"; then
101 + actual="$(docker container inspect \
102 + --format '{{.Id}}|{{index .Config.Labels "ai.vllm.snapshot.e2e.run"}}' \
103 + "$CONTAINER_NAME" 2>/dev/null)" || return 1
104 + expected="${CONTAINER_ID:-${actual%%|*}}|$RUN_ID"
105 + if [[ "$actual" != "$expected" ]]; then
106 + echo "refusing to remove a container with mismatched identity" >&2
107 + failed=1
108 + else
109 + timeout 60s docker rm --force "$CONTAINER_NAME" >/dev/null \
110 + || failed=1
111 + fi
112 + fi
113 + names="$(container_names 2>/dev/null)" || return 1
114 + grep -Fxq "$CONTAINER_NAME" <<< "$names" && failed=1
115 + fi
116 + if [[ -n "$PORT_TWO" ]] && ! wait_clean; then
117 + echo "process, GPU, port, or link-remap residue remained" >&2
118 + failed=1
119 + fi
120 + return "$failed"
121 +}
122 +
123 +on_exit() {
124 + local status=$?
125 + trap - EXIT INT TERM
126 + set +e
127 + cleanup || status=1
128 + exit "$status"
129 +}
130 +
131 +trap on_exit EXIT
132 +trap 'exit 130' INT
133 +trap 'exit 143' TERM
134 +
135 +parse_inspect() {
136 + timeout 30s python3 -c '
137 +import json, math, sys
138 +data = json.load(sys.stdin)
139 +model, revision, gpu_uuid, token_id, text = sys.argv[1:]
140 +expected = {
141 + "boundary": "post-engine-init-reloadable-state-released",
142 + "model": model, "model_revision": revision,
143 + "gpu_uuid": gpu_uuid, "oracle_token_ids": [int(token_id)],
144 + "oracle_text": text,
145 +}
146 +bad = next((field for field, value in expected.items() if data.get(field) != value), None)
147 +if bad:
148 + raise SystemExit(f"inspect field {bad} mismatch: {data.get(bad)!r}")
149 +logprob = data.get("oracle_sampled_token_logprob")
150 +if type(logprob) is not float or not math.isfinite(logprob):
151 + raise SystemExit("inspect logprob is not finite")
152 +pids = data.get("process_tree")
153 +if not pids:
154 + raise SystemExit("inspect process_tree is empty")
155 +for pid in pids:
156 + print(f"pid={pid}")
157 +print(f"oracle_logprob={logprob}")
158 +' "$MODEL" "$MODEL_REVISION" "$GPU_UUID" "$EXPECTED_TOKEN_ID" \
159 + "$EXPECTED_TEXT"
160 +}
161 +
162 +assert_oracle() {
163 + local port="$1" ordinal="$2" response
164 + response="$(run "restore ${ordinal} public completion" 120 curl \
165 + --fail --silent --show-error --max-time 120 \
166 + --header 'Content-Type: application/json' \
167 + --data "{\"model\":\"${MODEL}\",\"prompt\":\"The capital of France is\",\"temperature\":0,\"seed\":0,\"min_tokens\":1,\"max_tokens\":1,\"logprobs\":0,\"return_token_ids\":true}" \
168 + "http://127.0.0.1:${port}/v1/completions")"
169 + printf '%s' "$response" | timeout 30s python3 -c '
170 +import json, math, sys
171 +choice = json.load(sys.stdin)["choices"][0]
172 +token_id, text, expected, ordinal = sys.argv[1:]
173 +values = choice["logprobs"]["token_logprobs"]
174 +actual = float(values[0]) if len(values) == 1 else math.nan
175 +if (choice["token_ids"] != [int(token_id)] or choice["text"] != text
176 + or not math.isfinite(actual) or abs(actual - float(expected)) > 1e-3):
177 + raise SystemExit(f"public oracle mismatch: {choice!r}")
178 +print(f"restore={ordinal} token_id={token_id} text={text!r} logprob={actual}")
179 +' "$EXPECTED_TOKEN_ID" "$EXPECTED_TEXT" "$INSPECT_LOGPROB" "$ordinal"
180 +}
181 +
182 +[[ "$(uname -s)" == "Linux" && "$(uname -m)" == "x86_64" ]] \
183 + || die "host must be Linux x86_64"
184 +[[ "$EXPECTED_COMMIT" =~ ^[0-9a-f]{40}$ ]] \
185 + || die "BUILDKITE_COMMIT must be a full lowercase commit hash"
186 +[[ "$(< /proc/sys/kernel/io_uring_disabled)" == "2" ]] \
187 + || die "kernel.io_uring_disabled must already equal 2"
188 +run "Docker preflight" 60 docker info >/dev/null
189 +
190 +VISIBLE_GPU="${NVIDIA_VISIBLE_DEVICES:-}"
191 +if [[ -z "$VISIBLE_GPU" || "$VISIBLE_GPU" == "all" || "$VISIBLE_GPU" == "void" ]]; then
192 + VISIBLE_GPU="${CUDA_VISIBLE_DEVICES:-}"
193 +fi
194 +if [[ -z "$VISIBLE_GPU" || "$VISIBLE_GPU" == "all" || "$VISIBLE_GPU" == "void" ]]; then
195 + CDI_GPU="${BUILDKITE_PLUGIN_DOCKER_DEVICES_0:-}"
196 + if [[ "$CDI_GPU" == nvidia.com/gpu=* ]]; then
197 + VISIBLE_GPU="${CDI_GPU#nvidia.com/gpu=}"
198 + else
199 + VISIBLE_GPU=""
200 + fi
201 +fi
202 +[[ -n "$VISIBLE_GPU" && "$VISIBLE_GPU" != all && "$VISIBLE_GPU" != void \
203 + && "$VISIBLE_GPU" != *","* ]] \
204 + || die "the runner must expose exactly one selected GPU"
205 +GPU_ROW="$(nvidia-smi --id="$VISIBLE_GPU" \
206 + --query-gpu=name,uuid,mig.mode.current,memory.total \
207 + --format=csv,noheader,nounits)"
208 +IFS=',' read -r GPU_NAME GPU_UUID MIG_MODE GPU_MEMORY_MIB <<< "$GPU_ROW"
209 +for variable in GPU_NAME GPU_UUID MIG_MODE GPU_MEMORY_MIB; do
210 + printf -v "$variable" '%s' "${!variable#"${!variable%%[![:space:]]*}"}"
211 +done
212 +[[ "$GPU_NAME" == *H200* && "$GPU_UUID" == GPU-* && "$MIG_MODE" == Disabled ]] \
213 + || die "selected device is not a full non-MIG H200: $GPU_ROW"
214 +(( GPU_MEMORY_MIB >= 130000 )) || die "selected H200 does not expose full memory"
215 +GPU_PIDS="$(gpu_pids)" || die "failed to inspect the selected GPU"
216 +[[ -z "$GPU_PIDS" ]] || die "selected H200 is not idle"
217 +
218 +RUN_ID="snapshot-e2e-$(date +%s)-$$-$RANDOM"
219 +CONTAINER_NAME="vllm-$RUN_ID"
220 +read -r PORT_ONE PORT_TWO < <(python3 -c 'import socket; s=[socket.socket(),socket.socket()]; [x.bind(("127.0.0.1",0)) for x in s]; print(*(x.getsockname()[1] for x in s))')
221 +LINK_REMAP_BASELINE="$(link_remaps)"
222 +
223 +run "pull exact candidate image" 900 docker pull --platform linux/amd64 \
224 + "$IMAGE_TAG" >/dev/null
225 +IFS='|' read -r IMAGE_ID IMAGE_PLATFORM OCI_COMMIT VLLM_COMMIT < <(
226 + docker image inspect \
227 + --format '{{.Id}}|{{.Os}}/{{.Architecture}}|{{index .Config.Labels "org.opencontainers.image.revision"}}|{{index .Config.Labels "ai.vllm.build.commit"}}' \
228 + "$IMAGE_TAG"
229 +)
230 +[[ "$IMAGE_ID" =~ ^sha256:[0-9a-f]{64}$ && "$IMAGE_PLATFORM" == linux/amd64 ]] \
231 + || die "candidate image identity is invalid"
232 +[[ "$OCI_COMMIT" == "$EXPECTED_COMMIT" && "$VLLM_COMMIT" == "$EXPECTED_COMMIT" ]] \
233 + || die "candidate image commit labels do not match"
234 +CONTAINER_ID="$(run "start exact candidate container" 90 docker run --detach \
235 + --name "$CONTAINER_NAME" --label "ai.vllm.snapshot.e2e.run=$RUN_ID" \
236 + --gpus "device=$GPU_UUID" --user 0 --privileged --pid=host --ipc=host \
237 + --network=host --env "CUDA_VISIBLE_DEVICES=$GPU_UUID" --env HF_HUB_DISABLE_TELEMETRY=1 \
238 + --env VLLM_NO_USAGE_STATS=1 --env VLLM_USE_V2_MODEL_RUNNER=1 \
239 + ${HF_CACHE_MOUNT[@]+"${HF_CACHE_MOUNT[@]}"} \
240 + --entrypoint sleep "$IMAGE_ID" infinity)"
241 +[[ "$CONTAINER_ID" =~ ^[0-9a-f]{64}$ ]] || die "invalid container ID"
242 +run "prepare private artifact root" 60 docker exec "$CONTAINER_NAME" sh -c \
243 + "$PREPARE_ARTIFACT_ROOT && df -Pk / | awk 'NR == 2 && \$4 >= 10485760 {ok=1} END {exit !ok}'"
244 +run "verify snapshot runtime" 60 docker exec "$CONTAINER_NAME" sh -c \
245 + 'criu --version; test -x /usr/local/sbin/cuda-checkpoint; test -f /usr/local/lib/criu/cuda_plugin.so'
246 +run "verify selected CUDA device" 60 docker exec "$CONTAINER_NAME" python3 -c '
247 +import sys, torch
248 +if torch.cuda.device_count() != 1:
249 + raise SystemExit("expected exactly one visible CUDA device")
250 +actual = str(torch.cuda.get_device_properties(0).uuid).removeprefix("GPU-")
251 +if actual != sys.argv[1].removeprefix("GPU-"):
252 + raise SystemExit(f"CUDA device {actual} does not match {sys.argv[1]}")
253 +print(f"selected_cuda_uuid=GPU-{actual}")
254 +' "$GPU_UUID"
255 +OFFLINE_EXEC=(docker exec --env HF_HOME=/e2e/hf --env HF_HUB_OFFLINE=1 \
256 + --env TRANSFORMERS_OFFLINE=1 --env VLLM_SNAPSHOT_TIMEOUT_S=900 \
257 + "$CONTAINER_NAME")
258 +if [[ -n "$HOST_HF_CACHE" ]]; then
259 + run "validate pinned model in read-only host cache" 120 \
260 + "${OFFLINE_EXEC[@]}" python3 -c \
261 + 'from pathlib import Path; from huggingface_hub import snapshot_download; import sys; p=snapshot_download(repo_id=sys.argv[1], revision=sys.argv[2], local_files_only=True); assert Path(p).name == sys.argv[2]' \
262 + "$MODEL" "$MODEL_REVISION"
263 +else
264 + run "prefetch pinned public model" 900 docker exec --env HF_HOME=/e2e/hf \
265 + "$CONTAINER_NAME" python3 -c \
266 + 'from pathlib import Path; from huggingface_hub import snapshot_download; import sys; p=snapshot_download(repo_id=sys.argv[1], revision=sys.argv[2]); assert Path(p).name == sys.argv[2]' \
267 + "$MODEL" "$MODEL_REVISION"
268 +fi
269 +
270 +run "create compact initialized snapshot" 1200 "${OFFLINE_EXEC[@]}" \
271 + vllm snapshot create "$MODEL" --revision "$MODEL_REVISION" \
272 + --tokenizer-revision "$MODEL_REVISION" --snapshot-dir /e2e/artifact \
273 + --dtype float16 --max-model-len 512 --gpu-memory-utilization 0.50
274 +wait_clean || die "create left process, GPU, port, or link-remap residue"
275 +
276 +INSPECT_JSON="$(run "inspect compact initialized snapshot" 120 \
277 + "${OFFLINE_EXEC[@]}" vllm snapshot inspect /e2e/artifact)"
278 +INSPECT_RECEIPT="$(printf '%s' "$INSPECT_JSON" | parse_inspect)"
279 +mapfile -t SNAPSHOT_PIDS < <(sed -n 's/^pid=//p' <<< "$INSPECT_RECEIPT")
280 +INSPECT_LOGPROB="$(sed -n 's/^oracle_logprob=//p' <<< "$INSPECT_RECEIPT")"
281 +(( ${#SNAPSHOT_PIDS[@]} > 0 )) || die "inspect returned no PIDs"
282 +state_clean || die "create left recorded state alive"
283 +echo "$INSPECT_RECEIPT"
284 +
285 +run "restore compact snapshot 1" 900 "${OFFLINE_EXEC[@]}" \
286 + vllm snapshot restore /e2e/artifact --host 127.0.0.1 --port "$PORT_ONE"
287 +PORT_LISTENERS="$(port_listeners "$PORT_ONE")" || die "failed to inspect restore 1 port"
288 +[[ -n "$PORT_LISTENERS" ]] || die "restore 1 did not bind its port"
289 +GPU_PIDS="$(gpu_pids)" || die "failed to inspect the selected GPU"
290 +[[ -n "$GPU_PIDS" ]] || die "restore 1 did not engage the selected GPU"
291 +assert_oracle "$PORT_ONE" 1
292 +run "stop container after restore 1" 60 docker stop --time 20 "$CONTAINER_NAME" >/dev/null
293 +[[ "$(docker container inspect --format '{{.Id}}' "$CONTAINER_NAME")" == "$CONTAINER_ID" ]] \
294 + || die "docker stop replaced the container"
295 +wait_clean || die "restore 1 teardown left residue"
296 +
297 +run "start the same container" 60 docker start "$CONTAINER_NAME" >/dev/null
298 +[[ "$(docker container inspect --format '{{.Id}}' "$CONTAINER_NAME")" == "$CONTAINER_ID" ]] \
299 + || die "docker start replaced the container"
300 +run "restore compact snapshot 2" 900 "${OFFLINE_EXEC[@]}" \
301 + vllm snapshot restore /e2e/artifact --host 127.0.0.1 --port "$PORT_TWO"
302 +PORT_LISTENERS="$(port_listeners "$PORT_TWO")" || die "failed to inspect restore 2 port"
303 +[[ -n "$PORT_LISTENERS" ]] || die "restore 2 did not bind its port"
304 +GPU_PIDS="$(gpu_pids)" || die "failed to inspect the selected GPU"
305 +[[ -n "$GPU_PIDS" ]] || die "restore 2 did not engage the selected GPU"
306 +assert_oracle "$PORT_TWO" 2
307 +run "stop container after restore 2" 60 docker stop --time 20 "$CONTAINER_NAME" >/dev/null
308 +wait_clean || die "restore 2 teardown left residue"
309 +cleanup || die "exact cleanup failed"
310 +echo "container_id=$CONTAINER_ID restore_1_port=$PORT_ONE restore_2_port=$PORT_TWO"
311 +echo "result=PASS"
.buildkite/test_areas/entrypoints.yaml
+20
@@ -2,6 +2,26 @@ group: Entrypoints
2 depends_on:
3 - image-build
4 steps:
5 +- label: ":nvidia: (H200) Initialized Snapshot E2E"
6 + key: initialized-snapshot-e2e
7 + device: h200
8 + num_devices: 1
9 + no_plugin: true
10 + optional: true
11 + timeout_in_minutes: 60
12 + working_dir: "."
13 + env:
14 + SNAPSHOT_E2E_RUN_LIMIT_S: "1200"
15 + source_file_dependencies:
16 + - .buildkite/scripts/initialized-snapshot-e2e.sh
17 + - .buildkite/test_areas/entrypoints.yaml
18 + - docker/Dockerfile
19 + - tools/install_snapshot_runtime.sh
20 + - vllm/entrypoints/cli/
21 + - vllm/snapshot/
22 + commands:
23 + - bash .buildkite/scripts/initialized-snapshot-e2e.sh "$IMAGE_TAG" "$BUILDKITE_COMMIT"
24 +
25 - label: ":nvidia: (H200 MIG 35GB) Entrypoints Unit"
26 device: h200_35gb
27 key: entrypoints-unit-tests
docker/Dockerfile
+16
@@ -735,6 +735,8 @@ RUN --mount=type=cache,target=/opt/uv/cache \
735 FROM ${FINAL_BASE_IMAGE} AS vllm-runtime-base
736
737 ARG INSTALL_RUBIN_PRERELEASE
738 +ARG TARGETPLATFORM
739 +ARG max_jobs=2
740 ARG CUDA_VERSION
741 ARG PYTHON_VERSION
742 ARG NCCL_VERSION
@@ -854,6 +856,20 @@ RUN if [ "${INSTALL_RUBIN_PRERELEASE}" = "true" ]; then \
856 && ldconfig; \
857 fi
858
859 +# Install the initialized-snapshot runtime on the architecture supported by
860 +# `vllm snapshot`. CRIU and its CUDA plugin are built from the same pinned
861 +# source. The `cuda-checkpoint`-compatible helper is built from NVIDIA Dynamo's
862 +# Apache-2.0 source against the public CUDA Driver checkpoint APIs instead of
863 +# redistributing NVIDIA's proprietary cuda-checkpoint binary.
864 +COPY tools/install_snapshot_runtime.sh /usr/local/bin/install_snapshot_runtime.sh
865 +RUN if [ "${TARGETPLATFORM}" = "linux/amd64" ]; then \
866 + MAX_JOBS="${max_jobs}" /usr/local/bin/install_snapshot_runtime.sh; \
867 + else \
868 + echo "Initialized snapshots are not installed for ${TARGETPLATFORM}"; \
869 + fi
870 +
871 +ENV CRIU_CUDA_PLUGIN_DIR=/usr/local/lib/criu
872 +
873 # Install uv for faster pip installs
874 RUN python3 -m pip install uv
875
docs/features/initialized_snapshots.md new
+152
@@ -0,0 +1,152 @@
1 +# Initialized engine snapshots
2 +
3 +Initialized engine snapshots are an experimental way to trade local disk space
4 +and host privileges for a faster vLLM activation. Snapshot creation initializes
5 +the engine and records deterministic generation output before CRIU captures the
6 +process tree and CUDA state. Restore validates the saved environment, restores
7 +the engine, binds the HTTP server, and reproduces the recorded token and sampled
8 +token log probability before returning.
9 +
10 +This path is intended for repeatedly activating the same model and engine
11 +configuration on the same machine. It is not a portable model artifact.
12 +
13 +## Requirements
14 +
15 +Snapshots currently require:
16 +
17 +- Linux on x86-64 with one NVIDIA GPU.
18 +- TP1 with one unauthenticated plaintext HTTP server. Other parallel sizes,
19 + TLS, middleware, Unix sockets, and speculative decoding are unsupported.
20 +- Because snapshot mode requires an unauthenticated plaintext HTTP server, use
21 + a trusted host or network boundary, or external controls. See the vLLM
22 + [Security guide](../usage/security.md).
23 +- [CRIU](https://github.com/checkpoint-restore/criu), its CUDA plugin, a
24 + `cuda-checkpoint`-compatible helper, and `nvidia-smi` on `PATH`.
25 +- Root or passwordless `sudo` for CRIU.
26 +- `io_uring` disabled before launch because CRIU cannot dump it. Use
27 + `kernel.io_uring_disabled=1` for an unprivileged process. A process running
28 + as root, including the `docker exec` flow below, bypasses `=1`, so use `=2`
29 + host-wide.
30 +- No established TCP connection to a peer outside the captured process tree.
31 + Current Hugging Face hub clients hold their connections for the process
32 + lifetime, so download the model in a separate step and run create with
33 + `HF_HUB_OFFLINE=1`, as the quickstart below does.
34 +- A remote model ID and an immutable 40-character `--revision`. Local model
35 + directories and mutable revisions are not supported.
36 +- Enough disk for the artifact, with the same installed vLLM package, model
37 + files, container filesystem, and generated-cache paths available at restore.
38 + Generated-cache files are not copied into the artifact. The manifest
39 + fingerprints the ones the captured tree holds open, so restore fails early
40 + and names the file when one is removed or replaced. Only open descriptors
41 + are recorded. A library the tree mapped and then closed is not fingerprinted,
42 + and CRIU still reopens it by path, so replacing that file permanently
43 + invalidates the artifact without an early error.
44 +
45 +The official `vllm/vllm-openai` Linux x86-64 image includes the snapshot
46 +runtime. It still requires a compatible host driver, kernel, and privileges.
47 +Arm64 images omit it. Source installs must set `CRIU_CUDA_PLUGIN_DIR` to the
48 +directory containing `cuda_plugin.so`.
49 +
50 +Run snapshot commands with `docker exec` inside a long-lived container. Restore
51 +hands the API server off as a detached process, so a one-shot container would
52 +stop that server when its PID 1 exits. Snapshot preflight requires every
53 +component of the artifact path to be owned by the invoking user or root, with
54 +the directory itself at mode 0700, and the commands below run as root inside
55 +the official image, so the bind-mounted host directory is created with `sudo`
56 +and root ownership. The model downloads in its own step so the captured tree
57 +holds no hub connection, and create runs offline. This example also keeps the
58 +container filesystem and `/dev/shm` namespace stable for the lifetime of the
59 +artifact:
60 +
61 +```bash
62 +sudo sysctl kernel.io_uring_disabled=2
63 +
64 +snapshot_root="$(pwd)/vllm-snapshots"
65 +sudo install -d -m 0700 -o root -g root "${snapshot_root}"
66 +
67 +docker run --detach --name vllm-snapshot \
68 + --gpus all \
69 + --privileged \
70 + --pid=host \
71 + --ipc=host \
72 + --network=host \
73 + --mount "type=bind,source=${snapshot_root},target=/snapshots" \
74 + --entrypoint sleep \
75 + vllm/vllm-openai:latest infinity
76 +
77 +docker exec vllm-snapshot hf download Qwen/Qwen3-0.6B \
78 + --revision c1899de289a04d12100db370d81485cdf75e47ca
79 +
80 +docker exec -e HF_HUB_OFFLINE=1 vllm-snapshot vllm snapshot create Qwen/Qwen3-0.6B \
81 + --snapshot-dir /snapshots/qwen3-0.6b \
82 + --revision c1899de289a04d12100db370d81485cdf75e47ca \
83 + --dtype float16 \
84 + --max-model-len 512
85 +
86 +docker exec vllm-snapshot vllm snapshot inspect /snapshots/qwen3-0.6b
87 +
88 +docker exec vllm-snapshot vllm snapshot restore \
89 + /snapshots/qwen3-0.6b --host 0.0.0.0 --port 8000
90 +```
91 +
92 +Keep that container and its mounts available while the snapshot is in use.
93 +Stop and remove it only after the restored API server is no longer needed.
94 +
95 +Create initializes the engine, records a one-token canary, releases and reloads
96 +weights and KV cache to rehearse restore, then releases them again for capture.
97 +The manifest is published only after CRIU completes and the source tree stops.
98 +Creation is offline preparation and is not part of restore latency.
99 +
100 +The private `0700` artifact contains process memory, CUDA state, engine
101 +arguments, compatibility identity, and canary output; its manifest is `0600`.
102 +Literal API keys and Hugging Face tokens are redacted from the manifest's engine
103 +arguments. The manifest records selected environment names plus deterministic
104 +name-bound fingerprints, not their values. The protected CRIU artifact can
105 +still contain process secrets, so treat it as sensitive data. Restore reloads
106 +model files and KV cache before binding HTTP, then reproduces the canary or
107 +tears down the restored tree. The inspect command prints that identity and
108 +canary without executing the saved process.
109 +
110 +## Restore behavior
111 +
112 +Restore fails before CRIU runs if the saved identity does not match the current
113 +host. It does not silently fall back to ordinary startup. After CRIU restores
114 +the process tree, vLLM releases the saved engine to bind the requested HTTP
115 +address and checks the first generated token and sampled-token log probability
116 +against the snapshot canary. The command returns only after that check passes.
117 +The restored API server continues to run as a detached process.
118 +
119 +The pre-release port probe is best-effort only. It neither reserves the port
120 +nor authenticates the listener that appears afterward.
121 +
122 +Rollback terminates and waits for the restored tree after process identity
123 +verification succeeds. If verification fails, vLLM writes an abort marker for
124 +the snapshot server instead of signaling unverified PIDs. If that server has
125 +already exited, surviving engine processes can require operator cleanup.
126 +Verify that the previous tree has stopped before retrying; do not kill a process
127 +solely because its PID appears in the manifest, since PIDs can be reused.
128 +
129 +The artifact is reusable after its previous restored tree stops. Only one
130 +snapshot or external CRIU operation may use a shared `/dev/shm` mount at a time.
131 +
132 +## Tradeoffs and limitations
133 +
134 +- Creation has its own latency and briefly requires the full engine. Artifact
135 + size can approach the captured process and GPU memory.
136 +- Artifacts are not guaranteed to survive a power loss. The manifest is
137 + fsynced, but publication does not explicitly flush all captured payload files
138 + and directories. Recreate the artifact after an unclean host shutdown.
139 +- Restore currently requires the same host, GPU, driver, kernel, Python,
140 + PyTorch, installed vLLM version, model revision, engine arguments,
141 + selected environment variables, and CRIU plugin binaries.
142 +- Only dense float16 TP1 has been validated. Other model formats depend on their
143 + existing sleep level 2 reload support; NCCL state is not restored.
144 +- CRIU support varies by kernel and driver. Preserve package, library, model,
145 + and generated-cache paths for the artifact lifetime.
146 +- A snapshot can include application secrets or request state present in the
147 + process. Build it before traffic and protect it like process memory.
148 +- Any feature that opens an external connection must close it before capture.
149 +
150 +For a lower-complexity option that retains a live process, see
151 +[Sleep mode](sleep_mode.md). Sleep mode and initialized snapshots retain
152 +different amounts of state and have different idle resource costs.
tests/entrypoints/launchers/test_launch_cli.py
+1042 -1
@@ -3,15 +3,48 @@
3 """Unit tests for the `vllm launch` CLI subcommand."""
4
5 import argparse
6 -from unittest.mock import patch
6 +import hashlib
7 +import json
8 +import os
9 +import signal
10 +import subprocess
11 +import sys
12 +from contextlib import asynccontextmanager
13 +from pathlib import Path
14 +from typing import Any
15 +from unittest.mock import create_autospec, patch
16
17 import pytest
18
19 +import vllm.snapshot.runtime as snapshot_runtime
20 +import vllm.snapshot.server as snapshot_server
21 +from vllm.entrypoints.cli import snapshot as snapshot_cli
22 from vllm.entrypoints.cli.launch import (
23 LaunchSubcommand,
24 RenderSubcommand,
25 cmd_init,
26 )
27 +from vllm.snapshot.controller import (
28 + LocalSnapshotTools,
29 + SnapshotCreateError,
30 + SnapshotRestoreError,
31 + _child_engine_argv,
32 + create_snapshot,
33 + restore_snapshot,
34 +)
35 +from vllm.snapshot.manifest import (
36 + SnapshotCompatibilityError,
37 + SnapshotManifest,
38 + SnapshotRuntimeIdentity,
39 + SnapshotSecurityError,
40 + read_manifest,
41 + validate_artifact_root,
42 + validate_identity,
43 + write_manifest_atomic,
44 +)
45 +from vllm.snapshot.runtime import ProcessInventory, _decode_endpoint, _TcpSocketRecord
46 +from vllm.snapshot.server import SnapshotCanaryError
47 +from vllm.snapshot.types import Oracle, oracles_match
48 from vllm.utils.argparse_utils import FlexibleArgumentParser
49
50
@@ -109,3 +142,1011 @@ def test_launch_registered_in_main():
142 assert hasattr(launch_module, "cmd_init")
143 subcmds = launch_module.cmd_init()
144 assert any(s.name == "launch" for s in subcmds)
145 +
146 +
147 +_MODEL_REVISION = "c1899de289a04d12100db370d81485cdf75e47ca"
148 +
149 +
150 +def _run_cli(argv: list[str]) -> None:
151 + from vllm.entrypoints.cli import main as cli_main
152 +
153 + with patch.object(sys, "argv", ["vllm", *argv]):
154 + cli_main.main()
155 +
156 +
157 +def test_snapshot_environment_contract(
158 + tmp_path: Path,
159 + monkeypatch: pytest.MonkeyPatch,
160 + capsys: pytest.CaptureFixture[str],
161 +):
162 + from vllm.entrypoints.serve.utils import api_utils
163 +
164 + monkeypatch.setattr(api_utils, "cli_env_setup", pytest.fail)
165 + secret = "snapshot-secret"
166 + monkeypatch.setenv("VLLM_API_KEY", secret)
167 + monkeypatch.setenv("VLLM_USER_SETTING", secret)
168 + monkeypatch.setenv("VLLM_WORKER_MULTIPROC_METHOD", secret)
169 + environment = dict(LocalSnapshotTools()._environment_identity())
170 + assert (
171 + environment["VLLM_USER_SETTING"]
172 + == hashlib.sha256(b"VLLM_USER_SETTING\0snapshot-secret").hexdigest()
173 + )
174 + assert (
175 + environment["VLLM_USER_SETTING"] != environment["VLLM_WORKER_MULTIPROC_METHOD"]
176 + )
177 + assert secret not in str(environment)
178 + assert "VLLM_API_KEY" not in environment
179 +
180 + monkeypatch.delenv("VLLM_WORKER_MULTIPROC_METHOD")
181 + implicit_worker_default = dict(LocalSnapshotTools()._environment_identity())
182 + monkeypatch.setenv("VLLM_WORKER_MULTIPROC_METHOD", "spawn")
183 + assert dict(LocalSnapshotTools()._environment_identity()) == implicit_worker_default
184 +
185 + monkeypatch.setenv("VLLM_USER_SETTING", f"{secret}-changed")
186 + changed_environment = dict(LocalSnapshotTools()._environment_identity())
187 + assert changed_environment["VLLM_USER_SETTING"] != environment["VLLM_USER_SETTING"]
188 +
189 + artifact = tmp_path / "snapshot"
190 + artifact.mkdir(mode=0o700)
191 + write_manifest_atomic(artifact, _manifest(environment=tuple(environment.items())))
192 + before = os.environ.copy()
193 +
194 + _run_cli(["snapshot", "inspect", str(artifact)])
195 +
196 + assert os.environ == before
197 + output = capsys.readouterr().out
198 + assert secret not in output
199 + inspected = json.loads(output)
200 + inspected_environment = dict(inspected["environment"])
201 + assert inspected_environment == environment
202 +
203 + tools = LocalSnapshotTools()
204 + monkeypatch.setattr(tools, "current_identity", lambda _uuid: _runtime_identity())
205 + manifest = tools.make_manifest(
206 + argparse.Namespace(
207 + hf_token=secret,
208 + model_tag="Qwen/Qwen3-0.6B",
209 + revision=_MODEL_REVISION,
210 + served_model_name=None,
211 + tokenizer_revision=None,
212 + ),
213 + (
214 + "Qwen/Qwen3-0.6B",
215 + "--api-key",
216 + secret,
217 + f"{secret}-alternate",
218 + f"--api-key={secret}",
219 + "--hf-token",
220 + secret,
221 + f"--hf-token={secret}",
222 + ),
223 + ProcessInventory(100, (100, 101), (101,), "GPU-abc"),
224 + _oracle(),
225 + tmp_path,
226 + )
227 +
228 + assert manifest.engine_argv == (
229 + "Qwen/Qwen3-0.6B",
230 + "--api-key",
231 + "***",
232 + "***",
233 + "--api-key=***",
234 + "--hf-token",
235 + "***",
236 + "--hf-token=***",
237 + )
238 +
239 +
240 +@pytest.mark.parametrize(
241 + "case",
242 + [
243 + (("--hf_token", "SECRET"), ("--hf_token", "***")),
244 + (("--hf_token=SECRET",), ("--hf_token=***",)),
245 + (("--api_key", "SECRET"), ("--api_key", "***")),
246 + (("--api_key=SECRET",), ("--api_key=***",)),
247 + (("--hf-tok", "SECRET"), ("--hf-tok", "***")),
248 + (("--hf-tok=SECRET",), ("--hf-tok=***",)),
249 + (("--hf_tok", "SECRET"), ("--hf_tok", "***")),
250 + (("--api-k", "SECRET", "SECOND"), ("--api-k", "***", "***")),
251 + (("--api-k=SECRET",), ("--api-k=***",)),
252 + (("--hf-overrides", "{}"), ("--hf-overrides", "{}")),
253 + ],
254 +)
255 +def test_snapshot_manifest_redacts_accepted_secret_options(case):
256 + engine_argv, expected = case
257 + tools = create_autospec(LocalSnapshotTools, instance=True)
258 + tools.current_identity.return_value = _runtime_identity()
259 + tools._artifact_bytes.return_value = 0
260 + manifest = LocalSnapshotTools.make_manifest(
261 + tools,
262 + snapshot_server.parse_vllm_args(["model", *engine_argv]),
263 + engine_argv,
264 + ProcessInventory(100, (100, 101), (101,), "GPU-abc"),
265 + _oracle(),
266 + Path(),
267 + )
268 +
269 + assert manifest.engine_argv == expected
270 + if "SECRET" in " ".join(engine_argv):
271 + assert "***" in " ".join(manifest.engine_argv)
272 + assert "SECRET" not in " ".join(manifest.engine_argv)
273 +
274 +
275 +def parse_snapshot(*argv: str):
276 + parser = FlexibleArgumentParser()
277 + subparsers = parser.add_subparsers(dest="subparser", required=True)
278 + snapshot_cli.SnapshotSubcommand(
279 + create_requested=argv[:1] == ("create",)
280 + ).subparser_init(subparsers)
281 + return parser.parse_args(["snapshot", *argv])
282 +
283 +
284 +@pytest.mark.parametrize("option", ["--snapshot-dir", "--snapshot_dir", "--snapshot-d"])
285 +@pytest.mark.parametrize("joined", [False, True])
286 +def test_snapshot_create_forwards_only_engine_options(option, joined, monkeypatch):
287 + directory = [f"{option}=/tmp/snapshot"] if joined else [option, "/tmp/snapshot"]
288 + argv = ["create", "Qwen/Qwen3-0.6B", "--revision", _MODEL_REVISION, *directory]
289 + args = parse_snapshot(*argv)
290 + monkeypatch.setattr(sys, "argv", ["vllm", "snapshot", *argv])
291 +
292 + child = snapshot_server.parse_vllm_args(list(_child_engine_argv(args, None)))
293 +
294 + assert child.model == "Qwen/Qwen3-0.6B"
295 + assert child.revision == _MODEL_REVISION
296 + assert child.enable_sleep_mode
297 +
298 +
299 +@pytest.mark.parametrize("model_in_cli", [False, True])
300 +def test_snapshot_create_config_preserves_cli_precedence(
301 + tmp_path, monkeypatch, model_in_cli
302 +):
303 + config = tmp_path / "engine.yaml"
304 + config.write_text("model: config-model\nmax-model-len: 512\ndtype: float16\n")
305 + model = ["Qwen/Qwen3-0.6B"] if model_in_cli else []
306 + argv = [
307 + "create",
308 + *model,
309 + "--snapshot-dir",
310 + str(tmp_path / "snapshot"),
311 + "--config",
312 + str(config),
313 + "--revision",
314 + _MODEL_REVISION,
315 + "--max-model-len",
316 + "1024",
317 + ]
318 + args = parse_snapshot(*argv)
319 + monkeypatch.setattr(sys, "argv", ["vllm", "snapshot", *argv])
320 + child = snapshot_server.parse_vllm_args(list(_child_engine_argv(args, None)))
321 +
322 + assert (
323 + (args.model_tag or args.model)
324 + == child.model
325 + == ("Qwen/Qwen3-0.6B" if model_in_cli else "config-model")
326 + )
327 + assert args.max_model_len == child.max_model_len == 1024
328 + assert args.dtype == child.dtype == "float16"
329 +
330 +
331 +@pytest.mark.parametrize(
332 + ("probe", "status", "output"),
333 + [
334 + (None, 0, ""),
335 + ("nvidia-smi", 1, ""),
336 + ("find", 1, ""),
337 + ("ss", 1, ""),
338 + ("nvidia-smi", 0, "12345"),
339 + ("find", 0, "link_remap.fixture"),
340 + ("ss", 0, "LISTEN"),
341 + ],
342 +)
343 +def test_snapshot_e2e_cleanup_requires_successful_empty_probes(probe, status, output):
344 + script = (
345 + Path(__file__).resolve().parents[3]
346 + / ".buildkite/scripts/initialized-snapshot-e2e.sh"
347 + )
348 + if not script.exists():
349 + pytest.skip("snapshot E2E script is not packaged in this test image")
350 + source = script.read_text()
351 + # Exercise the real probes without entering Docker/CRIU setup.
352 + functions = source[source.index("link_remaps() {") : source.index("wait_clean() {")]
353 + shell = (
354 + """set -Eeuo pipefail
355 +GPU_UUID=fixture PORT_ONE=18001 PORT_TWO=18002 LINK_REMAP_BASELINE=''
356 +SNAPSHOT_PIDS=(999999999)
357 +nvidia-smi() { :; }
358 +find() { :; }
359 +ss() { :; }
360 +"""
361 + + functions
362 + )
363 + if probe:
364 + shell += f'\n{probe}() {{ printf %s "{output}"; return {status}; }}\n'
365 + shell += "\nif state_clean; then exit 0; else exit 1; fi\n"
366 +
367 + result = subprocess.run(["bash", "-c", shell], capture_output=True, timeout=10)
368 +
369 + assert result.returncode == (0 if probe is None else 1), result.stderr
370 +
371 +
372 +def test_snapshot_create_cli_accepts_only_pinned_compact_mode(
373 + monkeypatch: pytest.MonkeyPatch,
374 +):
375 + monkeypatch.setattr(snapshot_cli.platform, "system", lambda: "Linux")
376 + monkeypatch.setattr(snapshot_cli.platform, "machine", lambda: "x86_64")
377 + base = ("create", "Qwen/Qwen3-0.6B", "--snapshot-dir=/tmp/snapshot")
378 + compact = parse_snapshot(*base, "--revision", _MODEL_REVISION)
379 +
380 + snapshot_cli.validate_create_args(compact)
381 + assert compact.model_tag == "Qwen/Qwen3-0.6B"
382 +
383 + dispatched: list[argparse.Namespace] = []
384 + monkeypatch.setattr(snapshot_cli, "run_create", dispatched.append)
385 + _run_cli(["snapshot", *base, "--revision", _MODEL_REVISION])
386 + assert dispatched[0].revision == _MODEL_REVISION
387 +
388 +
389 +@pytest.mark.parametrize(
390 + ("attribute", "value", "message"),
391 + [
392 + ("prefill_context_parallel_size", 2, "prefill context parallel size 1"),
393 + ("model_tag", None, "immutable remote model"),
394 + ("logprobs_mode", "raw_logits", "log-probability mode"),
395 + ("speculative_config", {}, "does not support speculative decoding"),
396 + ("spec_method", "ngram", "does not support speculative decoding"),
397 + ("spec_model", "draft-model", "does not support speculative decoding"),
398 + ("spec_tokens", 3, "does not support speculative decoding"),
399 + ],
400 +)
401 +def test_snapshot_create_rejects_invalid_config(
402 + attribute: str,
403 + value: object,
404 + message: str,
405 + tmp_path: Path,
406 +):
407 + args = parse_snapshot(
408 + "create",
409 + "Qwen/Qwen3-0.6B",
410 + "--snapshot-dir=/tmp/snapshot",
411 + "--revision",
412 + _MODEL_REVISION,
413 + )
414 + if attribute == "model_tag":
415 + local_model = tmp_path / "model"
416 + local_model.mkdir()
417 + value = str(local_model)
418 + setattr(args, attribute, value)
419 + with pytest.raises(ValueError, match=message):
420 + snapshot_cli.validate_create_args(args)
421 +
422 +
423 +def _oracle(
424 + token_ids: tuple[int, ...] = (12095,),
425 + text: str = " Paris",
426 + logprob: float = -0.125,
427 +) -> Oracle:
428 + return Oracle(token_ids, text, logprob)
429 +
430 +
431 +def _manifest(**changes: object) -> SnapshotManifest:
432 + manifest = SnapshotManifest(
433 + schema_version=1,
434 + boundary="post-engine-init-reloadable-state-released",
435 + created_at="2026-08-06T00:00:00Z",
436 + artifact_bytes=8,
437 + vllm_version="0.1.dev1+snapshot",
438 + python_version="3.12.3",
439 + torch_version="2.9.0",
440 + cuda_runtime="12.9",
441 + driver_version="575.57.08",
442 + criu_version="4.1",
443 + cuda_checkpoint_sha256="a" * 64,
444 + kernel_release="6.8.0",
445 + host_id="host-a",
446 + gpu_name="NVIDIA A10",
447 + gpu_uuid="GPU-abc",
448 + model="Qwen/Qwen3-0.6B",
449 + served_model_name="Qwen/Qwen3-0.6B",
450 + model_revision="model-sha",
451 + tokenizer_revision="tokenizer-sha",
452 + engine_argv=("Qwen/Qwen3-0.6B",),
453 + environment=(("VLLM_USE_V1", "1"),),
454 + process_tree=(100, 101),
455 + cuda_holders=(101,),
456 + oracle_token_ids=(12095,),
457 + oracle_text=" Paris",
458 + oracle_sampled_token_logprob=-0.125,
459 + )
460 + return SnapshotManifest.model_validate(
461 + {**manifest.model_dump(mode="python"), **changes}, strict=True
462 + )
463 +
464 +
465 +def _runtime_identity(**changes: object) -> Any:
466 + fields = SnapshotRuntimeIdentity.model_fields.keys()
467 + payload = _manifest().model_dump(mode="python", include=fields)
468 + return SnapshotRuntimeIdentity.model_validate({**payload, **changes}, strict=True)
469 +
470 +
471 +def _fake_snapshot_tools() -> Any:
472 + tools = create_autospec(LocalSnapshotTools, instance=True)
473 + tools.launch_child.return_value = 100
474 + tools.wait_ready.return_value = _oracle()
475 + tools.inventory.return_value = ProcessInventory(100, (100, 101), (101,), "GPU-abc")
476 +
477 + tools.current_identity.side_effect = lambda _gpu_uuid: _runtime_identity()
478 + tools.restore.return_value = 100
479 + tools.request_oracle.return_value = _oracle()
480 + return tools
481 +
482 +
483 +@pytest.mark.parametrize("value", ["0", "-1", "nan", "inf"])
484 +def test_snapshot_timeout_must_be_positive_and_finite(
485 + value: str, monkeypatch: pytest.MonkeyPatch
486 +):
487 + monkeypatch.setenv("VLLM_SNAPSHOT_TIMEOUT_S", value)
488 + with pytest.raises(ValueError, match="positive finite number"):
489 + LocalSnapshotTools()
490 +
491 +
492 +def test_snapshot_create_abort_reaps_after_process_group_cleanup(
493 + tmp_path: Path, monkeypatch: pytest.MonkeyPatch
494 +):
495 + tools = LocalSnapshotTools()
496 + process = subprocess.Popen(
497 + [sys.executable, "-c", "raise SystemExit(1)"],
498 + stdin=subprocess.DEVNULL,
499 + stdout=subprocess.DEVNULL,
500 + stderr=subprocess.DEVNULL,
501 + start_new_session=True,
502 + )
503 + tools._children[process.pid] = process
504 + (tmp_path / "child.log").write_text("root failed")
505 + killed: list[tuple[int, signal.Signals]] = []
506 + monkeypatch.setattr(
507 + os,
508 + "killpg",
509 + lambda pid, sig: killed.append((pid, sig)),
510 + )
511 + monkeypatch.setattr(os, "waitid", lambda *_args: object(), raising=False)
512 +
513 + try:
514 + with pytest.raises(SnapshotCreateError, match="root failed"):
515 + tools.wait_ready(tmp_path, process.pid)
516 + assert process.returncode is None
517 + tools.abort_create(process.pid)
518 + finally:
519 + if process.pid in tools._children:
520 + tools.abort_create(process.pid)
521 +
522 + assert killed == [(process.pid, signal.SIGKILL)]
523 + assert process.pid not in tools._children
524 +
525 +
526 +def _supply_waitid(monkeypatch: pytest.MonkeyPatch) -> None:
527 + """`wait_ready` binds Linux-only `os.waitid` before it reads the marker."""
528 + monkeypatch.setattr(os, "waitid", lambda *_args: None, raising=False)
529 +
530 +
531 +def test_snapshot_wait_ready_reads_back_the_oracle_the_child_wrote(
532 + tmp_path: Path, monkeypatch: pytest.MonkeyPatch
533 +):
534 + _supply_waitid(monkeypatch)
535 + snapshot_server.write_ready_atomic(tmp_path / "ready.json", _oracle())
536 +
537 + assert LocalSnapshotTools().wait_ready(tmp_path, os.getpid()) == _oracle()
538 +
539 +
540 +@pytest.mark.parametrize(
541 + "payload",
542 + [
543 + {"token_ids": [12095], "sampled_token_logprob": -0.125},
544 + {"token_ids": ["12095"], "text": " Paris", "sampled_token_logprob": -0.125},
545 + ],
546 +)
547 +def test_snapshot_wait_ready_rejects_a_malformed_marker(
548 + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, payload: dict[str, object]
549 +):
550 + _supply_waitid(monkeypatch)
551 + (tmp_path / "ready.json").write_text(json.dumps(payload))
552 +
553 + with pytest.raises(SnapshotCreateError, match="ready marker invalid"):
554 + LocalSnapshotTools().wait_ready(tmp_path, os.getpid())
555 +
556 +
557 +def test_snapshot_create_abort_rejects_surviving_process_group(
558 + monkeypatch: pytest.MonkeyPatch,
559 +):
560 + tools = LocalSnapshotTools()
561 + process = create_autospec(subprocess.Popen, instance=True)
562 + process.wait.side_effect = subprocess.TimeoutExpired("snapshot child", 10)
563 + tools._children[100] = process
564 + monkeypatch.setattr(os, "killpg", lambda _pid, _sig: None)
565 +
566 + with pytest.raises(SnapshotCreateError, match="survived SIGKILL"):
567 + tools.abort_create(100)
568 +
569 +
570 +@pytest.mark.asyncio
571 +async def test_snapshot_child_rejects_mismatch_before_publication(
572 + tmp_path: Path, monkeypatch: pytest.MonkeyPatch
573 +):
574 + from vllm.entrypoints.launchers.api_server import entry as api_server
575 +
576 + canaries = iter((_oracle(), _oracle((42,), " Lyon")))
577 +
578 + @asynccontextmanager
579 + async def engine_context():
580 + yield object()
581 +
582 + async def next_canary(_engine: object) -> Oracle:
583 + return next(canaries)
584 +
585 + async def return_none(_engine: object) -> None:
586 + return None
587 +
588 + monkeypatch.setattr(
589 + api_server, "build_async_engine_client", lambda _args: engine_context()
590 + )
591 + monkeypatch.setattr(snapshot_server, "run_engine_canary", next_canary)
592 + monkeypatch.setattr(snapshot_server, "_release_reloadable_state", return_none)
593 + monkeypatch.setattr(snapshot_server, "_restore_reloadable_state", return_none)
594 + monkeypatch.setattr(snapshot_server, "detach_snapshot_streams", lambda: None)
595 + monkeypatch.setattr(
596 + snapshot_server,
597 + "write_ready_atomic",
598 + lambda *_args: pytest.fail("mismatched snapshot was published"),
599 + )
600 + control = snapshot_server.ControlArgs(
601 + ready_file=tmp_path / "ready.json",
602 + release_file=tmp_path / "release.json",
603 + release_timeout_s=1,
604 + )
605 + with pytest.raises(SnapshotCanaryError, match="rehearsal changed"):
606 + await snapshot_server.run_vllm_snapshot_child(control, argparse.Namespace())
607 +
608 +
609 +def test_snapshot_release_marker_ignores_unknown_keys(tmp_path: Path):
610 + marker = tmp_path / "release.json"
611 + marker.write_text(json.dumps({"release": True, "port": 8000, "unknown": 1}))
612 +
613 + listener = snapshot_server.read_release_marker(marker)
614 +
615 + assert listener == snapshot_server.ListenerConfig(host=None, port=8000)
616 +
617 +
618 +@pytest.mark.parametrize(
619 + "payload",
620 + [
621 + ["release"],
622 + {"port": 8000},
623 + {"release": "true", "port": 8000},
624 + {"release": 1, "port": 8000},
625 + {"release": True, "port": 0},
626 + {"release": True, "port": 70000},
627 + {"release": True, "port": True},
628 + {"release": True, "port": "8000"},
629 + {"release": True, "port": 8000, "host": 5},
630 + ],
631 +)
632 +def test_snapshot_release_marker_rejects_an_invalid_payload(
633 + tmp_path: Path, payload: object
634 +):
635 + marker = tmp_path / "release.json"
636 + marker.write_text(json.dumps(payload))
637 +
638 + with pytest.raises(snapshot_server.SnapshotBarrierError, match="release marker"):
639 + snapshot_server.read_release_marker(marker)
640 +
641 +
642 +def test_snapshot_create_rolls_back_failed_dump(tmp_path: Path):
643 + target = tmp_path / "snapshot"
644 + tools = _fake_snapshot_tools()
645 + tools.dump.side_effect = RuntimeError("dump failed")
646 + with pytest.raises(RuntimeError, match="dump failed"):
647 + create_snapshot(
648 + argparse.Namespace(snapshot_dir=str(target), model_tag="Qwen/Qwen3-0.6B"),
649 + tools=tools,
650 + )
651 + tools.abort_create.assert_called_once_with(100)
652 + assert not target.exists()
653 +
654 +
655 +def _restore_fixture(tmp_path: Path) -> argparse.Namespace:
656 + artifact = tmp_path / "snapshot"
657 + artifact.mkdir(mode=0o700)
658 + write_manifest_atomic(artifact, _manifest())
659 + return argparse.Namespace(snapshot_dir=str(artifact), host="127.0.0.1", port=9000)
660 +
661 +
662 +def test_snapshot_restore_rejects_identity_mismatch(tmp_path: Path):
663 + args = _restore_fixture(tmp_path)
664 +
665 + identity_mismatch = _fake_snapshot_tools()
666 + identity_mismatch.current_identity.side_effect = lambda _gpu_uuid: (
667 + _runtime_identity(vllm_version="different")
668 + )
669 + with pytest.raises(SnapshotCompatibilityError, match="vllm_version"):
670 + restore_snapshot(args, tools=identity_mismatch)
671 + identity_mismatch.restore.assert_not_called()
672 +
673 +
674 +def test_snapshot_identity_mismatch_names_every_differing_field():
675 + with pytest.raises(SnapshotCompatibilityError) as mismatch:
676 + validate_identity(
677 + _manifest(),
678 + _runtime_identity(vllm_version="different", gpu_uuid="GPU-other"),
679 + )
680 +
681 + assert str(mismatch.value) == "snapshot mismatch: vllm_version, gpu_uuid"
682 +
683 +
684 +def test_snapshot_restore_cleans_oracle_mismatch(tmp_path: Path):
685 + args = _restore_fixture(tmp_path)
686 + oracle_mismatch = _fake_snapshot_tools()
687 + oracle_mismatch.request_oracle.return_value = _oracle(logprob=-0.5)
688 + with pytest.raises(SnapshotRestoreError, match="oracle mismatch"):
689 + restore_snapshot(args, tools=oracle_mismatch)
690 + oracle_mismatch.cleanup.assert_called_once()
691 + oracle_mismatch.complete_restore.assert_not_called()
692 + assert oracles_match(_oracle(logprob=-100.0), _oracle(logprob=-100.0005))
693 + assert not oracles_match(_oracle(logprob=-100.0), _oracle(logprob=-100.002))
694 +
695 +
696 +@pytest.mark.parametrize(
697 + ("invalid_update", "diagnostic"),
698 + [
699 + ({"unexpected": True}, "unexpected"),
700 + ({"schema_version": True}, "schema_version"),
701 + ({"boundary": "unknown"}, "boundary"),
702 + ({"process_tree": [100, 100]}, "root"),
703 + ({"cuda_holders": [999]}, "root"),
704 + ({"oracle_token_ids": [1, 2]}, "oracle_token_ids"),
705 + ({"oracle_sampled_token_logprob": 0}, "oracle_sampled_token_logprob"),
706 + (
707 + {"oracle_sampled_token_logprob": float("nan")},
708 + "oracle_sampled_token_logprob",
709 + ),
710 + ],
711 +)
712 +def test_snapshot_manifest_validation(
713 + tmp_path: Path,
714 + invalid_update: dict[str, object],
715 + diagnostic: str,
716 +):
717 + artifact = tmp_path / "snapshot"
718 + artifact.mkdir(mode=0o700)
719 + write_manifest_atomic(artifact, _manifest())
720 + invalid = _manifest().model_dump(mode="json")
721 + invalid.update(invalid_update)
722 + (artifact / "manifest.json").write_text(json.dumps(invalid))
723 + with pytest.raises(
724 + SnapshotCompatibilityError,
725 + match=f"invalid snapshot manifest: {diagnostic}",
726 + ):
727 + read_manifest(artifact)
728 +
729 +
730 +def _local_restore_artifact(tmp_path: Path, name: str = "snapshot") -> Path:
731 + artifact = tmp_path / name
732 + artifact.mkdir(mode=0o700)
733 + (artifact / "child.log").write_bytes(b"startup log")
734 + (artifact / "child.log.snapshot-size").write_text("11\n")
735 + return artifact
736 +
737 +
738 +def _mark_snapshot_pids_free(monkeypatch: pytest.MonkeyPatch) -> list[int]:
739 + probed_pids: list[int] = []
740 +
741 + def missing(pid: int, _signal: int) -> None:
742 + probed_pids.append(pid)
743 + raise ProcessLookupError
744 +
745 + monkeypatch.setattr(snapshot_runtime.os, "kill", missing)
746 + return probed_pids
747 +
748 +
749 +@pytest.mark.parametrize(
750 + ("probe_error", "diagnostic"),
751 + [
752 + (None, "already occupied: 100"),
753 + (PermissionError(), "already occupied: 100"),
754 + (OSError("probe failed"), "availability probe failed: 100"),
755 + ],
756 +)
757 +def test_snapshot_restore_rejects_unavailable_pid_before_mutation(
758 + tmp_path: Path,
759 + monkeypatch: pytest.MonkeyPatch,
760 + probe_error: OSError | None,
761 + diagnostic: str,
762 +):
763 + args = _restore_fixture(tmp_path)
764 + artifact = Path(args.snapshot_dir)
765 + (artifact / "child.log").write_bytes(b"startup log")
766 + (artifact / "release.json").write_bytes(b"release sentinel")
767 + before = {path.name: path.read_bytes() for path in artifact.iterdir()}
768 + tools = LocalSnapshotTools()
769 +
770 + def fail_probe(_pid: int, _signal: int) -> None:
771 + if probe_error is not None:
772 + raise probe_error
773 +
774 + monkeypatch.setattr(snapshot_runtime.os, "kill", fail_probe)
775 + monkeypatch.setattr(tools, "preflight", lambda *_args: None)
776 + monkeypatch.setattr(tools, "current_identity", lambda _uuid: _runtime_identity())
777 + monkeypatch.setattr(tools, "_criu", lambda *_args: pytest.fail("CRIU called"))
778 +
779 + with pytest.raises(SnapshotRestoreError, match=diagnostic):
780 + restore_snapshot(args, tools=tools)
781 + assert {path.name: path.read_bytes() for path in artifact.iterdir()} == before
782 +
783 +
784 +@pytest.mark.parametrize("mode", ["pinned", "unpinned", "cleanup-fails"])
785 +def test_snapshot_restore_terminates_only_a_pinned_tree(
786 + tmp_path: Path,
787 + monkeypatch: pytest.MonkeyPatch,
788 + mode: str,
789 +):
790 + _mark_snapshot_pids_free(monkeypatch)
791 + artifact = _local_restore_artifact(tmp_path)
792 + manifest = _manifest()
793 + root_pid = manifest.process_tree[0]
794 + tools = LocalSnapshotTools()
795 + terminated: list[int] = []
796 +
797 + def terminate_and_wait(pid: int) -> None:
798 + terminated.append(pid)
799 + if mode == "cleanup-fails":
800 + raise SnapshotRestoreError("restored process cleanup is incomplete")
801 + tools._restored_processes.pop(pid)
802 +
803 + def pin(
804 + _artifact: Path, process_tree: tuple[int, ...], _holders: tuple[int, ...]
805 + ) -> None:
806 + if mode == "unpinned":
807 + raise SnapshotRestoreError(
808 + "restored session does not match the captured process tree"
809 + )
810 + tools._restored_processes[process_tree[0]] = ()
811 +
812 + monkeypatch.setattr(tools, "cleanup", terminate_and_wait)
813 + monkeypatch.setattr(tools, "_pin_restored_tree", pin)
814 + monkeypatch.setattr(tools, "_criu", lambda *_args: None)
815 + monkeypatch.setattr(
816 + tools,
817 + "_run",
818 + lambda command, **_kwargs: subprocess.CompletedProcess(command, 0, "333\n", ""),
819 + )
820 +
821 + with pytest.raises(SnapshotRestoreError, match="does not match") as failure:
822 + tools.restore(artifact, manifest)
823 +
824 + # A pinned tree is ours: it must be terminated and waited for, not left to
825 + # honour the abort marker. An unpinned PID is never signalled.
826 + assert terminated == ([] if mode == "unpinned" else [root_pid])
827 + if mode == "cleanup-fails":
828 + # The failure to terminate is reported beside the primary error, and a
829 + # tree that may still be running stays pinned.
830 + assert "restore cleanup failed" in str(failure.value)
831 + assert tools._restored_processes == {root_pid: ()}
832 + else:
833 + assert tools._restored_processes == {}
834 + assert json.loads((artifact / "release.json").read_text())["release"] is False
835 +
836 +
837 +def test_snapshot_private_path_and_link_remap_security(
838 + tmp_path: Path, monkeypatch: pytest.MonkeyPatch
839 +):
840 + _mark_snapshot_pids_free(monkeypatch)
841 + real = tmp_path / "real"
842 + real.mkdir(mode=0o700)
843 + linked = tmp_path / "linked"
844 + linked.symlink_to(real, target_is_directory=True)
845 + with pytest.raises(SnapshotSecurityError, match="symlink"):
846 + validate_artifact_root(linked, creating=False)
847 +
848 + unsafe = tmp_path / "unsafe"
849 + unsafe.mkdir()
850 + unsafe.chmod(0o777)
851 + with pytest.raises(SnapshotSecurityError, match="world-writable"):
852 + validate_artifact_root(unsafe / "snapshot", creating=True)
853 +
854 + artifact = _local_restore_artifact(tmp_path)
855 + saved_remaps = artifact / "link-remaps"
856 + saved_remaps.mkdir()
857 + (saved_remaps / "link_remap.270").write_bytes(b"semaphore state")
858 + shm_dir = tmp_path / "shm"
859 + shm_dir.mkdir()
860 + target = shm_dir / "link_remap.270"
861 + replacement = tmp_path / "replacement"
862 + replacement.write_bytes(b"semaphore state")
863 + target.symlink_to(replacement)
864 + tools = LocalSnapshotTools()
865 + tools.shm_dir = shm_dir
866 + monkeypatch.setattr(tools, "_criu", lambda *_args: pytest.fail("CRIU called"))
867 + with pytest.raises(SnapshotRestoreError, match="conflicting CRIU link remap"):
868 + tools.restore(artifact, _manifest())
869 +
870 +
871 +@pytest.mark.parametrize(
872 + ("blocked_state", "message"),
873 + [("tcp", "external established TCP"), ("io_uring", "kernel.io_uring_disabled=1")],
874 +)
875 +def test_snapshot_rejects_unsafe_process_state_before_criu(
876 + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, blocked_state: str, message: str
877 +):
878 + tools = LocalSnapshotTools()
879 + monkeypatch.setattr(tools, "_tree_pids", lambda _root_pid: (100, 101))
880 + monkeypatch.setattr(tools, "_cuda_process_rows", lambda: ("101, GPU-abc",))
881 + monkeypatch.setattr(
882 + tools,
883 + "_descriptor_inventory",
884 + lambda _tree: (
885 + (101,) if blocked_state == "io_uring" else (),
886 + {41: 101},
887 + ),
888 + )
889 + tcp_record = _TcpSocketRecord(
890 + family="AF_INET",
891 + local_raw="3C00000A:D6F6",
892 + remote_raw="01010101:01BB" if blocked_state == "tcp" else "00000000:0000",
893 + inode=41,
894 + )
895 + monkeypatch.setattr(tools, "_tcp_records", lambda: (tcp_record,))
896 + with pytest.raises(SnapshotCreateError, match=message) as excinfo:
897 + tools.inventory(100, tmp_path)
898 + if blocked_state == "tcp":
899 + assert "pid 101" in str(excinfo.value)
900 + assert "1.1.1.1:443" in str(excinfo.value)
901 +
902 +
903 +def test_decode_endpoint_families():
904 + assert _decode_endpoint("AF_INET", "3C00000A:D6F6") == "10.0.0.60:55030"
905 + assert (
906 + _decode_endpoint("AF_INET6", "00000000000000000000000001000000:01BB")
907 + == "[::1]:443"
908 + )
909 +
910 +
911 +def test_tcp_records_skips_missing_tables(tmp_path, monkeypatch):
912 + tcp, tcp6 = tmp_path / "tcp", tmp_path / "tcp6"
913 + contents = "header\n0: 0100007F:1F90 00000000:0000 01 0 0 0 0 0 41\n"
914 + tcp.write_text(contents)
915 + tables = (("AF_INET", tcp), ("AF_INET6", tcp6))
916 + monkeypatch.setattr(snapshot_runtime, "_TCP_TABLES", tables)
917 +
918 + tools = LocalSnapshotTools()
919 + assert tuple(record.family for record in tools._tcp_records()) == ("AF_INET",)
920 +
921 + tcp6.write_text(contents)
922 + families = tuple(record.family for record in tools._tcp_records())
923 + assert families == ("AF_INET", "AF_INET6")
924 +
925 +
926 +def test_snapshot_manifest_records_external_cache_files(tmp_path: Path):
927 + artifact = tmp_path / "snapshot"
928 + artifact.mkdir(mode=0o700)
929 + recorded = (
930 + (str(tmp_path / "kernel.so"), "b" * 64),
931 + (str(tmp_path / "launcher.lock"), "locked"),
932 + )
933 + write_manifest_atomic(artifact, _manifest(external_cache_files=recorded))
934 +
935 + assert read_manifest(artifact).external_cache_files == recorded
936 +
937 + # A manifest written before this field existed still loads.
938 + legacy = _manifest().model_dump(mode="json")
939 + del legacy["external_cache_files"]
940 + (artifact / "manifest.json").write_text(json.dumps(legacy))
941 +
942 + assert read_manifest(artifact).external_cache_files == ()
943 +
944 +
945 +def _pin_generated_cache_roots(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
946 + """Keep the resolver off whatever cache roots the host running this has."""
947 + monkeypatch.setenv("VLLM_CACHE_ROOT", str(tmp_path / "vllm_cache"))
948 + monkeypatch.setenv("TRITON_CACHE_DIR", str(tmp_path / "triton_cache_default"))
949 + monkeypatch.setenv("TORCHINDUCTOR_CACHE_DIR", str(tmp_path / "inductor_cache"))
950 +
951 +
952 +def _unreadable(_path: Path) -> str:
953 + raise OSError("cache file is unreadable")
954 +
955 +
956 +def test_snapshot_inventory_records_open_generated_cache_files(
957 + tmp_path: Path, monkeypatch: pytest.MonkeyPatch
958 +):
959 + cache_root = tmp_path / "triton_cache"
960 + cache_root.mkdir()
961 + kernel = cache_root / "kernel.so"
962 + kernel.write_bytes(b"compiled kernel")
963 + lock = cache_root / "launcher.lock"
964 + lock.write_bytes(b"held by pid 100")
965 + weights = tmp_path / "model.safetensors"
966 + weights.write_bytes(b"weights")
967 + artifact = tmp_path / "snapshot"
968 + artifact.mkdir(mode=0o700)
969 + (artifact / "child.log.lock").write_bytes(b"")
970 + _pin_generated_cache_roots(monkeypatch, tmp_path)
971 + monkeypatch.setenv("TRITON_CACHE_DIR", str(cache_root))
972 +
973 + targets = {
974 + 100: (str(kernel), str(weights), "socket:[41]"),
975 + 101: (str(lock), str(cache_root), str(artifact / "child.log.lock")),
976 + 102: (f"{cache_root / 'evicted.so'} (deleted)",),
977 + }
978 + tools = LocalSnapshotTools()
979 + monkeypatch.setattr(tools, "_tree_pids", lambda _root_pid: (100, 101, 102))
980 + monkeypatch.setattr(tools, "_descriptor_targets", lambda pid: targets[pid])
981 + monkeypatch.setattr(tools, "_cuda_process_rows", lambda: ("101, GPU-abc",))
982 + monkeypatch.setattr(tools, "_tcp_records", lambda: ())
983 + monkeypatch.setattr(tools, "current_identity", lambda _uuid: _runtime_identity())
984 +
985 + manifest = tools.make_manifest(
986 + argparse.Namespace(
987 + model_tag="Qwen/Qwen3-0.6B",
988 + revision=_MODEL_REVISION,
989 + served_model_name=None,
990 + tokenizer_revision=None,
991 + ),
992 + ("Qwen/Qwen3-0.6B",),
993 + tools.inventory(100, artifact),
994 + _oracle(),
995 + artifact,
996 + )
997 +
998 + # Weights, sockets, the cache directory itself, files inside the artifact,
999 + # and deleted targets that CRIU remaps are all left out.
1000 + assert manifest.external_cache_files == (
1001 + (str(kernel), hashlib.sha256(b"compiled kernel").hexdigest()),
1002 + (str(lock), "locked"),
1003 + )
1004 +
1005 +
1006 +def test_snapshot_inventory_marks_a_cache_file_it_cannot_read(
1007 + tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1008 +):
1009 + cache_root = tmp_path / "triton_cache"
1010 + cache_root.mkdir()
1011 + kernel = cache_root / "kernel.so"
1012 + kernel.write_bytes(b"compiled kernel")
1013 + lock = cache_root / "launcher.lock"
1014 + lock.write_bytes(b"held by pid 100")
1015 + artifact = tmp_path / "snapshot"
1016 + artifact.mkdir(mode=0o700)
1017 + _pin_generated_cache_roots(monkeypatch, tmp_path)
1018 + monkeypatch.setenv("TRITON_CACHE_DIR", str(cache_root))
1019 +
1020 + tools = LocalSnapshotTools()
1021 + monkeypatch.setattr(tools, "_tree_pids", lambda _root_pid: (100,))
1022 + monkeypatch.setattr(
1023 + tools, "_descriptor_targets", lambda _pid: (str(kernel), str(lock))
1024 + )
1025 + monkeypatch.setattr(tools, "_cuda_process_rows", lambda: ("100, GPU-abc",))
1026 + monkeypatch.setattr(tools, "_tcp_records", lambda: ())
1027 + monkeypatch.setattr(tools, "_sha256", _unreadable)
1028 +
1029 + # A file that cannot be digested keeps its own marker, so the manifest says
1030 + # which entries restore can check for existence only.
1031 + assert tools.inventory(100, artifact).external_cache_files == (
1032 + (str(kernel), "unreadable"),
1033 + (str(lock), "locked"),
1034 + )
1035 +
1036 +
1037 +def test_snapshot_inventory_matches_resolved_cache_roots_not_siblings(
1038 + tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1039 +):
1040 + # Descriptor targets come back kernel-canonical, so the fixture must be too.
1041 + base = Path(os.path.realpath(tmp_path))
1042 + real_cache = base / "mnt_cache"
1043 + real_cache.mkdir()
1044 + kernel = real_cache / "kernel.so"
1045 + kernel.write_bytes(b"compiled kernel")
1046 + linked_cache = base / "linked_cache"
1047 + linked_cache.symlink_to(real_cache, target_is_directory=True)
1048 + sibling = base / "vllm_cache-backup"
1049 + sibling.mkdir()
1050 + stale = sibling / "stale.so"
1051 + stale.write_bytes(b"stale kernel")
1052 + artifact = base / "snapshot"
1053 + artifact.mkdir(mode=0o700)
1054 +
1055 + _pin_generated_cache_roots(monkeypatch, base)
1056 + monkeypatch.setenv("TRITON_CACHE_DIR", str(linked_cache))
1057 +
1058 + tools = LocalSnapshotTools()
1059 + monkeypatch.setattr(tools, "_tree_pids", lambda _root_pid: (100,))
1060 + monkeypatch.setattr(
1061 + tools, "_descriptor_targets", lambda _pid: (str(kernel), str(stale))
1062 + )
1063 + monkeypatch.setattr(tools, "_cuda_process_rows", lambda: ("100, GPU-abc",))
1064 + monkeypatch.setattr(tools, "_tcp_records", lambda: ())
1065 +
1066 + # A cache root reached through a symlink still covers the file the kernel
1067 + # reports by its real path, and a directory that merely shares the root's
1068 + # name prefix is not part of the cache at all.
1069 + assert tools.inventory(100, artifact).external_cache_files == (
1070 + (str(kernel), hashlib.sha256(b"compiled kernel").hexdigest()),
1071 + )
1072 +
1073 +
1074 +def test_snapshot_restore_rejects_rotated_cache_file_before_mutation(
1075 + tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1076 +):
1077 + _mark_snapshot_pids_free(monkeypatch)
1078 + kernel = tmp_path / "kernel.so"
1079 + kernel.write_bytes(b"compiled kernel")
1080 + lock = tmp_path / "launcher.lock"
1081 + lock.write_bytes(b"held by pid 100")
1082 + manifest = _manifest(
1083 + external_cache_files=(
1084 + (str(kernel), hashlib.sha256(b"compiled kernel").hexdigest()),
1085 + (str(lock), "locked"),
1086 + )
1087 + )
1088 + artifact = _local_restore_artifact(tmp_path)
1089 + before = {path.name: path.read_bytes() for path in artifact.iterdir()}
1090 + tools = LocalSnapshotTools()
1091 + monkeypatch.setattr(tools, "_criu", lambda *_args: pytest.fail("CRIU called"))
1092 +
1093 + kernel.write_bytes(b"recompiled kernel")
1094 + with pytest.raises(SnapshotRestoreError, match="changed since capture") as changed:
1095 + tools.restore(artifact, manifest)
1096 + assert str(kernel) in str(changed.value)
1097 +
1098 + # Restoring the digest gets past the first entry, so the lock file is only
1099 + # checked for existence: the second failure proves the first one passed.
1100 + kernel.write_bytes(b"compiled kernel")
1101 + lock.unlink()
1102 + with pytest.raises(SnapshotRestoreError, match="vanished since capture") as gone:
1103 + tools.restore(artifact, manifest)
1104 + assert str(lock) in str(gone.value)
1105 +
1106 + assert {path.name: path.read_bytes() for path in artifact.iterdir()} == before
1107 +
1108 +
1109 +def test_snapshot_restore_rejects_a_cache_file_it_cannot_read(
1110 + tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1111 +):
1112 + _mark_snapshot_pids_free(monkeypatch)
1113 + kernel = tmp_path / "kernel.so"
1114 + kernel.write_bytes(b"compiled kernel")
1115 + manifest = _manifest(
1116 + external_cache_files=(
1117 + (str(kernel), hashlib.sha256(b"compiled kernel").hexdigest()),
1118 + )
1119 + )
1120 + artifact = _local_restore_artifact(tmp_path)
1121 + tools = LocalSnapshotTools()
1122 + monkeypatch.setattr(tools, "_criu", lambda *_args: pytest.fail("CRIU called"))
1123 + monkeypatch.setattr(tools, "_sha256", _unreadable)
1124 +
1125 + # A recorded digest that cannot be read again is a failed check, not a file
1126 + # to wave through on existence.
1127 + with pytest.raises(
1128 + SnapshotRestoreError, match="unreadable since capture"
1129 + ) as blocked:
1130 + tools.restore(artifact, manifest)
1131 + assert str(kernel) in str(blocked.value)
1132 +
1133 +
1134 +def test_snapshot_runtime_installer_logs_the_failing_exit_code():
1135 + installer = Path(__file__).parents[3] / "tools" / "install_snapshot_runtime.sh"
1136 + if not installer.is_file():
1137 + pytest.skip("installer script is not shipped in this test image")
1138 + timing = [
1139 + line
1140 + for line in installer.read_text().splitlines()
1141 + if line.startswith(("_snapshot_install_started=", "trap "))
1142 + ]
1143 + assert len(timing) == 2
1144 +
1145 + result = subprocess.run(
1146 + ["bash", "-c", "\n".join([*timing, "exit 7"])],
1147 + capture_output=True,
1148 + text=True,
1149 + )
1150 +
1151 + assert result.returncode == 7
1152 + assert "(exit 7)" in result.stdout
tests/models/quantization/test_mimo_v2_qkv_shard.py
+8
@@ -19,6 +19,7 @@ import torch
19
20 from vllm.model_executor.models.mimo_v2 import _shard_fp8_qkv_proj
21 from vllm.utils.math_utils import cdiv
22 +from vllm.utils.torch_utils import set_default_torch_num_threads
23
24 pytestmark = pytest.mark.cpu_test
25
@@ -39,6 +40,13 @@ GEOMETRIES = {
40 }
41
42
43 +@pytest.fixture(scope="module", autouse=True)
44 +def single_threaded_cpu_ops():
45 + # Do not prime the parent's thread pool before later engine forks.
46 + with set_default_torch_num_threads(1):
47 + yield
48 +
49 +
50 def _chunk_rows(num_heads, num_kv_heads, head_dim, v_head_dim, ckpt_tp):
51 return (
52 num_heads // ckpt_tp * head_dim
tools/install_snapshot_runtime.sh new
+109
@@ -0,0 +1,109 @@
1 +#!/usr/bin/env bash
2 +# SPDX-License-Identifier: Apache-2.0
3 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
4 +
5 +set -euxo pipefail
6 +
7 +# Surface the isolated snapshot-runtime install cost in the CI build log. The
8 +# trap captures the status first: the date substitution below would reset it.
9 +_snapshot_install_started=$(date +%s)
10 +code=0
11 +trap 'code=$?; echo "[TIMING] snapshot runtime install: $(( $(date +%s) - _snapshot_install_started )) s (exit $code)"' EXIT
12 +
13 +readonly CRIU_VERSION="4.2.1"
14 +readonly CRIU_SOURCE_SHA256="feffdf4638125ebb12d2434754f80a1d7bbba85a3e6bee98c216f88fb99a5d96"
15 +readonly DYNAMO_CHECKPOINT_HELPER_COMMIT="fdf25efad60f696a73393caf341c527a93d84190"
16 +readonly DYNAMO_CHECKPOINT_HELPER_SHA256="7b8ea21baecb1da729202c32e0fecbbbafb789d65e5c1c0649ea253ad09b8f82"
17 +readonly DYNAMO_LICENSE_SHA256="aeeca3d74a13b91d17ab9211c407b8d94a77bf8d9e5750c467818d3066b8adcf"
18 +
19 +# Keep the compiler and NL headers required by runtime JIT and RDMA.
20 +snapshot_shared_build_deps=(
21 + build-essential
22 + libnl-3-dev
23 + libnl-route-3-dev
24 +)
25 +snapshot_build_deps=(
26 + pkg-config
27 + libbsd-dev
28 + libcap-dev
29 + libnet1-dev
30 + libprotobuf-dev
31 + libprotobuf-c-dev
32 + protobuf-c-compiler
33 + protobuf-compiler
34 + python3-protobuf
35 + libgnutls28-dev
36 + libnftables-dev
37 + uuid-dev
38 +)
39 +snapshot_runtime_deps=(
40 + libbsd0
41 + libcap2
42 + libnet1
43 + libnl-3-200
44 + libnl-route-3-200
45 + libprotobuf-c1
46 + libgnutls30t64
47 + libnftables1
48 + iproute2
49 + iptables
50 + procps
51 + uuid-runtime
52 + util-linux
53 +)
54 +
55 +apt-get update -y
56 +apt-get install -y --no-install-recommends \
57 + "${snapshot_shared_build_deps[@]}" \
58 + "${snapshot_build_deps[@]}" \
59 + "${snapshot_runtime_deps[@]}"
60 +
61 +snapshot_build_dir="$(mktemp -d)"
62 +curl -fsSL \
63 + "https://github.com/checkpoint-restore/criu/archive/refs/tags/v${CRIU_VERSION}.tar.gz" \
64 + -o "${snapshot_build_dir}/criu.tar.gz"
65 +echo "${CRIU_SOURCE_SHA256} ${snapshot_build_dir}/criu.tar.gz" | sha256sum -c -
66 +mkdir "${snapshot_build_dir}/criu"
67 +tar -xzf "${snapshot_build_dir}/criu.tar.gz" \
68 + --strip-components=1 -C "${snapshot_build_dir}/criu"
69 +make -C "${snapshot_build_dir}/criu" -j"${MAX_JOBS:-$(nproc)}" criu cuda_plugin
70 +install -D -m 0755 "${snapshot_build_dir}/criu/criu/criu" \
71 + /usr/local/sbin/criu
72 +install -D -m 0755 \
73 + "${snapshot_build_dir}/criu/plugins/cuda/cuda_plugin.so" \
74 + /usr/local/lib/criu/cuda_plugin.so
75 +
76 +helper_url="https://raw.githubusercontent.com/ai-dynamo/dynamo/${DYNAMO_CHECKPOINT_HELPER_COMMIT}/deploy/snapshot/cmd/cuda-checkpoint-helper/main.c"
77 +curl -fsSL "${helper_url}" -o "${snapshot_build_dir}/cuda-checkpoint.c"
78 +echo "${DYNAMO_CHECKPOINT_HELPER_SHA256} ${snapshot_build_dir}/cuda-checkpoint.c" \
79 + | sha256sum -c -
80 +gcc -O2 -Wall -Wextra \
81 + -I/usr/local/cuda/include \
82 + -L/usr/local/cuda/lib64/stubs \
83 + "${snapshot_build_dir}/cuda-checkpoint.c" -lcuda \
84 + -o /usr/local/sbin/cuda-checkpoint
85 +
86 +license_url="https://raw.githubusercontent.com/ai-dynamo/dynamo/${DYNAMO_CHECKPOINT_HELPER_COMMIT}/LICENSE"
87 +curl -fsSL "${license_url}" -o "${snapshot_build_dir}/DYNAMO-LICENSE"
88 +echo "${DYNAMO_LICENSE_SHA256} ${snapshot_build_dir}/DYNAMO-LICENSE" \
89 + | sha256sum -c -
90 +
91 +install -D -m 0644 "${snapshot_build_dir}/criu.tar.gz" \
92 + /usr/share/doc/criu/source/criu.tar.gz
93 +install -D -m 0644 "${snapshot_build_dir}/criu/COPYING" \
94 + /usr/share/doc/criu/COPYING
95 +install -D -m 0644 "${snapshot_build_dir}/cuda-checkpoint.c" \
96 + /usr/share/doc/cuda-checkpoint-helper/main.c
97 +install -D -m 0644 "${snapshot_build_dir}/DYNAMO-LICENSE" \
98 + /usr/share/doc/cuda-checkpoint-helper/LICENSE
99 +
100 +criu --version
101 +ln -s /usr/local/cuda/lib64/stubs/libcuda.so \
102 + "${snapshot_build_dir}/libcuda.so.1"
103 +LD_LIBRARY_PATH="${snapshot_build_dir}" \
104 + cuda-checkpoint --help | grep -q -- "--action"
105 +test -x /usr/local/lib/criu/cuda_plugin.so
106 +
107 +apt-get purge -y --auto-remove "${snapshot_build_deps[@]}"
108 +rm -rf "${snapshot_build_dir}"
109 +rm -rf /var/lib/apt/lists/*
vllm/entrypoints/cli/main.py
+10 -2
@@ -46,6 +46,7 @@ def main():
46 import vllm.entrypoints.cli.openai
47 import vllm.entrypoints.cli.run_batch
48 import vllm.entrypoints.cli.serve
49 + import vllm.entrypoints.cli.snapshot
50 from vllm.entrypoints.serve.utils.api_utils import (
51 VLLM_SUBCMD_PARSER_EPILOG,
52 cli_env_setup,
@@ -59,9 +60,11 @@ def main():
60 vllm.entrypoints.cli.benchmark.main,
61 vllm.entrypoints.cli.collect_env,
62 vllm.entrypoints.cli.run_batch,
63 + vllm.entrypoints.cli.snapshot,
64 ]
65
64 - cli_env_setup()
66 + if sys.argv[1:2] != ["snapshot"]:
67 + cli_env_setup()
68
69 vllm.entrypoints.cli.benchmark.main.maybe_exec_rust_bench()
70
@@ -94,7 +97,12 @@ def main():
97 subparsers = parser.add_subparsers(required=False, dest="subparser")
98 cmds = {}
99 for cmd_module in CMD_MODULES:
97 - new_cmds = cmd_module.cmd_init()
100 + if cmd_module is vllm.entrypoints.cli.snapshot:
101 + new_cmds = cmd_module.cmd_init(
102 + create_requested=sys.argv[1:3] == ["snapshot", "create"]
103 + )
104 + else:
105 + new_cmds = cmd_module.cmd_init()
106 for cmd in new_cmds:
107 cmd.subparser_init(subparsers).set_defaults(dispatch_function=cmd.cmd)
108 cmds[cmd.name] = cmd
vllm/entrypoints/cli/snapshot.py new
+143
@@ -0,0 +1,143 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import argparse
5 +import json
6 +import os
7 +import platform
8 +import typing
9 +from pathlib import Path
10 +
11 +from vllm.entrypoints.cli.types import CLISubcommand
12 +
13 +if typing.TYPE_CHECKING:
14 + from vllm.utils.argparse_utils import FlexibleArgumentParser
15 +else:
16 + FlexibleArgumentParser = argparse.ArgumentParser
17 +
18 +
19 +def validate_create_args(args: argparse.Namespace) -> None:
20 + model = str(getattr(args, "model_tag", None) or args.model)
21 + revision = str(getattr(args, "revision", None) or "")
22 + if (
23 + Path(model).exists()
24 + or len(revision) != 40
25 + or any(character not in "0123456789abcdefABCDEF" for character in revision)
26 + ):
27 + raise ValueError(
28 + "snapshot create requires an immutable remote model and "
29 + "40-character --revision"
30 + )
31 + parallel_sizes = (
32 + ("tensor parallel", args.tensor_parallel_size),
33 + ("pipeline parallel", args.pipeline_parallel_size),
34 + ("data parallel", args.data_parallel_size),
35 + ("prefill context parallel", args.prefill_context_parallel_size),
36 + )
37 + for name, size in parallel_sizes:
38 + if size != 1:
39 + raise ValueError(f"snapshot create currently requires {name} size 1")
40 + unsupported_frontend = (
41 + args.api_server_count not in (None, 1)
42 + or args.grpc
43 + or args.headless
44 + or args.api_key
45 + or os.environ.get("VLLM_API_KEY")
46 + or args.uds
47 + or args.middleware
48 + )
49 + if unsupported_frontend or any(
50 + (
51 + args.ssl_keyfile,
52 + args.ssl_certfile,
53 + args.ssl_ca_certs,
54 + args.enable_ssl_refresh,
55 + args.ssl_ciphers,
56 + )
57 + ):
58 + raise ValueError(
59 + "snapshot create requires one unauthenticated plaintext HTTP server"
60 + )
61 + if args.logprobs_mode in {"raw_logits", "processed_logits"}:
62 + raise ValueError("snapshot canary requires a log-probability mode")
63 + if any(
64 + getattr(args, name, None) is not None
65 + for name in ("speculative_config", "spec_method", "spec_model", "spec_tokens")
66 + ):
67 + raise ValueError("snapshot create does not support speculative decoding")
68 + if platform.system() != "Linux" or platform.machine() != "x86_64":
69 + raise ValueError("snapshot create requires Linux x86_64")
70 +
71 +
72 +def run_create(args: argparse.Namespace) -> None:
73 + validate_create_args(args)
74 + from vllm.snapshot.controller import create_snapshot
75 +
76 + create_snapshot(args)
77 +
78 +
79 +def run_inspect(args: argparse.Namespace) -> None:
80 + from vllm.snapshot.manifest import inspect_snapshot
81 +
82 + print(json.dumps(inspect_snapshot(Path(args.snapshot_dir)), indent=2))
83 +
84 +
85 +def run_restore(args: argparse.Namespace) -> None:
86 + from vllm.snapshot.controller import restore_snapshot
87 +
88 + restore_snapshot(args)
89 +
90 +
91 +class SnapshotSubcommand(CLISubcommand):
92 + """The `snapshot` subcommand for the vLLM CLI."""
93 +
94 + name = "snapshot"
95 +
96 + def __init__(self, *, create_requested: bool = False) -> None:
97 + self.create_requested = create_requested
98 +
99 + @staticmethod
100 + def cmd(args: argparse.Namespace) -> None:
101 + args.snapshot_dispatch(args)
102 +
103 + def subparser_init(
104 + self, subparsers: argparse._SubParsersAction
105 + ) -> FlexibleArgumentParser:
106 + parser = subparsers.add_parser(
107 + self.name,
108 + help="Create, inspect, or restore an initialized vLLM snapshot.",
109 + usage="vllm snapshot <create|inspect|restore> [options]",
110 + )
111 + actions = parser.add_subparsers(required=True, dest="snapshot_action")
112 +
113 + create_parser = actions.add_parser(
114 + "create", help="Create a snapshot from an initialized TP1 engine."
115 + )
116 + if self.create_requested:
117 + from vllm.entrypoints.launchers.cli_args import make_arg_parser
118 +
119 + create_parser = make_arg_parser(create_parser)
120 + else:
121 + create_parser.add_argument("model_tag", nargs="?")
122 + create_parser.add_argument("--snapshot-dir", required=True)
123 + create_parser.set_defaults(snapshot_dispatch=run_create)
124 +
125 + inspect_parser = actions.add_parser(
126 + "inspect", help="Inspect a snapshot without restoring it."
127 + )
128 + inspect_parser.add_argument("snapshot_dir")
129 + inspect_parser.set_defaults(snapshot_dispatch=run_inspect)
130 +
131 + restore_parser = actions.add_parser(
132 + "restore", help="Restore a same-host TP1 snapshot."
133 + )
134 + restore_parser.add_argument("snapshot_dir")
135 + restore_parser.add_argument("--host", default=None)
136 + restore_parser.add_argument("--port", type=int, default=8000)
137 + restore_parser.set_defaults(snapshot_dispatch=run_restore)
138 +
139 + return parser
140 +
141 +
142 +def cmd_init(*, create_requested: bool = False) -> list[CLISubcommand]:
143 + return [SnapshotSubcommand(create_requested=create_requested)]
vllm/snapshot/__init__.py new
+4
@@ -0,0 +1,4 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +"""Create and restore explicitly compatible vLLM engine snapshots."""
vllm/snapshot/controller.py new
+132
@@ -0,0 +1,132 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Create and restore transaction orchestration for local snapshots."""
4 +
5 +import argparse
6 +import shutil
7 +import sys
8 +from pathlib import Path
9 +
10 +from vllm.snapshot.manifest import (
11 + read_manifest,
12 + validate_artifact_root,
13 + validate_identity,
14 +)
15 +from vllm.snapshot.runtime import (
16 + LocalSnapshotTools,
17 + SnapshotCreateError,
18 + SnapshotRestoreError,
19 + _error_detail,
20 +)
21 +from vllm.snapshot.types import Oracle, oracles_match
22 +
23 +
24 +def _child_engine_argv(
25 + args: argparse.Namespace, engine_argv: tuple[str, ...] | None
26 +) -> tuple[str, ...]:
27 + """Build the child argv from the first argv source that is present.
28 +
29 + In order: an explicit ``engine_argv`` from the caller, the process argv
30 + tail when invoked as ``vllm snapshot create ...``, then the bare model from
31 + the parsed args. Whichever source wins, snapshot options are stripped and
32 + sleep mode is appended.
33 + """
34 + if not engine_argv:
35 + process_argv = tuple(sys.argv[1:])
36 + if process_argv[:2] == ("snapshot", "create"):
37 + engine_argv = process_argv[2:]
38 + else:
39 + model = getattr(args, "model_tag", None) or getattr(args, "model", None)
40 + if not model:
41 + raise SnapshotCreateError("snapshot create requires a model argument")
42 + engine_argv = (str(model),)
43 + child_argv: list[str] = []
44 + iterator = iter(engine_argv)
45 + for item in iterator:
46 + option, separator, _ = item.partition("=")
47 + option = option.replace("_", "-")
48 + if len(option) > 2 and "--snapshot-dir".startswith(option):
49 + if not separator:
50 + next(iterator, None)
51 + else:
52 + child_argv.append(item)
53 + if "--enable-sleep-mode" not in child_argv:
54 + child_argv.append("--enable-sleep-mode")
55 + return tuple(child_argv)
56 +
57 +
58 +def create_snapshot(
59 + args: argparse.Namespace,
60 + *,
61 + engine_argv: tuple[str, ...] | None = None,
62 + tools: LocalSnapshotTools | None = None,
63 +) -> None:
64 + target = Path(args.snapshot_dir).absolute()
65 + toolset = tools or LocalSnapshotTools()
66 + toolset.preflight("create", target)
67 + if target.exists() or target.is_symlink():
68 + raise SnapshotCreateError(f"snapshot target already exists: {target}")
69 + validate_artifact_root(target, creating=True)
70 + target.parent.mkdir(mode=0o700, parents=False, exist_ok=True)
71 + validate_artifact_root(target.parent, creating=False)
72 + target.mkdir(mode=0o700)
73 + validate_artifact_root(target, creating=False)
74 + published = False
75 + root_pid: int | None = None
76 + try:
77 + child_argv = _child_engine_argv(args, engine_argv)
78 + root_pid = toolset.launch_child(target, child_argv)
79 + oracle = toolset.wait_ready(target, root_pid)
80 + inventory = toolset.inventory(root_pid, target)
81 + toolset.dump(target, inventory)
82 + toolset.verify_dead(inventory)
83 + manifest = toolset.make_manifest(args, child_argv, inventory, oracle, target)
84 + toolset.publish(target, manifest)
85 + published = True
86 + except BaseException:
87 + if root_pid is not None:
88 + toolset.abort_create(root_pid)
89 + raise
90 + finally:
91 + # Runs even when abort_create raised, so no partial artifact is left.
92 + if not published and target.exists():
93 + shutil.rmtree(target)
94 +
95 +
96 +def restore_snapshot(
97 + args: argparse.Namespace, *, tools: LocalSnapshotTools | None = None
98 +) -> None:
99 + artifact = Path(args.snapshot_dir).absolute()
100 + toolset = tools or LocalSnapshotTools()
101 + toolset.preflight("restore", artifact)
102 + manifest = read_manifest(artifact)
103 + validate_identity(manifest, toolset.current_identity(manifest.gpu_uuid))
104 +
105 + root_pid: int | None = None
106 + try:
107 + root_pid = toolset.restore(artifact, manifest)
108 + toolset.release(artifact, args.host, args.port)
109 + toolset.wait_listener(root_pid, args.host, args.port)
110 + actual = toolset.request_oracle(args.host, args.port, manifest)
111 + expected = Oracle(
112 + token_ids=manifest.oracle_token_ids,
113 + text=manifest.oracle_text,
114 + sampled_token_logprob=manifest.oracle_sampled_token_logprob,
115 + )
116 + if not oracles_match(expected, actual):
117 + raise SnapshotRestoreError(
118 + f"snapshot oracle mismatch: expected={expected!r}, actual={actual!r}"
119 + )
120 + toolset.complete_restore(root_pid)
121 + except BaseException as error:
122 + if root_pid is not None:
123 + try:
124 + toolset.cleanup(root_pid)
125 + except BaseException as cleanup_error:
126 + raise SnapshotRestoreError(
127 + f"snapshot restore failed: {_error_detail(error)}; "
128 + f"cleanup failed: {_error_detail(cleanup_error)}"
129 + ) from error
130 + if isinstance(error, SnapshotRestoreError):
131 + raise
132 + raise SnapshotRestoreError(str(error)) from error
vllm/snapshot/manifest.py new
+277
@@ -0,0 +1,277 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import json
5 +import math
6 +import os
7 +import stat
8 +from pathlib import Path
9 +from typing import Annotated, Any, Literal
10 +
11 +from pydantic import (
12 + BaseModel,
13 + ConfigDict,
14 + Field,
15 + StrictFloat,
16 + StrictInt,
17 + StrictStr,
18 + ValidationError,
19 + field_validator,
20 + model_validator,
21 +)
22 +from typing_extensions import Self
23 +
24 +
25 +class SnapshotCompatibilityError(RuntimeError):
26 + """The snapshot identity does not match the requested runtime."""
27 +
28 +
29 +class SnapshotSecurityError(RuntimeError):
30 + """The snapshot artifact does not meet the private-path contract."""
31 +
32 +
33 +_MAX_PID = 2**31 - 1
34 +_PositivePid = Annotated[int, Field(gt=0, le=_MAX_PID)]
35 +_OracleTokenId = Annotated[int, Field(ge=0)]
36 +
37 +
38 +class SnapshotRuntimeIdentity(BaseModel):
39 + model_config = ConfigDict(extra="forbid", frozen=True, strict=True)
40 +
41 + vllm_version: str
42 + python_version: str
43 + torch_version: str
44 + cuda_runtime: str
45 + driver_version: str
46 + criu_version: str
47 + cuda_checkpoint_sha256: str
48 + kernel_release: str
49 + host_id: str
50 + gpu_name: str
51 + gpu_uuid: str
52 + environment: tuple[tuple[str, str], ...]
53 +
54 +
55 +class SnapshotManifest(SnapshotRuntimeIdentity):
56 + schema_version: Literal[1]
57 + boundary: Literal["post-engine-init-reloadable-state-released"]
58 + created_at: str
59 + artifact_bytes: Annotated[int, Field(ge=0)]
60 + model: str
61 + served_model_name: str
62 + model_revision: str
63 + tokenizer_revision: str
64 + engine_argv: tuple[str, ...]
65 + process_tree: Annotated[tuple[_PositivePid, ...], Field(min_length=1)]
66 + cuda_holders: Annotated[tuple[_PositivePid, ...], Field(min_length=1)]
67 + external_cache_files: tuple[tuple[str, str], ...] = ()
68 + oracle_token_ids: Annotated[
69 + tuple[_OracleTokenId, ...], Field(min_length=1, max_length=1)
70 + ]
71 + oracle_text: str
72 + oracle_sampled_token_logprob: float
73 +
74 + @field_validator("schema_version", mode="before")
75 + @classmethod
76 + def _require_schema_version_one(cls, value: Any) -> Any:
77 + if type(value) is not int or value != 1:
78 + raise ValueError("schema version must be integer 1")
79 + return value
80 +
81 + @field_validator("oracle_sampled_token_logprob", mode="before")
82 + @classmethod
83 + def _require_finite_float(cls, value: Any) -> Any:
84 + if type(value) is not float or not math.isfinite(value):
85 + raise ValueError("sampled token log probability must be a finite float")
86 + return value
87 +
88 + @model_validator(mode="after")
89 + def _validate_process_relationships(self) -> Self:
90 + if len(set(self.process_tree)) != len(self.process_tree):
91 + raise ValueError("process_tree contains duplicate PIDs")
92 + if len(set(self.cuda_holders)) != len(self.cuda_holders):
93 + raise ValueError("cuda_holders contains duplicate PIDs")
94 + if not set(self.cuda_holders).issubset(self.process_tree):
95 + raise ValueError("cuda_holders must be a subset of process_tree")
96 + return self
97 +
98 +
99 +class ReleaseMarker(BaseModel):
100 + """Release marker the controller writes; unknown keys are ignored."""
101 +
102 + release: Literal[True]
103 + host: StrictStr | None = None
104 + port: Annotated[StrictInt, Field(ge=1, le=65535)]
105 +
106 + @field_validator("release", mode="before")
107 + @classmethod
108 + def _require_json_true(cls, value: Any) -> Any:
109 + # A literal accepts 1 as True; the barrier requires JSON true itself.
110 + if value is not True:
111 + raise ValueError("release must be JSON true")
112 + return value
113 +
114 +
115 +class ReadyMarker(BaseModel):
116 + """Ready marker the child writes; unknown keys are ignored."""
117 +
118 + token_ids: tuple[StrictInt, ...]
119 + text: StrictStr
120 + sampled_token_logprob: StrictFloat | StrictInt
121 +
122 +
123 +def validate_identity(
124 + expected: SnapshotRuntimeIdentity, actual: SnapshotRuntimeIdentity
125 +) -> None:
126 + """Require an exact match for every field that can affect compatibility."""
127 + # ``expected`` may be a manifest, so compare the identity fields only.
128 + fields = set(SnapshotRuntimeIdentity.model_fields)
129 + expected_values = expected.model_dump(include=fields)
130 + actual_values = actual.model_dump(include=fields)
131 + differing = [
132 + name
133 + for name in SnapshotRuntimeIdentity.model_fields
134 + if expected_values[name] != actual_values[name]
135 + ]
136 + if differing:
137 + raise SnapshotCompatibilityError(f"snapshot mismatch: {', '.join(differing)}")
138 +
139 +
140 +def _existing_path_chain(path: Path):
141 + current = path
142 + while True:
143 + if current.exists() or current.is_symlink():
144 + yield current
145 + if current.parent == current:
146 + break
147 + current = current.parent
148 +
149 +
150 +def _is_trusted_sticky_directory(path_stat: os.stat_result) -> bool:
151 + return (
152 + path_stat.st_uid == 0
153 + and stat.S_ISDIR(path_stat.st_mode)
154 + and bool(path_stat.st_mode & stat.S_ISVTX)
155 + )
156 +
157 +
158 +def validate_artifact_root(path: Path, *, creating: bool) -> None:
159 + """Validate that an artifact path cannot be replaced by another user."""
160 + path = Path(os.path.abspath(path))
161 + if path.is_symlink():
162 + raise SnapshotSecurityError(f"snapshot path is a symlink: {path}")
163 + if not creating and not path.exists():
164 + raise SnapshotSecurityError(f"snapshot path does not exist: {path}")
165 +
166 + effective_uid = os.geteuid()
167 + for component in _existing_path_chain(path):
168 + component_stat = component.lstat()
169 + if stat.S_ISLNK(component_stat.st_mode):
170 + raise SnapshotSecurityError(
171 + f"snapshot path contains a symlink: {component}"
172 + )
173 + if component_stat.st_uid not in (0, effective_uid):
174 + raise SnapshotSecurityError(
175 + f"snapshot path has an untrusted owner: {component}"
176 + )
177 + if component_stat.st_mode & (
178 + stat.S_IWGRP | stat.S_IWOTH
179 + ) and not _is_trusted_sticky_directory(component_stat):
180 + raise SnapshotSecurityError(
181 + f"snapshot path has a group- or world-writable ancestor: {component}"
182 + )
183 +
184 + if path.exists():
185 + path_stat = path.lstat()
186 + if not stat.S_ISDIR(path_stat.st_mode):
187 + raise SnapshotSecurityError(f"snapshot path is not a directory: {path}")
188 + if path_stat.st_uid != effective_uid:
189 + raise SnapshotSecurityError(
190 + f"snapshot directory is not owned by the current user: {path}"
191 + )
192 + if stat.S_IMODE(path_stat.st_mode) & 0o077:
193 + raise SnapshotSecurityError(
194 + f"snapshot directory must have mode 0700: {path}"
195 + )
196 +
197 +
198 +def _validation_path(error: ValidationError) -> str:
199 + detail = error.errors(include_url=False)[0]
200 + location = detail["loc"]
201 + if not location:
202 + return "JSON" if detail["type"] == "json_invalid" else "root"
203 + return ".".join(map(str, location))
204 +
205 +
206 +def _fsync_directory(path: Path) -> None:
207 + directory_fd = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
208 + try:
209 + os.fsync(directory_fd)
210 + finally:
211 + os.close(directory_fd)
212 +
213 +
214 +def _write_json_atomic(path: Path, payload: object, *, overwrite: bool = False) -> None:
215 + temporary = path.with_name(f"{path.name}.tmp")
216 + if overwrite:
217 + temporary.unlink(missing_ok=True)
218 + elif path.exists() or path.is_symlink():
219 + raise FileExistsError(path)
220 + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
221 + descriptor = os.open(temporary, flags, 0o600)
222 + try:
223 + with os.fdopen(descriptor, "wb") as output:
224 + output.write(
225 + json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
226 + )
227 + output.flush()
228 + os.fsync(output.fileno())
229 + os.replace(temporary, path)
230 + _fsync_directory(path.parent)
231 + except BaseException:
232 + temporary.unlink(missing_ok=True)
233 + raise
234 +
235 +
236 +def write_manifest_atomic(path: Path, manifest: SnapshotManifest) -> None:
237 + """Write a new private manifest and make its directory entry durable."""
238 + path = Path(path)
239 + validate_artifact_root(path, creating=False)
240 + destination = path / "manifest.json"
241 + try:
242 + _write_json_atomic(destination, manifest.model_dump(mode="json"))
243 + except FileExistsError as error:
244 + raise SnapshotSecurityError(
245 + f"manifest already exists: {destination}"
246 + ) from error
247 +
248 +
249 +def read_manifest(path: Path) -> SnapshotManifest:
250 + """Read a snapshot published by atomically installing its manifest."""
251 + path = Path(path)
252 + validate_artifact_root(path, creating=False)
253 + manifest_path = path / "manifest.json"
254 + if manifest_path.is_symlink():
255 + raise SnapshotSecurityError(f"manifest is a symlink: {manifest_path}")
256 + flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
257 + try:
258 + file_descriptor = os.open(manifest_path, flags)
259 + except FileNotFoundError as error:
260 + raise SnapshotSecurityError(
261 + f"manifest does not exist: {manifest_path}"
262 + ) from error
263 + try:
264 + with os.fdopen(file_descriptor, encoding="utf-8") as manifest_file:
265 + payload = manifest_file.read()
266 + except UnicodeDecodeError as error:
267 + raise SnapshotCompatibilityError("invalid snapshot manifest: JSON") from error
268 + try:
269 + return SnapshotManifest.model_validate_json(payload, strict=True)
270 + except ValidationError as error:
271 + raise SnapshotCompatibilityError(
272 + f"invalid snapshot manifest: {_validation_path(error)}"
273 + ) from error
274 +
275 +
276 +def inspect_snapshot(path: Path) -> dict[str, object]:
277 + return read_manifest(path).model_dump(mode="json")
vllm/snapshot/runtime.py new
+1246
@@ -0,0 +1,1246 @@
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)
vllm/snapshot/server.py new
+252
@@ -0,0 +1,252 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import argparse
5 +import asyncio
6 +import json
7 +import os
8 +import stat
9 +import sys
10 +from dataclasses import dataclass
11 +from pathlib import Path
12 +from typing import Any
13 +
14 +from pydantic import ValidationError
15 +
16 +from vllm.snapshot.manifest import ReleaseMarker, _validation_path, _write_json_atomic
17 +from vllm.snapshot.types import Oracle, oracles_match
18 +
19 +_CANARY_PROMPT = "The capital of France is"
20 +
21 +
22 +class SnapshotCanaryError(RuntimeError):
23 + """The initialized engine did not produce a valid snapshot oracle."""
24 +
25 +
26 +class SnapshotBarrierError(RuntimeError):
27 + """The controller supplied an invalid snapshot release marker."""
28 +
29 +
30 +@dataclass(frozen=True)
31 +class ListenerConfig:
32 + host: str | None
33 + port: int
34 +
35 +
36 +@dataclass(frozen=True)
37 +class ControlArgs:
38 + ready_file: Path
39 + release_file: Path
40 + release_timeout_s: float
41 +
42 +
43 +def write_ready_atomic(path: Path, oracle: Oracle) -> None:
44 + _write_json_atomic(
45 + path,
46 + {
47 + "sampled_token_logprob": oracle.sampled_token_logprob,
48 + "token_ids": oracle.token_ids,
49 + "text": oracle.text,
50 + },
51 + )
52 +
53 +
54 +def read_release_marker(path: Path) -> ListenerConfig:
55 + path = Path(path)
56 + path_stat = path.lstat()
57 + if stat.S_ISLNK(path_stat.st_mode):
58 + raise SnapshotBarrierError("release marker must not be a symlink")
59 + try:
60 + payload = json.loads(path.read_text())
61 + except (OSError, json.JSONDecodeError) as error:
62 + raise SnapshotBarrierError("release marker is not valid JSON") from error
63 + try:
64 + marker = ReleaseMarker.model_validate(payload)
65 + except ValidationError as error:
66 + raise SnapshotBarrierError(
67 + f"release marker is invalid: {_validation_path(error)}"
68 + ) from error
69 + return ListenerConfig(host=marker.host, port=marker.port)
70 +
71 +
72 +def parse_control_args(argv: list[str]) -> tuple[ControlArgs, list[str]]:
73 + parser = argparse.ArgumentParser(add_help=False)
74 + parser.add_argument("--ready-file", type=Path, required=True)
75 + parser.add_argument("--release-file", type=Path, required=True)
76 + parser.add_argument("--release-timeout-s", type=float, default=900.0)
77 + control_args, remaining = parser.parse_known_args(argv)
78 + if remaining and remaining[0] == "--":
79 + remaining = remaining[1:]
80 + if control_args.release_timeout_s <= 0:
81 + raise ValueError("release timeout must be positive")
82 + return (
83 + ControlArgs(
84 + ready_file=control_args.ready_file,
85 + release_file=control_args.release_file,
86 + release_timeout_s=control_args.release_timeout_s,
87 + ),
88 + remaining,
89 + )
90 +
91 +
92 +async def _release_reloadable_state(engine: Any) -> None:
93 + """Discard model and KV state before the process image is captured."""
94 + await engine.sleep(level=2)
95 +
96 +
97 +async def _restore_reloadable_state(engine: Any) -> None:
98 + """Rebuild state discarded by ``_release_reloadable_state``."""
99 + await engine.wake_up(tags=["weights"])
100 + await engine.collective_rpc("reload_weights")
101 + await engine.wake_up(tags=["kv_cache"])
102 +
103 +
104 +def oracle_from_request_output(request_output: Any) -> Oracle:
105 + try:
106 + candidate = request_output.outputs[0]
107 + token_ids = tuple(candidate.token_ids)
108 + (sampled_token_id,) = token_ids
109 + return Oracle(
110 + token_ids=token_ids,
111 + text=candidate.text,
112 + sampled_token_logprob=candidate.logprobs[0][sampled_token_id].logprob,
113 + )
114 + except ValueError as error:
115 + raise SnapshotCanaryError(
116 + "snapshot canary must produce exactly one finite token logprob"
117 + ) from error
118 + except (AttributeError, IndexError, KeyError, TypeError) as error:
119 + raise SnapshotCanaryError(
120 + "snapshot canary did not return sampled token logprob"
121 + ) from error
122 +
123 +
124 +async def run_engine_canary(engine: Any) -> Oracle:
125 + from vllm import SamplingParams
126 +
127 + final_output = None
128 + sampling_params = SamplingParams(
129 + temperature=0,
130 + min_tokens=1,
131 + max_tokens=1,
132 + seed=0,
133 + logprobs=0,
134 + )
135 + async for output in engine.generate(
136 + _CANARY_PROMPT,
137 + sampling_params,
138 + request_id="vllm-snapshot-canary",
139 + ):
140 + final_output = output
141 + if final_output is None:
142 + raise SnapshotCanaryError("canary generation returned no output")
143 + return oracle_from_request_output(final_output)
144 +
145 +
146 +def detach_snapshot_streams() -> None:
147 + """Remove launch-log descriptors from the reusable process image."""
148 + sys.stdout.flush()
149 + sys.stderr.flush()
150 + sink = os.open(os.devnull, os.O_WRONLY)
151 + try:
152 + os.dup2(sink, sys.stdout.fileno())
153 + os.dup2(sink, sys.stderr.fileno())
154 + finally:
155 + os.close(sink)
156 +
157 +
158 +async def wait_for_release_marker(
159 + path: Path,
160 + *,
161 + timeout_s: float,
162 + poll_interval_s: float = 0.05,
163 +) -> ListenerConfig:
164 + if timeout_s <= 0:
165 + raise ValueError("release timeout must be positive")
166 + if poll_interval_s <= 0:
167 + raise ValueError("release poll interval must be positive")
168 + remaining_s = timeout_s
169 + while not path.exists():
170 + if remaining_s <= 0:
171 + raise TimeoutError(f"release marker not found before timeout: {path}")
172 + delay_s = min(poll_interval_s, remaining_s)
173 + await asyncio.sleep(delay_s)
174 + remaining_s -= delay_s
175 + return read_release_marker(path)
176 +
177 +
178 +def parse_vllm_args(argv: list[str]) -> Any:
179 + from vllm.entrypoints.launchers.cli_args import (
180 + make_arg_parser,
181 + validate_parsed_serve_args,
182 + )
183 + from vllm.utils.argparse_utils import FlexibleArgumentParser
184 +
185 + parser = FlexibleArgumentParser(prog="python -m vllm.snapshot.server")
186 + args = make_arg_parser(parser).parse_args(argv)
187 + if args.model_tag is not None:
188 + args.model = args.model_tag
189 + if args.grpc or args.headless:
190 + raise ValueError("snapshot server requires the HTTP frontend")
191 + if args.api_server_count not in (None, 1):
192 + raise ValueError("snapshot server supports one API server")
193 + args.api_server_count = None
194 + validate_parsed_serve_args(args)
195 + return args
196 +
197 +
198 +async def run_vllm_snapshot_child(control: ControlArgs, args: Any) -> None:
199 + from vllm.entrypoints.launchers.api_server.entry import (
200 + build_and_serve,
201 + build_async_engine_client,
202 + )
203 + from vllm.entrypoints.launchers.launcher import setup_server
204 +
205 + args.enable_sleep_mode = True
206 +
207 + async with build_async_engine_client(args) as engine:
208 + oracle = await run_engine_canary(engine)
209 + try:
210 + await _release_reloadable_state(engine)
211 + await _restore_reloadable_state(engine)
212 + rehearsal_oracle = await run_engine_canary(engine)
213 + if not oracles_match(oracle, rehearsal_oracle):
214 + raise SnapshotCanaryError("snapshot rehearsal changed canary output")
215 + await _release_reloadable_state(engine)
216 + except SnapshotCanaryError:
217 + raise
218 + except Exception as error:
219 + raise SnapshotCanaryError("snapshot rehearsal failed") from error
220 +
221 + detach_snapshot_streams()
222 + write_ready_atomic(control.ready_file, oracle)
223 + listener = await wait_for_release_marker(
224 + control.release_file,
225 + timeout_s=control.release_timeout_s,
226 + )
227 + args.host = listener.host
228 + args.port = listener.port
229 + await _restore_reloadable_state(engine)
230 + listen_address, sock = setup_server(args, reuse_port=False)
231 + try:
232 + shutdown_task = await build_and_serve(
233 + engine,
234 + listen_address,
235 + sock,
236 + args,
237 + )
238 + await shutdown_task
239 + finally:
240 + sock.close()
241 +
242 +
243 +def main(argv: list[str] | None = None) -> None:
244 + control, remaining = parse_control_args(sys.argv[1:] if argv is None else argv)
245 + args = parse_vllm_args(remaining)
246 + import uvloop
247 +
248 + uvloop.run(run_vllm_snapshot_child(control, args))
249 +
250 +
251 +if __name__ == "__main__":
252 + main()
vllm/snapshot/types.py new
+37
@@ -0,0 +1,37 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +
4 +import math
5 +from dataclasses import dataclass
6 +
7 +
8 +@dataclass(frozen=True)
9 +class Oracle:
10 + token_ids: tuple[int, ...]
11 + text: str
12 + sampled_token_logprob: float
13 +
14 + def __post_init__(self) -> None:
15 + if len(self.token_ids) != 1:
16 + raise ValueError("snapshot oracle must contain exactly one token")
17 + value = self.sampled_token_logprob
18 + if (
19 + isinstance(value, bool)
20 + or not isinstance(value, (int, float))
21 + or not math.isfinite(value)
22 + ):
23 + raise ValueError("sampled token logprob must be finite")
24 + object.__setattr__(self, "sampled_token_logprob", float(value))
25 +
26 +
27 +def oracles_match(expected: Oracle, actual: Oracle) -> bool:
28 + return (
29 + expected.token_ids == actual.token_ids
30 + and expected.text == actual.text
31 + and math.isclose(
32 + expected.sampled_token_logprob,
33 + actual.sampled_token_logprob,
34 + rel_tol=0.0,
35 + abs_tol=1e-3,
36 + )
37 + )
vllm/utils/argparse_utils.py
+14 -7
@@ -519,7 +519,7 @@ class FlexibleArgumentParser(ArgumentParser):
519 config_args = self.load_config_file(file_path)
520
521 # 0th index might be the sub command {serve,chat,complete,...}
522 - # optionally followed by model_tag (only for serve)
522 + # optionally followed by model_tag (serve or snapshot create)
523 # followed by config args
524 # followed by rest of cli args.
525 # maintaining this order will enforce the precedence
@@ -527,8 +527,11 @@ class FlexibleArgumentParser(ArgumentParser):
527 if args[0].startswith("-"):
528 # No sub command (e.g., api_server entry point)
529 args = config_args + args[0:index] + args[index + 2 :]
530 - elif args[0] == "serve":
531 - model_in_cli = len(args) > 1 and not args[1].startswith("-")
530 + elif args[0] == "serve" or args[:2] == ["snapshot", "create"]:
531 + model_index = 1 if args[0] == "serve" else 2
532 + model_in_cli = len(args) > model_index and not args[model_index].startswith(
533 + "-"
534 + )
535 model_in_config = any(arg == "--model" for arg in config_args)
536
537 if not model_in_cli and not model_in_config:
@@ -540,15 +543,19 @@ class FlexibleArgumentParser(ArgumentParser):
543 if model_in_cli:
544 # Model specified as positional arg, keep CLI version
545 args = (
543 - [args[0]]
544 - + [args[1]]
546 + args[: model_index + 1]
547 + config_args
546 - + args[2:index]
548 + + args[model_index + 1 : index]
549 + args[index + 2 :]
550 )
551 else:
552 # No model in CLI, use config if available
551 - args = [args[0]] + config_args + args[1:index] + args[index + 2 :]
553 + args = (
554 + args[:model_index]
555 + + config_args
556 + + args[model_index:index]
557 + + args[index + 2 :]
558 + )
559 else:
560 args = [args[0]] + config_args + args[1:index] + args[index + 2 :]
561