Skip to content

leave page cache out of the memory guard's overshoot check - #3476

Merged
Tim Hockin (thockin) merged 3 commits into
agent-substrate:mainfrom
BenTheElder:memory-guard-overshoot-cache
Oct 30, 2026
Merged

Tim Hockin (thockin) merged 3 commits into
agent-substrate:mainfrom
BenTheElder:memory-guard-overshoot-cache

Conversation

@BenTheElder

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

Copy link
Copy Markdown
Collaborator

Follow-up to #3429, from Anish Ramasekar (@aramase)'s review: #3429 (comment)

When the pod can lend a microVM snapshot write all the memory it needs, the actor's cgroup has no memory.high during the write, so it can end with a lot of dirty page cache. Putting memory.high back reclaims what it can, but gives up on pages still dirty or under writeback:
https://github.com/torvalds/linux/blob/v7.0/mm/memcontrol.c#L4409-L4429

The memory guard resumes as soon as the write returns, before teardown, and its overshoot check counted that page cache. So it could kill the paused VM and report the actor crashed for exceeding its memory limit.

The overshoot check now leaves reclaimable page cache out too, and both checks take what's left from one memory.stat read. The kernel only flushes memory.stat past a threshold of updates, so it can trail memory.current, and subtracting one from the other could count fresh page cache as used:
https://github.com/torvalds/linux/blob/v7.0/mm/memcontrol.c#L561-L564

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

  • Tests pass
  • Docs updated

a snapshot write's dirty pages can stay charged to the actor's cgroup after its memory.high comes back, which could kill the paused VM before teardown

Signed-off-by: Benjamin Elder <[email protected]>
memory.stat is flushed lazily and can trail memory.current, so subtracting its page cache from memory.current could count fresh page cache as used

Signed-off-by: Benjamin Elder <[email protected]>
@MushuEE

Copy link
Copy Markdown
Collaborator

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

Tested this PR together with #3485 on top of v1.0.0-rc.1. This PR's change (leaving page cache out of the memory guard's overshoot check) is in both worker images 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

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.

Comment on lines 74 to 77
if current < limit-limit/16 {
w.samples = w.samples[:0]
return false, current, nil
}

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 🟢 – Both the overshoot and stall checks below now use used (memoryUsed), so current < limit-limit/16 is purely a fast path on the 250ms tick. Please add a short comment noting the invariant this relies on: used is a subset of current, so when memory.current is below limit - limit/16, used is too and we can skip reading memory.stat, memory.pressure, and memory.events.local.

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 almost factored these out but I didn't want to cause conflicts with #3485

could just pull it in. Addressed in follow-up commit with comments for now

Comment thread internal/ateworker/cgroup/memwatch.go Resolved
// microVM guest's, kernel and sock. All from one memory.stat read, which the
// kernel only flushes past a threshold of updates, so it can trail
// memory.current, and mixing the two could count fresh page cache as used.
// https://github.com/torvalds/linux/blob/v7.0/mm/memcontrol.c#L561-L564

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.

Perhaps state the kernel accounting logic here so it is clear why these four keys are not an arbitrary subset: memory.stat partitions charged memory into anon, file, kernel, and sock. file is filesystem page cache PLUS shmem. Excluding filesystem page cache (file) and including shared memory (shmem) is what we want: anon + shmem + kernel + sock.

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

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.

though again, I'd rather structure it, and I'm considering breaking it into a shared util and just merging this into the bigger PR

@annapendleton

Copy link
Copy Markdown
Collaborator

Heads-up: a second GKE burn-in confirms Grant McCloskey (@MushuEE)'s findings here, including a microVM node-disk regression (deleted checkpoint files held open by snapshotcache.StartWriteback). Details and suggested fixes are on #3485: #3485 (comment)

Note: our run was main @ 34cce1e + #3485 only, since this PR's memory-guard change doesn't apply on that base, so it doesn't isolate this PR.

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

…es, drop stale test lines

Signed-off-by: Benjamin Elder <[email protected]>
@thockin
Tim Hockin (thockin) added this pull request to the merge queue Oct 30, 2026
Merged via the queue into agent-substrate:main with commit 6ddb193 Oct 30, 2026
5 checks passed
Dmitry Berkovich (dberkov) pushed a commit that referenced this pull request Oct 31, 2026
…uard's overshoot check (#3513)

Cherry-pick of #3476 onto `release-1.0` (`git cherry-pick -x` of the
squash commit), it applied cleanly with no changes.

The memory guard's overshoot check leaves reclaimable page cache out, so
it no longer kills a paused microVM and reports the actor crashed when a
snapshot write leaves dirty page cache behind. This fixes a bug in
#3429, which is in 1.0.

AI use: AI-assisted. Clean pick.

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

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

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants