From 3d3001dbe8f4813c34d7efbb60aa735a324e5d91 Mon Sep 17 00:00:00 2001 From: Felipe705x Date: Wed, 22 Jul 2026 23:16:34 -0400 Subject: [PATCH] =?UTF-8?q?perf(typing):=20QW3=20=E2=80=94=20refine=20memo?= =?UTF-8?q?=20cache=20on=20Schema=20(15=E2=80=9335%=20on=20labeled=20shape?= =?UTF-8?q?s)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VariableType::refine's Node/Edge arms — a linear scan of the schema with recursive is_subtype + allocating meet per entry — now memoize per input type in a HashMap attached to the Schema (Rc-shared, RefCell). Because Typechecker::new(schema.clone()) shares the Rc, the cache is transparently cross-query for REPL/Connection lifetimes; invalidation is by construction (DDL and inference replace the whole Schema, never mutate in place; serde skips the field so deserialized schemas start cold). Capped at 4096 entries as a safety valve. - Hash derives on VariableType / DescriptorType / PropertyType (LabelType and SimpleType already had them) to key the memo. - Kill switch GQLITE_DISABLE_TC_REFINE_CACHE=1 (read per call, same convention as the runtime toggles) for A/B. - New refine_cache_hits counter in typing::stats + CSV/stdout columns in pattern_typecheck. - tests/tc_refine_cache_test.rs: differential suite pinning cache-on (cold AND warm) ≡ cache-off verdicts/errors/warnings over star and movies.gdb-inferred schemas. Measured (pattern_typecheck, LDBC SF0.1 schema, steady state = 100% hit rate on every case): - chain_16 436 → 349 us (−20%) anydir_8 194 → 148 us (−24%) - union_8 174 → 140 us (−20%) repeat_1_3 21.5 → 13.7 us (−36%) - subq_exists 68 → 58 us (−15%) chain_dir_16 440 → 423 us (−4%) - anon_unlabeled unchanged (its cost is PathType tree growth, not refine) chain_dir's small gain isolates the next dominant term for labeled shapes: PathType/descriptor meets (pt_meets 112 vs chain's 48). Consequence for the planned QW4 (schema label index): gated OUT. At steady state the cache already answers every scan-arm call; the index could only accelerate the one cold scan per distinct descriptor per session (~10 us each on 25 edge entries). Recorded in the final report instead of implemented. Co-Authored-By: Claude Fable 5 --- src/bin/pattern_typecheck.rs | 16 ++++--- src/typing/descriptor_type.rs | 2 +- src/typing/property_type.rs | 2 +- src/typing/stats.rs | 8 ++++ src/typing/variable_type.rs | 60 +++++++++++++++++++++++-- tests/tc_refine_cache_test.rs | 82 +++++++++++++++++++++++++++++++++++ 6 files changed, 158 insertions(+), 12 deletions(-) create mode 100644 tests/tc_refine_cache_test.rs diff --git a/src/bin/pattern_typecheck.rs b/src/bin/pattern_typecheck.rs index c1aef23..de647d8 100644 --- a/src/bin/pattern_typecheck.rs +++ b/src/bin/pattern_typecheck.rs @@ -345,10 +345,10 @@ fn main() { } println!( - "\n{:<26}{:<11}{:>7}{:>9}{:>13}{:>9}{:>11}", - "case", "category", "exp", "got", "check_med_us", "refines", "edge_scan" + "\n{:<26}{:<11}{:>7}{:>9}{:>13}{:>9}{:>7}{:>11}", + "case", "category", "exp", "got", "check_med_us", "refines", "hits", "edge_scan" ); - println!("{}", "-".repeat(86)); + println!("{}", "-".repeat(93)); let mut rows: Vec = Vec::new(); let mut mismatches = 0usize; for (id, category, expected, q) in &cases { @@ -387,13 +387,14 @@ fn main() { mismatches += 1; } println!( - "{:<26}{:<11}{:>7}{:>9}{:>13.3}{:>9}{:>11}{}", + "{:<26}{:<11}{:>7}{:>9}{:>13.3}{:>9}{:>7}{:>11}{}", id, category, expected, got, med as f64 / 1000.0, st.refine_calls, + st.refine_cache_hits, st.edge_entries_scanned, if ok { "" } else { " <-- VERDICT MISMATCH" } ); @@ -409,13 +410,13 @@ fn main() { ok, }); } - println!("{}", "-".repeat(86)); + println!("{}", "-".repeat(93)); let mut f = File::create(&out).unwrap_or_else(|e| panic!("create {out}: {e}")); writeln!( f, "case,category,expected,got,check_med_ns,check_min_ns,\ - refine_calls,node_scanned,edge_scanned,refine_to_nodes,\ + refine_calls,refine_cache_hits,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" @@ -424,7 +425,7 @@ fn main() { for r in &rows { writeln!( f, - "{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}", + "{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}", r.id, r.category, r.expected, @@ -432,6 +433,7 @@ fn main() { r.med, r.min, r.st.refine_calls, + r.st.refine_cache_hits, r.st.node_entries_scanned, r.st.edge_entries_scanned, r.st.refine_to_nodes_calls, diff --git a/src/typing/descriptor_type.rs b/src/typing/descriptor_type.rs index 1783594..57ca80f 100644 --- a/src/typing/descriptor_type.rs +++ b/src/typing/descriptor_type.rs @@ -7,7 +7,7 @@ use super::property_type::PropertyType; /// Combines a label constraint with a property constraint. /// Used to describe the type of a node or edge. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct DescriptorType { pub label: LabelType, pub props: PropertyType, diff --git a/src/typing/property_type.rs b/src/typing/property_type.rs index ad64304..f1d983c 100644 --- a/src/typing/property_type.rs +++ b/src/typing/property_type.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; use super::simple_type::SimpleType; /// Property types describe the record structure of node/edge properties. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum PropertyType { /// Open record — allows extra unspecified attributes (returns Star for unknown keys) Open(BTreeMap), diff --git a/src/typing/stats.rs b/src/typing/stats.rs index 670257a..c081581 100644 --- a/src/typing/stats.rs +++ b/src/typing/stats.rs @@ -16,6 +16,8 @@ use std::cell::Cell; pub struct TcStats { /// Calls to `VariableType::refine` that hit a scan arm (Node or Edge). pub refine_calls: u64, + /// Scan-arm refine calls answered from the schema's memo cache. + pub refine_cache_hits: 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. @@ -36,6 +38,7 @@ pub struct TcStats { const ZERO: TcStats = TcStats { refine_calls: 0, + refine_cache_hits: 0, node_entries_scanned: 0, edge_entries_scanned: 0, refine_to_nodes_calls: 0, @@ -85,6 +88,11 @@ pub(crate) fn record_refine_edge_scan(entries: usize) { }); } +#[inline] +pub(crate) fn record_refine_cache_hit() { + bump(|s| s.refine_cache_hits += 1); +} + #[inline] pub(crate) fn record_refine_to_nodes() { bump(|s| s.refine_to_nodes_calls += 1); diff --git a/src/typing/variable_type.rs b/src/typing/variable_type.rs index 79bd92f..e0941d6 100644 --- a/src/typing/variable_type.rs +++ b/src/typing/variable_type.rs @@ -7,7 +7,8 @@ use super::descriptor_type::DescriptorType; use super::simple_type::SimpleType; /// Types for pattern variables (nodes, edges, unions, lists, bottom). -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +/// `Hash` keys the per-schema refine memo cache. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum VariableType { Node(DescriptorType), EdgeDirectional { @@ -304,6 +305,12 @@ impl VariableType { pub fn refine(schema: &Schema, node: &VariableType) -> VariableType { match node { VariableType::Node(_) => { + if !refine_cache_disabled() { + if let Some(hit) = schema.refine_cache_get(node) { + super::stats::record_refine_cache_hit(); + return hit; + } + } super::stats::record_refine_node_scan(schema.nodes.len()); let matches: Vec = schema .nodes @@ -311,9 +318,19 @@ impl VariableType { .filter(|n| VariableType::is_subtype(n, node)) .map(|n| VariableType::meet(n, node)) .collect(); - VariableType::join_from_list(matches) + let refined = VariableType::join_from_list(matches); + if !refine_cache_disabled() { + schema.refine_cache_put(node.clone(), refined.clone()); + } + refined } VariableType::EdgeDirectional { .. } | VariableType::EdgeNonDirectional { .. } => { + if !refine_cache_disabled() { + if let Some(hit) = schema.refine_cache_get(node) { + super::stats::record_refine_cache_hit(); + return hit; + } + } super::stats::record_refine_edge_scan(schema.edges.len()); let matches: Vec = schema .edges @@ -321,7 +338,11 @@ impl VariableType { .filter(|e| VariableType::is_subtype(e, node)) .map(|e| VariableType::meet(e, node)) .collect(); - VariableType::join_from_list(matches) + let refined = VariableType::join_from_list(matches); + if !refine_cache_disabled() { + schema.refine_cache_put(node.clone(), refined.clone()); + } + refined } VariableType::Union(t1, t2) => VariableType::join( VariableType::refine(schema, t1), @@ -388,8 +409,23 @@ impl fmt::Display for VariableType { pub struct Schema { pub nodes: Rc>, pub edges: Rc>, + /// Memo for `VariableType::refine`'s Node/Edge scan arms, keyed by the + /// pattern type being refined. Shared across `Schema::clone` (so every + /// `Typechecker::new(schema.clone())` reuses it — transparently + /// cross-query for REPL/Connection lifetimes) and safely invalidated by + /// construction: DDL and inference replace the whole `Schema`, never + /// mutate one in place, so a cache can never outlive its entries. + /// Skipped by serde — a deserialized schema starts cold. + /// `GQLITE_DISABLE_TC_REFINE_CACHE=1` bypasses it (A/B kill switch). + #[serde(skip, default)] + refine_cache: Rc>>, } +/// Safety valve for adversarial/degenerate sessions: the cache resets when +/// it grows past this many distinct pattern descriptors (real queries stay +/// in the dozens). +const REFINE_CACHE_CAP: usize = 4096; + impl Schema { /// Permissive schema that allows anything. pub fn star() -> Self { @@ -399,6 +435,7 @@ impl Schema { VariableType::edge_directional(DescriptorType::star()), VariableType::edge_non_directional(DescriptorType::star()), ]), + refine_cache: Rc::default(), } } @@ -407,10 +444,27 @@ impl Schema { Schema { nodes: Rc::new(nodes), edges: Rc::new(edges), + refine_cache: Rc::default(), + } + } + + fn refine_cache_get(&self, key: &VariableType) -> Option { + self.refine_cache.borrow().get(key).cloned() + } + + fn refine_cache_put(&self, key: VariableType, value: VariableType) { + let mut m = self.refine_cache.borrow_mut(); + if m.len() >= REFINE_CACHE_CAP { + m.clear(); } + m.insert(key, value); } } +fn refine_cache_disabled() -> bool { + std::env::var("GQLITE_DISABLE_TC_REFINE_CACHE").is_ok() +} + #[cfg(test)] mod tests { use super::*; diff --git a/tests/tc_refine_cache_test.rs b/tests/tc_refine_cache_test.rs new file mode 100644 index 0000000..eb2d73b --- /dev/null +++ b/tests/tc_refine_cache_test.rs @@ -0,0 +1,82 @@ +//! Differential suite for the typechecker's refine memo cache +//! (`Schema::refine_cache`): with the cache enabled (default) and disabled +//! (`GQLITE_DISABLE_TC_REFINE_CACHE=1`), `check_query` must produce +//! identical verdicts, errors, and warnings — cold and warm. +//! +//! Kept as a single `#[test]` because the kill switch is a process-global +//! env var and cargo runs tests in threads. + +use std::path::Path; + +use frogql::store::lazy::LazyGraphStore; +use frogql::typing::checker::Typechecker; +use frogql::typing::variable_type::Schema; + +type Verdict = (bool, bool, Vec, Vec); + +fn verdict(schema: &Schema, q: &str) -> Verdict { + let parsed = frogql::parser::parse_query(q).expect("parse"); + let elab = frogql::elaborate::elaborate_query(parsed); + let mut tc = Typechecker::new(schema.clone()); + let r = tc.check_query(&elab); + (r.ok, r.empty, tc.errors.clone(), tc.warnings.clone()) +} + +const QUERIES: &[&str] = &[ + // Unlabeled / star shapes (wide refinements, exercise Union results). + "(a)", + "()-[]->()", + "()-[]->()-[]->()", + "(a)-[e]-(b)", + "(a)~[]~(b)~[]~(c)", + // Labeled shapes — valid or empty depending on the schema; either way + // both cache modes must agree. + "(p: Person)", + "(p: Person)-[]->(m: Movie)", + "(p: Person)-[:ACTED_IN]->(m: Movie)", + "(x: NoSuchLabel)", + "(a: Person)-[:NoSuchEdge]->(b: Person)", + // Repeats, unions, filters. + "(a)-[]->{1,3}(b)", + "(p: Person)-[]->{2,4}(m: Movie)", + "(a: Person) | (a: Movie)", + "(p: Person WHERE p.name = 'Keanu Reeves')", + "(p WHERE p.name = 'x')-[]->(q WHERE q.title = 'y')", + // Full-query surface: RETURN + subqueries + OPTIONAL. + "MATCH (a)-[]->(b) RETURN a", + "MATCH (a) WHERE EXISTS { MATCH (a)-[]->(b) } RETURN a", + "MATCH (a)-[]->(b) OPTIONAL MATCH (b)-[]->(c) RETURN a", +]; + +fn assert_cache_transparent(schema: &Schema, label: &str) { + for q in QUERIES { + // Cold + warm with the cache on (second run hits the memo). + std::env::remove_var("GQLITE_DISABLE_TC_REFINE_CACHE"); + let cold = verdict(schema, q); + let warm = verdict(schema, q); + // Cache off. + std::env::set_var("GQLITE_DISABLE_TC_REFINE_CACHE", "1"); + let off = verdict(schema, q); + std::env::remove_var("GQLITE_DISABLE_TC_REFINE_CACHE"); + assert_eq!(cold, off, "[{label}] cache-on (cold) != cache-off for: {q}"); + assert_eq!(warm, off, "[{label}] cache-on (warm) != cache-off for: {q}"); + } +} + +#[test] +fn refine_cache_is_transparent() { + // Star schema: everything satisfiable, exercises the Node/Edge arms + // with permissive entries. + assert_cache_transparent(&Schema::star(), "star"); + + // A data-derived schema from a committed example DB: multiple node and + // edge entries, so refinements produce real unions and real empties. + let lazy = + LazyGraphStore::open(Path::new("examples/movies.gdb")).expect("open examples/movies.gdb"); + let schema = lazy.catalog().active_schema(); + assert!( + !schema.nodes.is_empty() && !schema.edges.is_empty(), + "movies.gdb should yield a non-trivial inferred schema" + ); + assert_cache_transparent(&schema, "movies"); +}