---
title: "Solving vLLM cold starts on RunPod: why CRIU doesn’t work there"
description: "RunPod pods are containers without the privileges CRIU needs. How warmctl snapshots a warm vLLM engine in user space and starts Qwen3-8B in ~21 s, not 83 s."
author: Kirill Bespalov
date: 2026-11-01
url: https://warmctl.io/blog/vllm-cold-start-runpod/
---

# Solving vLLM cold starts on RunPod: why CRIU doesn’t work there

> Cold start is a tax on every new vLLM replica, and on RunPod it is steep: a 180B model takes eleven minutes or more before it answers. warmctl skips that wait by snapshotting and restoring an engine that is already warm. The catch: CRIU, the standard tool for process snapshots, cannot run in a RunPod container. Here is how we made it work anyway.

**Key results**

- RunPod pods run with Docker’s default capabilities and seccomp profile and a read-only /proc/sys, so CRIU cannot checkpoint or restore there.
- warmctl’s DMTCP backend checkpoints the vLLM process tree in user space, with NVIDIA cuda-checkpoint for GPU memory, and needs no extra privileges.
- Qwen3-8B on a new RunPod pod (L40S) serves in 20–23 s from a snapshot on a network volume in the same data center, against 83 s for vLLM with compile caches and 124 s cold.
- Qwen3.8-Flash-Next (180B, FP8) on a new RunPod pod with 4×RTX PRO 6000 serves in 215 s from a network volume and 21 s from the pod’s own copy, against 668 s for vLLM with compile caches and 751 s cold.
- A RunPod network volume in the same data center reads at 0.8–1.0 GiB/s per pod, about twice a Global Volume, and sets the restore time.

Qwen3-8B · 1×L40SQwen3.8-Flash-Next · 4×RTX PRO 6000vLLM, cold124 svLLM, compile caches on the volume83 swarmctl, snapshot on the volume21 swarmctl, the pod’s own copy11 s
Qwen3-8B on a new RunPod pod with one L40S. The model, compile caches and snapshot live on a network volume in the same data center. Time until the server answers.

  ~21 sa new pod serving Qwen3-8B, snapshot on a network volume4×faster than vLLM with warm compile caches11 sonce the pod keeps a local copy**0**extra privileges: Docker’s defaults are enough

## Where the time goes during a cold start

Every time you scale out on RunPod, a new pod boots vLLM in a fresh container with the model on a network volume. Before it can serve its first token, it goes through three steps:

    - **Load the weights:** read the checkpoint from the volume into GPU memory — 16 GB for Qwen3-8B, 180 GB for Qwen3.8-Flash-Next.

    - **Compile the model:** `torch.compile` fuses operations into GPU kernels, costing tens of seconds on every cold boot.

    - **Capture CUDA graphs & warm up:** vLLM records GPU execution graphs across batch sizes and runs dummy profiling passes.

Keeping compiled kernels on the volume helps, cutting Qwen3-8B’s start from 124 s to 83 s. But every new replica still pays for weight loading, graph capture and warmup — and on Qwen3.8-Flash-Next (180B on four GPUs) a warm-cache start still takes 668 s (751 s cold), 586 s of which is just reading weights off the volume.

**What it means.** Cold start is a tax paid on every new replica: while a pod boots, traffic waits or you pay for idle GPUs kept warm. Compile caches shave the tax; they don’t remove it.

## Boot once, restore everywhere

