perf(storer): reuse buffers and eliminate per-chunk allocations in reserve sample - #5612
Conversation
Sampling is about to start reading chunks into a buffer that each worker reuses. Nothing today checks that the bytes handed back in a SampleItem are still the bytes of that chunk, so a reused buffer would silently hand the redistribution proof the contents of some later chunk. assertValidSample now checks two things for every item: that ChunkData still reproduces ChunkAddress, and that no two items share a backing array. Every existing sample test picks both up. Also add the rulers for the work that follows. BenchmarkReserveSample1k keeps its name and behaviour so the recorded baseline stays comparable; its body moves to a helper that BenchmarkReserveSample10k reuses over a ten times larger reserve. BenchmarkChunkStoreGet measures a single chunk read, split into a variant that builds the ChunkStore handle per call as the sampler does today and one that hoists it, so the cost of the handle alone is visible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KFUseFQ8rhp6N9X6YS7pbq
db.ChunkStore() builds three objects every time it is called, and the sampler called it once per chunk. On a testnet node that is 2.3 million handles per round, thrown away immediately. Hoist it to one per worker. The handle is deliberately not shared across workers: the read-only chunk store makes no thread-safety promise, and three allocations per worker is already nothing next to three per chunk. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KFUseFQ8rhp6N9X6YS7pbq
444d89d to
841bbaf
Compare
Both ReserveSample benchmarks fill their reserve with chunk.GenerateValidRandomChunkAt, which produces content-addressed chunks only. The SOC branch of transformedAddress is therefore never measured by them, and any work that branch does beyond hashing the wrapped CAC is invisible. Benchmark the function directly, one case per chunk type, through a shim in export_test.go. The shim takes a swarm.Chunk and is held to that signature on purpose so the same benchmark source can be run against branches whose internal transformedAddress is shaped differently; only the shim changes between them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KFUseFQ8rhp6N9X6YS7pbq
|
|
||
| type ChunkStore interface { | ||
| Getter | ||
| GetterInto |
There was a problem hiding this comment.
Not sure if we need to add it to the ChunkStore interface. Since we only use ReadOnlyChunkStore for sampling, we can only add it to the interface below. I think we can even avoid adding it to transaction.ChunkStore also.
There was a problem hiding this comment.
Dropping it makes ReadOnlyChunkStore no longer a subset of ChunkStore, which breaks the test storages and the mock storer that return the same value as both. Then even more changes are needed...
| return ch, err | ||
| } | ||
|
|
||
| func (c *chunkStoreTrx) GetInto(ctx context.Context, addr swarm.Address, buf []byte) (n int, err error) { |
There was a problem hiding this comment.
Check comment above. Since we only need ReadOnlyChunkStore to provide this function, we can avoid adding it here.
| continue | ||
| } | ||
|
|
||
| ch, err := phase3ChunkStore.Get(ctx, item.chunkAddress) |
There was a problem hiding this comment.
instead ctx shouldn't we also use in this Phase 3 gCtx ?
There was a problem hiding this comment.
gCtx is part of the worker group, and errgroup cancels it as soon as Wait returns, so phase3 needs to live longer than this.
| continue | ||
| } | ||
|
|
||
| ch, err := phase3ChunkStore.Get(ctx, item.chunkAddress) |
There was a problem hiding this comment.
phase3ChunkStore.Get runs before the consensusTime check: stamp.Timestamp() is already in memory from Step 1. Loading the 4096-byte chunk from Sharky before verifying stamp.Timestamp() <= consensusTime causes completely wasted disk reads for every chunk stamped past consensusTime.
Also, phase3ChunkStore.Get runs before db.validStamp: In Swarm, ValidStamp does not inspect chunk data—it only validates chunk.Address() and chunk.Stamp(). Performing a disk read before verifying whether the stamp is even valid burns I/O for expired or invalid stamps.
There was a problem hiding this comment.
Here is another comment that would cover this as well, but as said, benefit is small, becuase on 4M chunks we are execting beetween 200-250 in the last phase.
| @@ -21,6 +21,14 @@ type Getter interface { | |||
| Get(context.Context, swarm.Address) (swarm.Chunk, error) | |||
| } | |||
|
|
|||
There was a problem hiding this comment.
implementations return a bare fmt.Errorf("chunk store: buffer too small: %d < %d", ...). A caller can't distinguish it from ErrNotFound.
Maybe add:
// ErrBufferTooSmall is returned by GetInto when len(buf) is smaller than the
// stored chunk. Callers may inspect it with errors.Is to retry with a larger buffer.
var ErrBufferTooSmall = errors.New("storage: buffer too small")
There was a problem hiding this comment.
No stored chunk can exceed swarm.SocMaxChunkSize. Sharky rejects larger writes with ErrTooLong. So a caller sized to that constant can never hit this. Nothing to match until some caller really needs it.
|
|
||
| ch, err := phase3ChunkStore.Get(ctx, item.chunkAddress) | ||
| if err != nil { | ||
| stats.ChunkLoadFailed++ |
There was a problem hiding this comment.
We should have a separate metric for ChunkLoadFailed for phase2 and phase3. We could count chunks which were previously available but are no longer available due to eviction here.
| return swarm.ZeroAddress, errors.New("chunk data too short for soc") | ||
| } | ||
| taddrCac, err := transformedAddressCAC(hasher, cacChunk) | ||
| cursor := swarm.HashSize + swarm.SocSignatureSize |
There was a problem hiding this comment.
It is unreachable but a cheap len(data) > swarm.SocMaxChunkSize guard alongside the existing SocMinChunkSize check would restore the rejection without giving back any of the allocation win. Especially when this fuzzing PR caught these type of cases. If someone changes some code later without changing this, better safe than sorry.
|
|
||
| // GetterInto is like Getter but reads chunk data into a caller-provided buffer, | ||
| // avoiding per-call allocations. len(buf) must be at least the chunk size; | ||
| // GetInto never writes past len(buf). Returns the number of bytes read into |
There was a problem hiding this comment.
nit: "Must" invites someone to size a buffer from a constant and skip checking the error, which is exactly the case that silently returns n=0 with a non-nil error today.
Suggest rewording to describe what actually happens — something like: "If len(buf) is smaller than the stored chunk, GetInto returns an error and does not write to buf. Returns the number of bytes read; callers use buf[:n]."
This is adjacent to the ErrBufferTooSmall thread but distinct: that one is about giving the error a type callers can match on, this one is about the prose describing the opposite contract from the code either way
Checklist
Description
This PR addresses per-chunk allocations and buffer overhead in
ReserveSample(issue #5174):escapes.
actually qualify for the sample candidate set.
BenchmarkReserveSample10k.
A/B on
bee-light-testnet, two nodes with identical config (no SIMD, verbosity 3, 1500m CPU, storage radius 2,isWarmingUp: false). Sample triggered viaGET /rchash/2/<own-overlay>/<anchor2>; allocations attributed withpprof -diff_basefocused on theReserveSamplesubtree.b6537ea2chunkstore.readChunkbuffertransformedAddress(control)bmt.Write(control)Total allocation churn per sample round: 24.65 GB → 10.30 GB over 2.405M chunks.
Open API Spec Version Changes (if applicable)
Motivation and Context (Optional)
Related Issue (Optional)
Screenshots (if appropriate):
AI Disclosure