Liking cljdoc? Tell your friends :D

Profiling and Profile-Guided Optimization Results

Summary

Comprehensive profiling using flame graphs identified critical bottlenecks in the liblevenshtein-rust library. Profile-Guided Optimization (PGO) has been successfully set up to optimize hot paths based on real-world usage patterns.

Profiling Setup

Tools Used

  • cargo-flamegraph: For generating visual flame graphs of hot paths
  • perf: Linux performance analysis tool (underlying flamegraph)
  • LLVM PGO toolchain: For profile-guided optimization

Profiling Benchmark

Created benches/profiling_benchmark.rs to exercise realistic workloads:

Workload Characteristics:

  • 10,000-word dictionary (from /usr/share/dict/words or synthetic)
  • 5,000 fuzzy string queries with edit distance 1-2
  • 1,000,000 dictionary contains() calls
  • Exercises all hot paths: DAWG construction, edge lookup, transducer queries

Benchmark Results:

Dictionary: 10,000 words
DAWG construction: 2.5ms
Transducer construction: 34ns
5,000 queries: 4.1 seconds (avg 823µs per query)
1M contains() calls: 165ms
Total query results: 1,084,000 matches found

Flame Graph Analysis

Generated two flame graphs:

  1. flamegraph.svg - Initial profiling (queries returned 0 results)
  2. flamegraph_improved.svg - Improved with realistic queries (1.08M results)

Key Findings from Flame Graph

1. Arc Reference Counting Overhead (~41% of execution time)

The #1 Bottleneck: Arc atomic operations dominate execution

  • Arc::clone (increment): ~20.77%

    • core::sync::atomic::AtomicUsize::fetch_add: 17.18% in transition(), 3.59% in root()
    • 97M + 20M atomic increment samples
    • Every edge transition clones the Arc to the node vector
  • Arc::drop (decrement): ~20.65%

    • core::sync::atomic::AtomicUsize::fetch_sub: 116M atomic decrement samples
    • Dropping DawgDictionaryNode after each operation

Root Cause:

// Current implementation in dawg.rs:282-306
fn transition(&self, label: u8) -> Option<Self> {
    // ...
    Some(DawgDictionaryNode {
        nodes: Arc::clone(&self.nodes),  // <-- Atomic increment here!
        node_idx: *idx,
    })
}

// When dropped:
impl Drop for Arc {
    fn drop(&mut self) {
        // Atomic decrement here!
    }
}

Impact:

  • ~232M atomic operations total
  • Each atomic operation involves expensive cache coherency protocol
  • Dominates all other operations by factor of 2x

2. Binary Search Edge Lookup (~26.88% of execution time)

Hot Path: Adaptive edge lookup working as designed

  • core::slice::<impl [T]>::binary_search_by_key: 152M samples (26.88%)
  • Shows adaptive optimization triggering (threshold: 8 edges)
  • Within binary search:
    • <core::cmp::Ordering as core::cmp::PartialEq>::eq: 5.85%
    • core::hint::select_unpredictable: 7.60%
    • core::slice::<impl [T]>::get_unchecked: 7.54%

Analysis: This is expected and optimal - binary search for nodes with ≥8 edges. Previous benchmarks showed:

  • Linear search faster for <8 edges (cache-friendly)
  • Binary search faster for ≥8 edges (O(log n) dominates)

3. Vector Operations (~10.66% of execution time)

  • alloc::vec::Vec<T,A>::len: 60M samples (10.66%)
  • Accessing edge vectors and checking lengths

4. Dictionary Contains (~93.94% of execution time)

  • Primary workload as expected from profiling benchmark
  • liblevenshtein::dictionary::Dictionary::contains: 531M samples

Profiling Insights

Hot Path Breakdown:

  1. Arc overhead: 41% - Primary optimization target
  2. Binary search: 27% - Optimal (recently optimized)
  3. Vector ops: 11% - Normal overhead
  4. Other: 21%

Critical Finding:

Arc reference counting overhead exceeds all other operations combined, including the binary search optimization we just implemented. This represents the next major optimization opportunity.

Profile-Guided Optimization (PGO)

PGO Setup

