Shadow Engine Recovery (vLLM)¶
Shadow engine recovery keeps a fully initialized standby dynamo.vllm engine parked on the same GPUs as the serving engine. Dynamo's GPU Memory Service (GMS) owns the model weights in a process of its own, so the standby maps the one copy already in HBM instead of loading another. When the serving engine dies, the standby takes over in seconds instead of the minutes a cold restart costs; the dead engine is relaunched in place and becomes the new standby.
engine.failover turns this on for a vLLM recipe behind the Dynamo frontend. It implies a gms service, one instance per worker, and launches the extra engines with the environment Dynamo's Kubernetes operator would give them. roles.<role>.restart relaunches an engine that exits.
Table of Contents¶
- Why no DRA
- Quick Start
- What Runs
- What srtslurm Owns vs What You Set
- Configuration Reference
- Sizing GPU Memory
- Testing a Failover
- Validation
- Limitations
- Troubleshooting
Why no DRA¶
The blog lists Dynamic Resource Allocation (Kubernetes 1.34 with the NVIDIA GPU DRA driver) as a requirement. That requirement is about Kubernetes, not about the feature: a pod's containers each get their own GPU allocation, and the default device plugin cannot hand the same GPU to the GMS sidecar and to two engine containers at once. DRA can.
On SLURM the whole node is allocated to the job and every step on it sees the same /dev/nvidia*. The three steps of one worker share its GPUs by setting the same CUDA_VISIBLE_DEVICES. Everything else the feature needs is a process property: the GMS server's CUDA VMM allocations are reference counted and survive any engine's exit as long as one mapping remains, the election is a POSIX flock on a file, and the relaunch is a supervisor watching step exits. None of that knows about Kubernetes.
The two things Kubernetes gives for free that srtslurm provides through existing machinery are a directory all containers of a worker share on the node (an emptyDir there; a node-local host path here) and the "restart the failed container" policy (the pod's restartPolicy: Always; roles.<role>.restart here).
Quick Start¶
schema: 2
model:
container: nvcr.io/nvidia/ai-dynamo/vllm-runtime:1.4.2 # ships gpu_memory_service
dynamo:
install: false
frontend:
type: dynamo
engine:
type: vllm
failover: {} # defaults: one shadow, /dev/shm
roles:
agg:
nodes: 1
workers: 2
gpus: 1
restart:
policy: always # relaunch an exited engine in place as the new shadow
args:
tensor-parallel-size: 1
gpu-memory-utilization: 0.4 # leave room for the shadow, see Sizing GPU Memory
examples/features/vllm-failover.yaml is the runnable version. srtctl dry-run -f <recipe> prints a "Shadow Engine Recovery" panel with the layout and lists the gms service under Services before you submit.
The container must ship the gpu_memory_service package. The ai-dynamo PyPI wheel does not include it, so the dynamo.source: pypi path does not work here; the nvcr.io/nvidia/ai-dynamo/vllm-runtime images from 1.4 on do, with dynamo.install: false.
What Runs¶
Per worker and node, in this order:
flowchart LR
subgraph node["node im-b200-c021"]
gms["step service_gms_agg_0_#60;node#62;<br/>gms service (per worker)<br/>python3 -m gpu_memory_service --device 0<br/>(one server per GPU of the worker)"]
engine0["step agg_0_#60;node#62;<br/>engine 0, ENGINE_ID=0<br/>python3 -m dynamo.vllm ... --load-format gms --gms-shadow-mode"]
engine1["step agg_0_#60;node#62;_e1<br/>engine 1, ENGINE_ID=1<br/>python3 -m dynamo.vllm ... --load-format gms --gms-shadow-mode"]
end
subgraph shm["/dev/shm/srtctl-#60;job#62;/agg_0/"]
sockets["gms_#60;GPU-UUID#62;_weights.sock<br/>gms_#60;GPU-UUID#62;_kv_cache.sock"]
lock["failover.lock"]
end
gms -- "binds" --> sockets
engine0 -- "maps weights" --> sockets
engine1 -- "maps weights" --> sockets
engine0 -- "flock: holds the lock, serving (registered)" --> lock
engine1 -. "flock: waiting, parked shadow (not registered)" .-> lock
- The gms service (
service_gms_<role>_<index>_<node>). A service implied byengine.failover,type: gms,placement.per: worker: one instance per worker on every worker node, in thebefore_workersphase, in the job container, with the worker'sCUDA_VISIBLE_DEVICES. It starts onegpu_memory_serviceserver per GPU of the worker, each binding aweightsand akv_cachesocket in the worker's directory, and printsGMS ready:once every socket exists; the service stage waits for that line before moving on. Critical (a worker without its weight server cannot recover), stopped after the engines at cleanup (shutdown tier 1), when it removes the directory. Declare it by name to change its container or its readiness timeout. - Engine 0 (
<role>_<index>_<node>).ENGINE_ID=0loads the weights from disk into GMS, or imports them read-only when they are already there after a relaunch. Every other engine imports read-only, so no two engines ever hold GMS's write lock at once. - Shadow engines (
<role>_<index>_<node>_e<k>). Same command, same GPUs, own ports (DYN_SYSTEM_PORT, KV events, NIXL side channel,VLLM_PORT). Each engine initializes fully (weights mapped, CUDA graphs captured, communicators up), sleeps, and blocks onfailover.lock. The one that acquires it wakes, materializes its KV cache and registers with the frontend. The frontend health gate counts registered instances, so shadows do not count toward the expected worker total. - Relaunch (
roles.<role>.restart). The worker supervisor's unit is one engine of a worker, so when the serving engine dies only its step is relaunched, with the role's backoff andmax_restarts, its log rotated to<log>.out.<n>, and the event recorded inworker_restarts.jsonand the lockfile; the engine that just took over is left alone. The relaunched engine loads through GMS (fast) and parks as the new shadow; the supervisor's readiness probe passes once it is parked, because Dynamo reports a standby healthy on purpose. Without a restart policy an exited engine's step simply ends androles.<role>.criticaldecides whether the run continues (the shadow still takes over either way).
Multi-node workers get the same treatment per node; every engine of a multi-node worker gets its own --master-port (29500 + 100 * ENGINE_ID) so their torch.distributed stores do not collide. Only single-node workers have been run so far.
Why the gms service is per worker rather than per node: GMS names its sockets after the NVML UUID of "device k" while the engine picks device k by CUDA index. With a subset CUDA_VISIBLE_DEVICES those disagree (NVML ignores it), so a node-level server would hand the worker on GPU 1 the socket of GPU 0. In the worker's own device view both sides name the same socket. This is what placement.per: worker exists for; see services.md.
What srtslurm Owns vs What You Set¶
| Piece | Owner | Value |
|---|---|---|
the gms service |
srtslurm | implied by engine.failover; declare services: [{name: gms, type: gms, ...}] to change its container or readiness timeout |
--load-format gms --gms-shadow-mode |
srtslurm | added to every engine; a load-format in roles.<role>.args must be gms or absent |
ENGINE_ID, GMS_SOCKET_DIR, FAILOVER_LOCK_PATH, DYN_VLLM_GMS_SHADOW_MODE, DYN_SYSTEM_STARTING_HEALTH_STATUS |
srtslurm | per engine, the same names the Dynamo operator injects |
CUDA_VISIBLE_DEVICES |
srtslurm | pinned on the engines and the gms instance (no --device-ids), so "device k" is the same GPU for all of them |
| ports | srtslurm | every engine is its own Process with its own ports, from the usual allocators |
--master-port (multi-node) |
srtslurm | 29500 + 100 * ENGINE_ID; a master-port in args moves the base |
gpu-memory-utilization |
you | see Sizing GPU Memory |
| the container | you | must ship gpu_memory_service; dynamo.install: false |
roles.<role>.restart |
you | relaunches an exited engine as the new shadow; roles.<role>.critical applies once its max_restarts are spent, or from the start without a policy |
Configuration Reference¶
engine:
type: vllm
failover:
shadow_engines: 1 # standby engines per worker
shared_dir: /dev/shm # node-local host path every container on the node sees
roles:
agg:
restart: # see topology.md, section "restart"
policy: always
backoff_seconds: 5
services: # optional: only to change the implied gms service
- name: gms
type: gms
readiness:
log: {pattern: "GMS ready:"}
timeout_seconds: 300 # also bounds the servers' own startup
| Key | Default | Meaning |
|---|---|---|
shadow_engines |
1 |
Standby engines per worker. Each one costs its CUDA context, captured graphs and communicator buffers in HBM while parked, and no weights. |
shared_dir |
/dev/shm |
Where srtctl-<job_id>/<role>_<index>/ (sockets and lock file) is created. It must be the same directory in every container on the node and it must be node-local: enroot bind-mounts the host's /dev/shm into every container, while /tmp is a fresh tmpfs per container and a Unix socket cannot be shared through the cluster filesystem. |
Validation refuses engine.failover without frontend.type: dynamo (the election lives in dynamo.vllm; a static router would also list the parked shadows), with dynamo.sidecar: true, with data-parallel-size on any role, or with a load-format other than gms. It warns when Dynamo is pip-installed at job start, because that path cannot supply gpu_memory_service. A declared gms service is refused without engine.failover or with placement.per: node.
Sizing GPU Memory¶
Two engines share each GPU. The weights are counted once (GMS owns them; both engines map the same physical pages). Everything else is per engine:
- the serving engine holds its KV cache, sized from
gpu-memory-utilizationas usual; - a parked shadow holds its CUDA context, captured CUDA graphs and NCCL/NIXL buffers, typically one to a few GB, and no KV cache (it reserves the address range and materializes it on promotion).
So gpu-memory-utilization must leave the shadow's standing cost free. The shadow's own profiling run accounts for the active engine being present, so a value that works is one where weights + active KV cache + one shadow's context and graphs fits. Start conservatively (the example uses 0.4 for a small model), read both engines' logs for [GMS] Scratch-KV engaged and the KV cache sizes, and raise it from there. A shadow that OOMs during its warm-up exits; with a restart policy it is relaunched until max_restarts is spent, then the role's critical flag decides.
Testing a Failover¶
Kill the engine process, not its step. scancel --signal=KILL <job>.<step> ends the step, and the supervisor (or, without a policy, the process monitor) treats that as the worker exit it is. What the blog measures is a process crash, and the engines run in the host PID namespace, so from the login node:
# which engine is serving: the lock file names the holder
srun --jobid <job> --overlap -w <node> -N1 cat /dev/shm/srtctl-<job>/agg_0/failover.lock # engine-0
# SIGKILL that engine (the dynamo.vllm parent and its EngineCore children)
srun --jobid <job> --overlap -w <node> -N1 bash -c '
for pid in $(pgrep -f "^python3 -m dynamo.vllm"); do
env=$(tr "\0" "\n" < /proc/$pid/environ 2>/dev/null) || continue
if echo "$env" | grep -qx "ENGINE_ID=0" &&
echo "$env" | grep -qx "FAILOVER_LOCK_PATH=/dev/shm/srtctl-'"$SLURM_JOB_ID"'/agg_0/failover.lock"; then
echo "killing $pid"; kill -9 $pid $(pgrep -P $pid)
fi
done'
Then watch:
- the shadow's log (
<node>_agg_w0_e1.out):[Shadow] Lock acquired, waking engine, then[Shadow] Engine awake, registering with discoveryandfailover_state engine=1 -> active; - the frontend's
/health: the dead instance id disappears and a new one takes its place; - the sweep log:
Worker agg_0_<node> exited with code 137; relaunching agg_0 in 5s (restart 1/3), thenRelaunched agg_0 as agg_0_<node>_r1; - engine 0's new log (
<node>_agg_w0.out; the crash log moved to<node>_agg_w0.out.1):Connected with rw_or_ro lock (granted=ro)andRead mode: imported 1.18 GiB(weights imported from GMS, no load from disk), then[Shadow] Engine sleeping, startup probe now passing, waiting for lock. The roles have swapped.
The lock file now reads engine-1. Kill again and it swaps back. The step logs are written by srun, which buffers, so read timings from the timestamps inside the lines rather than from when they appear in the file.
Validation¶
Validated on sa-b200 (one B200 node, two TP1 Qwen3-0.6B workers with one shadow each, examples/features/vllm-failover.yaml, nvcr.io/nvidia/ai-dynamo/vllm-runtime:1.4.2: Dynamo 1.4.2, vLLM 0.26.0, GMS 0.9.0), job 16074 on 2026-09-17. Times are from the timestamps inside the step logs.
| Event | Observed |
|---|---|
GMS start to GMS ready: (both sockets bound) |
9 s |
Engine 0 start to serving: weights loaded from disk into GMS, init engine 34 s (13.5 s compile), lock taken, KV cache (69 GiB) materialized, registered |
1 min 40 s |
Shadow start to parked: weights imported from GMS, init engine 34 s, Scratch-KV engaged (0.5 GiB physical), sleeping on the lock |
1 min 40 s (started 2 s after engine 0) |
| SIGKILL of the serving engine to the shadow holding the lock | 0.1 to 0.2 s (the lock is polled every 100 ms) |
| Lock held to awake: weights remapped (60 allocations, 1.18 GiB), KV cache reallocated (28 allocations, 69.23 GiB) | 0.27 s (It took 0.273367 seconds to wake up tags {'weights', 'kv_cache'}) |
Awake to registered: the frontend logs added model |
0.05 s |
| SIGKILL to the frontend routing to the shadow | about 0.5 s; the test script saw /health with the new instance id at 7.5 s and 11.7 s in two runs, at its own poll granularity |
| Frontend drops the dead instance's event publisher (lease expiry) | 10 s after the kill |
Killed engine relaunched in place: Connected with rw_or_ro lock (granted=ro), Read mode: imported 1.18 GiB in 1 s (no load from disk), init engine 21.9 s, parked |
49 s after the kill (in-step relaunch, compile cache warm) |
| Completions before, during (against the survivor), and after | all answered |
nvidia-smi during the run: 78.5 GiB used of 183 GiB per GPU, of which each engine process holds 2.7 GiB (CUDA context, graphs, communicators) and the rest is the GMS-owned weights plus the serving engine's KV cache. The weights appear under neither process.
A second cutover on the same job, killing engine 1 so the relaunched engine 0 (which had imported the weights read-only) took the lock back, behaved the same way.
Job 16086 (same recipe plus roles.agg.restart: {policy: always, backoff_seconds: 5}, on a build that merges this branch with roles.<role>.restart) is the layout as merged: the gms service launched service_gms_agg_0_<node> and service_gms_agg_1_<node> (each ready 9 s after its start) before the engines, and the supervisor tracked four units (agg_0, agg_0_e1, agg_1, agg_1_e1).
| Event | Observed |
|---|---|
| SIGKILL of engine 0 to the shadow active and registered | 0.4 s (lock 08:12:34.96, active 08:12:35.35) |
| Supervisor notices the exit (monitor tick) and schedules the relaunch | 5 s after the exit: Worker agg_0_<node> exited with code 137; relaunching agg_0 in 5s (restart 1/3) |
Relaunched as agg_0_<node>_r1; the shadow's step untouched |
11 s later (5 s backoff plus the srun) |
Relaunched engine imports the weights from GMS (granted=ro, imported 1.18 GiB), init engine 34 s (13.5 s compile: a new step is a new container, so the compile cache is cold), parked |
95 s after the kill; worker_restarts.json: outcome: ready, ready_seconds: 90.7, crash log rotated to <node>_agg_w0.out.1 |
Reverse cutover (kill engine 1; the relaunched engine 0 takes the lock; the supervisor relaunches engine 1 as agg_0_<node>_e1_r1) |
same shape, both engines back |
A relaunched engine pays the compile again because each srun step is a fresh container. Mounting a persistent TORCHINDUCTOR_CACHE_DIR / VLLM_CACHE_ROOT through container_mounts brings the relaunch close to the in-step number above.
Limitations¶
- vLLM only, behind the Dynamo frontend. SGLang and TRT-LLM have GMS weight-loading integrations but no shadow election in their Dynamo workers yet.
- No
data-parallel-size. Each DP rank would need its own GMS session and lock. - Multi-node workers are launched with the right ports but have not been run.
- Process failures only. A node or GPU failure still ends the run (SLURM has no spare in the allocation).
- The gms service is critical and not relaunched. If it dies, the engines keep their mappings and serve, but a relaunched engine cannot import the weights, so the run fails on the next engine exit rather than limping on.
- A promoted shadow starts with an empty KV cache (the current Dynamo preview does not carry it), so TTFT bumps briefly after a cutover.
- Tachometer scrapes every engine's
DYN_SYSTEM_PORT, shadows included; a parked shadow reports healthy and no traffic.
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
services[gms] exited with code 1 on <node> before its readiness probe passed and the log says No module named gpu_memory_service |
the container has no GMS | use a vllm-runtime image with dynamo.install: false |
GMS startup timed out: 0 of 2 sockets while the servers are running |
the servers bound their sockets somewhere the wrapper cannot see, or shared_dir is not writable |
check the service log's Socket path: lines; keep shared_dir on /dev/shm |
engines log connection refused on gms_*.sock |
shared_dir is not shared between the containers (a per-container /tmp) |
use /dev/shm or another host bind mount that enroot gives every container |
both engines serve at once (two instances per worker in /health) |
the lock directory was removed or recreated while engines ran, so they lock different inodes | do not touch srtctl-<job>/ during a run; a relaunched engine reopens the same path, which is fine as long as the file stays |
| a shadow is relaunched every backoff with a CUDA OOM | not enough free HBM for its context and graphs next to the active engine's KV cache | lower gpu-memory-utilization |
--gms-shadow-mode requires --load-format gms |
a load-format in args other than gms |
remove it; validation catches this before submit |
| the shadow took over but the dead engine never came back | no roles.<role>.restart policy |
add one; without it the step ends and critical decides |