This document analyzes the performance characteristics of three matching modes:
.prefix()Query: "test" with varying edit distances on 5000-word dictionary
| Distance | Exact Time | Prefix Time | Substring Time |
|---|---|---|---|
| 0 | 683 ns | 961 ns | 4.08 µs |
| 1 | 8.23 µs | 30.0 µs | 27.3 µs |
| 2 | 83.9 µs | 129.5 µs | 52.4 µs |
| 3 | 421 µs | 280 µs | 71.7 µs |
Key Findings:
Interpretation:
Edit distance = 1
| Query Length | Exact | Prefix | Substring |
|---|---|---|---|
| 2 chars | 6.02 µs | 58.5 µs | 17.3 µs |
| 4 chars | 8.17 µs | 30.1 µs | 24.3 µs |
| 7 chars | 8.21 µs | 8.08 µs | 23.9 µs |
| 11 chars | 7.70 µs | 8.02 µs | 25.8 µs |
Key Findings:
Interpretation:
| Mode | Time | Notes |
|---|---|---|
| Exact | 8.67 µs | ~10-20 results |
| Prefix | 30.0 µs | ~100-200 results |
| Substring | 229 µs | Limited to 1000 results |
Key Finding:
Distance = 2
| Mode | Ordered | Unordered |
|---|---|---|
| Exact | 85.4 µs | 85.8 µs |
| Prefix | 127.6 µs | 84.5 µs |
| Substring | 53.5 µs | 60.3 µs |
Key Finding:
Critical Optimization Opportunity: The 33% overhead in prefix ordered queries suggests the binary heap operations are expensive for the larger result sets that prefix matching produces.
| Dictionary Type | Time | Throughput |
|---|---|---|
| PathMap | 1.43 ms | 29.5 MiB/s |
| SuffixAutomaton | 6.18 ms | 6.84 MiB/s |
Key Finding:
Distance = 2
| Mode | Standard | Transposition |
|---|---|---|
| Exact | 85.1 µs | 101.5 µs |
| Prefix | 129.1 µs | 136.8 µs |
Key Finding:
Location: src/transducer/ordered_query.rs
Issue: Binary heap operations for ordering results create 33% overhead in prefix mode
Evidence:
Root Cause Analysis:
Looking at ordered_query.rs, the bottleneck is likely in the BinaryHeap operations combined with the large number of candidates that prefix matching generates. Each heap operation is O(log n), and with 10-20x more results in prefix mode, this compounds.
// Current implementation (simplified)
while let Some(Reverse(candidate)) = self.heap.pop() {
// Process candidate
// Push new candidates to heap
for (label, child) in node.edges() {
self.heap.push(Reverse(next_candidate));
}
}
Proposed Optimization:
Location: src/transducer/transition.rs and src/transducer/query.rs
Issue: 9.7x slowdown for 2-character prefix queries
Evidence:
Root Cause: Short queries create wider state space exploration because the characteristic vector is small, leading to more state merging operations.
Proposed Optimization:
Location: src/transducer/state.rs:44-61
Issue: State::insert() has O(n²) worst-case complexity
Current Implementation:
pub fn insert(&mut self, position: Position) {
// O(n) - Check if subsumed
for existing in &self.positions {
if existing.subsumes(&position) {
return;
}
}
// O(n) - Remove subsumed positions
self.positions.retain(|p| !position.subsumes(p));
// O(log n) - Binary search
let insert_pos = self.positions.binary_search(&position)
.unwrap_or_else(|pos| pos);
// O(n) - Vec insert
self.positions.insert(insert_pos, position);
}
Proposed Optimization:
Location: src/transducer/transition.rs:22-36
Issue: Computed repeatedly for same (dict_char, query) pairs
Current Implementation:
fn characteristic_vector<'a>(dict_char: u8, query: &[u8], ...) -> &'a [bool] {
for (i, item) in buffer.iter_mut().enumerate().take(len) {
let query_idx = offset + i;
*item = query_idx < query.len() && query[query_idx] == dict_char;
}
&buffer[..len]
}
Proposed Optimization:
Current Implementation: OPTIMAL ✓
The implementation correctly follows Schulz & Mihov (2002). The state transition logic is theoretically optimal:
Evidence: Linear scaling with dictionary size, logarithmic with max_distance
Current Implementation: SUBOPTIMAL for short queries
Issues:
Proposed Algorithm Improvements:
// Hybrid approach for optimal prefix matching
pub fn query_prefix(&self, query: &str, max_distance: usize) {
if query.len() < 4 && max_distance <= 1 {
// Use simple trie prefix enumeration (much faster)
return self.trie_prefix_enumerate(query, max_distance);
}
// Use full Levenshtein automaton for longer queries
return self.levenshtein_prefix(query, max_distance);
}
Current Implementation: OPTIMAL for construction, GOOD for querying ✓
The suffix automaton implementation is theoretically sound:
Minor Improvement Opportunity: The 6x overhead at distance=0 could be reduced by:
Impact: 33% speedup for ordered prefix queries
Complexity: Medium
Files: src/transducer/ordered_query.rs
Implementation:
// Add beam search parameter
pub struct OrderedQueryIterator<N> {
heap: BinaryHeap<Reverse<Candidate>>,
beam_width: Option<usize>, // Limit heap size
// ...
}
impl<N> Iterator for OrderedQueryIterator<N> {
fn next(&mut self) -> Option<Self::Item> {
// Trim heap if it exceeds beam width
if let Some(width) = self.beam_width {
while self.heap.len() > width {
// Remove worst candidate
let candidates: Vec<_> = self.heap.drain().collect();
candidates.into_iter()
.take(width)
.for_each(|c| self.heap.push(c));
}
}
// ...existing logic...
}
}
Impact: 5-9x speedup for 2-4 character prefix queries
Complexity: Low
Files: src/transducer/query.rs, src/transducer/ordered_query.rs
Implementation:
pub fn query_ordered(&self, query: &str, max_distance: usize) -> OrderedQueryIterator<N> {
// Fast path for short exact prefix queries
if query.len() <= 3 && max_distance == 0 {
return self.exact_prefix_iterator(query);
}
// Standard Levenshtein automaton path
// ...existing logic...
}
Impact: 10-15% speedup via reduced allocations
Complexity: Low
Files: src/transducer/state.rs
Implementation:
use smallvec::{SmallVec, smallvec};
pub struct State {
// Most states have ≤ 8 positions
positions: SmallVec<[Position; 8]>,
}
Impact: 5-10% speedup for long queries
Complexity: High
Files: src/transducer/transition.rs
Implementation (requires portable-simd feature):
#[cfg(target_feature = "avx2")]
fn characteristic_vector_simd(dict_char: u8, query: &[u8], ...) {
use std::simd::*;
// Use SIMD to compare 16-32 bytes at once
// Fall back to scalar for remainder
}
| Scenario | Current | Target | Method |
|---|---|---|---|
| Prefix ordered (d=2) | 127.6 µs | 90 µs | Beam search heap |
| Prefix 2-char (d=1) | 58.5 µs | 10 µs | Fast path |
| Prefix 4-char (d=1) | 30.1 µs | 12 µs | Fast path + SmallVec |
| Exact (d=2) | 83.9 µs | 75 µs | SmallVec + SIMD |
The flame graph (flamegraph.svg) shows:
Hottest path (~40% of time): transition_standard → Position::new → SmallVec operations
Second hottest (~25%): BinaryHeap::push/pop in ordered queries
Third hottest (~15%): State::insert → Vec::retain → Position::subsumes
Minor hotspots (~5% each):
characteristic_vector - SIMD opportunityDictionary::edges iteration - unavoidablemin_distance computation - already optimalThe prefix and substring matching algorithms are algorithmically sound and follow best practices from the literature. However, there are significant practical optimization opportunities:
Combined, these optimizations could yield:
The algorithms themselves are optimal; the gains come from implementation-level optimizations.
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 |