Created automated PGO build script: pgo_build.sh

PGO Workflow:

#!/bin/bash
# 1. Clean previous data
rm -rf /tmp/pgo-data && mkdir -p /tmp/pgo-data

# 2. Build with instrumentation
RUSTFLAGS="-C target-cpu=native -C profile-generate=/tmp/pgo-data" \
    cargo build --release --bench profiling_benchmark

# 3. Run profiling workload
./target/release/deps/profiling_benchmark-*

# 4. Merge profiling data
llvm-profdata merge -o /tmp/pgo-data/merged.profdata /tmp/pgo-data/*.profraw

# 5. Build with PGO
RUSTFLAGS="-C target-cpu=native -C profile-use=/tmp/pgo-data/merged.profdata" \
    cargo build --release

PGO Build Results

Successful PGO build completed:

Step 1: Instrumented build: 11.62s
Step 2: Profiling run: 5.52s (queries) + 0.20s (contains)
Step 3: Data merge: ~1s
Step 4: PGO-optimized build: 14.76s
Total PGO workflow: ~32 seconds

PGO Optimizations Applied:

  • Function inlining based on call frequency
  • Branch prediction based on actual execution
  • Code layout optimization for cache locality
  • Dead code elimination based on profiling data

PGO Binary

PGO-optimized binary: target/release/liblevenshtein-cli (11MB)

The compiler now has profiling data showing:

  • Arc cloning is the dominant hot path
  • Binary search paths are frequently taken
  • Specific query patterns and edge distributions

Optimization Recommendations

Phase 2: Arc Overhead Reduction (High Priority)

Problem: 41% of execution time spent on Arc atomic operations

Solutions:

  1. Replace Arc with Rc for single-threaded use

    • Benefit: Eliminate atomic operations entirely
    • Trade-off: Not thread-safe
    • Implementation: Add DawgDictionaryNode variant with Rc
  2. Use raw pointers with manual lifetime management

    • Benefit: Zero overhead
    • Trade-off: Unsafe code, complex lifetime tracking
    • Implementation: Store *const Vec<DawgNode> with lifetime parameter
  3. Copy-on-write with bump allocator

    • Benefit: Amortize Arc clones across many operations
    • Trade-off: Higher memory usage temporarily
    • Implementation: Use arena allocator for nodes
  4. Cache DawgDictionaryNode between operations

    • Benefit: Reduce clone frequency
    • Trade-off: More complex API
    • Implementation: Reuse nodes in iterator

Expected Impact: 15-30% overall performance improvement

Phase 3: Further Optimizations

Based on profiling, lower-priority optimizations:

  1. Inline Vector Length Checks (10.66% overhead)

    • Use #[inline(always)] on hot paths
    • Pre-compute lengths where possible
  2. SIMD Edge Lookup (potential 5-10% improvement)

    • Use SIMD for linear search in small edge lists
    • Benefit most visible for 4-8 edge nodes
  3. Memory Pool for State Objects

    • Reduce allocation overhead in transducer
    • Reuse State objects between queries

Next Steps

  1. ✅ Profiling infrastructure set up
  2. ✅ Flame graphs generated and analyzed
  3. ✅ PGO build system established
  4. ⏳ Run PGO benchmarks to measure improvement
  5. ⏳ Implement Phase 2: Arc overhead reduction
  6. ⏳ Benchmark Arc optimization improvements
  7. ⏳ Consider thread-safety requirements before Rc migration

Files Created

  • benches/profiling_benchmark.rs - Realistic profiling workload
  • flamegraph.svg - Initial flame graph
  • flamegraph_improved.svg - Improved flame graph with realistic queries
  • pgo_build.sh - Automated PGO build script
  • pgo_build_log.txt - PGO build log
  • docs/PROFILING_AND_PGO_RESULTS.md - This document

Usage

Generate New Flame Graph

RUSTFLAGS="-C target-cpu=native" cargo flamegraph --bench profiling_benchmark --output flamegraph.svg

Run PGO Build

./pgo_build.sh

Run Benchmarks with PGO

# After PGO build:
RUSTFLAGS="-C target-cpu=native" cargo bench

References

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