NUMERATA

BLOG · LUPINE ENGINEERING

How we cold start a
32B model in 1.1s.

You know what I want out of a GPU cloud? Instant cold starts. That enables true serverless GPUs.

State-of-the-art GPU snapshots have shown that restoring from a snapshot beats booting a container and initializing PyTorch from scratch. However, they still take 13 to 17 seconds to spin up a tiny 0.6B model, a paltry ~100 MB/s transfer rate. Meanwhile, PCIe Gen5 gives us 64 GB/s and Grace Hopper's NVLink-C2C gives us 400+ GB/s of usable bandwidth. What gives?

Over the past few weeks, we went down the rabbit hole of GPU checkpoint/restore to max out the hardware link. Today, our checkpoint/restore engine cold starts Qwen3-0.6B on vLLM in 0.15 seconds and Qwen3-32B on vLLM in 1.1 seconds.

Here is where all the time was actually going in traditional GPU snapshots, and how we engineered around it.

1. Scrubbing the CPU process (by not snapshotting it)

If you want to checkpoint a GPU workload today, the standard recipe combines CRIU with NVIDIA’s cuda-checkpoint utility. Because traditional GPU clouds stuff your Python interpreter, web server, threads, and CUDA driver into a single monolithic container, snapshotting the GPU means freezing the entire container.

That API actually does way too much, and is quite slow as a result:

  • Python is heavy: You have to freeze and dump gigabytes of Python heap, shared libraries, and open sockets before you can release the GPU.
  • cuda-checkpoint stages through host RAM: NVIDIA’s tool is a black box that locks the process and copies all your VRAM into internal CPU staging buffers through a slow, single-threaded bottleneck.
  • Zero overlap: You must wait for cuda-checkpoint to finish evacuating the GPU before CRIU can dump the CPU, and on restore you must wait for CRIU to finish restoring the CPU before a single byte starts uploading back to the GPU.
  • Walking page tables is slow: Scanning virtual memory mappings and page tables to discover what memory is live and dirty burns precious seconds before data transfer even starts.

Thankfully, we can cheat. Lupine Cloud uses Lupine's open-source CUDA-over-IP transport, which means your CPU instance can stay up while the GPU instance rotates underneath it.

Monolithic container snapshot versus Lupine's split CPU/GPU architecture Top row, traditional monolithic snapshot, serialized, 13 to 17 seconds: 1, freeze Python, pause threads and sockets; 2, walk page tables, find live CPU and GPU pages; 3, cuda-checkpoint, stage VRAM into host RAM; 4, CRIU dump, dump multi-GB process. Bottom row, Lupine CUDA-over-IP split architecture, sub-second: 1, Python stays up, the CPU instance keeps running; 2, pointers known, tracked by CUDA-over-IP; 3, stream GPU VRAM, 60+ GB/s direct transfer; 4, swap GPU socket, no CPU process dump. Underneath, the CPU instance is never snapshotted and the GPU instance rotates underneath it. TRADITIONAL MONOLITHIC SNAPSHOT (SERIALIZED · 13 TO 17 SECONDS) 1. FREEZE PYTHON Pause threads & sockets 2. WALK PAGE TABLES Find live CPU/GPU pages 3. CUDA-CHECKPOINT Stage VRAM into Host RAM 4. CRIU DUMP Dump multi-GB process LUPINE CUDA-OVER-IP SPLIT ARCHITECTURE (SUB-SECOND) 1. PYTHON STAYS UP CPU instance keeps running 2. POINTERS KNOWN Tracked by CUDA-over-IP 3. STREAM GPU VRAM 60+ GB/s direct transfer 4. SWAP GPU SOCKET No CPU process dump CPU INSTANCE (NEVER SNAPSHOTTED) GPU INSTANCE (ROTATES UNDERNEATH)
FIG 01 · Because the CPU instance stays up and already holds the pointer table, our checkpoint/restore skips CPU process snapshots and page-table walks entirely.

