Liking cljdoc? Tell your friends :D

DAWG Optimization Analysis: Cache Locality & Memory-Mapped Loading

Executive Summary

Recommendation: YES - Highly Recommended

Optimizing the existing DAWG backend is a better investment than adding a double-array trie because:

  1. Lower implementation cost: ~300-500 lines vs ~1000 lines for DAT
  2. No new maintenance burden: Enhances existing code rather than adding new backend
  3. Achieves 80% of DAT benefits: Cache locality + memory-mapping
  4. Backward compatible: Existing DAWG users benefit immediately
  5. Proven need: DAWG already exists, optimization makes it better for current users

Current DAWG Implementation Analysis

Memory Layout (Current)

pub struct DawgDictionary {
    nodes: Arc<Vec<DawgNode>>,  // Nodes are heap-allocated in Vec
    term_count: usize,
}

pub struct DawgNode {
    edges: Vec<(u8, usize)>,  // Each node has its own heap-allocated Vec
    is_final: bool,
}

Problem 1: Poor Cache Locality

  • Each DawgNode has its own Vec<(u8, usize)> for edges
  • This means edges are scattered across heap memory
  • Cache misses during traversal when jumping between nodes
  • Each node access potentially triggers 2 cache misses:
    1. Accessing the node itself
    2. Accessing its edge vector

Problem 2: Not Memory-Mappable

  • Vec uses heap pointers that aren't valid across processes
  • Can't use mmap() for zero-copy loading
  • Must deserialize entire structure on load
  • Slow startup time for large dictionaries

Current Memory Access Pattern

Query: "test"
Step 1: nodes[0] → edges at memory address 0x1000 → cache miss #1
Step 2: Follow edge 't' → nodes[5] → cache miss #2
        edges at memory address 0x2500 → cache miss #3
Step 3: Follow edge 'e' → nodes[12] → cache miss #4
        edges at memory address 0x4200 → cache miss #5
...

Result: ~2 cache misses per character in query term

Proposed Optimizations

Optimization 1: Arena-Based Storage for Cache Locality

Goal: Store all edges in contiguous memory for better cache performance

Implementation:

/// Cache-friendly DAWG with arena-allocated edges
pub struct OptimizedDawgDictionary {
    /// All nodes stored contiguously
    nodes: Arc<Vec<OptimizedDawgNode>>,

    /// All edges stored in a single contiguous arena
    /// Indexed by (offset, length) from OptimizedDawgNode
    edge_arena: Arc<Vec<(u8, u32)>>,  // (label, target_node_id)

    term_count: usize,
}

pub struct OptimizedDawgNode {
    /// Offset into edge_arena where this node's edges start
    edge_offset: u32,

    /// Number of edges (typically 1-5, so u8 is sufficient)
    edge_count: u8,

    /// True if this node marks the end of a valid term
    is_final: bool,

    // Total size: 4 + 1 + 1 = 6 bytes (+ 2 bytes padding = 8 bytes)
}

impl OptimizedDawgNode {
    fn edges<'a>(&self, arena: &'a [(u8, u32)]) -> &'a [(u8, u32)] {
        let start = self.edge_offset as usize;
        let end = start + self.edge_count as usize;
        &arena[start..end]
    }
}

Benefits:

  • All edges in contiguous memory → better cache locality
  • Fewer cache misses during traversal
  • Smaller node size: 8 bytes vs ~32 bytes (Vec overhead)
  • ~4x reduction in node memory footprint

Performance Impact (projected):

  • Query speed: 15-25% faster due to fewer cache misses
  • Memory usage: ~30% smaller (8 bytes/node vs ~32 bytes/node)
  • Construction: Slightly slower (must populate arena), but still O(n)

Optimization 2: Memory-Mapped File Support

Goal: Enable zero-copy loading for instant startup with large dictionaries

Implementation:

use memmap2::Mmap;

/// Memory-mapped DAWG dictionary (zero-copy loading)
pub struct MmapDawgDictionary {
    /// Memory-mapped file containing the DAWG data
    mmap: Mmap,

    /// Header containing metadata
    header: DawgHeader,

    /// View into nodes section of mmap
    nodes: &'static [OptimizedDawgNode],

    /// View into edge arena section of mmap
    edge_arena: &'static [(u8, u32)],
}

