Skip to content

zstd: decode at full speed into exact-size destinations - #1246

Merged
klauspost merged 2 commits into
klauspost:masterfrom
honeycombio:lizf.zstd-exact-dst
Sep 30, 2026
Merged

klauspost merged 2 commits into
klauspost:masterfrom
honeycombio:lizf.zstd-exact-dst

Conversation

@lizthegrey

@lizthegrey lizthegrey commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

DecodeAll into a destination whose capacity is exactly the decoded size ran much slower than the same call with 16 bytes to spare. This is the natural buffer for a caller that knows the decompressed size, for example from the frame header or its own metadata.

(1) zstd: bound the decodeSync slack check by the block, not the frame (7ef7a21). When the frame content size is known, useSafeDecodeSync asked for compressedBlockOverAlloc bytes of slack beyond the whole rest of the frame (maxSyncLen). With an exact-size destination that is never true, so every block ran the bounds-exact copies; on Neoverse N1 that path was 60% of decode time. The extended copies only need the slack past the block being decoded, which produces at most min(maxSyncLen, maxBlockSize) bytes. The assembly still checks every sequence against cap-compressedBlockOverAlloc on that path, so a frame that lies about its size fails as before. Test: TestUseSafeDecodeSyncFrameBound in zstd/seqdec_test.go.

The same change fixes multi-frame input decoded in one DecodeAll call. maxSyncLen starts at FrameContentSize + len(dst), so for every frame after the first it overstated what was left by all the output already written. The old whole-frame check then sent later frames to the bounds-exact copies even when the buffer had plenty of room.

(2) zstd: don't reallocate the output for missing slack in executeSimple (1720e5a). executeSimple grew s.out whenever the block plus compressedBlockOverAlloc did not fit. With an exact-size destination that happens on the frame's last block: the growth copied the entire decoded frame into a new buffer about twice its size, and DecodeAll returned that copy instead of the caller's buffer. It now grows only when the block itself does not fit, and uses the bounds-exact copies when only the slack is missing. DecodeAll reaches this through the two-pass path, which runs on arm64 for windows of 1 MiB and up. Test: TestDecodeAllExactCapacity in zstd/decoder_test.go, which forces the two-pass path so it also runs on amd64. A panic planted in the new branch confirmed the test reaches it.

Both tests fail on master and pass here. go test ./zstd passes in the default, noasm and nounsafe builds on amd64 and arm64.

Performance

Data: 64 MiB slices of apache.log, github-june-2days-2019.json, gob-stream and nyc-taxi-data-10M.csv from https://klauspost.com/files/compress/, in two shapes:

  • single frames encoded by this package at SpeedDefault and SpeedBetterCompression;
  • 4 MiB frames with a 4 MiB window, concatenated.

All CRC off. BenchmarkCorpus's DecodeAll preallocates exactly the decoded size. Each run is 10 interleaved rounds pinned to one core, compared with benchstat.

This PR vs master, B/s geomean over the 16 files. No regressed cells (0 of 64).

machine geomean single frames 4 MiB frames
Neoverse N1 (Ampere Altra) +7.5% +1.2…+20.6% +4.0…+8.7%
Graviton2 (c6g) +7.1% +3.5…+8.9%
Graviton3 (c7g) +10.0% +4.4…+11.9%
Graviton5 (c9g) +8.2% +3.2…+10.0%

For comparison on N1, giving master's destination 16 spare bytes recovers +10.4% on the single frames (the fix matches all but the last block, which still needs the exact copies), but nothing on the multi-frame files.

Callers that already leave slack are unaffected, because both changes make the same choices for them. That includes DecodeAll with a nil or too-small dst, which allocates FrameContentSize+16 itself. Measured with per-frame DecodeAll into a reused buffer on the same data, with PCALIGN $64 in both builds:

  • Graviton5 flat;
  • Graviton3 +0.2…+0.5% in 6 of 8 cells;
  • Graviton2 −0.5…−0.8% in 3 cells;
  • N1 −0.6…−1.4% in 6 cells.

