Skip to content

Bound compression and upload memory across concurrent snapshots #2553

Open

Description

What happened?

Atelet was OOMKilled five times during concurrent microVM snapshot uploads with a 1 GiB memory limit. Raising the limit to 2 GiB stopped further restarts during validation, but leaves the allocation pressure unresolved.

Two problems compound each other:

  • newParZstd eagerly allocates two 8 MiB chunk buffers per worker. writeSparseZstd requests at least one worker, so even a 36-byte metadata file gets 16 MiB of chunk capacity before encoder state and output allocations.
  • Snapshot uploads start one goroutine per file, and multiple snapshots can upload concurrently. There is no shared memory budget across requests. Current upstream runs this work in NodePlugin.UploadSnapshot; the compression allocation behavior is unchanged.

Observed version: v0.5.0-alpha2, 9c4b1fb. Sandbox runtime: microVM. Source paths above checked against upstream be3aa280; the live OOM reproduction has not been repeated on that commit.

Steps to reproduce

The compression allocations reproduce without Kubernetes or an object store: create 36-byte regular files and call writeContent(io.Discard, file) concurrently from a test in pkg/objectstorage. Run GC before each sample and measure the change in runtime.MemStats.TotalAlloc.

Concurrent files Allocated during compression
1 16.6 MiB
6 99.5 MiB
24 397.8 MiB

These are cumulative allocations, not peak live memory. The allocation profile attributed 94.75% of allocations across the samples to newParZstd.

Expected behavior

Small inputs should use proportionate compression memory. Total in-flight upload memory should be bounded across snapshot requests, accounting for compressor buffers/state and object-store multipart buffers together. A per-snapshot concurrency limit alone would still let simultaneous snapshots multiply memory use.

Validate the fix with concurrent multi-file snapshots, large sparse files, and slow multipart uploads, measuring peak memory and throughput. Waiting uploads must cancel promptly and release capacity after failures. Preserve sparse round trips, reader compatibility, and the manifest's role as the commit marker after all files succeed.

Activity

  1. EItanya commented on Oct 13, 2026

    @EItanya
    CollaboratorAuthor

    Generally I think we should be:

    1. Stress testing memory heavy sections of the code
    2. Adding GOMEMLIMIT based on k8s resource limits by default for a soft-cap and added safety
  2. dberkov commented on Oct 13, 2026

    @dberkov
    Collaborator

    Walter Fender (@cheftako) - could you PTAL on it.

  3. dberkov commented on Oct 13, 2026

    @dberkov
    Collaborator
  4. abhisheksharma2411 commented on Oct 14, 2026

    @abhisheksharma2411

    Could I take the first half of this, the proportionate allocation for small inputs?

    I had a look and your numbers line up. newParZstd fills workers * parZstdQueue buffers of 8 MiB before anything is compressed, and writeSparseZstd always asks for at least one worker, so a 36-byte file really does reserve 16 MiB.

    Filling the free list lazily, and sizing chunks to the input when it's smaller than 8 MiB, should sort that part out without touching the on-disk format or the reader.

    I'd leave the shared budget across concurrent snapshots out of it though - that one seems like its own design discussion rather than something to bolt on. Happy to follow up with it separately if you'd like.

  5. mastersingh24 commented on Oct 15, 2026

    @mastersingh24
    Contributor

    We're seeing the same thing on the v0.5.0 release with gVisor, where this code now runs in atelet's objectstore-plugin sidecar. Ten agents on one worker node idle-suspended within the same minute. Each snapshot uploads about 8 files, so that's roughly 80 concurrent compressed uploads. The sidecar went from about 30 MiB to 1.08 GiB during the burst, then dropped back to 33 MiB once the uploads finished, so it looks like allocation pressure rather than a leak.

    Because the sidecar only requests 128 MiB and sets no memory limit, it didn't get OOM-killed like in your microVM case. Instead the spike landed on the node. Our worker node (c4-highcpu-2, about 2.7 GiB allocatable) went to 88% memory, with memory requests already at 97%, so a bigger burst would have started evicting pods. That's probably worth keeping in mind for the fix: until total upload memory is bounded, the sidecar's request is far below what it actually uses, and with no limit the cost lands on whatever else runs on the node.

    +1 to a global budget across snapshot requests. A per-snapshot limit wouldn't have helped here, since this was many snapshots at once. A synchronized suspend of many idle agents is a common pattern when they share the same idle timeout.

  6. BenTheElder commented on Oct 16, 2026

    @BenTheElder
    Collaborator

    #2708 reviewed and merged.

  7. msau42 commented on Oct 16, 2026

    @msau42
    Collaborator

    As part of Actor Lifecycle v2, we are adding support to asynchronously upload snapshots to object storage. The async upload goes through a workqueue where you can configure the # workers. That can help provide the global budget, although it does not address synchronous uploads: #2571

  8. EItanya commented on Oct 16, 2026

    @EItanya
    CollaboratorAuthor

    #2708 should already handle a basic queue in the meantime, I can re-review #2633 today

  9. roycaihw commented on Oct 17, 2026

    @roycaihw
    Contributor

    Some live throughput data on the tradeoff from #2708, in case it helps size the default.

    Setup: one c3-highmem-192-metal node (v0.5, microVM, GCS backend), snapshot staging on a dedicated Hyperdisk Balanced disk. Three probe actors suspend (synchronous SuspendActor) and resume every 20–30 s while N SWE-bench replay actors run on the same node. p95 over 12 minutes (two reps at 512Mi, one at 16Gi), with the ratio to the probes-only baseline in parentheses:

    SWE-bench actors Suspend p95, 512Mi (default) Suspend p95, 16Gi Peak plugin working set, 512Mi / 16Gi
    0 6.0 s 4.5 s –
    32 70.3 s (11.8×) 9.6 s (2.1×) 0.5 / 1.9 GiB
    64 133.9 s (22.5×) 18.2 s (4.1×) 1.3 / 2.3 GiB

    With GCS, one upload reserves at least 384Mi, so the default admits one upload at a time per node and synchronous suspends queue behind each other: the plugin sent about 50 MB/s at 512Mi and 240 MB/s at 16Gi. Resume and the local snapshot write stayed under 1 s at p95 in every run. At 16Gi the plugin's working set peaked around 2.3 GiB, well under the budget, which matches the README's note that the GCS accounting is conservative.

    Some options:

    • Size the default from the node, e.g. a fraction of allocatable memory or of the container's memory limit when one is set, or at least expose the budget through the install and document a value for large nodes.
    • Reserve less for GCS objects that fit in a single request.

    The async upload workqueue in #2571 would help asynchronous suspends, but synchronous suspends still wait on this budget. Happy to test changes on the same setup.

  10. EItanya commented on Oct 18, 2026

    @EItanya
    CollaboratorAuthor

    Agreed that this absolutely needs further optimization, I just wanted to get rid of the OOM kills and move from there

  11. roycaihw commented on Oct 18, 2026

    @roycaihw
    Contributor

    Follow-up with measurements and a proposed fix: #2847. On a large node, each upload's reservation grows with the budget and GOMAXPROCS, so a 16Gi budget fits only 5–6 uploads at once. Reserving for the path an upload actually takes gets 64Gi's result at 16Gi.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions