Skip to content
zit Artifact [df6f898e49]
Login

Artifact [df6f898e49]

Artifact df6f898e4951d8f411023b1c7951f0498e8a09ece63a98255b093e169e7c312c:


//! No-op sync cost probe (`zig build bench -- noop-sync [--reps N] [--assert]`).
//!
//! Seeds a sub-manifest k=7 store of `small_files` and one of `large_files`
//! files through a real `zit daemon`, serves each with `zit serve` on
//! loopback, clones it, and then runs `zit sync` against the clone in THIS
//! process once there is nothing left to move. Each rep is timed as process
//! CPU (user + system, the server being another process) and as wall time;
//! the minimum over the reps is reported, since noise only ever adds.
//!
//! A sync that moves nothing must cost what it moves, not what the store
//! holds: the large store's CPU is printed beside the `large_cpu_max_ms`
//! target, and `--assert` fails the run when it misses. Off by default,
//! because a loaded machine misses an absolute bound without anything being
//! wrong. The ratio of the two stores' CPU is printed too, never asserted:
//! both are around a millisecond, where what is left is a few linear passes
//! with small constants (the scan index's checksum, the announcement walk),
//! and a ratio of two small numbers is mostly noise.
//!
//! The stores live under `.zig-cache/tmp/` and are removed afterwards; the
//! daemon and server are the ReleaseFast `zit` the bench step builds.

const std = @import("std");
const Io = std.Io;
const net = std.Io.net;
const cli = @import("zit").cli;
const cmd_sync = @import("cmd/sync.zig");
const daemon_bench = @import("daemon/daemon_benchmark.zig");

pub const Error = daemon_bench.Error || error{
    ServerNeverBound,
    /// The sync failed, or still moved artifacts after the warm-up.
    SyncFailed,
    /// Under `--assert`, the large store's no-op sync missed the target.
    NoopCostGrows,
};

const small_files: usize = 2_000;
const large_files: usize = 42_000;
const large_cpu_max_ms: f64 = 10;
const max_reps: usize = 1_000;
/// Syncs allowed to settle what the clone left (the clusters a server mints
/// on the first pull) before a sync is expected to move nothing.
const max_warmups: usize = 4;

const Config = struct {
    reps: usize = 20,
    /// Fail the run when the large store misses `large_cpu_max_ms`.
    assert_bound: bool = false,
};

fn parseArgs(args: []const []const u8) Error!Config {
    var cfg: Config = .{};
    var i: usize = 2;
    while (i < args.len) : (i += 2) {
        if (std.mem.eql(u8, args[i], "--assert")) {
            cfg.assert_bound = true;
            i -= 1;
            continue;
        }
        if (i + 1 >= args.len) return error.BadArgs;
        const v = std.fmt.parseInt(usize, args[i + 1], 10) catch return error.BadArgs;
        if (std.mem.eql(u8, args[i], "--reps")) {
            if (v == 0 or v > max_reps) return error.BadArgs;
            cfg.reps = v;
        } else return error.BadArgs;
    }
    return cfg;
}

const Sample = struct { cpu_ns: u64, wall_ns: u64 };

pub fn run(gpa: std.mem.Allocator, io: Io, args: []const []const u8, out: *Io.Writer) Error!void {
    const cfg = try parseArgs(args);
    var arena_state = std.heap.ArenaAllocator.init(gpa);
    defer arena_state.deinit();
    const a = arena_state.allocator();
    const exe = try daemon_bench.absExe(a, io);

    const small = try measure(gpa, a, io, exe, small_files, cfg.reps);
    const large = try measure(gpa, a, io, exe, large_files, cfg.reps);
    try out.print("no-op zit sync against a loopback zit serve, k=7 stores, min of {d} reps\n", .{cfg.reps});
    try out.print("  {d:>6} files   cpu {d:>8.3} ms   wall {d:>8.3} ms\n", .{ small_files, ms(small.cpu_ns), ms(small.wall_ns) });
    try out.print("  {d:>6} files   cpu {d:>8.3} ms   wall {d:>8.3} ms\n", .{ large_files, ms(large.cpu_ns), ms(large.wall_ns) });
    const ratio = ms(large.cpu_ns) / @max(ms(small.cpu_ns), 0.001);
    const met = ms(large.cpu_ns) < large_cpu_max_ms;
    try out.print("  cpu ratio {d:.2}; large cpu target {d} ms: {s}\n", .{ ratio, large_cpu_max_ms, if (met) "met" else "missed" });
    try out.flush();
    if (cfg.assert_bound and !met) return error.NoopCostGrows;
}

