An allocation is memory claimed from the heap, the pool the garbage collector manages. Every allocation on a hot path — code that runs on nearly every request — adds GC pressure and latency. We had been removing these one at a time from the S3 gateway; this change removed two more, from a place that runs many times per object: the parser for streaming upload headers.
The Problem: One Allocation Per Chunk
An S3 client can upload a body as a streaming, signed payload. Instead of sending the whole object and a signature up front, it sends the object in chunks — typically 64 KiB each — so the server can verify data as it arrives. Each chunk is preceded by a small text header line:
| |
The first field is the chunk size in hexadecimal; the rest are semicolon-
separated fields, of which chunk-signature= carries the per-chunk signature.
Parsing that line is not a one-time cost. A 100 MB object split into 64 KiB chunks arrives as roughly 1,600 chunk headers, each parsed once on the ingest path. The old implementation parsed each line with:
| |
strings.Split allocates a slice header and a backing array to hold the parts,
even when the caller only needs the first two fields. strings.TrimPrefix
allocates again whenever it matches. Two heap allocations per chunk, multiplied
by the chunk count, is pure waste — the data is already in memory as a string,
and all we need are slices of it.
The Fix: Scan and Slice, Don’t Copy
Go strings are immutable and cheap to slice: s[i:j] produces a new string
header that shares the same underlying bytes, with no copy and no heap
allocation. strings.IndexByte finds a byte without allocating. Together they
replace Split and TrimPrefix with a single pass and zero allocations:
| |
The loop walks the same fields in the same order as the old range over
parts[1:], so the semantics are unchanged — including the subtle rule that if
a line somehow carries two chunk-signature= fields, the last one wins.
Principle — prefer slicing to splitting. When you only need offsets into a string, use
IndexByte+ slicing. ReserveSplit(andTrimPrefix) for the cases where you genuinely need independent substrings, and remember they never land on a hot path by accident.
Verifying It
A refactor of a parser must prove two things: nothing broke, and the cost went
down. The first is a table test. We extended TestParseAWSChunkHeader with the
edge cases the old code and the new code must agree on: a line with no ;, a
trailing ;, an empty field (;;), extension fields before and after the
signature, whitespace around the size, multiple signatures, an empty signature,
an empty size, a non-hex size, and a negative size. Both implementations return
the same result for every one.
The second is a microbenchmark — a small benchmark that isolates one
function. BenchmarkParseAWSChunkHeader reports allocations per operation:
| ns/op | B/op | allocs/op | |
|---|---|---|---|
strings.Split + TrimPrefix (before) | ~280–520 | 32 | 1 |
IndexByte + slice (after) | ~58–69 | 0 | 0 |
The allocation count is the number that matters. Driving it to zero means the GC never sees these calls at all, no matter how many chunks an upload contains.
Failure Modes
| Risk | Guard |
|---|---|
| A subtle parse difference changes behavior | Edge-case table test asserts old and new agree on every input |
| Signature length bound is lost in the rewrite | The 64-byte limit is preserved and covered |
| A path inside a rebuilt string leaks out | Fields are slices of the original line, bounded by the existing header-line limit |
| The win is only a microbenchmark artifact | The de-framing benchmarks (BenchmarkAWSChunkedReaderSigned/Unsigned) exercise the full path |
When NOT to Use This
When you need real substrings. Split returns owned, independent strings; a
slice aliases the parent and keeps it alive. If the parsed pieces must outlive
the source line — or be modified — copy them. Here the fields are consumed
immediately and the source line is bounded to 1 KiB, so slicing is both safe and
correct. As always, measure first: reach for this pattern only after a profile
points at the parser.