Artifact 290887ab34f7777effcd4062c000456b1fcf8a59f84617a96a740d15a0245776:
- File
src/warmread_benchmark.zig
— part of check-in
[c57c16c5cd]
at
2026-07-29 13:21:29
on branch logstore-index
— crc32: export for the benchmark and the fuzz lane
Both consumers live in their own build roots and cannot reach crc32.zig by path, so the export is forced. Adds the differential fuzz lane against std — the only coverage that can see a self-consistent but numerically shifted implementation, which would orphan every existing .zit file.
The implementation brief prohibited this export, citing repocache as the precedent for unexported leaf modules. That precedent does not apply: repocache has no out-of-module consumer. storestat and repoimage, which do, are both exported. Overridden deliberately. (user: dmestas, size: 18383) [annotate] [blame] [check-ins using] [more...]
//! Warm-path read timing probe (measurement gate before any zero-parse SoA //! rewrite of the read path). //! //! `zig build bench -- warm <.zit path> [--reps N] [--warmup N]`. //! //! The `.zit-cache` (repocache.zig) is a REPLAY cache: on a warm hit it stores //! the crosslink INPUT records and re-runs the full derivation //! (`RepoView.buildFromRecordBody` → `RepoState.crosslink`), rebuilding the //! derived hashmaps from scratch. A zero-parse image would remove only that //! residual re-derivation term. This probe measures, on a WARM read, what //! fraction of wall-time each phase costs: //! //! (a) cache-body I/O — read the record body off disk + CRC32 self-check //! (what `repocache.tryLoad` does before it derives; //! the 40-byte header / key / O(1) `validateTail` are //! negligible and excluded); //! (b) re-derivation — `RepoView.buildFromRecordBody` (the replay that //! rebuilds `RepoState`) — the ONLY term zero-parse //! could cut; //! (c) formatting — the `zit leaves` render (gather → §7.5 sort → write //! lines). `leaves` is fully cache-served, so it //! isolates I/O-vs-derivation cleanly; `log`/`timeline` //! comment rendering would add log reads OUTSIDE this //! split and is deliberately not modelled here. //! //! It reports each phase's median ns + % of the warm total over N reps. //! //! This is thrown-away measurement tooling, not library code: the phase split //! replays `tryLoad`'s dominant steps directly (repocache is library-internal //! and stays that way) so each phase can be timed in isolation. Determinism //! seam: the monotonic clock and the temp file both go through the injected //! `std.Io`; the library modules stay pure. const std = @import("std"); const Io = std.Io; const storage = @import("zit").storage; const logstore = @import("zit").logstore; const repoview = @import("zit").repoview; const repostate = @import("zit").repostate; const repoimage = @import("zit").repoimage; // Through the `zit` module, not by path: a file belongs to exactly one module, // and `crc32.zig` is already in `zit`. The benchmark models `repocache.tryLoad`'s // integrity pass, so it must measure the checksum the product actually runs. const crc32 = @import("zit").crc32; /// Closed harness error set. `Storage`/`Corrupt`/`OutOfMemory` propagate from /// the log open and the derivation; `Io` is a failed temp/store file syscall; /// `BadArgs` is a rejected flag or oversized rep count. pub const Error = error{ OutOfMemory, Storage, Corrupt, Io, BadArgs }; /// Adds the writer's own error set to the harness causes — both explicit, never /// `anyerror`. const RunError = Error || Io.Writer.Error; /// Explicit fixed caps (TigerStyle bounds). const max_reps: u64 = 10_000; const min_reps: u64 = 1; const Config = struct { path: []const u8, reps: u64 = 50, warmup: u64 = 5, }; pub fn run(gpa: std.mem.Allocator, io: Io, args: []const []const u8, out: *Io.Writer) RunError!void { const cfg = try parseArgs(args); try probe(gpa, io, cfg, out); try out.flush(); } /// The sole positional is the `.zit` path; every remaining token is a /// `--flag value` integer pair. A leading `--` (no path) is rejected. fn parseArgs(args: []const []const u8) Error!Config { std.debug.assert(args.len >= 2); if (args.len < 3 or std.mem.startsWith(u8, args[2], "--")) return error.BadArgs; var cfg = Config{ .path = args[2] }; var i: usize = 3; while (i < args.len) : (i += 1) { const flag = args[i]; i += 1; if (i >= args.len) return error.BadArgs; // flag with no value const val = std.fmt.parseInt(u64, args[i], 0) catch return error.BadArgs; if (std.mem.eql(u8, flag, "--reps")) { cfg.reps = val; } else if (std.mem.eql(u8, flag, "--warmup")) { cfg.warmup = val; } else return error.BadArgs; } if (cfg.reps < min_reps or cfg.reps > max_reps) return error.BadArgs; if (cfg.warmup > max_reps) return error.BadArgs; return cfg; } // --------------------------------------------------------------------------- // The probe. // --------------------------------------------------------------------------- fn probe(gpa: std.mem.Allocator, io: Io, cfg: Config, out: *Io.Writer) RunError!void { // Warm step: one full read/derive over the log to produce the exact cache // body a `.zit-cache` stores, plus the resolved main-branch. The whole-log // scan here is the COLD path — untimed; it only populates what the warm // loop replays. var file = Io.Dir.openFileAbsolute(io, cfg.path, .{}) catch return error.Io; defer file.close(io); const st = file.stat(io) catch return error.Io; var fs = storage.FileStorage.init(io, file, st.size); var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch |e| return mapOpen(e); defer log.deinit(gpa); var body: std.ArrayList(u8) = .empty; defer body.deinit(gpa); var warm_view: repoview.RepoView = undefined; warm_view.loadCapturing(gpa, &log, &body) catch |e| return mapLoad(e); const main_branch = try gpa.dupe(u8, warm_view.main_branch); defer gpa.free(main_branch); const artifact_count = warm_view.store.store().count(); // Materialize the zero-parse image from the same derived state (§ Phase A): // the memo a warm `.fast_read` reloads instead of re-deriving. var image: std.ArrayList(u8) = .empty; defer image.deinit(gpa); repoimage.serialize(gpa, &warm_view.state, main_branch, &image) catch |e| return mapBuild2(e); warm_view.deinit(gpa); // Persist the record body and the image to temp files so the read phases // read real bytes off disk (page-cached, i.e. a warm read). var tmp = try Temp.create(io); defer tmp.cleanup(io); tmp.file.writePositionalAll(io, body.items, 0) catch return error.Io; tmp.file.sync(io) catch return error.Io; var itmp = try Temp.create(io); defer itmp.cleanup(io); itmp.file.writePositionalAll(io, image.items, 0) catch return error.Io; itmp.file.sync(io) catch return error.Io; const reps: usize = @intCast(cfg.reps); const a_ns = try gpa.alloc(u64, reps); defer gpa.free(a_ns); const b_ns = try gpa.alloc(u64, reps); defer gpa.free(b_ns); const c_ns = try gpa.alloc(u64, reps); defer gpa.free(c_ns); // Image path: (a') image I/O + crc, (b') zero-parse image load, (c') render. const ai_ns = try gpa.alloc(u64, reps); defer gpa.free(ai_ns); const bi_ns = try gpa.alloc(u64, reps); defer gpa.free(bi_ns); const ci_ns = try gpa.alloc(u64, reps); defer gpa.free(ci_ns); // Read both cache files ONCE into resident buffers, untimed. A warm read is // page-cache-resident by definition (repo-state §1 memo), so the timed I/O // phase is the CRC integrity pass `repocache.tryLoad` runs over the resident // bytes — not a disk seek, whose latency is environment noise that swamps // the derivation term Phase A actually changes. const read_buf = try gpa.alloc(u8, body.items.len); defer gpa.free(read_buf); if ((tmp.file.readPositionalAll(io, read_buf, 0) catch return error.Io) != read_buf.len) return error.Io; const image_buf = try gpa.alloc(u8, image.items.len); defer gpa.free(image_buf); if ((itmp.file.readPositionalAll(io, image_buf, 0) catch return error.Io) != image_buf.len) return error.Io; var sink: std.ArrayList(u8) = .empty; defer sink.deinit(gpa); var leaf_count: usize = 0; // Each path is measured in its OWN loop with its own warmup, so its cache // file stays page-cache hot and the two paths never perturb each other's // I/O phase (interleaving them thrashes the page cache and inflates I/O). // --- Derive path (today's warm read) --- var w: usize = 0; while (w < cfg.warmup) : (w += 1) { _ = phaseCrc(read_buf); var view: repoview.RepoView = undefined; view.buildFromRecordBody(gpa, read_buf, main_branch) catch |e| return mapBuild(e); _ = try formatLeaves(gpa, &view, &sink); view.deinit(gpa); } for (a_ns, b_ns, c_ns) |*ra, *rb, *rc| { const a0 = now(io); _ = phaseCrc(read_buf); // (a) cache-body integrity crc32 over the resident bytes const a1 = now(io); var view: repoview.RepoView = undefined; view.buildFromRecordBody(gpa, read_buf, main_branch) catch |e| return mapBuild(e); // (b) re-derivation const b1 = now(io); leaf_count = try formatLeaves(gpa, &view, &sink); // (c) render const c1 = now(io); view.deinit(gpa); // untimed teardown ra.* = elapsed(a0, a1); rb.* = elapsed(a1, b1); rc.* = elapsed(b1, c1); } // --- Image path (Phase A .fast_read) --- w = 0; while (w < cfg.warmup) : (w += 1) { _ = phaseCrc(image_buf); var iview: repoview.RepoView = undefined; const owned = try gpa.dupe(u8, image_buf); iview.buildFromImage(gpa, owned, owned, main_branch) catch |e| return mapBuild(e); _ = try formatLeaves(gpa, &iview, &sink); iview.deinit(gpa); } for (ai_ns, bi_ns, ci_ns) |*rai, *rbi, *rci| { const ai0 = now(io); _ = phaseCrc(image_buf); // (a') image integrity crc32 over the resident bytes const ai1 = now(io); var iview: repoview.RepoView = undefined; const owned = try gpa.dupe(u8, image_buf); // the alloc tryLoad also pays iview.buildFromImage(gpa, owned, owned, main_branch) catch |e| return mapBuild(e); // (b') zero-parse load const bi1 = now(io); _ = try formatLeaves(gpa, &iview, &sink); // (c') render const ci1 = now(io); iview.deinit(gpa); // untimed teardown rai.* = elapsed(ai0, ai1); rbi.* = elapsed(ai1, bi1); rci.* = elapsed(bi1, ci1); } const med_a = median(gpa, a_ns) catch return error.OutOfMemory; const med_b = median(gpa, b_ns) catch return error.OutOfMemory; const med_c = median(gpa, c_ns) catch return error.OutOfMemory; const med_ai = median(gpa, ai_ns) catch return error.OutOfMemory; const med_bi = median(gpa, bi_ns) catch return error.OutOfMemory; const med_ci = median(gpa, ci_ns) catch return error.OutOfMemory; try report(out, cfg, artifact_count, leaf_count, body.items.len, image.items.len, .{ .a = med_a, .b = med_b, .c = med_c, .ai = med_ai, .bi = med_bi, .ci = med_ci, }); } /// The CRC32 integrity pass `repocache.tryLoad` runs over the resident cache /// bytes before it trusts them — the per-read cost on a warm (page-cache-hot) /// read. Returns the checksum so the pass cannot be optimized away. fn phaseCrc(buf: []const u8) u32 { var crc = crc32.Crc32.init(); crc.update(buf); return crc.final(); } /// Model `zit leaves` (main.zig `renderLeaves`): gather open-leaf rows, §7.5 /// sort (mtime desc, id asc), and write each ` <hash> <branch>` line into a /// reused discard buffer. Returns the leaf-row count so the work is observable. fn formatLeaves(gpa: std.mem.Allocator, view: *repoview.RepoView, sink: *std.ArrayList(u8)) Error!usize { sink.clearRetainingCapacity(); const rs = &view.state; var rows: std.ArrayList(LeafRow) = .empty; defer rows.deinit(gpa); const bound = rs.leaves().rids.len; var scanned: usize = 0; var it = rs.leaves(); while (it.next()) |rid| { scanned += 1; std.debug.assert(scanned <= bound); if (!rs.isOpenLeaf(rid)) continue; // default listing: open leaves only const mtime = rs.mtimeOf(rid) orelse unreachable; // a leaf always bears an event try rows.append(gpa, .{ .mtime = mtime, .hash = view.hashOf(rid) }); } std.mem.sort(LeafRow, rows.items, {}, leafRowLess); var line: [160]u8 = undefined; for (rows.items) |row| { const branch = rs.branchOf(view.lookup(row.hash).?, view.main_branch); const s = std.fmt.bufPrint(&line, " {s} {s}\n", .{ row.hash, branch }) catch return error.Corrupt; try sink.appendSlice(gpa, s); } return rows.items.len; } /// A leaf row for the §7.5 render sort (the fields the ordering reads). const LeafRow = struct { mtime: repostate.Mtime, hash: []const u8 }; /// §7.5 order: mtime descending, ties broken by hash ascending. fn leafRowLess(_: void, a: LeafRow, b: LeafRow) bool { if (a.mtime != b.mtime) return a.mtime > b.mtime; return std.mem.order(u8, a.hash, b.hash) == .lt; } // --------------------------------------------------------------------------- // Reporting. // --------------------------------------------------------------------------- const Medians = struct { a: u64, b: u64, c: u64, ai: u64, bi: u64, ci: u64 }; fn report( out: *Io.Writer, cfg: Config, artifacts: usize, leaves: usize, body_len: usize, image_len: usize, m: Medians, ) Io.Writer.Error!void { const derive_total = m.a + m.b + m.c; const image_total = m.ai + m.bi + m.ci; try out.print("warm-read probe: {s}\n", .{cfg.path}); try out.print(" artifacts={d} open-leaves={d} cache-body={d}B image={d}B reps={d} (warmup {d})\n", .{ artifacts, leaves, body_len, image_len, cfg.reps, cfg.warmup, }); try out.writeAll(" DERIVE path (today's warm read) median % of warm total\n"); try out.print(" (a) integrity crc32 (resident){d:>9.2} us {d:>6.1}%\n", .{ nsToUs(m.a), pct(m.a, derive_total) }); try out.print(" (b) replay re-derivation {d:>9.2} us {d:>6.1}%\n", .{ nsToUs(m.b), pct(m.b, derive_total) }); try out.print(" (c) leaves formatting {d:>9.2} us {d:>6.1}%\n", .{ nsToUs(m.c), pct(m.c, derive_total) }); try out.print(" DERIVE warm total (a+b+c) {d:>9.2} us\n", .{nsToUs(derive_total)}); try out.writeAll(" IMAGE path (Phase A .fast_read) median % of warm total\n"); try out.print(" (a') integrity crc32 (resident){d:>9.2} us {d:>6.1}%\n", .{ nsToUs(m.ai), pct(m.ai, image_total) }); try out.print(" (b') zero-parse image load {d:>9.2} us {d:>6.1}%\n", .{ nsToUs(m.bi), pct(m.bi, image_total) }); try out.print(" (c') leaves formatting {d:>9.2} us {d:>6.1}%\n", .{ nsToUs(m.ci), pct(m.ci, image_total) }); try out.print(" IMAGE warm total (a'+b'+c') {d:>9.2} us\n", .{nsToUs(image_total)}); try out.print(" SPEEDUP (derive / image) {d:>9.2}x\n", .{speedup(derive_total, image_total)}); // Phase B removes remaining hashmap-fill from the load. Phase A's image // load (b') has NO hashmap fill — it is offset-view setup + leaf-rid widen. // So the gate is whether the load still does derivation-scale work: b' as a // fraction of the derive term (b) it replaced. Warm-total fraction is the // wrong denominator once the total collapses. try out.print(" derivation term eliminated: {d:.1}% (b {d:.2}us -> b' {d:.2}us)\n", .{ pct(m.b - m.bi, m.b), nsToUs(m.b), nsToUs(m.bi), }); try out.print(" image load (b') as % of the derive it replaced: {d:.2}% ⇒ {s}\n", .{ pct(m.bi, m.b), if (pct(m.bi, m.b) >= 40.0) "Phase B candidate" else "Phase A sufficient (no re-derivation residual)", }); } fn speedup(derive_total: u64, image_total: u64) f64 { if (image_total == 0) return 0; return @as(f64, @floatFromInt(derive_total)) / @as(f64, @floatFromInt(image_total)); } // --------------------------------------------------------------------------- // Timing, median, temp file, error mapping. // --------------------------------------------------------------------------- fn now(io: Io) Io.Timestamp { return Io.Timestamp.now(io, .awake); } fn elapsed(t0: Io.Timestamp, t1: Io.Timestamp) u64 { const ns = t0.durationTo(t1).nanoseconds; // i96, monotonic so >= 0 return if (ns < 0) 0 else @intCast(ns); } /// Median of `samples` (a copy is sorted so the caller's timing array is left /// in rep order). Even counts take the lower-middle element — a probe reports a /// real observed sample, never a synthesized average. fn median(gpa: std.mem.Allocator, samples: []const u64) error{OutOfMemory}!u64 { std.debug.assert(samples.len >= 1); const copy = try gpa.dupe(u64, samples); defer gpa.free(copy); std.mem.sort(u64, copy, {}, std.sort.asc(u64)); return copy[(copy.len - 1) / 2]; } fn pct(part: u64, whole: u64) f64 { if (whole == 0) return 0; return @as(f64, @floatFromInt(part)) * 100.0 / @as(f64, @floatFromInt(whole)); } fn nsToUs(ns: u64) f64 { return @as(f64, @floatFromInt(ns)) / 1000.0; } /// A throwaway temp file for the record body; created readable so the phase-(a) /// loop reads it back. Removed on cleanup. const Temp = struct { file: Io.File, path_buf: [64]u8, path_len: usize, fn create(io: Io) Error!Temp { var t: Temp = undefined; const nonce = Io.Clock.Timestamp.now(io, .awake).raw.nanoseconds; const p = std.fmt.bufPrint(&t.path_buf, "/tmp/zit-warmprobe-{d}.bin", .{nonce}) catch return error.BadArgs; t.path_len = p.len; t.file = Io.Dir.createFileAbsolute(io, t.path_buf[0..t.path_len], .{ .read = true }) catch return error.Io; return t; } fn cleanup(self: *Temp, io: Io) void { self.file.close(io); Io.Dir.deleteFileAbsolute(io, self.path_buf[0..self.path_len]) catch {}; } }; fn mapOpen(e: logstore.OpenError) Error { return switch (e) { error.OutOfMemory => error.OutOfMemory, error.Storage => error.Storage, error.Corrupt => error.Corrupt, }; } fn mapLoad(e: repoview.RepoView.LoadError) Error { return switch (e) { error.OutOfMemory => error.OutOfMemory, error.Storage => error.Storage, error.Corrupt => error.Corrupt, }; } fn mapBuild(e: repoview.RepoView.BuildError) Error { return switch (e) { error.OutOfMemory => error.OutOfMemory, error.Corrupt => error.Corrupt, }; } fn mapBuild2(e: error{OutOfMemory}) Error { return switch (e) { error.OutOfMemory => error.OutOfMemory, }; }