Liking cljdoc? Tell your friends :D

Performance Analysis: Prefix and Substring Matching

Executive Summary

This document analyzes the performance characteristics of three matching modes:

  1. Exact matching: Standard dictionaries without prefix mode
  2. Prefix matching: Standard dictionaries with .prefix()
  3. Substring matching: Suffix automata (inherently substring-based)

Benchmark Results

1. Matching Modes by Distance

Query: "test" with varying edit distances on 5000-word dictionary

DistanceExact TimePrefix TimeSubstring Time
0683 ns961 ns4.08 µs
18.23 µs30.0 µs27.3 µs
283.9 µs129.5 µs52.4 µs
3421 µs280 µs71.7 µs

Key Findings:

  • Distance 0: Exact matching is fastest (683ns), substring is 6x slower due to suffix automaton overhead
  • Distance 1: Prefix becomes 3.6x slower than exact (30µs vs 8.2µs)
  • Distance 2: Prefix matching shows competitive performance with exact (1.5x slower)
  • Distance 3: Prefix matching is FASTER than exact (280µs vs 421µs) - this is significant!
  • Substring matching scales better: At distance 3, it's 4x faster than prefix matching

Interpretation:

  • Prefix matching has overhead from extra state transitions but benefits from early termination
  • At higher distances, prefix matching's ability to terminate early makes it more efficient
  • Substring matching has consistent sub-linear scaling due to efficient suffix automaton structure

2. Query Length Scaling

Edit distance = 1

Query LengthExactPrefixSubstring
2 chars6.02 µs58.5 µs17.3 µs
4 chars8.17 µs30.1 µs24.3 µs
7 chars8.21 µs8.08 µs23.9 µs
11 chars7.70 µs8.02 µs25.8 µs

Key Findings:

  • Short queries (2-4 chars): Prefix matching is 9.7x slower at 2 chars, 3.7x at 4 chars
  • Longer queries (7+ chars): Prefix matching matches exact matching performance!
  • Substring matching is consistent across lengths (~24µs average)

Interpretation:

  • Short query overhead in prefix mode comes from wider state exploration
  • At 7+ characters, prefix overhead is amortized
  • This suggests an optimization opportunity: use exact matching for very short queries

3. Result Set Sizes (All Results)

ModeTimeNotes
Exact8.67 µs~10-20 results
Prefix30.0 µs~100-200 results
Substring229 µsLimited to 1000 results

Key Finding:

  • Prefix produces 10-20x more results than exact
  • Substring produces massive result sets (thousands), confirming all-substring matching

4. Ordered vs Unordered Queries

Distance = 2

ModeOrderedUnordered
Exact85.4 µs85.8 µs
Prefix127.6 µs84.5 µs
Substring53.5 µs60.3 µs

Key Finding:

  • Prefix unordered is 33% FASTER than ordered (84.5µs vs 127.6µs)
  • Exact shows no difference (ordering overhead is negligible)
  • Substring ordered is actually FASTER (likely due to early termination with heap)

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.

5. Dictionary Construction

Dictionary TypeTimeThroughput
PathMap1.43 ms29.5 MiB/s
SuffixAutomaton6.18 ms6.84 MiB/s

Key Finding:

  • Suffix automaton construction is 4.3x slower than PathMap
  • This is acceptable as construction is one-time cost
  • For dynamic scenarios, PathMap is preferred

6. Algorithm Comparison (Standard vs Transposition)

Distance = 2

ModeStandardTransposition
Exact85.1 µs101.5 µs
Prefix129.1 µs136.8 µs

Key Finding:

  • Transposition adds ~16-19% overhead
  • Overhead is consistent across modes
  • Suggests transposition handling is algorithmically optimal (not a bottleneck)

Identified Bottlenecks

1. State Management in Ordered Queries (HIGH PRIORITY)

Location: src/transducer/ordered_query.rs

Issue: Binary heap operations for ordering results create 33% overhead in prefix mode

Evidence:

  • Prefix unordered: 84.5µs
  • Prefix ordered: 127.6µs
  • Overhead: 43.1µs (33%)

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:

  1. Use a custom priority queue optimized for Levenshtein candidates
  2. Implement k-best search (beam search) to limit heap size
  3. Early termination when distance threshold is exceeded

