milestone 12: performance bench harness, measured docs, aarch64 tests under qemu and no-dist size assert
This commit is contained in:
+382
@@ -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", .{});
|
||||
}
|
||||
Reference in New Issue
Block a user