[warmctl](https://github.com/TensorRails/warmctl) is an open-source tool (Apache-2.0) that warms an inference engine once and ships the result as a snapshot. A snapshot here is the whole running engine at the moment it became ready: the memory of its processes, their open files and their GPU memory. warmctl packs it as an OCI image, the format container registries already store, so you push it to any registry and every new replica restores it instead of booting.

On a virtual machine it stands on three pieces:

    - **CRIU** (Checkpoint/Restore In Userspace) is the standard Linux tool that freezes a tree of processes, saves their memory, files and sockets to disk, and later recreates them exactly.

    - **cuda-checkpoint** is NVIDIA’s utility that moves a process’s GPU state into ordinary host memory, so it can be saved with the rest, and back after a restore.

    - **cuInterpose** is NVIDIA’s library that lets the GPU processes of one engine, spread over several GPUs, be checkpointed together.

On VMs this restores Qwen3-8B in about 9 seconds ([KServe](https://warmctl.io/blog/kserve-llm-replicas-in-seconds/)) and a 27B model in 11 ([RTX PRO 6000](https://warmctl.io/blog/qwen38-27b-11-second-start-rtx-pro-6000/)).

**Bottom line.** On a machine you control, the hard part, the operating system’s view of the engine, is CRIU’s job, and CRIU does it well.

## RunPod is different: a GPU container is not a virtual machine

  one criu dump, two clouds · VM vs RunPod podcomparing both**Virtual machine**Nebius · AWS · GCPyour kernelvLLM · 3 processesAPI serverengine coreGPU workerkernel call**CAP_SYS_ADMIN · clone3 · /proc/sys**✓criu dump · kernel allowscheckpoint images3 of 3 saved**RunPod pod**container on a shared hostrestrictedvLLM · 3 processesAPI serverengine coreGPU workercontainer limits**no caps · seccomp · ro /proc/sys**✕criu dump · refused (EPERM)checkpoint imagesrefusedleft: you own the kernel · right: container limits block CRIUVM 3/3 · Pod 0/3 (EPERM)

Most GPU clouds rent you a virtual machine. You get your own Linux kernel and root on it, so CRIU can do whatever it needs. Container-first clouds such as RunPod rent you a container on a shared host instead. Your processes run on the provider’s kernel, inside limits the provider sets, and three of those limits stop CRIU:

    - **No capabilities.** Linux splits root’s powers into about forty capabilities, and Docker keeps a safe subset by default. CRIU needs `CAP_SYS_ADMIN` or `CAP_CHECKPOINT_RESTORE`, and a pod gets neither.

    - **A system-call filter.** seccomp decides which kernel calls a process may make. Docker’s default profile refuses `unshare` and `clone3`, which CRIU uses to rebuild processes and their namespaces.

    - **Read-only `/proc/sys`.** CRIU cannot set the next process ID, so it cannot give restored processes back their original PIDs.

We checked a RunPod pod: exactly Docker’s defaults, and there is no way to ask for more.

**What it means.** Kernel-level checkpointing needs kernel-level privileges, and a container-first cloud will not hand them out. On RunPod, warmctl’s main backend cannot even start, so the fix has to live in user space.

## Multiple backend support in warmctl: CRIU and DMTCP

Once kernel-level checkpointing is off the table in a container, how else can you bring a multi-GPU vLLM replica up in seconds? Before adding a second backend, we evaluated the alternatives (see our full [alternatives comparison](https://warmctl.io/docs/explanation/alternatives/)):

| CRIU alternative | Verdict on RunPod |
|---|---|
| Other CRIU tools (Dynamo Snapshot) | Same wall: still needs CRIU in a privileged node agent. |
| Fast weight loaders + compile caches | Still 83 s on 8B and 668 s on 180B: Python init, CUDA graphs and warmup remain. |
| vLLM initialized snapshots / Preload | Single-GPU and single-host only, or keeps a daemon holding VRAM. |
| User-space checkpoint (DMTCP) | Chosen as warmctl’s second backend. Processes save themselves via LD_PRELOAD — 0 kernel privileges, multi-GPU, portable. |

Rather than compromise on cold-start time, we built **multiple process-backend support** into warmctl: **CRIU** and **DMTCP** behind a single internal process-tree interface (`tree`):

    - **CRIU backend (default on VMs & Kubernetes):** kernel-level checkpoint/restore with zero runtime overhead when you control the host.

    - **DMTCP backend (default in unprivileged containers):** user-space checkpoint/restore via [DMTCP](https://dmtcp.sourceforge.io/), where processes save and restore themselves through preloaded `libc` wrappers.

A bake records which backend it used in the snapshot’s manifest (`process.backend`) and OCI tag (`-dmtcp`), and `warmctl up` automatically picks the snapshot backend the current host can restore.

**Bottom line.** Supporting both CRIU and DMTCP under one roof means warmctl uses the fastest kernel path on VMs and seamlessly falls back to user-space checkpointing in locked-down GPU containers — while GPU checkpointing, weight streaming, and OCI distribution stay identical.

## What’s the difference: CRIU and DMTCP, side by side

  in a RunPod pod · CRIU (kernel) vs DMTCP (library)1 · GPU state → RAM**CRIU · via the kernel**inspects from outsidekernel syscallsvLLM · 3 unmodified processesAPI serverno shimengine coreno shimGPU workerno shimGPU statecuda-checkpoint → RAMkernel wall**ptrace · unshare · clone3 · /proc/sys**✕blocked by container seccomp & capscheckpoint imagesrefused · EPERM**DMTCP · via LD_PRELOAD**runs inside each processlibrary shimvLLM · launched with preloaded libraryAPI serverlibdmtcp.soengine corelibdmtcp.soGPU workerlibdmtcp.soGPU statecuda-checkpoint → RAMuser-space shim**hooks libc (socket, mmap, clone) + fdkeep**✓bypasses kernel limits · 0 privilegescheckpoint images3 of 3 writtenleft: CRIU hits EPERM · right: preloaded libdmtcp.so writes from insideCRIU 0/3 · DMTCP 3/3

Both tools freeze the same three vLLM processes, but from opposite sides of the operating system boundary:

    - **CRIU works from outside, through the kernel.** A separate `criu` process stops the engine, reads every mapping, file descriptor and socket straight from kernel tables, and asks the kernel to recreate the tree with its original PIDs — leaving zero wrappers in the restored process, at the cost of kernel privileges.

    - **DMTCP works from inside, through `LD_PRELOAD`.** `dmtcp_launch` preloads `libdmtcp.so` into every process so it tracks its own `libc` calls, stops its own threads on checkpoint, and rebuilds sockets and virtual PIDs on restart — using only ordinary user permissions.

    - **The GPU and weights path is identical.** Before either dump, `cuda-checkpoint` moves GPU state into host RAM and restores it afterwards, while weights stream straight onto the GPUs and are checked against chunk fingerprints and a canary prompt.

**If DMTCP needs no privileges, why not drop CRIU and use DMTCP everywhere?** Because CRIU reads the ground truth from the kernel regardless of how a resource was created, whereas DMTCP only sees what passes through its `libc` wrappers. In a modern inference stack, plenty bypasses `libc`: the CUDA driver’s internal `eventfd`s and pipes, Rust libraries (`cuInterpose`) issuing raw syscalls, `io_uring`, UCX’s `mmap` hooks, Python’s `fcntl64`, and ZMQ sockets that must drain in lockstep. Each of those silently dropped state, hung a dump, or crashed a restart until we patched DMTCP itself (11 commits in [TensorRails/dmtcp](https://github.com/TensorRails/dmtcp/tree/warmctl)) and wrote a companion plugin (`fdkeep`) to rebuild what bypassed `libc`. CRIU works out of the box on arbitrary process trees with zero runtime overhead; DMTCP is the surgical path for locked-down containers where the kernel door is shut.

**What it means.** The snapshot format, OCI registry, and `warmctl up` stay the same. CRIU remains the universal default on VMs, and DMTCP takes over inside containers.

## How to run it on RunPod

    - **Create a network volume in the data center where your pods will run**, and start one pod from the warmctl runtime image with the volume mounted at `/nv`. warmctl uses RunPod’s default `/workspace` itself.

    - **Bake the snapshot on that pod**, then copy it to the volume, `manifest.json` last, so a pod starting meanwhile never sees half a snapshot:

```
warmctl build --in-place --profile qwen3-8b-l40s-tp1 \
  --model-dir /nv/models/qwen3-8b --output /root/snap/qwen3-8b --compress
```

    - **Start every new replica from it:** any pod with the same image, GPU model and driver, with this as its start command.

```
warmctl up /nv/snap/qwen3-8b --mode in-place    # OpenAI-compatible API on :8080
```

## What a new replica costs now — and how it serves

Running those three steps on a new RunPod pod cuts Qwen3-8B’s cold start to a fifth of warm-cache vLLM:

| Qwen3-8B, a new pod | Network volume, EU-NL-1 (L40S) | Global Volume (A40) |
|---|---|---|
| vLLM, cold | 124 s | 162 s |
| vLLM, compile caches on the volume | 83 s | 125 s |
| warmctl, snapshot on the volume | 20–23 s | 49 s |
| warmctl, the pod’s own copy | 11 s | 11 s |

Smaller models gain even more: Qwen3-0.6B starts in 8 seconds from a network volume and 4 from the pod’s disk, against 34 for vLLM with warm caches; split over two GPUs (TP=2), it serves in 5 to 6 seconds.

### Does a restored engine lose throughput or accuracy?

Because DMTCP keeps its `LD_PRELOAD` wrappers in the process after restart, we benchmarked a restored Qwen3-8B engine head-to-head against stock vLLM and a CRIU-restored engine on the same RTX PRO 6000 GPU (`vllm bench serve`, 1,024 input / 512 output tokens, greedy):

| Engine (Qwen3-8B) | c=1 tok/s | c=1 ITL p50 | c=64 tok/s | c=64 TPOT p50 |
|---|---|---|---|---|
| Stock vLLM | 87 tok/s | 11.40 ms | 2,503 tok/s | 23.75 ms |
| warmctl · CRIU restore | 87 tok/s | 11.39 ms | 2,498 tok/s | 23.83 ms |
| warmctl · DMTCP restore | 87 tok/s | 11.41 ms | 2,495 tok/s | 23.46 ms |

Throughput and inter-token latency match within noise (0.3%), and every restore verifies chunk fingerprints on the GPU copy stream plus a canary generation check before `/health` returns 200.

**What it means.** A restored replica serves at full stock speed — and if you look at the startup table above (11 s from local disk vs 21 s from a Network Volume and 49 s from a Global Volume), the remaining cold-start time is no longer vLLM or DMTCP. It is the storage.

## Another RunPod problem: slow remote storage

Once DMTCP and `cuda-checkpoint` restore the processes and GPU state in a few seconds, we hit a second wall on RunPod: **remote storage bandwidth**. Throughput may vary by GPU tier and region, but across the A40, L40S and RTX PRO 6000 pods we tested, a single reader got only 0.11–0.28 GiB/s, and even with 32 parallel readers a regional RunPod Network Volume (MooseFS over FUSE) topped out at **~0.8–1.0 GiB/s** (and a Global Volume at 0.5 GiB/s).

Unlike clouds such as **Nebius, Verda, or CoreWeave**, where shared network filesystems over RDMA or virtiofs deliver **7 to 15+ GiB/s per node**, a RunPod pod spends almost its entire startup time pulling gigabytes off the volume. Squeezing ~1 GiB/s out of RunPod’s FUSE mount forced us to add two things: **32-stream parallel staging** (restoring while copying, so `dmtcp_restart` and `cuda-checkpoint` run while weights stream in) and a dedicated **`--compress` mode** to shrink how many bytes cross the wire:

| Qwen3-8B from a RunPod network volume | Read from volume | To serving |
|---|---|---|
| Plain copy off FUSE, then restore | 23.11 GiB | 35.9 s |
| 32-reader stage: restore while copying | 23.11 GiB | 28–30 s |
| 32-reader stage + --compress + weights reloaded as they land | 13.35 GiB zstd | 20.2–22.9 s |
| Later starts from the pod’s own staged copy | local disk | 11.1 s |

You can either bake a compressed snapshot directly, compress an existing snapshot, and then restore it on any replica pod:

        Option A · Bake**Bake compressed straight to the volume**zstd frames + BF16 byte planes
        23.1 → 13.4 GiB

```
$ warmctl build --in-place --profile qwen3-8b-l40s-tp1 \
  --model-dir /nv/models/qwen3-8b --output /nv/snap/qwen3-8b --compress
  published /nv/snap/qwen3-8b: 37 files, 23.11 GiB, 13.35 GiB stored
```

        Option B · Convert**Or compress an already-built snapshot**keeps the raw source pushable to OCI
        images 2.84× · weights 1.46×

```
$ warmctl compress /root/snap/qwen3-8b /nv/snap/qwen3-8b
  compressed 37 files into /nv/snap/qwen3-8b (13.35 GiB stored)
```

        Step 2 · Replica**Restore on any replica pod**32-reader stage + on-the-fly frame decode
        ~21 s to /health 200

```
$ warmctl up /nv/snap/qwen3-8b --mode in-place
  staged in /var/lib/warmctl/staged/qwen3-8b · serving started 21.4s
```

Linux process images compress well — **2.84×** with independent 32 MiB zstd frames (7.28 → 2.56 GB on Qwen3-8B). Model weights are near-random and barely compress as raw bytes, but splitting BF16 weights into two byte planes (the high sign+exponent byte compresses to 0.37×, while the mantissa stays raw) yields **1.46×** on the weights too.

**What matters: `--compress` will hurt you on fast storage.** Decompression is heavily CPU-bound — 8 worker threads on a pod decode weights at about **3.1–3.4 GiB/s** (and single-stream zstd inside `mtcp_restart` ran at 0.67 GB/s, slowing a local restore from 10.3 s to 15.4 s). If you have fast local NVMe or a high-speed shared network filesystem like on [Nebius](https://warmctl.io/blog/boosting-dedicated-inference/) (where we measured 7.0–7.8 GiB/s per node and 22 GiB/s across three nodes), Verda, or CoreWeave, storage is far faster than the CPU can decompress, so `--compress` will only slow your startup down:

| Storage tier | Read per pod/node | Use --compress? |
|---|---|---|
| RunPod Global Volume (S3 FUSE) | 0.11–0.50 GiB/s | Yes — network is 6–10× slower than CPU decode. |
| RunPod Network Volume (MooseFS) | 0.8–1.0 GiB/s | Yes (BF16) — cuts Qwen3-8B start from 28–30 s to 20–23 s. |
| Local NVMe / pod’s staged copy | 3–6+ GiB/s | No — CPU decode is slower than disk (10.3 s raw vs 15.4 s piped zstd). |
| Fast shared FS (Nebius, Verda, CoreWeave) | 7–15+ GiB/s | No — network storage is 2–4× faster than CPU decompression. |

**What it means.** Slow remote storage is a separate bottleneck on RunPod, but `--compress` offsets it at build time. Just don’t turn it on where your storage is faster than your CPUs.

## Testing large multi-GPU models

Single-GPU 8B models are the easy case; production deployments split 30B to 180B+ models across multiple GPUs. Beyond dense TP=2 and TP=4 models (Qwen3-32B, Gemma 4 31B), the hardest case we tested on RunPod was **Qwen3.8-Flash-Next**: a 180B mixture-of-experts model in FP8 on four RTX PRO 6000 cards, running our full catalog recipe — tensor and expert parallel across all four GPUs, FP8 KV cache, multi-token prediction, and the native 262K context.

Stock vLLM needs **751 seconds** to start it on a new pod, 586 of them reading 180 GB of weights from the network volume. A replica that takes over twelve minutes to appear cannot absorb a spike: by the time it serves, the spike is over.

### What broke, and what we fixed

    - **The fast all-reduce shares GPU memory.** Over PCIe, the profile uses FlashInfer’s all-reduce, which maps the other ranks’ buffers directly, and cuda-checkpoint refuses such a process. We kept the collective and ran the engine under NVIDIA’s cuInterpose, which takes the sharing apart before the checkpoint and puts it back at the same addresses after it.

    - **cuInterpose and DMTCP had never met.** Under DMTCP, cuInterpose could not find its own core library, its control socket did not survive a restart, and the processes vLLM spawns lost it. Each is fixed: two patches in our DMTCP fork, one in warmctl’s DMTCP plugin, and the launch order.

    - **This model makes vLLM spawn its workers.** Its model class touches CUDA when Python imports it, so vLLM starts the engine and workers as fresh programs, and DMTCP’s checkpoint then waited forever on a pipe. The cause was one missing wrapper, `fcntl64`, the name under which modern glibc compiles `fcntl`. With it, DMTCP sees how Python duplicates descriptors, and the checkpoint completes.

    - **The next pod numbered its GPUs differently.** The bake pod’s rank 2 was `/dev/nvidia7`; on the replica that name belonged to rank 3’s GPU, and warmctl refused to restore. It turned out to be harmless: the CUDA restore moves each GPU by its UUID and opens its devices itself, and the weights’ fingerprints and the canary prove it.

| Qwen3.8-Flash-Next, 4×RTX PRO 6000 | Seconds to serving |
|---|---|
| vLLM, cold | 751 s |
| vLLM, compile caches on the volume | 668 s |
| warmctl, snapshot on the volume | 215 s |
| warmctl, compressed snapshot on the volume | 283 s |
| warmctl, the pod’s own copy | 21 s |

    - **The bake runs once.** It took 16 minutes on one pod, and the snapshot is 228 GiB: 183 GiB of weights and 45 GiB of process memory. Every replica afterwards restores it.

    - **The volume is the clock.** Restarting the processes, the GPUs and their shared memory took about 7 seconds of the 215; the rest was reading 228 GiB at 1.06 GiB/s, the rate one pod got from RunPod’s network volume with 32 parallel readers. From the pod’s own disk the same restore takes 21 seconds.

    - **Compression does not pay for FP8.** FP8 weights barely compress (the snapshot went from 228 to 183 GiB, mostly in the process memory), and reading frames was slower, so the compressed snapshot took 283 seconds. For BF16 weights, which compress well, the trade turns the other way.

**What it means.** A replica of a 180B model on RunPod now serves in under four minutes from the network volume instead of eleven to twelve, and in 21 seconds when its pod restarts with a copy on disk, where vLLM would take the full eleven minutes again. Faster storage next to the GPUs would shrink the first number; nothing in warmctl stands in the way.

## Where it stands

    - **Verified across architectures and tensor-parallel sizes.** Tested end to end on RunPod Pods (A40, L40S, RTX PRO 6000) and in unprivileged containers on 4×/8×RTX PRO 6000 across **Qwen3-0.6B** (TP1/TP2/TP4), **Qwen3-8B** (TP1, raw and `--compress`), **Qwen3.8-27B-FP8** (TP1), **Qwen3-32B** (TP2/TP4), **Gemma 4 31B** (TP4), **gpt-oss-20b** (TP1), and **Qwen3.8-Flash-Next 180B** (TEP4).

    - **Multi-GPU collectives that share memory** run under cuInterpose inside DMTCP, verified with FlashInfer’s PCIe all-reduce on RTX PRO 6000.

    - **Same image, GPU model and driver on both sides.** A snapshot holds GPU state, so the receiving pod has to match.

    - **Not tested yet:** RunPod Serverless workers.

## Set it up on RunPod

The step-by-step guide, with every pod setting and the exact start commands, is the tutorial [Serve and scale vLLM on RunPod](https://warmctl.io/docs/tutorials/runpod/); the design and every limit are in [docs/dmtcp.md](https://github.com/TensorRails/warmctl/blob/main/docs/dmtcp.md). warmctl is on [GitHub](https://github.com/TensorRails/warmctl), and the dmtcp backend ships in [v2.0.0-beta.16](https://github.com/TensorRails/warmctl/releases/tag/v2.0.0-beta.16). If you run inference on RunPod and want help with your models, [write to us](https://warmctl.iomailto:hello@warmctl.io).

## Questions we get

  Why is vLLM’s cold start slow on RunPod?Each new pod loads the weights from a network volume, compiles the model with torch.compile, captures CUDA graphs and warms up: 83–124 s for Qwen3-8B, 11–12.5 minutes for a 180B model on four GPUs. Compile caches save part of it; the weights are read again on every pod.Can I run CRIU on RunPod?No. Pods get Docker’s default capabilities and seccomp profile and a read-only /proc/sys; CRIU needs CAP_SYS_ADMIN or CAP_CHECKPOINT_RESTORE, unshare and clone3.What does warmctl use instead?DMTCP, which checkpoints processes from inside, in user space, together with NVIDIA’s cuda-checkpoint for GPU memory. warmctl picks it automatically when CRIU cannot run.Which storage should hold the snapshot?A network volume in the same data center as your pods. It reads at 0.8–1.0 GiB/s per pod, about twice a Global Volume, and that sets the start time.Does it work on RunPod Serverless?Not tested yet; this post covers RunPod Pods.

### Sources

    - [CRIU](https://criu.org/); [DMTCP](https://dmtcp.sourceforge.io/) and the [TensorRails/dmtcp fork](https://github.com/TensorRails/dmtcp/tree/warmctl); NVIDIA [cuda-checkpoint](https://github.com/NVIDIA/cuda-checkpoint).

    - RunPod docs: [network volumes](https://docs.runpod.io/pods/storage/create-network-volumes).

    - warmctl [PR #61](https://github.com/TensorRails/warmctl/pull/61): the dmtcp backend, the measurements and their limits.

(()=>{const R=matchMedia('(prefers-reduced-motion: reduce)').matches,W=t=>new Promise(r=>setTimeout(r,t));
const q=(f,s)=>f.querySelector(s),T=(e,t,c)=>{if(!e)return;e.textContent=t;if(c!==undefined)e.className=e.className.split(' ')[0]+(c?' '+c:'');};
const RUN={
wall:async(f,once)=>{const st=q(f,'.cx-state'),ct=q(f,'.cx-count'),vem=q(f,'.vm-em'),pem=q(f,'.pod-em');
 if(once){f.classList.add('step1','step2');T(vem,'3 of 3 saved');T(pem,'refused · EPERM');T(st,'VM writes 3 · Pod EPERM','ok');T(ct,'VM 3/3 · Pod 0/3 (EPERM)','ok');return;}
 for(;;){f.classList.remove('step1','step2');T(vem,'empty');T(pem,'empty');T(st,'1 · criu dump calls the kernel','');T(ct,'running criu dump…','');
  await W(500);f.classList.add('step1');T(st,'2 · VM allows · Pod blocks syscalls','');
  await W(700);f.classList.add('step2');T(vem,'3 of 3 saved');T(pem,'refused · EPERM');T(st,'VM writes 3 · Pod EPERM','ok');T(ct,'VM 3/3 · Pod 0/3 (EPERM)','ok');
  await W(4500);}},
who:async(f,once)=>{const st=q(f,'.cx-state'),ct=q(f,'.cx-count'),cem=q(f,'.criu-em'),dem=q(f,'.dm-em'),gps=[...f.querySelectorAll('.gp-r')];
 if(once){f.classList.add('gpu','ask','go');gps.forEach(g=>g.textContent='in host RAM');T(cem,'refused · EPERM');T(dem,'3 of 3 written');T(st,'3 · DMTCP writes 3 of 3 in user space','ok');T(ct,'CRIU 0/3 · DMTCP 3/3','ok');return;}
 for(;;){f.classList.remove('gpu','ask','go');gps.forEach(g=>g.textContent='on the GPU');T(cem,'waiting');T(dem,'waiting');
  T(st,'1 · cuda-checkpoint → host RAM','');T(ct,'preparing GPU state…','');
  await W(450);f.classList.add('gpu');gps.forEach(g=>g.textContent='in host RAM');
  await W(650);f.classList.add('ask');T(cem,'refused · EPERM');
  T(st,'2 · CRIU blocked · DMTCP via LD_PRELOAD','');
  await W(700);f.classList.add('go');T(dem,'3 of 3 written');
  T(st,'3 · DMTCP writes 3 of 3 in user space','ok');T(ct,'CRIU 0/3 · DMTCP 3/3','ok');
  await W(4500);}}};
document.querySelectorAll('figure.cx[data-cx]').forEach(f=>{const go=RUN[f.dataset.cx];if(!go)return;
 if(R){go(f,true);return;}
 const io=new IntersectionObserver(e=>{if(e[0].isIntersecting){io.disconnect();go(f,false);}},{threshold:.25});io.observe(f);});})();
(()=>{const r=document.querySelector('[data-race]');if(!r)return;const k=document.querySelector('[data-race-kpis]');
const B=[...r.querySelectorAll('.btns button')];B.forEach((b,i)=>b.addEventListener('click',()=>{
 B.forEach((x,j)=>x.setAttribute('aria-pressed',j===i));
 r.querySelectorAll('.r i').forEach(e=>{e.style.animation='none';e.style.width=e.dataset['w'+i];});
 [...r.querySelectorAll('[data-t0]'),...(k?k.querySelectorAll('[data-t0]'):[])].forEach(e=>e.textContent=e.dataset['t'+i]);}));})();