/// Seed, serve and clone a store of `files` files, then time `reps` no-op
/// syncs from the clone. The minimum of each measure.
fn measure(gpa: std.mem.Allocator, a: std.mem.Allocator, io: Io, exe: []const u8, files: usize, reps: usize) Error!Sample {
    var nonce: u64 = undefined;
    io.random(std.mem.asBytes(&nonce));
    const dir_rel = try std.fmt.allocPrint(a, ".zig-cache/tmp/zit-noop-sync-bench-{x}", .{nonce});
    const cwd = Io.Dir.cwd();
    cwd.createDirPath(io, dir_rel) catch return error.Setup;
    defer cwd.deleteTree(io, dir_rel) catch {};
    var dir = cwd.openDir(io, dir_rel, .{}) catch return error.Setup;
    defer dir.close(io);

    try daemon_bench.runOnce(a, io, dir, &.{ exe, "init", "-q", "--submanifest", "7", "store" });
    {
        var daemon = try daemon_bench.spawnDaemon(a, io, dir, exe);
        defer daemon.kill(io);
        const socket = try std.fmt.allocPrint(a, "{s}/s/d.sock", .{dir_rel});
        var c = try daemon_bench.Client.open(a, io, socket);
        defer c.stream.close(io);
        try daemon_bench.seed(a, io, &c, 0, files, 1_700_000_000);
    }

    const port = try freshPort(io);
    const port_text = try std.fmt.allocPrint(a, "{d}", .{port});
    const store_rel = try std.fmt.allocPrint(a, "{s}/store", .{dir_rel});
    var server = std.process.spawn(io, .{
        .argv = &.{ exe, "serve", "--port", port_text },
        .cwd = .{ .path = store_rel },
        .stdin = .ignore,
        .stdout = .ignore,
        .stderr = .ignore,
    }) catch return error.Spawn;
    defer server.kill(io);
    try waitForServer(io, port);
    const url = try std.fmt.allocPrint(a, "http://127.0.0.1:{d}", .{port});
    try daemon_bench.runOnce(a, io, dir, &.{ exe, "clone", url, "clone", "--no-open" });

    const cwd_abs = std.process.currentPathAlloc(io, a) catch return error.Setup;
    const clone_abs = try std.fs.path.resolve(a, &.{ cwd_abs, dir_rel, "clone" });
    var warm: usize = 0;
    while (!try syncOnce(gpa, io, url, clone_abs, null)) {
        warm += 1;
        if (warm == max_warmups) return error.SyncFailed;
    }
    var best: Sample = .{ .cpu_ns = std.math.maxInt(u64), .wall_ns = std.math.maxInt(u64) };
    for (0..reps) |_| {
        var s: Sample = undefined;
        if (!try syncOnce(gpa, io, url, clone_abs, &s)) return error.SyncFailed;
        best.cpu_ns = @min(best.cpu_ns, s.cpu_ns);
        best.wall_ns = @min(best.wall_ns, s.wall_ns);
    }
    return best;
}

