From 00ac8d03729897aba1673cc9a6ba1768ce142012 Mon Sep 17 00:00:00 2001 From: Lukas Lalinsky Date: Tue, 13 Oct 2026 10:38:40 +0200 Subject: [PATCH] perf: mark the writer noalias in the writing helpers The writer's buffer is a []u8, and a byte store may alias anything, including the writer struct itself. So after every byte written into the buffer, the compiler has to reload end, buffer.ptr and buffer.len before the next write, and a run of small writes becomes a chain of dependent loads and stores through memory. noalias on writeByte, writeAll, packHeaderAndInt and packStringLiteral tells it that the stores into the buffer leave the writer alone. These are leaves that never hand the writer to other code, so the promise is easy to keep. The functions that do pass the writer on, such as packAny, which can call a user's msgpackWrite, are left as they are. Cycles per encode, ReleaseFast, perf stat: case before after order 343 257 -25% struct with 8 fields, 4 nested items ints 8864 8939 flat array of 1000 mixed-size u32 The instruction count barely moves (804 -> 786 for order); the saving is in the dependency chain. The int array is one call per element, so there is no run of writes for it to help. --- CHANGELOG.md | 1 + src/string.zig | 2 +- src/utils.zig | 13 ++++++++++--- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index affaffe..3daab89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Encoding writes straight into the writer's buffer when there is room, and keeps the drain path out of line, which is about 13% cheaper on a typical struct and 37% on an array of integers +- Encoding a struct is about 25% cheaper again, by telling the compiler that writing into the buffer cannot change the writer itself ## [0.9.0] - 2026-09-05 diff --git a/src/string.zig b/src/string.zig index adb8b8b..a178686 100644 --- a/src/string.zig +++ b/src/string.zig @@ -67,7 +67,7 @@ pub fn packString(writer: *std.Io.Writer, value_or_maybe_null: ?[]const u8) !voi /// Packs a string whose contents are known at comptime, such as a struct field /// name used as a map key. The header and the bytes are one contiguous store of /// comptime-known length, so this compiles to immediate stores with no `memcpy`. -pub fn packStringLiteral(writer: *std.Io.Writer, comptime value: []const u8) !void { +pub fn packStringLiteral(noalias writer: *std.Io.Writer, comptime value: []const u8) !void { const bytes = comptime blk: { if (value.len <= hdrs.FIXSTR_MAX - hdrs.FIXSTR_MIN) { break :blk [_]u8{hdrs.FIXSTR_MIN + @as(u8, value.len)} ++ value[0..].*; diff --git a/src/utils.zig b/src/utils.zig index b7d5f1b..8dc7b01 100644 --- a/src/utils.zig +++ b/src/utils.zig @@ -24,7 +24,7 @@ test isOptional { /// Like `Writer.writeByte`, but with the drain moved out of line, which keeps /// the function small enough to inline into every caller. -pub fn writeByte(writer: *std.Io.Writer, byte: u8) !void { +pub fn writeByte(noalias writer: *std.Io.Writer, byte: u8) !void { if (writer.end < writer.buffer.len) { @branchHint(.likely); writer.buffer[writer.end] = byte; @@ -38,7 +38,14 @@ pub fn writeByte(writer: *std.Io.Writer, byte: u8) !void { /// `Writer.writeAll` otherwise. `Writer.writeAll` loops over `write`, so the /// copy goes through an out-of-line `memcpy` even for a few bytes; checking for /// room first lets the copy inline. -pub fn writeAll(writer: *std.Io.Writer, bytes: []const u8) !void { +/// +/// The writer is `noalias`, here and in the other helpers that store into its +/// buffer. Without it, the compiler has to assume that a byte stored into the +/// buffer may have changed the writer's own fields, and reloads `end` and the +/// buffer after every write, which turns a run of small writes into a chain of +/// dependent loads and stores. Nothing may change the writer through another +/// pointer during the call. +pub fn writeAll(noalias writer: *std.Io.Writer, bytes: []const u8) !void { if (writer.end + bytes.len <= writer.buffer.len) { @branchHint(.likely); @memcpy(writer.buffer[writer.end..][0..bytes.len], bytes); @@ -92,7 +99,7 @@ pub inline fn eqlLiteral(comptime name: []const u8, value: []const u8) bool { /// Writes a header byte followed by a big-endian `T`, as a single contiguous /// store when the writer's buffer has room, and as a single `writeAll` /// otherwise. Never splits the header from its payload across a drain. -pub fn packHeaderAndInt(writer: *std.Io.Writer, header: u8, comptime T: type, value: T) !void { +pub fn packHeaderAndInt(noalias writer: *std.Io.Writer, header: u8, comptime T: type, value: T) !void { const len = 1 + @sizeOf(T); if (writer.end + len <= writer.buffer.len) { @branchHint(.likely);