Repository navigation
Description
What happened?
Follow-up to #2553 / #2708. On a large node, snapshot uploads at high density spend most of their time waiting for the shared upload budget, not uploading. Raising --upload-memory-budget helps less than it should, because each upload's reservation grows with the budget and with GOMAXPROCS.
Uploader.plan reserves max(streaming, ranged) for every file:
- streaming:
uploadBufferMemory(320 MiB on GCS) +workers × parallelWorkerMemory(64 MiB each), withworkers = min(GOMAXPROCS, (budget − 320 MiB) / 64 MiB, populated / 8 MiB + 1) - ranged:
parts × sparseUploadMemory(48 MiB each), at most 12 parts
On GCS, a file with two or more parts always takes the ranged path: PutSparseFile only falls back to streaming below two parts. So the streaming term never runs for these files, but it dominates their reservation. On a 192-vCPU node with a 16Gi budget:
| File (populated bytes) | Path that runs | Reserved | What that path holds, per the package's own constants |
|---|---|---|---|
| 292 MiB memory image | ranged, 4 parts | 2,624–2,880 MiB | 192 MiB |
| 611 MiB memory image | ranged, 9 parts | 5,248 MiB | 432 MiB |
| Metadata files (36 B–0.5 MiB) | streaming, one request | 384 MiB | 128 MiB for the request (64 MiB lookahead + the client's 64 MiB chunk buffer) + one 64 MiB worker |
manifest.json (~1 KB, via Send) |
one request | 320 MiB | 128 MiB for the request |
Two things make it worse:
golang.org/x/sync/semaphoreis FIFO, so a metadata file or a manifest waits behind multi-GiB reservations.- The manifest is uploaded in a second
UploadSnapshotcall after the data files, so under load every snapshot waits in the queue twice.
The measurements below use synchronous suspends on v0.5. The plugin code is unchanged on main, where SuspendActor is asynchronous by default (#2805). There, background uploads go through the same two plugin calls, so the budget also caps how fast they drain. At the default budget, one memory image fills it, so only one of the two default upload workers (#2802) makes progress at a time.
Related work: #2633 reduces what small files actually allocate. #2835 measures a part's encoder at ~26 MiB, inside the 32 MiB serialWorkerMemory that the ranged reservation below relies on. Neither changes how much plan() reserves.
Measurements
Setup: one c3-highmem-192-metal node (v0.5, microVM, GCS backend), snapshot staging on a dedicated Hyperdisk Balanced disk. Three probe actors (512 MiB Glutton) run synchronous SuspendActor and resume every 20–30 s, next to 64 SWE-bench replay actors that suspend and resume back to back. Each run is 12 minutes; p95 is taken after a 3-minute warm-up, one run per configuration. Phase times come from per-request traces and the plugin's Compressed zstd upload logs.
At 16Gi, 82% of a suspend's checkpoint time (p50 over ~2,000 suspends) is waiting for the budget: 4.3 s for the data files and 4.2 s for the manifest. The memory-image upload itself takes 1.2 s and the manifest PUT 0.07 s.
| 64 SWE-bench actors | Probe suspend p95 | Checkpoint persist p50 / p95 | Memory-image uploads in flight, mean / peak | Snapshot data uploaded (before compression) | Plugin working set, max |
|---|---|---|---|---|---|
512Mi (default) |
112–136 s | 108–117 / 119–136 s | 0.7 / 1 | 0.17–0.19 GB/s | 0.5 GiB |
16Gi |
18.2 s | 10.3 / 17.3 s | 5.1 / 6 | 1.2 GB/s | 1.9 GiB |
16Gi, GOMAXPROCS=16 |
5.8 s | 1.8 / 4.6 s | 8.7 / 12 | 2.1 GB/s | 3.2 GiB |
64Gi |
3.3 s | 1.3 / 1.9 s | 8.9 / 23 | 2.2 GB/s | 3.8 GiB |
16Gi, patched plan() (below) |
3.7 s | 1.4 / 2.5 s | 9.8 / 55 | 2.2 GB/s | 3.5 GiB |
512Mi, patched plan() |
42.4 s | 35.2 / 41.1 s | 1.8 / 2 | 0.52 GB/s | 1.3 GiB |
The two patched rows ran an earlier revision that reserved only the 64 MiB lookahead for single-request objects. The fix above also counts the client's chunk buffer, so it reserves 64 MiB more per metadata file and manifest. That shouldn't affect the 16Gi row: modelled reservations peaked at 11.5 GiB, well under the budget. It may trim the 512Mi gain somewhat; that isn't re-measured.
The probes-only suspend p95 is 3.0–4.5 s. Each upload's median speed stayed between 248 and 284 MB/s for the SWE-bench snapshots in every run, and a raw upload test from the same node reached at least 2.5 GB/s to GCS. So the node's throughput is set by how many uploads the budget admits, not by the network, compression or CPU: the plugin used 5.5 cores at 16Gi and 11 cores with the patch. GOMAXPROCS=16 helps only because it caps workers and so shrinks each reservation.
Proposed fix
Have plan() reserve for the path that will run:
- For a file whose backend implements
sparseFilePutterand that has ≥ 2 parts, reserveparts × sparseUploadMemory.PutSparseFilewill split it and never fall back. - For a streamed object whose sparse stream is bounded below
uploadCompositeMin, reserve the single request's buffers (uploadCompositeMinlookahead +uploadChunkSizeclient chunk buffer) + workers, not the composite part buffers, since GCS sends it in one request. - Size
Send's reservation by the file when it is a small regular file.
With the patch, the modelled reservations average 2.2 GiB at 16Gi against a measured 2.0 GiB average working set. With today's sizing they average 14.6 GiB for 1.3 GiB. So the budget keeps bounding memory (#2553) without capping throughput.
At the 512Mi default the patch helps about 3×, since two memory images fit instead of one. But with 64 SWE-bench actors, suspend p95 is still ~10× the quiet baseline, so the default itself should scale with the node, as suggested in #2553. With the patch at 512Mi, the plugin's working set peaked at 1.3 GiB (0.5 GiB without it), because more uploads run at once and the budget only counts upload buffers. A total-memory cap needs GOMEMLIMIT or a container limit.
Smaller follow-ups:
- The composed parts are deleted one by one before the upload returns: 0.17 s for 4 parts, 0.48 s for 9.
- With nothing queuing, synchronized suspends can start 50+ uploads at once. If that tail matters, a cap on concurrent uploads is a separate knob from memory.
I'll send a PR with the change.
Activity
roycaihw commentedon Oct 18, 2026
cc Eitan Yarmush (@EItanya) Benjamin Elder (@BenTheElder) Michelle Au (@msau42) Nishanth Kotla (@Nishanth29)
Nishanth29 commentedon Oct 19, 2026
Adding gVisor (and microVM) numbers from sweperf at the default budget.
Setup (different from yours, same pattern): CI's sweperf layout (150 users, 1×
c3-highcpu-192-metal, GCS, synchronous suspends, snapshots on the boot disk, not a dedicated one). Default512Mionmainatb5b34818. The control is the same cluster with only--upload-memory-budget=64Gichanged, built from4e896f78.upload.gois the same in both builds.512Mi(default)64GiSuspendActorp50 / p99checkpoint_latest_persistp50 / p95checkpoint_latest_persistp50 / p95SuspendActorp50 / p99A gVisor snapshot is only ~17 MB, but at
512MieachSendFilereserves 384–512 MiB and each manifestSendreserves 320 MiB. So no two uploads fit at once, and every suspend on the node waits in one FIFO queue. For reference, CI before #2708 (ac296df6) had gVisorSuspendActorp50 at 0.84 s.At
64Gi, microVMSuspendActoris still 39 s because the worker's boot disk is saturated (~77% IO PSI stall), which is a separate node-storage issue, not the budget.Snapshot uploads reserve only the memory their upload path uses (agen…
objectstorage: size small snapshot upload buffers to actual data (age…