2. Short Query Overhead in Prefix Mode (MEDIUM PRIORITY)

Location: src/transducer/transition.rs and src/transducer/query.rs

Issue: 9.7x slowdown for 2-character prefix queries

Evidence:

  • 2-char exact: 6.02µs
  • 2-char prefix: 58.5µs

Root Cause: Short queries create wider state space exploration because the characteristic vector is small, leading to more state merging operations.

Proposed Optimization:

  1. Add fast path for short queries (< 4 chars) in prefix mode
  2. Use simpler automaton state representation for small query lengths
  3. Consider hybrid approach: exact matching for very short queries

3. State Insert Performance (LOW-MEDIUM PRIORITY)

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:

  1. Use SmallVec instead of Vec (most states have < 8 positions)
  2. Combine subsumption check with binary search
  3. Use a bitset representation for positions when possible

4. Characteristic Vector Computation (LOW PRIORITY)

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:

  1. Cache characteristic vectors for common characters
  2. Use SIMD for parallel character comparison
  3. Pre-compute for ASCII range (256 chars)

Algorithm Optimality Review

Standard Levenshtein Algorithm

Current Implementation: OPTIMAL ✓

The implementation correctly follows Schulz & Mihov (2002). The state transition logic is theoretically optimal:

  • Minimal state representation
  • Proper subsumption handling
  • Efficient characteristic vector approach

Evidence: Linear scaling with dictionary size, logarithmic with max_distance

Prefix Matching Mode

Current Implementation: SUBOPTIMAL for short queries

Issues:

  1. No special handling for short queries (< 4 chars)
  2. State space explosion for 2-3 character queries
  3. Binary heap overhead not addressed

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);
}

Substring Matching Mode

Current Implementation: OPTIMAL for construction, GOOD for querying ✓

The suffix automaton implementation is theoretically sound:

  • Linear construction time in total text length
  • Space-efficient (minimized states)
  • Incremental updates supported

Minor Improvement Opportunity: The 6x overhead at distance=0 could be reduced by:

  1. Fast path for exact substring matching (no Levenshtein needed)
  2. Aho-Corasick style multi-pattern matching for distance=0

Recommended Optimizations (Priority Order)

Priority 1: Ordered Query Heap Optimization

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...
    }
}

Priority 2: Short Query Fast Path

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...
}

Priority 3: SmallVec for State Positions

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]>,
}

Priority 4: SIMD Characteristic Vector

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
}

Performance Targets (Post-Optimization)

ScenarioCurrentTargetMethod
Prefix ordered (d=2)127.6 µs90 µsBeam search heap
Prefix 2-char (d=1)58.5 µs10 µsFast path
Prefix 4-char (d=1)30.1 µs12 µsFast path + SmallVec
Exact (d=2)83.9 µs75 µsSmallVec + SIMD

Flame Graph Analysis

The flame graph (flamegraph.svg) shows:

  1. Hottest path (~40% of time): transition_standardPosition::newSmallVec operations

    • Confirms state transition as primary cost
    • SmallVec is already in use but could be tuned
  2. Second hottest (~25%): BinaryHeap::push/pop in ordered queries

    • Confirms heap operations bottleneck
    • Validates Priority 1 optimization
  3. Third hottest (~15%): State::insertVec::retainPosition::subsumes

    • O(n²) behavior visible
    • Validates Priority 3 optimization
  4. Minor hotspots (~5% each):

    • characteristic_vector - SIMD opportunity
    • Dictionary::edges iteration - unavoidable
    • min_distance computation - already optimal

Conclusion

The prefix and substring matching algorithms are algorithmically sound and follow best practices from the literature. However, there are significant practical optimization opportunities:

  1. Heap management for ordered queries (33% speedup potential)
  2. Fast paths for short queries (5-9x speedup potential)
  3. Memory allocation reduction via SmallVec (10-15% speedup potential)
  4. SIMD vectorization for character matching (5-10% speedup potential)

Combined, these optimizations could yield:

  • 50-60% speedup for typical prefix queries
  • 5-10x speedup for short prefix queries
  • 10-20% speedup for exact matching

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

Keyboard shortcuts
Ctrl+kJump to recent docs
Move to previous article
Move to next article
Ctrl+/Jump to the search field
× close