Skip to content

Commit ea63865

Browse files
committed
perf(zig): use padded map slots for default encode
1 parent 4586abf commit ea63865

3 files changed

Lines changed: 95 additions & 12 deletions

File tree

.dirtree-state

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ ver=1.2
33
annotate=[
44
.mechatron-prime/targets = Exact Nix package and check targets that Mechatron Prime builds from each pushed commit.
55
MEMORIES/WASM benchmarks must stream inputs and APE needs a clean environment = WASI and Cosmopolitan runtime requirements for benchmark adapters.
6-
PLAN.md = Issue #1 plan: decode workflow + printable-binary-file.json container
6+
PLAN.md = Active project plan; records benchmarked default-path optimization and remaining SIMD/decode experiments.
77
bm/benchmark-zig-opt = Benchmarks all available CLI variants, including Rust stdin, WASM, and APE adapters.
88
docs/plans/2026-06-26-printable-binary-file-container-design.md = Design spec: printable-binary-file.json container + web decode workflow (issue #1)
99
elixir/lib/printable_binary.ex = Elixir ~PB compile-time sigil: decodes printable-binary glyphs to raw bytes at compile time (reads character_map.txt via @external_resource); decode/1 runtime helper (whitespace-tolerant)
@@ -15,6 +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.
1819
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
1920
test/test_container = Container (.pbf.json) CLI round-trip + self-verify test, parameterized by IMPLEMENTATION_TO_TEST (issue #1)
2021
test/test_container_cross = Cross-impl container differential: impl-A container decodes via impl-B (MFIC, issue #1)

PLAN.md

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,26 @@ maintained_by: agent
2222

2323
## Deferred performance investigation
2424

25-
- [ ] Benchmark SIMD feasibility for PrintableBinary encode/decode using
26-
`simdutf` as a technique reference (or dependency only if it cleanly
27-
fits). Start with a scalar profile and a reproducible before/after
28-
benchmark; prioritize byte classification, UTF-8 validation, and
29-
variable-width packing only where measurements show a bottleneck.
30-
Curiosity poke: per-byte map lookup plus 1–3-byte output may make gathers
31-
and compaction slower than the scalar table path on real inputs.
25+
- [x] Baseline the deterministic 10 MB mixed-byte core benchmark against Rust,
26+
then implement and measure a four-byte internal glyph slot. Zig began at
27+
236 MB/s encode / 308 MB/s decode; Rust measured 300/462 MB/s alloc/call.
28+
The compact-wire-format-preserving slot plus default-option fast path
29+
reaches 605–631 MB/s encode; decode remains ~310 MB/s. (2026-07-23
30+
12:17 AM EDT)
31+
- [ ] Measure reusable-output ownership only if a consumer needs it. The public
32+
`[]u8` API must shrink before return, whereas Rust's `Vec` retains
33+
capacity; exposing an owned-capacity buffer is an API design, not a
34+
transparent micro-optimization. Curiosity poke: callers must never
35+
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.
40+
- [ ] Prototype a portable SIMD/hybrid classifier only if the measured
41+
scalar loop remains dominant. Use `simdutf` as a technique reference,
42+
not a dependency: its UTF-8 transcoder cannot directly express this
43+
custom byte-to-glyph map. Curiosity poke: 1–3-byte output compaction can
44+
cost more than the scalar table path on mixed data.
3245

3346
## Container honors `--spaces` (legible-markdown containers) — DONE (2026-07-21 EDT)
3447

src/zig/printable_binary.zig

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,19 @@ const flat_map_entries: [256]FlatMapEntry = blk: {
9999
break :blk entries;
100100
};
101101

102+
/// Four-byte internal slots let the default encoder use one fixed-width write
103+
/// per glyph while retaining the compact 1–3 byte PrintableBinary wire format.
104+
/// The final three bytes are allocation-only padding and are excluded on return.
105+
const padded_map_data: [256][4]u8 = blk: {
106+
@setEvalBranchQuota(100000);
107+
var data = [_][4]u8{[_]u8{0} ** 4} ** 256;
108+
for (0..256) |i| {
109+
const glyph = character_map[i];
110+
for (glyph, 0..) |byte, j| data[i][j] = byte;
111+
}
112+
break :blk data;
113+
};
114+
102115
/// Direct O(1) decode lookup for 1-byte UTF-8 sequences
103116
const decode_1byte: [256]?u8 = blk: {
104117
@setEvalBranchQuota(100000);
@@ -365,11 +378,33 @@ fn decodeLookup(bytes: []const u8) ?u8 {
365378
}
366379
}
367380

381+
/// Encode the common option-free case with fixed-width internal writes.
382+
/// Each map glyph is stored in a four-byte slot, avoiding per-glyph variable
383+
/// copies; only the true 1–3 byte length advances the public output.
384+
fn encodeDefault(allocator: std.mem.Allocator, input: []const u8) ![]u8 {
385+
// Three trailing bytes make a four-byte store safe even for the final glyph.
386+
const capacity = try std.math.add(usize, try std.math.mul(usize, input.len, 3), 3);
387+
var result = try allocator.alloc(u8, capacity);
388+
errdefer allocator.free(result);
389+
390+
var pos: usize = 0;
391+
for (input) |byte| {
392+
@memcpy(result[pos..][0..4], &padded_map_data[byte]);
393+
pos += flat_map_entries[byte].len;
394+
}
395+
396+
return allocator.realloc(result, pos);
397+
}
398+
368399
/// Encode binary data to printable UTF-8.
400+
/// The option-free path specializes the dominant CLI/library workload so
401+
/// disabled formatting switches do not branch inside the per-byte loop.
369402
/// Caller owns the returned slice and must free it with the same allocator.
370403
pub fn encode(allocator: std.mem.Allocator, input: []const u8, options: EncodeOptions) ![]u8 {
371-
if (input.len == 0) {
372-
return try allocator.alloc(u8, 0);
404+
if (input.len == 0) return try allocator.alloc(u8, 0);
405+
406+
if (!options.spaces and !options.tabs and !options.crlf and options.preserve_chars.len == 0) {
407+
return encodeDefault(allocator, input);
373408
}
374409

375410
// Pre-allocate worst case: every byte → max 3-byte UTF-8
@@ -408,12 +443,46 @@ pub fn encode(allocator: std.mem.Allocator, input: []const u8, options: EncodeOp
408443
return allocator.realloc(result, pos);
409444
}
410445

446+
/// Decode printable UTF-8 back to binary data.
447+
/// Unrecognized UTF-8 characters pass through unchanged.
448+
/// The normal path has neither literal-space nor whitespace formatting rules;
449+
/// specializing it keeps those option checks out of the UTF-8 character loop.
450+
fn decodeDefault(allocator: std.mem.Allocator, input: []const u8) ![]u8 {
451+
var result = try allocator.alloc(u8, input.len);
452+
errdefer allocator.free(result);
453+
454+
var i: usize = 0;
455+
var pos: usize = 0;
456+
while (i < input.len) {
457+
const seq_len = utf8SeqLen(input[i]);
458+
const remaining = input.len - i;
459+
const actual_len: usize = if (seq_len > remaining) remaining else seq_len;
460+
461+
if (actual_len == seq_len) {
462+
if (decodeLookup(input[i .. i + actual_len])) |byte| {
463+
result[pos] = byte;
464+
pos += 1;
465+
i += actual_len;
466+
continue;
467+
}
468+
}
469+
470+
@memcpy(result[pos..][0..actual_len], input[i..][0..actual_len]);
471+
pos += actual_len;
472+
i += actual_len;
473+
}
474+
475+
return allocator.realloc(result, pos);
476+
}
477+
411478
/// Decode printable UTF-8 back to binary data.
412479
/// Unrecognized UTF-8 characters pass through unchanged.
413480
/// Caller owns the returned slice and must free it with the same allocator.
414481
pub fn decode(allocator: std.mem.Allocator, input: []const u8, options: DecodeOptions) ![]u8 {
415-
if (input.len == 0) {
416-
return try allocator.alloc(u8, 0);
482+
if (input.len == 0) return try allocator.alloc(u8, 0);
483+
484+
if (!options.spaces and !options.strip_whitespace) {
485+
return decodeDefault(allocator, input);
417486
}
418487

419488
// Optionally strip whitespace (pre-allocated buffer, no ArrayList)

0 commit comments

Comments
 (0)