streamingaead: lazily allocate segment buffers in Reader and Writer - #64
Open
iainmcgin wants to merge 1 commit into
Open
streamingaead: lazily allocate segment buffers in Reader and Writer#64iainmcgin wants to merge 1 commit into
iainmcgin wants to merge 1 commit into
Conversation
iainmcgin
force-pushed
the
iain/streaming-lazy-allocate
branch
from
July 7, 2026 02:04
9fb1ad2 to
cb65c24
Compare
iainmcgin
marked this pull request as ready for review
July 7, 2026 15:54
noncebased.NewReader and noncebased.NewWriter eagerly allocate
segment-sized buffers: the Reader a ciphertext buffer of
CiphertextSegmentSize+1 bytes, the Writer a plaintext buffer of
PlaintextSegmentSize bytes. With the 1MB key templates that is a
~1 MiB allocation per primitive, regardless of how much data the
stream actually carries, and the decrypting primitive constructs one
reader per candidate key in the keyset. Workloads that process many
small records (for example, envelope encryption of few-hundred-byte
records) therefore pay ~1 MiB of allocation and the associated zeroing
and GC cost per record on each side -- several orders of magnitude
more than the data they process -- and double that on decryption once
a keyset contains a second key after rotation.
Instead, allocate 4 KiB up front (or the full segment buffer size when
that is smaller) and grow exponentially by a factor of 8 as the stream
proves larger, jumping directly to the full size once the next step
would land within a quarter of it. Payloads that fit in the initial
buffer never touch a segment-sized allocation, mid-sized payloads
allocate roughly in proportion to their size, and with the 1MB
templates a segment-sized stream pays three intermediate buffers
(~300 KB of extra allocation) on top of the full-size buffer it always
needed. The factor of 8 rather than doubling is deliberate: cumulative
doubling from 4 KiB costs a segment-sized stream roughly twice its
size in additional allocation, and the power-of-two sequence lands on
exactly the segment size only to reallocate a full-size buffer for the
Reader's one-byte lookahead. The jump-to-full-size rule closes the
same near-miss for other segment sizes. The Writer additionally grows
no smaller than the data still pending in the current Write call, so a
caller handing over a large record in a single Write skips the
intermediate steps entirely and allocates as the eager code did; the
Reader cannot know how much data is pending and always walks the
growth sequence, once, during the first segment.
On the read side, readSegmentGrowing replicates the io.ReadFull
contract exactly: it returns the number of bytes read, io.EOF when no
bytes were read, and io.ErrUnexpectedEOF when the stream ends after
some bytes but before the limit, so last-segment detection is
unchanged. On the write side, segment boundaries are computed from the
recorded segment size rather than the buffer length, and the
ciphertext output is unchanged: the new tests assert that chunked
writes produce ciphertext byte-identical to a single write of the
whole plaintext. The wire format is untouched.
New small-payload benchmarks (300-byte payloads; go1.26, linux/amd64,
medians of 6 runs). The 1MB templates:
before after
Decrypt/AES128_GCM_HKDF_1MB 106 us 1061 KB/op 5.9 us 8.0 KB/op
Decrypt/AES256_GCM_HKDF_1MB 97 us 1061 KB/op 6.0 us 8.1 KB/op
Decrypt/AES256_CTR_HMAC_1MB 111 us 1062 KB/op 7.5 us 8.8 KB/op
Encrypt/AES128_GCM_HKDF_1MB 85 us 1052 KB/op 5.2 us 7.1 KB/op
Encrypt/AES256_GCM_HKDF_1MB 87 us 1052 KB/op 5.2 us 7.2 KB/op
Encrypt/AES256_CTR_HMAC_1MB 101 us 1052 KB/op 6.7 us 8.0 KB/op
The 4KB templates, whose segment size is below the initial buffer
size, are unchanged. Payloads between the initial buffer size and the
segment size allocate roughly in proportion to their size instead of a
full segment: with the AES256_GCM_HKDF_1MB template, an 8 KiB payload
allocates ~50-64 KB per operation instead of ~1 MB. In
BenchmarkEncryptDecrypt (16 MiB streams) times are unchanged within
noise, with +9 allocs/op and ~12% B/op on the 1MB templates from the
growth sequence on each side.
Degenerate configurations change behavior in the direction of failing
cleanly: a Reader or Writer whose FirstCiphertextSegmentOffset lies
outside the valid range previously panicked with a slice-bounds error
on first use; both now return an error. The keyset-based streamingaead
API never produces such offsets, but the exported subtle constructors
only bound the offset from above, so a sufficiently negative offset
could previously reach the panic.
The new tests cover plaintext, ciphertext, and segment sizes just
below, at, and just above the initial buffer size, last-segment
detection at those boundaries, the first-segment offset interaction
including degenerate offsets, readers that return and writers that
receive one byte at a time, truncations at boundaries around the
initial buffer size, and ciphertext byte-identity across write
chunkings.
iainmcgin
force-pushed
the
iain/streaming-lazy-allocate
branch
from
July 20, 2026 16:39
cb65c24 to
0998456
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
noncebased.NewReaderandnoncebased.NewWritereagerly allocate segment-sized buffers: the Reader a ciphertext buffer ofCiphertextSegmentSize+1bytes, the Writer a plaintext buffer ofPlaintextSegmentSizebytes. With the 1MB key templates that is a ~1 MiB allocation per primitive regardless of how much data the stream carries, and the decrypting primitive constructs one reader per candidate key in the keyset. Workloads that process many small records — for example, envelope encryption of few-hundred-byte records — pay ~1 MiB of allocation, zeroing, and GC pressure per record on each side, several orders of magnitude more than the data they process.This change makes both buffers start at 4 KiB (or the full size, when that is smaller) and grow on demand:
Writecall, so a caller handing over a large record in one call allocates exactly as the eager code did.The wire format and ciphertext bytes are unchanged — tests assert that chunked writes produce ciphertext byte-identical to a single write. With 300-byte payloads and the 1MB templates, decryption drops from ~97–111 µs and ~1 MB/op to ~6–7.5 µs and ~8 KB/op, and encryption similarly; 16 MiB streams are unchanged within noise (+9 allocs/op, ~12% B/op from the one-time growth sequence). Payloads between 4 KiB and the segment size now allocate roughly in proportion to their size. Full numbers are in the commit message.
One behavioral edge changes in the direction of failing cleanly: a Reader or Writer whose
FirstCiphertextSegmentOffsetlies outside the valid range previously panicked with a slice-bounds error on first use and now returns an error. Such offsets are not produced by the keyset-based API, but the exported subtle constructors only bound the offset from above, so a negative offset could previously reach the panic.