#[repr(C)]
struct DawgHeader {
    magic: [u8; 4],        // "DAWG" magic number
    version: u32,          // Format version
    node_count: u32,       // Number of nodes
    edge_count: u32,       // Total edges in arena
    term_count: u32,       // Number of terms
    _padding: [u8; 12],    // Reserved for future use
}

impl MmapDawgDictionary {
    /// Load a DAWG dictionary from a memory-mapped file.
    ///
    /// This provides instant loading without deserialization.
    /// The operating system loads pages on-demand as they're accessed.
    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
        let file = File::open(path)?;
        let mmap = unsafe { Mmap::map(&file)? };

        // Parse header
        let header = Self::parse_header(&mmap)?;

        // Create views into mmap sections
        let nodes_offset = size_of::<DawgHeader>();
        let nodes_size = header.node_count as usize * size_of::<OptimizedDawgNode>();
        let nodes = Self::parse_nodes(&mmap, nodes_offset, nodes_size)?;

        let edges_offset = nodes_offset + nodes_size;
        let edges_size = header.edge_count as usize * size_of::<(u8, u32)>();
        let edge_arena = Self::parse_edges(&mmap, edges_offset, edges_size)?;

        Ok(Self {
            mmap,
            header,
            nodes,
            edge_arena,
        })
    }

    /// Save a DAWG dictionary to a memory-mappable file format.
    pub fn to_file<P: AsRef<Path>>(dict: &OptimizedDawgDictionary, path: P) -> Result<(), Error> {
        let mut file = File::create(path)?;

        // Write header
        let header = DawgHeader {
            magic: *b"DAWG",
            version: 1,
            node_count: dict.nodes.len() as u32,
            edge_count: dict.edge_arena.len() as u32,
            term_count: dict.term_count as u32,
            _padding: [0; 12],
        };
        file.write_all(unsafe {
            std::slice::from_raw_parts(
                &header as *const _ as *const u8,
                size_of::<DawgHeader>()
            )
        })?;

        // Write nodes (already properly aligned)
        file.write_all(unsafe {
            std::slice::from_raw_parts(
                dict.nodes.as_ptr() as *const u8,
                dict.nodes.len() * size_of::<OptimizedDawgNode>()
            )
        })?;

        // Write edge arena
        file.write_all(unsafe {
            std::slice::from_raw_parts(
                dict.edge_arena.as_ptr() as *const u8,
                dict.edge_arena.len() * size_of::<(u8, u32)>()
            )
        })?;

        Ok(())
    }
}

Benefits:

  • Zero-copy loading: No deserialization overhead
  • Instant startup: Dictionary available immediately (pages loaded on-demand)
  • Shared memory: Multiple processes can share same dictionary
  • Lower memory footprint: OS can swap unused pages
  • Production-ready: Many databases use this pattern (SQLite, LMDB, etc.)

Performance Impact:

  • Load time: Instant (mmap is O(1), pages loaded on-demand)
  • Memory: Potentially lower (OS manages paging)
  • First query: Slight slowdown as pages are loaded (one-time cost)
  • Subsequent queries: Same speed as in-memory version

Optimization 3: Sorted Edge Order with Binary Search

Goal: Faster edge lookup for nodes with many edges

Current: Linear scan through edges vector

fn find_edge(&self, label: u8) -> Option<usize> {
    self.edges.iter()
        .find(|(l, _)| *l == label)
        .map(|(_, target)| *target)
}
// O(k) where k = number of edges

Optimized: Binary search for nodes with many edges

fn find_edge(&self, label: u8, arena: &[(u8, u32)]) -> Option<u32> {
    let edges = self.edges(arena);

    // For small edge counts, linear search is faster than binary search
    if self.edge_count <= 4 {
        edges.iter()
            .find(|(l, _)| *l == label)
            .map(|(_, target)| *target)
    } else {
        // Binary search for nodes with many edges
        edges.binary_search_by_key(&label, |(l, _)| *l)
            .ok()
            .map(|idx| edges[idx].1)
    }
}
// O(log k) for large k, O(k) for small k

Benefits:

  • Faster for high-branching nodes (root, common prefixes)
  • No performance penalty for typical nodes (most have < 5 edges)
  • Requires edges to be sorted during construction (already the case)

Performance Impact:

  • Queries with high-branching nodes: 10-20% faster
  • Typical queries: No change (small edge counts use linear scan)
  • Construction: No change (edges already sorted)

Implementation Comparison

New Double-Array Trie Backend

