Skip to content

Resuming a microVM actor on the same node no longer reads its rootfs upper back from disk - #3485

Merged
Tim Hockin (thockin) merged 10 commits into
agent-substrate:mainfrom
BenTheElder:tar-write-behind-keep-cache
Nov 3, 2026
Merged

Tim Hockin (thockin) merged 10 commits into
agent-substrate:mainfrom
BenTheElder:tar-write-behind-keep-cache

Conversation

@BenTheElder

@BenTheElder Benjamin Elder (BenTheElder) commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator

Why: In the microVM burn-in (GKE downstream testing), wake p50 went from ~350ms to ~1.1s (!), almost all of it in upper_join. Reported to me by Anna Pendleton (@annapendleton) though I'm not sure who originally found it.

#3353 started writing out snapshot tars as they're written, so their dirty pages can't run a tight worker pod out of memory. It also dropped the written pages from the page cache, so a resume on the same node read the rootfs upper tar back from disk ... which is slow. It also made every suspend write the tars at disk speed.

Fixes by commit order:

  1. Pull summing memory.stat keys out of memoryUsed, no behavior change.
  2. Clean pages are reclaimable, so dropping them is irrelevant for OOM safety.
    • The tars now stay cached. Write-out waits on the previous chunk instead of the one just written, so dirty pages stay at about two chunks without stalling on each one.
  3. Start snapshot writeback after the OnDemand delta merge, which replaces memory-ranges, so it tracks the merged file instead of the unlinked delta.
  4. Write snapshot files back in the background instead of with a blocking fdatasync.
  5. What snapshot writes can borrow leaves clean page cache out, so cached tars don't make a pod look "full".
    • A worker pod without a memory limit (e.g. the deploy_locust.sh default) has nothing to protect, so it lends all of it instead of capping every suspend as if the pod were full.
  6. When the pod can lend memory for the whole archive, the tar skips the write-behind and goes to the page cache at full speed, and the loan comes back once it's on disk. Otherwise the write-behind still bounds its dirty pages.

That said, with respect to workers without limits: We should probably consider recommending memory limits on worker pods. I will think about it overnight and propose docs updates tomorrow.

Tested with locust burn-in tests (see comments in the PR).

AI use: AI-assisted, I reviewed and tested it.

  • Tests pass
  • Docs updated

@BenTheElder Benjamin Elder (BenTheElder) added kind/bug Something isn't working / bugfixes area/microVM labels Oct 30, 2026
@BenTheElder

Copy link
Copy Markdown
Collaborator Author

I'm going ahead and pursuing the density-aware-write-behind to scope out if we can reasonably include that as well, just to bring the performance back inline to before OOMkiller on workers that aren't packed to the gills, but for now doing that in another branch as a POC.

@BenTheElder

Benjamin Elder (BenTheElder) commented Oct 30, 2026 •

Copy link
Copy Markdown
Collaborator Author

I'm going ahead and pursuing the density-aware-write-behind to scope out if we can reasonably include that as well, just to bring the performance back inline to before OOMkiller on workers that aren't packed to the gills, but for now doing that in another branch as a POC.

this is not too bad actually, and it has a similarly bad regression without.

EDIT: added

@BenTheElder Benjamin Elder (BenTheElder) added this to the M3 milestone Oct 30, 2026

@chw120 Chenyi Wang (chw120) left a comment

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.

LGTM

One note: #3353 added StartWriteback (fdatasync) for the VM memory snapshot, and this commit adds the fast-path tars to it too. Since fdatasync locks the shared ext4 journal (#1954) while returning borrowed memory only needs the pages to become clean, replacing fdatasync in StartWriteback avoids disk contention during concurrent suspends and immediate wakes.

I've updated #3477 to keep just that StartWriteback fix and a small mergeOnDemandDelta ordering fix, which brings local suspend + wake down further (630ms -> 393ms) on top of this PR.

