Skip to content

objectstore-plugin: upload reservations cover a path the upload never takes, which caps snapshot throughput on large nodes #2847

Closed

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), with workers = 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/semaphore is FIFO, so a metadata file or a manifest waits behind multi-GiB reservations.
  • The manifest is uploaded in a second UploadSnapshot call 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 sparseFilePutter and that has ≥ 2 parts, reserve parts × sparseUploadMemory. PutSparseFile will split it and never fall back.
  • For a streamed object whose sparse stream is bounded below uploadCompositeMin, reserve the single request's buffers (uploadCompositeMin lookahead + uploadChunkSize client 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

  1. Nishanth29 commented on Oct 19, 2026

    @Nishanth29
    Contributor

    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). Default 512Mi on main at b5b34818. The control is the same cluster with only --upload-memory-budget=64Gi changed, built from 4e896f78. upload.go is the same in both builds.

    Metric 512Mi (default) 64Gi
    gVisor SuspendActor p50 / p99 81 s / 92 s 0.46 s / 0.68 s
    gVisor checkpoint_latest_persist p50 / p95 80.4 s / 89.8 s 0.21 s / 0.27 s
    gVisor suspends completed / trajectories 894 / 263 3,228 / 1,027
    microVM checkpoint_latest_persist p50 / p95 160.9 s / 167.7 s 3.12 s / 6.11 s
    microVM SuspendActor p50 / p99 161 s / 170 s 39 s / 358 s

    A gVisor snapshot is only ~17 MB, but at 512Mi each SendFile reserves 384–512 MiB and each manifest Send reserves 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 gVisor SuspendActor p50 at 0.84 s.

    At 64Gi, microVM SuspendActor is 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.

  2. added a commit that references this issue on Oct 19, 2026
    9eefc30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions