383 lines
16 KiB
Zig
383 lines
16 KiB
Zig
//! 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", .{});
|
|
}
|