Skip to content
zit Artifact [7656638cd9]
Login

Artifact [7656638cd9]

Artifact 7656638cd9439e6c397cad10a4adb8d7cc5a60d4759ef7c1f75a51539491687f:


//! 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;

/// 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();
    warm_view.deinit(gpa);

    // Persist the record body to a temp file so phase (a) reads real bytes off
    // disk (page-cached, i.e. a warm read) rather than a memory copy.
    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;

    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);

    const read_buf = try gpa.alloc(u8, body.items.len);
    defer gpa.free(read_buf);
    var sink: std.ArrayList(u8) = .empty;
    defer sink.deinit(gpa);

    var leaf_count: usize = 0;
    // Warmup reps prime the page cache and the allocator's arenas; their timings
    // are discarded so the reported medians reflect steady state.
    var w: usize = 0;
    while (w < cfg.warmup) : (w += 1) {
        _ = try phaseIo(io, tmp.file, 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| {
        // (a) cache-body I/O + self-checksum.
        const a0 = now(io);
        _ = try phaseIo(io, tmp.file, read_buf);
        const a1 = now(io);

        // (b) replay re-derivation — the only term a zero-parse image removes.
        var view: repoview.RepoView = undefined;
        view.buildFromRecordBody(gpa, read_buf, main_branch) catch |e| return mapBuild(e);
        const b1 = now(io);

        // (c) `zit leaves` render over the derived state.
        leaf_count = try formatLeaves(gpa, &view, &sink);
        const c1 = now(io);

        view.deinit(gpa); // teardown — untimed

        ra.* = elapsed(a0, a1);
        rb.* = elapsed(a1, b1);
        rc.* = elapsed(b1, c1);
    }

    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;
    try report(out, cfg, artifact_count, leaf_count, body.items.len, med_a, med_b, med_c);
}

/// Read the persisted record body off disk and CRC32 it — the dominant work
/// `repocache.tryLoad` does before it derives (read-into-buffer + self-check).
/// Returns the checksum so the read cannot be optimized away.
fn phaseIo(io: Io, file: Io.File, buf: []u8) Error!u32 {
    const n = file.readPositionalAll(io, buf, 0) catch return error.Io;
    if (n != buf.len) return error.Io;
    var crc = std.hash.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.
// ---------------------------------------------------------------------------

fn report(
    out: *Io.Writer,
    cfg: Config,
    artifacts: usize,
    leaves: usize,
    body_len: usize,
    a: u64,
    b: u64,
    c: u64,
) Io.Writer.Error!void {
    const total = a + b + c;
    try out.print("warm-read probe: {s}\n", .{cfg.path});
    try out.print("  artifacts={d}  open-leaves={d}  cache-body={d}B  reps={d} (warmup {d})\n", .{
        artifacts, leaves, body_len, cfg.reps, cfg.warmup,
    });
    try out.writeAll("  phase                          median      % of warm total\n");
    try out.print("  (a) cache-body I/O + crc32   {d:>9.2} us   {d:>6.1}%\n", .{ nsToUs(a), pct(a, total) });
    try out.print("  (b) replay re-derivation     {d:>9.2} us   {d:>6.1}%\n", .{ nsToUs(b), pct(b, total) });
    try out.print("  (c) leaves formatting        {d:>9.2} us   {d:>6.1}%\n", .{ nsToUs(c), pct(c, total) });
    try out.print("  warm total (a+b+c)           {d:>9.2} us\n", .{nsToUs(total)});
    try out.writeAll("  gate: re-derivation (b) >= 40% of warm total ⇒ GO (zero-parse SoA); else DEFER\n");
}

// ---------------------------------------------------------------------------
// 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,
    };
}