Skip to content

fix(ateom-gvisor): isolate and evict checkpoint page cache to prevent actor cgroup OOM - #1917

Merged
Benjamin Elder (BenTheElder) merged 3 commits into
agent-substrate:mainfrom
chw120:gvisor-checkpoint-cache-cgroup
Oct 16, 2026
Merged

Benjamin Elder (BenTheElder) merged 3 commits into
agent-substrate:mainfrom
chw120:gvisor-checkpoint-cache-cgroup

Conversation

@chw120

@chw120 Chenyi Wang (chw120) commented Sep 26, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

When a gVisor (runsc) sandbox is paused or suspended under a tight memory limit (ActorMemory), writing the snapshot (pages.img) inside the actor's own cgroup doubles memory pressure (guest RAM + snapshot page cache), causing severe swap thrashing or cgroup OOM-kills.

This PR decouples snapshot I/O from the actor's memory cgroup and deterministically reclaims snapshot page cache after restore:

  1. Checkpoint Cgroup Isolation: Run runsc checkpoint in a dedicated sibling cgroup(-_ckpt) so snapshot page cache is not charged against the actor's memory limit during pause or suspend.
  2. Post-Restore Cache Reclamation: After runsc wait -restore completes, evict snapshot page cache (guarded by cachestat(2) so FADV_DONTNEED never forces writeback). Staged files in restore_dir are removed only when preserve_restore_dir is unset (GCS restore-state/); for local resume (atelet,ateom: restore local pause checkpoints directly from LocalSnap #1876) the files are kept and only their cache is evicted.

Notes

Rebased on #1876: file cleanup now keys off preserve_restore_dir instead of Nlink, since a local restore no longer has a hard-link. DATA_ON_GOLDEN case dropped after #2105.

Benchmark

1. Memory-Constrained Local Pause/Resume (glutton_1user_500mi_gvisor_pause)

1 user, 500 MiB active guest RAM (64 MiB dirtied before each pause), 600 MiB actor memory cgroup limit (ActorMemory), 1m duration.

Metric Baseline (main) With Fix (#1917) Improvement
Completed Cycles (in 60s) 6 (1 actor OOM-crashed) 23 3.8x throughput
PauseActor Failure Rate 25.0% (2/8 failed, runsc checkpoint OOM) 0.0% (0/23) Eliminates checkpoint OOM
PauseActor P50 / Avg / P95 3,400 ms / 3,245 ms / 5,800 ms 620 ms / 980 ms / 2,900 ms -82% P50 (-70% Avg, -50% P95)
ResumeActor P50 / Avg / P95 3,000 ms / 2,957 ms / 3,000 ms 370 ms / 404 ms / 660 ms -88% P50 (8.1x faster)

2. Memory-Constrained GCS Suspend/Resume (glutton_1user_500mi_gvisor_suspend)

1 user, 500 MiB active guest RAM (64 MiB dirtied before each suspend), 600 MiB actor memory cgroup limit (ActorMemory), 1m duration.

Metric Baseline (main) With Fix (#1917) Improvement
Completed Cycles (in 60s) 2 (5 actors OOM-crashed) 9 (10 suspends) 4.5x–5x throughput
SuspendActor Failure Rate 83.3% (10/12 failed, runsc checkpoint OOM) 0.0% (0/10) Eliminates checkpoint OOM
SuspendActor Latency (successful) 7,700 ms – 8,844 ms 2,800 ms P50 / 3,105 ms Avg / 4,400 ms P95 -64% to -68%
ResumeActor P50 / Avg / P95 1,800 ms / 1,715 ms / 1,815 ms ($N=2$) 1,500 ms / 1,605 ms / 2,300 ms ($N=9$) -17% P50 (-6% Avg)

3. Multi-User Local Pause/Resume (glutton_mem_512mi_4_user_gvisor_pause)

4 concurrent users, 512 MiB active guest RAM (64 MiB dirtied before each pause), 768 MiB actor memory cgroup limit (ActorMemory), 1m duration.

Metric Baseline (main) With Fix (#1917) Improvement
Completed Cycles (in 60s) 20 (2 actors OOM-crashed) 27 +35% throughput
PauseActor Failure Rate 15.4% (4/26 failed, runsc checkpoint OOM) 0.0% (0/30) Eliminates checkpoint OOM
PauseActor P50 / Avg / P95 3,100 ms / 3,272 ms / 8,600 ms 1,000 ms / 1,008 ms / 1,400 ms -68% P50 (-69% Avg, -84% P95)
ResumeActor P50 / Avg / P95 3,800 ms / 3,698 ms / 6,000 ms ($N=20$) 5,000 ms / 4,365 ms / 5,600 ms ($N=27$) -7% P95

Note: ResumeActor P50/Avg is slightly higher because main OOM-crashes 2 of the 4 actors during PauseActor, whereas with #1917 all 4 actors stay alive and share the worker pod's 2-vCPU limit concurrently (+35% more completed cycles).

  • Tests pass
  • Appropriate changes to documentation are included in the PR

Comment thread cmd/ateom-gvisor/main.go Resolved
Comment thread cmd/ateom-gvisor/ckptcgroup.go Resolved
@chw120
Chenyi Wang (chw120) force-pushed the gvisor-checkpoint-cache-cgroup branch from 9f717b2 to 53bf150 Compare September 29, 2026 01:57
@bowei Bowei Du (bowei) added area/gvisor area/node kind/bug Something isn't working / bugfixes labels Sep 30, 2026
@chw120
Chenyi Wang (chw120) force-pushed the gvisor-checkpoint-cache-cgroup branch 3 times, most recently from 5f3a786 to c683e25 Compare October 1, 2026 07:45
@ajaysundark

Copy link
Copy Markdown
Contributor

/cc

@dajiaohuang

Copy link
Copy Markdown

There is an interaction with merged #1876: the direct-local restore path sets ActorDirs.RestoreDir to the checkpoint directory and sends PreserveRestoreDir=true. In this PR, that call has restoreDir == actorDirs.GetRestoreDir(), so the preserved-source guard does not match; a single-link checkpoint then reaches the truncate-and-remove branch. That destroys the local checkpoint needed for another resume. Could this cleanup receive and honor PreserveRestoreDir? #1876's body calls out this follow-up explicitly.

Moe Abadi (moetemp) pushed a commit to moetemp/substrate that referenced this pull request Oct 3, 2026
…agent-substrate#1876)

### Summary

For a local pause resume (`CHECKPOINT_TYPE_LOCAL`), atelet no longer
stages the snapshot into `restore-state/`. It verifies the files in
`local-checkpoint/<snapshotName>/` and passes that directory to ateom as
`ActorDirs.restore_dir` with `preserve_restore_dir = true`. The
external/GCS `restore-state/` flow is unchanged.

### Why

`main` currently hard-links the snapshot into `restore-state/`, and the
only thing stopping ateom from mutating the actor's local restore point
is the `Nlink > 1` side effect of that hard-link. This PR replaces that
implicit guard with an explicit `preserve_restore_dir` flag: when set,
ateom never deletes or rewrites files in `restore_dir` (no
post-eager-restore drop of `memory-ranges`, copy-merge instead of
in-place merge on the next OnDemand checkpoint, no `config.json` rewrite
on a same-actor resume).

This also removes the staging step and its cross-filesystem copy
fallback, and gives lifecycle v2, where local snapshots are kept and
reused as a cache after upload, a clear read-only contract. Resume
latency and disk usage are on par with the current hard-link path.

Follow-ups:
- With `DATA_ON_GOLDEN` gone (agent-substrate#2105), `copyLocalCheckpoint` has no
remaining callers and will be removed in a separate PR.
- agent-substrate#1917 / agent-substrate#2030 should key their restore-file cleanup off
`preserve_restore_dir` rather than `Nlink`, since a local restore now
has a single link.

### Original benchmark vs the pre-hardlink full-copy baseline

Setup: glutton, 1 user, 512Mi working set, pause/resume lifecycle.
gVisor actors use 1536Mi so `pages.img` stays ~100% cached after pause
(same as MicroVM); MicroVM actors use 768Mi.

| | ResumeActor P50 | `download` (copy) | `ateom_restore` |
|---|---|---|---|
| gVisor 1536Mi, full-copy baseline | 740ms | ~310ms | ~370ms |
| gVisor 1536Mi, this PR | **430ms** | 0 | ~370ms |
| MicroVM 768Mi, full-copy baseline | 2900ms | ~1.8s (1.4–2.1s) | ~0.97s
(0.78–1.1s) |
| MicroVM 768Mi, this PR | **600ms** | 0 | ~0.57s (0.54–0.82s) |

ResumeActor P50 is client-side (locust). `download` and `ateom_restore`
are server-side medians from atelet's "Restore timing breakdown" log
(6–8 resumes per run).


- [x] Tests pass
- [ ] Appropriate changes to documentation are included in the PR
@chw120
Chenyi Wang (chw120) force-pushed the gvisor-checkpoint-cache-cgroup branch from c683e25 to 683e5b4 Compare October 7, 2026 05:10
@chw120

Copy link
Copy Markdown
Collaborator Author

There is an interaction with merged #1876: the direct-local restore path sets ActorDirs.RestoreDir to the checkpoint directory and sends PreserveRestoreDir=true. In this PR, that call has restoreDir == actorDirs.GetRestoreDir(), so the preserved-source guard does not match; a single-link checkpoint then reaches the truncate-and-remove branch. That destroys the local checkpoint needed for another resume. Could this cleanup receive and honor PreserveRestoreDir? #1876's body calls out this follow-up explicitly.

Yeah, rebased on #1876: staged-file cleanup now keys off preserve_restore_dir instead of Nlink, since a local restore now has a single link. Please review again. Thanks.

@dajiaohuang

Copy link
Copy Markdown

Thanks for the rebase and the preserve_restore_dir fix — looks good.

@chw120 Chenyi Wang (chw120) changed the title perf(ateom-gvisor): isolate checkpoint page cache from actor cgroup limit fix(ateom-gvisor): isolate and evict checkpoint page cache to prevent actor cgroup OOM Oct 7, 2026
@chw120
Chenyi Wang (chw120) force-pushed the gvisor-checkpoint-cache-cgroup branch from ae52c9a to 2a8e61d Compare October 9, 2026 02:21
Comment thread cmd/ateom-gvisor/ckptcgroup.go Resolved
Comment thread cmd/ateom-gvisor/ckptcgroup.go Resolved
Comment thread cmd/ateom-gvisor/ckptcgroup_test.go Resolved
Comment thread cmd/ateom-gvisor/ckptcgroup.go Resolved
Comment thread cmd/ateom-gvisor/ckptcgroup.go Resolved
Comment thread cmd/ateom-gvisor/ckptcgroup.go Resolved
Comment thread cmd/ateom-gvisor/ckptcgroup.go Resolved
@chw120
Chenyi Wang (chw120) force-pushed the gvisor-checkpoint-cache-cgroup branch 3 times, most recently from 2a8e61d to 808216f Compare October 13, 2026 05:37
Comment thread internal/snapshotcache/snapshotcache.go Resolved
Comment thread cmd/ateom-gvisor/main.go Outdated
}

switch fidelity {
case ateompb.SnapshotFidelity_SNAPSHOT_FIDELITY_VOLUMES:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we wrap the SNAPSHOT_FIDELITY_VOLUMES branch in checkpoint cgroup isolation as well? Runsc will write pages.img and multitar.img from sentry process, large durable-dir tmpfs will still charge page cache to _pause.

cc: Fabricio Voznika (@fvoznika)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — generalized checkpointFull to checkpointIsolated and wrapped both SNAPSHOT_FIDELITY_VOLUMES (cmdFsCheckpoint) and SNAPSHOT_FIDELITY_MEMORY (cmdCheckpoint) in checkpoint cgroup isolation.

Comment thread cmd/ateom-gvisor/main.go Resolved
Comment thread cmd/ateom-gvisor/ckptcgroup.go Resolved
Comment thread cmd/ateom-gvisor/runsc.go Resolved
Comment thread cmd/ateom-gvisor/ckptcgroup.go Resolved
Comment thread cmd/ateworker-gvisor/ckptcgroup.go Resolved
Comment thread cmd/ateom-gvisor/ckptcgroup.go Resolved
Comment thread internal/snapshotcache/snapshotcache.go Resolved
Comment thread internal/snapshotcache/snapshotcache.go Resolved
@chw120
Chenyi Wang (chw120) force-pushed the gvisor-checkpoint-cache-cgroup branch 2 times, most recently from 7148904 to 74f48ae Compare October 14, 2026 20:02
Comment thread cmd/ateom-gvisor/ckptcgroup.go Resolved
Comment thread cmd/ateworker-gvisor/ckptcgroup.go Resolved
Comment thread cmd/ateom-gvisor/ckptcgroup.go Resolved
Comment thread cmd/ateom-microvm/restore.go Resolved
Comment thread cmd/ateom-gvisor/ckptcgroup_test.go Resolved
- checkpointIsolated: if removing <uid>-_ckpt fails after teardown (the
  sentry is still alive in it, e.g. a failed terminate after a VOLUMES
  checkpoint), move it back into <uid>-_pause and retry the removal so a
  live sandbox never stays without its memory.max.
- isolateCheckpointCache: refuse to move a pid that is not in the pause
  leaf, so a reused pid is never moved into the checkpoint cgroup.
- dropActorCheckpointCacheAsync: bound the preserved-dir restore wait by
  the hosted actor's lifetime (new hostedActor ctx, cancelled by
  unhostActor) instead of a fixed 30s timeout.
- ateworker-microvm: scope the pre-Resume drop comment to the
  non-preserved case.
- Tests for the reused-pid rejection, the failed-terminate rollback, and
  the preserved-dir eviction path (wait, wait error, actor unhosted).
@chw120
Chenyi Wang (chw120) force-pushed the gvisor-checkpoint-cache-cgroup branch from 74f48ae to ad6ee0a Compare October 15, 2026 16:13

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a pretty complex change. Closer now I think.

How should we be handling VOLUMES mode for actor lifecycle v2?

Comment thread cmd/ateworker-gvisor/ckptcgroup.go Outdated
return nil
}
err := movePID(c.leaf, c.pid)
if errors.Is(err, fs.ErrNotExist) || errors.Is(err, unix.ESRCH) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 should-fix 🟡 – cgroupHasPID(c.dir, c.pid) just confirmed the sentry is still alive in c.dir, so fs.ErrNotExist from movePID(c.leaf, c.pid) means the pause leaf is missing and the sentry was not moved out of <uid>-_ckpt. Returning nil here makes checkpointIsolated set terminateOnExit = false and skip terminate(), leaving the live sentry running in the unlimited <uid>-_ckpt cgroup. Only unix.ESRCH should be ignored here, and TestCheckpointCgroupRestoreAfterLeafGone (cmd/ateworker-gvisor/ckptcgroup_test.go:161) should expect an error.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — restore() now only ignores ESRCH, so a missing pause leaf makes checkpointIsolated terminate the sandbox. Updated TestCheckpointCgroupRestoreAfterLeafGone to expect the error and added an end-to-end case for it.

@BenTheElder

Copy link
Copy Markdown
Collaborator

Ah, I see we discussed that in #1917 (comment) (lots of comment threads on this one).

I think #1917 (comment) is a bug, reasoning about if it can be a follow-up ... This seems kinda bad though.

…n checkpoint failure

checkpointCgroup.restore() treated fs.ErrNotExist like ESRCH, but after cgroupHasPID confirmed the sentry is alive in <uid>-_ckpt, ENOENT means the pause leaf is missing and the sentry was not moved. Returning nil made checkpointIsolated skip terminate() and leave the live sentry in the unlimited cgroup. Only ignore ESRCH, so the caller terminates the workload instead.
@BenTheElder
Benjamin Elder (BenTheElder) added this pull request to the merge queue Oct 16, 2026
Merged via the queue into agent-substrate:main with commit a153e10 Oct 16, 2026
15 checks passed
@chw120
Chenyi Wang (chw120) deleted the gvisor-checkpoint-cache-cgroup branch October 16, 2026 05:14
Haowei Cai (Roy) (roycaihw) pushed a commit to roycaihw/substrate that referenced this pull request Oct 20, 2026
…agent-substrate#2796)

Replaces agent-substrate#1551, which was four commits from September that no longer
applied: the restore source message, the download path and the module
path all changed underneath it. Same design, ported onto today's main,
after agent-substrate#2756 and agent-substrate#2889, in four commits, plus three from review: the
sharing derived from the snapshot's owner, the gVisor worker evicting a
shared file's pages before unlinking it, and the manifest read through
the cache.

Milestone M2 of the node-local artifact cache from agent-substrate#690: an actor
restoring from a shared snapshot, the one a tag holds and every actor
created from it starts on, gets its files from a node-level cache
instead of its own download.

**Sharing is the control plane's knowledge.** `ObjectStorage` gains
`SnapshotSharing`, the property rather than the snapshot's kind, so new
shared snapshot kinds need no atelet change. ate-api-server sets it on
Restore from the snapshot's recorded owner: SHARED for a tag's snapshot,
borrowed at creation and immutable, PRIVATE for the actor's own, which
its next suspend replaces, and for an unrecorded owner; the local-copy
fallback object carries it too. Neither side infers it from the URI.

**The cache.** A `filecache.Store` under `/var/lib/ate/snapshot-cache`,
behind `--snapshot-cache-dir` (empty disables),
`--snapshot-cache-min-age` and `--snapshot-cache-fetch-timeout`, opened
at startup with crash debris swept before atelet serves. The directory
must be under the node state dir: the object-store plugin writes only
there, so a cache elsewhere could never be filled, and startup refuses
it. Every flag is validated whether or not the cache is enabled, and the
help text says which are read only while it is. The image cache's
base-path check moved to `nodepath.UnderBasePath`, which both caches
use.

**Eviction.** The cache runs on the eviction loop every file cache on
the node shares, `cmd/atelet/internal/filecachegc` from agent-substrate#2889:
`--snapshot-cache-gc-period`, `--snapshot-cache-max-bytes` and
`--snapshot-cache-gc-dry-run`, a budget with a 90% low water, least
recently used first, never an entry a restore still links. Its size,
evictions and passes appear under the existing `ate.filecache.*`
instruments as a second value of `ate.filecache.name`, `snapshot`; no
new instrument and no new loop. Every flag is validated whether or not
the cache is enabled, as the other flags in atelet are, and the help
text says which ones are read only while it is; a disabled cache starts
no loop and reports no size.

**The restore path.** A restore whose durable snapshot is declared
SHARED stages each file through the cache; concurrent restores of one
snapshot share a single download, later ones read disk. How a hit is
served depends on the sandbox class the request's runtime resolved to.
ateworker-gvisor gets hard links. Once the restore's page loading is
done the worker evicts the memory and filesystem images from the page
cache and unlinks them from its restore dir, keeping only the small
descriptors linked: a file with another link is treated like a preserved
restore dir, since unlinking it would free nothing and would leave its
pages charged to the actor's cgroup, the case agent-substrate#1917 fixed. So a link
protects an image's entry through the restore and the min age, not for
the actor's life; an entry evicted later is still read to the end
through the descriptors runsc holds open, and the next restore reads the
images from local disk. ateworker-microvm gets private hole-preserving
copies, not because a link would corrupt the cache, it replaces
`config.json` by rename and its delta merge copies when the inode is
shared, but because a link would cost more than it saves: an on-demand
restore keeps `memory-ranges` open for the VM's life, which would pin
the entry that long and turn every suspend's in-place merge into a full
copy, and the worker's page-cache eviction would drop the cached pages
for every other restore; agent-substrate#2948 tracks what links would take. EXDEV on a
link, from a cache bind-mounted apart from the restore dirs, degrades to
a copy, warned once per process. The manifest of a shared snapshot is
read through the cache too, so a restore that finds the snapshot on the
node makes no request to object storage; a private snapshot's manifest
is read fresh, as the commit marker it is. The cache key is the object's
address without its query, so a signed URL's credential reaches neither
the key, the cache's errors, `meta.json` nor the logs; and a restore
that fails after staging removes its restore-state dir, so the links do
not hold the cache's entries until the next lifecycle call. The fetch
goes through the object-store plugin as the direct path does, one file
into a scratch dir on the cache's filesystem; because the cache then
writes the restore dir by path rather than through the plugin's
`os.Root`, a symlink waiting there fails the restore instead of being
followed.

**Not here.** Private snapshots stay uncached by design. Links for
ateworker-microvm are agent-substrate#2948, a worker change. The open-and-sweep code
the two file caches share in shape is still duplicated, since the
snapshot cache can be disabled and degrades to copies across mounts
where the asset cache refuses to start.

**Tests.** ate-api functional: a golden restore declares SHARED, the
actor's own snapshot PRIVATE. ate-api unit: the detach-and-resume test
from agent-substrate#2852 compares the whole Restore snapshot, so its fixture now puts
the snapshot under the actor's own UID, and it expects PRIVATE for the
downloaded copy and nothing for a local one. atelet: flag validation,
enabled or not, including the eviction flags, the fetch timeout, and a
directory outside the node state dir; two shared manifest reads download
once and a private one reads fresh; open disabled, creating its root,
sweeping debris; the sharing gate and the class modes; through the
plugin-backed test atelet, two link restores share one download and one
inode and the file is read-only, copy mode gives private inodes and
survives a consumer's mutation, a nil cache downloads, a missing file's
error reaches the caller with nothing left behind, a plugin error
through the cached modes keeps its code and leaks no credential from a
signed URI, a restore that fails at the worker after staging leaves no
file in restore-state and no link on the cache's entry, four concurrent
restores download once, a file in the checkpoint subdirectory lands
there, and symlinks in place of the file or the subdirectory fail; on
Linux, a restore dir on another mount gets a copy with no second
download and one warning for two restores. ateworker-gvisor, on Linux:
staged images hard-linked elsewhere are evicted after the restore wait
and only then unlinked, with the other links intact.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/gvisor area/node kind/bug Something isn't working / bugfixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants