Skip to content

wal: reduce per-record allocations in the decoder - #22380

Open
gyuho wants to merge 1 commit into
etcd-io:mainfrom
gyuho:wal-decoder-reduce-allocations
Open

wal: reduce per-record allocations in the decoder#22380
gyuho wants to merge 1 commit into
etcd-io:mainfrom
gyuho:wal-decoder-reduce-allocations

Conversation

@gyuho

@gyuho gyuho commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

WAL replay at startup decodes every record since the last snapshot in one burst, and decodeRecord allocated a record-sized buffer for each one. This PR reuses a scratch buffer on the decoder and replaces binary.Read with a direct 8-byte decode in readInt64, 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 decodeRecordproto.Unmarshal copies the record bytes out of it, and this package has no unsafe unmarshal. readInt64 is byte-for-byte equivalent to binary.Read(r, binary.LittleEndian, &n), including io.EOF / io.ErrUnexpectedEOF semantics, 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)

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%)

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.

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>
@kubernetes-prow

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is NOT APPROVED

This pull-request has been approved by: gyuho
Once this PR has been reviewed and has the lgtm label, please assign jmhbnz for approval. For more information see the Code Review Process.

The full list of commands accepted by this bot can be found here.

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@kubernetes-prow

Copy link
Copy Markdown

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 /ok-to-test on its own line. Until that is done, I will not automatically test new commits in this PR, but the usual testing commands by org members will still work.

Tip

We noticed you've done this a few times! Consider joining the org to skip this step and gain /lgtm and other bot rights. We recommend asking approvers on your previous PRs to sponsor you.

Once the patch is verified, the new status will be reflected by the ok-to-test label.

I understand the commands that are listed here.

Details

Instructions 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Development

Successfully merging this pull request may close these issues.

1 participant