wal: reduce per-record allocations in the decoder - #22380
Conversation
WAL replay at startup (Open + ReadAll) decodes every record since the last snapshot in one burst. Until now decodeRecord allocated a record-sized buffer per record (make([]byte, recBytes+padBytes)), so for a multi-GB WAL, replay produced double the garbage: one frame buffer plus proto's copy of the record for every entry. readInt64 also paid binary.Read's interface conversion and type-switch dispatch for every record. Two changes, mirroring cockroachdb/pebble@8ca7bf3 ("Reduce VersionEdit.Decode allocations"): - Reuse a scratch buffer on the decoder, grown on demand to the largest record seen and never shrunk. Reuse is safe: no reference to the buffer (or a subslice) escapes decodeRecord, because proto.Unmarshal copies the record bytes out of it and this package contains no unsafe unmarshal. isTornEntry and the CRC computation only use the buffer within the current call. - readInt64 reads 8 bytes and decodes via binary.LittleEndian.Uint64. It is equivalent to binary.Read(r, binary.LittleEndian, &n) with identical error semantics (io.EOF when no bytes were read, io.ErrUnexpectedEOF on a partial read), so end-of-file, preallocated-space, and torn-write detection are unchanged. Adds decoder benchmarks: the raw record decode loop over a WAL segment and the full Open + ReadAll + Close replay, each with 64B/512B/4KB entries. go test ./server/storage/wal/... passes, also with -race, including torn-write, CRC-mismatch, repair, and continueOnCrcError coverage. ## Results (Sep 4, 2026) Setup: 10,000-entry single-segment WAL per benchmark; the decode benchmark reuses one open segment file and replays NewDecoder + Decode until EOF; the ReadAll benchmark does a full Open + ReadAll + Close per iteration. go1.26.7, darwin/arm64, Apple M4, benchstat, n=6 (decode) and n=10 (ReadAll). "~" marks differences that are not statistically significant (p>0.05) on this host; all other deltas have p<=0.023. ### Decode only (lower is better) | Benchmark | sec/op | B/op | allocs/op | |---|---|---|---| | DecoderDecodeEntry64B | 1.387m -> 1.176m (**-15.2%**) | 1957.8Ki -> 1020.4Ki (**-47.9%**) | 50.02k -> 40.02k (**-20.0%**) | | DecoderDecodeEntry512B | 3.362m -> 2.700m (**-19.7%**) | 11.220Mi -> 5.727Mi (**-49.0%**) | 50.02k -> 40.02k (**-20.0%**) | | DecoderDecodeEntry4KB | 18.90m -> 15.02m (**-20.5%**) | 93.01Mi -> 46.63Mi (**-49.9%**) | 50.03k -> 40.02k (**-20.0%**) | Throughput: +17.9% / +24.5% / +25.8% (64B / 512B / 4KB). ### Full replay, Open + ReadAll + Close (lower is better) | Benchmark | sec/op | B/op | allocs/op | |---|---|---|---| | WALReadAllEntry64B | 60.99m -> 59.99m (~) | 5.026Mi -> 4.111Mi (**-18.2%**) | 100.12k -> 90.12k (**-10.0%**) | | WALReadAllEntry512B | 62.48m -> 58.66m (~, p=0.063) | 18.61Mi -> 13.11Mi (**-29.5%**) | 100.12k -> 90.12k (**-10.0%**) | | WALReadAllEntry4KB | 64.23m -> 59.00m (**-8.1%**) | 134.57Mi -> 88.19Mi (**-34.5%**) | 100.12k -> 90.12k (**-10.0%**) | Reading the numbers: the raw decode loop is 15-20% faster and its heap allocation is halved, one frame-buffer allocation saved per record. In the full replay the decode step is only one component (entry unmarshal and file open/close dominate), so end-to-end time improves 8.1% for 4KB entries and is within noise for smaller ones, but the allocation burst during recovery drops 18-35%, from about 2.3x to 1.2x the WAL payload size, which lowers GC pressure during the most latency-sensitive phase of a member restart. Signed-off-by: Gyuho Lee <gyuhol@nvidia.com>
|
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: gyuho The full list of commands accepted by this bot can be found here. DetailsNeeds approval from an approver in each of these files:Approvers can indicate their approval by writing |
|
Hi @gyuho. Thanks for your PR. I'm waiting for a etcd-io member to verify that this patch is reasonable to test. If it is, they should reply with Tip We noticed you've done this a few times! Consider joining the org to skip this step and gain Once the patch is verified, the new status will be reflected by the I understand the commands that are listed here. DetailsInstructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository. |
WAL replay at startup decodes every record since the last snapshot in one burst, and
decodeRecordallocated a record-sized buffer for each one. This PR reuses a scratch buffer on the decoder and replacesbinary.Readwith a direct 8-byte decode inreadInt64, mirroring cockroachdb/pebble@8ca7bf3. The decode loop runs 15-20% faster and allocates half the memory; full replay allocates 18-35% less.Safety: no reference to the scratch buffer escapes
decodeRecord—proto.Unmarshalcopies the record bytes out of it, and this package has no unsafe unmarshal.readInt64is byte-for-byte equivalent tobinary.Read(r, binary.LittleEndian, &n), includingio.EOF/io.ErrUnexpectedEOFsemantics, so end-of-file and torn-write detection are unchanged.go test ./server/storage/wal/...passes, including with-race(torn writes, CRC mismatch, repair, continueOnCrcError).Results (Sep 4, 2026)
Setup: 10,000-entry single-segment WAL; the decode benchmark replays NewDecoder + Decode to EOF over one open segment file; the ReadAll benchmark does a full Open + ReadAll + Close per iteration. go1.26.7, darwin/arm64, Apple M4, benchstat, n=6 (decode), n=10 (ReadAll). "~" = not statistically significant (p>0.05) on this host; all other deltas have p<=0.023.
Decode only (lower is better)
Throughput: +17.9% / +24.5% / +25.8% (64B / 512B / 4KB).
Full replay, Open + ReadAll + Close (lower is better)
Full-replay time improves 8.1% only for 4KB entries because entry unmarshal and file open/close dominate ReadAll; the consistent win across sizes is the recovery-time allocation burst, down from ~2.3x to ~1.2x the WAL payload size.
Disclosure: implemented and benchmarked with AI assistance.