/// One in-process `zit sync URL` from the clone at `repo`. True when it
/// moved nothing; `sample` gets its cost.
fn syncOnce(gpa: std.mem.Allocator, io: Io, url: []const u8, repo: []const u8, sample: ?*Sample) Error!bool {
    var arena = std.heap.ArenaAllocator.init(gpa);
    defer arena.deinit();
    var out_buf: [1024]u8 = undefined;
    var err_buf: [1024]u8 = undefined;
    var out = std.Io.Writer.fixed(&out_buf);
    var err = std.Io.Writer.fixed(&err_buf);
    const streams: cli.Streams = .{ .out = &out, .err = &err };
    const amb: cli.Ambient = .{ .now_epoch = 0, .user_env = null, .repo_override = repo };
    const cpu_start = selfCpuNs();
    const wall_start = Io.Timestamp.now(io, .awake);
    const exit = cmd_sync.runSync(gpa, io, arena.allocator(), &.{url}, streams, amb);
    const wall = wall_start.durationTo(Io.Timestamp.now(io, .awake)).toNanoseconds();
    const cpu_end = selfCpuNs();
    if (exit != .ok) {
        std.debug.print("noop-sync: sync failed: {s}{s}\n", .{ out.buffered(), err.buffered() });
        return error.SyncFailed;
    }
    if (sample) |s| s.* = .{ .cpu_ns = cpu_end -| cpu_start, .wall_ns = if (wall <= 0) 0 else @intCast(wall) };
    return std.mem.indexOf(u8, out.buffered(), "already in sync") != null;
}

/// This process's CPU, user plus system: the server's work is not in it.
fn selfCpuNs() u64 {
    const usage = std.posix.getrusage(std.posix.rusage.SELF);
    const user: u64 = @as(u64, @intCast(usage.utime.sec)) * std.time.ns_per_s + @as(u64, @intCast(usage.utime.usec)) * std.time.ns_per_us;
    const system: u64 = @as(u64, @intCast(usage.stime.sec)) * std.time.ns_per_s + @as(u64, @intCast(usage.stime.usec)) * std.time.ns_per_us;
    return user + system;
}

fn ms(ns: u64) f64 {
    return @as(f64, @floatFromInt(ns)) / std.time.ns_per_ms;
}

fn freshPort(io: Io) Error!u16 {
    const addr = net.IpAddress.parseIp4("127.0.0.1", 0) catch return error.ServerNeverBound;
    var listener = addr.listen(io, .{}) catch return error.ServerNeverBound;
    defer listener.deinit(io);
    const port = listener.socket.address.getPort();
    if (port == 0) return error.ServerNeverBound;
    return port;
}

fn waitForServer(io: Io, port: u16) Error!void {
    const addr = net.IpAddress.parseIp4("127.0.0.1", port) catch return error.ServerNeverBound;
    var tries: usize = 0;
    while (tries < 60_000) : (tries += 1) {
        if (addr.connect(io, .{ .mode = .stream })) |stream| {
            stream.close(io);
            return;
        } else |_| {}
        io.sleep(.{ .nanoseconds = 500_000 }, .awake) catch return error.Setup;
    }
    return error.ServerNeverBound;
}

test "parseArgs: default reps, a value, and refusals" {
    try std.testing.expectEqual(@as(usize, 20), (try parseArgs(&.{ "bench", "noop-sync" })).reps);
    try std.testing.expectEqual(@as(usize, 5), (try parseArgs(&.{ "bench", "noop-sync", "--reps", "5" })).reps);
    try std.testing.expectError(error.BadArgs, parseArgs(&.{ "bench", "noop-sync", "--reps" }));
    try std.testing.expectError(error.BadArgs, parseArgs(&.{ "bench", "noop-sync", "--reps", "0" }));
    try std.testing.expectError(error.BadArgs, parseArgs(&.{ "bench", "noop-sync", "--bogus", "1" }));
    try std.testing.expect(!(try parseArgs(&.{ "bench", "noop-sync" })).assert_bound);
    const strict = try parseArgs(&.{ "bench", "noop-sync", "--assert", "--reps", "3" });
    try std.testing.expect(strict.assert_bound);
    try std.testing.expectEqual(@as(usize, 3), strict.reps);
}