Keeping the CPU instance alive gives us two big shortcuts:

  • We never walk memory: Because the Lupine client intercepts allocation calls on the way out, it already holds the full list of every live GPU pointer and its byte size. At checkpoint time, we pause incoming calls, the client hands over its pointer list, and transfers start on millisecond one.
  • Live socket handoff: When we rotate the GPU instance underneath you, both ends pause their HTTP/2 stream cleanly between frames. Once the new GPU instance is warm, our regional gateway splices your existing connection onto a resume tunnel. To PyTorch on the CPU instance, rotating onto a new physical GPU just looks like one CUDA call took an extra 200ms.

2. Putting pointers back where we found them

Keeping the CPU instance up narrows our problem to a simple contract: save every GPU allocation and put the bytes back at exactly the same memory address.

“Exactly” is the hard part. Modern ML workloads like PyTorch, torch.compile, and CUDA graphs embed raw 64-bit GPU pointers directly inside VRAM buffers. If a tensor lived at 0x7f04a0000000 before the checkpoint, it must land at 0x7f04a0000000 on restore. Move it by a single page and internal pointers break. And scanning a 32B model's opaque floating-point soup to guess which 8 bytes are secretly a pointer is a non-starter.

Why we had to go around libcuda.so

It turns out libcuda’s APIs are too blunt to do this though, and they come with a ton of baggage:

  • Opaque do-everything APIs: Standard cuMemAlloc gives you zero control over what address comes back. Even if you use CUDA’s lower-level virtual memory API (cuMemAddressReserve), the target address parameter is just a hint. If libcuda dislikes the alignment, it silently picks a different address. Worse, cuMemAlloc packs small tensors into shared 2 MiB chunks inside 32 MiB address blocks, so the public APIs refuse to reserve individual tensors at smaller offsets.
  • Too much userspace baggage: Every public allocation call updates internal lookup trees, context tables, and per-pointer bookkeeping inside libcuda.so on top of slow driver page allocation. That's not very cash money.

So our first prototype went around libcuda.so completely. If you only read NVIDIA’s public CUDA Driver API docs, cuMemAddressReserve is as low as you can go, and it treats your target address as a polite suggestion. To find out how libcuda.so actually tells the kernel where to put a page, we had to go spelunking through NVIDIA’s open-gpu-kernel-modules repository.

Inside kernel-open/nvidia-uvm/uvm_ioctl.h and uvm_map_external.c, the Unified Virtual Memory driver (/dev/nvidia-uvm) exposes two undocumented ioctl commands that do the actual mapping:

  • UVM_CREATE_EXTERNAL_RANGE: Unlike cuMemAddressReserve, this command does not take a hint. You pass a base and length, and the kernel reserves that exact [base, base + length) range in the GPU’s virtual address space.
  • UVM_MAP_EXTERNAL_ALLOCATION: Maps physical VRAM pages directly into the GPU page tables at that reserved range, using the raw /dev/nvidiactl file descriptor and internal Resource Manager handles created by NV_ESC_RM_ALLOC.

With these two totally cavalier, undocumented incantations, we blast our way to mapping physical pages onto their exact target address in ~1 millisecond.

That worked, right up until we tried to copy the weights back in. Because we created those mappings behind libcuda.so’s back, its internal lookup tables had no idea our restored pointers existed. Calling cuMemcpyHtoD immediately returned CUDA_ERROR_INVALID_VALUE. (Excuse me, I just mapped that.) Working around libcuda’s bouncer required uploading data into a temporary VRAM staging buffer and launching a GPU copy kernel to move it into the final address, cutting effective bandwidth in half.

Stripping the baggage

By looking at how the driver manages virtual addresses under the hood, we got rid of both libcuda’s per-tensor baggage and the staging buffer. Normally libcuda tracks every tiny allocation so it can answer pointer-size queries. But in Lupine, the CPU client already tracks individual tensor bounds locally! The GPU server doesn't need per-tensor bookkeeping at all. We merge adjacent allocations into coarse 32 MiB virtual memory blocks at the exact target addresses so cuMemcpyHtoDAsync copies weights straight into place in a single pass.