Comment thread cmd/ateworker-microvm/snapshotborrow.go Outdated
size, err := dirSize(srcDir)
var borrowed int64
if err == nil {
borrowed = s.borrow.Take(size)

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.

Minor question: since writeSnapshot waits for a snapshotWrites slot before calling Take while archiveDir calls Take immediately, could smaller tars grab the remaining pod headroom first during a burst and leave the larger VM memory snapshot throttled?

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.

Yes, it can. In that case the snapshot falls back to the growth limit and CH writes at disk speed, so it's slower but still safe. I think ordering the loans is worth a follow-up, but I'd rather not add it here.

@MushuEE

Copy link
Copy Markdown
Collaborator

GKE burn-in: rc.1 vs rc.1 + #3485 + #3476

Tested this PR together with #3476 on top of v1.0.0-rc.1. This PR's change (keeping snapshot tars in the page cache and skipping the write-behind) targets the microVM upper_join regression measured below.

Result: with both PRs applied, the #3353 suspend/resume regression is gone on microVM, and the gVisor suspend tail shrinks. microVM upper_join drops from p50 310 → 29 ms and p90 796 → 40 ms, back to the pre-#3353 level. No new regressions turned up.

Setup

  • Same clusters, control plane, settings and load for both runs. The control plane was the published v1.0.0-rc.1 images, byte-identical in both runs. Only the worker image changed.
  • GKE 1.37.1, 5 × c3-standard-8 per cluster, one microVM cluster and one gVisor cluster.
  • deploy_locust.sh --worker-count 4 --actor-memory 1Gi with unsized workers (no --worker-memory), agentsession user class.
  • Ladder 6:2h,19:1h,40:20m,0:45m,13:90m,2:25m, chaos off.
  • Window: the first ~4.5 minutes of the 6-user step for each build.
  • PATCHED = v1.0.0-rc.1 + the 4 commits of Resuming a microVM actor on the same node no longer reads its rootfs upper back from disk #3485 + the 2 commits of leave page cache out of the memory guard's overshoot check #3476, cherry-picked cleanly. I checked the running worker pods' digests and the commit embedded in the worker binaries before starting load.

microVM

metric (ms) rc.1 p50 patched p50 Δ rc.1 p90 patched p90 Δ
worker upper_join (restores with an upper) 310 29 −91% 796 40 −95%
worker vm_restore 190 198 +4% 230 234 +2%
worker restore total 568 299 −47% 1072 359 −67%
atelet worker_checkpoint 735 199 −73% 2826 321 −89%
atelet restore total (snapshot.kind=local) 576 308 −46% 1126 459 −59%
locust TimeToWake (median) 600 330 −45%
locust ResumeActor (median) 590 320 −46%
locust SuspendActorAsync (median) 750 210 −72%

gVisor

metric (ms) rc.1 p50 patched p50 Δ rc.1 p90 patched p90 Δ rc.1 p99 patched p99
atelet worker_checkpoint 169 169 0% 907 225 −75% 9022 340
atelet restore total (snapshot.kind=local) 222 225 +1% 346 349 +1% 860 497
locust SuspendActorAsync (median / max) 190 / 11061 180 / 473
locust TimeToWake (median) 250 250 0%

Samples per build: microVM ~215 → ~260, gVisor ~270 → ~325 restores and checkpoints. The patched window was 23 s longer; the rest of the increase comes from faster cycles.

Regression checks (patched): 0 locust failures (baseline 0). 0 ERROR-level lines in atelet or worker logs. 0 worker or ate-system restarts. No OOMKilled or Evicted events. No actors stuck in RESUMING/SUSPENDING. No new warning types: the only WARN in either run is the same pre-existing OpenTelemetry SDK error, at the same rate. vm_restore and gVisor wake/resume are unchanged, as expected.

Caveats

  • Both PRs were applied together, so this doesn't attribute the gains to one PR or the other.
  • This covers only the early 6-user window. The 2 h step, the T+3 h 40-user burst and the full 6–12 h ladder weren't run, and neither was the sized-worker variant (--worker-memory).
  • The pre-Suspending actors no longer OOM-kills tight worker pods #3353 upper_join figure (~30 ms) comes from the original burn-in, not a local 45ab64df build.
  • microVM SuspendActorAsync max stayed ~6.3 s even though median/avg dropped ~72%, so a rare slow suspend remains.
Commits and image digests

Commits

build commit
BASELINE v1.0.0-rc.1 = f86fde9dfb5424c8d16f613de80469bdbef06541 (contains #3353 e8cdf13daf921520695db5ebd1999b6c2f739662)
PATCHED 672ce5b3b3584fba44e2f9c143bb8d1eb9e7851e = rc.1 + cherry-picks below
#3485 head b05659c3c283543d4c1158f6aa4da313f0c18efa
#3476 head 58a012a383c1032084bd78a3bd4cb661f4df407b

Cherry-picks (patched ← original): f4cebe4e ← e1e672ab, 2ed2a259 ← 16108b28, 71575952 ← 94c3bf69, 0056cec7 ← b05659c3 (#3485); 6ad6417e ← 5a84b1fa, 672ce5b3 ← 58a012a3 (#3476).

Only cmd/ateworker-gvisor and cmd/ateworker-microvm import the changed packages (internal/ateworker/{cgroup,memborrow}, internal/tarutil), so only the worker images differ between builds.

Control plane (identical in both runs): ghcr.io/agent-substrate/substrate/<name>:v1.0.0-rc.1

image digest
ateapi sha256:53afaaabe07c6caa…
atelet sha256:5c117b06276e709d…
objectstore-plugin sha256:d621b8cb79a75b90…
atecontroller sha256:b280e6ee6c9e4ebf…
ate-idp-server sha256:e12ba06ad37b792d…
atenet sha256:e2633da825fe45bc…
envoy-dataplane sha256:c8dcfc127ec4381b…
kubernetes-secrets sha256:d0e376e7abfe4548…

Workers

BASELINE (published rc.1) PATCHED (built with ko from 672ce5b3, tag rc1-regression-test)
ateworker-microvm ghcr.io/agent-substrate/substrate/ateworker-microvm:v1.0.0-rc.1@sha256:db86ad3ed25cf3a30b84fc32c9fde710e76cc34ee4e6a05b167554c3427476e1 ateworker-microvm:rc1-regression-test@sha256:734a32d3f993f42be0232c667f6b7cf5b4b45f67ab0a9cb459e74c0bac857951
ateworker-gvisor ghcr.io/agent-substrate/substrate/ateworker-gvisor:v1.0.0-rc.1@sha256:02a31963cdfb1d250d205a55fb870385b1ba5bad1be1308d556a6296f76bf145 ateworker-gvisor:rc1-regression-test@sha256:c74b2df0e22139a2d694f94d442945c944a46c1e339ff98cdc09d6fcc5a1db06

The patched worker binaries embed vcs.revision=672ce5b3b3584fba44e2f9c143bb8d1eb9e7851e, vcs.modified=false.

Benchmark images (not published; built from each build's commit): glutton sha256:8b0a1fb1116f… (rc.1) / sha256:2e4feeedd110… (patched, unchanged source); locust-test sha256:24c6d5d727a0… (identical for both).

@MushuEE Grant McCloskey (MushuEE) left a comment

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.

Validated changes against burn-in tests

@MushuEE

Copy link
Copy Markdown
Collaborator

Correction: the micro-VM checkpoint tail is not back to pre-break yet

My comment above used only the first ~4.5 minutes of the 6-user step, and that overstated the micro-VM result. Over a longer window (same patched build, same clusters, still 6 users), the micro-VM checkpoint tail grows with time. gVisor stays fixed.

Patched, first 22 minutes of the 6-user step (exact percentiles, ms; 0 failures)

metric n p50 p90 p99 max
micro-VM atelet worker_checkpoint 1128 239 1,266 5,624 10,499
micro-VM locust SuspendActorAsync 1123 250 1,300 5,600 10,512
micro-VM locust TimeToWake 1119 360 530 1,100 2,177
micro-VM worker upper_join 1114 32 43 274 457
gVisor atelet worker_checkpoint 1516 198 246 439 616
gVisor locust SuspendActorAsync 1512 210 280 470 672
gVisor locust TimeToWake 1507 290 410 650 953

micro-VM worker_checkpoint per 5 minutes, constant 6 users:

slot p50 p90 p99 checkpoints > 1 s
0–5 min 174 234 393 0
5–10 min 225 370 3,692 9
10–15 min 234 550 6,950 20
15–20 min 251 2,878 5,869 47
20–25 min 255 2,159 5,598 39

Compared with the original burn-in: Round 1 = 28520c0d, before #3353; Round 2 = 34cce1e9, after it. Those were full runs including 40-user bursts.

  • Round 1: micro-VM worker_checkpoint p50 / p90 / p99 = 250 / 355 / 2,200 ms.
  • Round 2: 3,100 / 10,900 / 19,900 ms.
  • Patched (6 users only): 239 / 1,266 / 5,624 ms. The median is back to Round 1 levels, but the tail is still well above Round 1, even though Round 1's tail includes bursts.
  • gVisor: suspend p90 / p99 is 280 / 470 ms patched, against Round 1 680 ms / 16 s and Round 2 10.1 s / 30 s.

Where the slow micro-VM checkpoints spend their time (the worker's own "Checkpoint timing breakdown"):

phase fastest 80% p50 slowest 10% p50 slowest 10% p90
snapshot (guest memory) 154 2,573 5,197
teardown 67 71 3,058
rootfs_upper 31 36 326

So the remaining tail is in the guest-memory snapshot phase (plus occasional teardown spikes), not in rootfs_upper. I can't yet tell whether this is a part of the regression these PRs don't cover, or something they introduce (for example, page cache kept by #3485 slowing guest-memory writes over time). To find out, I'll run the rc.1 workers over the same 22+ minute window, and I'm keeping the patched ladder running through the 40-user burst at T+3 h. I'll follow up with both.

@thockin Tim Hockin (thockin) left a comment

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.

I'm afraid this whole subsystem is turning into spaghetti. We're plugging gaps here and there but I don't think we fully comprehend the implications, and it's now complicated enough that I doubt I am following it correctly. Gemini has a lot to say about this, and it's a real challenge to tell if it is right or if not, why.

I will send the naked comments now and do a deeper read of them in a bit

Comment thread cmd/ateworker-microvm/snapshotborrow.go Resolved
Comment thread cmd/ateworker-microvm/snapshotborrow.go Resolved
Comment thread cmd/ateworker-microvm/snapshotborrow.go Resolved
Comment thread internal/ateworker/cgroup/actor.go Resolved
Comment thread internal/tarutil/tarutil.go Resolved
Comment thread internal/tarutil/tarutil_linux_test.go Resolved
@annapendleton

Copy link
Copy Markdown
Collaborator

Second GKE burn-in confirms Grant McCloskey (@MushuEE)'s results, including a node-disk regression to fix before merge

Grant McCloskey (@MushuEE)'s runs above (v1.0.0-rc.1 + #3485 + #3476) and an independent run on our side (main @ 34cce1e + this PR's 4 commits; #3476 doesn't apply on that base) agree. Setup for ours: GKE 1.37, 5 × c3-standard-8, microVM, 4 unsized benchmark workers (no pod memory limit), agentsession load with 1Gi actors, 30-minute high-churn ladder.

Latency medians are fixed 👍 upper_join p50 ~300–840 ms → ~20–30 ms, and microVM worker_checkpoint p50 ~0.7–3 s → ~200–250 ms. Grant McCloskey (@MushuEE) also saw the gVisor suspend tail fixed.

Regression: node disk fills on microVM nodes.

  • Grant McCloskey (@MushuEE) saw it at higher load and over a longer run: one node went 22 → 93.5 of 94 GiB in ~10 min. That caused DiskPressure, one ephemeral-storage eviction and container-runtime errors. Then disk and page cache were freed together. rc.1 alone stayed ≤ 12.9 GiB under the same load, and gVisor nodes stayed flat.
  • Our run is still in progress, so we don't have peak numbers yet. So far, nodes with a worker show the same pattern in a milder form: disk jumps by several GiB within a minute and then frees, with 2–4 GiB of dirty pages per node. The node without a worker stays flat.
  • The microVM checkpoint tail grows with load: worker_checkpoint p90 8.3 s / p99 17 s at 19 users. That matches Grant McCloskey (@MushuEE)'s correction above.

Cause (confirmed on a node in our run): /var had 21 GiB used, while everything visible under /var/lib/ate was 2 GiB. ateworker-microvm held ~54 GiB (apparent size) of deleted memory-ranges files open (…/local-checkpoint/<uuid>/memory-ranges (deleted), …/checkpoint-state/memory-ranges (deleted)).

  • snapshotcache.StartWriteback opens every file up front and closes each only after its own fdatasync.
  • atelet uploads and deletes the checkpoint in the meantime.
  • This PR lifts memory.high in pods without a limit (and adds a writeback per tar in archiveDir). So each sync has much more dirty data to flush, and deleted checkpoints stay pinned for minutes.
  • The writeback call itself dates from Suspending actors no longer OOM-kills tight worker pods #3353. With the cap in place its syncs were short, which fits rc.1 looking clean.

Suggestions:

  1. In StartWriteback (and Avoid synchronous fdatasync in snapshotcache writeback and eviction #3477's polling variant), open, sync and close one file at a time. If fstat shows Nlink == 0, close immediately and return the loan, since a deleted file's dirty pages don't need writing.
  2. Cap what a pod without a memory limit can lend, so dirty pages stay bounded per node.

Happy to re-run either change on the same setups.

Drafted and posted by an AI assistant on behalf of Anna Pendleton (@annapendleton).

@MushuEE

Copy link
Copy Markdown
Collaborator

Candidate fix under test: StartWriteback pinning deleted snapshot files

What we saw. In the burn-in with v1.0.0-rc.1 + #3485 + #3476 (unsized worker pods, micro-VM), node disk usage on some worker nodes climbed by tens of GiB with no visible file accounting for it. On one node it went from 22 to 93.5 of 94 GiB in ~10 minutes while /var/lib/ate stayed at 3–5 GiB, then disk and page cache were freed together. Another node evicted its worker for low ephemeral storage. rc.1 and v0.6.0 workers under the same load stayed flat (~10–13 GiB).

Suspected mechanism (not yet confirmed). snapshotcache.StartWriteback opens every file up front and syncs them one after another, closing each only after its fdatasync. This PR adds a call per snapshot tar (archiveDir, always taken in unsized pods). A file queued behind a slow sync stays open after atelet uploads and deletes it, so its blocks stay allocated. And fdatasync still forces out pages the kernel would otherwise drop. #3477 changes the same function (it closes unlinked files while polling), so this may overlap with it.

What we're testing. The patch below, on top of rc.1 + #3485 + #3476:

  • open each file only when its turn comes;
  • skip it if it has been unlinked or its path names another file;
  • cap concurrent fdatasync at 2 per process.

done still runs once every file is on disk or gone. We'll report whether node disk stays flat and whether the micro-VM checkpoint tail moves closer to v0.6.0's (micro-VM worker_checkpoint p50/p90/p99 = 225/253/366 ms on v0.6.0, vs 239/1,266/5,624 ms with #3485 + #3476 over the same 22-minute window).

Diff under test ( 2 files changed, 235 insertions(+), 10 deletions(-))
diff --git a/internal/snapshotcache/snapshotcache.go b/internal/snapshotcache/snapshotcache.go
index c3812a96..7302ad07 100644
--- a/internal/snapshotcache/snapshotcache.go
+++ b/internal/snapshotcache/snapshotcache.go
@@ -46,6 +46,8 @@ var (
 	// tmpfs, overlayfs) or kernel version.
 	cachestatFn = unix.Cachestat
 	fadviseFn   = unix.Fadvise
+	// fdatasyncFn is a seam for tests of StartWriteback.
+	fdatasyncFn = unix.Fdatasync
 
 	// RetryDelays are the backoff intervals between post-restore eviction
 	// passes when dirty pages are waiting for background kernel writeback.
@@ -111,25 +113,39 @@ func DropFiles(files []File) bool {
 	return dropped
 }
 
+// writebackSlots bounds how many StartWriteback fdatasync calls run at once in
+// this process. Concurrent fdatasync calls contend on the filesystem journal,
+// so each takes longer, and a file pinned by a slow sync can't be freed.
+var writebackSlots = make(chan struct{}, 2)
+
+// pendingWriteback is a file StartWriteback will sync, pinned to the inode the
+// path named when the write finished.
+type pendingWriteback struct {
+	path     string
+	dev, ino uint64
+}
+
 // StartWriteback writes each path's dirty pages to disk in the background and
 // then calls done, if set. Writing can block on a busy disk, and callers don't
 // wait.
+//
+// A file is opened only when its turn comes, and skipped if by then it has been
+// unlinked or its path names another file: the kernel drops a deleted file's
+// dirty pages without writing them, so syncing it would only pin its blocks and
+// slow the other writes. Either way its pages no longer count against the pod,
+// so done still means every file is on disk or gone.
 func StartWriteback(paths []string, done func()) error {
-	var files []*os.File
+	pending := make([]pendingWriteback, 0, len(paths))
 	for _, p := range paths {
-		f, err := os.Open(p)
-		if err != nil {
-			for _, f := range files {
-				_ = f.Close()
-			}
+		var st unix.Stat_t
+		if err := unix.Stat(p, &st); err != nil {
 			return err
 		}
-		files = append(files, f)
+		pending = append(pending, pendingWriteback{path: p, dev: uint64(st.Dev), ino: st.Ino})
 	}
 	go func() {
-		for _, f := range files {
-			_ = unix.Fdatasync(int(f.Fd()))
-			_ = f.Close()
+		for _, w := range pending {
+			writeback(w)
 		}
 		if done != nil {
 			done()
@@ -138,6 +154,23 @@ func StartWriteback(paths []string, done func()) error {
 	return nil
 }
 
+// writeback syncs w if the path still names the same, linked file.
+func writeback(w pendingWriteback) {
+	writebackSlots <- struct{}{}
+	defer func() { <-writebackSlots }()
+	f, err := os.Open(w.path)
+	if err != nil {
+		return // removed: nothing left to write back
+	}
+	defer f.Close()
+	var st unix.Stat_t
+	if err := unix.Fstat(int(f.Fd()), &st); err != nil || st.Nlink == 0 ||
+		uint64(st.Dev) != w.dev || st.Ino != w.ino {
+		return
+	}
+	_ = fdatasyncFn(int(f.Fd()))
+}
+
 // SyncAndEvict waits for targets to reach disk and then evicts them, so pages
 // still dirty from a recent snapshot get dropped too.
 func SyncAndEvict(targets []File) {
diff --git a/internal/snapshotcache/writeback_test.go b/internal/snapshotcache/writeback_test.go
new file mode 100644
index 00000000..234c706a
--- /dev/null
+++ b/internal/snapshotcache/writeback_test.go
@@ -0,0 +1,192 @@
+//go:build linux
+
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package snapshotcache
+
+import (
+	"os"
+	"path/filepath"
+	"strconv"
+	"sync"
+	"testing"
+	"time"
+)
+
+// syncRecorder replaces fdatasyncFn and records which files were synced.
+type syncRecorder struct {
+	mu    sync.Mutex
+	paths []string
+}
+
+func (r *syncRecorder) install(t *testing.T) {
+	t.Helper()
+	prev := fdatasyncFn
+	fdatasyncFn = func(fd int) error {
+		p, err := os.Readlink("/proc/self/fd/" + strconv.Itoa(fd))
+		if err != nil {
+			p = "?"
+		}
+		r.mu.Lock()
+		r.paths = append(r.paths, p)
+		r.mu.Unlock()
+		return nil
+	}
+	t.Cleanup(func() { fdatasyncFn = prev })
+}
+
+func (r *syncRecorder) synced() []string {
+	r.mu.Lock()
+	defer r.mu.Unlock()
+	return append([]string(nil), r.paths...)
+}
+
+// holdSlots occupies every writeback slot until the returned func runs, so a
+// StartWriteback started meanwhile waits before opening anything.
+func holdSlots(t *testing.T) func() {
+	t.Helper()
+	for range cap(writebackSlots) {
+		writebackSlots <- struct{}{}
+	}
+	var once sync.Once
+	release := func() {
+		once.Do(func() {
+			for range cap(writebackSlots) {
+				<-writebackSlots
+			}
+		})
+	}
+	t.Cleanup(release)
+	return release
+}
+
+func writeFile(t *testing.T, path, data string) {
+	t.Helper()
+	if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
+		t.Fatal(err)
+	}
+}
+
+func waitDone(t *testing.T, done <-chan struct{}) {
+	t.Helper()
+	select {
+	case <-done:
+	case <-time.After(10 * time.Second):
+		t.Fatal("done was not called")
+	}
+}
+
+func TestStartWritebackSyncsLinkedFile(t *testing.T) {
+	var rec syncRecorder
+	rec.install(t)
+	p := filepath.Join(t.TempDir(), "memory-ranges")
+	writeFile(t, p, "pages")
+
+	done := make(chan struct{})
+	if err := StartWriteback([]string{p}, func() { close(done) }); err != nil {
+		t.Fatal(err)
+	}
+	waitDone(t, done)
+	if got := rec.synced(); len(got) != 1 || got[0] != p {
+		t.Errorf("synced %v, want [%s]", got, p)
+	}
+}
+
+// A file unlinked while its writeback is queued is never synced (its dirty
+// pages are dropped, not written), and done still runs.
+func TestStartWritebackSkipsFileUnlinkedWhileQueued(t *testing.T) {
+	var rec syncRecorder
+	rec.install(t)
+	dir := t.TempDir()
+	gone, kept := filepath.Join(dir, "rootfs-upper.tar"), filepath.Join(dir, "state.json")
+	writeFile(t, gone, "tar")
+	writeFile(t, kept, "state")
+
+	release := holdSlots(t)
+	done := make(chan struct{})
+	if err := StartWriteback([]string{gone, kept}, func() { close(done) }); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.Remove(gone); err != nil {
+		t.Fatal(err)
+	}
+	release()
+	waitDone(t, done)
+	if got := rec.synced(); len(got) != 1 || got[0] != kept {
+		t.Errorf("synced %v, want only [%s]", got, kept)
+	}
+}
+
+// A path that names a different file by its turn is skipped: the file that was
+// written is gone, and the new one isn't StartWriteback's to sync.
+func TestStartWritebackSkipsReplacedFile(t *testing.T) {
+	var rec syncRecorder
+	rec.install(t)
+	p := filepath.Join(t.TempDir(), "memory-ranges")
+	writeFile(t, p, "old")
+
+	release := holdSlots(t)
+	done := make(chan struct{})
+	if err := StartWriteback([]string{p}, func() { close(done) }); err != nil {
+		t.Fatal(err)
+	}
+	if err := os.Remove(p); err != nil {
+		t.Fatal(err)
+	}
+	writeFile(t, p, "new")
+	release()
+	waitDone(t, done)
+	if got := rec.synced(); len(got) != 0 {
+		t.Errorf("synced %v, want nothing", got)
+	}
+}
+
+// Queued writebacks hold no open file: an open descriptor on a deleted file
+// pins its blocks, which is how unsized micro-VM workers filled node disks.
+func TestStartWritebackOpensNothingWhileQueued(t *testing.T) {
+	var rec syncRecorder
+	rec.install(t)
+	p := filepath.Join(t.TempDir(), "memory-ranges")
+	writeFile(t, p, "pages")
+
+	release := holdSlots(t)
+	done := make(chan struct{})
+	if err := StartWriteback([]string{p}, func() { close(done) }); err != nil {
+		t.Fatal(err)
+	}
+	time.Sleep(50 * time.Millisecond)
+	ents, err := os.ReadDir("/proc/self/fd")
+	if err != nil {
+		t.Fatal(err)
+	}
+	for _, e := range ents {
+		if target, err := os.Readlink(filepath.Join("/proc/self/fd", e.Name())); err == nil && target == p {
+			t.Errorf("%s is open while its writeback is queued", p)
+		}
+	}
+	release()
+	waitDone(t, done)
+}
+
+func TestStartWritebackMissingPathFailsUpFront(t *testing.T) {
+	called := false
+	err := StartWriteback([]string{filepath.Join(t.TempDir(), "missing")}, func() { called = true })
+	if err == nil {
+		t.Fatal("StartWriteback succeeded for a missing path")
+	}
+	if called {
+		t.Error("done was called for a writeback that never started")
+	}
+}

@BenTheElder

Copy link
Copy Markdown
Collaborator Author

Anna Pendleton (@annapendleton) Thanks, great find, that's a big miss.

Pulled #3477's writeback rework in (Chenyi Wang (@chw120)): it closes each file as soon as it's clean or unlinked instead of holding all of them through fdatasync.

On a 15 round soak of 3 filled actors in an unsized pool, /var stayed flat and deleted files held open went to 0 within 30s. Could you rerun your burn-in on the new head?

Tim Hockin (@thockin) fixed the should-fix items in separate commits. Agreed this needs a holistic redesign after 1.0.0, these are stopgaps for the regression, and frankly the whole PR is less curated than I'd generally aim for given the time constraints.

I also had an epoll cleanup / optimization for all this polling logic I'm ~happy with prior to this PR, but I'm keeping it out of this PR to focus on the regressions.

Similarly #3431 is something we should take a hard look at, but it's not a regression and can wait.

@MushuEE

Copy link
Copy Markdown
Collaborator

Results: #3485 + #3476 + the StartWriteback fix (micro-VM, first 22 minutes)

The fix is the patch in my previous comment (#3485 (comment)). It changes only snapshotcache.StartWriteback:

  • open each file only when its turn comes;
  • skip it if unlinked or replaced;
  • at most 2 concurrent fdatasync per process.

Same setup as the other builds:

  • v1.0.0-rc.1 control plane (identical image digests);
  • GKE 1.37, 5 × c3-standard-8, unsized worker pods, 1Gi actors;
  • agentsession ladder, first 22 minutes of the 6-user step;
  • the cluster was cleaned and its nodes replaced before this run.

Only the worker image differs. v0.6.0 is the whole v0.6.0 stack, pre-#3353; its benchmark client needed #3089 to reach the router.

micro-VM, p50 / p90 / p99 (ms)

v0.6.0 rc.1 #3485 + #3476 + StartWriteback fix
atelet worker_checkpoint 225 / 253 / 366 1,253 / 4,158 / 8,969 239 / 1,266 / 5,624 241 / 364 / 2,061
worker snapshot phase 151 / 169 / 188 159 / 3,202 / 8,898 158 / 446 / 5,197 160 / 192 / 349
worker rootfs_upper 31 / 40 / 51 775 / 2,550 / 4,032 32 / 44 / 326 32 / 41 / 103
worker teardown 67 / 80 / 178 68 / 153 / 1,824 68 / 133 / 3,058 70 / 146 / 1,891
restore upper_join 28 / 35 / 46 426 / 1,283 / 2,879 32 / 43 / 274 31 / 41 / 119
worker_checkpoint p90 per 5 min 223, 248, 253, 262 3,207, 4,386, 3,833, 4,818 333, 445, 1,771, 2,697 307, 346, 390, 383
checkpoints > 1 s 0 / 1,453 571 / 1,008 120 / 1,128 34 / 1,313
locust TimeToWake 350 / 380 / 480 760 / 1,600 / 3,300 360 / 530 / 1,100 360 / 440 / 660
locust SuspendActorAsync 260 / 310 / 450 1,300 / 4,100 / 9,100 250 / 1,300 / 5,600 260 / 430 / 3,500

0 request failures in all four.

What changed

  • The guest-memory snapshot tail is nearly back to v0.6.0 (p99 349 vs 5,197 ms).
  • The checkpoint p90 no longer grows over time at constant load.
  • The remaining tail is mostly teardown (p99 ~1.9 s vs 178 ms on v0.6.0).

Disk / page cache

There's a second path the fix doesn't cover. At one point a single worker held 7 deleted memory-ranges files (~5 GiB of real disk, released shortly after). StartWriteback now holds at most 2, so these must come from the post-restore snapshotcache.SyncAndEvict. It runs once per restore in its own goroutine and does a blocking fdatasync on each file, with no concurrency limit and even when the file has already been unlinked. #3477 changes this path too. The equivalent change here would be to skip unlinked or replaced targets (t.Info is already pinned) and share the same slots.

Caveats

@BenTheElder

Copy link
Copy Markdown
Collaborator Author

Thanks for the results

There's a second path the fix doesn't cover. At one point a single worker held 7 deleted memory-ranges files (~5 GiB of real disk, released shortly after). StartWriteback now holds at most 2, so these must come from the post-restore snapshotcache.SyncAndEvict. It runs once per restore in its own goroutine and does a blocking fdatasync on each file, with no concurrency limit and even when the file has already been unlinked. #3477 changes this path too. The equivalent change here would be to skip unlinked or replaced targets (t.Info is already pinned) and share the same slots.

That should be in HEAD already, unless I'm missing something, no fdatasync left anywhere (133dcf5 + #3476)

@MushuEE

Copy link
Copy Markdown
Collaborator

Burn-in update: #3485 @ 133dcf52 (includes #3477) + #3476 on rc.1, first 90 minutes

gVisor looks healthy: worker checkpoint p99 is 314 ms and node disk is flat.

Micro-VM still leaks disk. One node climbed to 79 GiB, and the kubelet evicted its worker about an hour in. The worker was holding 15–30 open handles to deleted 1 GiB checkpoint-state/memory-ranges files.

Micro-VM median latency is back to v0.6.0 levels (239 ms), but the tail is worse than the earlier #3485 run: p90 2,863 ms, p99 6,133 ms, with nearly all of it in snapshot.

The new teardown timing shows the teardown tail is almost all rootfs upper removal (p99 2.2 s), which fits disk contention. The full ladder follows tonight.

Micro-VM latency, first 22 minutes (6 users), worker checkpoint ms
build total p50 / p90 / p99 >1 s snapshot p99 teardown p99
v0.6.0 225 / 253 / 366 0% 188 178
rc.1 1,253 / 4,158 / 8,969 57% 8,898 1,824
earlier #3485 + #3476 239 / 1,266 / 5,624 11% 5,197 3,058
this build 239 / 2,863 / 6,133 19% 5,951 2,303

Locust: Suspend 250 / 3,000 / 6,100 ms, Resume 360 / 650 / 1,500 ms, 0 failures.
gVisor worker checkpoint: 204 / 236 / 314 ms, 0% over 1 s.

Disk leak evidence
  • On the affected node, usage went from 9.6 to 48 GiB in the first 25 minutes, fell back to 18, then climbed to 79 GiB over 15 minutes. The worker was evicted for ephemeral storage at 17:33 PDT.
  • The other micro-VM nodes peaked at 10–22 GiB. gVisor nodes stayed at 9–13 GiB.
  • A privileged sampler lists open handles to deleted files every 30 s. On the affected node, ateworker-microvm held 15–30 handles to deleted snapshots/<uid>/checkpoint-state/memory-ranges files, all from the node's 3 actors.
  • Live snapshot files on that node totaled about 3.5 GiB.
  • Open question: the held-open deleted files add up to about 25 GiB, but the node read 79 GiB.
Teardown breakdown, p99 ms

rootfs_upper_remove 2,249 · sandbox_cleanup 132 · ch_stop 109 · bundle_unmount 18 · agent_close / vfsd_stop about 0.

teardownActor is the same in v0.6.0 and rc.1, so this looks like disk contention rather than a code change.

Setup and build

@BenTheElder

Copy link
Copy Markdown
Collaborator Author

I can't repro the leak so far, fc9ece2 may be the fix though. Pushed some fixes based on Dmitry Berkovich (@dberkov) AI scans + more adversarial review locally.

@BenTheElder

Benjamin Elder (BenTheElder) commented Oct 31, 2026 •

Copy link
Copy Markdown
Collaborator Author

1183627 fixes the leak and the checkpoint tail.

The writeback goroutine was blocking in sync_file_range while holding deleted snapshot files, see https://github.com/torvalds/linux/blob/v6.12/fs/sync.c#L315-L317.

It now starts write-out once, 64MiB at a time, and closes a file as soon as it's unlinked.

A/B on 4 identical clusters matching the burn-in setup from Grant McCloskey (@MushuEE) (5x c3-standard-8, 4 unsized workers, 1Gi actors, agentsession at 6 users for 45m, started together), 32432e1 vs 1183627:

  • microVM checkpoint p50/p90/p99: 247/3052/6551ms -> 246/312/1356ms, 19% -> 1% over 1s
  • microVM node disk peak: 50.8GiB -> 17.7GiB, deleted files held with no links: 19.0GiB -> 2.0GiB
  • gVisor checkpoint p50/p90/p99: 222/2570/4471ms -> 213/259/2052ms, 22% -> 2% over 1s
  • gVisor deleted files held with no links: 8.0GiB -> 1.5GiB
  • no failures, evictions or restarts

The base matches the 9f90899 burn-in on both runtimes, including the slow busiest node, and with the fix the busiest node looks like the others.

@BenTheElder

Copy link
Copy Markdown
Collaborator Author

Second run on the same clusters and builds. The base didn't fall behind this time, load spread more evenly across the workers, so checkpoint latency came out about the same on both. The base still held ~6x more deleted micro-VM data though.

  • microVM over 1s: 1% on both, deleted files held with no links: 14.2GiB -> 2.3GiB, node disk peak 19.5GiB -> 16.9GiB
  • gVisor over 1s: 0% on both, deleted files held with no links: 1.1GiB -> 0.4GiB
  • 3 failures on the base, all "another operation is in progress for this actor" from one session collision, none on the fix

So the slowdown needs one node to fall behind. The base did that in one of two runs, the fix in neither.

Actor placement is uneven in every run, often one worker gets no actors at all. That's separate from this PR, I'll file an issue after some further debugging.

I also added a cleanup for the global test seam kludge introduced by this PR.
We inject a local private interface the way I'd write it by hand.
It has no functional changes so I'm not rushing to burn-in test that one.

I resisted the urge to do this for nearby code we didn't touch, but we really need a cleanup pass over all of this.

@BenTheElder

Copy link
Copy Markdown
Collaborator Author

Squashed into 6 commits and rebased on main for easier review, one change per commit.

Same code as d6d3b1c earlier with the burn-in results, the only diff in the PR's files is the comments now stating pre- and post-conditions.

git diff d6d3b1ca7 68a88d63a -- $(git diff --name-only 0ddbf78d5 68a88d63a)

Comment thread internal/snapshotcache/snapshotcache.go Resolved
// poll instead of waiting on write-out, so a file unlinked meanwhile is
// closed and its dirty pages dropped instead of written back
for i := 0; pollWritebackFiles(pc, files, i == 1); i++ {
time.Sleep(delays[min(i, len(delays)-1)])

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.

🤖 nit 🟢 – Nothing bounds this loop. On tmpfs pages stay dirty forever, so the goroutine polls every 3 s indefinitely and the loan is never returned. Once loans exceed what is unbooked, Wait blocks placement. The old fdatasync returned at once there. A cap on passes, after which the loan is returned and a warning logged, would keep that failure contained.

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.

tmpfs pages never get the dirty tag cachestat counts, shmem uses noop_dirty_folio (https://github.com/torvalds/linux/blob/v6.12/mm/shmem.c#L4809), so done runs right away there.

A file whose pages can never be written (e.g. EIO on a broken filesystem) does poll forever though.

Capping it means giving the loan back while the pages are still dirty, I think that's probably a follow-up with a warning log, the node is in trouble at that point anyway.

Again, will fold in if the other comments are blocking.

Comment thread internal/ateworker/cgroup/actor.go Resolved
}
l.mu.Lock()
defer l.mu.Unlock()
l.bytes -= n

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.

nit: l.bytes = max(0, l.bytes-n) to avoid negative number

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.

I'd rather not, Return only gets what Take handed out, so going negative would be a bug and a clamp would hide it.

@BenTheElder

Copy link
Copy Markdown
Collaborator Author

Given we need N=2 reviews anyhow and it is late, we might as well stash the branch as-is, fix those, and qualify overnight in this case. nobody else has reviewed the current code.

If it qualifies, I will push the update. Otherwise I will leave it as-is, because I know this code is thoroughly vetted to work and none of these are blocking in my opinion but some form of the fix is needed for GA.

@thockin

Copy link
Copy Markdown
Collaborator

Realistically, this code is so subtle that nobody can read it and fully comprehend. The proof is in the testing.

Dima previously approved, so I will enqueue this,

@thockin
Tim Hockin (thockin) added this pull request to the merge queue Nov 3, 2026
Merged via the queue into agent-substrate:main with commit a6442f9 Nov 3, 2026
5 checks passed
@BenTheElder
Benjamin Elder (BenTheElder) deleted the tar-write-behind-keep-cache branch November 3, 2026 02:11
Eitan Yarmush (EItanya) pushed a commit to kagent-dev/substrate that referenced this pull request Nov 3, 2026
…upper back from disk (agent-substrate#3485)

Why: In the microVM burn-in (GKE downstream testing), wake p50 went from
~350ms to ~1.1s (!), almost all of it in upper_join. Reported to me by
@annapendleton though I'm not sure who originally found it.

agent-substrate#3353 started writing out snapshot tars as they're written, so their
dirty pages can't run a tight worker pod out of memory. It also dropped
the written pages from the page cache, so a resume on the same node read
the rootfs upper tar back from disk ... which is slow. It also made
every suspend write the tars at disk speed.

Fixes by commit order:
1. Pull summing memory.stat keys out of memoryUsed, no behavior change.
2. Clean pages are reclaimable, so dropping them is irrelevant for OOM
safety.
- The tars now stay cached. Write-out waits on the previous chunk
instead of the one just written, so dirty pages stay at about two chunks
without stalling on each one.
4. Start snapshot writeback after the OnDemand delta merge, which
replaces memory-ranges, so it tracks the merged file instead of the
unlinked delta.
5. Write snapshot files back in the background instead of with a
blocking fdatasync.
- Starting write-out can block for a long time on a busy disk
(https://github.com/torvalds/linux/blob/v6.12/fs/sync.c#L315-L317), and
holding deleted snapshot files open meanwhile leaked disk under load, so
it starts write-out once, 64MiB at a time, and closes a file as soon as
it's unlinked, which drops its dirty pages.
7. What snapshot writes can borrow leaves clean page cache out, so
cached tars don't make a pod look "full".
- A worker pod without a memory limit (e.g. the deploy_locust.sh
default) has nothing to protect, so it lends all of it instead of
capping every suspend as if the pod were full.
9. When the pod can lend memory for the whole archive, the tar skips the
write-behind and goes to the page cache at full speed, and the loan
comes back once it's on disk. Otherwise the write-behind still bounds
its dirty pages.

That said, with respect to workers without limits: We should probably
consider recommending memory limits on worker pods. I will think about
it overnight and propose docs updates tomorrow.

Tested with locust burn-in tests (see comments in the PR).

AI use: AI-assisted, I reviewed and tested it.

- [x] Tests pass
- [x] Docs updated

---------

Signed-off-by: Benjamin Elder <[email protected]>
(cherry picked from commit a6442f9)
Signed-off-by: Eitan Yarmush <[email protected]>
Dmitry Berkovich (dberkov) pushed a commit that referenced this pull request Nov 3, 2026
Bring `release-1.0` up to `main` at
5a5b192 with the 19 changes merged
since the previous batch in #3536. The final tree matches that commit
exactly.

**Please use Rebase and merge to preserve the individual commits and
author attribution.** All 19 applied cleanly in upstream order. Original
authors, author dates, co-author and sign-off trailers, and `-x` source
references are retained.

Upgrade notes:

- #3533: old gVisor MEMORY snapshots, including goldens, no longer
restore. Recreate them after upgrading. MicroVM MEMORY snapshots still
resume with their old mounts until recreated. `/tmp` is now always
tmpfs; ROOTFS snapshots no longer preserve its contents, and
image-provided files there are hidden. MicroVM `/tmp` counts against
actor memory and is capped at half the guest's memory. gVisor `/dev/shm`
is now a 64 MiB tmpfs. Mount a `durableDir` at either path when
persistence or more space is needed. `/run` now comes from the image
rootfs.
- #3447: remove `--actor-jwt-jwks-file` from custom credential-exchange
sidecar configs. Set `--actor-jwt-issuer-ca-file` only when the issuer
uses a private CA other than the service DNS CA.

- #3450: do not reuse a deleted atespace name. See the updated atespace
name-reuse guidance.

Includes #3505, which overlaps the open backport in #3547. GKE
provisioning and teardown now live in
[substrate-gke](https://github.com/ai-on-gke/substrate-gke) rather than
this repository.

<details>
<summary>Included PRs, in cherry-pick order</summary>

1. #3500: demos/{sandbox,jupyter,multi-template}: fix tutorial
prerequisites, walkthroughs, and counter volume
2. #3498: demos/counter: inline gVisor walkthrough and fix setup
commands
3. #3511: e2e tests: Extend the existing tests to cover tag actor.
4. #2775: Generate API reference documentation and publish it on
Substrate website
5. #3119: Document how people, pods and CI jobs should authenticate to
ate-api
6. #3505: tools: remove setup-gcp and hack/teardown.sh
7. #3147: e2e: resume fidelity after SandboxConfig version edits
8. #3370: ate-setup: reconcile ate-api-server ConfigMaps and roll out on
config changes
9. #1607: hack: reject breaking proto changes across each proto package
10. #3415: docs: update and add security diagrams
11. #3485: Resuming a microVM actor on the same node no longer reads its
rootfs upper back from disk
12. #3447: Verify actor JWTs in the credential exchange through OIDC
discovery
13. #3533: ocispec: mount tmpfs at /dev/shm, and /tmp on both runtimes
14. #3450: docs: Document that atespace names must not be reused
15. #3410: Document using more than one egress credential provider
16. #3486: atelet: register the device plugin on a new socket each time
17. #3553: ate-setup: wait for the demo namespace in delete demo
18. #3560: Fix multiple issues in claude-code-multiplex demo
19. #3517: docs: remove inaccurate early-stage architecture
documentation

</details>

Validation: every intermediate tree matches its upstream source commit,
all 19 authorship records and original attribution trailers are
preserved, and `git diff --check` passes. The original 18 backport
commits are unchanged.

AI use: AI-assisted cherry-picking, attribution checks, and review.

- [ ] Tests pass
- [x] Docs updated

---------

Signed-off-by: Mofi Rahman <[email protected]>
Signed-off-by: Eitan Yarmush <[email protected]>
Signed-off-by: Nayana Bidari <[email protected]>
Signed-off-by: Julian Gutierrez Oschmann <[email protected]>
Signed-off-by: Max Thompson <[email protected]>
Signed-off-by: Steven Shriver <[email protected]>
Signed-off-by: Grant McCloskey <[email protected]>
Signed-off-by: Shrutiya <[email protected]>
Signed-off-by: Haiyan Meng <[email protected]>
Signed-off-by: Greg Castle <[email protected]>
Signed-off-by: Benjamin Elder <[email protected]>
Signed-off-by: Anish Ramasekar <[email protected]>
Signed-off-by: Shruti Nair <[email protected]>
Signed-off-by: Haven Xia <[email protected]>
Co-authored-by: Mofi Rahman <[email protected]>
Co-authored-by: nybidari <[email protected]>
Co-authored-by: Julian Gutierrez Oschmann <[email protected]>
Co-authored-by: Max Thompson <[email protected]>
Co-authored-by: Grant McCloskey <[email protected]>
Co-authored-by: Steven Shriver <[email protected]>
Co-authored-by: shrutiyam-glitch <[email protected]>
Co-authored-by: haiyanmeng <[email protected]>
Co-authored-by: Greg Castle <[email protected]>
Co-authored-by: Benjamin Elder <[email protected]>
Co-authored-by: Anish Ramasekar <[email protected]>
Co-authored-by: Shruti Nair <[email protected]>
Co-authored-by: Haven Xia <[email protected]>
Co-authored-by: Adam Wathieu <[email protected]>
Dmitry Berkovich (dberkov) pushed a commit that referenced this pull request Nov 3, 2026
…upper back from disk (#3485)

Why: In the microVM burn-in (GKE downstream testing), wake p50 went from
~350ms to ~1.1s (!), almost all of it in upper_join. Reported to me by
@annapendleton though I'm not sure who originally found it.

#3353 started writing out snapshot tars as they're written, so their
dirty pages can't run a tight worker pod out of memory. It also dropped
the written pages from the page cache, so a resume on the same node read
the rootfs upper tar back from disk ... which is slow. It also made
every suspend write the tars at disk speed.

Fixes by commit order:
1. Pull summing memory.stat keys out of memoryUsed, no behavior change.
2. Clean pages are reclaimable, so dropping them is irrelevant for OOM
safety.
- The tars now stay cached. Write-out waits on the previous chunk
instead of the one just written, so dirty pages stay at about two chunks
without stalling on each one.
4. Start snapshot writeback after the OnDemand delta merge, which
replaces memory-ranges, so it tracks the merged file instead of the
unlinked delta.
5. Write snapshot files back in the background instead of with a
blocking fdatasync.
- Starting write-out can block for a long time on a busy disk
(https://github.com/torvalds/linux/blob/v6.12/fs/sync.c#L315-L317), and
holding deleted snapshot files open meanwhile leaked disk under load, so
it starts write-out once, 64MiB at a time, and closes a file as soon as
it's unlinked, which drops its dirty pages.
7. What snapshot writes can borrow leaves clean page cache out, so
cached tars don't make a pod look "full".
- A worker pod without a memory limit (e.g. the deploy_locust.sh
default) has nothing to protect, so it lends all of it instead of
capping every suspend as if the pod were full.
9. When the pod can lend memory for the whole archive, the tar skips the
write-behind and goes to the page cache at full speed, and the loan
comes back once it's on disk. Otherwise the write-behind still bounds
its dirty pages.

That said, with respect to workers without limits: We should probably
consider recommending memory limits on worker pods. I will think about
it overnight and propose docs updates tomorrow.

Tested with locust burn-in tests (see comments in the PR).

AI use: AI-assisted, I reviewed and tested it.

- [x] Tests pass
- [x] Docs updated

---------

Signed-off-by: Benjamin Elder <[email protected]>
(cherry picked from commit a6442f9)
Signed-off-by: Eitan Yarmush <[email protected]>
Anna Pendleton (annapendleton) added a commit to annapendleton/substrate that referenced this pull request Nov 4, 2026
After agent-substrate#3485 (a6442f9), every snapshot checkpoint called snapshotcache.StartWriteback unconditionally, even on worker pods without a memory limit.

In workloads that suspend and resume frequently (such as glutton_mem_512mi_1_user_gvisor_pause), atelet deletes the previous local snapshot as soon as the next checkpoint completes (~1.9s later). Without forced writeback, deleting that short-lived snapshot drops its unwritten pages in memory without touching the disk. Calling StartWriteback unconditionally forced all 520 MiB of every checkpoint onto the 175 MB/s disk and held the files open until writeback finished, saturating the disk and stalling gvisor_sentry for ~750ms per suspend (SuspendActorAsync p50 ~1,400ms vs ~410ms before agent-substrate#3353).

Move the StartWriteback call into Ledger.Writeback(paths, n), which returns n immediately when the pod has no memory limit (or n <= 0) and only flushes to disk when a memory-limited pod has borrowed memory to reclaim.
Anna Pendleton (annapendleton) added a commit to annapendleton/substrate that referenced this pull request Nov 4, 2026
In the gVisor pause benchmark (glutton_mem_512mi_1_user_gvisor_pause),
SuspendActorAsync p50 regressed from ~390-430 ms (before agent-substrate#3353) to
~1300-1500 ms (after agent-substrate#3353 / agent-substrate#3485).

Each 512 MiB checkpoint writes ~520 MiB into the kernel page cache on a
175 MB/s persistent disk. Because atelet only deletes the previous
checkpoint after the next checkpoint finishes (~1.8 s later), calling
sync_file_range(SYNC_FILE_RANGE_WRITE) immediately on every checkpoint
causes a 3.0 s disk flush that overlaps the next checkpoint at t = 1.4 s
and stalls gvisor_sentry for ~750-1,000 ms in balance_dirty_pages.

Move post-checkpoint writeback behind Ledger.Writeback(paths, n):
- Without a pod memory limit, return n immediately without starting
  writeback.
- With a pod memory limit, poll paths (every 25ms..500ms) so unlinked
  checkpoints close their FDs and drop dirty pages in RAM promptly, and
  only force write-out via sync_file_range when the pod has less than n
  of unbooked or free memory left.
Anna Pendleton (annapendleton) added a commit to annapendleton/substrate that referenced this pull request Nov 4, 2026
In the gVisor pause benchmark (glutton_mem_512mi_1_user_gvisor_pause),
SuspendActorAsync p50 regressed from ~390-430 ms (before agent-substrate#3353) to
~1300-1500 ms (after agent-substrate#3353 / agent-substrate#3485).

Each 512 MiB checkpoint writes ~520 MiB into the kernel page cache on a
175 MB/s persistent disk. Because atelet only deletes the previous
checkpoint after the next checkpoint finishes (~1.8 s later), calling
sync_file_range(SYNC_FILE_RANGE_WRITE) immediately on every checkpoint
causes a 3.0 s disk flush that overlaps the next checkpoint at t = 1.4 s
and stalls gvisor_sentry for ~750-1,000 ms in balance_dirty_pages.

Move post-checkpoint writeback behind Ledger.Writeback(paths, n) so
write-out is only started when the pod has less than n of unbooked or
free memory left.
Anna Pendleton (annapendleton) added a commit to annapendleton/substrate that referenced this pull request Nov 4, 2026
In the gVisor pause benchmark (glutton_mem_512mi_1_user_gvisor_pause),
SuspendActorAsync p50 regressed from ~390-430 ms (before agent-substrate#3353) to
~1300-1500 ms (after agent-substrate#3353 / agent-substrate#3485).

Each 512 MiB checkpoint writes ~520 MiB into the page cache. atelet only
deletes the previous checkpoint after the next one finishes, so starting
write-out of every checkpoint right away keeps the disk busy flushing
files that are about to be deleted, and the next checkpoint stalls behind
it in balance_dirty_pages.

StartWriteback now takes a flush func, and only starts write-out on a
pass when it returns true. The callers pass Ledger.Tight, which reports
whether the pod has less than the borrowed amount of unbooked or free
memory left. While the pod has room, dirty pages stay cached and are
dropped when the file is unlinked; once it's tight, they're written out
as before so placements waiting on the loan can boot.
Anna Pendleton (annapendleton) added a commit to annapendleton/substrate that referenced this pull request Nov 4, 2026
In the gVisor pause benchmark (glutton_mem_512mi_1_user_gvisor_pause),
SuspendActorAsync p50 regressed from ~390-430 ms (before agent-substrate#3353) to
~1300-1500 ms (after agent-substrate#3353 / agent-substrate#3485).

Each 512 MiB checkpoint writes ~520 MiB into the page cache. atelet only
deletes the previous checkpoint after the next one finishes, so starting
write-out of every checkpoint right away keeps the disk busy flushing
files that are about to be deleted, and the next checkpoint stalls behind
it in balance_dirty_pages.

StartWriteback now takes a flush func, and only starts write-out on a
pass when it returns true. The callers pass Ledger.Tight, which reports
whether the pod has less than the borrowed amount of unbooked or free
memory left. While the pod has room, dirty pages stay cached and are
dropped when the file is unlinked; once it's tight, they're written out
as before so placements waiting on the loan can boot.
Anna Pendleton (annapendleton) added a commit to annapendleton/substrate that referenced this pull request Nov 4, 2026
In the gVisor pause benchmark (glutton_mem_512mi_1_user_gvisor_pause),
SuspendActorAsync p50 regressed from ~390-430 ms (before agent-substrate#3353) to
~1300-1500 ms (after agent-substrate#3353 / agent-substrate#3485).

Each 512 MiB checkpoint writes ~520 MiB into the page cache. atelet only
deletes the previous checkpoint after the next one finishes, so starting
write-out of every checkpoint right away keeps the disk busy flushing
files that are about to be deleted, and the next checkpoint stalls behind
it in balance_dirty_pages.

StartWriteback now takes a flush func, and only starts write-out on a
pass when it returns true. The callers pass Ledger.Tight, which reports
whether the pod has less than the borrowed amount of unbooked or free
memory left. While the pod has room, dirty pages stay cached and are
dropped when the file is unlinked; once it's tight, they're written out
as before so placements waiting on the loan can boot.
Anna Pendleton (annapendleton) added a commit to annapendleton/substrate that referenced this pull request Nov 4, 2026
In the gVisor pause benchmark (glutton_mem_512mi_1_user_gvisor_pause),
SuspendActorAsync p50 regressed from ~390-430 ms (before agent-substrate#3353) to
~1300-1500 ms (after agent-substrate#3353 / agent-substrate#3485).

Each 512 MiB checkpoint writes ~520 MiB into the page cache. atelet only
deletes the previous checkpoint after the next one finishes, so starting
write-out of every checkpoint right away keeps the disk busy flushing
files that are about to be deleted, and the next checkpoint stalls behind
it in balance_dirty_pages.

StartWriteback now takes a flush func, and only starts write-out on a
pass when it returns true. The callers pass Ledger.Tight, which reports
whether the pod has less than the borrowed amount of unbooked or free
memory left. While the pod has room, dirty pages stay cached and are
dropped when the file is unlinked; once it's tight, they're written out
as before so placements waiting on the loan can boot.
Anna Pendleton (annapendleton) added a commit to annapendleton/substrate that referenced this pull request Nov 4, 2026
atelet only deletes the previous checkpoint after the next one finishes, so starting write-out of every checkpoint right away (agent-substrate#3353 / agent-substrate#3485) keeps the disk busy writing ~520 MiB that is about to be unlinked, and the next gVisor checkpoint stalls behind it in balance_dirty_pages.

StartWriteback now takes a flush func and only starts write-out on a pass when it returns true. Callers pass Ledger.Tight, which reports whether the pod has less than the borrowed amount of unbooked or free memory left.

Signed-off-by: Anna Pendleton <[email protected]>
Anna Pendleton (annapendleton) added a commit to annapendleton/substrate that referenced this pull request Nov 4, 2026
atelet only deletes the previous checkpoint after the next one finishes, so starting write-out of every checkpoint right away (agent-substrate#3353 / agent-substrate#3485) keeps the disk busy writing ~520 MiB that is about to be unlinked, and the next gVisor checkpoint stalls behind it in balance_dirty_pages.

StartWriteback now takes a flush func and only starts write-out on a pass when it returns true. Callers pass Ledger.Tight, which reports whether the pod has less than the borrowed amount of unbooked or free memory left.

Signed-off-by: Anna Pendleton <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/microVM kind/bug Something isn't working / bugfixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants