//! Performance bench harness (PLAN §18, milestone-12 rulings 1-4). //! //! `zig build bench -Doptimize=ReleaseFast -- [filter|cache|compile|all] [flags]` //! //! Flags: `--domains=N` (default 1_000_000), `--iters=N` (default 200_000), //! `--seed=N` (default 0x5eed), `--assert`. The default run is informational; //! `--assert` exits non-zero when a §18 target is exceeded — meant for the //! Pi 5 acceptance run, never for CI (required CI stays deterministic). //! //! What each suite measures: //! - `filter`: `matcher.normalize` + `Snapshot.evaluate` per op — the handler's //! filtering work — against a snapshot built from `--domains` generated exact //! entries plus a small wildcard body. Query mix cycles hit, miss and //! parent-walk. Target p95 < 1 ms; VmRSS < 100 MiB with the list loaded. //! - `cache`: `buildKey` + `DnsCache.get` + `packet.setId` — the handler's //! cache-hit path, TTL aging included — on a 10k-entry cache prefilled with a //! realistic response. Query mix alternates hit and miss. Target p95 < 5 ms. //! - `compile`: `compiler.compile` over `--domains` generated hosts lines. //! Wall time, informational (no §18 target). const std = @import("std"); const builtin = @import("builtin"); const core = @import("core"); const matcher = core.matcher; const dns_cache = core.dns_cache; const compiler = core.compiler; const model = core.model; const dns_name = core.dns_name; const dns_types = core.dns_types; const packet = core.packet; const Allocator = std.mem.Allocator; const Writer = std.Io.Writer; const filter_p95_target_ns: u64 = 1 * std.time.ns_per_ms; const cache_p95_target_ns: u64 = 5 * std.time.ns_per_ms; const rss_target_bytes: usize = 100 * 1024 * 1024; const cache_entries: u32 = 10_000; /// Byte-for-byte copy of `response` in tests/fuzz/corpus.zig (a copy on /// purpose, same as the corpus itself: a bench input that changes whenever a /// test fixture is edited is a benchmark that silently shifts). A CNAME to /// www.example.com (TTL 300) plus its A record (TTL 60) and an OPT record, /// so `classify` stores it as a positive entry with a 60 s lifetime. const cached_response = "\x12\x34\x81\x80\x00\x01\x00\x02\x00\x00\x00\x01" ++ "\x07example\x03com\x00\x00\x01\x00\x01" ++ "\xc0\x0c\x00\x05\x00\x01\x00\x00\x01\x2c\x00\x06\x03www\xc0\x0c" ++ "\xc0\x29\x00\x01\x00\x01\x00\x00\x00\x3c\x00\x04\x5d\xb8\xd8\x22" ++ "\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x00"; /// Strictly ascending, so `DomainSet.build` accepts it. Never matched by the /// generated queries: the wildcards exist to be walked past, the way a real /// snapshot's wildcard set is on most queries. const wild_body = "ads.bench.invalid\nmetrics.bench.invalid\ntelemetry.bench.invalid\n"; const usage = "usage: zig build bench -Doptimize=ReleaseFast -- " ++ "[filter|cache|compile|all] [--domains=N] [--iters=N] [--seed=N] [--assert]"; const Suite = enum { filter, cache, compile, all }; const Options = struct { suite: Suite = .all, domains: u32 = 1_000_000, iters: u32 = 200_000, seed: u64 = 0x5eed, assert: bool = false, }; pub fn main(init: std.process.Init) !u8 { const arena = init.arena.allocator(); const gpa = init.gpa; const io = init.io; const args = try init.minimal.args.toSlice(arena); const opts = parseOptions(args); var out_buffer: [4096]u8 = undefined; var out = std.Io.File.stdout().writer(io, &out_buffer); const w = &out.interface; if (builtin.mode == .Debug) { try w.print("warning: Debug build; run with -Doptimize=ReleaseFast for meaningful numbers\n", .{}); } try w.print("nxdns bench suite={t} domains={d} iters={d} seed=0x{x} optimize={t}\n\n", .{ opts.suite, opts.domains, opts.iters, opts.seed, builtin.mode, }); try w.print("{s:<9}{s:>10}{s:>12}{s:>12}{s:>12}{s:>12}\n", .{ "suite", "ops", "p50(us)", "p95(us)", "p99(us)", "max(us)", }); var exceeded: u32 = 0; if (opts.suite == .filter or opts.suite == .all) exceeded += try runFilter(io, gpa, opts, w); if (opts.suite == .cache or opts.suite == .all) exceeded += try runCache(io, gpa, opts, w); if (opts.suite == .compile or opts.suite == .all) try runCompile(io, gpa, opts, w); if (exceeded > 0) try w.print("\n{d} target(s) exceeded\n", .{exceeded}); try w.flush(); return if (opts.assert and exceeded > 0) 1 else 0; } fn parseOptions(args: []const [:0]const u8) Options { var opts: Options = .{}; for (args[1..]) |arg| { if (std.mem.eql(u8, arg, "--assert")) { opts.assert = true; } else if (std.mem.startsWith(u8, arg, "--domains=")) { opts.domains = parseNumber(u32, arg, "--domains="); } else if (std.mem.startsWith(u8, arg, "--iters=")) { opts.iters = parseNumber(u32, arg, "--iters="); } else if (std.mem.startsWith(u8, arg, "--seed=")) { opts.seed = parseNumber(u64, arg, "--seed="); } else if (std.meta.stringToEnum(Suite, arg)) |suite| { opts.suite = suite; } else { std.process.fatal("unknown argument '{s}'\n{s}", .{ arg, usage }); } } if (opts.iters == 0) std.process.fatal("--iters must be at least 1", .{}); if (opts.domains == 0) std.process.fatal("--domains must be at least 1", .{}); // Seven zero-padded digits keep generation order equal to sorted order; // the DomainSet cap is lower anyway. if (opts.domains > 4_000_000) std.process.fatal("--domains must be at most 4000000", .{}); return opts; } fn parseNumber(comptime T: type, arg: []const u8, prefix: []const u8) T { return std.fmt.parseInt(T, arg[prefix.len..], 10) catch { std.process.fatal("bad value in '{s}'\n{s}", .{ arg, usage }); }; } // --------------------------------------------------------------------------- // Suites // --------------------------------------------------------------------------- /// Returns how many §18 targets the suite exceeded. fn runFilter(io: std.Io, gpa: Allocator, opts: Options, w: *Writer) !u32 { var body: std.ArrayList(u8) = .empty; errdefer body.deinit(gpa); try body.ensureTotalCapacity(gpa, @as(usize, opts.domains) * 20); var line: [64]u8 = undefined; for (0..opts.domains) |i| { const text = std.fmt.bufPrint(&line, "d{d:0>7}.example.com\n", .{i}) catch unreachable; try body.appendSlice(gpa, text); } const sources = [_]model.BlocklistSource{.{ .url = "bench://list", .name = "bench" }}; const links = [_]model.GroupSource{.{ .group = "default", .source_url = "bench://list" }}; var snapshot = try matcher.Snapshot.build(gpa, .{ .groups = &.{.{ .name = "default" }}, .group_ids = &.{1}, .group_sources = &links, .sources = &sources, .source_ids = &.{1}, .rules = &.{}, .clients = &.{}, .prefixes = &.{}, .compiled = &.{.{ .list_body = body.items, .wild_body = wild_body }}, .seed = opts.seed, .generation = 1, }); defer snapshot.deinit(); // The snapshot copied everything it needs; freeing the source body before // the RSS read keeps the memory number about the loaded snapshot. // clearAndFree leaves the list valid so the errdefer above stays safe. body.clearAndFree(gpa); var prng = std.Random.DefaultPrng.init(opts.seed); const random = prng.random(); const pool = try gpa.alloc(dns_name.Name, 4096); defer gpa.free(pool); for (pool, 0..) |*entry, i| { const r = random.uintLessThan(u32, opts.domains); const text = switch (i % 3) { 0 => std.fmt.bufPrint(&line, "d{d:0>7}.example.com", .{r}), 1 => std.fmt.bufPrint(&line, "m{d:0>7}.example.org", .{r}), else => std.fmt.bufPrint(&line, "a.b.d{d:0>7}.example.com", .{r}), } catch unreachable; entry.* = dns_name.fromText(text) catch unreachable; } const samples = try gpa.alloc(u64, opts.iters); defer gpa.free(samples); var buf: [dns_types.max_name_len]u8 = undefined; for (pool) |qname| { std.mem.doNotOptimizeAway(snapshot.evaluate(0, matcher.normalize(qname, &buf)).blocked); } var blocked: u64 = 0; for (samples, 0..) |*sample, i| { const qname = pool[i % pool.len]; const t0 = std.Io.Clock.awake.now(io); const domain = matcher.normalize(qname, &buf); const decision = snapshot.evaluate(0, domain); const t1 = std.Io.Clock.awake.now(io); sample.* = @intCast(@max(0, t0.durationTo(t1).toNanoseconds())); if (decision.blocked) blocked += 1; } if (blocked == 0) std.process.fatal("filter bench blocked nothing; the suite is broken", .{}); const pct = percentiles(samples); const rss = vmRssBytes(io); try printRow(w, "filter", opts.iters, pct); try w.print(" blocked {d}/{d}, Snapshot.memoryBytes {d:.1} MiB, VmRSS {d:.1} MiB\n", .{ blocked, opts.iters, mib(snapshot.memoryBytes()), mib(rss), }); var exceeded: u32 = 0; exceeded += try printTarget(w, "p95 < 1ms", pct.p95 < filter_p95_target_ns); exceeded += try printTarget(w, "VmRSS < 100 MiB", rss < rss_target_bytes); return exceeded; } fn runCache(io: std.Io, gpa: Allocator, opts: Options, w: *Writer) !u32 { var cache = try dns_cache.DnsCache.init(gpa, .{ .size = cache_entries, .negative_ttl_max = 3600, }); defer cache.deinit(); const class = dns_cache.classify(cached_response, 3600) orelse { std.process.fatal("cache bench response is not cacheable; the suite is broken", .{}); }; const filled_at: i64 = 1_000_000; var key_buf: [dns_cache.max_key_len]u8 = undefined; var text_buf: [64]u8 = undefined; for (0..cache_entries) |i| { const qname = std.fmt.bufPrint(&text_buf, "c{d:0>5}.example.com", .{i}) catch unreachable; const key = dns_cache.buildKey(&key_buf, qname, 1, 1, false, null); try cache.put(filled_at, key, cached_response, class); } const samples = try gpa.alloc(u64, opts.iters); defer gpa.free(samples); var prng = std.Random.DefaultPrng.init(opts.seed); const random = prng.random(); // Inside the entry's 60 s lifetime, far enough in to make `get` age TTLs. const queried_at = filled_at + 30; var out_buf: [512]u8 = undefined; var hits: u64 = 0; for (samples, 0..) |*sample, i| { const r = random.uintLessThan(u32, cache_entries); const qname = if (i % 2 == 0) std.fmt.bufPrint(&text_buf, "c{d:0>5}.example.com", .{r}) catch unreachable else std.fmt.bufPrint(&text_buf, "x{d:0>5}.example.org", .{r}) catch unreachable; const t0 = std.Io.Clock.awake.now(io); const key = dns_cache.buildKey(&key_buf, qname, 1, 1, false, null); const found = cache.get(queried_at, key, &out_buf); if (found) |bytes| packet.setId(bytes, @truncate(i)); const t1 = std.Io.Clock.awake.now(io); sample.* = @intCast(@max(0, t0.durationTo(t1).toNanoseconds())); if (found != null) hits += 1; } if (hits == 0) std.process.fatal("cache bench hit nothing; the suite is broken", .{}); const pct = percentiles(samples); const rss = vmRssBytes(io); try printRow(w, "cache", opts.iters, pct); try w.print(" hits {d}/{d}, DnsCache.memoryBytes {d:.1} MiB, VmRSS {d:.1} MiB\n", .{ hits, opts.iters, mib(cache.memoryBytes()), mib(rss), }); return try printTarget(w, "p95 < 5ms", pct.p95 < cache_p95_target_ns); } fn runCompile(io: std.Io, gpa: Allocator, opts: Options, w: *Writer) !void { if (opts.domains > compiler.max_domains) { std.process.fatal("compile suite needs --domains <= {d}", .{compiler.max_domains}); } var body: std.ArrayList(u8) = .empty; defer body.deinit(gpa); try body.ensureTotalCapacity(gpa, @as(usize, opts.domains) * 28); var line: [64]u8 = undefined; for (0..opts.domains) |i| { const text = std.fmt.bufPrint(&line, "0.0.0.0 d{d:0>7}.example.com\n", .{i}) catch unreachable; try body.appendSlice(gpa, text); } var reader = std.Io.Reader.fixed(body.items); var list_buf: [4096]u8 = undefined; var wild_buf: [4096]u8 = undefined; var list_out: Writer.Discarding = .init(&list_buf); var wild_out: Writer.Discarding = .init(&wild_buf); const t0 = std.Io.Clock.awake.now(io); const result = compiler.compile(gpa, &reader, .hosts, &list_out.writer, &wild_out.writer) catch |err| { std.process.fatal("compiler.compile failed: {t}", .{err}); }; const t1 = std.Io.Clock.awake.now(io); if (result.counts.domains != opts.domains) { std.process.fatal("compile kept {d} of {d} domains; the suite is broken", .{ result.counts.domains, opts.domains, }); } const elapsed_ns: u64 = @intCast(@max(1, t0.durationTo(t1).toNanoseconds())); const lines_per_s = @as(u64, opts.domains) * std.time.ns_per_s / elapsed_ns; try w.print("{s:<9}{d:>10} wall {f}, {d} lines/s, {d} domains kept (informational)\n", .{ "compile", opts.domains, std.Io.Duration.fromNanoseconds(@intCast(elapsed_ns)), lines_per_s, result.counts.domains, }); } // --------------------------------------------------------------------------- // Reporting helpers // --------------------------------------------------------------------------- const Percentiles = struct { p50: u64, p95: u64, p99: u64, max: u64 }; /// Nearest-rank percentiles over per-op nanoseconds. Sorts `samples` in place. fn percentiles(samples: []u64) Percentiles { std.debug.assert(samples.len > 0); std.mem.sort(u64, samples, {}, std.sort.asc(u64)); return .{ .p50 = atRank(samples, 50), .p95 = atRank(samples, 95), .p99 = atRank(samples, 99), .max = samples[samples.len - 1], }; } fn atRank(sorted: []const u64, pct: usize) u64 { const rank = (sorted.len * pct + 99) / 100; return sorted[@max(rank, 1) - 1]; } fn printRow(w: *Writer, suite: []const u8, ops: u32, pct: Percentiles) !void { try w.print("{s:<9}{d:>10}{d:>12.2}{d:>12.2}{d:>12.2}{d:>12.2}\n", .{ suite, ops, us(pct.p50), us(pct.p95), us(pct.p99), us(pct.max), }); } fn printTarget(w: *Writer, target: []const u8, ok: bool) !u32 { try w.print(" target {s}: {s}\n", .{ target, if (ok) "PASS" else "FAIL" }); return @intFromBool(!ok); } fn us(ns: u64) f64 { return @as(f64, @floatFromInt(ns)) / @as(f64, std.time.ns_per_us); } fn mib(bytes: usize) f64 { return @as(f64, @floatFromInt(bytes)) / (1024.0 * 1024.0); } /// The kernel's resident-set figure, since nothing in-repo wraps it. In-repo /// accounting (`Snapshot.memoryBytes`, `DnsCache.memoryBytes`) is reported /// alongside; the two bracket the truth from below and above. fn vmRssBytes(io: std.Io) usize { var file = std.Io.Dir.cwd().openFile(io, "/proc/self/status", .{}) catch |err| { std.process.fatal("cannot open /proc/self/status: {t}", .{err}); }; defer file.close(io); // procfs reports a zero size to stat, so the size-aware alloc readers see // an instant end-of-stream; a plain streaming read does not. var reader_buf: [64]u8 = undefined; var reader = file.readerStreaming(io, &reader_buf); var status_buf: [8192]u8 = undefined; const len = reader.interface.readSliceShort(&status_buf) catch |err| { std.process.fatal("cannot read /proc/self/status: {t}", .{err}); }; const status = status_buf[0..len]; var lines = std.mem.splitScalar(u8, status, '\n'); while (lines.next()) |status_line| { if (!std.mem.startsWith(u8, status_line, "VmRSS:")) continue; var fields = std.mem.tokenizeAny(u8, status_line["VmRSS:".len..], " \t"); const kib = fields.next() orelse break; return 1024 * (std.fmt.parseInt(usize, kib, 10) catch break); } std.process.fatal("no VmRSS line in /proc/self/status", .{}); }