perf: read fixed-size values and map keys without copying#19
Conversation
Decoding read every fixed-size value into a stack array with readSliceAll,
and every map key into a 256 byte stack buffer, before looking at either.
Both go through a generic memcpy. Decoding a 4-field struct made 7 memcpy
calls to move chunks of 2 to 8 bytes.
Read fixed-size values with Reader.takeInt, which loads straight out of the
reader's buffer. Add unpackStringBorrowed, which returns a map key as a
slice of the reader's buffer rather than a copy, since a key is compared and
discarded. It resolves a buffered fixstr without parsing a header at all,
which covers every field name of 31 characters or fewer. Compare keys with
eqlLiteral, whose length is comptime-known, so a compare lowers to inline
compares instead of a call to mem.eql.
Both Reader.takeInt and Reader.take rebase, and defaultRebase asserts the
reader's buffer can hold what was asked for. A reader whose buffer is
smaller than the value being decoded would panic, and a network stream can
easily be configured that way. Check the capacity and return
error.ReaderBufferTooSmall instead. readSliceAll never had this problem
because it reads into the destination, so this hazard arrived with the
change; the tests cover it.
unpackUnionAsMap and unpackUnionAsTagged take comptime opts, matching what
the pack side already needed, so their key names reach eqlLiteral. The
[256]u8 key buffers in packStructAsMap and both union decoders are gone.
Decoding a {u64, i32, f64, bool} struct as a map with field-name keys, on
this machine, instructions per operation:
ReleaseSafe ReleaseFast
before 1009 708
after 576 362
The decode path for that struct issues no memcpy calls.
📝 WalkthroughWalkthroughThe changes add buffer-safe integer decoding, borrowed string unpacking, and shared literal comparison helpers. Struct and union key decoding adopt borrowed strings, union options become comptime parameters, and streaming tests cover trickle reads and undersized buffers. ChangesReader decoding and key matching
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant Reader
participant unpackStringBorrowed
participant StructOrUnionDecoder
Reader->>unpackStringBorrowed: provide buffered string payload
unpackStringBorrowed->>StructOrUnionDecoder: return borrowed field-name slice
StructOrUnionDecoder->>StructOrUnionDecoder: perform literal or prefix match
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/union.zig (1)
239-246: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueConsider combining identical switch arms.
The
.field_nameand.field_name_prefixarms in theopts.tag_valueswitch are identical — both callunpackStringBorrowedand store tounion_field_name. Theprefixpayload is not needed here (it's used later in theis_matchswitch), so the arms can be merged.♻️ Proposed refactor
.field_name => { const field_name = try unpackStringBorrowed(reader); union_field_name = field_name; }, - .field_name_prefix => { - const field_name = try unpackStringBorrowed(reader); - union_field_name = field_name; - }, + .field_name_prefix => { + const field_name = try unpackStringBorrowed(reader); + union_field_name = field_name; + },Or combined:
- .field_name => { - const field_name = try unpackStringBorrowed(reader); - union_field_name = field_name; - }, - .field_name_prefix => { - const field_name = try unpackStringBorrowed(reader); - union_field_name = field_name; - }, + .field_name, .field_name_prefix => { + const field_name = try unpackStringBorrowed(reader); + union_field_name = field_name; + },🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/union.zig` around lines 239 - 246, In the opts.tag_value switch, merge the identical .field_name and .field_name_prefix arms into a single switch arm that calls unpackStringBorrowed(reader) and assigns the result to union_field_name; retain the field_name_prefix value handling in the later is_match switch.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@src/union.zig`:
- Around line 239-246: In the opts.tag_value switch, merge the identical
.field_name and .field_name_prefix arms into a single switch arm that calls
unpackStringBorrowed(reader) and assigns the result to union_field_name; retain
the field_name_prefix value handling in the later is_match switch.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: b86ca445-da92-4b7a-a93b-c3010a392a7a
📒 Files selected for processing (7)
src/float.zigsrc/int.zigsrc/msgpack.zigsrc/string.zigsrc/struct.zigsrc/union.zigsrc/utils.zig
Follow-up to #18, which did the same for the encode path.
Problem
Decoding read every fixed-size value into a stack array via
readSliceAll, and every map key into a[256]u8stack buffer, before looking at either. Both route throughcompiler_rt's genericmemcpy. Decoding a 4-field struct made 7memcpycalls to move chunks of 2 to 8 bytes, plus 4 calls tounpackStringHeaderto parse what is almost always a single fixstr byte.Changes
Reader.takeInt, which ispub inlineand loads straight out of the reader's buffer. This replacesreadSliceAllinto a stack array inunpackIntValueandreadFloatValue.unpackStringBorrowedreturns a map key as a slice of the reader's buffer instead of a copy. A key is compared and discarded, so it never outlives the next read. Its fast path resolves a buffered fixstr without parsing a header at all, which covers every field name of 31 characters or fewer — i.e. essentially all of them, since fixstr encodes lengths 0–31.eqlLiteralcompares a key against a comptime-known field name. The length test is against a constant and the byte compare that follows has a comptime length, so it lowers to inline compares rather than a call tomem.eql.unpackUnionAsMapandunpackUnionAsTaggednow takecomptime opts, matching whatpackUnionAsMap/packUnionAsTaggedalready needed in perf: write fixed-size values directly into the writer buffer #18, so their key names reacheqlLiteral.[256]u8key buffers inpackStructAsMapand both union decoders are gone. Nothing copies a key anymore.A panic this introduced, and the fix
Reader.takeIntandReader.takeboth gofill → fillUnbuffered → rebase, anddefaultRebaseends with:So a reader whose buffer is smaller than the value being decoded panics (Debug/ReleaseSafe) or hits UB (ReleaseFast). A network stream can easily be configured that way. The old
readSliceAllnever had this problem, because it drains the buffered bytes and then reads directly into the destination slice, needing no reader capacity at all — so the hazard arrived with this change.Rather than copy as a fallback, both sites check capacity and return
error.ReaderBufferTooSmall, marked@branchHint(.unlikely).error.ReaderBufferTooSmallis a new error in the decode error set, which is user-visible.The guards cost ~6 instructions per struct decode. Note that
utils.takeIntmust beinline:std.Io.Reader.takeIntis itselfpub inline, and wrapping it in an ordinary function reintroduced the very call frame std was avoiding — ReleaseSafe decode went from 574 to 669 Ir/op until I marked it.Tests
Three new tests in
src/msgpack.zig, built on aTrickleReaderthat serves one byte per fill from a caller-sized buffer. This matters: every existing test usesReader.fixed, whose buffer is the whole payload, so it is always large enough and no value ever straddles a fill. That is exactly why the panic was invisible to the suite.error.ReaderBufferTooSmallrather than panickingstr8rather thanfixstr; a 16-byte reader buffer errors, a 64-byte one decodes. This also covers thestr8key path inpackStringLiteral, which nothing else exercised.I verified the tests fail without the fix: with the guards removed, the too-small-buffer test aborts with
SIGABRTinsidedefaultRebase.Results
Instructions per operation, callgrind (deterministic). Encode is untouched at 184/249.
-O3)The decode path for the numeric struct now issues no
memcpycalls. The gap to msgpuck narrows from 4.2× to 2.2× on the numeric struct, and to 1.7× on the message struct, where msgpuck is doing the same two string copies.Verified against a harness that decodes the identical payload with msgpack.zig and msgpuck in one process, asserting the encoders agree byte-for-byte before measuring. Wall-clock numbers are omitted: the machine drifted ~20% between runs on unmodified code. Instruction counts reproduce exactly.
Things considered and rejected
std.StaticStringMapfor key dispatch. It stores keys as runtime[]const u8in a table and compares them byte-at-a-time with a runtime length. The unrolledeqlLiteralcompare is strictly less work for a comptime-known field list. Its length-bucketing idea would matter for very wide structs, where a comptimeswitchon the fixstr header byte (which is0xa0 + len) would give the same bucketing with no runtime table.takeArray/takeSlicehelpers. Measured:takeArraywas 84 Ir/op worse thanstd.Io.Reader.takeInton the message decode, andtakeSlicemade no difference against plainreader.take. Both deleted.Summary by CodeRabbit
New Features
Bug Fixes
ReaderBufferTooSmallsafely when buffers cannot hold decoded values, without panicking.