From bc59e91f842cd42ada18c652f532b54c163fcf87 Mon Sep 17 00:00:00 2001 From: Felipe705x Date: Wed, 22 Jul 2026 22:40:48 -0400 Subject: [PATCH] perf(typing): counters + opt-in phase profiler + scaling bench harness Instrumentation pass before optimizing the typechecker (measure first): - src/typing/stats.rs: thread-local always-on counters for the hot primitives (refine calls + schema entries scanned per arm, refine_to_nodes, env meet/union/outer_join/to_group, PathType::meet). reset()/snapshot() around a region; increments are noise next to the schema scans they count. - Typechecker::enable_profiling(): per-phase wall split of check_query (pattern / rep-checks / group_by / returns / order_by) behind an Option> that costs one branch per phase when off. - pattern_typecheck: adds generated scaling families against the LDBC schema (chain_N, chain_dir_N, anydir_N, union_W, repeat_bounded, subq, anon_unlabeled_N), a --star-schema small-schema control, per-case counter + phase columns in the CSV, batch timing for sub-2us cases, and a per-case iteration budget so pathological cases cannot stall the run. Baseline (LDBC SF0.1 schema, 11 node / 25 edge entries, warm): - labeled chains scale linearly: ~28 us/hop (chain_16 = 448 us), of which ~23 us/hop is schema scanning (star-schema control: 5 us/hop). - refine dominates labeled patterns: chain_16 = 65 refine calls / 400 edge entries scanned. - pathological find: unlabeled chains blow up exponentially in structure SIZE with linear call counts (anon_unlabeled_8 = 32 ms, anon_unlabeled_16 = 23.3 s per check_query) -- PathType carries the full prefix history per union arm, so width multiplies per hop by the compatible-schema-entry count. Co-Authored-By: Claude Fable 5 --- src/bin/pattern_typecheck.rs | 371 +++++++++++++++++++++++++++++---- src/typing/checker.rs | 67 ++++++ src/typing/mod.rs | 1 + src/typing/path_type.rs | 1 + src/typing/stats.rs | 116 +++++++++++ src/typing/type_environment.rs | 4 + src/typing/variable_type.rs | 3 + 7 files changed, 519 insertions(+), 44 deletions(-) create mode 100644 src/typing/stats.rs diff --git a/src/bin/pattern_typecheck.rs b/src/bin/pattern_typecheck.rs index 03649f5..c1aef23 100644 --- a/src/bin/pattern_typecheck.rs +++ b/src/bin/pattern_typecheck.rs @@ -1,14 +1,25 @@ -//! pattern_typecheck — times the type checker on the internal-bench cases -//! reduced to the FPPC path-pattern surface (MATCH/RETURN dropped, a -//! query-level WHERE inlined into the descriptor). Isolated from the runtime, -//! with a reused checker and WARMUP + ITERS iterations. +//! pattern_typecheck — times the type checker in isolation. //! -//! cargo run --release --bin pattern_typecheck -- [path.gdb] [out.csv] +//! Two case populations: +//! * `bench` — the internal-bench cases reduced to the FPPC path-pattern +//! surface (MATCH/RETURN dropped, a query-level WHERE inlined into the +//! descriptor), kept for comparability with earlier runs. +//! * generated scaling families (`chain_N`, `chain_dir_N`, `anydir_N`, +//! `union_W`, `repeat_bounded`, `subq`, `anon_unlabeled_N`) that expose +//! how cost scales with pattern size against a real schema. //! -//! Emits per-case check medians to `results_rust_typecheck.csv` and asserts each -//! verdict (valid / empty / invalid) matches the expected classification. It -//! measures only Rust: any comparison against another checker is a separate step -//! that joins this CSV with the other's — this bench does not read either. +//! cargo run --release --bin pattern_typecheck -- [path.gdb] [out.csv] [--star-schema] +//! +//! `--star-schema` checks against `Schema::star()` instead of the file's +//! active schema — the small-schema control for separating scan cost +//! (O(schema)) from walk cost (O(pattern)). +//! +//! Per case it emits the timed median/min (hot loop, profiling off), one +//! counter snapshot (`typing::stats`), and one per-phase split +//! (`enable_profiling`), and asserts the verdict (valid / empty / invalid) +//! matches the expected classification. It measures only Rust: any +//! comparison against another checker is a separate step that joins this +//! CSV with the other's — this bench does not read either. use std::fs::File; use std::hint::black_box; @@ -17,10 +28,22 @@ use std::path::Path; use std::time::Instant; use frogql::store::lazy::LazyGraphStore; +use frogql::syntax::query::Query; use frogql::typing::checker::Typechecker; +use frogql::typing::stats; +use frogql::typing::variable_type::Schema; const WARMUP: usize = 1000; const ITERS: usize = 10000; +/// Per-case timed-loop budget. Heavy cases (the pathological scaling +/// families can reach hundreds of ms per call) get proportionally fewer +/// iterations instead of stalling the whole run. +const CASE_BUDGET_NS: u128 = 300_000_000; +const MIN_ITERS: usize = 10; +/// Below this median the per-call QPC read is a visible fraction of the +/// measurement; re-time in batches and report per-call = batch / BATCH. +const BATCH_THRESHOLD_NS: u128 = 2000; +const BATCH: usize = 32; const DEFAULT_GDB: &str = "bench/data/ldbc-sf0.1.gdb"; fn median_min_ns(mut v: Vec) -> (u128, u128) { @@ -28,21 +51,139 @@ fn median_min_ns(mut v: Vec) -> (u128, u128) { (v[v.len() / 2], v[0]) } +/// Hot-loop timing with the established warmup + median protocol; falls +/// back to batch timing when a case is too fast for per-call resolution. +fn time_case(tc: &mut Typechecker, q: &Query) -> (u128, u128) { + // Pilot estimate → scale iteration count to the per-case budget. + let t = Instant::now(); + for _ in 0..3 { + black_box(tc.check_query(black_box(q))); + } + let est = (t.elapsed().as_nanos() / 3).max(1); + let iters = ((CASE_BUDGET_NS / est) as usize).clamp(MIN_ITERS, ITERS); + let warmup = (iters / 10).clamp(3, WARMUP); + for _ in 0..warmup { + black_box(tc.check_query(black_box(q))); + } + let mut samples = Vec::with_capacity(iters); + for _ in 0..iters { + let t = Instant::now(); + black_box(tc.check_query(black_box(q))); + samples.push(t.elapsed().as_nanos()); + } + let (med, min) = median_min_ns(samples); + if med >= BATCH_THRESHOLD_NS { + return (med, min); + } + let mut samples = Vec::with_capacity(ITERS / BATCH); + for _ in 0..ITERS / BATCH { + let t = Instant::now(); + for _ in 0..BATCH { + black_box(tc.check_query(black_box(q))); + } + samples.push(t.elapsed().as_nanos() / BATCH as u128); + } + median_min_ns(samples) +} + +// --- Generated scaling families (all expected `valid` on the LDBC schema) --- + +/// `(p0: Person)~[:knows]~(p1: Person)~...` — N undirected labeled hops. +fn chain_case(n: usize) -> String { + let mut s = String::from("(p0: Person)"); + for i in 1..=n { + s.push_str(&format!("~[:knows]~(p{i}: Person)")); + } + s +} + +/// Directed labeled chain alternating `-[:likes]->` / `-[:hasCreator]->` +/// (Person → Comment → Person → ...). Exercises the edge-scan arm without +/// the Union PathTypes that `~` produces. +fn chain_dir_case(n: usize) -> String { + let mut s = String::from("(n0: Person)"); + for i in 1..=n { + if i % 2 == 1 { + s.push_str(&format!("-[:likes]->(n{i}: Comment)")); + } else { + s.push_str(&format!("-[:hasCreator]->(n{i}: Person)")); + } + } + s +} + +/// `(p0: Person)-[e1:knows]-(p1: Person)-...` — any-direction hops: each +/// one refines twice (forward + undirected) and unions the PathTypes. +fn anydir_case(n: usize) -> String { + let mut s = String::from("(p0: Person)"); + for i in 1..=n { + s.push_str(&format!("-[e{i}:knows]-(p{i}: Person)")); + } + s +} + +/// `()-[]->()-[]->...` — star descriptors everywhere: the control where +/// label-driven pruning cannot help and every position scans everything. +fn anon_case(n: usize) -> String { + let mut s = String::from("()"); + for _ in 0..n { + s.push_str("-[]->()"); + } + s +} + +/// W top-level `|` arms, alternating two valid shapes over shared vars. +fn union_case(w: usize) -> String { + (0..w) + .map(|i| { + if i % 2 == 0 { + "(a: Person)~[:knows]~(b: Person)" + } else { + "(b: Comment)-[:hasCreator]->(a: Person)" + } + }) + .collect::>() + .join(" | ") +} + fn main() { - let mut args = std::env::args().skip(1); - let gdb = args.next().unwrap_or_else(|| DEFAULT_GDB.to_string()); - let out = args - .next() - .unwrap_or_else(|| "results_rust_typecheck.csv".to_string()); + let mut gdb: Option = None; + let mut out: Option = None; + let mut star_schema = false; + for arg in std::env::args().skip(1) { + if arg == "--star-schema" { + star_schema = true; + } else if gdb.is_none() { + gdb = Some(arg); + } else if out.is_none() { + out = Some(arg); + } else { + panic!("unexpected argument: {arg}"); + } + } + let gdb = gdb.unwrap_or_else(|| DEFAULT_GDB.to_string()); + let out = out.unwrap_or_else(|| "results_rust_typecheck.csv".to_string()); - let lazy = LazyGraphStore::open(Path::new(&gdb)).unwrap_or_else(|e| panic!("open {gdb}: {e}")); - let schema = lazy.catalog().active_schema(); + let schema = if star_schema { + Schema::star() + } else { + let lazy = + LazyGraphStore::open(Path::new(&gdb)).unwrap_or_else(|e| panic!("open {gdb}: {e}")); + let schema = lazy.catalog().active_schema(); + schema + }; + println!( + "schema: {} ({} node entries, {} edge entries)", + if star_schema { "star" } else { gdb.as_str() }, + schema.nodes.len(), + schema.edges.len() + ); // Reduced path-pattern queries, one per internal-bench case (undirected `~` // edges, query-level WHERE inlined; the IC and aggregate cases are dropped // because they are not path patterns). i_parse is omitted (a parse error has // no typecheck to time). - let cases: &[(&str, &str, &str)] = &[ + let bench_cases: &[(&str, &str, &str)] = &[ ("v_label", "valid", "(p: Person)"), ("v_chain_knows", "valid", "(p: Person)~[:knows]~(f: Person)"), ("v_where", "valid", "(p: Person WHERE p.id = 933)"), @@ -109,14 +250,108 @@ fn main() { ), ]; + // (id, category, expected, query) + let mut cases: Vec<(String, String, String, String)> = bench_cases + .iter() + .map(|(id, exp, q)| { + ( + id.to_string(), + "bench".into(), + exp.to_string(), + q.to_string(), + ) + }) + .collect(); + for n in [1usize, 2, 4, 8, 16] { + cases.push(( + format!("chain_{n}"), + "chain".into(), + "valid".into(), + chain_case(n), + )); + } + for n in [1usize, 2, 4, 8, 16] { + cases.push(( + format!("chain_dir_{n}"), + "chain_dir".into(), + "valid".into(), + chain_dir_case(n), + )); + } + for n in [1usize, 2, 4, 8] { + cases.push(( + format!("anydir_{n}"), + "anydir".into(), + "valid".into(), + anydir_case(n), + )); + } + for w in [2usize, 4, 8] { + cases.push(( + format!("union_{w}"), + "union".into(), + "valid".into(), + union_case(w), + )); + } + for n in [1usize, 2, 4, 8, 16] { + cases.push(( + format!("anon_unlabeled_{n}"), + "anon".into(), + "valid".into(), + anon_case(n), + )); + } + cases.push(( + "repeat_1_3".into(), + "repeat".into(), + "valid".into(), + "(p: Person)~[:knows]~{1,3}(f: Person)".into(), + )); + cases.push(( + "repeat_2_4".into(), + "repeat".into(), + "valid".into(), + "(p: Person)~[:knows]~{2,4}(f: Person)".into(), + )); + cases.push(( + "subq_exists".into(), + "subq".into(), + "valid".into(), + "MATCH (p: Person)~[:knows]~(f: Person) \ + WHERE EXISTS { MATCH (f)<-[:hasCreator]-(c: Comment) } RETURN p.id" + .into(), + )); + cases.push(( + "multi_optional".into(), + "subq".into(), + "valid".into(), + "MATCH (p: Person)~[:knows]~(f: Person) \ + OPTIONAL MATCH (f)<-[:hasCreator]-(c: Comment) \ + OPTIONAL MATCH (f)<-[:hasCreator]-(m: Post) RETURN p.id" + .into(), + )); + + struct Row { + id: String, + category: String, + expected: String, + got: String, + med: u128, + min: u128, + st: stats::TcStats, + phases: [u128; 5], + ok: bool, + } + println!( - "\n{:<26}{:>7}{:>9}{:>15}", - "case", "exp", "got", "check_med_us" + "\n{:<26}{:<11}{:>7}{:>9}{:>13}{:>9}{:>11}", + "case", "category", "exp", "got", "check_med_us", "refines", "edge_scan" ); - println!("{}", "-".repeat(57)); - let mut rows: Vec<(String, String, String, u128, u128, bool)> = Vec::new(); + println!("{}", "-".repeat(86)); + let mut rows: Vec = Vec::new(); let mut mismatches = 0usize; - for (id, expected, q) in cases { + for (id, category, expected, q) in &cases { let parsed = frogql::parser::parse_query(q).expect("parse"); let elaborated = frogql::elaborate::elaborate_query(parsed); let mut tc = Typechecker::new(schema.clone()); @@ -128,51 +363,99 @@ fn main() { } else { "valid" }; - for _ in 0..WARMUP { - black_box(tc.check_query(black_box(&elaborated))); - } - let mut samples = Vec::with_capacity(ITERS); - for _ in 0..ITERS { - let t = Instant::now(); - black_box(tc.check_query(black_box(&elaborated))); - samples.push(t.elapsed().as_nanos()); - } - let (med, min) = median_min_ns(samples); + + let (med, min) = time_case(&mut tc, &elaborated); + + // One un-timed run for the counter shape, one profiled run for the + // phase split. Both after the timed loop so they cannot pollute it. + stats::reset(); + black_box(tc.check_query(black_box(&elaborated))); + let st = stats::snapshot(); + tc.enable_profiling(); + black_box(tc.check_query(black_box(&elaborated))); + let p = tc.last_profile().copied().unwrap_or_default(); + let phases = [ + p.pattern_ns, + p.rep_checks_ns, + p.group_by_ns, + p.returns_ns, + p.order_by_ns, + ]; + let ok = got == *expected; if !ok { mismatches += 1; } println!( - "{:<26}{:>7}{:>9}{:>15.3}{}", + "{:<26}{:<11}{:>7}{:>9}{:>13.3}{:>9}{:>11}{}", id, + category, expected, got, med as f64 / 1000.0, + st.refine_calls, + st.edge_entries_scanned, if ok { "" } else { " <-- VERDICT MISMATCH" } ); - rows.push(( - id.to_string(), - expected.to_string(), - got.to_string(), + rows.push(Row { + id: id.clone(), + category: category.clone(), + expected: expected.clone(), + got: got.to_string(), med, min, + st, + phases, ok, - )); + }); } - println!("{}", "-".repeat(57)); + println!("{}", "-".repeat(86)); let mut f = File::create(&out).unwrap_or_else(|e| panic!("create {out}: {e}")); - writeln!(f, "case,expected,got,check_med_ns,check_min_ns,status").unwrap(); - for (id, exp, got, med, min, ok) in &rows { + writeln!( + f, + "case,category,expected,got,check_med_ns,check_min_ns,\ + refine_calls,node_scanned,edge_scanned,refine_to_nodes,\ + env_meets,env_unions,env_outer_joins,env_to_groups,pt_meets,\ + phase_pattern_ns,phase_rep_ns,phase_group_by_ns,phase_returns_ns,phase_order_by_ns,\ + status" + ) + .unwrap(); + for r in &rows { writeln!( f, - "{id},{exp},{got},{med},{min},{}", - if *ok { "PASS" } else { "FAIL" } + "{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}", + r.id, + r.category, + r.expected, + r.got, + r.med, + r.min, + r.st.refine_calls, + r.st.node_entries_scanned, + r.st.edge_entries_scanned, + r.st.refine_to_nodes_calls, + r.st.env_meet_calls, + r.st.env_union_calls, + r.st.env_outer_join_calls, + r.st.env_to_group_calls, + r.st.pathtype_meet_calls, + r.phases[0], + r.phases[1], + r.phases[2], + r.phases[3], + r.phases[4], + if r.ok { "PASS" } else { "FAIL" } ) .unwrap(); } println!("wrote {out}"); - if mismatches == 0 { + if star_schema { + println!( + "note: star-schema mode — expected verdicts assume the LDBC schema, so \ + schema-driven `empty` cases legitimately read `valid` here ({mismatches} such)." + ); + } else if mismatches == 0 { println!( "SANITY OK: rust matched the expected verdict on all {} cases.", rows.len() diff --git a/src/typing/checker.rs b/src/typing/checker.rs index 379cf5d..fe87a7b 100644 --- a/src/typing/checker.rs +++ b/src/typing/checker.rs @@ -21,6 +21,24 @@ use super::simple_type::SimpleType; use super::type_environment::TypeEnvironment; use super::variable_type::{Schema, VariableType}; +/// Per-phase wall-time split of one `check_query` call, in nanoseconds. +/// Populated only when profiling is enabled via +/// [`Typechecker::enable_profiling`]; the phases mirror the sequential +/// sections of `check_query`. +#[derive(Debug, Default, Clone, Copy)] +pub struct PhaseProfile { + /// Pattern / match-chain walk (includes `collapsed_pattern` when taken). + pub pattern_ns: u128, + /// Unbounded-repetition + selective-isolation checks. + pub rep_checks_ns: u128, + /// GROUP BY key checking. + pub group_by_ns: u128, + /// RETURN item checking (incl. group-by matching). + pub returns_ns: u128, + /// ORDER BY spec checking. + pub order_by_ns: u128, +} + /// Result of type-checking a path pattern or query. #[derive(Clone, Debug)] pub struct TypecheckResult { @@ -58,6 +76,9 @@ pub struct Typechecker { /// support nesting. Empty outside subquery bodies, so a top-level /// per-clause WHERE keeps its strict pattern-local scope. ambient_env: Vec, + /// Per-phase timing of the last `check_query` call. `None` (the default) + /// costs one branch per phase; benches opt in via `enable_profiling`. + profile: Option>, } impl Typechecker { @@ -68,9 +89,22 @@ impl Typechecker { warnings: Vec::new(), comprehension_scope: Vec::new(), ambient_env: Vec::new(), + profile: None, } } + /// Record per-phase wall times for subsequent `check_query` calls. + /// Each call overwrites the previous profile; read it with + /// [`Typechecker::last_profile`]. + pub fn enable_profiling(&mut self) { + self.profile = Some(Box::default()); + } + + /// The phase split of the most recent `check_query`, if profiling is on. + pub fn last_profile(&self) -> Option<&PhaseProfile> { + self.profile.as_deref() + } + /// Permissive checker — `Schema::star()`. pub fn untyped() -> Self { Typechecker::new(Schema::star()) @@ -86,20 +120,31 @@ impl Typechecker { pub fn check_query(&mut self, q: &Query) -> TypecheckResult { self.errors.clear(); self.warnings.clear(); + if let Some(p) = self.profile.as_deref_mut() { + *p = PhaseProfile::default(); + } + let t = self.phase_start(); let mut r = if q.has_any_optional() { self.check_match_chain(&q.matches) } else { self.check_path_pattern(&q.collapsed_pattern()) }; r.empty = r.path.is_unsatisfiable() || r.env.is_empty(); + self.phase_end(t, |p| &mut p.pattern_ns); + let t = self.phase_start(); self.check_unbounded_repetition(q); self.check_selective_isolation(q); + self.phase_end(t, |p| &mut p.rep_checks_ns); + let t = self.phase_start(); if let Some(group_by) = &q.group_by { self.check_group_by(group_by, &r.env); } + self.phase_end(t, |p| &mut p.group_by_ns); + + let t = self.phase_start(); if let Some(returns) = &q.returns { self.check_returns(returns, &r.env); match &q.group_by { @@ -107,9 +152,13 @@ impl Typechecker { None => self.check_no_implicit_group_by(returns), } } + self.phase_end(t, |p| &mut p.returns_ns); + + let t = self.phase_start(); if let Some(specs) = &q.order_by { self.check_order_by(specs, q.returns.as_deref(), &r.env); } + self.phase_end(t, |p| &mut p.order_by_ns); if !self.errors.is_empty() { r.ok = false; @@ -117,6 +166,24 @@ impl Typechecker { r } + /// Start a phase timer — `None` (free) unless profiling is enabled. + #[inline] + fn phase_start(&self) -> Option { + self.profile.is_some().then(std::time::Instant::now) + } + + /// Store an elapsed phase time into the profile slot selected by `slot`. + #[inline] + fn phase_end( + &mut self, + start: Option, + slot: fn(&mut PhaseProfile) -> &mut u128, + ) { + if let (Some(t), Some(p)) = (start, self.profile.as_deref_mut()) { + *slot(p) = t.elapsed().as_nanos(); + } + } + /// ISO §16.17 + §22.14: enforce comparable-value-type per CR 1 /// (no Feature GA04). Mixed Expr+Column sort keys cannot be /// served by either pre- or post-projection sort. diff --git a/src/typing/mod.rs b/src/typing/mod.rs index 5e86ce4..e7276e8 100644 --- a/src/typing/mod.rs +++ b/src/typing/mod.rs @@ -6,6 +6,7 @@ pub mod label_type; pub mod path_type; pub mod property_type; pub mod simple_type; +pub mod stats; pub mod type_environment; pub mod validate; pub mod variable_type; diff --git a/src/typing/path_type.rs b/src/typing/path_type.rs index f9103ae..944ccaf 100644 --- a/src/typing/path_type.rs +++ b/src/typing/path_type.rs @@ -138,6 +138,7 @@ impl PathType { /// uses the schema to refine the descriptors at each shared node /// position. pub fn meet(schema: &Schema, p1: &PathType, p2: &PathType) -> PathType { + super::stats::record_pathtype_meet(); match (p1, p2) { (PathType::Zero, _) | (_, PathType::Zero) => PathType::Zero, diff --git a/src/typing/stats.rs b/src/typing/stats.rs new file mode 100644 index 0000000..670257a --- /dev/null +++ b/src/typing/stats.rs @@ -0,0 +1,116 @@ +//! Lightweight always-on counters for the typechecker's hot primitives. +//! +//! The counters answer "which component dominates a `check_query` call" +//! more precisely than wall time can at the µs scale: an increment is +//! ~1 ns against schema scans that run recursive `is_subtype`/`meet` +//! per entry, so they stay compiled in unconditionally. Thread-local so +//! concurrent embedders never contend or mix streams. +//! +//! Usage (see `src/bin/pattern_typecheck.rs`): +//! `stats::reset()` → run the region → `stats::snapshot()`. + +use std::cell::Cell; + +/// Counter values accumulated since the last [`reset`]. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct TcStats { + /// Calls to `VariableType::refine` that hit a scan arm (Node or Edge). + pub refine_calls: u64, + /// Total `schema.nodes` entries walked across all refine Node-arm scans. + pub node_entries_scanned: u64, + /// Total `schema.edges` entries walked across all refine Edge-arm scans. + pub edge_entries_scanned: u64, + /// Calls to `VariableType::refine_to_nodes` (PathType descriptor re-refinement). + pub refine_to_nodes_calls: u64, + /// Calls to `TypeEnvironment::meet` (Concat/Join/match-chain). + pub env_meet_calls: u64, + /// Calls to `TypeEnvironment::union` (pattern Union arms). + pub env_union_calls: u64, + /// Calls to `TypeEnvironment::outer_join` (OPTIONAL MATCH). + pub env_outer_join_calls: u64, + /// Calls to `TypeEnvironment::to_group` (Repeat/Questioned wrapping). + pub env_to_group_calls: u64, + /// Calls to `PathType::meet` (every Concat, recursing through Unions). + pub pathtype_meet_calls: u64, +} + +const ZERO: TcStats = TcStats { + refine_calls: 0, + node_entries_scanned: 0, + edge_entries_scanned: 0, + refine_to_nodes_calls: 0, + env_meet_calls: 0, + env_union_calls: 0, + env_outer_join_calls: 0, + env_to_group_calls: 0, + pathtype_meet_calls: 0, +}; + +thread_local! { + static STATS: Cell = const { Cell::new(ZERO) }; +} + +/// Zero all counters for the current thread. +pub fn reset() { + STATS.with(|s| s.set(ZERO)); +} + +/// Read the current counter values for this thread (does not reset). +pub fn snapshot() -> TcStats { + STATS.with(|s| s.get()) +} + +#[inline] +fn bump(f: impl FnOnce(&mut TcStats)) { + STATS.with(|s| { + let mut v = s.get(); + f(&mut v); + s.set(v); + }); +} + +#[inline] +pub(crate) fn record_refine_node_scan(entries: usize) { + bump(|s| { + s.refine_calls += 1; + s.node_entries_scanned += entries as u64; + }); +} + +#[inline] +pub(crate) fn record_refine_edge_scan(entries: usize) { + bump(|s| { + s.refine_calls += 1; + s.edge_entries_scanned += entries as u64; + }); +} + +#[inline] +pub(crate) fn record_refine_to_nodes() { + bump(|s| s.refine_to_nodes_calls += 1); +} + +#[inline] +pub(crate) fn record_env_meet() { + bump(|s| s.env_meet_calls += 1); +} + +#[inline] +pub(crate) fn record_env_union() { + bump(|s| s.env_union_calls += 1); +} + +#[inline] +pub(crate) fn record_env_outer_join() { + bump(|s| s.env_outer_join_calls += 1); +} + +#[inline] +pub(crate) fn record_env_to_group() { + bump(|s| s.env_to_group_calls += 1); +} + +#[inline] +pub(crate) fn record_pathtype_meet() { + bump(|s| s.pathtype_meet_calls += 1); +} diff --git a/src/typing/type_environment.rs b/src/typing/type_environment.rs index 6399c38..b7d34db 100644 --- a/src/typing/type_environment.rs +++ b/src/typing/type_environment.rs @@ -51,6 +51,7 @@ impl TypeEnvironment { /// joined with `Null` — the variable may be absent in the other branch. /// This matches the rule `Γ₁ ⊔ Γ₂` in the paper. pub fn union(a: &TypeEnvironment, b: &TypeEnvironment) -> TypeEnvironment { + super::stats::record_env_union(); let keys: HashSet<&String> = a.bindings.keys().chain(b.bindings.keys()).collect(); let mut result = HashMap::with_capacity(keys.len()); for key in keys { @@ -76,6 +77,7 @@ impl TypeEnvironment { a: &TypeEnvironment, b: &TypeEnvironment, ) -> Result { + super::stats::record_env_meet(); let mut result = a.bindings.clone(); for (key, other) in &b.bindings { let merged: Rc = match result.get(key) { @@ -119,6 +121,7 @@ impl TypeEnvironment { a: &TypeEnvironment, b: &TypeEnvironment, ) -> TypeEnvironment { + super::stats::record_env_outer_join(); let mut result: HashMap> = HashMap::new(); // Shared keys (i) and left-only keys (j) start from `a`. @@ -159,6 +162,7 @@ impl TypeEnvironment { /// patterns where variables become groups of values. (Mirrors fppc's /// `to_list` — gqlite uses `Group` for the same role.) pub fn to_group(&self) -> TypeEnvironment { + super::stats::record_env_to_group(); TypeEnvironment { bindings: self .bindings diff --git a/src/typing/variable_type.rs b/src/typing/variable_type.rs index c6ee78c..79bd92f 100644 --- a/src/typing/variable_type.rs +++ b/src/typing/variable_type.rs @@ -283,6 +283,7 @@ impl VariableType { /// the concrete `Node` variants reachable. Mirrors fppc's /// `VariableType::refine_to_nodes` and is consumed by `PathType::meet`. pub fn refine_to_nodes(schema: &Schema, t: &VariableType) -> Vec { + super::stats::record_refine_to_nodes(); let mut out = Vec::new(); let mut stack = vec![VariableType::refine(schema, t)]; while let Some(curr) = stack.pop() { @@ -303,6 +304,7 @@ impl VariableType { pub fn refine(schema: &Schema, node: &VariableType) -> VariableType { match node { VariableType::Node(_) => { + super::stats::record_refine_node_scan(schema.nodes.len()); let matches: Vec = schema .nodes .iter() @@ -312,6 +314,7 @@ impl VariableType { VariableType::join_from_list(matches) } VariableType::EdgeDirectional { .. } | VariableType::EdgeNonDirectional { .. } => { + super::stats::record_refine_edge_scan(schema.edges.len()); let matches: Vec = schema .edges .iter()