Skip to content

perf: write fixed-size values directly into the writer buffer#18

Merged
lalinsky merged 1 commit into
mainfrom
perf/encode-direct-buffer-writes
Jul 10, 2026
Merged

perf: write fixed-size values directly into the writer buffer#18
lalinsky merged 1 commit into
mainfrom
perf/encode-direct-buffer-writes

Conversation

@lalinsky

@lalinsky lalinsky commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Problem

Every multi-byte value on the encode path was built in a stack array and handed to writeAll. writeAll calls Writer.write, whose @memcpy receives a runtime length and therefore lowers to a call into compiler_rt's generic byte mover — even for 2 bytes.

Encoding a 4-field struct as a map with field-name keys cost 7 writeAll calls and 7 memcpy calls, to move chunks of 2 to 8 bytes each. Callgrind put 57% of the instructions in those two functions. For comparison, msgpuck's equivalent encode issues zero calls; GCC folds its mp_encode_* inline functions into immediate stores.

writeByte was never part of the problem — it has an inlined buffer-store fast path, which is why single-byte headers and bool fields cost nothing measurable.

Changes

  • reserveArray (utils.zig) hands back a *[len]u8 into the writer's buffer with len comptime-known, so stores through it are immediate stores. Returns null when the value doesn't fit, and callers fall back to writeAll.
  • packHeaderAndInt builds on it: one capacity check, one contiguous store of header + big-endian payload. Every fixed-size int, float, and str/bin/array/map header now goes through it. Previously each of those tested the buffer capacity twice — once in writeByte for the header, once again for the length.
  • packStringLiteral packs a comptime-known string as a single store, with the header byte and string bytes concatenated at comptime. Used for all struct and union map keys derived from field names.
  • packIntValue is removed. Nothing needs it now that headers and payloads are written together.
  • packUnionAsMap and packUnionAsTagged take comptime opts, matching packStructAsMap, so their field names reach packStringLiteral.

The header/payload fusing also fixes a latent correctness wart: against a draining writer whose buffer filled between the two writes, a header could land in a different drain call than its own length. Each is now a single writeAll on the slow path.

Results

Encoding a {u64, i32, f64, bool} struct as a map with field-name keys. Instructions per operation, measured with callgrind (deterministic; perf agrees to within 0.5%):

ReleaseSafe ReleaseFast
before 1001 594
after 249 184
msgpuck (-O3) 62 27

The encode path for that struct now issues no calls at all — no writeAll, no memcpy.

A struct with two runtime-length string values goes from 1056 to 479 (ReleaseSafe) and 467 to 315 (ReleaseFast); its keys are literals now, but the string payloads still need a real memcpy.

Wall-clock numbers are omitted deliberately: the machine I measured on was drifting by ~20% between runs on unmodified code. Instruction counts reproduce exactly.

Notes

  • Verified against a benchmark harness that encodes the identical struct with msgpack.zig and with msgpuck in the same process and asserts the two produce byte-identical output before timing anything. That check passes at every step.
  • zig build test passes, including the msgpack conformance vectors.
  • The decode path is untouched. It's now the slower half — 708 instructions/op against msgpuck's 168 — and unpackIntValue and readFloatValue still readSliceAll into a stack buffer, which is the same pattern this PR eliminated on the write side. Worth a follow-up.

Summary by CodeRabbit

  • New Features
    • Added support for efficiently packing compile-time string literals.
    • Added utilities for writing packed headers and integer values more efficiently.
  • Performance
    • Improved serialization efficiency for arrays, binaries, floats, maps, strings, integers, and unions.
    • Header and payload data can now be written together to reduce fragmented output.
  • Breaking Changes
    • Removed the public packIntValue helper.
    • Union packing options must now be known at compile time.

Encoding built every multi-byte value in a stack array and handed it to
writeAll, whose @memcpy sees a runtime length and lowers to a call into
compiler_rt. Encoding a 4-field struct cost 7 writeAll and 7 memcpy calls
to move chunks of 2 to 8 bytes.

Add reserveArray, which hands back a *[len]u8 into the writer's buffer with
len comptime-known, so stores through it become immediate stores. Build
packHeaderAndInt on top of it and route every header-plus-payload write
through it: fixed-size ints, floats, and the str/bin/array/map headers.
This also makes those writes atomic with respect to a draining writer,
which previously could split a header from its length.

Pack struct and union map keys derived from field names as comptime
literals, so the header byte and the string bytes are a single store.

packIntValue is gone; nothing needs it now that headers and payloads are
written together. packUnionAsMap and packUnionAsTagged take comptime opts,
matching packStructAsMap, so their key names reach packStringLiteral.

Encoding a {u64, i32, f64, bool} struct as a map with field-name keys, on
this machine, instructions per operation:

                  ReleaseSafe   ReleaseFast
    before             1001           594
    after               249           184

The encode path for that struct now issues no calls at all.
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

The serialization code centralizes header-and-integer writes through new writer utilities. A compile-time string literal encoder is exported and used for struct and union metadata keys, while the previous public integer-packing helper is removed.

Packing and literal encoding