Three ways to restore tensors to their exact addresses Top, traditional cuda-checkpoint plus CRIU: per-tensor calls from a host RAM staging buffer, with no address coalescing, into thousands of separate GPU allocations, each with its own mapping and libcuda lookup tree. Middle, attempt 1, raw UVM ioctls behind libcuda's back: copy number 1 over PCIe from host pinned RAM into a staging VRAM buffer known to libcuda.so, then copy number 2 by a GPU kernel into the exact target virtual address, because cuMemcpyHtoD is rejected directly. Bottom, our solution: a single-pass DMA with cuMemcpyHtoDAsync from a 64 MiB pinned host pool into one coalesced 32 MiB address block on the server, with tensors A, B and C contiguous inside it and their bounds tracked on the client. TRADITIONAL (CUDA-CHECKPOINT + CRIU): UNCOALESCED PER-TENSOR RESTORE HOST RAM STAGING cuda-checkpoint Buffer Per-Tensor Calls No Address Coalescing GPU: THOUSANDS OF SEPARATE ALLOCATIONS & LIBCUDA LOOKUP TREES Tensor A (0x7f04a000) Separate Map + Tree Tensor B (0x7f04a180) Separate Map + Tree Tensor C (0x7f04a400) Separate Map + Tree ATTEMPT 1: RAW UVM IOCTL BEHIND LIBCUDA'S BACK (TWO-COPY PENALTY) HOST PINNED RAM Checkpoint Weights Copy #1 (PCIe) STAGING VRAM BUFFER Known to libcuda.so Copy #2 (Kernel) EXACT TARGET VA (0x7f04a...) ✕ cuMemcpyHtoD rejected directly! OUR SOLUTION: CLIENT TRACKS TENSORS + SERVER MAPS COALESCED BLOCKS (SINGLE COPY) HOST PINNED RAM 64 MiB Pinned Pool 1-Pass DMA cuMemcpyHtoDAsync SERVER: SINGLE COALESCED 32 MiB ADDRESS BLOCK Tensor A (0x7f04a000) Tracked on Client Tensor B (0x7f04a180) Tracked on Client Tensor C (0x7f04a400) Tracked on Client
FIG 02 · Unlike cuda-checkpoint + CRIU (which restores every tensor allocation individually) or raw UVM ioctls (which force a two-copy staging hop), our server performs bulk copies over the entire allocation.

3. Hashing on the GPU

Once address mapping takes ~1ms and the two-copy tax is gone, we have a pure streaming pipeline problem. We replaced sequential 1 MiB transfers with a pipeline of reusable pinned memory buffers and 8 concurrent workers streaming 64 MiB chunks.

To optimize storage, we also content-address every 64 MiB chunk by its cryptographic hash. Because base model weights and untouched memory pages stay identical across snapshots and across users running the same model, content addressing lets us deduplicate traffic right at the beginning and skip transferring chunks that are already present in storage or local cache.

However, content addressing at multi-gigabyte-per-second speeds poses a major problem: CPU hashing throughput isn't fast enough. Computing SHA-256 on a 32B BF16 model takes over 30 CPU-seconds (37.76 seconds total), turning the CPU into a massive bottleneck.

How do we fix that? At checkpoint and cold-start time, the weights are already sitting on a massively parallel processor: the GPU.

Overlapped pipeline: pinned DMA and on-GPU BLAKE3 hashing Three staggered lanes over time. Disk or VAST I/O reads chunks 0, 1 and 2, each 64 MiB. The pinned DMA stream runs host-to-device or device-to-host copies 0, 1 and 2, each starting as its chunk arrives. The GPU BLAKE3 stream tree-hashes chunks 0 and 1 and sends a 32-byte digest to the host, overlapping with the copies. 8-worker pinned DMA plus on-GPU BLAKE3 at 878 GB/s. OVERLAPPED PIPELINE: 8-WORKER PINNED DMA + ON-GPU BLAKE3 (878 GB/s) DISK / VAST I/O Chunk 0 (64 MiB) Chunk 1 (64 MiB) Chunk 2 (64 MiB) PINNED DMA STREAM HtoD / DtoH #0 HtoD / DtoH #1 HtoD / DtoH #2 GPU BLAKE3 STREAM Tree Hash #0 Tree Hash #1 32B Digest → Host
FIG 03 · Switching from CPU SHA-256 to custom CUDA BLAKE3 kernels hashes a 32B model's GPU-resident weights at 878 GB/s in parallel with DMA transfers.

