milestone 12: performance bench harness, measured docs, aarch64 tests under qemu and no-dist size assert

This commit is contained in:
2026-08-02 16:08:23 +02:00
parent bdb6ffab7a
commit d522b1f947
5 changed files with 760 additions and 0 deletions
+382
View File
@@ -0,0 +1,382 @@
//! 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", .{});
}