Repository navigation
Suspending actors no longer OOM-kills tight worker pods - #3353
Conversation
while CH writes a snapshot the actor's cgroup gets memory.high at what it booked plus what the pod can lend, or no limit when the pod can lend the whole write. a placement that would grow into lent memory waits for writeback. also write out tar archives as we go. Signed-off-by: Benjamin Elder <[email protected]>
the checkpoint cgroup's page cache gets the same limit and lending as microVM snapshot writes, and at most two checkpoints write at once. Signed-off-by: Benjamin Elder <[email protected]>
… writes are capped Signed-off-by: Benjamin Elder <[email protected]>
…ring it Signed-off-by: Benjamin Elder <[email protected]>
ce3fb51 to
aa865cb
Compare
Tim Hockin (thockin)
left a comment
There was a problem hiding this comment.
I'm going to approve because it seems better than the alternative (doing nothing). But I think we need a more holistic approach to this.
Broadly speaking, actors have a lifecycle, and the nodes and edges of that lifecycle need varying amounts of resources. I think we can find a cleaner way to manage resources that feels like like whack-a-mole and is still "safe" in the context of cgroups.
| return io.CopyBuffer(writerOnly{dst}, readerOnly{src}, *bp) | ||
| } | ||
|
|
||
| // writeBehindChunk is how much of an archive may be dirty in the page cache |
There was a problem hiding this comment.
FTR I am somewhat concerned that we don't FULLY understand the consequences of trying to manage this so tightly. I feel like there SHOULD BE more holistic approaches to this, rahter than piecemeal.
There was a problem hiding this comment.
Yeah, but we're already doing this for snapshot/resume. I think we do need bursting for performance and limiting for safety but we have to get the right balance between these. They were added incrementally late in the cycle. I think we can carefully evolve these without a totally breaking change (in particular it may be slightly breaking in that the same actor : worker pod mix may not pack as densely)
| } | ||
|
|
||
| // An archive bigger than writeBehindChunk is written out a chunk at a time. | ||
| func TestRoundTripLargeFile(t *testing.T) { |
There was a problem hiding this comment.
Not for this PR, but we really need some focused benchmarks to measure the impact of these sorts of changes? We need to identify and isolate variables and see how manipulating them changes the throughput and the footprint.
E.g. convince me that using O_DIRECT is a worse answer.
There was a problem hiding this comment.
I was talking to our scalability / density team about this PR stack a bit, ACK to follow-up on that.
| // writeBehindChunk is how much of an archive may be dirty in the page cache | ||
| // before we write it out and drop it. Dirty pages can't be reclaimed, and | ||
| // piling up a whole archive of them can run a worker pod out of memory. | ||
| const writeBehindChunk = 8 << 20 |
There was a problem hiding this comment.
This feels like it should be a field in type writeBehind, even if we don't plumb it all the way down from the caller yet
| if l.PodMemory != nil { | ||
| return l.PodMemory() | ||
| } | ||
| return cgroup.PodMemory() |
There was a problem hiding this comment.
Again, for later -- this deeply rooted knowledge of Kubernetes may turn out to be a liability. Kube stuff evolves. We should (followup) to make this a bit more abstract to figure out more generally what resources are available and manage those without knowing "it's a Pod"
There was a problem hiding this comment.
This is internal to ateworker, do we expect ateworker to run outside of pods?
There was a problem hiding this comment.
probably not, but why couple if we don't need to? E.g. would it be WRONG to run an ateworker on a non-kube VM? I don't think it would be wrong (even if it lacked some nice things)
|
|
||
| mu sync.Mutex | ||
| bytes int64 | ||
| // returned is closed and replaced whenever bytes drops |
There was a problem hiding this comment.
This feels like a very odd contract - why not just send a message (e.g. the delta)?
| type Ledger struct { | ||
| // Unbooked is the worker's reported memory capacity, for a pod memory | ||
| // limit, less what its hosted actors booked. | ||
| Unbooked func(podLimit int64) int64 |
There was a problem hiding this comment.
I've seen agents choosing this pattern of using function pointers rather than defining an interface. I wonder why? I would have written a 2-method interface and injected it here.
There was a problem hiding this comment.
I think what we should do is add an agents.md rule for this, we'll be playing whack-a-mole otherwise. I agree on the interface, but wasn't super worried about this WRT correctness / relatively readable.
|
We need to find a way to get some pre-merge scale testing on critical PRs like this. Max Smythe (@maxsmythe) Aditya Shantanu (@aditya-shantanu) |
|
Agreed. I did some testing but it's all adhoc inbetween everything else. I think these values will have to be tuned in follow-ups. I would still really like something easier to use than our benchmark harness that can be used on PRs with a personal developer cluster reasonably, so at minimum when I'm submitting or reviewing these I can run a stable test to A/B across PRs. We've been evolving the benchmarks rapidly still and the tooling we have is not easy to just point at an existing small test cluster. cc Walter Fender (@cheftako) we've talked about this for the uVM team as well. |
… down its worker (agent-substrate#3429) A microVM actor's cloud-hypervisor and virtiofsd run in the worker pod with no memory limit of their own. If they grow past what the actor booked, they take the pod to its limit, and the OOM kill takes out the worker and every actor on it. agent-substrate#3106 fixed this for gVisor, this PR fixes it for microVM. Part of agent-substrate#3092. - each actor's cgroup gets `memory.high` at what it books (its memory limit plus the runtime overhead from agent-substrate#3104). The guest can't use more than its memory limit, so anything past that is cloud-hypervisor or virtiofsd. - the worker kills a cgroup stuck at its `memory.high` with `cgroup.kill`, using the same rule as agent-substrate#3106, and reports the actor crashed with "killed for exceeding its memory limit". Other actors keep running. - the watch pauses during snapshot writes. When the pod can lend the whole write, the cgroup's `memory.high` is lifted for the write and restored after, same as agent-substrate#3353. - without PSI the worker sets no limit and warns at startup, like gVisor - the gVisor watch from agent-substrate#3106 moves into a shared `cgroup.MemoryGuard` that both workers use, gVisor's behavior is unchanged Tested on GKE with a fresh install: - a 1GiB actor filled to 925MiB peaked at 1007MiB against a `memory.high` of 1036MiB, and survived 3/3 suspend/resume cycles with no kills - a 400MiB process added to one 512Mi actor's cgroup got that actor killed and reported crashed. Its neighbour kept answering and the worker didn't restart. AI use: AI-assisted, I reviewed and tested it. - [x] Tests pass - [x] Docs updated --------- Signed-off-by: Benjamin Elder <[email protected]>
…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]>
…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]>
…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]>
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.
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.
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.
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.
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.
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.
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.
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]>
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]>
A suspend writes about as much page cache as the actor has in memory, and it's charged to the worker pod. In a pod sized close to its actors that runs the pod out of memory, and the kernel OOM kills the worker along with every actor on it.
Part of #3092.
memory.high, so the page cache can only grow into what the actor booked and isn't using, plus what the pod has spare to lend. If the pod can lend all of it there's no limit, so pods with room still suspend at full speed. Tight pods slow down to disk speed instead.Tested on GKE with a fresh install:
AI use: AI-assisted, I reviewed and tested it.