We switched our chunk store to BLAKE3, whose tree structure is easy to parallelize across GPU thread blocks, and wrote custom CUDA kernels to hash chunks directly on the GPU. Our CUDA BLAKE3 kernel hashes GPU memory at 878 GB/s (73 ms for an entire 32B BF16 model) on a side stream, returning just a 32-byte digest per chunk to the host. When we took all the above into account, our 32B cold start time dropped from 37.76s to 1.11s.

4. Tricking NVIDIA's Resource Manager

Putting raw VRAM bytes back at the right addresses restores your model weights and KV caches. But that is not enough, because NVIDIA also has a Resource Manager that owns the driver's bookkeeping.

When a process uses CUDA, that bookkeeping lives in two places: userspace structs inside libcuda.so on the CPU, and kernel-side state inside the NVIDIA Resource Manager behind /dev/nvidia*. The key insight is that this driver bookkeeping is tiny compared to an entire application process. For the userspace half, we use the exact same approach on the CPU that we used on the GPU: we track the worker's CPU allocations and restore those few megabytes directly into the new process memory in ~122 milliseconds. And for the kernel-side Resource Manager state, we record the ioctl calls made against /dev/nvidia* and replay them on the new instance:

Overlapped restore and filtered ioctl replay on /dev/nvidia* Two jobs run in parallel: CPU bookkeeping, about 122 milliseconds, restores tracked libcuda memory, while a parallel VRAM upload pre-loads weights into GPU RAM. Both feed the ioctl replay engine, which skips unnecessary queries, replays driver allocations, and binds the pre-loaded VRAM handles. It drives the /dev/nvidia* driver, whose Resource Manager then matches the restored libcuda.so state. OVERLAPPED RESTORE + FILTERED IOCTL REPLAY ON /dev/nvidia* CPU BOOKKEEPING (~122ms) Restores tracked libcuda memory PARALLEL VRAM UPLOAD Pre-loads weights into GPU RAM IOCTL REPLAY ENGINE ✕ Skip unnecessary queries ✓ Replay driver allocations ✓ Bind pre-loaded VRAM handles /dev/nvidia* DRIVER Resource Manager matches restored libcuda.so state
FIG 04 · Restoring tracked CPU bookkeeping in parallel with VRAM upload, then replaying the optimized /dev/nvidia* ioctl sequence to rebuild the kernel Resource Manager state.
  • Intercept device calls: We hook open, ioctl, and mmap on /dev/nvidia* via LD_PRELOAD as the worker runs, logging every Resource Manager allocation and mapping so we can replay them against the fresh driver.
  • Skip unnecessary ioctls: We also optimize the recorded ioctl sequence to strip out calls that are unnecessary on restore, since 90% of startup ioctls are just libcuda asking read-only questions whose answers are already cached in CPU memory.
  • Overlap CPU & GPU restore: We stream model weights into the GPU at the same time we restore the 122ms of tracked CPU bookkeeping into process memory, then tell the NVIDIA Resource Manager to attach our already-loaded VRAM directly to the saved handle IDs.

5. Fixing the disk and network bottleneck

Moving a 32B model out of VRAM in 1 second doesn't help much if you then spend 30 seconds uploading it to S3 over HTTP. On a standard cloud, a server might only have a 10 Gb/s network link, which quickly becomes the bottleneck when migrating between machines.

