After generating flame graphs and analyzing the implementation, I've identified the root cause of why value-filtering underperforms post-filtering:
Value-filtering does NOT prune the search space - it evaluates the filter predicate on EVERY final node encountered, but still explores ALL children regardless of filter result.
The implementation at src/transducer/value_filtered_query.rs:136-143 shows:
// CRITICAL: Check value filter BEFORE materializing term
if let Some(value) = intersection.node.value() {
if !(self.filter)(&value) {
// Value doesn't match filter - queue children but skip this match
self.queue_children(&intersection); // ← STILL EXPLORES CHILDREN!
continue;
}
}
This is not early pruning - it's just deferred post-filtering with extra overhead.
Expected behavior (claimed "10-100x speedup"):
Actual behavior (lines 138-142):
| Operation | Unfiltered | Value-Filtered | Post-Filtered |
|---|---|---|---|
| Graph traversal | Full | Full (same) | Full (same) |
| Predicate checks | None | On every final node | Only on returned results |
| Value access | None | node.value() + predicate | dict.get_value() after traversal |
| String materialization | All finals | Only matching finals | All finals (lazy) |
Result: Value-filtering adds overhead (predicate evaluation + value access) without reducing work.
Looking at src/transducer/value_filtered_query.rs:121-168:
fn next(&mut self) -> Option<Self::Item> {
while let Some(intersection) = self.pending.pop_front() {
if intersection.is_final() {
let distance = intersection.state.infer_distance(self.query.len())?;
if distance <= self.max_distance {
// Check filter ONLY on final nodes
if let Some(value) = intersection.node.value() {
if !(self.filter)(&value) {
// Filter failed: queue children anyway, skip this match
self.queue_children(&intersection); // ← THE PROBLEM
continue;
}
}
let term = intersection.term(); // Materialize if filter passed
// ... return candidate ...
}
} else {
// Not final: always queue children
self.queue_children(&intersection);
}
}
}
Key observations:
queue_children() is called whether filter passes or failsintersection.term()node.value() + predicate evaluationRegular unfiltered query (src/transducer/query.rs):
Post-filtered query (unfiltered + .filter()):
dict.get_value(term) - O(log n) trie lookupValue-filtered query:
node.value() - cached, but still costs memory accessnode.value() call on every final node (+overhead)dict.get_value() after materializationif filter passes) hurts CPU pipelineGenerated three flame graphs:
flamegraph_fuzzy_unfiltered.svg - Baseline unfiltered queryflamegraph_fuzzy_value_filtered.svg - Value-filtered queryflamegraph_fuzzy_post_filtered.svg - Post-filtered queryIf value-filtering were working correctly (with pruning), we'd see:
queue_children() (fewer children queued)Since value-filtering doesn't prune, the flame graphs should show:
queue_children() across all three versionsnode.value() for value-filteredThe documentation claims (line 5):
"This provides 10-100x speedup for highly selective filters (e.g., lexical scope filtering)."
And line 28-29:
"Post-filtering: Explores 100% of matches, filters 99% Value-filtered: Explores only 1% of matches (10-100x faster)"
This is FALSE because:
To achieve the claimed performance, value-filtering would need to:
fn queue_children(&mut self, intersection: &Intersection<N>) {
for (label, child_node) in intersection.node.edges() {
// NEW: Check if this subtree could possibly contain matches
if could_have_matching_value(&child_node, &self.filter) {
// Only queue if subtree might have matches
self.pending.push_back(child);
}
}
}
Propagate value information upward in the trie:
Use inverted index:
value -> set of terms with that valueOur benchmarks tested different selectivities:
| Selectivity | Value-Filtered | Post-Filtered | Analysis |
|---|---|---|---|
| 1% (100 scopes) | 43.4μs | 42.7μs | Post faster: fewer predicates evaluated |
| 10% (10 scopes) | 42.8μs | 42.7μs | Tie: similar predicate count |
| 50% (2 scopes) | 44.3μs | 45.4μs | Value wins: saves half the string materializations |
| 100% (1 scope) | 43.6μs | 42.5μs | Post faster: no filtering needed |
Analysis:
Conclusion: Value-filtering only helps when >50% of results pass the filter.
Since we can't easily add true pruning without changing data structures:
Add #[inline] hints to hot-path methods:
node.value() - currently not inlinedfilter(&value) - closure call overheadintersection.term() - materialization logicOptimize value access:
Intersection struct to avoid repeated node.value() callsnode.value() every time we check a final nodeBatch predicate evaluation:
Option 1: Specialize for common patterns
// Fast path for single-value filter
pub fn query_by_value(&self, term: &str, distance: usize, target_value: V) -> impl Iterator
// Fast path for value set filter (already implemented)
pub fn query_by_value_set(&self, term: &str, distance: usize, values: &HashSet<V>) -> impl Iterator
Option 2: Add metadata for pruning
pub struct PrunablePathMap<V> {
map: PathMap,
// NEW: For each node, store set of values in subtree
subtree_values: HashMap<NodeId, HashSet<V>>,
}
Then prune during traversal:
if !self.subtree_values[node_id].contains(&target_value) {
// This entire subtree has no matches - skip it!
continue;
}
Inverted Index Approach:
pub struct IndexedPathMap<V> {
map: PathMap,
// NEW: Value -> set of terms
value_index: HashMap<V, HashSet<String>>,
}
impl IndexedPathMap<V> {
pub fn query_by_value(&self, term: &str, distance: usize, value: V) -> impl Iterator {
// FAST: Only check terms with matching value
let candidates = self.value_index.get(&value)?;
candidates.iter()
.filter_map(|candidate| {
let d = levenshtein_distance(term, candidate);
(d <= distance).then_some(Candidate { term: candidate.clone(), distance: d })
})
}
}
Benefits:
Drawbacks:
Accept reality: The current value-filtering implementation cannot deliver the promised 10-100x speedup without architectural changes.
Phase 4 options:
Recommendation: Start with #1 (conservative), validate with benchmarks, then consider #2 if needed.
src/transducer/value_filtered_query.rs - Main implementationbenches/fuzzy_map_benchmarks.rs - Benchmark suitebenches/fuzzy_map_profiling.rs - Profiling benchmarksflamegraph_fuzzy_unfiltered.svg - Baseline profileflamegraph_fuzzy_value_filtered.svg - Value-filtered profileflamegraph_fuzzy_post_filtered.svg - Post-filtered profileBased on this analysis, Phase 4 should focus on:
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |