Skip to content

snapshot: decode chunks in parallel out of a pull-wide buffer pool - #12

Merged
CMGS merged 6 commits into
mainfrom
feat/parallel-chunk-decode
Jul 26, 2026
Merged

snapshot: decode chunks in parallel out of a pull-wide buffer pool#12
CMGS merged 6 commits into
mainfrom
feat/parallel-chunk-decode

Conversation

@tonicmuroq

@tonicmuroq tonicmuroq commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

What

Push stores every chunk as an independent zstd frame. This PR decodes those frames in the fetch workers, preserves chunk order through futures, and shares one bounded buffer pipeline across the whole pull.

The pull path now:

  • plans all encoded files before I/O;
  • derives the largest stored-input and decoded-output capacities independently across the plan;
  • permits a parallel window only when outputCap + window × (inputCap + outputCap) fits the configured prefetch-buffer budget;
  • falls back to the sequential streaming path when fewer than two chunks fit or a chunk exceeds the buffering cap;
  • limits DecodeAll to the planned output capacity, so malformed compressed content cannot allocate past the slot;
  • reads manifest-sized blobs into exact-size pooled slices and preserves terminal transport verification errors.

The independent maxima and the extra current output buffer are intentional. Files with different compression ratios can make the largest input and output occur in different entries, while the reader holds one output buffer as the next window is fetched.

Performance evidence

Earlier profiling on the target test node motivated the change: parallel stateless decoding increased the isolated decode step from 644 to 4952 MiB/s, and the shared-pool prototype reduced a 14 GiB pull from 58.2s to 36.2s. Review hardening changed the final budget formula and can reduce the allowed window, so those measurements are historical prototype evidence, not current-HEAD runtime acceptance. Current-head performance should be refreshed on the testbed before making a release claim.

Verified

  • make fmt-check
  • make test (go vet on linux and darwin; go test -race ./...)
  • make lint (linux and darwin, zero issues)
  • GOOS=darwin asl ./...
  • GOOS=linux asl ./...

Regression coverage includes byte-identical v1/v2 reconstruction, pull-wide budget boundaries, cross-file input/output maxima, per-file window narrowing, decoded-size rejection, exact blob-length enforcement, transport-error propagation, and bounded prefetch starts.

All explanatory comments introduced by this PR were removed during review; the code relies on names and regression tests for the contract.

Base automatically changed from fix/skip-redundant-blob-hash to main July 26, 2026 08:36
tonicmuroq and others added 5 commits July 26, 2026 16:39
Push writes every chunk as its own zstd frame, but the reader concatenated
them and fed one stream decoder, so SNAPSHOT_TRANSFER_CONCURRENCY only
parallelised downloads. Decoding inside the fetch goroutines measured
7.7x on the decode step alone (644 -> 4952 MiB/s over 8 cores), and turned
compression from a net loss into a net win: on internal-cocoon-node-7 a
14 GiB snapshot pull went 59.9s -> 32.5s, and the compressed pull now
beats the uncompressed one despite moving 2.24x fewer bytes.

Parallel decode has to materialise whole decompressed chunks, which the
serial decoder never did, so buffers now come from two bounded pools and
the window is charged for both halves a decoding slot holds at once. Two
sizing bugs were worth fixing on the way:

  - fetchChunk read into a bytes.Buffer, whose 512-byte grow step
    overshoots a pre-sized capacity near EOF and then reallocates and
    copies the whole chunk. That was 8% of pull CPU. It now reads an
    exact-size slice.
  - The pools were per file, so two large files in one snapshot could hold
    two sets at once — 7.2 GiB peak against a 2 GiB budget. They are now
    sized once against the whole plan, which is why writeImportTar is
    split into planLayers -> newChunkPipeline -> stream: the budget belongs
    to the pull, and per-file sizing cannot see the other files.

Sharing the pools also made it faster, not just smaller: a fresh 256 MiB
buffer costs a page fault and a kernel-zeroed page per page on first
touch, and a pull touches 14 GiB of them. Same window, 42.6s -> 36.2s.

Peak RSS for one pull ends up at 3.74 GiB against the pre-change 3.65 GiB,
i.e. the same memory for 38% less time. SNAPSHOT_PULL_BUDGET_MIB (vk side)
trades the rest: 3584 gives window 7 and 32.5s for 4.90 GiB.
Type blocks atomic (chunkPipeline methods rejoin their type); utility funcs sink below the method-bearing types.
@CMGS
CMGS force-pushed the feat/parallel-chunk-decode branch from 65aae77 to b3e1dac Compare July 26, 2026 08:43
fetch's size guard and fileWindow's per-chunk cap loop are unreachable: validateSnapshotLayers rejects negative sizes before any fetch, and newChunkPipeline zeroes the window when any chunk exceeds the buffering cap. window >= 2 at every newChunkSource call site, so the max(...) floor was dead.
@CMGS
CMGS merged commit 5579c6d into main Jul 26, 2026
2 checks passed
@CMGS
CMGS deleted the feat/parallel-chunk-decode branch July 26, 2026 09:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants