From 524c652e0b90909cf4227d520359065e79bbbf66 Mon Sep 17 00:00:00 2001 From: Lukas Lalinsky Date: Fri, 16 Oct 2026 22:01:32 +0200 Subject: [PATCH 1/2] perf: write straight into the writer buffer, with a noalias writer Writer.writeAll loops over write, so every key and every string run went through an out-of-line memcpy, even for a few bytes. writeByte and writeAll now copy into the buffer directly when it has room, and leave the drain to a cold out-of-line function. Both take the writer as noalias. Without it, a byte stored into the buffer may have changed the writer's own fields, so end and the buffer are reloaded after every write. Median ns per encode, ReleaseFast, i7-6700: twitter 197309 -> 145861 (-26%) canada 4998434 -> 4796230 (-4%, std's float formatting dominates) small 218 -> 187 (-14%) --- CHANGELOG.md | 3 ++ src/encode.zig | 118 +++++++++++++++++++++++++++++++++---------------- 2 files changed, 82 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa4c349..f295941 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Support for Zig 0.17, alongside 0.16 - Decoding single-item pointers (`*T`, `?*T`), which makes recursive types work +### Changed +- Encoding writes straight into the writer's buffer when there is room, with the writer marked `noalias`, which makes encoding about 26% faster on a string-heavy document and 14% on a small message + ### Fixed - Structs with a few dozen fields failing to compile with "evaluation exceeded 1000 backwards branches" diff --git a/src/encode.zig b/src/encode.zig index d707561..43759c9 100644 --- a/src/encode.zig +++ b/src/encode.zig @@ -30,28 +30,28 @@ pub const Encoder = struct { } pub fn beginObject(self: Encoder) Writer.Error!void { - return self.writer.writeByte('{'); + return writeByte(self.writer, '{'); } pub fn endObject(self: Encoder) Writer.Error!void { - return self.writer.writeByte('}'); + return writeByte(self.writer, '}'); } pub fn beginArray(self: Encoder) Writer.Error!void { - return self.writer.writeByte('['); + return writeByte(self.writer, '['); } pub fn endArray(self: Encoder) Writer.Error!void { - return self.writer.writeByte(']'); + return writeByte(self.writer, ']'); } pub fn comma(self: Encoder) Writer.Error!void { - return self.writer.writeByte(','); + return writeByte(self.writer, ','); } pub fn writeKey(self: Encoder, key: []const u8) Writer.Error!void { try writeString(key, self.writer); - try self.writer.writeByte(':'); + try writeByte(self.writer, ':'); } pub fn writeStringValue(self: Encoder, s: []const u8) Writer.Error!void { @@ -70,13 +70,13 @@ pub fn encodeValue( } switch (@typeInfo(T)) { - .void, .null => try w.writeAll("null"), - .bool => try w.writeAll(if (value) "true" else "false"), + .void, .null => try writeAll(w, "null"), + .bool => try writeAll(w, if (value) "true" else "false"), .int, .comptime_int => try writeInt(T, value, w), .float, .comptime_float => try writeFloat(T, value, w, opts), .optional => { if (value) |payload| return encodeValue(@TypeOf(payload), payload, w, opts); - try w.writeAll("null"); + try writeAll(w, "null"); }, .@"enum" => try writeString(@tagName(value), w), .enum_literal => try writeString(@tagName(value), w), @@ -105,12 +105,12 @@ pub fn encodeValue( } fn encodeSlice(comptime Child: type, items: []const Child, w: *Writer, opts: EncodeOptions) EncodeError!void { - try w.writeByte('['); + try writeByte(w, '['); for (items, 0..) |item, i| { - if (i != 0) try w.writeByte(','); + if (i != 0) try writeByte(w, ','); try encodeValue(Child, item, w, opts); } - try w.writeByte(']'); + try writeByte(w, ']'); } fn encodeUnion(comptime T: type, value: T, w: *Writer, opts: EncodeOptions) EncodeError!void { @@ -119,14 +119,14 @@ fn encodeUnion(comptime T: type, value: T, w: *Writer, opts: EncodeOptions) Enco switch (comptime json.unionFormat(T)) { .as_object => { - try w.writeByte('{'); + try writeByte(w, '{'); switch (value) { inline else => |payload, tag| { - try w.writeAll(comptime "\"" ++ escapedLiteral(@tagName(tag)) ++ "\":"); + try writeAll(w, comptime "\"" ++ escapedLiteral(@tagName(tag)) ++ "\":"); try encodeValue(@TypeOf(payload), payload, w, opts); }, } - try w.writeByte('}'); + try writeByte(w, '}'); }, .as_tagged => |tagged| { switch (value) { @@ -136,12 +136,12 @@ fn encodeUnion(comptime T: type, value: T, w: *Writer, opts: EncodeOptions) Enco @compileError("json: as_tagged needs a struct or void payload, but " ++ @typeName(T) ++ "." ++ @tagName(tag) ++ " is " ++ @typeName(Payload)); } - try w.writeAll(comptime "{\"" ++ escapedLiteral(tagged.tag_field) ++ + try writeAll(w, comptime "{\"" ++ escapedLiteral(tagged.tag_field) ++ "\":\"" ++ escapedLiteral(@tagName(tag)) ++ "\""); // The tag is always present, so every hoisted field is // preceded by a comma - no first-member bookkeeping. if (Payload != void) try encodeStructFieldsAfter(Payload, payload, w, opts); - try w.writeByte('}'); + try writeByte(w, '}'); }, } }, @@ -158,7 +158,7 @@ fn encodeStructFieldsAfter(comptime T: type, value: T, w: *Writer, opts: EncodeO @typeInfo(f.type) == .optional and @field(value, f.name) == null; if (!omit) { - try w.writeAll(comptime ",\"" ++ escapedLiteral(fieldKey(T, f.name)) ++ "\":"); + try writeAll(w, comptime ",\"" ++ escapedLiteral(fieldKey(T, f.name)) ++ "\":"); try encodeValue(f.type, @field(value, f.name), w, opts); } } @@ -228,11 +228,11 @@ fn encodeStruct(comptime T: type, value: T, w: *Writer, opts: EncodeOptions) Enc const fields = comptime compat.structFields(T); const options = comptime json.structOptions(T); - try w.writeByte('{'); + try writeByte(w, '{'); if (comptime !canOmitFields(T)) { inline for (fields, 0..) |f, i| { const prefix = comptime (if (i == 0) "\"" else ",\"") ++ escapedLiteral(fieldKey(T, f.name)) ++ "\":"; - try w.writeAll(prefix); + try writeAll(w, prefix); try encodeValue(f.type, @field(value, f.name), w, opts); } } else { @@ -242,14 +242,14 @@ fn encodeStruct(comptime T: type, value: T, w: *Writer, opts: EncodeOptions) Enc @typeInfo(f.type) == .optional and @field(value, f.name) == null; if (!omit) { - if (!first) try w.writeByte(','); + if (!first) try writeByte(w, ','); first = false; - try w.writeAll(comptime "\"" ++ escapedLiteral(fieldKey(T, f.name)) ++ "\":"); + try writeAll(w, comptime "\"" ++ escapedLiteral(fieldKey(T, f.name)) ++ "\":"); try encodeValue(f.type, @field(value, f.name), w, opts); } } } - try w.writeByte('}'); + try writeByte(w, '}'); } /// Formats at the value's own precision. Widening to f64 first would lose @@ -261,7 +261,7 @@ fn writeFloat(comptime T: type, value: T, w: *Writer, opts: EncodeOptions) Encod const v: Float = value; if (!std.math.isFinite(v)) { switch (opts.non_finite) { - .null_value => return w.writeAll("null"), + .null_value => return writeAll(w, "null"), .fail => return error.NonFiniteFloat, } } @@ -278,7 +278,47 @@ fn writeFloat(comptime T: type, value: T, w: *Writer, opts: EncodeOptions) Encod // `bufferSize` is defined as the capacity this mode and type need. error.BufferTooSmall => unreachable, }; - try w.writeAll(rendered); + try writeAll(w, rendered); +} + +/// Like `Writer.writeByte`, but with the drain moved out of line, which keeps +/// the function small enough to inline into every caller. +fn writeByte(noalias w: *Writer, byte: u8) Writer.Error!void { + if (w.end < w.buffer.len) { + @branchHint(.likely); + w.buffer[w.end] = byte; + w.end += 1; + return; + } + return writeAllSlow(w, &.{byte}); +} + +/// Copies `bytes` into the writer's buffer when they fit, and falls back to +/// `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. +/// +/// The writer is `noalias`, here and in `writeByte`. 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. +fn writeAll(noalias w: *Writer, bytes: []const u8) Writer.Error!void { + if (w.end + bytes.len <= w.buffer.len) { + @branchHint(.likely); + @memcpy(w.buffer[w.end..][0..bytes.len], bytes); + w.end += bytes.len; + return; + } + return writeAllSlow(w, bytes); +} + +/// The fallback of the helpers above, for when the bytes do not fit the +/// buffer. Kept out of line so the fast paths stay small enough to inline. +noinline fn writeAllSlow(w: *Writer, bytes: []const u8) Writer.Error!void { + @branchHint(.cold); + return w.writeAll(bytes); } const digits2: [200]u8 = blk: { @@ -320,14 +360,14 @@ fn writeInt(comptime T: type, value: T, w: *Writer) Writer.Error!void { i -= 1; buf[i] = '-'; } - try w.writeAll(buf[i..]); + try writeAll(w, buf[i..]); } /// Escapes `"`, `\` and the C0 control characters; everything else, high bytes /// included, passes through untouched. Chunks with nothing to escape are /// emitted as a single run rather than byte by byte. pub fn writeString(str: []const u8, w: *Writer) Writer.Error!void { - try w.writeByte('"'); + try writeByte(w, '"'); const V = @Vector(16, u8); const quote: V = @splat('"'); @@ -344,7 +384,7 @@ pub fn writeString(str: []const u8, w: *Writer) Writer.Error!void { continue; } const at = i + @ctz(hits); - try w.writeAll(str[cursor..at]); + try writeAll(w, str[cursor..at]); try writeEscape(str[at], w); cursor = at + 1; i = at + 1; @@ -352,26 +392,26 @@ pub fn writeString(str: []const u8, w: *Writer) Writer.Error!void { while (i < str.len) : (i += 1) { const c = str[i]; if (c == '"' or c == '\\' or c < 0x20) { - try w.writeAll(str[cursor..i]); + try writeAll(w, str[cursor..i]); try writeEscape(c, w); cursor = i + 1; } } - try w.writeAll(str[cursor..]); - try w.writeByte('"'); + try writeAll(w, str[cursor..]); + try writeByte(w, '"'); } fn writeEscape(c: u8, w: *Writer) Writer.Error!void { switch (c) { - '\\' => try w.writeAll("\\\\"), - '"' => try w.writeAll("\\\""), - 0x08 => try w.writeAll("\\b"), - 0x0c => try w.writeAll("\\f"), - '\n' => try w.writeAll("\\n"), - '\r' => try w.writeAll("\\r"), - '\t' => try w.writeAll("\\t"), + '\\' => try writeAll(w, "\\\\"), + '"' => try writeAll(w, "\\\""), + 0x08 => try writeAll(w, "\\b"), + 0x0c => try writeAll(w, "\\f"), + '\n' => try writeAll(w, "\\n"), + '\r' => try writeAll(w, "\\r"), + '\t' => try writeAll(w, "\\t"), else => { - try w.writeAll("\\u"); + try writeAll(w, "\\u"); try w.printInt(c, 16, .lower, .{ .width = 4, .fill = '0' }); }, } From fcf5163c4e6469d8d7dce0df4473666fabf80d40 Mon Sep 17 00:00:00 2001 From: Lukas Lalinsky Date: Fri, 16 Oct 2026 22:11:22 +0200 Subject: [PATCH 2/2] perf: write integers in place and escape short strings inline Both from zt (#41, #44): writeInt formatted into a temporary buffer and copied that out, a memcpy call of variable length for every number. When the buffer has room for the longest value of the type, count the digits and write them in place. Strings shorter than 16 bytes never reach the vector loop, but still paid for a call to writeString, which set up the vector constants and sent the writer's state through memory. Escape them inline with a lookup table and keep the vector scan out of line for longer strings. The string, integer, float and escape writers take the writer as noalias too, like writeAll. Median ns per encode, ReleaseFast, i7-6700, against the previous commit: twitter 145224 -> 138658 (-4.5%) canada 4777284 -> 4744561 (-0.7%) small 186 -> 171 (-8.3%) Instructions per encode drop by about 5% on twitter and 4% on small, so this isn't code layout moving around. --- CHANGELOG.md | 1 + src/encode.zig | 91 ++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 67 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f295941..18fa014 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,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, with the writer marked `noalias`, which makes encoding about 26% faster on a string-heavy document and 14% on a small message +- Integers are written straight into the writer's buffer, and strings shorter than 16 bytes are escaped inline, about another 5% on a string-heavy document and 8% on a small message ### Fixed - Structs with a few dozen fields failing to compile with "evaluation exceeded 1000 backwards branches" diff --git a/src/encode.zig b/src/encode.zig index 43759c9..d740af4 100644 --- a/src/encode.zig +++ b/src/encode.zig @@ -256,7 +256,7 @@ fn encodeStruct(comptime T: type, value: T, w: *Writer, opts: EncodeOptions) Enc /// digits for f80/f128, and would turn a finite value that exceeds f64's range /// into an infinity - reported as `null` or an error for a number that is /// perfectly representable in its own type. -fn writeFloat(comptime T: type, value: T, w: *Writer, opts: EncodeOptions) EncodeError!void { +fn writeFloat(comptime T: type, value: T, noalias w: *Writer, opts: EncodeOptions) EncodeError!void { const Float = if (T == comptime_float) f64 else T; const v: Float = value; if (!std.math.isFinite(v)) { @@ -332,41 +332,74 @@ const digits2: [200]u8 = blk: { /// Two decimal digits per iteration into a stack buffer, written back to /// front. Same output as `print("{}")` without the formatting machinery. -fn writeInt(comptime T: type, value: T, w: *Writer) Writer.Error!void { +/// Writes an integer in decimal. When the buffer has room for the longest +/// value of the type, the digits are written straight into it; formatting +/// into a temporary buffer and copying that out is a `memcpy` call per number. +fn writeInt(comptime T: type, value: T, noalias w: *Writer) Writer.Error!void { const Int = if (T == comptime_int) i64 else T; - const bits = @typeInfo(Int).int.bits; - if (bits > 64) return w.print("{}", .{value}); + const info = @typeInfo(Int).int; + if (info.bits > 64) return w.print("{}", .{value}); const v: Int = value; const neg = v < 0; - var x: u64 = if (neg) @intCast(-@as(i128, v)) else @intCast(v); + const max_len = comptime countDigits(@abs(std.math.maxInt(Int))) + @intFromBool(info.signedness == .signed); + if (w.buffer.len - w.end < max_len) { + @branchHint(.unlikely); + return w.printInt(v, 10, .lower, .{}); + } - var buf: [21]u8 = undefined; - var i: usize = buf.len; - while (x >= 100) { - const r: usize = @intCast(x % 100); - x /= 100; + var x: u64 = @abs(v); + const len = countDigits(x) + @intFromBool(neg); + const dest = w.buffer[w.end..][0..len]; + if (neg) dest[0] = '-'; + var i = len; + while (x >= 100) : (x /= 100) { i -= 2; - buf[i..][0..2].* = digits2[r * 2 ..][0..2].*; + dest[i..][0..2].* = digits2[@as(usize, @intCast(x % 100)) * 2 ..][0..2].*; } - if (x >= 10) { - i -= 2; - buf[i..][0..2].* = digits2[@as(usize, @intCast(x)) * 2 ..][0..2].*; + if (x < 10) { + dest[i - 1] = '0' + @as(u8, @intCast(x)); } else { - i -= 1; - buf[i] = '0' + @as(u8, @intCast(x)); + dest[i - 2 ..][0..2].* = digits2[@as(usize, @intCast(x)) * 2 ..][0..2].*; } - if (neg) { - i -= 1; - buf[i] = '-'; + w.end += len; +} + +fn countDigits(value: u64) usize { + var count: usize = 1; + var limit: u64 = 10; + while (value >= limit) { + count += 1; + limit = std.math.mul(u64, limit, 10) catch break; } - try writeAll(w, buf[i..]); + return count; } /// Escapes `"`, `\` and the C0 control characters; everything else, high bytes -/// included, passes through untouched. Chunks with nothing to escape are -/// emitted as a single run rather than byte by byte. -pub fn writeString(str: []const u8, w: *Writer) Writer.Error!void { +/// included, passes through untouched. +/// +/// Strings shorter than a vector never reach the vector loop, and most strings +/// in a document are short, so those are escaped inline. A call costs more +/// than the scan: it sets up the vector constants and makes the writer's state +/// go through memory. +pub inline fn writeString(str: []const u8, noalias w: *Writer) Writer.Error!void { + if (str.len >= 16) return writeStringLong(str, w); + + try writeByte(w, '"'); + var cursor: usize = 0; + for (str, 0..) |c, i| { + if (!needs_escape[c]) continue; + try writeAll(w, str[cursor..i]); + try writeEscape(c, w); + cursor = i + 1; + } + try writeAll(w, str[cursor..]); + try writeByte(w, '"'); +} + +/// Chunks with nothing to escape are emitted as a single run rather than byte +/// by byte. +noinline fn writeStringLong(str: []const u8, noalias w: *Writer) Writer.Error!void { try writeByte(w, '"'); const V = @Vector(16, u8); @@ -391,7 +424,7 @@ pub fn writeString(str: []const u8, w: *Writer) Writer.Error!void { } while (i < str.len) : (i += 1) { const c = str[i]; - if (c == '"' or c == '\\' or c < 0x20) { + if (needs_escape[c]) { try writeAll(w, str[cursor..i]); try writeEscape(c, w); cursor = i + 1; @@ -401,7 +434,15 @@ pub fn writeString(str: []const u8, w: *Writer) Writer.Error!void { try writeByte(w, '"'); } -fn writeEscape(c: u8, w: *Writer) Writer.Error!void { +const needs_escape = blk: { + var table: [256]bool = @splat(false); + for (0..0x20) |c| table[c] = true; + table['"'] = true; + table['\\'] = true; + break :blk table; +}; + +fn writeEscape(c: u8, noalias w: *Writer) Writer.Error!void { switch (c) { '\\' => try writeAll(w, "\\\\"), '"' => try writeAll(w, "\\\""),