From b1485c97b493f7eaf65c6afdfb82e3065306b8fe Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 22:58:53 -0300 Subject: The PDF benches share their clock, fatal exit, hash, count parser and effect drain, and lspprobe is lspbench's probe subcommand B4: test/bench_util.zig holds what pdf_bench, pdf_pointer_bench, pdf_scroll_bench and pdf_sections_bench each copied (nowNs x4, fatal x3 named per bench, mix x3, parsePositive/parseCount x3, drainEffects x2). They stay four entry points: pdf_bench links its own mupdf module (with its C source and c_heap) where the others link the core's, which one executable cannot import under one name, and their reports and identities are each bench's own. pdf_sections_bench had rotted since the pane's cursor and selections moved to its body and the render revision became next_raster_revision; it builds and runs again. C6: tools/lspprobe.zig is lspbench's `probe` subcommand (zig build lspbench -- probe :), and the lspprobe build step goes; docs/lsp.md names the new command. Co-Authored-By: Claude Opus 5.5 --- build.zig | 16 +--- docs/lsp.md | 2 +- test/bench_util.zig | 43 +++++++++++ test/lspbench.zig | 169 ++++++++++++++++++++++++++++++++++++++++ test/pdf_bench.zig | 23 ++---- test/pdf_pointer_bench.zig | 14 +--- test/pdf_scroll_bench.zig | 43 +++-------- test/pdf_sections_bench.zig | 65 +++++----------- tools/lspprobe.zig | 184 -------------------------------------------- 9 files changed, 251 insertions(+), 308 deletions(-) create mode 100644 test/bench_util.zig delete mode 100644 tools/lspprobe.zig diff --git a/build.zig b/build.zig index fcb4b0b7..eef05b44 100644 --- a/build.zig +++ b/build.zig @@ -1370,7 +1370,7 @@ pub fn build(b: *std.Build) void { if (b.args) |args| run_lspbench.addArgs(args); run_lspbench.setCwd(b.path(".")); run_lspbench.has_side_effects = true; - const lspbench_step = b.step("lspbench", "language-backend latency + feature matrix (-- [--json] [--check] [repo-root])"); + const lspbench_step = b.step("lspbench", "language-backend latency + feature matrix (-- [--json] [--check] [repo-root]), or one query (-- probe : [arg] [--reps N])"); lspbench_step.dependOn(&run_lspbench.step); const run_lspbench_check = b.addRunArtifact(lspbench); run_lspbench_check.addArg("--check"); @@ -1380,20 +1380,6 @@ pub fn build(b: *std.Build) void { const lspbench_check_step = b.step("lspbench-check", "require every configured language probe to complete correctly"); lspbench_check_step.dependOn(&run_lspbench_check.step); - const lspprobe = b.addExecutable(.{ - .name = "pardes-lspprobe", - .root_module = b.createModule(.{ - .target = target, - .optimize = optimize, - .root_source_file = b.path("tools/lspprobe.zig"), - .link_libc = true, - }), - }); - lspprobe.root_module.addImport("pardes", core_module); - const run_lspprobe = b.addRunArtifact(lspprobe); - if (b.args) |args| run_lspprobe.addArgs(args); - const lspprobe_step = b.step("lspprobe", "one language query against the real seam (-- : [arg] [--reps N])"); - lspprobe_step.dependOn(&run_lspprobe.step); const locations_bench_step = b.step("locations-bench", "measure cold/warm result formatting (-Doptimize=ReleaseFast; CSV on stderr)"); if (optimize != .ReleaseFast) { diff --git a/docs/lsp.md b/docs/lsp.md index ae350156..22cf1b90 100644 --- a/docs/lsp.md +++ b/docs/lsp.md @@ -51,7 +51,7 @@ position encoding, and handles both push and pull diagnostics. Startup and request waits have deadlines; failed starts back off before retrying. Worker status messages reach the host event queue. -`zig build lspprobe -- gd :` queries the same backend from +`zig build lspbench -- probe gd :` queries the same backend from the command line. `zig build lspbench` measures it. The Zig snapshot tests (lsp, lspcomplete, lspdebug and the rest) run the real `zls`; the protocol ones use a Zig mock server with deterministic answers while exercising the real client, diff --git a/test/bench_util.zig b/test/bench_util.zig new file mode 100644 index 00000000..9b91e621 --- /dev/null +++ b/test/bench_util.zig @@ -0,0 +1,43 @@ +//! What the PDF benches share: a clock, a fatal exit named for the bench, a +//! seeded hash, a count parser and the core's effect drain. +const std = @import("std"); + +pub fn nowNs() u64 { + var ts: std.c.timespec = undefined; + _ = std.c.clock_gettime(.MONOTONIC, &ts); + return @as(u64, @intCast(ts.sec)) *| 1_000_000_000 +| @as(u64, @intCast(ts.nsec)); +} + +/// `fatal` for the bench called `who`: says so, prefixed, and exits 1. +pub fn Fatal(comptime who: []const u8) type { + return struct { + pub fn f(comptime format: []const u8, args: anytype) noreturn { + std.debug.print(who ++ ": " ++ format ++ "\n", args); + std.process.exit(1); + } + }; +} + +/// A seeded hash of `value`'s bytes; a comptime integer hashes as a u64. +pub fn mix(seed: u64, value: anytype) u64 { + const Value = @TypeOf(value); + const Stable = switch (@typeInfo(Value)) { + .comptime_int => u64, + else => Value, + }; + var stable: Stable = value; + return std.hash.Wyhash.hash(seed, std.mem.asBytes(&stable)); +} + +/// A flag's value as a count in 1..`maximum`, or `fatal` saying why not. +pub fn parsePositive(comptime fatal: anytype, name: []const u8, text: []const u8, maximum: usize) usize { + const value = std.fmt.parseUnsigned(usize, text, 10) catch + fatal("{s} expects a positive integer", .{name}); + if (value == 0 or value > maximum) + fatal("{s} must be in 1..{d}", .{ name, maximum }); + return value; +} + +pub fn drainEffects(core: anytype) void { + while (core.nextEffect()) |_| {} +} diff --git a/test/lspbench.zig b/test/lspbench.zig index e094ed69..02138d24 100644 --- a/test/lspbench.zig +++ b/test/lspbench.zig @@ -114,6 +114,7 @@ pub fn main(init: std.process.Init) !void { const gpa = std.heap.page_allocator; const args = try init.minimal.args.toSlice(init.arena.allocator()); + if (args.len > 1 and std.mem.eql(u8, args[1], "probe")) return probeCommand(init); const options = try Options.parse(args[1..]); const rss0 = rssKib(); @@ -295,3 +296,171 @@ fn rssKib() u64 { } return 0; } + +// ---- probe: one query from the command line ---- + +fn nowUs() u64 { + var ts: std.c.timespec = undefined; + _ = std.c.clock_gettime(.MONOTONIC, &ts); + return @as(u64, @intCast(ts.sec)) *| 1_000_000 +| @as(u64, @intCast(ts.nsec)) / 1000; +} + +const aliases = .{ + .{ "gd", lsp.Kind.definition }, + .{ "gD", lsp.Kind.declaration }, + .{ "gy", lsp.Kind.type_definition }, + .{ "gi", lsp.Kind.implementation }, + .{ "gr", lsp.Kind.references }, +}; + +fn probeUsage() noreturn { + std.debug.print( + \\usage: pardes-lspbench probe : [arg] [--reps N] [--warm S] + \\ kind: gd gD gy gi gr, or any lsp.Kind tag (hover, references, + \\ document_symbols, workspace_symbols, incoming_calls, ...) + \\ arg: the new name for rename, the query for workspace_symbols + \\ --warm sleep S seconds after the first (cold) query — long enough + \\ for an indexing server to reach ready, narrated on stderr + \\ + , .{}); + std.process.exit(2); +} + +/// The editor's message row, here: the client's reader threads narrate server +/// state through the same sink the shells register, printed to stderr so the +/// rows on stdout stay clean. +fn statusSink(ctx: ?*anyopaque, text: []const u8) void { + _ = ctx; + std.debug.print("# status: {s}\n", .{text}); +} + +extern "c" fn usleep(usec: c_uint) c_int; + +/// `lspbench probe`: one language query against the real seam, from the +/// command line (the old lspprobe): `probe : [arg] +/// [--reps N] [--warm S]`, rows on stdout, the timing on stderr. +fn probeCommand(init: std.process.Init) !void { + const gpa = std.heap.page_allocator; + const arena = init.arena.allocator(); + const io = std.Io.Threaded.global_single_threaded.io(); + + // args[1] is `probe`: the rest are where lspprobe's were. + const all = try init.minimal.args.toSlice(arena); + const args = all[1..]; + if (args.len < 4) probeUsage(); + + // comma-separated kinds share ONE server instance and its index — the + // whole battery against a big workspace pays the cold cost once + var kinds: [16]lsp.Kind = undefined; + var nkinds: usize = 0; + var kit = std.mem.tokenizeScalar(u8, args[1], ','); + while (kit.next()) |word| { + if (nkinds == kinds.len) probeUsage(); + kinds[nkinds] = blk: { + inline for (aliases) |a| if (std.mem.eql(u8, word, a[0])) break :blk a[1]; + break :blk std.meta.stringToEnum(lsp.Kind, word) orelse probeUsage(); + }; + nkinds += 1; + } + if (nkinds == 0) probeUsage(); + + var pz: [4096:0]u8 = undefined; + const pz_s = std.fmt.bufPrintSentinel(&pz, "{s}", .{args[2]}, 0) catch probeUsage(); + var realbuf: [4096]u8 = undefined; + const path = std.mem.span(realpath(pz_s.ptr, &realbuf) orelse { + std.debug.print("cannot resolve {s}\n", .{args[2]}); + std.process.exit(1); + }); + const source = std.Io.Dir.cwd().readFileAllocOptions( + io, + path, + arena, + .limited(64 << 20), + .of(u8), + 0, + ) catch { + std.debug.print("cannot read {s}\n", .{path}); + std.process.exit(1); + }; + + const colon = std.mem.indexOfScalar(u8, args[3], ':') orelse probeUsage(); + const line = std.fmt.parseInt(u32, args[3][0..colon], 10) catch probeUsage(); + const col = std.fmt.parseInt(u32, args[3][colon + 1 ..], 10) catch probeUsage(); + if (line == 0 or col == 0) probeUsage(); + + var arg: []const u8 = ""; + var reps: u32 = 1; + var warm_s: u32 = 0; + var i: usize = 4; + while (i < args.len) : (i += 1) { + if (std.mem.eql(u8, args[i], "--reps")) { + i += 1; + if (i >= args.len) probeUsage(); + reps = @max(1, std.fmt.parseInt(u32, args[i], 10) catch probeUsage()); + } else if (std.mem.eql(u8, args[i], "--warm")) { + i += 1; + if (i >= args.len) probeUsage(); + warm_s = std.fmt.parseInt(u32, args[i], 10) catch probeUsage(); + } else arg = args[i]; + } + + lsp.setStatusSink(null, statusSink); + + // 1-based line:col -> byte offset + var off: usize = 0; + var ln: u32 = 1; + while (ln < line and off < source.len) : (off += 1) { + if (source[off] == '\n') ln += 1; + } + off = @min(off + col - 1, source.len); + + const root = std.fs.path.dirname(path) orelse "/"; + const timings = try arena.alloc(u64, reps); + var stdout_buf: [64 * 1024]u8 = undefined; + var stdout = std.Io.File.stdout().writer(io, &stdout_buf); + var first_query = true; + + for (kinds[0..nkinds]) |kind| { + var rows: []const u8 = ""; + for (0..reps) |r| { + // one warm pause for the whole battery, right after the very + // first (cold, server-spawning) query — the status lines on + // stderr narrate what the server does with the time + var out: std.Io.Writer.Allocating = .init(gpa); + defer out.deinit(); + var scratch: std.heap.ArenaAllocator = .init(gpa); + defer scratch.deinit(); + const t0 = nowUs(); + try lsp.query(gpa, scratch.allocator(), .{ + .kind = kind, + .path = path, + .source = source, + .offset = @intCast(off), + .arg = arg, + .root = root, + }, &out.writer); + timings[r] = nowUs() -| t0; + rows = try arena.dupe(u8, out.written()); + if (first_query) { + first_query = false; + if (warm_s > 0) for (0..warm_s * 10) |_| { + _ = usleep(100_000); + }; + } + } + + try stdout.interface.print("## {t}\n", .{kind}); + try stdout.interface.writeAll(rows); + try stdout.interface.flush(); + + const n = std.mem.count(u8, rows, "\n"); + const cold = timings[0]; + std.mem.sort(u64, timings[0..reps], {}, std.sort.asc(u64)); + if (reps == 1) + std.debug.print("# {t}: {d} row(s) in {d}us\n", .{ kind, n, cold }) + else + std.debug.print("# {t}: {d} row(s); first {d}us, min {d}us, median {d}us ({d} reps)\n", .{ + kind, n, cold, timings[0], timings[reps / 2], reps, + }); + } +} diff --git a/test/pdf_bench.zig b/test/pdf_bench.zig index 0f09f72a..23565106 100644 --- a/test/pdf_bench.zig +++ b/test/pdf_bench.zig @@ -13,6 +13,9 @@ //! filesystem cache is deliberately uncontrolled), while warm scenarios //! retain one Document. const std = @import("std"); +const bench_util = @import("bench_util.zig"); +const nowNs = bench_util.nowNs; +const fatal = bench_util.Fatal("pdf-bench").f; const pdf = @import("mupdf"); pub const std_options: std.Options = .{ .log_level = .err }; @@ -205,14 +208,6 @@ fn parseArgs(args: []const []const u8) Config { return config; } -fn parsePositive(name: []const u8, text: []const u8, maximum: usize) usize { - const value = std.fmt.parseUnsigned(usize, text, 10) catch - fatal("{s} expects a positive integer", .{name}); - if (value == 0 or value > maximum) - fatal("{s} must be in 1..{d}", .{ name, maximum }); - return value; -} - fn usage() noreturn { printUsage(); std.process.exit(2); @@ -226,13 +221,6 @@ fn printUsage() void { ); } -fn nowNs() u64 { - var ts: std.c.timespec = undefined; - _ = std.c.clock_gettime(.MONOTONIC, &ts); - return @as(u64, @intCast(ts.sec)) *| 1_000_000_000 +| - @as(u64, @intCast(ts.nsec)); -} - fn measure( config: Config, profile_index: usize, @@ -617,7 +605,6 @@ fn reportJson( std.Io.File.stdout().writeStreamingAll(io, out.buffered()) catch {}; } -fn fatal(comptime format: []const u8, args: anytype) noreturn { - std.debug.print("pdf-bench: " ++ format ++ "\n", args); - std.process.exit(1); +fn parsePositive(name: []const u8, text: []const u8, maximum: usize) usize { + return bench_util.parsePositive(fatal, name, text, maximum); } diff --git a/test/pdf_pointer_bench.zig b/test/pdf_pointer_bench.zig index f1d61229..ae2de2f5 100644 --- a/test/pdf_pointer_bench.zig +++ b/test/pdf_pointer_bench.zig @@ -15,6 +15,9 @@ //! frame each step settles on. It is the no-regression proof: a change to how highlights //! are painted may change what it costs, never one presented pixel. const std = @import("std"); +const bench_util = @import("bench_util.zig"); +const nowNs = bench_util.nowNs; +const mix = bench_util.mix; const pardes = @import("pardes"); const pdf = @import("mupdf"); @@ -208,17 +211,6 @@ fn print(comptime format: []const u8, args: anytype) void { _ = std.c.write(1, line.ptr, line.len); } -fn mix(seed: u64, value: anytype) u64 { - var copy = value; - return std.hash.Wyhash.hash(seed, std.mem.asBytes(©)); -} - -fn nowNs() u64 { - var ts: std.c.timespec = undefined; - _ = std.c.clock_gettime(.MONOTONIC, &ts); - return @as(u64, @intCast(ts.sec)) * 1_000_000_000 + @as(u64, @intCast(ts.nsec)); -} - comptime { _ = pdf.Document; } diff --git a/test/pdf_scroll_bench.zig b/test/pdf_scroll_bench.zig index 33dfe70a..a4511dbe 100644 --- a/test/pdf_scroll_bench.zig +++ b/test/pdf_scroll_bench.zig @@ -42,6 +42,11 @@ //! //! Setup, teardown, checksumming, and reporting are outside every timer. const std = @import("std"); +const bench_util = @import("bench_util.zig"); +const nowNs = bench_util.nowNs; +const fatal = bench_util.Fatal("pdf-scroll-bench").f; +const mix = bench_util.mix; +const drainEffects = bench_util.drainEffects; const libc = std.c; const pardes = @import("pardes"); const pdf = @import("mupdf"); @@ -211,14 +216,6 @@ fn parseArgs(args: []const []const u8) Config { return config; } -fn parseCount(flag: []const u8, text: []const u8) usize { - const value = std.fmt.parseUnsigned(usize, text, 10) catch - fatal("{s} expects a positive integer", .{flag}); - if (value == 0 or value > 10_000) - fatal("{s} must be in 1..10000", .{flag}); - return value; -} - const Travel = struct { /// Scrollable display pixels: the whole strip minus one screenful. max_scroll: f64, @@ -520,20 +517,6 @@ fn stridedHash(seed: u64, rgba: []const u8) u64 { return hash; } -fn mix(seed: u64, value: anytype) u64 { - const Value = @TypeOf(value); - const Stable = switch (@typeInfo(Value)) { - .comptime_int => u64, - else => Value, - }; - var stable: Stable = value; - return std.hash.Wyhash.hash(seed, std.mem.asBytes(&stable)); -} - -fn drainEffects(core: *pardes.Pardes) void { - while (core.nextEffect()) |_| {} -} - fn verifyIdentity( name: []const u8, expected: *?Identity, @@ -557,13 +540,6 @@ fn verifyIdentity( } else expected.* = got; } -fn nowNs() u64 { - var ts: std.c.timespec = undefined; - _ = std.c.clock_gettime(.MONOTONIC, &ts); - return @as(u64, @intCast(ts.sec)) *| 1_000_000_000 +| - @as(u64, @intCast(ts.nsec)); -} - fn reportText(config: Config, travel: Travel, results: []const Result) void { std.debug.print("pardes PDF fast-scroll benchmark ({s})\n", .{bench_config.core_optimize}); std.debug.print( @@ -649,13 +625,12 @@ fn reportJson(io: std.Io, config: Config, travel: Travel, results: []const Resul std.Io.File.stdout().writeStreamingAll(io, out.buffered()) catch {}; } -fn fatal(comptime format: []const u8, args: anytype) noreturn { - std.debug.print("pdf-scroll-bench: " ++ format ++ "\n", args); - std.process.exit(1); -} - comptime { // The bench links the real MuPDF wrapper the core uses; keep the import // load-bearing so a build that silently dropped it fails here. _ = pdf.Document; } + +fn parseCount(flag: []const u8, text: []const u8) usize { + return bench_util.parsePositive(fatal, flag, text, 10_000); +} diff --git a/test/pdf_sections_bench.zig b/test/pdf_sections_bench.zig index b08af552..92c9ef7c 100644 --- a/test/pdf_sections_bench.zig +++ b/test/pdf_sections_bench.zig @@ -9,6 +9,11 @@ //! report nanoseconds per operation; `operations_per_sample` makes that //! normalization explicit in both output formats. const std = @import("std"); +const bench_util = @import("bench_util.zig"); +const nowNs = bench_util.nowNs; +const fatal = bench_util.Fatal("pdf-sections-bench").f; +const mix = bench_util.mix; +const drainEffects = bench_util.drainEffects; const libc = std.c; const pardes = @import("pardes"); const pdf = @import("mupdf"); @@ -107,14 +112,6 @@ fn parseArgs(args: []const []const u8) Config { return config; } -fn parseCount(flag: []const u8, text: []const u8) usize { - const value = std.fmt.parseUnsigned(usize, text, 10) catch - fatal("{s} expects a positive integer", .{flag}); - if (value == 0 or value > 10_000) - fatal("{s} must be in 1..10000", .{flag}); - return value; -} - fn measureOutline(path: []const u8, config: Config) !Result { const samples = try gpa.alloc(u64, config.reps); defer gpa.free(samples); @@ -145,7 +142,7 @@ fn measureMaterialize( var expected: ?Identity = null; for (0..config.warmup + config.reps) |round| { const started = nowNs(); - const content = try pardes.PdfSectionRows.render(gpa, path, entries); + const content = try pardes.panes.Pdf.SectionRows.render(gpa, path, entries); const elapsed = nowNs() -| started; const identity = contentIdentity(content); gpa.free(content); @@ -255,7 +252,7 @@ fn measureWorstShape(entries: []const pdf.OutlineEntry, config: Config) !Result var expected: ?Identity = null; for (0..config.warmup + config.reps) |round| { const started = nowNs(); - const content = try pardes.PdfSectionRows.render(gpa, "/tmp/near-limit.pdf", entries); + const content = try pardes.panes.Pdf.SectionRows.render(gpa, "/tmp/near-limit.pdf", entries); const elapsed = nowNs() -| started; const identity = contentIdentity(content); gpa.free(content); @@ -300,11 +297,11 @@ fn perturbReopen(prepared: PreparedReopen, operation: usize) void { const marker: i32 = @intCast(operation % 31 + 1); const output_file = &prepared.output.file.?; output_file.scroll = @intCast(operation % 17 + 1); - prepared.output.cur_row = marker; - prepared.output.cur_col = marker + 1; - prepared.output.msel.active = true; - prepared.output.vsel.active = true; - prepared.output.nsel = 1; + prepared.output.body.cur_row = marker; + prepared.output.body.cur_col = marker + 1; + prepared.output.body.msel.active = true; + prepared.output.body.vsel.active = true; + prepared.output.body.nsel = 1; prepared.owner.search_pane = null; prepared.owner.search_row = operation % 23; prepared.core.active = prepared.output_id; @@ -313,21 +310,17 @@ fn perturbReopen(prepared: PreparedReopen, operation: usize) void { fn observeReopen(prepared: PreparedReopen) u64 { const file = prepared.output.file.?; var observed: u64 = file.scroll; - observed = observed *% 131 +% @as(u64, @intCast(@max(0, prepared.output.cur_row))); - observed = observed *% 131 +% @as(u64, @intCast(@max(0, prepared.output.cur_col))); - observed = observed *% 131 +% @intFromBool(prepared.output.msel.active); - observed = observed *% 131 +% @intFromBool(prepared.output.vsel.active); - observed = observed *% 131 +% prepared.output.nsel; + observed = observed *% 131 +% @as(u64, @intCast(@max(0, prepared.output.body.cur_row))); + observed = observed *% 131 +% @as(u64, @intCast(@max(0, prepared.output.body.cur_col))); + observed = observed *% 131 +% @intFromBool(prepared.output.body.msel.active); + observed = observed *% 131 +% @intFromBool(prepared.output.body.vsel.active); + observed = observed *% 131 +% prepared.output.body.nsel; observed = observed *% 131 +% (prepared.owner.search_pane orelse std.math.maxInt(usize)); observed = observed *% 131 +% (prepared.owner.search_row orelse std.math.maxInt(usize)); observed = observed *% 131 +% prepared.core.active; return observed; } -fn drainEffects(core: *pardes.Pardes) void { - while (core.nextEffect()) |_| {} -} - fn sectionsOutputIdentity(core: *pardes.Pardes) Identity { const owner = core.panes[0] orelse fatal("PDF pane disappeared", .{}); const output_id = owner.search_pane orelse fatal("+PdfSections was not armed", .{}); @@ -347,7 +340,7 @@ fn stepIdentity(core: *pardes.Pardes) Identity { identity.checksum = mix(identity.checksum, view.page); identity.checksum = mix(identity.checksum, @as(u64, @bitCast(view.document_scroll_y))); identity.checksum = mix(identity.checksum, owner.search_row orelse std.math.maxInt(usize)); - identity.checksum = mix(identity.checksum, view.render_revision); + identity.checksum = mix(identity.checksum, view.next_raster_revision); return identity; } @@ -389,16 +382,6 @@ fn outlineIdentity(entries: []const pdf.OutlineEntry) Identity { return .{ .items = entries.len, .bytes = bytes, .checksum = checksum }; } -fn mix(seed: u64, value: anytype) u64 { - const Value = @TypeOf(value); - const Stable = switch (@typeInfo(Value)) { - .comptime_int => u64, - else => Value, - }; - var stable: Stable = value; - return std.hash.Wyhash.hash(seed, std.mem.asBytes(&stable)); -} - fn makeWorstShape() ![]pdf.OutlineEntry { const entries = try gpa.alloc(pdf.OutlineEntry, near_limit_items); for (entries, 0..) |*entry, ordinal| { @@ -457,13 +440,6 @@ fn summarize( }; } -fn nowNs() u64 { - var ts: std.c.timespec = undefined; - _ = std.c.clock_gettime(.MONOTONIC, &ts); - return @as(u64, @intCast(ts.sec)) *| 1_000_000_000 +| - @as(u64, @intCast(ts.nsec)); -} - /// A host benchmark compiler barrier. MuPDF benchmarks are build-and-run /// steps, so they never target the freestanding web backend; an empty volatile /// asm with only the abstract memory clobber is valid on every native backend @@ -545,7 +521,6 @@ fn unlink(path: []const u8) void { _ = libc.unlink(path_z); } -fn fatal(comptime format: []const u8, args: anytype) noreturn { - std.debug.print("pdf-sections-bench: " ++ format ++ "\n", args); - std.process.exit(1); +fn parseCount(flag: []const u8, text: []const u8) usize { + return bench_util.parsePositive(fatal, flag, text, 10_000); } diff --git a/tools/lspprobe.zig b/tools/lspprobe.zig deleted file mode 100644 index 0b7d772c..00000000 --- a/tools/lspprobe.zig +++ /dev/null @@ -1,184 +0,0 @@ -//! One language query against the REAL seam, from the command line. -//! -//! pardes-lspprobe : [arg] [--reps N] -//! -//! `kind` is an `lsp.Kind` tag (`definition`, `references`, ...) or its helix -//! key (`gd`, `gr`, `gy`, `gi`, `gD`). Line and column are 1-based, the way -//! the rows print them. Rows go to stdout; the timing line goes to stderr so -//! `lspprobe gd f.rs 10:5 | head` still composes. -//! -//! This is the development probe for the PROTOCOL CLIENT: point it at a file -//! in any workspace (a rust crate, a C tree with compile_commands.json) and it -//! exercises exactly what `gd` in the editor would — same seam, same client, -//! same child server. The server is spawned fresh per invocation and kept for -//! the process, so rep 0 is the true cold cost (spawn + handshake + whatever -//! indexing the deadline allows) and later reps are the warm per-press cost. -//! Generic on purpose: no project names anywhere — the workspace is whatever -//! the file's root markers say. -const std = @import("std"); -const lsp = @import("pardes").lsp; - -extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*:0]u8; - -pub const std_options: std.Options = .{ .log_level = .err }; - -fn nowUs() u64 { - var ts: std.c.timespec = undefined; - _ = std.c.clock_gettime(.MONOTONIC, &ts); - return @as(u64, @intCast(ts.sec)) *| 1_000_000 +| @as(u64, @intCast(ts.nsec)) / 1000; -} - -const aliases = .{ - .{ "gd", lsp.Kind.definition }, - .{ "gD", lsp.Kind.declaration }, - .{ "gy", lsp.Kind.type_definition }, - .{ "gi", lsp.Kind.implementation }, - .{ "gr", lsp.Kind.references }, -}; - -fn usage() noreturn { - std.debug.print( - \\usage: pardes-lspprobe : [arg] [--reps N] [--warm S] - \\ kind: gd gD gy gi gr, or any lsp.Kind tag (hover, references, - \\ document_symbols, workspace_symbols, incoming_calls, ...) - \\ arg: the new name for rename, the query for workspace_symbols - \\ --warm sleep S seconds after the first (cold) query — long enough - \\ for an indexing server to reach ready, narrated on stderr - \\ - , .{}); - std.process.exit(2); -} - -/// The editor's message row, here: the client's reader threads narrate server -/// state through the same sink the shells register, printed to stderr so the -/// rows on stdout stay clean. -fn statusSink(ctx: ?*anyopaque, text: []const u8) void { - _ = ctx; - std.debug.print("# status: {s}\n", .{text}); -} - -extern "c" fn usleep(usec: c_uint) c_int; - -pub fn main(init: std.process.Init) !void { - const gpa = std.heap.page_allocator; - const arena = init.arena.allocator(); - const io = std.Io.Threaded.global_single_threaded.io(); - - const args = try init.minimal.args.toSlice(arena); - if (args.len < 4) usage(); - - // comma-separated kinds share ONE server instance and its index — the - // whole battery against a big workspace pays the cold cost once - var kinds: [16]lsp.Kind = undefined; - var nkinds: usize = 0; - var kit = std.mem.tokenizeScalar(u8, args[1], ','); - while (kit.next()) |word| { - if (nkinds == kinds.len) usage(); - kinds[nkinds] = blk: { - inline for (aliases) |a| if (std.mem.eql(u8, word, a[0])) break :blk a[1]; - break :blk std.meta.stringToEnum(lsp.Kind, word) orelse usage(); - }; - nkinds += 1; - } - if (nkinds == 0) usage(); - - var pz: [4096:0]u8 = undefined; - const pz_s = std.fmt.bufPrintSentinel(&pz, "{s}", .{args[2]}, 0) catch usage(); - var realbuf: [4096]u8 = undefined; - const path = std.mem.span(realpath(pz_s.ptr, &realbuf) orelse { - std.debug.print("cannot resolve {s}\n", .{args[2]}); - std.process.exit(1); - }); - const source = std.Io.Dir.cwd().readFileAllocOptions( - io, - path, - arena, - .limited(64 << 20), - .of(u8), - 0, - ) catch { - std.debug.print("cannot read {s}\n", .{path}); - std.process.exit(1); - }; - - const colon = std.mem.indexOfScalar(u8, args[3], ':') orelse usage(); - const line = std.fmt.parseInt(u32, args[3][0..colon], 10) catch usage(); - const col = std.fmt.parseInt(u32, args[3][colon + 1 ..], 10) catch usage(); - if (line == 0 or col == 0) usage(); - - var arg: []const u8 = ""; - var reps: u32 = 1; - var warm_s: u32 = 0; - var i: usize = 4; - while (i < args.len) : (i += 1) { - if (std.mem.eql(u8, args[i], "--reps")) { - i += 1; - if (i >= args.len) usage(); - reps = @max(1, std.fmt.parseInt(u32, args[i], 10) catch usage()); - } else if (std.mem.eql(u8, args[i], "--warm")) { - i += 1; - if (i >= args.len) usage(); - warm_s = std.fmt.parseInt(u32, args[i], 10) catch usage(); - } else arg = args[i]; - } - - lsp.setStatusSink(null, statusSink); - - // 1-based line:col -> byte offset - var off: usize = 0; - var ln: u32 = 1; - while (ln < line and off < source.len) : (off += 1) { - if (source[off] == '\n') ln += 1; - } - off = @min(off + col - 1, source.len); - - const root = std.fs.path.dirname(path) orelse "/"; - const timings = try arena.alloc(u64, reps); - var stdout_buf: [64 * 1024]u8 = undefined; - var stdout = std.Io.File.stdout().writer(io, &stdout_buf); - var first_query = true; - - for (kinds[0..nkinds]) |kind| { - var rows: []const u8 = ""; - for (0..reps) |r| { - // one warm pause for the whole battery, right after the very - // first (cold, server-spawning) query — the status lines on - // stderr narrate what the server does with the time - var out: std.Io.Writer.Allocating = .init(gpa); - defer out.deinit(); - var scratch: std.heap.ArenaAllocator = .init(gpa); - defer scratch.deinit(); - const t0 = nowUs(); - try lsp.query(gpa, scratch.allocator(), .{ - .kind = kind, - .path = path, - .source = source, - .offset = @intCast(off), - .arg = arg, - .root = root, - }, &out.writer); - timings[r] = nowUs() -| t0; - rows = try arena.dupe(u8, out.written()); - if (first_query) { - first_query = false; - if (warm_s > 0) for (0..warm_s * 10) |_| { - _ = usleep(100_000); - }; - } - } - - try stdout.interface.print("## {t}\n", .{kind}); - try stdout.interface.writeAll(rows); - try stdout.interface.flush(); - - const n = std.mem.count(u8, rows, "\n"); - const cold = timings[0]; - std.mem.sort(u64, timings[0..reps], {}, std.sort.asc(u64)); - if (reps == 1) - std.debug.print("# {t}: {d} row(s) in {d}us\n", .{ kind, n, cold }) - else - std.debug.print("# {t}: {d} row(s); first {d}us, min {d}us, median {d}us ({d} reps)\n", .{ - kind, n, cold, timings[0], timings[reps / 2], reps, - }); - } -} -- cgit v1.3