To solve this across the cluster, we built our storage tier on CoreWeave Dedicated VAST Storage. Instead of sitting behind high-latency HTTP object endpoints, VAST exposes a co-located, all-flash NVMe-over-Fabrics (NVMe-oF) shared filesystem directly to our GPU nodes over a 400 Gb/s RDMA fabric.

We combine local memory and VAST into a three-tier cache (VRAM ↔ Host RAM ↔ VAST):

  • Tier 1 (Local Host RAM in ~1s): When we checkpoint a model, we stream it straight from VRAM into local Host RAM. As soon as that 1-second copy finishes, the GPU is immediately free to serve the next request.
  • Tier 2 (Asynchronous flush to VAST): In the background, worker threads flush the 64 MiB BLAKE3 chunks from Host RAM to VAST while keeping a local LRU cache on the machine. Our scheduler tracks which machines hold warm chunks so returning requests can be routed back to the same node and restore from RAM at light speed.
  • Tier 3 (Line-rate cross-node reads over RDMA): When a workload needs to cold start or live-migrate onto a completely different machine, 8 parallel workers pull the 64 MiB chunks straight off VAST over the 400 Gb/s RDMA fabric, saturating the network link without S3 or HTTP overhead.
Multi-tiered checkpoint cache: VRAM, host RAM, and VAST over RDMA Tier 1, GPU VRAM, holds the 32B model and KV state and is freed in about 1.05 seconds. It writes to tier 2 at 60+ GB/s and is restored from it in a 1.1 second cold start. Tier 2, a host RAM LRU, stores 64 MiB nvCOMP frames at 0.667x size, indexed by BLAKE3. It flushes asynchronously to tier 3 and pulls back over RDMA. Tier 3, VAST over RDMA, is shared NVMe-oF storage with one third fewer bytes on the wire. MULTI-TIERED CHECKPOINT CACHE: VRAM ↔ HOST RAM ↔ VAST RDMA TIER 1: GPU VRAM 32B Model & KV State Freed in ~1.05s 60+ GB/s 1.1s Cold Start TIER 2: HOST RAM LRU 64 MiB nvCOMP Frames 0.667x Size (BLAKE3 Indexed) Async Flush RDMA Pull TIER 3: VAST RDMA Shared NVMe-oF / RDMA 1/3 Fewer Bytes on Wire
FIG 05 · Checkpoints compress on-GPU with nvCOMP (0.667x) and dump to local Host RAM in ~1s to free the GPU immediately, then flush asynchronously to VAST over RDMA.

“But wait, isn’t the line rate of VAST 400 Gb/s? How’d you exceed 50 GB/s?”

Well, eagle-eyed Hacker News reader, we compress the model on the GPU before storage using NVIDIA’s nvCOMP!

Right after computing the raw BLAKE3 digest of each 64 MiB chunk on the GPU, we run nvCOMP’s batched ANS (Asymmetric Numeral Systems) codec in its 2-byte float (FLOAT16) mode to entropy-code the tightly clustered BF16 exponents. On real BF16 model weights, nvCOMP shrinks every chunk to 0.667x its original size (a full one-third smaller) at 1.19 TB/s compression and 1.87 TB/s decompression.

Each compressed frame is still named by the BLAKE3 hash of its raw bytes so content deduplication keeps working unchanged. Because every checkpoint and VAST transfer moves a third fewer bytes over the wire (42.7 GB instead of 64 GB for a 32B model), and decompression runs at nearly 1.9 TB/s straight into our exact-address VRAM mappings, our effective model throughput blows right past the 50 GB/s physical wire limit to 60+ GB/s.

Putting everything together

We benchmarked our checkpoint/restore across BF16 model sizes from 0.6B (1.2 GB) up to 32B (64 GB), and compared them against published numbers from state-of-the-art GPU memory snapshots, workload snapshot benchmarks, OS-level GPU checkpointing (PhoenixOS), and standard cuda-checkpoint + CRIU:

