Skip to content

Commit 5a5b1ea

Browse files
committed
perf(zig): table three-byte glyph decoding
1 parent ea63865 commit 5a5b1ea

4 files changed

Lines changed: 46 additions & 52 deletions

File tree

.dirtree-state

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ annotate=[
1515
rust/src/main.rs = Minimal Rust CLI (encode default, -d decode; stdin->stdout) for cross-impl verification
1616
src/container_json.h = Shared pure transport-resistant flat-JSON helpers for the .pbf.json container (C FFI + standalone C)
1717
src/zig/ffi.zig = C ABI (FFI) export surface: all 12 pb_* C functions; root of libprintable_binary.a; keeps C symbols OUT of the importable printable_binary module so static (musl) consumers don't collide
18-
src/zig/printable_binary.zig = Pure Zig codec core; default encode uses four-byte padded internal glyph slots while returning compact UTF-8.
18+
src/zig/printable_binary.zig = Pure Zig codec core; default encode uses four-byte padded slots and decode uses compact direct UTF-8 lookup tables.
1919
test/module_consumer.zig = Test fixture: minimal downstream importer of the printable_binary Zig module (mirrors how difz/blip consume it) for the FFI-symbol-leak test
2020
test/test_container = Container (.pbf.json) CLI round-trip + self-verify test, parameterized by IMPLEMENTATION_TO_TEST (issue #1)
2121
test/test_container_cross = Cross-impl container differential: impl-A container decodes via impl-B (MFIC, issue #1)

PLAN.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,12 @@ maintained_by: agent
3333
capacity; exposing an owned-capacity buffer is an API design, not a
3434
transparent micro-optimization. Curiosity poke: callers must never
3535
observe stale bytes after a shorter subsequent encode/decode.
36-
- [ ] Prototype and measure a cache-resident O(1) decoder for the 28 three-byte
37-
glyphs before replacing the current ~100-byte binary-search table.
38-
Curiosity poke: a 24 KB direct table may cost more cache than five
39-
predictable comparisons.
36+
- [x] Prototype and measure a cache-resident O(1) decoder for the 28 three-byte
37+
glyphs. The compact three-lead-byte table is 24 KiB and improves mixed
38+
decode from ~310 to 330–341 MB/s (~8%), so it replaces the ~100-byte
39+
binary-search table. Curiosity poke: cross-architecture measurements
40+
still decide whether the cache trade holds beyond this x86_64 host.
41+
(2026-07-23 12:22 AM EDT)
4042
- [ ] Prototype a portable SIMD/hybrid classifier only if the measured
4143
scalar loop remains dominant. Use `simdutf` as a technique reference,
4244
not a dependency: its UTF-8 transcoder cannot directly express this

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ The Rust crate also has an in-process codec microbenchmark (`nix develop -c carg
7777
Key optimizations in the Zig core:
7878
- **Pre-allocated buffers**: encode/decode output sized upfront (no growth checks in the hot loop).
7979
- **Flat character map**: a comptime-built contiguous byte buffer (~1.5 KB) replacing 256 scattered fat pointers — fits in L1 cache.
80-
- **O(1) decode lookup**: direct tables for 1-, 2-, and 3-byte UTF-8 sequences instead of an O(log 256) binary search.
80+
- **O(1) decode lookup**: direct tables for 1- and 2-byte UTF-8 sequences plus a compact 24 KiB table for the map's three 3-byte lead-byte planes, replacing the former binary search.
8181
- **No inner decode loop**: a single UTF-8 length check + direct lookup per character.
8282

8383
The C and Lua decoders were tuned in a measured pass: C uses direct 1-/2-byte lookup tables (**1.9×** decode). Lua got two passes — resolving each glyph by its UTF-8 leading-byte length (instead of brute-forcing all four), then writing decoded bytes straight into a LuaJIT `string.buffer` via its FFI `reserve`/`commit` API (no per-byte `string.char`). Together that took Lua **decode from ~8 to ~91 MB/s** — now faster than its own encode (which uses `string.buffer:put`, ~1.2×). Every optimization is benchmarked before and after (hyperfine), and a continuous memory-leak suite (`test/leak_test`) guards the FFI/C paths against regressions.

src/zig/printable_binary.zig

Lines changed: 38 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -139,65 +139,57 @@ const decode_2byte: [32][64]?u8 = blk: {
139139
break :blk table;
140140
};
141141

142-
/// Sorted lookup table for 3-byte UTF-8 sequences (binary search on ~30 entries)
143-
const Decode3Entry = struct {
144-
codepoint: u16,
145-
value: u8,
146-
};
147-
148-
const decode_3byte_count: usize = blk: {
142+
/// Number of distinct UTF-8 lead bytes used by the map's 3-byte glyphs.
143+
/// The current map uses E1, E2, and EA, so its direct table occupies 24 KiB.
144+
const decode_3byte_lead_count: usize = blk: {
145+
var seen = [_]bool{false} ** 256;
149146
var count: usize = 0;
150-
for (0..256) |i| {
151-
if (character_map[i].len == 3) count += 1;
147+
for (character_map) |glyph| {
148+
if (glyph.len == 3 and !seen[glyph[0]]) {
149+
seen[glyph[0]] = true;
150+
count += 1;
151+
}
152152
}
153153
break :blk count;
154154
};
155155

156-
const decode_3byte_table: [decode_3byte_count]Decode3Entry = blk: {
157-
@setEvalBranchQuota(100000);
158-
var entries: [decode_3byte_count]Decode3Entry = undefined;
159-
var idx: usize = 0;
160-
for (0..256) |i| {
161-
if (character_map[i].len == 3) {
162-
const b = character_map[i];
163-
const cp: u16 = (@as(u16, b[0] & 0x0F) << 12) |
164-
(@as(u16, b[1] & 0x3F) << 6) |
165-
@as(u16, b[2] & 0x3F);
166-
entries[idx] = .{ .codepoint = cp, .value = @intCast(i) };
167-
idx += 1;
156+
/// Maps a 3-byte UTF-8 lead byte to its compact table index, if present.
157+
const decode_3byte_lead_index: [256]?u8 = blk: {
158+
var indices = [_]?u8{null} ** 256;
159+
var next: u8 = 0;
160+
for (character_map) |glyph| {
161+
if (glyph.len == 3 and indices[glyph[0]] == null) {
162+
indices[glyph[0]] = next;
163+
next += 1;
164+
}
165+
}
166+
break :blk indices;
167+
};
168+
169+
/// Direct O(1) lookup for 3-byte glyphs, compacted by actual lead bytes.
170+
/// 0x100 is an out-of-range sentinel, avoiding a collision with source byte FF.
171+
const decode_3byte: [decode_3byte_lead_count][64][64]u16 = blk: {
172+
@setEvalBranchQuota(300000);
173+
var table: [decode_3byte_lead_count][64][64]u16 = undefined;
174+
for (0..decode_3byte_lead_count) |lead| {
175+
for (0..64) |second| {
176+
for (0..64) |third| table[lead][second][third] = 0x100;
168177
}
169178
}
170-
// Insertion sort by codepoint
171-
for (0..decode_3byte_count) |i| {
172-
var j = i;
173-
while (j > 0 and entries[j].codepoint < entries[j - 1].codepoint) {
174-
const tmp = entries[j];
175-
entries[j] = entries[j - 1];
176-
entries[j - 1] = tmp;
177-
j -= 1;
179+
for (character_map, 0..) |glyph, i| {
180+
if (glyph.len == 3) {
181+
const lead = decode_3byte_lead_index[glyph[0]].?;
182+
table[lead][glyph[1] & 0x3F][glyph[2] & 0x3F] = @intCast(i);
178183
}
179184
}
180-
break :blk entries;
185+
break :blk table;
181186
};
182187

183188
fn decode3ByteLookup(bytes: []const u8) ?u8 {
184189
if (bytes.len < 3) return null;
185-
const cp: u16 = (@as(u16, bytes[0] & 0x0F) << 12) |
186-
(@as(u16, bytes[1] & 0x3F) << 6) |
187-
@as(u16, bytes[2] & 0x3F);
188-
var left: usize = 0;
189-
var right: usize = decode_3byte_count;
190-
while (left < right) {
191-
const mid = left + (right - left) / 2;
192-
if (decode_3byte_table[mid].codepoint == cp) {
193-
return decode_3byte_table[mid].value;
194-
} else if (decode_3byte_table[mid].codepoint < cp) {
195-
left = mid + 1;
196-
} else {
197-
right = mid;
198-
}
199-
}
200-
return null;
190+
const lead = decode_3byte_lead_index[bytes[0]] orelse return null;
191+
const value = decode_3byte[lead][bytes[1] & 0x3F][bytes[2] & 0x3F];
192+
return if (value == 0x100) null else @intCast(value);
201193
}
202194

203195
/// Encoding options

0 commit comments

Comments
 (0)