Skip to content

objectstorage: write snapshot parts as sized zstd frames and pool upload buffers - #2835

Merged
Max Smythe (maxsmythe) merged 4 commits into
agent-substrate:mainfrom
Nishanth29:zstd-framed-parts
Oct 20, 2026
Merged

Max Smythe (maxsmythe) merged 4 commits into
agent-substrate:mainfrom
Nishanth29:zstd-framed-parts

Conversation

@Nishanth29

@Nishanth29 Nishanth Kotla (Nishanth29) commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Part of #2553. Related to #2502 and #2708.

Large GCS snapshots (PutSparseFile → writeSparsePart) previously compressed each 64 MiB part as one unsized streaming zstd frame with freshly allocated buffers. #2502's parZstdReader only parallelizes frames whose size is declared or bounded at ≤ 16 MiB; a part written as one streaming frame of 64 MiB or more is neither, so restore goes serial from the first part. Meanwhile, switching parts to the worker-pool parZstd with one worker uses ~77 MiB peak per part (2.4× over #2708's 32 MiB serialWorkerMemory budget).

This PR:

  • Adds an inline mode to parZstd for workers == 1: no goroutines; each ≤ 8 MiB chunk is compressed on the caller's goroutine into a frame that declares its size. writeSparsePart uses newParZstd(dst, 1), and small files get it too.
  • Pools the 8 MiB chunk, 8.5 MiB frame, and 1 MiB sparse copy buffers, and removes unused newSerialZstd.
  • Makes Write and a second Close after Close return os.ErrClosed.
  • Adds BenchmarkWriteSparsePart, BenchmarkDecodeSparse, and format tests, including one that pins the output byte for byte to per-chunk EncodeAll.

Results (-count=10, 512 MiB populated / 2 GiB sparse)

Benchmark main #2502 as-is this PR
Write 64 MiB part: time / peak heap (32 MiB budget) 148 ms / 9.8 MiB 133 ms / 76.9 MiB ❌ 144 ms / 26.4 MiB ✅
Write 64 MiB part: frames with content size 0 / 1 8 / 9 9 / 9
Read 512 MiB: time (this PR + #2502: 254 ms, 3.8×) 971 ms 285 ms (3.4×) 552 ms (1.76×)
ConcurrentSnapshotUploads (shared_512MiB): B/op / peak heap¹ 1,326 / 272 MiB 1,326 / 286 MiB 70 / 196 MiB
  • Serial restore is 1.76× faster even without #2502: sized frames decode straight into a buffer of the declared size (runtime.memmove drops from 40% to 9% of decode CPU).
  • Stress case (all_80_admitted)¹: batch time roughly halves (57.6 → ~29 ms) and peak heap drops about a third (1,994 → 1,330 MiB).
  • Cluster A/B (sweperf, 150u on c3-highcpu-192-metal, with #2502): microVM restore_golden_download −31% at p50 (1,095 → 752 ms) and −63% at p95 (3,419 → 1,255 ms); restore_p50_s −29% (945 → 669 ms).

¹ This PR's upload numbers are from a later session on the same machine than the main and #2502 columns, so treat the deltas as approximate.

Compatibility & follow-up

  • Snapshot format is unchanged; old and new snapshots decode byte-exact on both readers.

  • #2502's newParZstd(dst, 1) in writeSparsePart lands as-is; only TestReadZstdFrameBoundsUnsizedSmallFrames needs to build its unsized frame with zstd.NewWriter(nil).EncodeAll.

  • Follow-up (now that #2848 is merged): 1-worker uploads could reserve serialWorkerMemory instead of parallelWorkerMemory.

  • Tests pass

  • Appropriate changes to documentation are included in the PR (none needed)

Add BenchmarkWriteSparsePart (one 64 MiB part, and a 512 MiB snapshot
written as concurrent parts like PutSparseFile) and BenchmarkDecodeSparse
(readSparseZstd on the stream this build writes and on the older
one-frame-per-part layout). They report throughput, allocations, peak
heap, CPU cores used, compression ratio and frame layout, and only call
functions every variant of this path has, so the same file can measure
encoder and decoder changes against each other.

TestSparseSnapshotFormatsDecode checks both layouts decode byte-exact.
@Nishanth29

Copy link
Copy Markdown
Contributor Author

Each sparse upload part was compressed as one streaming zstd frame with no
content size, through fresh buffers on every upload. A frame like that can
only be decoded serially, so a parallel decoder gets nothing to
split.

Write parts with framedZstd instead: it compresses on the caller's
goroutine into independent single-segment frames of at most parZstdChunk
input bytes, each declaring its size. Unlike running parZstd with one
worker per part, it holds one chunk, one frame and one encoder, which
keeps a part inside the 32 MiB serialWorkerMemory the upload budget
reserves (about 26 MiB measured, vs about 77 MiB for parZstd with one
worker), with no goroutines.

Chunk and frame buffers are now pooled and shared by parZstd workers and
framedZstd, and the parts' 1 MiB copy buffer is pooled too, so uploads
stop allocating those from scratch. Encoders stay per worker and per part:
an Encoder makes an internal channel on first use, which would tie a
pooled one to whichever synctest bubble used it first.

The snapshot format is unchanged: concatenated zstd frames still decode
through the existing serial reader. newSerialZstd has no callers left
and is removed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a nit. Otherwise LGTM

Comment thread pkg/objectstorage/parzstd.go Outdated
Comment thread pkg/objectstorage/parzstd.go Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@maxsmythe Max Smythe (maxsmythe) left a comment

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.

LGTM

@maxsmythe
Max Smythe (maxsmythe) added this pull request to the merge queue Oct 20, 2026
Merged via the queue into agent-substrate:main with commit c4020be Oct 20, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/storage kind/feature An enhancement / feature request or implementation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants