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