Layer / File(s) Summary
Shared header packing
src/utils.zig, src/int.zig, src/float.zig, src/binary.zig, src/array.zig, src/map.zig, src/string.zig
Adds contiguous reservation and combined header/integer serialization, then applies it across numeric, float, binary, array, map, and string headers.
Compile-time literal string API
src/string.zig, src/msgpack.zig
Adds and exports packStringLiteral, while removing the public packIntValue export.
Struct and union metadata keys
src/struct.zig, src/union.zig
Uses compile-time literal packing for field names, prefixes, and tagged-union keys; union options and format selection become comptime-evaluated.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant StructOrUnion
  participant packStringLiteral
  participant Writer
  StructOrUnion->>packStringLiteral: provide comptime field name or prefix
  packStringLiteral->>Writer: write encoded string header, length, and bytes
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly reflects the main performance change: writing fixed-size values directly into the writer buffer.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/encode-direct-buffer-writes

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
src/float.zig (1)

35-44: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Combine the two duplicate switches into one.

Both switches operate on type_info.float.bits with identical ranges (0...32, 33...64). The second switch's else => unreachable is safe today only because the first switch's @compileError guards it at comptime — but the duplication invites divergence if a new float size is added to one switch and not the other.

♻️ Proposed refactor: single switch returning both TargetType and header
-    const TargetType = switch (type_info.float.bits) {
-        0...32 => f32,
-        33...64 => f64,
-        else => `@compileError`("Unsupported float size"),
-    };
-    const header = switch (type_info.float.bits) {
-        0...32 => hdrs.FLOAT32,
-        33...64 => hdrs.FLOAT64,
-        else => unreachable,
-    };
+    const float_info = switch (type_info.float.bits) {
+        0...32 => .{ .target_type = f32, .header = hdrs.FLOAT32 },
+        33...64 => .{ .target_type = f64, .header = hdrs.FLOAT64 },
+        else => `@compileError`("Unsupported float size"),
+    };

Then update the downstream references:

-    const IntType = std.meta.Int(.unsigned, `@bitSizeOf`(TargetType));
-    const int_value = `@as`(IntType, `@bitCast`(`@as`(TargetType, `@floatCast`(value))));
-    return packHeaderAndInt(writer, header, IntType, int_value);
+    const IntType = std.meta.Int(.unsigned, `@bitSizeOf`(float_info.target_type));
+    const int_value = `@as`(IntType, `@bitCast`(`@as`(float_info.target_type, `@floatCast`(value))));
+    return packHeaderAndInt(writer, float_info.header, IntType, int_value);
🤖 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/float.zig` around lines 35 - 44, Combine the duplicate switches on
type_info.float.bits in the relevant declaration into one switch that returns
both TargetType and header for each supported range, while preserving
`@compileError` for unsupported sizes. Update downstream references to use the
combined result’s fields and remove the separate header switch.
src/string.zig (1)

65-93: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

packStringLiteral is well-implemented; consider a direct test for STR8+ paths.

The comptime block correctly mirrors packStringHeader encoding across all four format branches, and the reserveArray/writeAll fallback pattern is sound. The existing struct and union tests only exercise the fixstr path (field names ≤ 31 bytes). A direct unit test covering the STR8 boundary (e.g., a 32-byte comptime string) would protect the untested branches.

🧪 Suggested test for STR8+ coverage
test "packStringLiteral: str8 boundary" {
    const value = "abcdefghijklmnopqrstuvwxyz012345"; // 32 bytes
    var buffer: [64]u8 = undefined;
    var writer = std.Io.Writer.fixed(&buffer);
    try packStringLiteral(&writer, value);
    const expected = [_]u8{ hdrs.STR8, 32 } ++ value[0..].*;
    try std.testing.expectEqualSlices(u8, &expected, writer.buffered());
}
🤖 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/string.zig` around lines 65 - 93, Add a direct unit test for
packStringLiteral using a 32-byte comptime string to exercise the STR8 boundary,
asserting the output contains the STR8 header, length byte, and string contents.
Include the test alongside the existing string tests and use the fixed writer
pattern already used in the suggested coverage.
🤖 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/float.zig`:
- Around line 35-44: Combine the duplicate switches on type_info.float.bits in
the relevant declaration into one switch that returns both TargetType and header
for each supported range, while preserving `@compileError` for unsupported sizes.
Update downstream references to use the combined result’s fields and remove the
separate header switch.

In `@src/string.zig`:
- Around line 65-93: Add a direct unit test for packStringLiteral using a
32-byte comptime string to exercise the STR8 boundary, asserting the output
contains the STR8 header, length byte, and string contents. Include the test
alongside the existing string tests and use the fixed writer pattern already
used in the suggested coverage.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6fc9d5d8-d119-4be1-bee6-7fd6267b5848

📥 Commits

Reviewing files that changed from the base of the PR and between bef6671 and b7749e3.

📒 Files selected for processing (10)
  • src/array.zig
  • src/binary.zig
  • src/float.zig
  • src/int.zig
  • src/map.zig
  • src/msgpack.zig
  • src/string.zig
  • src/struct.zig
  • src/union.zig
  • src/utils.zig

@lalinsky
lalinsky merged commit c7b0dc8 into main Jul 10, 2026
3 checks passed
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