From 46cc40d0387e35f4ab2fec4203b36a8e02fe3011 Mon Sep 17 00:00:00 2001 From: Felipe705x Date: Thu, 23 Jul 2026 22:53:20 -0400 Subject: [PATCH] =?UTF-8?q?perf(typing):=20M06=20=E2=80=94=20descriptor=20?= =?UTF-8?q?interning;=20PathSummary=20and=20junction=20cache=20on=20ids?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schema gains a descriptor interner (hash-consing DescriptorType → dense u32, ids stable for the Schema's lifetime). PathSummary boundaries and the junction memo now operate on ids: - dedup/equality in summaries is integer arithmetic (was: eq walks over label trees + property BTreeMaps per insertion), - the junction cache keys by (u32, u32) — a lookup no longer hashes two rich descriptors, - summary clones copy Vec instead of descriptor trees, - each distinct descriptor pays exactly one hash, at intern time. Ids are schema-scoped; summaries only ever compare within one schema (the only comparison the checker performs). summarize() now takes the schema; hom proptest updated and green (meet/union commute, judgments agree with the live reference). Idle-machine medians vs M05 (all suites + full 80-target sweep green): - chain_1 8.8 → 3.7 us (−58%) chain_16 141 → 64 us (−54%) - chain_dir_16 147 → 73 us (−50%) anydir_8 94 → 54 us (−42%) - union_8 94 → 59 us (−37%) subq_exists 41.5 → 22.6 us (−46%) - multi_optional 77 → 44 us (−43%) repeat_1_3 10 → 3.7 us (−63%) - anon_16 cliff guard 12.7 → 4.2 ms - check/parse ratios on typical shapes: 2.3–8.9× (north star ≤1×) vs session baseline: chain_16 7.0×, subq_exists 3.1×, chain_1 5.2×. Co-Authored-By: Claude Fable 5 --- src/typing/checker.rs | 4 +- src/typing/path_summary.rs | 140 +++++++++++++++-------------- src/typing/variable_type.rs | 90 +++++++++++-------- tests/path_summary_hom_proptest.rs | 8 +- 4 files changed, 132 insertions(+), 110 deletions(-) diff --git a/src/typing/checker.rs b/src/typing/checker.rs index 7243deb..48bc014 100644 --- a/src/typing/checker.rs +++ b/src/typing/checker.rs @@ -578,7 +578,7 @@ impl Typechecker { match node { PathPattern::Node(desc) => { let t = self.refine_pattern_node(desc); - let p = PathSummary::from_variable(&t, EdgeDir::Any); + let p = PathSummary::from_variable(&self.schema, &t, EdgeDir::Any); let env = create_context(desc, t); TypecheckResult::new(p, env) } @@ -732,7 +732,7 @@ impl Typechecker { fn check_edge(&mut self, dir: EdgeDir, desc: &Option) -> TypecheckResult { let t = self.refine_pattern_edge(dir, desc); - let p = PathSummary::from_variable(&t, dir); + let p = PathSummary::from_variable(&self.schema, &t, dir); let env = create_context(desc, t); TypecheckResult::new(p, env) } diff --git a/src/typing/path_summary.rs b/src/typing/path_summary.rs index d39076d..63cba8d 100644 --- a/src/typing/path_summary.rs +++ b/src/typing/path_summary.rs @@ -30,15 +30,15 @@ //! pattern length, so concatenation cost is independent of chain position //! and the exponential disappears. //! -//! Representation note: arms live in flat `Vec`s deduplicated by linear -//! equality scan, NOT hash sets. Queries users actually type summarize to -//! 1–3 arms, where a hash structure loses twice: allocation/setup per -//! operation, and hashing must walk the *entire* rich descriptor -//! (label + property `BTreeMap`) on every insert and lookup, while an -//! equality probe fails fast on the label. The linear scan is O(w²) in -//! arm width, bounded by the schema as above; the synthetic unlabeled -//! families are cliff guards, not tuning targets. Set semantics are -//! preserved by a manual order-insensitive `PartialEq`. +//! Representation note: boundaries are **interned descriptor ids** +//! (`Schema::intern_desc`) — each distinct descriptor pays one hash at +//! intern time, after which dedup, equality, and the junction-cache key +//! are integer operations. Arms live in flat `Vec`s deduplicated by +//! linear scan (typical queries summarize to 1–3 arms; the synthetic +//! unlabeled families are cliff guards, not tuning targets). Set +//! semantics are preserved by a manual order-insensitive `PartialEq`; +//! ids are schema-scoped, so summaries only compare within one schema — +//! which is the only place the checker ever compares them. //! //! Correspondence with the spec type: `PathSummary` is the image of the //! abstraction `summarize : PathType → PathSummary`, and the lattice @@ -63,14 +63,13 @@ use super::descriptor_type::DescriptorType; use super::path_type::{EdgeDir, PathType}; use super::variable_type::{Schema, VariableType}; -/// Boundary summary: zero-length arms as a node-descriptor set, edge- -/// bearing arms as `(first, last, min edge count)`. Both empty ⇔ the -/// path is unsatisfiable (`PathType::Zero`). Set semantics with `Vec` -/// storage — see the module's representation note. +/// Boundary summary: zero-length arms as a set of interned node- +/// descriptor ids, edge-bearing arms as `(first, last, min edge count)` +/// id triples. Both empty ⇔ the path is unsatisfiable (`PathType::Zero`). #[derive(Debug, Clone, Default)] pub struct PathSummary { - nodes: Vec, - pairs: Vec<(DescriptorType, DescriptorType, usize)>, + nodes: Vec, + pairs: Vec<(u32, u32, usize)>, } /// Order-insensitive set equality (arm order is an artifact of @@ -80,12 +79,7 @@ impl PartialEq for PathSummary { self.nodes.len() == other.nodes.len() && self.pairs.len() == other.pairs.len() && self.nodes.iter().all(|d| other.nodes.contains(d)) - && self.pairs.iter().all(|(f, l, n)| { - other - .pairs - .iter() - .any(|(f2, l2, n2)| f == f2 && l == l2 && n == n2) - }) + && self.pairs.iter().all(|p| other.pairs.contains(p)) } } impl Eq for PathSummary {} @@ -97,34 +91,35 @@ impl PathSummary { } /// A single-node path. Mirrors `PathType::Node`. - pub fn node(desc: DescriptorType) -> Self { + pub fn node(schema: &Schema, desc: &DescriptorType) -> Self { + let id = schema.intern_desc(desc); PathSummary { - nodes: vec![desc], + nodes: vec![id], pairs: Vec::new(), } } /// Mirrors `PathType::default()`: the anonymous star node. - pub fn star_node() -> Self { - PathSummary::node(DescriptorType::star()) + pub fn star_node(schema: &Schema) -> Self { + PathSummary::node(schema, &DescriptorType::star()) } /// Mirrors `PathType::from_variable`: build from a (refined) variable /// type and the direction the edge is observed at. - pub fn from_variable(t: &VariableType, dir: EdgeDir) -> Self { + pub fn from_variable(schema: &Schema, t: &VariableType, dir: EdgeDir) -> Self { match t { - VariableType::Node(d) => PathSummary::node(d.clone()), + VariableType::Node(d) => PathSummary::node(schema, d), VariableType::EdgeDirectional { left, right, .. } => { - directed_edge_pairs(left, right, dir) + directed_edge_pairs(schema, left, right, dir) } // Undirected: both orientations regardless of dir (as the // spec's `from_variable` does). VariableType::EdgeNonDirectional { left, right, .. } => { - directed_edge_pairs(left, right, EdgeDir::Any) + directed_edge_pairs(schema, left, right, EdgeDir::Any) } VariableType::Union(t1, t2) => PathSummary::union( - PathSummary::from_variable(t1, dir), - PathSummary::from_variable(t2, dir), + PathSummary::from_variable(schema, t1, dir), + PathSummary::from_variable(schema, t2, dir), ), VariableType::Group(_) | VariableType::Null @@ -153,13 +148,13 @@ impl PathSummary { self.nodes.is_empty() && self.pairs.is_empty() } - fn insert_node(&mut self, d: DescriptorType) { - if !self.nodes.contains(&d) { - self.nodes.push(d); + fn insert_node(&mut self, id: u32) { + if !self.nodes.contains(&id) { + self.nodes.push(id); } } - fn insert_pair(&mut self, f: DescriptorType, l: DescriptorType, len: usize) { + fn insert_pair(&mut self, f: u32, l: u32, len: usize) { debug_assert!(len >= 1, "edge-bearing pair with zero length"); for (f2, l2, n) in self.pairs.iter_mut() { if *f2 == f && *l2 == l { @@ -198,42 +193,40 @@ impl PathSummary { super::stats::record_pathtype_meet(); let mut out = PathSummary::zero(); // The junction only depends on (a.last, b.first) and is memoized - // on the Schema itself (`Schema::junction_nodes`) — cross-hop AND - // cross-query: a chain reuses one junction at every position, a - // REPL session across queries. A hit is an `Rc` bump. - let junction = |l1: &DescriptorType, f2: &DescriptorType| schema.junction_nodes(l1, f2); + // on the Schema (`Schema::junction_ids`, keyed by id pair) — + // cross-hop AND cross-query. A hit is an `Rc` bump. // pairs × pairs: junction interior, outer boundaries survive. - for (f1, l1, len1) in &a.pairs { - for (f2, l2, len2) in &b.pairs { - if !junction(l1, f2).is_empty() { - out.insert_pair(f1.clone(), l2.clone(), len1 + len2); + for &(f1, l1, len1) in &a.pairs { + for &(f2, l2, len2) in &b.pairs { + if !schema.junction_ids(l1, f2).is_empty() { + out.insert_pair(f1, l2, len1 + len2); } } } // pairs × nodes: the zero-length right is the junction; the // refined junction becomes the result's last boundary. - for (f1, l1, len1) in &a.pairs { - for d in &b.nodes { - for r in junction(l1, d).iter() { - out.insert_pair(f1.clone(), r.clone(), *len1); + for &(f1, l1, len1) in &a.pairs { + for &d in &b.nodes { + for &r in schema.junction_ids(l1, d).iter() { + out.insert_pair(f1, r, len1); } } } // nodes × pairs: symmetric — refined junction becomes first. - for d in &a.nodes { - for (f2, l2, len2) in &b.pairs { - for r in junction(d, f2).iter() { - out.insert_pair(r.clone(), l2.clone(), *len2); + for &d in &a.nodes { + for &(f2, l2, len2) in &b.pairs { + for &r in schema.junction_ids(d, f2).iter() { + out.insert_pair(r, l2, len2); } } } // nodes × nodes: both are the junction; result is the refined // node itself. - for d1 in &a.nodes { - for d2 in &b.nodes { - for r in junction(d1, d2).iter() { - out.insert_node(r.clone()); + for &d1 in &a.nodes { + for &d2 in &b.nodes { + for &r in schema.junction_ids(d1, d2).iter() { + out.insert_node(r); } } } @@ -244,7 +237,7 @@ impl PathSummary { /// the checker's `pow_path_type`. pub fn pow(schema: &Schema, p: &PathSummary, n: u64) -> PathSummary { match n { - 0 => PathSummary::star_node(), + 0 => PathSummary::star_node(schema), 1 => p.clone(), _ => PathSummary::meet(schema, p, &PathSummary::pow(schema, p, n - 1)), } @@ -255,49 +248,58 @@ impl PathSummary { /// (it became interior when the edge was appended); a dead arm /// (empty descriptor or `Zero` prefix) contributes nothing, giving /// the live semantics documented above. - pub fn summarize(p: &PathType) -> PathSummary { + pub fn summarize(schema: &Schema, p: &PathType) -> PathSummary { match p { PathType::Zero => PathSummary::zero(), PathType::Node(n) => { if n.desc.is_empty() { PathSummary::zero() } else { - PathSummary::node(n.desc.clone()) + PathSummary::node(schema, &n.desc) } } PathType::Edge(e) => { if e.n2.desc.is_empty() { return PathSummary::zero(); } - let prefix = PathSummary::summarize(&e.p1); + let prefix = PathSummary::summarize(schema, &e.p1); + let n2 = schema.intern_desc(&e.n2.desc); let mut out = PathSummary::zero(); - for d in &prefix.nodes { - out.insert_pair(d.clone(), e.n2.desc.clone(), 1); + for &d in &prefix.nodes { + out.insert_pair(d, n2, 1); } - for (f, _, len) in &prefix.pairs { - out.insert_pair(f.clone(), e.n2.desc.clone(), len + 1); + for &(f, _, len) in &prefix.pairs { + out.insert_pair(f, n2, len + 1); } out } - PathType::Union(p1, p2) => { - PathSummary::union(PathSummary::summarize(p1), PathSummary::summarize(p2)) - } + PathType::Union(p1, p2) => PathSummary::union( + PathSummary::summarize(schema, p1), + PathSummary::summarize(schema, p2), + ), } } } /// Mirrors `path_type.rs::directed_edge_to_path` in pair form. -fn directed_edge_pairs(left: &VariableType, right: &VariableType, dir: EdgeDir) -> PathSummary { +fn directed_edge_pairs( + schema: &Schema, + left: &VariableType, + right: &VariableType, + dir: EdgeDir, +) -> PathSummary { let (l, r) = match (left, right) { (VariableType::Node(l), VariableType::Node(r)) => (l, r), _ => return PathSummary::zero(), }; + let l_id = schema.intern_desc(l); + let r_id = schema.intern_desc(r); let mut out = PathSummary::zero(); if matches!(dir, EdgeDir::Right | EdgeDir::Any | EdgeDir::None) { - out.insert_pair(l.clone(), r.clone(), 1); + out.insert_pair(l_id, r_id, 1); } if matches!(dir, EdgeDir::Left | EdgeDir::Any | EdgeDir::None) { - out.insert_pair(r.clone(), l.clone(), 1); + out.insert_pair(r_id, l_id, 1); } out } diff --git a/src/typing/variable_type.rs b/src/typing/variable_type.rs index 7a39806..9e72314 100644 --- a/src/typing/variable_type.rs +++ b/src/typing/variable_type.rs @@ -443,23 +443,33 @@ pub struct Schema { /// kill switch). #[serde(skip, default)] refine_cache: Rc>>>, + /// Descriptor interner: hash-conses `DescriptorType`s into dense `u32` + /// ids for the lifetime of this Schema. `PathSummary` and the junction + /// cache operate on ids, so their dedup/equality/hash work is integer + /// arithmetic instead of walks over label trees and property maps — + /// each distinct descriptor pays one hash at intern time. Ids are + /// never recycled (no cap: growth is bounded by distinct descriptors + /// seen against this schema, and DDL/inference replace the Schema). + #[serde(skip, default)] + interner: Rc>, /// Memo for `PathSummary::meet`'s junction refinement: for a boundary - /// pair `(last, first)` the satisfiable refined junction node - /// descriptors, i.e. `refine_to_nodes(meet(last, first))` flattened to - /// descriptors. Nested map so lookups need no key clones. Same - /// lifetime/invalidation story as `refine_cache`; cross-query AND - /// cross-hop (chains reuse the same junction at every position). - /// `GQLITE_DISABLE_TC_JUNCTION_CACHE=1` bypasses it. + /// id pair `(last, first)` the satisfiable refined junction node + /// descriptors as interned ids. Same lifetime/invalidation story as + /// `refine_cache`; cross-query AND cross-hop (chains reuse one + /// junction at every position). `GQLITE_DISABLE_TC_JUNCTION_CACHE=1` + /// bypasses it. #[serde(skip, default)] - #[allow(clippy::type_complexity)] - junction_cache: Rc< - std::cell::RefCell< - std::collections::HashMap< - DescriptorType, - std::collections::HashMap>>, - >, - >, - >, + junction_cache: Rc>, +} + +/// Junction memo: boundary id pair → refined junction descriptor ids. +type JunctionCache = std::collections::HashMap<(u32, u32), Rc>>; + +/// Backing store for [`Schema`]'s descriptor interner. +#[derive(Debug, Default)] +struct DescInterner { + ids: std::collections::HashMap, + descs: Vec>, } /// Safety valve for adversarial/degenerate sessions: the cache resets when @@ -477,6 +487,7 @@ impl Schema { VariableType::edge_non_directional(DescriptorType::star()), ]), refine_cache: Rc::default(), + interner: Rc::default(), junction_cache: Rc::default(), } } @@ -487,10 +498,28 @@ impl Schema { nodes: Rc::new(nodes), edges: Rc::new(edges), refine_cache: Rc::default(), + interner: Rc::default(), junction_cache: Rc::default(), } } + /// Intern a descriptor, returning its dense id for this Schema. + pub(crate) fn intern_desc(&self, d: &DescriptorType) -> u32 { + let mut i = self.interner.borrow_mut(); + if let Some(&id) = i.ids.get(d) { + return id; + } + let id = i.descs.len() as u32; + i.descs.push(Rc::new(d.clone())); + i.ids.insert(d.clone(), id); + id + } + + /// Resolve an interned descriptor id back to the descriptor. + pub(crate) fn desc_of(&self, id: u32) -> Rc { + Rc::clone(&self.interner.borrow().descs[id as usize]) + } + fn refine_cache_get(&self, key: &VariableType) -> Option> { self.refine_cache.borrow().get(key).map(Rc::clone) } @@ -503,31 +532,24 @@ impl Schema { m.insert(key, value); } - /// The satisfiable refined junction descriptors for a boundary pair — - /// `refine_to_nodes(meet(last, first))` flattened to descriptors, - /// memoized per schema (see `junction_cache`). - pub(crate) fn junction_nodes( - &self, - last: &DescriptorType, - first: &DescriptorType, - ) -> Rc> { + /// The satisfiable refined junction descriptors for a boundary id + /// pair — `refine_to_nodes(meet(last, first))` flattened to interned + /// ids, memoized per schema (see `junction_cache`). + pub(crate) fn junction_ids(&self, last: u32, first: u32) -> Rc> { let cache_on = !junction_cache_disabled(); if cache_on { - if let Some(hit) = self - .junction_cache - .borrow() - .get(last) - .and_then(|m| m.get(first)) - { + if let Some(hit) = self.junction_cache.borrow().get(&(last, first)) { return Rc::clone(hit); } } - let met = VariableType::Node(DescriptorType::meet(last, first)); - let rs: Rc> = Rc::new( + let last_d = self.desc_of(last); + let first_d = self.desc_of(first); + let met = VariableType::Node(DescriptorType::meet(&last_d, &first_d)); + let rs: Rc> = Rc::new( VariableType::refine_to_nodes(self, &met) .into_iter() .filter_map(|v| match v { - VariableType::Node(d) => Some(d), + VariableType::Node(d) => Some(self.intern_desc(&d)), _ => None, }) .collect(), @@ -537,9 +559,7 @@ impl Schema { if m.len() >= REFINE_CACHE_CAP { m.clear(); } - m.entry(last.clone()) - .or_default() - .insert(first.clone(), Rc::clone(&rs)); + m.insert((last, first), Rc::clone(&rs)); } rs } diff --git a/tests/path_summary_hom_proptest.rs b/tests/path_summary_hom_proptest.rs index 086c09f..738a795 100644 --- a/tests/path_summary_hom_proptest.rs +++ b/tests/path_summary_hom_proptest.rs @@ -148,11 +148,11 @@ fn live_len(p: &PathType) -> Option { } fn hom_checks(schema: &Schema, p: &PathType, q: &PathType) -> Result<(), TestCaseError> { - let sp = PathSummary::summarize(p); - let sq = PathSummary::summarize(q); + let sp = PathSummary::summarize(schema, p); + let sq = PathSummary::summarize(schema, q); // Homomorphism: meet and union commute with summarize. - let spec_meet = PathSummary::summarize(&PathType::meet(schema, p, q)); + let spec_meet = PathSummary::summarize(schema, &PathType::meet(schema, p, q)); let eval_meet = PathSummary::meet(schema, &sp, &sq); prop_assert_eq!( spec_meet, @@ -162,7 +162,7 @@ fn hom_checks(schema: &Schema, p: &PathType, q: &PathType) -> Result<(), TestCas q ); - let spec_union = PathSummary::summarize(&PathType::union(p.clone(), q.clone())); + let spec_union = PathSummary::summarize(schema, &PathType::union(p.clone(), q.clone())); let eval_union = PathSummary::union(sp.clone(), sq.clone()); prop_assert_eq!( spec_union,