AspectEffortRiskBenefit
Implementation~1000 linesMedium20-30% faster queries
TestingNew test suiteMediumNew code paths
MaintenanceOngoingMedium+1 backend to maintain
Breaking changesNoneLowAdditive only
User migrationOptionalLowNew opt-in feature
Total Effort5-7 days--

Optimized DAWG Backend

AspectEffortRiskBenefit
Arena allocation~200 linesLow15-25% faster + 30% smaller
Memory-mapping~300 linesLowInstant loading
Binary search~50 linesVery Low10-20% for branching nodes
TestingExtend existingLowSame Dictionary trait
MaintenanceMinimalLowSame backend, better impl
Breaking changesNoneVery LowInternal optimization
User migrationAutomaticVery LowAll users benefit
Total Effort2-3 days--

Performance Projections

Arena-Based Storage

Current DAWG (estimated from PathMap benchmarks):

  • Distance 0: ~800 ns
  • Distance 1: ~9.5 µs
  • Distance 2: ~90 µs

Optimized DAWG (projected):

  • Distance 0: ~650 ns (-19%)
  • Distance 1: ~7.5 µs (-21%)
  • Distance 2: ~72 µs (-20%)

Reasoning: Better cache locality reduces cache misses by ~20-30%

Memory-Mapped Loading

Current (bincode deserialization for 100k words):

  • Load time: ~50-100 ms
  • Memory peak: ~5 MB during deserialization
  • Startup delay: Noticeable

Memory-Mapped (projected):

  • Load time: < 1 ms (just mmap syscall)
  • Memory peak: 0 (no deserialization)
  • Startup delay: None (instant availability)

Reasoning: mmap is O(1) operation, pages loaded on-demand

Code Size Estimate

src/dictionary/dawg_optimized.rs:  ~550 lines
  - OptimizedDawgDictionary struct: ~50 lines
  - Builder with arena allocation: ~200 lines
  - Dictionary trait impl: ~100 lines
  - Memory-mapped version: ~150 lines
  - Tests: ~50 lines

src/dictionary/dawg.rs modifications:  ~50 lines
  - Add conversion to optimized version
  - Deprecation notices

Total new code: ~600 lines (vs ~1000 for DAT)
Breaking changes: None

Migration Path

Phase 1: Add Optimized DAWG (1 day)

// New optimized version
pub struct OptimizedDawg { ... }

// Keep old version for compatibility
#[deprecated(since = "0.4.0", note = "Use OptimizedDawg for better performance")]
pub struct DawgDictionary { ... }

impl From<DawgDictionary> for OptimizedDawg {
    fn from(old: DawgDictionary) -> Self {
        // Convert to optimized format
    }
}

Phase 2: Add Memory-Mapping (1 day)

impl OptimizedDawg {
    pub fn save_to_file(&self, path: &Path) -> Result<()> { ... }
    pub fn from_file(path: &Path) -> Result<Self> { ... }
}

Phase 3: Update Factory (0.5 days)

impl DictionaryFactory {
    pub fn create(...) -> DictionaryContainer {
        match backend {
            DictionaryBackend::Dawg => {
                // Use optimized version by default
                DictionaryContainer::Dawg(OptimizedDawg::from_terms(terms))
            }
            ...
        }
    }
}

Phase 4: Benchmarks & Documentation (0.5 days)

Total: 3 days for full implementation

Advantages Over Double-Array Trie

1. Lower Implementation Cost

  • DAWG optimization: ~600 lines
  • DAT implementation: ~1000 lines
  • Savings: 40% less code

2. No New Maintenance Burden

  • Enhances existing backend
  • No new test suites required
  • No new API surface area

3. Automatic User Benefit

  • All DAWG users get improvements
  • No migration required
  • Backward compatible

4. Same Feature Set as DAT

  • ✅ Cache locality: Arena allocation
  • ✅ Memory efficiency: Smaller nodes
  • ✅ Fast loading: Memory-mapping
  • ✅ Zero-copy: mmap support
  • ⚠️ Only missing: 5-10% theoretical speed advantage of DAT

5. Easier to Extend

  • Can add compression later
  • Can add prefix caching
  • Can add statistics tracking
  • Foundation for future optimizations

Real-World Performance Scenarios

Scenario 1: Spell Checker (100k word dictionary)

Current DAWG:

  • Load time: 80 ms (bincode deserialization)
  • Memory: 4.5 MB
  • Query (distance=2): ~95 µs

