From 2c7f010c39a625ee575e31f25a0948010ad16d0a Mon Sep 17 00:00:00 2001 From: RhizoNymph Date: Tue, 11 Aug 2026 19:26:38 -0700 Subject: [PATCH 01/12] test: add a deterministic synthetic repo generator for benchmarks --- benchmarks/gen_repo.py | 391 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 391 insertions(+) create mode 100644 benchmarks/gen_repo.py diff --git a/benchmarks/gen_repo.py b/benchmarks/gen_repo.py new file mode 100644 index 0000000..bf92e16 --- /dev/null +++ b/benchmarks/gen_repo.py @@ -0,0 +1,391 @@ +#!/usr/bin/env python3 +"""Deterministically generate a synthetic Python (+ optional TypeScript) repo. + +The end-to-end harness (`crates/cc-core/examples/perf_harness.rs`) needs an +input repo that is (a) big enough for the pipeline costs to dominate noise and +(b) BYTE-IDENTICAL between two checkouts, so the same harness run on `main` and +on a feature branch is comparing the same work. Everything here is driven by a +single seeded `random.Random`, and the file order is fixed, so a given +(seed, files, depth, hub_fraction, ts_fraction) tuple always produces the same +tree. + +Shape knobs that matter for what the benchmarks measure: + +- `--files` / `--depth`: node and directory-chain counts, which drive scan cost, + the size of the child -> parent map, and how far edge endpoints have to be + lifted when containers are collapsed. +- `--hub-fraction`: the share of modules that define the SAME hub names + (`get`, `run`, `handle`, `new`, `__init__`). Those names are what push symbol + resolution into its ambiguous tiers, which is the path the early-bail change + in the perf work targets. At 0.0 every symbol is unique and resolution is + trivially cheap; at 0.6 a hub name has hundreds of global definitions. +- `--imports-per-file`: cross-module import + call density, i.e. edge count. + +Usage: + + python3 benchmarks/gen_repo.py --preset medium --out "$TMPDIR/cc-bench-medium" + python3 benchmarks/gen_repo.py --files 500 --depth 3 --hub-fraction 0.5 \ + --out /path/to/repo + +Never generates into the repository itself: `--out` is required (the presets +only pick sizes, not locations). +""" + +from __future__ import annotations + +import argparse +import random +import shutil +import sys +from dataclasses import dataclass +from pathlib import Path + +# Names deliberately shared by many modules. These are the ones that make a +# global symbol lookup return hundreds of candidates. +HUB_FUNCTIONS = ["run", "build", "handle", "process"] +HUB_METHODS = ["__init__", "get", "new", "update", "close"] + +PRESETS = { + "small": dict(files=200, depth=3, imports_per_file=3, hub_fraction=0.5), + "medium": dict(files=2000, depth=4, imports_per_file=4, hub_fraction=0.5), + "large": dict(files=10000, depth=5, imports_per_file=4, hub_fraction=0.5), +} + + +@dataclass(frozen=True) +class Module: + """One generated module: where it lives and whether it defines hub names.""" + + index: int + package: tuple[str, ...] + name: str + is_hub: bool + is_ts: bool + + @property + def rel_path(self) -> Path: + suffix = ".ts" if self.is_ts else ".py" + return Path(*self.package) / f"{self.name}{suffix}" + + @property + def dotted(self) -> str: + """Python import path (`pkg_0.pkg_3.mod_17`).""" + return ".".join([*self.package, self.name]) + + +def build_package_tree(rng: random.Random, depth: int, count: int) -> list[tuple[str, ...]]: + """Return `count` package paths, at most `depth` levels deep. + + Built breadth-first from a fixed branching factor so the directory tree is + bushy rather than a single chain: real repos have a handful of top-level + packages and progressively fewer deep ones. + """ + packages: list[tuple[str, ...]] = [()] + frontier: list[tuple[str, ...]] = [()] + branching = 4 + next_id = 0 + + while len(packages) < count and frontier: + parent = frontier.pop(0) + if len(parent) >= depth: + continue + for _ in range(branching): + if len(packages) >= count: + break + child = (*parent, f"pkg_{next_id}") + next_id += 1 + packages.append(child) + frontier.append(child) + + # Shuffle only the ORDER modules are assigned to packages, not the set, so + # the tree shape is stable while file placement still looks irregular. + rng.shuffle(packages) + return packages + + +def plan_modules( + rng: random.Random, files: int, depth: int, hub_fraction: float, ts_fraction: float +) -> list[Module]: + package_count = max(1, files // 6) + packages = build_package_tree(rng, depth, package_count) + + modules: list[Module] = [] + for i in range(files): + package = packages[i % len(packages)] + is_hub = rng.random() < hub_fraction + is_ts = rng.random() < ts_fraction + modules.append( + Module(index=i, package=package, name=f"mod_{i}", is_hub=is_hub, is_ts=is_ts) + ) + return modules + + +def pick_imports(rng: random.Random, modules: list[Module], me: Module, k: int) -> list[Module]: + """`k` distinct other modules for `me` to import, chosen deterministically.""" + if len(modules) <= 1 or k <= 0: + return [] + picked: list[Module] = [] + seen = {me.index} + for _ in range(k * 3): + if len(picked) >= k: + break + candidate = modules[rng.randrange(len(modules))] + if candidate.index in seen or candidate.is_ts != me.is_ts: + continue + seen.add(candidate.index) + picked.append(candidate) + return picked + + +def python_module_source(module: Module, imports: list[Module]) -> str: + i = module.index + lines: list[str] = [f'"""Generated module {module.dotted}."""', "", "import os", "import sys"] + + for dep in imports: + lines.append(f"from {dep.dotted} import Widget{dep.index}, make_{dep.index}") + lines.append("") + lines.append("") + + # Class with either hub method names or module-unique ones. + methods = HUB_METHODS if module.is_hub else [f"{m}_{i}" for m in HUB_METHODS] + lines.append(f"class Widget{i}:") + lines.append(f' """Widget defined by {module.dotted}."""') + lines.append("") + for method in methods: + if method == "__init__": + lines.append(" def __init__(self, name=None):") + lines.append(" self.name = name") + lines.append(" self.items = []") + elif method.startswith("__init__"): + # Unique-name variant of the constructor for non-hub modules. + lines.append(" def __init__(self, name=None):") + lines.append(" self.name = name") + lines.append(" self.items = []") + else: + lines.append(f" def {method}(self, value=None):") + lines.append(" if value is not None:") + lines.append(" self.items.append(value)") + lines.append(" return self.items") + lines.append("") + + # Free functions: hub-named for hub modules, unique otherwise. + functions = HUB_FUNCTIONS if module.is_hub else [f"{f}_{i}" for f in HUB_FUNCTIONS] + for func in functions: + lines.append(f"def {func}(source=None):") + lines.append(f" widget = Widget{i}(source)") + lines.append(" widget.items.append(source)") + lines.append(" return widget") + lines.append("") + + # A unique factory every importer can call, so imports produce call edges + # rather than dangling names. + lines.append(f"def make_{i}(source=None):") + lines.append(f" return Widget{i}(source)") + lines.append("") + + # Call sites into the imported modules: this is what makes the resolver do + # real work, and (for hub names) what drives it into the ambiguous tiers. + lines.append(f"def wire_{i}(payload):") + lines.append(" results = []") + for dep in imports: + lines.append(f" results.append(make_{dep.index}(payload))") + lines.append(f" results.append(Widget{dep.index}(payload))") + for func in HUB_FUNCTIONS: + lines.append(f" results.append({func}(payload))") + lines.append(" return results") + lines.append("") + + # Hub-name call sites. In a HUB module these resolve same-file (tier 1, the + # cheap path); everywhere else they hit the global tiers, where a name with + # hundreds of definitions is exactly the ambiguity the resolver has to bail + # out of. `--hub-fraction` therefore sets the ambiguous/cheap ratio. + lines.append(f"def dispatch_{i}(payload):") + lines.append(" out = []") + for func in HUB_FUNCTIONS: + lines.append(f" out.append({func}(payload))") + for dep in imports: + for method in ("get", "update", "close"): + lines.append(f" out.append(make_{dep.index}(payload).{method}(payload))") + lines.append(" return out") + lines.append("") + + return "\n".join(lines) + + +def typescript_module_source(module: Module, imports: list[Module]) -> str: + i = module.index + lines: list[str] = [f"// Generated module {module.dotted}.", ""] + + for dep in imports: + rel = relative_ts_import(module, dep) + lines.append(f'import {{ Widget{dep.index}, make{dep.index} }} from "{rel}";') + lines.append("") + + methods = HUB_METHODS if module.is_hub else [f"{m}_{i}" for m in HUB_METHODS] + lines.append(f"export interface Spec{i} {{") + lines.append(" name: string;") + lines.append(" items: string[];") + lines.append("}") + lines.append("") + lines.append(f"export class Widget{i} {{") + lines.append(" items: string[] = [];") + lines.append("") + for method in methods: + safe = method.replace("__init__", "init") + lines.append(f" {safe}(value?: string): string[] {{") + lines.append(" if (value) this.items.push(value);") + lines.append(" return this.items;") + lines.append(" }") + lines.append("") + lines.append("}") + lines.append("") + + functions = HUB_FUNCTIONS if module.is_hub else [f"{f}_{i}" for f in HUB_FUNCTIONS] + for func in functions: + lines.append(f"export function {func}(source: string): Widget{i} {{") + lines.append(f" const widget = new Widget{i}();") + lines.append(" widget.items.push(source);") + lines.append(" return widget;") + lines.append("}") + lines.append("") + + lines.append(f"export function make{i}(source: string): Widget{i} {{") + lines.append(f" const widget = new Widget{i}();") + lines.append(" widget.items.push(source);") + lines.append(" return widget;") + lines.append("}") + lines.append("") + + lines.append(f"export function wire{i}(payload: string): unknown[] {{") + lines.append(" const results: unknown[] = [];") + for dep in imports: + lines.append(f" results.push(make{dep.index}(payload));") + lines.append(f" results.push(new Widget{dep.index}());") + lines.append(" return results;") + lines.append("}") + lines.append("") + + return "\n".join(lines) + + +def relative_ts_import(module: Module, dep: Module) -> str: + """A `./`-prefixed relative specifier from `module` to `dep` (no extension).""" + from_dir = Path(*module.package) + to_path = Path(*dep.package) / dep.name + up = len(from_dir.parts) + rel = Path(*([".."] * up)) / to_path if up else to_path + text = rel.as_posix() + return text if text.startswith(".") else f"./{text}" + + +def generate( + out: Path, + files: int, + depth: int, + imports_per_file: int, + hub_fraction: float, + ts_fraction: float, + seed: int, + force: bool, +) -> dict[str, int]: + if out.exists(): + if not force: + raise SystemExit( + f"{out} already exists; pass --force to regenerate it from scratch" + ) + shutil.rmtree(out) + + rng = random.Random(seed) + modules = plan_modules(rng, files, depth, hub_fraction, ts_fraction) + + # A second generator for import edges, seeded off the same seed, so changing + # `--imports-per-file` does not perturb module placement. + edge_rng = random.Random(seed ^ 0x5EED) + + packages_written: set[tuple[str, ...]] = set() + hub_modules = 0 + ts_modules = 0 + + for module in modules: + target = out / module.rel_path + target.parent.mkdir(parents=True, exist_ok=True) + + # Every Python package level needs an __init__.py to be importable. + if not module.is_ts: + for level in range(len(module.package) + 1): + package = module.package[:level] + if package in packages_written: + continue + packages_written.add(package) + init = out / Path(*package) / "__init__.py" + init.parent.mkdir(parents=True, exist_ok=True) + init.write_text(f'"""Package {".".join(package) or "root"}."""\n') + + imports = pick_imports(edge_rng, modules, module, imports_per_file) + source = ( + typescript_module_source(module, imports) + if module.is_ts + else python_module_source(module, imports) + ) + target.write_text(source) + + hub_modules += int(module.is_hub) + ts_modules += int(module.is_ts) + + return { + "modules": len(modules), + "hub_modules": hub_modules, + "ts_modules": ts_modules, + "packages": len(packages_written), + } + + +def main(argv: list[str]) -> int: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--out", required=True, type=Path, help="destination directory (must be outside the repo)") + parser.add_argument("--preset", choices=sorted(PRESETS), help="size preset: small/medium/large") + parser.add_argument("--files", type=int, help="number of modules to generate") + parser.add_argument("--depth", type=int, help="maximum package nesting depth") + parser.add_argument("--imports-per-file", type=int, help="cross-module imports per module") + parser.add_argument( + "--hub-fraction", + type=float, + help="share of modules defining the shared hub names (0.0-1.0)", + ) + parser.add_argument( + "--ts-fraction", + type=float, + default=0.0, + help="share of modules emitted as TypeScript instead of Python", + ) + parser.add_argument("--seed", type=int, default=20240611, help="RNG seed (default: 20240611)") + parser.add_argument("--force", action="store_true", help="delete --out first if it exists") + args = parser.parse_args(argv) + + settings = dict(PRESETS[args.preset]) if args.preset else dict(PRESETS["small"]) + for key in ("files", "depth", "imports_per_file", "hub_fraction"): + value = getattr(args, key) + if value is not None: + settings[key] = value + + stats = generate( + out=args.out, + files=int(settings["files"]), + depth=int(settings["depth"]), + imports_per_file=int(settings["imports_per_file"]), + hub_fraction=float(settings["hub_fraction"]), + ts_fraction=args.ts_fraction, + seed=args.seed, + force=args.force, + ) + + print( + f"generated {stats['modules']} modules " + f"({stats['hub_modules']} hub-named, {stats['ts_modules']} TypeScript) " + f"across {stats['packages']} packages into {args.out}" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) From df2269aa5a70ab7cdc8d60f02a18962e96a3ee32 Mon Sep 17 00:00:00 2001 From: RhizoNymph Date: Tue, 11 Aug 2026 19:26:38 -0700 Subject: [PATCH 02/12] test: add an end-to-end perf harness example to cc-core --- crates/cc-core/examples/perf_harness.rs | 660 ++++++++++++++++++++++++ 1 file changed, 660 insertions(+) create mode 100644 crates/cc-core/examples/perf_harness.rs diff --git a/crates/cc-core/examples/perf_harness.rs b/crates/cc-core/examples/perf_harness.rs new file mode 100644 index 0000000..1f2856b --- /dev/null +++ b/crates/cc-core/examples/perf_harness.rs @@ -0,0 +1,660 @@ +//! End-to-end performance harness: scan -> parse -> resolve, then the query +//! battery the UI actually issues, printed as one JSON object on stdout. +//! +//! Criterion benches measure single functions on synthetic fixtures. This +//! measures the thing a user waits for: opening a repo, and then interacting +//! with it (expanding/collapsing containers, focusing nodes, drilling into an +//! aggregated edge). It is the most representative number in the suite, and the +//! one to quote when comparing two branches. +//! +//! # Portability across branches +//! +//! This file must compile UNCHANGED on `main` and on the perf branches so the +//! same measurement runs on both. It therefore restricts itself to the cc-core +//! public API that is identical on both sides: +//! +//! - `RepoScanner::scan`, `Extractor::extract_file` +//! - `SymbolTable::build_from_graph`, `SymbolTable::resolve_references` +//! - `ImportResolver::resolve` +//! - `SubGraph::from_graph`, `CodeGraph::neighborhood`, `CodeGraph::edge_detail` +//! - `ParseResult::from_graph` (owned on `main`, borrowing on the perf branch -- +//! the CALL is identical, which is the point: the harness measures the +//! difference without naming it) +//! +//! It deliberately does NOT touch `build_parent_map` (main-only) or +//! `CodeGraph::parent_map` (perf-branch-only), and never names `ParseResult` as +//! a type. +//! +//! # Determinism +//! +//! Every choice the harness makes -- which nodes go in a render set, which nodes +//! get focused, which aggregated edges get expanded -- is derived from a sorted +//! id list indexed by a seeded SplitMix64 stream. Two runs on the same input +//! therefore do the same work in the same order, so a delta between branches is +//! a delta in cost, not in workload. +//! +//! # Usage +//! +//! ```text +//! cargo run --release --example perf_harness -- --repo /path/to/repo --label main +//! ``` +//! +//! Flags: `--repo PATH` (required), `--label NAME`, `--seed N`, +//! `--subgraph-reps N`, `--neighborhood-queries N`, `--edge-detail-queries N`, +//! `--out FILE` (also write the JSON there). + +use std::collections::HashSet; +use std::hint::black_box; +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +use cc_core::model::{ + CodeGraph, CodeNode, EdgeKind, FocusDirection, Language, NodeId, ParseResult, SubGraph, +}; +use cc_core::parser::Extractor; +use cc_core::repo::RepoScanner; +use cc_core::resolver::{ImportResolver, SymbolTable}; +use rayon::prelude::*; +use serde_json::{json, Value}; + +// --------------------------------------------------------------------------- +// Deterministic RNG (SplitMix64) -- no rand dependency, identical on both +// branches, and stable across Rust versions. +// --------------------------------------------------------------------------- + +struct Rng(u64); + +impl Rng { + fn new(seed: u64) -> Self { + Rng(seed) + } + + fn next_u64(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + fn below(&mut self, n: usize) -> usize { + if n == 0 { + 0 + } else { + (self.next_u64() % n as u64) as usize + } + } +} + +/// Pick `count` distinct items from `items` deterministically (sorted input + +/// seeded stream), preserving the sampled order. +fn sample(rng: &mut Rng, items: &[T], count: usize) -> Vec { + if items.is_empty() { + return Vec::new(); + } + let count = count.min(items.len()); + let mut taken: HashSet = HashSet::with_capacity(count); + let mut out = Vec::with_capacity(count); + // Bounded attempts: with count <= len this terminates quickly, and the + // fallback sweep guarantees it terminates at all. + let mut attempts = 0; + while out.len() < count && attempts < count * 8 { + attempts += 1; + let idx = rng.below(items.len()); + if taken.insert(idx) { + out.push(items[idx].clone()); + } + } + for (idx, item) in items.iter().enumerate() { + if out.len() >= count { + break; + } + if taken.insert(idx) { + out.push(item.clone()); + } + } + out +} + +// --------------------------------------------------------------------------- +// Timing helpers +// --------------------------------------------------------------------------- + +fn ms(d: Duration) -> f64 { + d.as_secs_f64() * 1000.0 +} + +/// Summary of a repeated measurement, in milliseconds. +struct Samples { + values: Vec, +} + +impl Samples { + fn new() -> Self { + Samples { values: Vec::new() } + } + + fn push(&mut self, d: Duration) { + self.values.push(ms(d)); + } + + fn first(&self) -> f64 { + self.values.first().copied().unwrap_or(0.0) + } + + /// Mean of every sample AFTER the first. On a branch that caches derived + /// state on the graph, the first call pays for the cache and the rest do + /// not; reporting both separates "cold" from "steady state" instead of + /// smearing them together. + fn steady_mean(&self) -> f64 { + if self.values.len() < 2 { + return self.first(); + } + self.values[1..].iter().sum::() / (self.values.len() - 1) as f64 + } + + fn total(&self) -> f64 { + self.values.iter().sum() + } + + fn min(&self) -> f64 { + self.values.iter().copied().fold(f64::INFINITY, f64::min) + } + + fn to_json(&self) -> Value { + json!({ + "samples": self.values.len(), + "first_ms": self.first(), + "steady_mean_ms": self.steady_mean(), + "min_ms": self.min(), + "total_ms": self.total(), + }) + } +} + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- + +struct Args { + repo: PathBuf, + label: String, + seed: u64, + subgraph_reps: usize, + neighborhood_queries: usize, + edge_detail_queries: usize, + out: Option, +} + +fn parse_args() -> Result { + let mut repo: Option = None; + let mut label = "unlabelled".to_string(); + let mut seed = 20_240_611u64; + let mut subgraph_reps = 6usize; + let mut neighborhood_queries = 200usize; + let mut edge_detail_queries = 50usize; + let mut out: Option = None; + + let mut argv = std::env::args().skip(1); + while let Some(arg) = argv.next() { + let mut value = || { + argv.next() + .ok_or_else(|| format!("{arg} requires a value")) + }; + match arg.as_str() { + "--repo" => repo = Some(PathBuf::from(value()?)), + "--label" => label = value()?, + "--seed" => seed = value()?.parse().map_err(|e| format!("--seed: {e}"))?, + "--subgraph-reps" => { + subgraph_reps = value()?.parse().map_err(|e| format!("--subgraph-reps: {e}"))? + } + "--neighborhood-queries" => { + neighborhood_queries = value()? + .parse() + .map_err(|e| format!("--neighborhood-queries: {e}"))? + } + "--edge-detail-queries" => { + edge_detail_queries = value()? + .parse() + .map_err(|e| format!("--edge-detail-queries: {e}"))? + } + "--out" => out = Some(PathBuf::from(value()?)), + "--help" | "-h" => { + eprintln!( + "perf_harness --repo PATH [--label NAME] [--seed N] \ + [--subgraph-reps N] [--neighborhood-queries N] \ + [--edge-detail-queries N] [--out FILE]" + ); + std::process::exit(0); + } + other => return Err(format!("unknown argument: {other}")), + } + } + + Ok(Args { + repo: repo.ok_or("--repo is required")?, + label, + seed, + subgraph_reps: subgraph_reps.max(2), + neighborhood_queries, + edge_detail_queries, + out, + }) +} + +// --------------------------------------------------------------------------- +// Pipeline +// --------------------------------------------------------------------------- + +/// Every edge kind, i.e. the toolbar's default "show everything" state, which is +/// the most expensive filter and the one the app starts in. +fn all_edge_kinds() -> HashSet { + [ + EdgeKind::Import, + EdgeKind::FunctionCall, + EdgeKind::MethodCall, + EdgeKind::TypeReference, + EdgeKind::Inheritance, + EdgeKind::TraitImpl, + EdgeKind::VariableUsage, + ] + .into_iter() + .collect() +} + +struct Pipeline { + graph: CodeGraph, + timings: Value, + stats: Value, +} + +/// Scan + parse + resolve, mirroring `cc_tauri::commands::parse_repo` phase for +/// phase (minus the IPC progress events, which are not part of the cost being +/// compared here). +fn run_pipeline(root: &PathBuf) -> anyhow::Result { + let t_scan = Instant::now(); + let mut graph = RepoScanner::scan(root)?; + let scan_ms = ms(t_scan.elapsed()); + + let dir_count = graph.nodes.values().filter(|n| n.is_directory()).count(); + + // File nodes with a known language, in a deterministic order so the parallel + // parse is fed identically on every run. + let mut file_nodes: Vec<(NodeId, String, Language)> = graph + .nodes + .iter() + .filter_map(|(id, node)| match node { + CodeNode::File { + path, + language: Some(lang), + .. + } => Some((id.clone(), path.clone(), lang.clone())), + _ => None, + }) + .collect(); + file_nodes.sort_by(|a, b| a.1.cmp(&b.1)); + + // Phase 1: parse in parallel (I/O + tree-sitter). + let t_parse = Instant::now(); + let parsed: Vec<(NodeId, Option<(Vec, Vec<_>)>)> = file_nodes + .par_iter() + .map(|(file_id, rel_path, language)| { + let abs = root.join(rel_path); + let source = match std::fs::read_to_string(&abs) { + Ok(s) => s, + Err(_) => return (file_id.clone(), None), + }; + match Extractor::extract_file(rel_path, &source, language) { + Ok(pair) => (file_id.clone(), Some(pair)), + Err(_) => (file_id.clone(), None), + } + }) + .collect(); + let parse_ms = ms(t_parse.elapsed()); + + // Phase 2: merge block nodes into the graph and collect raw references. + let t_merge = Instant::now(); + let mut all_refs = Vec::new(); + let mut total_blocks = 0usize; + let mut failed_files = 0usize; + for (file_id, result) in parsed { + let (nodes, refs) = match result { + Some(pair) => pair, + None => { + failed_files += 1; + continue; + } + }; + total_blocks += nodes.len(); + for node in nodes { + let is_top_level = matches!(&node, CodeNode::CodeBlock { parent, .. } if *parent == file_id); + let block_id = node.id().clone(); + graph.add_node(node); + if is_top_level { + if let Some(file_node) = graph.nodes.get_mut(&file_id) { + file_node.children_mut().push(block_id); + } + } + } + all_refs.extend(refs); + } + let merge_ms = ms(t_merge.elapsed()); + let raw_refs = all_refs.len(); + + // Phase 3: resolution. + let t_symbols = Instant::now(); + let symbol_table = SymbolTable::build_from_graph(&graph); + let symbol_table_ms = ms(t_symbols.elapsed()); + let symbol_count = symbol_table.symbols.len(); + + let t_imports = Instant::now(); + let (import_edges, import_map) = ImportResolver::resolve(&graph, &all_refs); + let import_resolve_ms = ms(t_imports.elapsed()); + let import_edge_count = import_edges.len(); + + let t_import_insert = Instant::now(); + for edge in import_edges { + graph.add_edge(edge); + } + let import_insert_ms = ms(t_import_insert.elapsed()); + + let t_resolve = Instant::now(); + let edges = symbol_table.resolve_references(&all_refs, &import_map); + let symbol_resolve_ms = ms(t_resolve.elapsed()); + let symbol_edge_count = edges.len(); + + let t_insert = Instant::now(); + for edge in edges { + graph.add_edge(edge); + } + let symbol_insert_ms = ms(t_insert.elapsed()); + + let pipeline_total_ms = scan_ms + + parse_ms + + merge_ms + + symbol_table_ms + + import_resolve_ms + + import_insert_ms + + symbol_resolve_ms + + symbol_insert_ms; + + let stats = json!({ + "files_parsed": file_nodes.len(), + "files_failed": failed_files, + "directories": dir_count, + "blocks": total_blocks, + "nodes": graph.node_count(), + "edges": graph.edge_count(), + "raw_refs": raw_refs, + "symbols": symbol_count, + "import_edges_resolved": import_edge_count, + "symbol_edges_resolved": symbol_edge_count, + }); + + let timings = json!({ + "scan_ms": scan_ms, + "parse_files_ms": parse_ms, + "merge_ms": merge_ms, + "symbol_table_ms": symbol_table_ms, + "import_resolve_ms": import_resolve_ms, + "import_edge_insert_ms": import_insert_ms, + "symbol_resolve_ms": symbol_resolve_ms, + "symbol_edge_insert_ms": symbol_insert_ms, + "pipeline_total_ms": pipeline_total_ms, + }); + + Ok(Pipeline { + graph, + timings, + stats, + }) +} + +// --------------------------------------------------------------------------- +// Render sets +// --------------------------------------------------------------------------- + +/// One render set plus the label describing what the UI state it stands for. +struct RenderSet { + name: &'static str, + ids: Vec, +} + +/// Build the render sets the query battery runs against. +/// +/// These are the states the canvas is actually in, ordered from "everything +/// collapsed" to "everything expanded". The COLLAPSED ones are the interesting +/// ones: every edge endpoint is a code block that is not in the render set, so +/// aggregation has to walk block -> block -> file -> directory chains for both +/// endpoints of every edge. That walk is what needs a child -> parent map, and +/// it is the work the old flat bench fixture accidentally skipped entirely. +fn build_render_sets(graph: &CodeGraph, rng: &mut Rng) -> Vec { + let mut dirs: Vec = Vec::new(); + let mut files: Vec = Vec::new(); + let mut blocks: Vec = Vec::new(); + + for (id, node) in graph.nodes.iter() { + match node { + CodeNode::Directory { .. } => dirs.push(id.clone()), + CodeNode::File { .. } => files.push(id.clone()), + CodeNode::CodeBlock { .. } => blocks.push(id.clone()), + } + } + dirs.sort_by(|a, b| a.0.cmp(&b.0)); + files.sort_by(|a, b| a.0.cmp(&b.0)); + blocks.sort_by(|a, b| a.0.cmp(&b.0)); + + // Directories + a quarter of the files expanded to their blocks: the state + // a user lands in after drilling into a couple of packages. + let expanded_files: HashSet = sample(rng, &files, files.len() / 4) + .into_iter() + .collect(); + let mut partial: Vec = dirs.iter().chain(files.iter()).cloned().collect(); + for (id, node) in graph.nodes.iter() { + if let CodeNode::CodeBlock { parent, .. } = node { + if expanded_files.contains(parent) { + partial.push(id.clone()); + } + } + } + partial.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut dirs_files: Vec = dirs.iter().chain(files.iter()).cloned().collect(); + dirs_files.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut everything: Vec = dirs_files.iter().chain(blocks.iter()).cloned().collect(); + everything.sort_by(|a, b| a.0.cmp(&b.0)); + + vec![ + RenderSet { + name: "directories_only", + ids: dirs, + }, + RenderSet { + name: "directories_and_files", + ids: dirs_files, + }, + RenderSet { + name: "quarter_of_files_expanded", + ids: partial, + }, + RenderSet { + name: "fully_expanded", + ids: everything, + }, + ] +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- + +fn main() -> anyhow::Result<()> { + let args = match parse_args() { + Ok(args) => args, + Err(err) => { + eprintln!("error: {err}"); + std::process::exit(2); + } + }; + + eprintln!("[perf_harness] label={} repo={}", args.label, args.repo.display()); + + let Pipeline { + graph, + timings, + stats, + } = run_pipeline(&args.repo)?; + + eprintln!( + "[perf_harness] parsed: {} nodes, {} edges", + graph.node_count(), + graph.edge_count() + ); + + let kinds = all_edge_kinds(); + let mut rng = Rng::new(args.seed); + + // --- Payload: build + serialize the parse response ------------------- + // + // Both branches expose `ParseResult::from_graph`; on `main` it deep-clones + // the node map, on the perf branch it borrows it and serializes slim nodes. + // The call site is identical, so the difference lands entirely in these two + // numbers plus the byte count. + let mut payload_build = Samples::new(); + let mut payload_serialize = Samples::new(); + let mut payload_bytes = 0usize; + for _ in 0..3 { + let t_build = Instant::now(); + let result = ParseResult::from_graph(&graph); + payload_build.push(t_build.elapsed()); + + let t_ser = Instant::now(); + let json = serde_json::to_string(&result)?; + payload_serialize.push(t_ser.elapsed()); + payload_bytes = json.len(); + black_box(&json); + } + + // --- Subgraph battery ------------------------------------------------- + let render_sets = build_render_sets(&graph, &mut rng); + let mut subgraph_report = Vec::new(); + let mut subgraph_total_ms = 0.0f64; + + for set in &render_sets { + let mut samples = Samples::new(); + let mut direct = 0usize; + let mut aggregated = 0usize; + for _ in 0..args.subgraph_reps { + let t = Instant::now(); + let sub = SubGraph::from_graph(&graph, &set.ids, &kinds); + samples.push(t.elapsed()); + direct = sub.edges.len(); + aggregated = sub.aggregated_edges.len(); + black_box(&sub); + } + subgraph_total_ms += samples.total(); + let mut entry = samples.to_json(); + entry["render_nodes"] = json!(set.ids.len()); + entry["direct_edges"] = json!(direct); + entry["aggregated_edges"] = json!(aggregated); + entry["name"] = json!(set.name); + subgraph_report.push(entry); + eprintln!( + "[perf_harness] subgraph {:<26} {:>8} nodes first {:>8.2}ms steady {:>8.2}ms", + set.name, + set.ids.len(), + samples.first(), + samples.steady_mean() + ); + } + + // --- Neighborhood battery -------------------------------------------- + // + // Focus nodes are drawn from actual edge endpoints (sorted, then sampled) + // so every query has something to walk. + let mut endpoints: Vec = graph + .edges + .iter() + .flat_map(|e| [e.source.clone(), e.target.clone()]) + .collect::>() + .into_iter() + .collect(); + endpoints.sort_by(|a, b| a.0.cmp(&b.0)); + let focus_nodes = sample(&mut rng, &endpoints, args.neighborhood_queries); + + let mut neighborhood_nodes = 0usize; + let mut neighborhood_edges = 0usize; + let t_neighborhood = Instant::now(); + for focus in &focus_nodes { + if let Some(n) = graph.neighborhood(focus, 2, &kinds, FocusDirection::Both) { + neighborhood_nodes += n.node_ids.len(); + neighborhood_edges += n.edges.len(); + black_box(&n); + } + } + let neighborhood_ms = ms(t_neighborhood.elapsed()); + + // --- Edge-detail battery ---------------------------------------------- + // + // Drills into the aggregated edges of the most collapsed view -- the exact + // pairs a user can click on there. + let collapsed = SubGraph::from_graph(&graph, &render_sets[0].ids, &kinds); + let pairs: Vec<(NodeId, NodeId)> = collapsed + .aggregated_edges + .iter() + .take(args.edge_detail_queries) + .map(|e| (e.source.clone(), e.target.clone())) + .collect(); + + let mut edge_detail_edges = 0usize; + let t_edge_detail = Instant::now(); + for (source, target) in &pairs { + if let Some(detail) = graph.edge_detail(source, target, &kinds) { + edge_detail_edges += detail.edges.len(); + black_box(&detail); + } + } + let edge_detail_ms = ms(t_edge_detail.elapsed()); + + // --- Report ------------------------------------------------------------ + let report = json!({ + "label": args.label, + "repo": args.repo.display().to_string(), + "seed": args.seed, + "profile": if cfg!(debug_assertions) { "debug" } else { "release" }, + "stats": stats, + "pipeline_ms": timings, + "payload": { + "bytes": payload_bytes, + "build": payload_build.to_json(), + "serialize": payload_serialize.to_json(), + }, + "subgraph": { + "reps_per_render_set": args.subgraph_reps, + "battery_total_ms": subgraph_total_ms, + "render_sets": subgraph_report, + }, + "neighborhood": { + "queries": focus_nodes.len(), + "depth": 2, + "total_ms": neighborhood_ms, + "mean_ms": if focus_nodes.is_empty() { 0.0 } else { neighborhood_ms / focus_nodes.len() as f64 }, + "nodes_returned": neighborhood_nodes, + "edges_returned": neighborhood_edges, + }, + "edge_detail": { + "queries": pairs.len(), + "total_ms": edge_detail_ms, + "mean_ms": if pairs.is_empty() { 0.0 } else { edge_detail_ms / pairs.len() as f64 }, + "edges_returned": edge_detail_edges, + }, + }); + + let rendered = serde_json::to_string_pretty(&report)?; + if let Some(path) = &args.out { + std::fs::write(path, format!("{rendered}\n"))?; + } + println!("{rendered}"); + Ok(()) +} From 3342a9b057755f74afc00f47652bb09662e11d83 Mon Sep 17 00:00:00 2001 From: RhizoNymph Date: Tue, 11 Aug 2026 19:26:38 -0700 Subject: [PATCH 03/12] test: bench subgraph and neighborhood work on a nested, collapsed fixture --- crates/cc-core/benches/common/mod.rs | 313 ++++++++++++++++++++++++++ crates/cc-core/benches/graph_bench.rs | 234 +++++++++++++++---- 2 files changed, 503 insertions(+), 44 deletions(-) create mode 100644 crates/cc-core/benches/common/mod.rs diff --git a/crates/cc-core/benches/common/mod.rs b/crates/cc-core/benches/common/mod.rs new file mode 100644 index 0000000..1491cda --- /dev/null +++ b/crates/cc-core/benches/common/mod.rs @@ -0,0 +1,313 @@ +//! Shared fixtures for the cc-core criterion benches. +//! +//! Lives in a SUBDIRECTORY on purpose: cargo auto-discovers `benches/*.rs` as +//! bench targets, so a top-level `benches/common.rs` would be compiled as a +//! bench of its own (and fail, having no `main`). `benches/common/mod.rs` is +//! only ever pulled in by an explicit `mod common;`. +//! +//! The fixtures here exist because the ones they replace measured the wrong +//! thing. See [`nested_graph`] for the specific trap. + +#![allow(dead_code)] + +use std::collections::HashSet; + +use cc_core::model::{ + BlockKind, CodeEdge, CodeGraph, CodeNode, EdgeKind, Language, NodeId, Resolution, Span, + Visibility, +}; + +/// Deterministic SplitMix64 stream, so every fixture is byte-identical on every +/// run and on every branch. (Nothing here may depend on `HashMap` iteration +/// order, which is randomized per process.) +pub struct Rng(u64); + +impl Rng { + pub fn new(seed: u64) -> Self { + Rng(seed) + } + + pub fn next_u64(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + pub fn below(&mut self, n: usize) -> usize { + if n == 0 { + 0 + } else { + (self.next_u64() % n as u64) as usize + } + } +} + +/// Every edge kind: the toolbar's default state, and the most expensive filter. +pub fn all_edge_kinds() -> HashSet { + [ + EdgeKind::Import, + EdgeKind::FunctionCall, + EdgeKind::MethodCall, + EdgeKind::TypeReference, + EdgeKind::Inheritance, + EdgeKind::TraitImpl, + EdgeKind::VariableUsage, + ] + .into_iter() + .collect() +} + +fn span(line: usize) -> Span { + Span { + start_line: line, + start_col: 0, + end_line: line + 12, + end_col: 1, + } +} + +/// A nested graph plus the id groups a bench needs to build render sets. +pub struct NestedGraph { + pub graph: CodeGraph, + /// Directory ids, sorted. + pub dirs: Vec, + /// File ids, sorted. + pub files: Vec, + /// Code-block ids (both levels), sorted. + pub blocks: Vec, + /// Ids that are an endpoint of at least one edge, sorted. + pub edge_endpoints: Vec, +} + +impl NestedGraph { + /// Render set: directories only. Every edge endpoint is a code block whose + /// nearest rendered ancestor is 3-8 links up the chain. + pub fn render_dirs(&self) -> Vec { + self.dirs.clone() + } + + /// Render set: directories + files, i.e. every container expanded down to + /// file level with the files themselves collapsed. Endpoints lift 1-2 links. + pub fn render_dirs_files(&self) -> Vec { + let mut ids = self.dirs.clone(); + ids.extend(self.files.iter().cloned()); + ids + } + + /// Render set: everything. No lifting happens at all -- the parent map is + /// built (on `main`, rebuilt per call) and then never walked, which isolates + /// the map's construction cost from the walk's. + pub fn render_all(&self) -> Vec { + let mut ids = self.render_dirs_files(); + ids.extend(self.blocks.iter().cloned()); + ids + } +} + +/// Shape of the directory tree every nested fixture is built on: 3-way fanout, +/// 5 levels, so a leaf directory sits 5 links below the root and a nested code +/// block sits 8 links below it. +const TREE_DEPTH: usize = 5; +const TREE_FANOUT: usize = 3; +/// Top-level blocks per file, each carrying `NESTED_BLOCKS` children (a class +/// with methods, a module with functions). +const BLOCKS_PER_FILE: usize = 5; +const NESTED_BLOCKS: usize = 2; +/// Nodes contributed by one file: itself plus its block subtree. +const NODES_PER_FILE: usize = 1 + BLOCKS_PER_FILE * (1 + NESTED_BLOCKS); + +/// Build a nested `Directory > File > CodeBlock > CodeBlock` graph of roughly +/// `target_nodes` nodes, with cross-file edges between the DEEPEST blocks. +/// +/// # Why this shape +/// +/// The fixture this replaces built `target_nodes` flat `File` nodes with empty +/// `children` arrays and put every edge between two of them. That graph has an +/// EMPTY parent map, so `find_render_ancestor` returned on its first probe and +/// the aggregation path -- the entire reason `SubGraph::from_graph` needs a +/// parent map -- never ran. It benchmarked the one input for which the parent +/// map does not matter, and would report "no change" for any amount of work +/// saved building or reusing it. +/// +/// Here every edge endpoint is a leaf code block, and the render sets used by +/// the benches keep the containers COLLAPSED, so both endpoints of every edge +/// have to be walked up a real ancestor chain before the edge can be +/// aggregated. That is the shape the app is in whenever a repo is first opened. +pub fn nested_graph(target_nodes: usize, seed: u64) -> NestedGraph { + let mut rng = Rng::new(seed); + let mut graph = CodeGraph::new(NodeId("root".into())); + + // --- directory tree --------------------------------------------------- + // Breadth-first so the ids are assigned level by level; `children` is filled + // in as each level is created. + let mut dir_paths: Vec = vec!["root".to_string()]; + let mut dir_children: Vec> = vec![Vec::new()]; + let mut dir_of_depth: Vec = vec![0]; + let mut level: Vec = vec![0]; + + for _ in 0..TREE_DEPTH { + let mut next = Vec::new(); + for parent_idx in level { + for k in 0..TREE_FANOUT { + let path = format!("{}/dir_{}", dir_paths[parent_idx], k); + let idx = dir_paths.len(); + dir_paths.push(path); + dir_children.push(Vec::new()); + dir_of_depth.push(dir_of_depth[parent_idx] + 1); + dir_children[parent_idx].push(NodeId(dir_paths[idx].clone())); + next.push(idx); + } + } + level = next; + } + + // --- files, distributed round-robin over every non-root directory ------ + let placeable: Vec = (1..dir_paths.len()).collect(); + let file_count = target_nodes + .saturating_sub(dir_paths.len()) + .div_ceil(NODES_PER_FILE) + .max(1); + + let mut files: Vec = Vec::with_capacity(file_count); + let mut blocks: Vec = Vec::new(); + // Deepest blocks only: these are the edge endpoints, so every edge has the + // full ancestor chain above it. + let mut leaf_blocks: Vec = Vec::new(); + + for f in 0..file_count { + let dir_idx = placeable[f % placeable.len()]; + let file_path = format!("{}/file_{f}.py", dir_paths[dir_idx]); + let file_id = NodeId::file(&file_path); + dir_children[dir_idx].push(file_id.clone()); + + let mut file_children = Vec::with_capacity(BLOCKS_PER_FILE); + for b in 0..BLOCKS_PER_FILE { + let block_id = NodeId::code_block(&file_path, &format!("Class_{f}_{b}"), b * 40); + let mut nested = Vec::with_capacity(NESTED_BLOCKS); + for m in 0..NESTED_BLOCKS { + let method_id = + NodeId::code_block(&file_path, &format!("method_{f}_{b}_{m}"), b * 40 + m * 10); + graph.add_node(CodeNode::CodeBlock { + id: method_id.clone(), + name: format!("method_{f}_{b}_{m}"), + kind: BlockKind::Function, + span: span(b * 40 + m * 10), + signature: Some(format!("def method_{f}_{b}_{m}(self, value=None):")), + visibility: Some(Visibility::Public), + parent: block_id.clone(), + children: Vec::new(), + }); + nested.push(method_id.clone()); + blocks.push(method_id.clone()); + leaf_blocks.push(method_id); + } + graph.add_node(CodeNode::CodeBlock { + id: block_id.clone(), + name: format!("Class_{f}_{b}"), + kind: BlockKind::Class, + span: span(b * 40), + signature: Some(format!("class Class_{f}_{b}:")), + visibility: Some(Visibility::Public), + parent: file_id.clone(), + children: nested, + }); + file_children.push(block_id.clone()); + blocks.push(block_id); + } + + graph.add_node(CodeNode::File { + id: file_id.clone(), + name: format!("file_{f}.py"), + path: file_path, + language: Some(Language::Python), + children: file_children, + }); + files.push(file_id); + } + + for (idx, path) in dir_paths.iter().enumerate() { + graph.add_node(CodeNode::Directory { + id: NodeId::directory(path), + name: path.rsplit('/').next().unwrap_or(path).to_string(), + path: path.clone(), + children: std::mem::take(&mut dir_children[idx]), + }); + } + + // --- edges between leaf blocks in DIFFERENT files ---------------------- + // ~4 per file, mixed kinds, so aggregation has several kinds to key on. + let kinds = [ + EdgeKind::FunctionCall, + EdgeKind::MethodCall, + EdgeKind::Import, + EdgeKind::TypeReference, + ]; + let edge_count = file_count * 4; + for e in 0..edge_count { + let source = leaf_blocks[rng.below(leaf_blocks.len())].clone(); + let target = leaf_blocks[rng.below(leaf_blocks.len())].clone(); + if source == target { + continue; + } + graph.add_edge(CodeEdge { + source, + target, + kind: kinds[e % kinds.len()].clone(), + weight: 1, + resolution: Resolution::GlobalUnique, + }); + } + + let mut dirs: Vec = dir_paths.iter().map(|p| NodeId::directory(p)).collect(); + dirs.sort_by(|a, b| a.0.cmp(&b.0)); + files.sort_by(|a, b| a.0.cmp(&b.0)); + blocks.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut edge_endpoints: Vec = graph + .edges + .iter() + .flat_map(|e| [e.source.clone(), e.target.clone()]) + .collect::>() + .into_iter() + .collect(); + edge_endpoints.sort_by(|a, b| a.0.cmp(&b.0)); + + NestedGraph { + graph, + dirs, + files, + blocks, + edge_endpoints, + } +} + +/// Build a flat graph of `n` `File` nodes with empty `children` and one edge per +/// node -- the OLD subgraph fixture, kept for what it genuinely measures. +/// +/// With no containment hierarchy the parent map is empty, so this isolates the +/// direct-edge scan and render-set membership test with zero ancestor walking. +pub fn flat_graph(n: usize) -> (CodeGraph, Vec) { + let mut graph = CodeGraph::new(NodeId("root".into())); + for i in 0..n { + graph.add_node(CodeNode::File { + id: NodeId(format!("file_{i}")), + name: format!("file_{i}.py"), + path: format!("src/file_{i}.py"), + language: Some(Language::Python), + children: Vec::new(), + }); + } + for i in 0..n { + graph.add_edge(CodeEdge { + source: NodeId(format!("file_{i}")), + target: NodeId(format!("file_{}", (i + 1) % n)), + kind: EdgeKind::Import, + weight: 1, + resolution: Resolution::GlobalUnique, + }); + } + let visible: Vec = (0..n / 2).map(|i| NodeId(format!("file_{i}"))).collect(); + (graph, visible) +} diff --git a/crates/cc-core/benches/graph_bench.rs b/crates/cc-core/benches/graph_bench.rs index d04c70c..cd1cdb8 100644 --- a/crates/cc-core/benches/graph_bench.rs +++ b/crates/cc-core/benches/graph_bench.rs @@ -1,6 +1,28 @@ +//! Criterion benches for the graph model: edge insertion, adjacency rebuild, +//! subgraph extraction, neighborhood BFS and parse-payload serialization. +//! +//! The subgraph/neighborhood benches run on the NESTED fixture from +//! [`common::nested_graph`] with containers collapsed. That matters: on a flat +//! graph the child -> parent map is empty and the ancestor walk that aggregation +//! exists for never runs, so a flat fixture reports "no change" no matter what +//! happens to the parent map. See the doc comment on `common::nested_graph`. + +use std::collections::HashSet; +use std::time::Duration; + use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion}; -use cc_core::model::{CodeEdge, CodeGraph, EdgeKind, NodeId, Resolution}; +use cc_core::model::{ + CodeEdge, CodeGraph, EdgeKind, FocusDirection, NodeId, ParseResult, Resolution, SubGraph, +}; + +mod common; + +use common::{all_edge_kinds, flat_graph, nested_graph, NestedGraph, Rng}; + +/// Seed shared by every fixture in this file, so two runs (or two branches) +/// build byte-identical graphs. +const SEED: u64 = 20_240_611; /// Build a graph pre-loaded with `n` unique edges so we can benchmark add_edge /// against a populated graph (the hot path for duplicate checking). @@ -126,55 +148,175 @@ fn bench_rebuild_adjacency(c: &mut Criterion) { group.finish(); } -/// Benchmark SubGraph::from_graph extraction with varying visible-node counts. -fn bench_subgraph_extraction(c: &mut Criterion) { - use cc_core::model::SubGraph; +/// `SubGraph::from_graph` on a FLAT graph: no containment hierarchy at all. +/// +/// Kept from the original suite, renamed to say what it actually covers. With +/// an empty parent map this measures the direct-edge scan and the render-set +/// membership test ONLY -- no ancestor walking, no aggregation. It is the floor, +/// not the representative case; `subgraph_nested_collapsed` is that. +fn bench_subgraph_flat_no_hierarchy(c: &mut Criterion) { + let mut group = c.benchmark_group("subgraph_flat_no_hierarchy"); + let kinds = all_edge_kinds(); - let mut group = c.benchmark_group("subgraph_extraction"); for total in [500, 2000] { - // Build a graph with nodes and edges - let mut graph = CodeGraph::new(NodeId("root".into())); - for i in 0..total { - graph.add_node(cc_core::model::CodeNode::File { - id: NodeId(format!("file_{i}")), - name: format!("file_{i}.py"), - path: format!("src/file_{i}.py"), - language: Some(cc_core::model::Language::Python), - children: Vec::new(), - }); - } - for i in 0..total { - graph.add_edge(CodeEdge { - source: NodeId(format!("file_{i}")), - target: NodeId(format!("file_{}", (i + 1) % total)), - kind: EdgeKind::Import, - weight: 1, - resolution: Resolution::GlobalUnique, - }); + let (graph, visible) = flat_graph(total); + group.bench_with_input( + BenchmarkId::new("nodes", total), + &(&graph, &visible), + |b, &(graph, visible)| { + b.iter(|| { + black_box(SubGraph::from_graph(graph, visible, &kinds)); + }); + }, + ); + } + group.finish(); +} + +/// `SubGraph::from_graph` on the NESTED fixture with containers COLLAPSED -- +/// the representative case, and the one that needs the child -> parent map. +/// +/// Two render sets per size: +/// - `directories_only`: nothing but the directory tree is on screen, so both +/// endpoints of every edge walk 3-8 links up (block -> class -> file -> dirs). +/// - `dirs_and_files`: files on screen but collapsed, so endpoints walk 1-2 +/// links. This is the state a freshly-opened repo is in. +/// +/// Sample counts are cut at the larger sizes to keep the suite a few minutes. +fn bench_subgraph_nested_collapsed(c: &mut Criterion) { + let mut group = c.benchmark_group("subgraph_nested_collapsed"); + group.sample_size(20); + group.warm_up_time(Duration::from_millis(500)); + group.measurement_time(Duration::from_secs(3)); + let kinds = all_edge_kinds(); + + for target in [2_000usize, 10_000, 50_000] { + let fixture = nested_graph(target, SEED); + let dirs = fixture.render_dirs(); + let dirs_files = fixture.render_dirs_files(); + + group.bench_with_input( + BenchmarkId::new("directories_only", target), + &(&fixture.graph, &dirs), + |b, &(graph, render)| { + b.iter(|| black_box(SubGraph::from_graph(graph, render, &kinds))); + }, + ); + + group.bench_with_input( + BenchmarkId::new("dirs_and_files", target), + &(&fixture.graph, &dirs_files), + |b, &(graph, render)| { + b.iter(|| black_box(SubGraph::from_graph(graph, render, &kinds))); + }, + ); + } + group.finish(); +} + +/// `SubGraph::from_graph` with EVERY node rendered. +/// +/// No endpoint ever needs lifting, so this isolates the cost of having a parent +/// map available at all from the cost of walking it: any delta here is the map's +/// construction, not its use. +fn bench_subgraph_fully_expanded(c: &mut Criterion) { + let mut group = c.benchmark_group("subgraph_fully_expanded"); + group.sample_size(20); + group.warm_up_time(Duration::from_millis(500)); + group.measurement_time(Duration::from_secs(3)); + let kinds = all_edge_kinds(); + + for target in [10_000usize, 50_000] { + let fixture = nested_graph(target, SEED); + let all = fixture.render_all(); + group.bench_with_input( + BenchmarkId::from_parameter(target), + &(&fixture.graph, &all), + |b, &(graph, render)| { + b.iter(|| black_box(SubGraph::from_graph(graph, render, &kinds))); + }, + ); + } + group.finish(); +} + +/// Neighborhood BFS (depth 2, both directions) on the nested fixture. +/// +/// Each iteration runs a batch of 32 queries against deterministically chosen +/// focus nodes, which is closer to a user's click-through than a single query +/// and keeps per-iteration time above timer noise. The container-chain walk at +/// the end of `neighborhood` also needs the parent map, so this is the second +/// query type the caching change touches. +fn bench_neighborhood(c: &mut Criterion) { + let mut group = c.benchmark_group("neighborhood_bfs"); + group.sample_size(20); + group.warm_up_time(Duration::from_millis(500)); + group.measurement_time(Duration::from_secs(3)); + let kinds = all_edge_kinds(); + + for target in [10_000usize, 50_000] { + let fixture = nested_graph(target, SEED); + let focuses = pick_focus_nodes(&fixture, 32); + + group.bench_with_input( + BenchmarkId::from_parameter(target), + &(&fixture.graph, &focuses), + |b, &(graph, focuses)| { + b.iter(|| { + for focus in focuses { + black_box(graph.neighborhood(focus, 2, &kinds, FocusDirection::Both)); + } + }); + }, + ); + } + group.finish(); +} + +/// Deterministically choose `count` focus nodes from the fixture's edge +/// endpoints (which are sorted, so the choice does not depend on hash order). +fn pick_focus_nodes(fixture: &NestedGraph, count: usize) -> Vec { + let mut rng = Rng::new(SEED ^ 0x0F0C); + let mut seen: HashSet = HashSet::new(); + let mut out = Vec::with_capacity(count); + while out.len() < count && seen.len() < fixture.edge_endpoints.len() { + let idx = rng.below(fixture.edge_endpoints.len()); + if seen.insert(idx) { + out.push(fixture.edge_endpoints[idx].clone()); } + } + out +} - // Visible = half the nodes - let visible: Vec = (0..total / 2) - .map(|i| NodeId(format!("file_{i}"))) - .collect(); - let all_kinds: std::collections::HashSet = [ - EdgeKind::Import, - EdgeKind::FunctionCall, - EdgeKind::MethodCall, - EdgeKind::TypeReference, - EdgeKind::Inheritance, - EdgeKind::TraitImpl, - EdgeKind::VariableUsage, - ] - .into_iter() - .collect(); +/// Build + serialize the parse payload (`ParseResult` -> JSON). +/// +/// This is the IPC cost of opening a repo. The payload SIZE is printed once per +/// fixture to stderr (criterion only reports time), because the shipped win here +/// is as much about bytes crossing the IPC boundary as about the clock. +fn bench_parse_result_serialization(c: &mut Criterion) { + let mut group = c.benchmark_group("parse_result_serialize"); + group.sample_size(20); + group.warm_up_time(Duration::from_millis(500)); + group.measurement_time(Duration::from_secs(3)); + + for target in [10_000usize, 50_000] { + let fixture = nested_graph(target, SEED); + let bytes = serde_json::to_string(&ParseResult::from_graph(&fixture.graph)) + .expect("parse result serializes") + .len(); + eprintln!( + "parse_result_serialize/{target}: {} nodes, {} edges, payload {bytes} bytes", + fixture.graph.node_count(), + fixture.graph.edge_count() + ); group.bench_with_input( - BenchmarkId::new("nodes", total), - &(&graph, &visible, &all_kinds), - |b, &(graph, visible, kinds)| { + BenchmarkId::from_parameter(target), + &fixture.graph, + |b, graph| { b.iter(|| { - black_box(SubGraph::from_graph(graph, visible, kinds)); + let result = ParseResult::from_graph(graph); + black_box(serde_json::to_string(&result).unwrap()); }); }, ); @@ -188,6 +330,10 @@ criterion_group!( bench_add_edge_all_duplicates, bench_add_edge_mixed, bench_rebuild_adjacency, - bench_subgraph_extraction, + bench_subgraph_flat_no_hierarchy, + bench_subgraph_nested_collapsed, + bench_subgraph_fully_expanded, + bench_neighborhood, + bench_parse_result_serialization, ); criterion_main!(benches); From df2e2cd716f87507cdd7f2cff825a0d7018c8ed9 Mon Sep 17 00:00:00 2001 From: RhizoNymph Date: Tue, 11 Aug 2026 19:26:38 -0700 Subject: [PATCH 04/12] test: bench symbol resolution under hub-name ambiguity --- crates/cc-core/benches/parse_bench.rs | 147 ++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) diff --git a/crates/cc-core/benches/parse_bench.rs b/crates/cc-core/benches/parse_bench.rs index dbdd65c..8a9866b 100644 --- a/crates/cc-core/benches/parse_bench.rs +++ b/crates/cc-core/benches/parse_bench.rs @@ -368,10 +368,157 @@ fn bench_full_pipeline(c: &mut Criterion) { group.finish(); } +// --------------------------------------------------------------------------- +// Symbol resolution under hub-name ambiguity +// --------------------------------------------------------------------------- + +/// Names a real repo defines in hundreds of files. Every reference to one of +/// these matches every definition, which is what drives resolution into its +/// ambiguous tiers. +const HUB_NAMES: &[&str] = &["__init__", "new", "get", "run", "handle"]; + +/// Build a graph where `definers` files each define every hub name, plus a set +/// of caller files that reference those names. +/// +/// The callers deliberately define NOTHING, so their references cannot resolve +/// same-file (tier 1) or via an import (tier 3) and fall all the way through to +/// the global tiers. With `definers` well above the 5-candidate cap, every one +/// of those references lands in tier 6 -- matched by hundreds of symbols and +/// then dropped. That tier is pure waste by construction, and how cheaply the +/// resolver reaches the decision to drop is exactly what this measures. +/// +/// `unique` swaps the hub names for per-file unique ones, giving the tier-4 +/// (single global match) control: same number of references, same table size, +/// no ambiguity. +fn hub_ambiguity_fixture( + definers: usize, + callers: usize, + refs_per_caller: usize, + unique: bool, +) -> (cc_core::model::CodeGraph, Vec) { + use cc_core::model::{BlockKind, CodeGraph, CodeNode, Language, NodeId, Span}; + use cc_core::parser::{RawRefKind, RawReference}; + + let span = |line: usize| Span { + start_line: line, + start_col: 0, + end_line: line + 8, + end_col: 1, + }; + + let mut graph = CodeGraph::new(NodeId("root".into())); + + for f in 0..definers { + let path = format!("pkg_{}/mod_{f}.py", f % 32); + let file_id = NodeId::file(&path); + let mut children = Vec::new(); + for (i, base) in HUB_NAMES.iter().enumerate() { + let name = if unique { + format!("{base}_{f}") + } else { + (*base).to_string() + }; + let block_id = NodeId::code_block(&path, &name, i * 20); + graph.add_node(CodeNode::CodeBlock { + id: block_id.clone(), + name, + kind: BlockKind::Function, + span: span(i * 20), + signature: Some("def f(self):".to_string()), + visibility: None, + parent: file_id.clone(), + children: Vec::new(), + }); + children.push(block_id); + } + graph.add_node(CodeNode::File { + id: file_id, + name: format!("mod_{f}.py"), + path, + language: Some(Language::Python), + children, + }); + } + + let mut refs = Vec::with_capacity(callers * refs_per_caller); + for c in 0..callers { + let path = format!("callers/caller_{c}.py"); + let file_id = NodeId::file(&path); + graph.add_node(CodeNode::File { + id: file_id.clone(), + name: format!("caller_{c}.py"), + path, + language: Some(Language::Python), + children: Vec::new(), + }); + for r in 0..refs_per_caller { + let base = HUB_NAMES[r % HUB_NAMES.len()]; + let name = if unique { + // Point at a definer that exists, so the control resolves to + // exactly one symbol instead of missing entirely. + format!("{base}_{}", r % definers.max(1)) + } else { + base.to_string() + }; + refs.push(RawReference { + from_node: file_id.clone(), + kind: RawRefKind::FunctionCall, + name, + span: span(r * 3), + }); + } + } + + (graph, refs) +} + +/// Symbol-table build + reference resolution on repos full of hub names. +/// +/// `ambiguous` is the case the early-bail change targets: hundreds of candidates +/// per name, all discarded. `unique_control` is the same volume of work with one +/// candidate per name; the gap between them is the price of ambiguity. +fn bench_resolve_hub_ambiguity(c: &mut Criterion) { + use cc_core::resolver::{ImportMap, SymbolTable}; + + let mut group = c.benchmark_group("resolve_hub_ambiguity"); + group.sample_size(10); + group.warm_up_time(std::time::Duration::from_millis(500)); + group.measurement_time(std::time::Duration::from_secs(4)); + + for definers in [500usize, 1000] { + let callers = definers / 2; + let refs_per_caller = 20; + + for (label, unique) in [("ambiguous", false), ("unique_control", true)] { + let (graph, refs) = hub_ambiguity_fixture(definers, callers, refs_per_caller, unique); + let table = SymbolTable::build_from_graph(&graph); + let imports = ImportMap::new(); + + group.bench_with_input( + BenchmarkId::new(format!("{label}_resolve"), definers), + &(&table, &refs), + |b, &(table, refs)| { + b.iter(|| black_box(table.resolve_references(refs, &imports))); + }, + ); + + group.bench_with_input( + BenchmarkId::new(format!("{label}_build_table"), definers), + &graph, + |b, graph| { + b.iter(|| black_box(SymbolTable::build_from_graph(graph))); + }, + ); + } + } + group.finish(); +} + criterion_group!( benches, bench_extract_file, bench_extract_many_files, bench_full_pipeline, + bench_resolve_hub_ambiguity, ); criterion_main!(benches); From 4a283aeb44ac3437f5841b3561069f32668a6815 Mon Sep 17 00:00:00 2001 From: RhizoNymph Date: Tue, 11 Aug 2026 19:26:38 -0700 Subject: [PATCH 05/12] test: add an edge-routing benchmark for the canvas redraw path --- packages/app/benchmarks/edgeRouting.bench.ts | 459 +++++++++++++++++++ 1 file changed, 459 insertions(+) create mode 100644 packages/app/benchmarks/edgeRouting.bench.ts diff --git a/packages/app/benchmarks/edgeRouting.bench.ts b/packages/app/benchmarks/edgeRouting.bench.ts new file mode 100644 index 0000000..257fa6a --- /dev/null +++ b/packages/app/benchmarks/edgeRouting.bench.ts @@ -0,0 +1,459 @@ +/** + * Edge-routing benchmark for the canvas redraw path. + * + * cd packages/app && node benchmarks/edgeRouting.bench.ts + * node benchmarks/edgeRouting.bench.ts --json out.json --sizes 300x200,800x500 + * + * NOT part of the `node --test "tests/*.test.ts"` glob: it is a stopwatch, not + * an assertion, and it takes minutes at the larger sizes. + * + * # What it measures + * + * The per-redraw cost of turning laid-out edges into routed polylines. Three + * scenarios, each on the same synthetic layout: + * + * 1. `full_scan_routing` -- obstacles collected PER EDGE by scanning every + * visible node, then `routePolylineAroundObstacles` with crossing-aware + * scoring. This is what the renderer did before the obstacle index, and it is + * reimplemented HERE rather than imported, so it is byte-identical on every + * branch and can serve as the common yardstick. + * 2. `indexed_routing` -- obstacles indexed ONCE per redraw and queried per edge + * via `src/canvas/layout/obstacleIndex.ts`. That module only exists on the + * perf branch; where it is missing this scenario is reported as `skipped`. + * 3. `shipped_redraw` -- what the branch under test actually does at this size, + * budget gate included. `src/canvas/renderers/edgeRoutingBudget.ts` also only + * exists on the perf branch; without it the scenario falls back to + * "route everything, crossing-aware", which is the older shipped behaviour. + * + * # Comparing branches + * + * - `full_scan_routing` is comparable across branches directly, and mostly + * exists to prove the two runs are on comparable hardware/runtime: it should + * come out roughly EQUAL. A large gap there means the environments differ and + * the rest of the numbers should be distrusted. + * - `indexed_routing` vs `full_scan_routing` (same run) is the obstacle-index + * win, measurable on the perf branch alone. + * - `shipped_redraw` old vs new is the user-visible number, and includes the + * budget gate skipping routing entirely above its thresholds. + * + * Imports use explicit `.ts` specifiers so the module chain loads under plain + * `node` (see tsconfig `allowImportingTsExtensions`). + */ + +import { + routePolylineAroundObstacles, + type NodeBox, + type Point, +} from "../src/canvas/layout/edgeGeometry.ts"; + +/** Mirrors `OBSTACLE_QUERY_MARGIN` in edgeDrawing.ts. */ +const OBSTACLE_QUERY_MARGIN = 160; + +/** No-reference-polylines sentinel, matching the renderer's shared constant. */ +const NO_REFERENCE_POLYLINES: Point[][] = []; + +// --------------------------------------------------------------------------- +// Optional modules (perf branch only) +// --------------------------------------------------------------------------- + +interface ObstacleIndexModule { + ObstacleIndex: new (entries: readonly unknown[]) => { + readonly size: number; + queryForPolyline( + points: readonly Point[], + margin: number, + excludeOwnerA?: string | null, + excludeOwnerB?: string | null + ): NodeBox[]; + }; + obstacleEntry: (ownerId: string, box: NodeBox) => unknown; +} + +interface BudgetModule { + resolveEdgeRoutingMode: (input: { + renderedEdges: number; + visibleNodes: number; + edgesVisible?: boolean; + }) => "full" | "obstacles" | "none"; + routesAroundObstacles: (mode: string) => boolean; + scoresEdgeCrossings: (mode: string) => boolean; +} + +async function loadOptional(specifier: string): Promise { + try { + return (await import(specifier)) as T; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Deterministic synthetic layout +// --------------------------------------------------------------------------- + +/** SplitMix32: same layout on every run and every branch. */ +function makeRng(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state + 0x9e3779b9) >>> 0; + let z = state; + z = Math.imul(z ^ (z >>> 16), 0x21f0aaad) >>> 0; + z = Math.imul(z ^ (z >>> 15), 0x735a2d97) >>> 0; + return ((z ^ (z >>> 15)) >>> 0) / 0x100000000; + }; +} + +interface BenchNode { + id: string; + box: NodeBox; + labelBox: NodeBox; +} + +interface BenchEdge { + source: string; + target: string; + points: Point[]; +} + +interface Layout { + nodes: BenchNode[]; + nodesById: Map; + edges: BenchEdge[]; +} + +/** + * A grid of boxes with edges between randomly chosen pairs, routed as the + * two-bend orthogonal polylines ELK produces. Edge endpoints are anchored on + * the box borders, so the polylines start out legal and the routing pass has to + * do real detour work where boxes sit in between. + */ +function buildLayout(nodeCount: number, edgeCount: number, seed = 20240611): Layout { + const rng = makeRng(seed); + const columns = Math.max(1, Math.ceil(Math.sqrt(nodeCount))); + const cellW = 260; + const cellH = 150; + + const nodes: BenchNode[] = []; + for (let i = 0; i < nodeCount; i++) { + const col = i % columns; + const row = Math.floor(i / columns); + const x = col * cellW + 20 + Math.floor(rng() * 20); + const y = row * cellH + 20 + Math.floor(rng() * 20); + const width = 140 + Math.floor(rng() * 60); + const height = 48 + Math.floor(rng() * 24); + nodes.push({ + id: `n${i}`, + box: { x, y, width, height }, + labelBox: { x: x + 4, y: y - 18, width: width - 8, height: 14 }, + }); + } + + const nodesById = new Map(nodes.map((n) => [n.id, n])); + + const edges: BenchEdge[] = []; + for (let i = 0; i < edgeCount; i++) { + const a = nodes[Math.floor(rng() * nodes.length)]; + let b = nodes[Math.floor(rng() * nodes.length)]; + if (a === b) { + b = nodes[(nodes.indexOf(a) + 1) % nodes.length]; + } + const start: Point = { + x: a.box.x + a.box.width, + y: a.box.y + a.box.height / 2, + }; + const end: Point = { x: b.box.x, y: b.box.y + b.box.height / 2 }; + const midX = (start.x + end.x) / 2; + edges.push({ + source: a.id, + target: b.id, + points: [ + start, + { x: midX, y: start.y }, + { x: midX, y: end.y }, + end, + ], + }); + } + + return { nodes, nodesById, edges }; +} + +function boxCenter(box: NodeBox): Point { + return { x: box.x + box.width / 2, y: box.y + box.height / 2 }; +} + +function boxContainsPoint(box: NodeBox, point: Point): boolean { + return ( + point.x >= box.x && + point.x <= box.x + box.width && + point.y >= box.y && + point.y <= box.y + box.height + ); +} + +// --------------------------------------------------------------------------- +// Scenario 1: full-scan obstacle collection (the pre-index renderer) +// --------------------------------------------------------------------------- + +function fullScanObstacles(layout: Layout, edge: BenchEdge): NodeBox[] { + const source = layout.nodesById.get(edge.source)!; + const target = layout.nodesById.get(edge.target)!; + const sourceCenter = boxCenter(source.box); + const targetCenter = boxCenter(target.box); + const obstacles: NodeBox[] = []; + + for (const node of layout.nodes) { + if (node.id === edge.source || node.id === edge.target) continue; + + if ( + !boxContainsPoint(node.labelBox, sourceCenter) && + !boxContainsPoint(node.labelBox, targetCenter) + ) { + obstacles.push(node.labelBox); + } + if ( + !boxContainsPoint(node.box, sourceCenter) && + !boxContainsPoint(node.box, targetCenter) + ) { + obstacles.push(node.box); + } + } + + return obstacles; +} + +function routeFullScan(layout: Layout, scoreCrossings: boolean): number { + const routed: Point[][] = []; + let points = 0; + for (const edge of layout.edges) { + const result = routePolylineAroundObstacles( + edge.points, + fullScanObstacles(layout, edge), + scoreCrossings ? routed : NO_REFERENCE_POLYLINES + ); + if (scoreCrossings) routed.push(result); + points += result.length; + } + return points; +} + +// --------------------------------------------------------------------------- +// Scenario 2: indexed obstacle collection (perf branch) +// --------------------------------------------------------------------------- + +function routeIndexed( + layout: Layout, + mod: ObstacleIndexModule, + scoreCrossings: boolean +): number { + const entries = layout.nodes.flatMap((node) => [ + mod.obstacleEntry(node.id, node.labelBox), + mod.obstacleEntry(node.id, node.box), + ]); + const index = new mod.ObstacleIndex(entries); + + const routed: Point[][] = []; + let points = 0; + for (const edge of layout.edges) { + const source = layout.nodesById.get(edge.source)!; + const target = layout.nodesById.get(edge.target)!; + const sourceCenter = boxCenter(source.box); + const targetCenter = boxCenter(target.box); + + const candidates = index.queryForPolyline( + edge.points, + OBSTACLE_QUERY_MARGIN, + edge.source, + edge.target + ); + const obstacles: NodeBox[] = []; + for (const box of candidates) { + if (boxContainsPoint(box, sourceCenter) || boxContainsPoint(box, targetCenter)) { + continue; + } + obstacles.push(box); + } + + const result = routePolylineAroundObstacles( + edge.points, + obstacles, + scoreCrossings ? routed : NO_REFERENCE_POLYLINES + ); + if (scoreCrossings) routed.push(result); + points += result.length; + } + return points; +} + +// --------------------------------------------------------------------------- +// Timing +// --------------------------------------------------------------------------- + +interface Timing { + name: string; + status: "ok" | "skipped"; + reps?: number; + meanMs?: number; + minMs?: number; + totalMs?: number; + note?: string; +} + +/** + * Run `fn` up to `reps` times, stopping early once `budgetMs` of wall time is + * spent. The heavy scenarios take tens of seconds per rep; a fixed rep count + * would make the suite unusable at the top size. + */ +function time(name: string, reps: number, budgetMs: number, fn: () => unknown): Timing { + const samples: number[] = []; + const deadline = performance.now() + budgetMs; + + // One untimed warm-up so JIT tiering does not land entirely in sample 1. + fn(); + + for (let i = 0; i < reps; i++) { + const start = performance.now(); + fn(); + samples.push(performance.now() - start); + if (performance.now() > deadline) break; + } + + const total = samples.reduce((a, b) => a + b, 0); + return { + name, + status: "ok", + reps: samples.length, + meanMs: total / samples.length, + minMs: Math.min(...samples), + totalMs: total, + }; +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +interface SizeSpec { + nodes: number; + edges: number; +} + +function parseSizes(raw: string | undefined): SizeSpec[] { + if (!raw) { + return [ + { nodes: 300, edges: 200 }, + { nodes: 800, edges: 500 }, + { nodes: 1500, edges: 1200 }, + ]; + } + return raw.split(",").map((chunk) => { + const [nodes, edges] = chunk.split("x").map((n) => Number.parseInt(n, 10)); + if (!Number.isFinite(nodes) || !Number.isFinite(edges)) { + throw new Error(`bad --sizes entry: ${chunk} (expected e.g. 800x500)`); + } + return { nodes, edges }; + }); +} + +function flag(name: string): string | undefined { + const idx = process.argv.indexOf(`--${name}`); + return idx >= 0 ? process.argv[idx + 1] : undefined; +} + +async function main(): Promise { + const sizes = parseSizes(flag("sizes")); + const label = flag("label") ?? "unlabelled"; + const reps = Number.parseInt(flag("reps") ?? "5", 10); + const budgetMs = Number.parseInt(flag("budget-ms") ?? "20000", 10); + + const obstacleMod = await loadOptional( + "../src/canvas/layout/obstacleIndex.ts" + ); + const budgetMod = await loadOptional( + "../src/canvas/renderers/edgeRoutingBudget.ts" + ); + + process.stderr.write( + `[edgeRouting.bench] label=${label} obstacleIndex=${obstacleMod ? "present" : "absent"} ` + + `routingBudget=${budgetMod ? "present" : "absent"}\n` + ); + + const results: Array<{ nodes: number; edges: number; timings: Timing[] }> = []; + + for (const size of sizes) { + const layout = buildLayout(size.nodes, size.edges); + const timings: Timing[] = []; + + timings.push( + time("full_scan_routing", reps, budgetMs, () => routeFullScan(layout, true)) + ); + + if (obstacleMod) { + timings.push( + time("indexed_routing", reps, budgetMs, () => + routeIndexed(layout, obstacleMod, true) + ) + ); + } else { + timings.push({ + name: "indexed_routing", + status: "skipped", + note: "src/canvas/layout/obstacleIndex.ts does not exist on this branch", + }); + } + + // What this branch actually does for a redraw of this size. + if (budgetMod && obstacleMod) { + const mode = budgetMod.resolveEdgeRoutingMode({ + renderedEdges: size.edges, + visibleNodes: size.nodes, + }); + const scoreCrossings = budgetMod.scoresEdgeCrossings(mode); + const routes = budgetMod.routesAroundObstacles(mode); + const timing = time("shipped_redraw", reps, budgetMs, () => + routes ? routeIndexed(layout, obstacleMod, scoreCrossings) : 0 + ); + timing.note = `budget mode=${mode}`; + timings.push(timing); + } else { + const timing = time("shipped_redraw", reps, budgetMs, () => + routeFullScan(layout, true) + ); + timing.note = "no routing budget on this branch: always full-scan, crossing-aware"; + timings.push(timing); + } + + for (const t of timings) { + process.stderr.write( + ` ${size.nodes}n/${size.edges}e ${t.name.padEnd(18)} ` + + (t.status === "ok" + ? `${t.meanMs!.toFixed(1)}ms mean over ${t.reps} reps${t.note ? ` (${t.note})` : ""}\n` + : `skipped (${t.note})\n`) + ); + } + + results.push({ nodes: size.nodes, edges: size.edges, timings }); + } + + const report = { + label, + runtime: process.version, + obstacleIndexAvailable: Boolean(obstacleMod), + routingBudgetAvailable: Boolean(budgetMod), + reps, + sizes: results, + }; + + const rendered = `${JSON.stringify(report, null, 2)}\n`; + const out = flag("json"); + if (out) { + const { writeFileSync } = await import("node:fs"); + writeFileSync(out, rendered); + } + process.stdout.write(rendered); +} + +main().catch((error: unknown) => { + process.stderr.write(`edgeRouting.bench failed: ${String(error)}\n`); + process.exitCode = 1; +}); From 949066d25240036d6c50571a720fb353859eed0a Mon Sep 17 00:00:00 2001 From: RhizoNymph Date: Tue, 11 Aug 2026 19:28:33 -0700 Subject: [PATCH 06/12] test: add a runner that drives every benchmark layer in one pass --- benchmarks/run_all.sh | 69 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100755 benchmarks/run_all.sh diff --git a/benchmarks/run_all.sh b/benchmarks/run_all.sh new file mode 100755 index 0000000..338a37b --- /dev/null +++ b/benchmarks/run_all.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# +# Run every benchmark layer once and drop the raw output in one directory. +# +# benchmarks/run_all.sh