Skip to content

streamingaead: lazily allocate segment buffers in Reader and Writer - #64

Open
iainmcgin wants to merge 1 commit into
tink-crypto:mainfrom
iainmcgin:iain/streaming-lazy-allocate
Open

streamingaead: lazily allocate segment buffers in Reader and Writer#64
iainmcgin wants to merge 1 commit into
tink-crypto:mainfrom
iainmcgin:iain/streaming-lazy-allocate

Conversation

@iainmcgin

Copy link
Copy Markdown

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 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:

  • Growth multiplies the buffer by 8, and jumps directly to the full size once a step would reach at least three quarters of it. Plain doubling was measured and rejected: cumulative doubling costs segment-sized streams roughly twice their size in extra allocation, and its power-of-two sequence lands on exactly the segment size only to reallocate once more for the Reader's one-byte lookahead.
  • The Writer additionally never grows smaller than the buffered data plus what remains of the current Write call, so a caller handing over a large record in one call allocates exactly as the eager code did.
  • Growth state is per-primitive, not per-segment: a large stream walks the sequence once during its first segment and then reuses the full-size buffer.

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 FirstCiphertextSegmentOffset lies 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.

@iainmcgin
iainmcgin force-pushed the iain/streaming-lazy-allocate branch from 9fb1ad2 to cb65c24 Compare July 7, 2026 02:04
@iainmcgin
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
iainmcgin force-pushed the iain/streaming-lazy-allocate branch from cb65c24 to 0998456 Compare July 20, 2026 16:39
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.

1 participant