Cold start latency by model size: our checkpoint/restore versus the state of the art Panel A, log-log scale, cold start or restore time against BF16 model size from 0.5B to 32B. cuda-checkpoint plus CRIU: 17 seconds at 0.6B, 39 seconds at 8B, about 50 seconds at 32B. State-of-the-art GPU snapshots: 8.48 seconds at 0.5B, 13.8 at 0.6B, 7.9 at 2B, 18.7 at 7B (P50), with a P95 of 34.2 seconds at 7B. PhoenixOS: 2.1 seconds at 1.5B, 5.2 at 7B, 9.8 at 13B, 22.0 at 32B. Ours: 0.15 seconds at 0.6B rising to 1.11 seconds at 32B. Panel B, linear scale, our process overhead from 0.6B (1.2 GB) to 32B (64 GB). vLLM cold start: 0.15, 0.19, 0.25, 0.39, 0.46, 0.62, 0.87 and 1.11 seconds at 0.6B, 1.5B, 3B, 7B, 9B, 14B, 24B and 32B. Raw checkpoint: 0.06, 0.09, 0.14, 0.27, 0.34, 0.50, 0.82 and 1.05 seconds at the same sizes. COLD START LATENCY BY MODEL SIZE (BF16): OUR CHECKPOINT/RESTORE VS. STATE OF THE ART A. SYSTEM COMPARISON (LOG-LOG SCALE) 0.1s 0.3s 1s 3s 10s 30s 100s 0.5B 1B 2B 4B 8B 16B 32B MODEL SIZE IN BF16 (LOG SCALE) cuda-checkpoint + CRIU (~50s) P95 (34.2s) State of the Art P50 (18.7s @ 7B) PhoenixOS (9.8s @ 13B) 0.15s (0.6B) Ours: 0.15s to 1.11s (32B) B. PROCESS OVERHEAD 0.0s 0.25s 0.50s 0.75s 1.00s 1.25s 0.6B 3B 7B 9B 14B 24B 32B MODEL SIZE (1.2 GB TO 64 GB BF16, LINEAR) 0.15s 0.39s (7B) 0.46s (9B) 0.62s (14B) 0.87s (24B) 1.11s (32B) 1.05s ckpt Our vLLM Cold Start Our Checkpoint State-of-the-Art Snapshot PhoenixOS cuda-checkpoint + CRIU
FIG 06 · Top: Log-log comparison of GPU cold start / restore latency across BF16 model sizes (0.5B to 32B) against state-of-the-art GPU snapshots, PhoenixOS, and standard cuda-checkpoint + CRIU. Bottom: Measuring the overhead of process continuation versus raw checkpoint/restore from 0.6B (1.2 GB) to 32B (64 GB).

Cool stuff we can do as a result

Once you can move live GPU state in and out of VRAM in under a second, there's some neato stuff you can do that wasn't possible before:

  • True scale to zero: Most "serverless" GPU deployments still keep warm GPUs burning money 24/7 because a 30-second cold start ruins user experience. When cold starts take under a second, you can scale idle endpoints all the way down to zero and spin them back up as requests arrive, saving you money by making cold starts affordable.
  • Turn spot instances into reserved instances: Because your CPU instance lives separately from the GPU, we can put your CPU on a cheap reserved instance and your GPU on a spot instance. If the spot instance goes down, we checkpoint your VRAM to VAST and live-migrate you to a new GPU within a second. The CPU doesn't know any different, aside from a small blip in latency. Pay for spot instances, get reserved instances. Neat!
  • Fork/join training at will: Tired of pausing your training run for torch.save() and evals? Just snapshot your GPU in a second and send the snapshot to another GPU to run the eval while your training cluster keeps running.
  • Sub-second deploys without green/blue complexity: Instead of allocating double the GPUs, waiting minutes for weights to load and CUDA graphs to warm up, and draining traffic over, checkpoint/restore lets you resume state on the fly with zero code changes.

And there we go, paid for the whole bus, use the whole bus. Now we can just dream about the day when someone lets us bolt a network card directly onto the HBM stacks.

That's it, there's no call to action, hope you enjoyed the post!

← Back to blog