The only added work is one compare per block, so I believe that spread is the placement of the Go code, which PCALIGN doesn't pin, but I haven't isolated it.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved compressed-data decoding when the output buffer has exactly enough space for the decoded data, including when it already contains a prefix.
    • Reduced unnecessary buffer growth while maintaining safe decoding when available space is limited.

lizthegrey and others added 2 commits September 29, 2026 12:13
When the frame content size is known, useSafeDecodeSync required the
output buffer to have compressedBlockOverAlloc bytes of slack beyond the
whole rest of the frame (maxSyncLen). A destination sized exactly to the
frame therefore ran the bounds-exact copies for every block, although
the extended copies only need the slack past the block being decoded,
which produces at most min(maxSyncLen, maxBlockSize) bytes. The assembly
still checks each sequence against cap-compressedBlockOverAlloc, so a
frame that lies about its size fails as before.

Test in zstd/seqdec_test.go.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
executeSimple grew s.out whenever the block plus compressedBlockOverAlloc
did not fit. With a destination sized exactly to the frame that happens
on the last block, and the growth copied the whole decoded frame into a
new buffer about twice its size, so DecodeAll also returned a different
backing array than the caller supplied. Grow only when the block itself
does not fit, and use the bounds-exact copies when the output lacks the
slack.

The two-pass path that calls executeSimple from DecodeAll runs on arm64
for windows of 1 MiB and up.

Test in zstd/decoder_test.go.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@coderabbitai

coderabbitai Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: ff32b382-7674-4bdd-b62d-56733be248af

📥 Commits

Reviewing files that changed from the base of the PR and between 4e2dcf8 and 1720e5a.

📒 Files selected for processing (3)
  • zstd/decoder_test.go
  • zstd/seqdec_asm.go
  • zstd/seqdec_test.go

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

The Zstandard decoder adjusts output growth and copy-variant selection based on decoded block size and available buffer slack. Tests cover copy selection and exact-capacity destination reuse.

Changes

Zstandard decode buffer handling

Layer / File(s) Summary
Copy selection and output growth
zstd/seqdec_asm.go, zstd/seqdec_test.go
The assembly decode path selects safe copies when the literal or output buffer lacks required slack. It grows output only when the decoded block exceeds capacity. Tests check copy selection against frame size and available slack.
Exact-capacity destination reuse
zstd/decoder_test.go
Tests cover normal and forced two-pass decoding with exact-capacity destinations, with and without a prefix. They check decoded output and destination backing-array reuse.

Priority: ⬇️ Low

Estimated code review effort: 2 (Simple) | ~12 minutes

Change: Bug fix

Merge Risk: ⚪ Minimal · up to 1720e

The change avoids unnecessary output reallocation for exact-size destinations and falls back to safe copies when slack is lacking. No merge-blocking risk was identified.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 1720e

The decoder’s copy-safety decisions change for exact-size output buffers. The reviewed paths retain bounds checks and input limits, and no new security issue was established. Coverage of malformed and multi-frame exact-capacity cases remains limited.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — The relevant exposure is within applications that pass compressed input to the existing zstd decoder. The changed tests do not add an independently reachable entrypoint; no service or tenant exposure can be established from the supplied scope.

Trust Boundaries and Controls

  • observed — Compressed input crosses into DecodeAll and then sequence decoding. The reviewed paths retain decoder limits and per-sequence error checks, while the new selectors choose bounds-exact copies when the required copy slack is unavailable.

Resilience and Maintainability Implications

  • inferred — The source checks support bounds safety on malformed-input and partial-failure paths, but the new exact-capacity tests establish those paths less directly than they establish successful decoding and buffer reuse.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 5 functions across 3 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: enabling zstd decoding into exact-size destinations without unnecessary output growth.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@klauspost
klauspost merged commit da4df9c into klauspost:master Sep 30, 2026
94 checks passed
@lizthegrey
lizthegrey deleted the lizf.zstd-exact-dst branch September 30, 2026 14:59
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