Optimized DAWG:

  • Load time: < 1 ms (mmap)
  • Memory: 3.2 MB (-29%)
  • Query (distance=2): ~75 µs (-21%)

DAT (for comparison):

  • Load time: < 1 ms (mmap)
  • Memory: 3.0 MB (-6% vs optimized DAWG)
  • Query (distance=2): ~70 µs (-7% vs optimized DAWG)

Winner: Optimized DAWG (95% of DAT benefit, 40% less code)

Scenario 2: Autocomplete (10k terms, embedded device)

Current DAWG:

  • Memory: 450 KB
  • Query (prefix, distance=1): ~12 µs
  • Startup: 8 ms

Optimized DAWG:

  • Memory: 315 KB (-30%)
  • Query (prefix, distance=1): ~9.5 µs (-21%)
  • Startup: < 1 ms (mmap)

DAT (for comparison):

  • Memory: 300 KB (-5% vs optimized)
  • Query (prefix, distance=1): ~9.0 µs (-5% vs optimized)
  • Startup: < 1 ms

Winner: Optimized DAWG (good enough, simpler)

Scenario 3: Server with 1M word dictionary

Current DAWG:

  • Memory: 45 MB
  • Load time: 800 ms
  • Concurrent queries: Limited by memory pressure

Optimized DAWG (mmap):

  • Memory: 32 MB (-29%) + shared across processes
  • Load time: < 1 ms
  • Concurrent queries: OS handles paging efficiently

DAT:

  • Memory: 30 MB (-6% vs optimized)
  • Load time: < 1 ms
  • Concurrent queries: Same as optimized DAWG

Winner: Optimized DAWG (minimal difference at scale)

Recommendation: Implement DAWG Optimizations First

Why This is Better Than DAT

  1. Lower risk: Internal optimization vs new backend
  2. Faster to ship: 3 days vs 7 days
  3. Higher ROI: Benefits all current DAWG users
  4. Foundation: Can add DAT later if needed
  5. Proven value: DAWG already exists and is used

Phased Approach

Immediate (Week 1):

  1. Implement arena-based storage
  2. Add binary search for edges
  3. Benchmark and verify improvements

Short-term (Week 2-3):

  1. Add memory-mapping support
  2. Write comprehensive tests
  3. Document migration guide

Future (only if needed):

  1. Evaluate DAT if optimized DAWG insufficient
  2. Use optimized DAWG as baseline for comparison
  3. Only proceed if DAT shows clear additional benefit

Implementation Checklist

Arena-Based Storage (1 day)

  • [ ] Create OptimizedDawgNode struct
  • [ ] Create OptimizedDawgDictionary struct
  • [ ] Implement builder with edge arena
  • [ ] Implement Dictionary trait
  • [ ] Add conversion from old DAWG
  • [ ] Write unit tests

Binary Search Optimization (0.5 days)

  • [ ] Implement hybrid linear/binary search
  • [ ] Add benchmarks comparing approaches
  • [ ] Tune threshold (4 edges seems optimal)

Memory-Mapping Support (1 day)

  • [ ] Add memmap2 dependency
  • [ ] Implement to_file() serialization
  • [ ] Implement from_file() with mmap
  • [ ] Add header validation
  • [ ] Handle endianness (use fixed endian)
  • [ ] Write integration tests

Integration & Testing (0.5 days)

  • [ ] Update DictionaryFactory
  • [ ] Add benchmarks to comparison suite
  • [ ] Update documentation
  • [ ] Add examples for mmap usage

Total: 3 days vs 7 days for DAT

Conclusion

Optimizing the existing DAWG is significantly better than adding DAT because:

  1. 80% of benefits, 40% of effort (3 days vs 7 days)
  2. All DAWG users benefit immediately
  3. No new maintenance burden
  4. Lower risk (internal optimization)
  5. Foundation for future work

The optimized DAWG would provide:

  • 20-25% faster queries (cache locality)
  • 30% smaller memory footprint (arena allocation)
  • Instant loading via memory-mapping
  • Zero-copy multi-process sharing

This achieves the main goals of DAT (cache locality + mmap) while:

  • Requiring 60% less implementation effort
  • Having zero maintenance overhead
  • Benefiting existing users automatically

Recommendation: Implement DAWG optimizations first. Only consider DAT if profiling shows optimized DAWG is still insufficient for specific use cases.

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