Skip to content
zit Artifact [290887ab34]
Login

Artifact [290887ab34]

Artifact 290887ab34f7777effcd4062c000456b1fcf8a59f84617a96a740d15a0245776:


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