summaryrefslogtreecommitdiff
path: root/test/fs_bench.zig
diff options
context:
space:
mode:
Diffstat (limited to 'test/fs_bench.zig')
-rw-r--r--test/fs_bench.zig299
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 }));
+}