diff options
Diffstat (limited to 'test/fs_bench.zig')
| -rw-r--r-- | test/fs_bench.zig | 299 |
1 files changed, 192 insertions, 107 deletions
diff --git a/test/fs_bench.zig b/test/fs_bench.zig index 7315676a..7fe49734 100644 --- a/test/fs_bench.zig +++ b/test/fs_bench.zig @@ -1,39 +1,15 @@ -//! THE FILESYSTEM SCOREBOARD: what one acme-fs request costs the core, and -//! what serving one costs an editor that nobody is scripting. -//! -//! zig build fs-bench -- the table -//! zig build fs-bench -- --json -- the same, machine-readable -//! zig build fs-bench -- --reps 200000 -- more samples per row -//! -//! There is no FUSE here, and no thread: `acmefs.handle` IS the transaction -//! (src/acmefs.zig), so driving it directly is measuring the whole of what the -//! core does per request. That is the point of the split — a transport adds a -//! `read(2)`, a `write(2)` and a wake, and those are the kernel's numbers, not -//! ours (the mounted end-to-end figures are measured with real clients; see -//! examples/README.md). -//! -//! THREE QUESTIONS THIS ANSWERS. -//! -//! 1. Is a request cheap enough to serve thousands per frame? Each row is -//! one `handle` call, median of `--reps`. -//! 2. Does the steady state ALLOCATE? Every row is measured through a -//! counting allocator and the table prints the allocation count. A -//! non-zero number in a read row is a bug: reads answer with a range of -//! the pane's live text (`Payload.region`) or with the staging buffer, -//! and the staging buffer is cleared, never freed. -//! 3. What does an editor with NO script attached pay? The last two rows are -//! the same keystroke with zero listeners and with one. Zero listeners -//! must be indistinguishable from an editor with no filesystem compiled -//! in at all: one branch in `file_pane.setContent`. const std = @import("std"); const pardes = @import("pardes"); -const acmefs = pardes.acmefs; +const filesystem = pardes.filesystem; pub const std_options: std.Options = .{ .log_level = .err }; const backing = std.heap.page_allocator; +const harness_id = blk: { + @setEvalBranchQuota(100_000); + break :blk std.fmt.comptimePrint("{x}", .{std.hash.Wyhash.hash(0, @embedFile("fs_bench.zig"))}); +}; -/// std.time.Timer is gone in 0.16; clock_gettime is what test/perf.zig uses. fn nowNs() u64 { var ts: std.c.timespec = undefined; _ = std.c.clock_gettime(.MONOTONIC, &ts); @@ -43,40 +19,43 @@ fn nowNs() u64 { const Row = struct { name: []const u8, ns: u64, + reps: usize, allocs: usize, note: []const u8 = "", }; var rows: std.ArrayList(Row) = .empty; -fn record(name: []const u8, total_ns: u64, reps: usize, counting: *std.testing.FailingAllocator, note: []const u8) void { - rows.append(backing, .{ +fn record(name: []const u8, total_ns: u64, reps: usize, counting: *std.testing.FailingAllocator, note: []const u8) !void { + try rows.append(backing, .{ .name = name, - .ns = total_ns / @max(1, reps), + .ns = total_ns / reps, + .reps = reps, .allocs = counting.allocations, .note = note, - }) catch {}; + }); } -/// A session with something to measure against: one big file pane, one shell, -/// and a scripted pane whose event queue has records waiting. const Session = struct { core: *pardes.Pardes, counting: *std.testing.FailingAllocator, file_id: usize, file_serial: u32, + body_bytes: usize, + tag: []u8, fn init(counting: *std.testing.FailingAllocator, body_bytes: usize) !Session { const gpa = counting.allocator(); const core = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 120, .rows = 40 }); + errdefer core.deinit(); while (core.nextEffect()) |_| {} - // A body big enough that a copy would show up in the numbers. const line = "the quick brown fox jumps over the lazy dog\n"; var content: std.ArrayList(u8) = .empty; - while (content.items.len < body_bytes) try content.appendSlice(backing, line); - const pane = try core.hxOpenFileContent(content.items); - content.deinit(backing); + defer content.deinit(backing); + while (content.items.len < body_bytes) + try content.appendSlice(backing, line[0..@min(line.len, body_bytes - content.items.len)]); + const pane = try core.setTestFile(content.items); const id = core.active; while (core.nextEffect()) |_| {} return .{ @@ -84,120 +63,208 @@ const Session = struct { .counting = counting, .file_id = id, .file_serial = pane.serial, + .body_bytes = pane.file.?.content.len, + .tag = try core.tagText(backing, pane), }; } fn deinit(s: *Session) void { + backing.free(s.tag); s.core.deinit(); } - fn node(s: *const Session, file: acmefs.PaneFile) u64 { - return acmefs.Node.of(s.file_serial, file); + fn node(s: *const Session, file: filesystem.PaneFile) u64 { + return filesystem.Node.of(s.file_serial, file); } }; -/// One row: run `req` `reps` times and report the mean cost plus how many -/// allocations the whole run made. -/// -/// The per-update scratch arena is reset each rep because that is what the -/// real path does — `Pardes.update` resets it at the end of every event, and -/// `handle` called bare would otherwise let one arena grow across a hundred -/// thousand requests and count its CHUNKS as allocations. Resetting here -/// measures the request, not the harness. -fn bench(s: *Session, name: []const u8, reps: usize, req: acmefs.Req, note: []const u8) void { - // warm the staging buffer and any lazy index the first call builds +fn checkNumbers(bytes: []const u8, expected: []const usize) ![]const u8 { + var rest = bytes; + for (expected) |value| { + rest = std.mem.trimStart(u8, rest, " "); + const end = std.mem.indexOfScalar(u8, rest, ' ') orelse return error.IncorrectReply; + const got = std.fmt.parseInt(usize, rest[0..end], 10) catch return error.IncorrectReply; + if (got != value) return error.IncorrectReply; + rest = rest[end + 1 ..]; + } + return std.mem.trimStart(u8, rest, " "); +} + +fn checkReply(s: *Session, req: filesystem.Req, reply: filesystem.Reply) !void { + const event_read = req.op == .read and req.node == s.node(.event); + const expected_status: filesystem.Status = if (event_read) .again else .ok; + if (reply.tag != req.tag or reply.errno != 0 or reply.status != expected_status) + return error.IncorrectReply; + const body = s.core.panes[s.file_id].?.file.?.content; + switch (req.op) { + .getattr => if (reply.attr.node != req.node or reply.attr.size != body.len or reply.attr.dir) return error.IncorrectReply, + .lookup => if (reply.attr.node != s.node(.ctl) or reply.attr.dir) return error.IncorrectReply, + .read => { + if (event_read) { + if (reply.payload != .none) return error.IncorrectReply; + } else if (req.node == s.node(.body)) { + if (reply.payload != .region) return error.IncorrectReply; + const region = reply.payload.region; + if (region.pane != s.file_id or region.serial != s.file_serial or region.off != req.off or region.len != req.size) + return error.IncorrectReply; + } else { + const bytes = s.core.fs.out.items; + if (reply.payload != .staged or reply.payload.staged != bytes.len or bytes.len > req.size) + return error.IncorrectReply; + const pane = s.core.panes[s.file_id].?; + const rest = try checkNumbers(bytes, &.{ s.file_serial, s.tag.len, body.len, 0, @intFromBool(pane.file.?.revision != pane.file.?.saved_revision) }); + if (req.node == s.node(.ctl)) { + const tail = try checkNumbers(rest, &.{pane.cols}); + if (!std.mem.startsWith(u8, tail, "default ")) return error.IncorrectReply; + if ((try checkNumbers(tail[8..], &.{pardes.config.tab_width})).len != 0) + return error.IncorrectReply; + } else if (req.node == @intFromEnum(filesystem.SelfFile.index)) { + const tag = s.tag[0 .. std.mem.indexOfScalar(u8, s.tag, '\n') orelse s.tag.len]; + if (rest.len != tag.len + 1 or rest[rest.len - 1] != '\n' or !std.mem.eql(u8, rest[0..tag.len], tag)) + return error.IncorrectReply; + } else return error.UnmeasuredOperation; + } + }, + .readdir => { + const bytes = s.core.fs.out.items; + if (reply.payload != .staged or reply.payload.staged != bytes.len or bytes.len > req.size) + return error.IncorrectReply; + const names = [_][]const u8{ "index", "cons", "new", "pane", "screen", "listeners" }; + const nodes = [_]u64{ @intFromEnum(filesystem.SelfFile.index), @intFromEnum(filesystem.SelfFile.cons), @intFromEnum(filesystem.SelfFile.new), filesystem.namespace_panes, @intFromEnum(filesystem.SelfFile.screen), @intFromEnum(filesystem.SelfFile.listeners) }; + var off: usize = 0; + var entry: usize = 0; + while (off < bytes.len) : (entry += 1) { + if (bytes.len - off < 10) return error.IncorrectReply; + const node = std.mem.readInt(u64, bytes[off..][0..8], .little); + const dir = bytes[off + 8]; + const size: usize = bytes[off + 9]; + off += 10; + if (dir > 1 or size == 0 or size > bytes.len - off) return error.IncorrectReply; + if (entry < names.len and (node != nodes[entry] or dir != @intFromBool(entry == 2 or entry == 3) or + !std.mem.eql(u8, names[entry], bytes[off..][0..size]))) return error.IncorrectReply; + off += size; + } + if (entry < names.len) return error.IncorrectReply; + }, + .write => if (reply.written != req.data.len or !std.mem.endsWith(u8, body, req.data)) return error.IncorrectReply, + else => return error.UnmeasuredOperation, + } +} + +fn bench(s: *Session, name: []const u8, reps: usize, req: filesystem.Req, note: []const u8) !void { + std.debug.assert(reps > 0); + const initial_bytes = s.core.panes[s.file_id].?.file.?.content.len; + const initial_hash = if (req.op == .write) std.hash.Wyhash.hash(0, s.core.panes[s.file_id].?.file.?.content) else 0; for (0..64) |_| { - _ = acmefs.handle(s.core, req); + try checkReply(s, req, filesystem.handle(s.core, req)); _ = s.core.scratch.reset(.retain_capacity); } s.counting.allocations = 0; + const expected_status: filesystem.Status = if (req.op == .read and req.node == s.node(.event)) .again else .ok; + var valid = true; + var reply: filesystem.Reply = undefined; const start = nowNs(); for (0..reps) |_| { - const reply = acmefs.handle(s.core, req); - std.mem.doNotOptimizeAway(reply.status); + reply = filesystem.handle(s.core, req); + valid = valid and reply.tag == req.tag and reply.status == expected_status and reply.errno == 0; + std.mem.doNotOptimizeAway(reply); _ = s.core.scratch.reset(.retain_capacity); } - record(name, nowNs() - start, reps, s.counting, note); + const elapsed = nowNs() - start; + if (!valid) return error.IncorrectReply; + try checkReply(s, req, reply); + if (req.op == .write) { + const body = s.core.panes[s.file_id].?.file.?.content; + if (body.len != initial_bytes + (64 + reps) * req.data.len or + std.hash.Wyhash.hash(0, body[0..initial_bytes]) != initial_hash or + !std.mem.allEqual(u8, body[initial_bytes..], 'x')) return error.MissingWrite; + } + try record(name, elapsed, reps, s.counting, note); } -pub fn main(init: std.process.Init) !void { - const args = try init.minimal.args.toSlice(init.arena.allocator()); - var reps: usize = 100_000; - var json = false; - var i: usize = 1; - while (i < args.len) : (i += 1) { - if (std.mem.eql(u8, args[i], "--json")) { - json = true; - } else if (std.mem.eql(u8, args[i], "--reps") and i + 1 < args.len) { - i += 1; - reps = try std.fmt.parseInt(usize, args[i], 10); +const Options = struct { + reps: usize = 100_000, + json: bool = false, + + fn parse(args: []const []const u8) !Options { + var result: Options = .{}; + var seen_reps = false; + var i: usize = 0; + while (i < args.len) : (i += 1) { + if (std.mem.eql(u8, args[i], "--json") and !result.json) { + result.json = true; + } else if (std.mem.eql(u8, args[i], "--reps") and !seen_reps) { + i += 1; + if (i == args.len) return error.MissingRepetitions; + result.reps = std.fmt.parseInt(usize, args[i], 10) catch return error.InvalidRepetitions; + if (result.reps == 0) return error.InvalidRepetitions; + seen_reps = true; + } else return error.InvalidArgument; } + return result; } +}; + +pub fn main(init: std.process.Init) !void { + const args = try init.minimal.args.toSlice(init.arena.allocator()); + const options = try Options.parse(args[1..]); + const reps = options.reps; + defer rows.deinit(backing); - // std.testing.FailingAllocator with the default options never induces a - // failure and counts every allocation, which is the whole of what this - // benchmark wanted from a wrapper. var counting: std.testing.FailingAllocator = .init(backing, .{}); - var s = try Session.init(&counting, 1 << 20); // a 1 MiB body + var s = try Session.init(&counting, 1 << 20); defer s.deinit(); - // ---- the three shapes of request ------------------------------------- - bench(&s, "getattr body", reps, .{ + try bench(&s, "getattr body", reps, .{ .tag = 1, .op = .getattr, .node = s.node(.body), }, "stat of a 1 MiB body"); - bench(&s, "lookup ctl", reps, .{ + try bench(&s, "lookup ctl", reps, .{ .tag = 2, .op = .lookup, - .node = acmefs.Node.of(s.file_serial, .dir), + .node = filesystem.Node.of(s.file_serial, .dir), .data = "ctl", }, "name -> node"); - bench(&s, "read body 4K", reps, .{ + try bench(&s, "read body 4K", reps, .{ .tag = 3, .op = .read, .node = s.node(.body), .off = 4096, .size = 4096, }, "must be zero-copy"); - bench(&s, "read body 1M", reps / 10, .{ + try bench(&s, "read body 1M", @max(1, reps / 10), .{ .tag = 4, .op = .read, .node = s.node(.body), .off = 0, .size = 1 << 20, }, "same cost as 4K if truly zero-copy"); - bench(&s, "read ctl", reps, .{ + try bench(&s, "read ctl", reps, .{ .tag = 5, .op = .read, .node = s.node(.ctl), .size = 256, }, "formatted into the staging buffer"); - bench(&s, "read index", reps, .{ + try bench(&s, "read index", reps, .{ .tag = 6, .op = .read, - .node = @intFromEnum(acmefs.TopFile.index), + .node = @intFromEnum(filesystem.SelfFile.index), .size = 4096, }, "one line per pane"); - bench(&s, "readdir root", reps, .{ + try bench(&s, "readdir self", reps, .{ .tag = 7, .op = .readdir, - .node = @intFromEnum(acmefs.TopFile.root), + .node = @intFromEnum(filesystem.SelfFile.root), .size = 4096, }, "staged dirents"); - bench(&s, "read event (empty)", reps, .{ + try bench(&s, "read event (empty)", reps, .{ .tag = 8, .op = .read, .node = s.node(.event), .size = 256, }, "Status.again — the blocking primitive"); - // Appending GROWS the fixture, and every append is a whole-body swap plus - // an undo snapshot (that is the core's edit model, not this filesystem's), - // so this row is quadratic in its own rep count against a 1 MiB body. - // Bounded on purpose: the question is what one write costs, and 200 of - // them answer it without spending a quarter of an hour proving that - // appending ten megabytes a kilobyte at a time is slow. - bench(&s, "write body 1K", @min(reps, 200), .{ + try bench(&s, "write body 1K", @min(reps, 200), .{ .tag = 9, .op = .write, .node = s.node(.body), @@ -205,25 +272,15 @@ pub fn main(init: std.process.Init) !void { .size = 1024, }, "append: whole-body swap + undo snapshot"); - // ---- what an unscripted editor pays ---------------------------------- - // The same keystroke, twice: with nobody listening and with one listener. - // The first number is the honest answer to "what does this feature cost a - // session that never uses it", and the pair is what recording costs. - // - // A FRESH SESSION PER ROW, and this matters: typing inserts at the cursor, - // so the line under it grows by one character per rep, and the core's - // per-keystroke cost is dominated by walking that line's grapheme widths - // (measured: `file_pane.graphemeDisplayWidth` is 65% of this benchmark's - // cycles). Reusing one session made the second row type into a body the - // first had already lengthened, and reported a 2.8x "overhead" that was - // entirely the fixture. Small bodies for the same reason: on the megabyte - // fixture a keystroke costs ~40 ms whatever this filesystem does. const key_reps = @min(reps, 2000); + var key_body_bytes: usize = 0; for ([_]bool{ false, true }) |scripted| { var keys = try Session.init(&counting, 32 * 1024); defer keys.deinit(); const pane = keys.core.panes[keys.file_id].?; pane.mode = .insert; + const initial_bytes = pane.file.?.content.len; + key_body_bytes = initial_bytes; if (scripted) { keys.core.fs.panes[keys.file_id].readers = 1; keys.core.fs.listeners = 1; @@ -234,9 +291,20 @@ pub fn main(init: std.process.Init) !void { keys.core.update(.{ .key = .{ .cp = 'x', .text = "x" } }); while (keys.core.nextEffect()) |_| {} } - record( + const elapsed = nowNs() - start; + if (pane.file.?.content.len != initial_bytes + key_reps or pane.cur_col != key_reps or + !std.mem.allEqual(u8, pane.file.?.content[0..key_reps], 'x')) return error.MissingKeystroke; + const events = &keys.core.fs.panes[keys.file_id].events; + if (scripted) for (0..key_reps) |i| { + var buf: [64]u8 = undefined; + const expected = try std.fmt.bufPrint(&buf, "KI{d} {d} 0 1 x\n", .{ i, i + 1 }); + if (!std.mem.eql(u8, expected, events.peek() orelse return error.MissingEvent)) return error.IncorrectEvent; + events.pop(); + }; + if (!events.empty()) return error.IncorrectEvent; + try record( if (scripted) "keystroke, 1 listener" else "keystroke, no listener", - nowNs() - start, + elapsed, key_reps, &counting, if (scripted) "diff + record" else "one branch", @@ -246,11 +314,11 @@ pub fn main(init: std.process.Init) !void { var out: std.Io.Writer.Allocating = .init(backing); defer out.deinit(); const w = &out.writer; - if (json) { - try w.writeAll("{\"rows\":["); + if (options.json) { + try w.print("{{\"harness\":\"{s}\",\"body_bytes\":{d},\"key_body_bytes\":{d},\"rows\":[", .{ harness_id, s.body_bytes, key_body_bytes }); for (rows.items, 0..) |r, n| { if (n > 0) try w.writeAll(","); - try w.print("{{\"name\":\"{s}\",\"ns\":{d},\"allocs\":{d}}}", .{ r.name, r.ns, r.allocs }); + try w.print("{{\"name\":\"{s}\",\"ns\":{d},\"reps\":{d},\"allocs\":{d}}}", .{ r.name, r.ns, r.reps, r.allocs }); } try w.writeAll("]}\n"); } else { @@ -261,3 +329,20 @@ pub fn main(init: std.process.Init) !void { } try std.Io.File.stdout().writeStreamingAll(init.io, out.written()); } + +test "fs benchmark rejects empty workloads and ambiguous options" { + try std.testing.expectEqual(@as(usize, 100_000), (try Options.parse(&.{})).reps); + try std.testing.expectEqualDeep(Options{ .reps = 1, .json = true }, try Options.parse(&.{ "--json", "--reps", "1" })); + try std.testing.expectError(error.MissingRepetitions, Options.parse(&.{"--reps"})); + for ([_][]const u8{ "0", "-1", "no", "99999999999999999999999999" }) |value| + try std.testing.expectError(error.InvalidRepetitions, Options.parse(&.{ "--reps", value })); + for ([_][]const []const u8{ &.{"--typo"}, &.{ "--json", "--json" }, &.{ "--reps", "1", "--reps", "2" } }) |args| + try std.testing.expectError(error.InvalidArgument, Options.parse(args)); +} + +test "fs benchmark rejects wrong or incomplete staged metadata" { + try std.testing.expectEqualStrings("/test.txt\n", try checkNumbers(" 2 9 1024 0 0 /test.txt\n", &.{ 2, 9, 1024, 0, 0 })); + try std.testing.expectError(error.IncorrectReply, checkNumbers("2 9 1023 0 0 /test.txt\n", &.{ 2, 9, 1024, 0, 0 })); + try std.testing.expectError(error.IncorrectReply, checkNumbers("3 9 1024 0 0 /test.txt\n", &.{ 2, 9, 1024, 0, 0 })); + try std.testing.expectError(error.IncorrectReply, checkNumbers("2 9 1024 ", &.{ 2, 9, 1024, 0, 0 })); +} |
