Date: 2025-10-29 Target: StatePool and Intersection implementations Scope: Memory allocation patterns and path reconstruction efficiency
Analysis of StatePool and Intersection implementations reveals already well-optimized designs with intelligent memory management strategies:
Initial Assessment: Both systems show evidence of prior optimization work. Benchmark validation recommended before any changes.
src/transducer/pool.rs (302 lines)
Data Structure:
pub struct StatePool {
pool: Vec<State>, // Recycled states
allocations: usize, // Total new allocations
reuses: usize, // Total reuses
}
Configuration:
MAX_POOL_SIZE: 32 statesINITIAL_CAPACITY: 16 statesacquire() - Get state from poolCurrent Implementation (lines 92-101):
#[inline]
pub fn acquire(&mut self) -> State {
if let Some(mut state) = self.pool.pop() {
state.clear(); // Clear positions but keep Vec capacity
self.reuses += 1;
state
} else {
self.allocations += 1;
State::new()
}
}
Performance:
Analysis:
✅ Optimal - Uses #[inline] correctly
✅ Preserves capacity - clear() keeps Vec allocation
✅ LIFO strategy - Better cache locality (recently used states)
release() - Return state to poolCurrent Implementation (lines 114-119):
#[inline]
pub fn release(&mut self, state: State) {
if self.pool.len() < Self::MAX_POOL_SIZE {
self.pool.push(state);
}
// Otherwise drop the state (let it deallocate)
}
Performance:
Analysis: ✅ Optimal - Simple bounded pool ✅ Prevents unbounded growth - Caps at MAX_POOL_SIZE ✅ Inline - Minimal overhead
State Size (from previous analysis):
Pool Memory:
From src/transducer/transition.rs:580-638 - transition_state_pooled():
// Acquire state for epsilon closure
let mut expanded_state = pool.acquire(); // Reuse #1
epsilon_closure_into(..., &mut expanded_state, ...);
// Acquire state for next state
let mut next_state = pool.acquire(); // Reuse #2
// ... compute transitions ...
pool.release(expanded_state); // Return #1
if next_state.is_empty() {
pool.release(next_state); // Return #2 (if empty)
None
} else {
Some(next_state) // Caller owns (will release later)
}
Observation:
Current: Fixed MAX_POOL_SIZE = 32
Alternative: Adaptive sizing based on query depth
// For deep dictionary traversals (depth > 100), more states in flight
// For shallow traversals (depth < 10), fewer states needed
let dynamic_max = min(64, max(16, avg_path_depth * 2));
Expected Impact: Minor - fixed size of 32 is already reasonable Recommendation: Low priority - current size works well
Current: Pool starts empty, grows as states are released
Alternative: Pre-allocate some states on pool creation
pub fn new() -> Self {
let mut pool = Vec::with_capacity(Self::INITIAL_CAPACITY);
// Pre-warm with a few states
for _ in 0..4 {
pool.push(State::new());
}
Self { pool, allocations: 0, reuses: 0 }
}
Expected Impact:
Recommendation: Medium priority - simple change, measureable benefit
Current: Each query creates its own pool
Alternative: Thread-local pools that persist across queries
thread_local! {
static POOL: RefCell<StatePool> = RefCell::new(StatePool::new());
}
pub fn with_thread_pool<F, R>(f: F) -> R
where
F: FnOnce(&mut StatePool) -> R
{
POOL.with(|pool| f(&mut pool.borrow_mut()))
}
Expected Impact:
Recommendation: Low priority - adds complexity, unclear if better
src/transducer/intersection.rs (215 lines)
Key Innovation: Lightweight PathNode for parent chain
Data Structures:
// Lightweight path node (~16 bytes)
pub struct PathNode {
label: u8, // 1 byte (+ 7 padding)
parent: Option<Box<PathNode>>, // 8 bytes
}
// Full intersection (size depends on DictionaryNode type)
pub struct Intersection<N: DictionaryNode> {
label: Option<u8>, // 2 bytes (1 + discriminant)
node: N, // Varies by dictionary type
state: State, // ~80 bytes (SmallVec)
parent: Option<Box<PathNode>>, // 8 bytes
}
PathNode vs Full Intersection:
For a depth-10 path:
For 1000 active paths:
term() - Path reconstruction (lines 103-118)Current Implementation:
pub fn term(&self) -> String {
let mut bytes = Vec::new();
// Collect current label
if let Some(label) = self.label {
bytes.push(label);
}
// Collect parent labels
if let Some(parent) = &self.parent {
parent.collect_labels(&mut bytes);
}
bytes.reverse();
String::from_utf8_lossy(&bytes).into_owned()
}
Performance:
Total: O(depth) with one allocation
Potential Issues:
collect_labels() - Recursive collection (lines 36-41)Current Implementation:
pub fn collect_labels(&self, labels: &mut Vec<u8>) {
labels.push(self.label);
if let Some(parent) = &self.parent {
parent.collect_labels(labels);
}
}
Performance:
Potential Issues:
depth() - Path length calculation (lines 121-132)Current Implementation:
pub fn depth(&self) -> usize {
match &self.parent {
Some(parent) => 1 + parent.depth(),
None => {
if self.label.is_some() { 1 } else { 0 }
}
}
}
Performance:
Potential Issues:
Current: depth() walks entire chain
Alternative:
pub struct PathNode {
label: u8,
depth: u16, // Cached depth (supports up to 65K levels)
parent: Option<Box<PathNode>>,
}
impl PathNode {
pub fn new(label: u8, parent: Option<Box<PathNode>>) -> Self {
let depth = match &parent {
Some(p) => p.depth + 1,
None => 1,
};
Self { label, depth, parent }
}
#[inline(always)]
pub fn depth(&self) -> usize {
self.depth as usize
}
}
Impact:
Recommendation: Medium priority - depth() likely called frequently
Current: Recursive collect_labels()
Alternative:
pub fn collect_labels(&self, labels: &mut Vec<u8>) {
let mut current = Some(self);
while let Some(node) = current {
labels.push(node.label);
current = node.parent.as_deref();
}
}
Impact:
Recommendation: High priority - eliminates stack overflow risk
Current: Vec grows dynamically during collection
Alternative:
pub fn term(&self) -> String {
let depth = self.depth(); // O(1) if cached
let mut bytes = Vec::with_capacity(depth);
// ... rest unchanged ...
}
Impact:
Recommendation: Medium priority - pairs with depth caching
Current: Collect in reverse, then reverse Vec
Alternative: Collect in forward order using parent walk
pub fn term(&self) -> String {
let depth = self.depth();
let mut bytes = Vec::with_capacity(depth);
// Collect path in vector
let mut path = Vec::with_capacity(depth);
let mut current = self.parent.as_ref();
while let Some(node) = current {
path.push(node);
current = node.parent.as_ref();
}
// Add labels in forward order
for node in path.iter().rev() {
bytes.push(node.label);
}
if let Some(label) = self.label {
bytes.push(label);
}
String::from_utf8_lossy(&bytes).into_owned()
}
Impact:
Recommendation: Low priority - increased complexity, unclear benefit
Current: Every term() call allocates new String
Alternative: Cache reconstructed terms
pub struct Intersection<N: DictionaryNode> {
// ... existing fields ...
cached_term: Option<String>, // Lazily computed
}
pub fn term(&self) -> &str {
if let Some(ref term) = self.cached_term {
return term;
}
let term = self.compute_term();
self.cached_term = Some(term);
self.cached_term.as_ref().unwrap()
}
Impact:
Recommendation: Low priority - likely term() called once per match
From query implementations, typical flow:
Query Start:
├── Pool: [] (empty)
├── Active Intersections: 1 (root)
After First Transition:
├── Pool: [state1] (returned expanded_state)
├── Active Intersections: 2-5 (children)
After Deep Traversal (depth=10):
├── Pool: [s1, s2, ..., sN] (released states)
├── Active Intersections: ~10-100 (branch factor dependent)
├── PathNode chains: 10 levels × 16 bytes = 160 bytes per path
Key Insight: Pool size of 32 is sufficient because:
Create benches/pool_intersection_benchmarks.rs:
Pool Operations:
acquire() hit rate (pool not empty)acquire() miss rate (pool empty)release() performancePool Usage Patterns:
Pool Size Impact:
PathNode Operations:
new() allocationdepth() calculation (current vs cached)collect_labels() (recursive vs iterative)term() Reconstruction:
Memory Benchmarks:
Based on analysis, expected benchmarks:
| Operation | Expected Time | Notes |
|---|---|---|
acquire() (hit) | 2-5ns | Pop + clear SmallVec |
acquire() (miss) | 10-15ns | Allocate new SmallVec |
release() | 1-3ns | Push to Vec |
| Warmup benefit | 40-60ns savings | Eliminates 4 × 10-15ns allocations |
| Operation | Current (est) | Optimized (est) | Improvement |
|---|---|---|---|
depth() (depth=10) | 10 calls × 10ns = 100ns | 10 calls × 1ns = 10ns | 10x |
collect_labels() | Stack + recursion | Iterative | Safer, same speed |
term() (depth=10) | 50-80ns | 30-50ns | ~30-40% |
For a typical query with:
Current:
Optimized:
Total savings: ~700ns per query (0.7µs)
Percentage improvement: Depends on total query time
⏳ Pool pre-warming - 4 states on creation
⏳ Cache depth in PathNode - Add depth field
⏳ Preallocate Vec in term() - Use cached depth
src/transducer/pool.rssrc/transducer/intersection.rssrc/transducer/transition.rs:580-638Status: ⏳ ANALYSIS COMPLETE - BENCHMARKS NEXT
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 |