This document details the performance optimizations applied to Levenshtein distance implementations, including space optimization, SIMD acceleration, caching strategies, and early exit techniques.
These optimizations can provide 2-18× speedup depending on input characteristics.
These techniques build on the classic dynamic-programming formulation of Wagner & Fischer [3]; the early-termination strategies are instances of the bounded-distance cutoff analysed by Ukkonen [4], while the diagonal structure exploited by the SIMD path mirrors Myers' $\mathcal{O}(ND)$ difference algorithm [5].
Traditional DP stores full $m \times n$ matrix:
Memory: O(m×n) = 1000×1000 = 4 MB (for usize = 4 bytes)
Store only 2-3 rows instead of full matrix.
// Instead of:
let mut dp = vec![vec![0; n+1]; m+1]; // O(m×n)
// Use:
let mut prev_row = vec![0; n+1]; // O(n)
let mut curr_row = vec![0; n+1]; // O(n)
// Total: O(2n)
pub fn standard_distance_impl(source: &str, target: &str) -> usize {
// ...
let mut prev_row = vec![0; n + 1];
let mut curr_row = vec![0; n + 1];
for (j, item) in prev_row.iter_mut().enumerate().take(n + 1) {
*item = j;
}
for i in 1..=m {
curr_row[0] = i;
for j in 1..=n {
// Compute curr_row[j] using prev_row
curr_row[j] = (prev_row[j] + 1)
.min(curr_row[j - 1] + 1)
.min(prev_row[j - 1] + cost);
}
// Efficient swap: O(1) instead of O(n) copy
std::mem::swap(&mut prev_row, &mut curr_row);
}
prev_row[n]
}
Source: src/distance/mod.rs:260-287
| String Size | Full Matrix | Two-Row | Savings |
|---|---|---|---|
| 100×100 | 40 KB | 800 bytes | 50× |
| 500×500 | 1 MB | 4 KB | 250× |
| 1000×1000 | 4 MB | 8 KB | 500× |
Impact: 50-500× memory reduction
Many real-world strings share common prefixes/suffixes:
http://example.com/page1 vs http://example.com/page2/home/user/docs/file1.txt vs /home/user/docs/file2.txtgetUserName vs getUserIdComputing distance on full strings is wasteful.
Strip common prefix and suffix before recursion.
pub fn strip_common_affixes(a: &str, b: &str) -> (usize, usize, usize) {
let a_chars: SmallVec<[char; 32]> = a.chars().collect();
let b_chars: SmallVec<[char; 32]> = b.chars().collect();
let len_a = a_chars.len();
let len_b = b_chars.len();
// Find common prefix
let mut prefix_len = 0;
let min_len = len_a.min(len_b);
while prefix_len < min_len && a_chars[prefix_len] == b_chars[prefix_len] {
prefix_len += 1;
}
if prefix_len == min_len {
return (prefix_len, len_a - prefix_len, len_b - prefix_len);
}
// Find common suffix (don't overlap with prefix)
let mut suffix_len = 0;
while suffix_len < (min_len - prefix_len)
&& a_chars[len_a - 1 - suffix_len] == b_chars[len_b - 1 - suffix_len]
{
suffix_len += 1;
}
(
prefix_len,
len_a - prefix_len - suffix_len,
len_b - prefix_len - suffix_len,
)
}
Source: src/distance/mod.rs:111-147
Input:
a = "http://example.com/page1" (26 chars)
b = "http://example.com/page2" (26 chars)
Strip:
Prefix: "http://example.com/page" (23 chars)
Suffix: "" (0 chars)
Remaining:
a' = "1" (1 char)
b' = "2" (1 char)
Distance computation: O(1×1) = 1 operation
Without stripping: O(26×26) = 676 operations
Speedup: 676×!
| Prefix Overlap | Speedup (Recursive) |
|---|---|
| 0% | 1.0× (no benefit) |
| 25% | 1.4× |
| 50% | 2.6× |
| 75% | 6.0× |
| 90% | 18.0× |
Impact: 1.4-18× speedup depending on overlap
Scalar DP processes one cell at a time. Modern CPUs can process 4-8 values in parallel using SIMD instructions.
Vectorize DP operations using AVX2 (256-bit) or SSE4.1 (128-bit) intrinsics.
#[cfg(target_arch = "x86_64")]
pub fn standard_distance_simd(source: &str, target: &str) -> usize {
// Short strings: SIMD overhead dominates, use scalar
let source_len = source.chars().count();
let target_len = target.chars().count();
if source_len < 16 && target_len < 16 {
return standard_distance_impl(source, target);
}
// Runtime CPU feature detection
if is_x86_feature_detected!("avx2") {
unsafe { standard_distance_avx2(source, target) }
} else if is_x86_feature_detected!("sse4.1") {
unsafe { standard_distance_sse41(source, target) }
} else {
standard_distance_impl(source, target)
}
}
Source: src/distance/simd.rs:25-44
Process 8 u32 values (DP cells) in parallel:
#[target_feature(enable = "avx2")]
unsafe fn standard_distance_avx2(source: &str, target: &str) -> usize {
// ... initialization ...
let one_vec = _mm256_set1_epi32(1); // Vector of eight 1s
for i in 1..=m {
curr_row[0] = i as u32;
let source_char = source_chars[i - 1] as u32;
let mut j = 1;
while j + 8 <= n {
// Load 8 target characters
let target_vec = _mm256_loadu_si256(/* ... */);
// Broadcast source character to all 8 lanes
let source_vec = _mm256_set1_epi32(source_char);
// Compare: source == target (vectorized)
let eq_mask = _mm256_cmpeq_epi32(source_vec, target_vec);
// Compute cost: 0 if equal, 1 if different
let cost_vec = _mm256_andnot_si256(eq_mask, one_vec);
// Load prev_row, curr_row values (8 at a time)
let prev_vec = _mm256_loadu_si256(/* prev_row[j..j+8] */);
let left_vec = _mm256_loadu_si256(/* curr_row[j-1..j+7] */);
let diag_vec = _mm256_loadu_si256(/* prev_row[j-1..j+7] */);
// Compute min(prev+1, left+1, diag+cost) vectorized
let del_vec = _mm256_add_epi32(prev_vec, one_vec);
let ins_vec = _mm256_add_epi32(left_vec, one_vec);
let sub_vec = _mm256_add_epi32(diag_vec, cost_vec);
let min1 = _mm256_min_epu32(del_vec, ins_vec);
let result_vec = _mm256_min_epu32(min1, sub_vec);
// Store 8 results
_mm256_storeu_si256(/* curr_row[j..j+8] */, result_vec);
j += 8;
}
// Handle remaining columns (< 8) with scalar code
for j in j..=n {
// Scalar computation
}
std::mem::swap(&mut prev_row, &mut curr_row);
}
prev_row[n] as usize
}
Source: src/distance/simd.rs:53-150 (simplified)
if is_x86_feature_detected!("avx2") {
// Use AVX2 (8-wide vectors)
} else if is_x86_feature_detected!("sse4.1") {
// Use SSE4.1 (4-wide vectors)
} else {
// Fall back to scalar
}
Benefit: No compile-time CPU targeting required. Binary runs optimally on any CPU.
| String Length | Scalar (µs) | AVX2 (µs) | Speedup |
|---|---|---|---|
| 10×10 | 0.8 | 1.2 | 0.67× (overhead) |
| 50×50 | 18.5 | 8.3 | 2.23× |
| 100×100 | 72.3 | 28.4 | 2.54× |
| 500×500 | 1,820 | 512 | 3.55× |
Impact: 2-4× speedup for long strings (>16 chars)
Threshold: SIMD disabled for strings <16 chars due to overhead.
Exploit $d(a,b) = d(b,a)$:
struct SymmetricPair {
first: Arc<str>, // Lexicographically smaller
second: Arc<str>, // Lexicographically larger
}
impl SymmetricPair {
fn new(a: &str, b: &str) -> Self {
// Always order: first <= second
if a <= b {
Self { first: Arc::from(a), second: Arc::from(b) }
} else {
Self { first: Arc::from(b), second: Arc::from(a) }
}
}
}
Impact:
("test", "best") and ("best", "test") use same cache keyfirst: Arc<str>,
second: Arc<str>,
Benefits:
\mathcal{O}(1)$ reference count increment#[cfg(feature = "eviction-dashmap")]
pub struct MemoCache {
cache: DashMap<SymmetricPair, usize>,
}
Pros: No locks, high concurrency throughput Cons: ~24 bytes overhead per entry
#[cfg(not(feature = "eviction-dashmap"))]
pub struct MemoCache {
cache: RwLock<FxHashMap<SymmetricPair, usize>>,
}
Pros: Faster hash (FxHash), lower overhead (~16 bytes/entry) Cons: Read/write locks (still very fast for read-heavy workloads)
| Scenario | Time | Speedup |
|---|---|---|
| Cold cache | 72µs | 1× |
| Warm cache (exact match) | 150ns | 480× |
| Symmetric query | 150ns | 480× |
Impact: 100-500× speedup for cache hits
Return immediately when remaining distance is 0:
if a == b {
distance = standard_distance_recursive(s, t, cache);
// If remaining distance is 0, total distance is 0
if distance == 0 {
cache.insert(cache_key, 0);
return 0; // Early exit!
}
}
Example:
Input: "test" vs "test"
First chars match: 't' == 't'
Recurse: distance("est", "est")
First chars match: 'e' == 'e'
Recurse: distance("st", "st")
First chars match: 's' == 's'
Recurse: distance("t", "t")
First chars match: 't' == 't'
Recurse: distance("", "")
→ Return 0 (base case)
← Early exit: return 0
← Early exit: return 0
← Early exit: return 0
← Early exit: return 0
Impact: 2-3× speedup for identical or near-identical strings
// Deletion
distance = standard_distance_recursive(s, &t_remaining, cache);
if distance == 0 {
cache.insert(cache_key, 1);
return 1; // Early exit: 1 deletion + 0 remaining
}
Impact: Saves 2/3 of recursive calls when first operation succeeds
chars().collect() allocates on heap for every string, even short ones.
Use SmallVec<[char; 32]> for stack allocation of short strings:
use smallvec::SmallVec;
let source_chars: SmallVec<[char; 32]> = source.chars().collect();
let target_chars: SmallVec<[char; 32]> = target.chars().collect();
Behavior:
\le 32$ chars: Stack allocation (zero-cost)> 32$ chars: Heap allocation (same as Vec)| String Length | Vec (heap) | SmallVec (stack) | Speedup |
|---|---|---|---|
| 5 chars | 80ns | 12ns | 6.67× |
| 10 chars | 85ns | 15ns | 5.67× |
| 20 chars | 95ns | 22ns | 4.32× |
| 50 chars | Heap | Heap | ~1× |
Impact: 4-7× faster for short strings (<32 chars)
a = "https://example.com/docs/guide/intro"
b = "https://example.com/docs/guide/overview"
| Optimization | Time | Cumulative Speedup |
|---|---|---|
| Baseline (no opts) | 1,200µs | 1× |
| + Space optimization | 1,180µs | 1.02× |
| + SmallVec | 980µs | 1.22× |
| + Prefix stripping | 120µs | 10.0× |
| + Memoization (cache) | 8µs | 150× |
| + Early exit | 4µs | 300× |
Total speedup: 300×
-C target-cpu=native (enables AVX2)cargo bench --bench real_world_benchmark
Source: benches/real_world_benchmark.rs
┌─────────────────────┐
│ Distance Query │
└──────────┬──────────┘
│
┌─────────────────┼─────────────────┐
│ │ │
┌────▼─────┐ ┌──────▼──────┐ ┌─────▼─────┐
│ One-off │ │ Repeated │ │ Batch │
│ query │ │ queries │ │ processing│
└────┬─────┘ └──────┬──────┘ └─────┬─────┘
│ │ │
┌────▼────┐ ┌────▼────┐ ┌────▼─────┐
│ Iterative│ │Recursive│ │Recursive +│
│ DP │ │+ memo │ │ memo │
│ │ │ │ │ (shared) │
└────┬─────┘ └────┬────┘ └────┬──────┘
│ │ │
┌────▼────┐ ┌────▼────┐ ┌────▼──────┐
│ Space │ │ Prefix │ │ All opts │
│ opt │ │ strip │ │ enabled │
└────┬────┘ └────┬────┘ └────┬──────┘
│ │ │
┌────▼────┐ ┌────▼────┐ ┌────▼──────┐
│ SIMD │ │ Early │ │ Result │
│ (long │ │ exit │ │ │
│ strs) │ │ │ │ │
└─────────┘ └─────────┘ └───────────┘
\mathcal{O}(ND)$ Difference Algorithm and Its Variations". Algorithmica, 1(1-4), 251-266. DOI: 10.1007/BF01840446src/distance/mod.rs:111-147 (prefix stripping)src/distance/simd.rs (SIMD implementations)benches/real_world_benchmark.rsCan 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 |