A from-scratch implementation of Modal's snapshot-restore model on top of gVisor, with the full substrate underneath: content-addressed image format, unified blob store, custom lazy FUSE, thin scheduler, and a Modal-shaped Python SDK.
A serverless function shouldn't pay import torch + model build (~13 s for resnet50) on every cold start. The standard answer is to checkpoint-and-restore the warm process. I built that end-to-end on gVisor (image format up through SDK) and measured it on a real workload: a 661 KB JPEG → resnet50 → ImageNet label. Cold p50 774 ms (p99 806 ms, n=12), warm 125 ms p50 (n=30), against a same-host from-scratch baseline of ~2.58 s and a true cold pull of ~29 s. 10 of 12 cold samples land under the 800 ms target; 2 are over (≤806 ms). runsc restore itself was ~240–300 ms at every stage of the project. Every cold-path tax I hit was something around restore (rootfs re-extraction, FUSE faults on the critical path, lazy faulting torch with no prefetch), diagnosed precisely and either fixed or scoped out with a stated tradeoff. The most interesting parts of the project are the things that didn't work the first time.
Modal's pitch (and the reason cold starts on ML serverless got fast) is straightforward: if a function's expensive work is importing torch and building a model, do that work once, checkpoint the warm process, and on every later cold start restore the post-import memory state directly. The model never has to be built again. The Python interpreter never has to re-import.
The natural questions are: how much of that speedup is the snapshot itself, and how much is everything else? Is runsc restore the bottleneck? Is image transfer? Is file system materialization? If snapshot-restore is the magic, what's the irreducible cost? I wanted to know by building it.
thaw is the result: a Go binary (cmd/thaw {snapshot, restore, image, push, pull, mount, deploy, scheduler}) plus a Modal-shaped Python SDK, all sitting on top of gVisor's checkpoint/restore. Everything in the path is custom: image format, on-disk store, lazy FUSE, scheduler. Stdlib-only with one deliberate exception (vendored hanwen/go-fuse; the FUSE wire protocol is a learning-scoped target, not a reimpl-scoped one, and S3 SigV4 went the other way for the same reason).
The real workload is examples/torch_app.py: import torch at module load, build resnet50 with weights baked into the image, transform + 1000 ImageNet labels, one warm forward at snapshot time. Per request: a real 661 KB JPEG → real classification. Driven through the actual platform (thaw deploy → thaw scheduler → SDK app.call with JPEG bytes). The metric is the SDK's stat.total_ms: invoke → first usable result, fully synchronous (no scheduler hiding restore time behind a queue).
| regime | p0 | p50 | p90 | p99 | mean | sd | n |
|---|---|---|---|---|---|---|---|
| COLD steady-state | 721.9 | 773.7 | 803.2 | 806.4 | 771.7 | 26.3 | 12 |
| — of which cmdRestore wall | 487.0 | 519.6 | 559.0 | 564.8 | 525.7 | 25.0 | 12 |
— of which runsc restore | — | ≈ 280 | — | — | — | — | — |
| WARM (warm-pool reuse) | 101.8 | 124.8 | 130.5 | 132.9 | 122.3 | 7.2 | 30 |
| one-time materialize (first cold) | — | ~5700 | — | — | — | — | 1 |
All numbers in milliseconds. Substrate: Hetzner CPX31 (AMD EPYC-Genoa, 4 shared vCPU, 8 GB RAM, local NVMe), runsc release-20260511.0, --network=none. 0/44 contract violations across the run.
Cold p50 is comfortably sub-second. The cold p99 (806 ms) is about 1% over the 800 ms target for a real heavy inference; lighter workloads (numpy, FastAPI) clear it with margin. I'm calling out the two over-target samples explicitly because "10/12 under 800 ms, 2 at most 806 ms" is more useful than rounding the headline down. Warm 125 ms is where serverless ML actually lives in practice, once a function has been called once.
The row in the middle is the part I want to point at. runsc restore is ~280 ms. It was ~280 ms when the integrated platform measured a cold p50 of 5.7 seconds. It was ~280 ms when the platform measured 1.6 seconds. It was ~280 ms at the headline 774 ms. The snapshot-restore primitive is not the bottleneck and never was. Every other number in the cold path is something happening around restore: file system materialization, page-file reassembly from a content-addressed store, FUSE faults on the critical path, lazy-loading without prefetch. Each of those has its own little story.
Early in the project, on a local arm64 Lima VM, runsc checkpoint worked fine (~30 ms) but runsc restore SIGILL'd on every real CPython process. Both systrap and ptrace platforms. A trivial /bin/sleep restored cleanly, so core checkpoint/restore worked; the symptom was specific to processes with the kind of state CPython has. My best hypothesis is arm64 PAC/BTI register state not preserved across gVisor restore: the userspace instructions live, but the kernel/CPU pointer-authentication state where the bug lives is untouched by Rosetta (asked and answered separately).
This was the decision-gate moment: thesis-killer, or substrate-specific? I provisioned a Hetzner CPX31 (AMD EPYC-Genoa, x86-64), ran the same matrix, and CPython restored cleanly on both platforms. Substrate decision settled: all subsequent work is x86-64 only. Modal ships this on x86-64; that's not a coincidence. The arm64 issue is a real bug surface, not an architectural property of gVisor, but it ate a half-week and reframed the rest of the project as cloud-only.
The bundle format is content-addressed: rootfs and checkpoint files are decomposed into sha256-named 4 MiB chunks, deduped by construction, reassembled with corruption detection. Tiered storage (NVMe ← S3 origin) is one indirection away, via a hand-rolled SigV4 client because writing it once teaches you what the surface actually looks like and the wire protocol is small enough to be the right scope.
The first cut chunked the flattened docker export tar. Re-importing the identical image deduped at ~98.5% (CAS works). But two images derived from the same base (a numpy-pandas image and the torch image, both FROM python:3.12-slim) shared 0 chunks. The dedup payoff across artifacts was a flat zero.
The cause is exactly what you'd expect once you look at it: torch's extra files shift byte offsets in the tar, so every 4 MiB chunk boundary misaligns versus the simpler image's shared base. File content never coincides with a chunk boundary. CAS is exact; the offsets weren't.
The instinct here is to reach for content-defined chunking (FastCDC). I deliberately didn't. The same root cause (chunking-over-tar) also broke the FUSE that would come next: a lazy FUSE can't serve one file without materializing the whole tar prefix that contains it. So instead of building FastCDC as a standalone thing that would be a throwaway, I went straight to per-file content-addressed entries. Each file becomes its own list of chunks; the image manifest tracks files with their per-file content addresses; the FUSE can seek to any file at any offset.
After the change: re-importing the identical image dedupes to +0 chunks (was +68); bench and torch share 5,612 chunks ≈ 58% of bench reused by torch (was 0); restore got faster not slower (no whole-tar reassemble + untar on the critical path). One refactor solved two problems. The lesson I keep coming back to from this one: when a measured negative has an obvious fix that's also the right move for a downstream system, do the right move instead of patching the negative in isolation.
The custom lazy FUSE (internal/thawfs) faults chunks on demand from CAS. find over an entire ~10k-file torch image faults 0 content chunks (directory entries from the manifest are enough); reading two files lands at 8 of 9,617 chunks served = 0.1%. Lazy as advertised.
The question is whether lazy is fast in practice on a heavy workload. Measured: booting the per-file CAS image lazily and running torch init to first result lands at ~18.9 s p50. The naive docker pull cold is ~29 s. The lazy path is barely 1.5× better. A trap, not a win.
What's happening: torch's import working set is huge and synchronous. The FUSE doesn't know in advance which chunks will be needed, so every page fault is a round-trip on the critical path. The catastrophic first-read tail is the cost of not knowing the working set. The same lesson reappeared when I tried to optimize cold restore later: an early prefetch pass that fetched only the checkpoint pages.img performed identically to no prefetch (~5.6 s for restore), and only adding the rootfs profile to the prefetch set collapsed it to 0.5 s. gVisor's C/R re-faults file-backed library mappings (libpython, .so's) on restore, so the snapshot-time profile is exactly the right prefetch target. Measuring caught the wrong call before shipping it; I reverted the half-measure and removed the misleading flag.
Lazy faulting wins when the working set is small and unknown; eager materialization wins when it's large and knowable. For a snapshot-restore platform, the working set is always knowable because the snapshot-time profile is the working set. The platform's default is the eager path; the lazy FUSE is opt-in and retained for the small-workload case it actually wins on.
The first integrated build of the scheduler measured 5.7 s p50 cold for the real workload. This was nominally a regression, since the bare thaw restore path was hitting ~240 ms. The integration was paying ~5 seconds I had to find.
The diagnosis: per call, the scheduler was re-extracting the full 1.18 GB rootfs from CAS into a fresh per-sandbox work directory, then runsc restore was reassembling the 365 MB checkpoint pages.img from CAS bytes the same way. Both were repeatable work that could have been done once.
The fix is a symmetric pair of caches, digest-keyed:
| stage | steady-state cold p50 | note |
|---|---|---|
| as first built | 5,736 ms | per-call 1.18 GB rootfs re-extract dominated |
| + shared rootfs cache | 1,658 ms | 3.5×; overlay lower is a passive cached directory |
| + shared checkpoint cache | 703 ms | synthetic workload, every sample < 800 ms |
| + real workload (real photo → label) | 774 ms | p99 806 ms (real JPEG decode + preprocess + forward) |
The architectural detail that matters: the shared rootfs is a passive cached directory plus a per-sandbox kernel overlay. That's the lazy-FS chain minus the FUSE. The active in-process FUSE was incompatible with the scheduler's lifecycle, because thaw restore exits after the restore call, and an in-process FUSE dies with it, but the scheduler has to keep the sandbox parked for warm-pool reuse. A passive directory + kernel overlay survives thaw restore exiting; the scheduler owns the overlay umount in sandbox.teardown() after runsc delete. Cold / warm / evict / drain are leak-free (0 sandboxes, 0 mounts across the test matrix). This is the moment the lazy-FS work paid off as architecture, not as the FUSE you'd reach for.
The one-time materialize (~5.7 s) is real, per (function, host). I report it separately and prominently rather than folding it into the steady-state distribution. It amortizes over every later cold start and is the same class of cost as Modal materializing at deploy. A later refinement moves the materialization into thaw deploy itself, so the first invocation pays only the genuinely-irreducible import torch + build-resnet50 cost (~13.4 s), gets served, and then checkpoints in the background so call #2 is already steady-state cold at about 0.84 s, roughly 16× faster than call #1. The snapshot is the actual win.
The benchmark I most want to point at, because it's the one where the result didn't go the way I expected: a 2-way head-to-head against eager-pull-cold (i.e., the naive "just docker pull and run") across three workloads (tiny / numpy-pandas-fastapi / torch-resnet50) × two network profiles (loopback / 25 ms RTT, 100 Mbit). N=30 warm, N=12 cold, 504 runs, 0 failures.
| workload × link | thaw cold | eager-pull cold | ratio |
|---|---|---|---|
| tiny / loopback | small | small | ~7× |
| numpy+pandas+fastapi / loopback | fast | fast | ~7× |
| torch / loopback | fast | fast | ~9× |
| tiny / 25 ms · 100 Mbit | small | small | 2.2× |
| numpy+pandas+fastapi / 25 ms · 100 Mbit | 19.2 s | 11.5 s | 0.60× (loss) |
| torch / 25 ms · 100 Mbit | 80.5 s | 29.6 s | 0.37× (loss) |
| any workload / warm-pool reuse | 125–1900 ms | n/a | 5–7× |
thaw wins warm everywhere, net-independent, by 5–7× (torch warm is 1.9 s vs eager 14 s; this is the case that matters once a function has been invoked once). It wins cold 7–9× on a fast link. But cold over a real-network model for heavy images, thaw loses, with torch by nearly 3×.
The mechanism is visible in the timing structure, though I didn't packet-trace it. thaw ships uncompressed CAS bytes and serializes the full prefetch working set onto the critical path. docker pull ships gzip layers in parallel and is netem-insensitive; its cold wall is extract + import, not transfer. The same metric the head-to-head measures (fully synchronous start to first real op) refuses thaw the scheduler-overlap it doesn't have in this benchmark. The integrated scheduler eventually lets that overlap happen, but the head-to-head deliberately doesn't allow it; the point is to compare substrates with the same discipline.
I report this as a loss and a mechanism, not a footnote. Two named next-steps that would move the constant are recorded but not built: compress CAS chunks in transit (zstd, format-additive), and overlap prefetch with scheduling. Both are out of scope for "understand the substrate", and either would change this crossover. The point of running the benchmark wasn't to win it.
| component | path | what it does |
|---|---|---|
thaw snapshot / restore | cmd/thaw/, internal/manifest/ | gVisor C/R wrapped in a content-addressed bundle format (v2): per-file image entries, sentinel-triggered checkpoint, CPU-feature-set as a first-class manifest field |
| Content-addressed store | internal/cas/, internal/store/ | sha256 fixed 4 MiB chunks; dedup-by-construction; corruption-detecting reassemble; tiered NVMe ← S3 origin via hand-rolled SigV4 (internal/s3/) |
| Per-file image format | internal/imgfs/ | flattened docker export → per-file content-addressed entries; faithful rootfs (symlinks/hardlinks/devices); 58% cross-image dedup |
| Custom lazy FUSE | internal/thawfs/, vendored go-fuse | on-demand chunk faults from CAS; mounts as the overlay lower for a gVisor gofer rootfs |
| Thin scheduler + SDK | cmd/thaw/scheduler.go, sdk/thaw.py | Unix-socket JSON protocol, warm-pool + idle-TTL, leak-free SIGTERM drain; Modal-shaped @app.function / .remote() / .call() |
thaw deploy | cmd/thaw/deploy.go, internal/deploy/ | atomic registry JSON; --snapshot=false for non-blocking import-only deploys + auto-snapshot on first invocation |
Stdlib-only Go, cross-compiles darwin/arm64 → linux/amd64, so the runtime box needs no Go toolchain. One vendored dep: hanwen/go-fuse v2.10.1, the deliberate exception. The FUSE wire protocol is a learning-scoped target, not a reimpl-scoped one; opposite call from S3 SigV4, for the same reason.
thaw deploy's untimed prepare.--network=none for the headline run; the head-to-head's netem matrix is the network story.Same gVisor, same image, same real photo, same resnet50. The only variable is thaw's snapshot + warm pool. Two contestants race live: contestant A runs without a snapshot (fresh runsc run, import torch, build model, classify), and contestant B runs thaw (SDK → scheduler, one cold restore then warm-pool reuse, keeps classifying until A finishes its single result).
Validated run: dog.jpg → Samoyed on both sides, with the one-time materialize shown as an explicit untimed prepare. In the 5.53 seconds A needed for one inference, B served 16 (1 cold + 15 warm).
scripts/demo.sh; raw docs/data/demo.csvscripts/nockpt.sh; raw docs/data/nockpt.csvscripts/headtohead.sh; raw docs/data/headtohead.csvscripts/race.shexamples/torch_app.py + examples/Dockerfile.torchapp