Benchmarking Guide
Chassis uses Criterion.rs for statistical benchmarking with outlier detection and HTML reporting.
The example outputs below come from an older machine. Their fsync timings are too fast for a real
disk (the sync probably went to tmpfs or a virtual disk), and their search timings come from
hand-wired graphs, not ones built by VectorIndex. Current numbers are in
Performance.
Running Benchmarks
All Benchmarks
# Run all benchmarks
cargo bench --workspace
# Takes ~15-20 minutes for full suite
Specific Benchmarks
# Storage layer
cargo bench --bench storage_bench
# Distance computation
cargo bench --bench distance_bench
# HNSW search
cargo bench --bench search_bench
# Graph linking
cargo bench --bench link_bench
# Graph I/O
cargo bench --bench graph_io_bench
# Node operations
cargo bench --bench hnsw_node_bench
Filtering Benchmarks
# Run only insert benchmarks
cargo bench --bench storage_bench -- insert
# Run only SIMD benchmarks
cargo bench --bench distance_bench -- simd
# Run only high_ef search tests
cargo bench --bench search_bench -- high_ef
Benchmark Suites
1. Storage Benchmarks (storage_bench.rs)
Raw Insert Performance
#![allow(unused)]
fn main() {
raw_insert/single_insert_no_commit
}
Measures pure insertion speed without fsync.
Result: ~7.4 µs per vector (134K vectors/sec)
What it tests: Memory-mapped write performance, file growth overhead.
Durable Insert Performance
#![allow(unused)]
fn main() {
durable_insert/insert_with_commit
}
Measures insertion + fsync (full durability guarantee).
Result: ~6.0 µs per operation (166K vectors/sec)
What it tests: Kernel flush + disk sync latency.
Batch Insert Performance
#![allow(unused)]
fn main() {
batch_insert/10
batch_insert/100
batch_insert/1000
}
Measures batched insertions with single commit.
Results:
- 10 vectors: 174K/sec
- 100 vectors: 173K/sec
- 1000 vectors: 171K/sec
What it tests: Fsync amortization, optimal batch size.
Read Performance
#![allow(unused)]
fn main() {
hot_read/get_vector_cached // L1/L2 cache hit
cold_read/get_vector_cold_start // Page fault
sequential_read/read_1000_sequential
}
Results:
- Hot: 72 ns (13.8M vectors/sec)
- Cold: 15 µs (66K vectors/sec)
- Sequential 1000: 125 µs total (8M vectors/sec)
What it tests: Cache locality, prefetcher effectiveness, kernel page-in latency.
Growth
#![allow(unused)]
fn main() {
growth/grow_from_empty_to_1000
}
Measures growing a file from empty to 1,000 vectors and committing it.
Result: ~5.9 ms total for 1000 vectors, measured with file format 2, which remapped the file to grow it. Format 3 appends segments instead; not re-measured.
What it tests: truncate() + mmap() overhead.
2. Distance Benchmarks (distance_bench.rs)
Distance by Dimension
#![allow(unused)]
fn main() {
distance_by_dimension/simd/{64,128,384,768,1536,3072}
distance_by_dimension/scalar/{64,128,384,768,1536,3072}
}
Compares SIMD vs scalar across dimensions.
Key Results (AVX2):
- 128D: 9.2 ns SIMD, 53.8 ns scalar → 5.8x speedup
- 768D: 40 ns SIMD, 370 ns scalar → 9.2x speedup
- 1536D: 70 ns SIMD, 750 ns scalar → 10.7x speedup
What it tests: 4-way accumulator effectiveness, main loop vs tail loop ratio.
Batch Distances
#![allow(unused)]
fn main() {
batch_distances/simd_batch
batch_distances/scalar_batch
}
100 vectors × 768D per iteration.
Results:
- SIMD: 4.97 µs → 15.5 Gelem/s
- Scalar: 38.0 µs → 2.02 Gelem/s
What it tests: Real-world search pattern (query vs many candidates).
Unaligned Access
#![allow(unused)]
fn main() {
unaligned_access/simd_unaligned
}
Tests SIMD performance with misaligned memory.
Result: ~58 ns (minimal penalty)
What it tests: _mm256_loadu_ps vs _mm256_load_ps.
Small Vectors
#![allow(unused)]
fn main() {
small_vectors/dims/{3,7,15,31,63}
}
Tests scalar tail loop dominance.
Results:
- 3D: 4.67 ns
- 63D: 8.26 ns
What it tests: Scalar tail performance, setup overhead.
Sparse Vectors
#![allow(unused)]
fn main() {
sparse_vectors/simd_sparse_10pct
sparse_vectors/scalar_sparse_10pct
}
90% zeros, 10% non-zero.
What it tests: SIMD efficiency with sparse data (still faster despite wasted computation).
Memory Bandwidth
#![allow(unused)]
fn main() {
memory_bandwidth/simd/{4096,8192,16384}
}
Very large vectors to saturate DRAM bandwidth.
Results:
- 4096 floats: 505 GiB/s
- 8192 floats: 409 GiB/s
- 16384 floats: 394 GiB/s
What it tests: L3 cache miss → DRAM throughput limit.
3. Search Benchmarks (search_bench.rs)
Search Baseline
#![allow(unused)]
fn main() {
search_baseline/ef/{8,16,32,64,128}
}
Fixed k=10, varying ef (search quality).
Results:
- ef=8: 2.81 µs (356K queries/sec)
- ef=32: 5.85 µs (171K queries/sec)
- ef=128: 20.1 µs (50K queries/sec)
What it tests: ef scaling (logarithmic candidate pool growth).
High ef Stress Test
#![allow(unused)]
fn main() {
high_ef_stress/ef/{64,128,256,512}
}
5000-node graph, high ef values.
Results:
- ef=256: 45.3 µs
- ef=512: 101.6 µs
What it tests: Dense visited filter performance, heap operations.
Varying k
#![allow(unused)]
fn main() {
varying_k/k/{1,5,10,50,100}
}
Different result set sizes.
Results:
- k=1: 8.63 µs
- k=10: 8.98 µs
- k=100: 30.7 µs (ef=200)
What it tests: Result heap size impact (minimal for k < ef).
Graph Size Scaling
#![allow(unused)]
fn main() {
graph_size_scaling/nodes/{100,500,1000,5000}
}
Fixed ef=50, k=10.
Results:
- 100 nodes: 6.32 µs
- 5000 nodes: 9.07 µs
What it tests: Sub-linear scaling (HNSW’s logarithmic hop count).
Dimension Scaling
#![allow(unused)]
fn main() {
dimension_scaling/dimensions/{64,128,384,768,1536}
}
Results:
- 64D: 7.38 µs
- 768D: 17.5 µs
- 1536D: 35.2 µs
What it tests: Distance computation dominates at high dimensions.
4. Linking Benchmarks (link_bench.rs)
Single Node Linking
#![allow(unused)]
fn main() {
link_single_node/neighbors/{4,8,16,32}
}
Link one node with varying neighbor counts.
Results:
- 4 neighbors: 39.5 µs
- 16 neighbors: 37.4 µs
- 32 neighbors: 68.0 µs (pruning kicks in)
What it tests: Neighbor slot filling, pruning threshold.
Pruning Pressure
#![allow(unused)]
fn main() {
link_with_pruning/full_list_pruning
}
Add node to hub with full neighbor list (triggers diversity heuristic).
Result: ~20.3 µs
What it tests: The cost of diversity pruning when a hub’s list is full.
Multi-Layer Linking
#![allow(unused)]
fn main() {
multilayer_linking/layers/{1,2,4,8}
}
Results:
- 1 layer: 22.1 µs
- 4 layers: 25.2 µs
- 8 layers: 29.1 µs
What it tests: Layer traversal overhead (~1.5 µs per additional layer).
Batch Construction
#![allow(unused)]
fn main() {
batch_linking/nodes/{10,50,100}
}
Sequential graph construction.
Results:
- 10 nodes: 37.5 µs total → 3.75 µs/node
- 100 nodes: 331 µs total → 3.31 µs/node
What it tests: Batching efficiency, graph growth patterns.
Worst Case: Identical Vectors
#![allow(unused)]
fn main() {
worst_case_clustering/identical_vectors
}
32 identical vectors (diversity heuristic degenerates).
Result: 394 µs total
What it tests: Cache still helps even when all distances equal.
5. Graph I/O Benchmarks (graph_io_bench.rs)
Graph Header
#![allow(unused)]
fn main() {
graph_header/read // 8.29 ns
graph_header/write // 2.00 ns
}
What it tests: 64-byte aligned header access (cache-resident).
Node Record I/O
#![allow(unused)]
fn main() {
node_record/read // 320 ns
node_record/read_sequential // 279 ns per record
node_record/read_random // 250 ns per record
node_record/write // 283 ns
}
What it tests: Record deserialization, validation overhead, sequential vs random access.
Neighbor Iteration
#![allow(unused)]
fn main() {
neighbors_mmap/iter_layer0 // 43.3 ns (zero-alloc)
neighbors_mmap/iter_via_record_layer0 // 330 ns (with record read)
}
What it tests: Zero-allocation iteration vs deserialization overhead.
Speedup: 7.6x for direct mmap iteration.
Node Offset Computation
#![allow(unused)]
fn main() {
offset/compute_single // 0.83 ns
offset/compute_batch_1000 // 398 ns total → 0.4 ns each
}
What it tests: O(1) addressing formula, instruction pipelining.
Search Pattern Simulation
#![allow(unused)]
fn main() {
search_pattern/visit_100_nodes // 33.7 µs (with record read)
search_pattern/visit_100_nodes_mmap // 2.96 µs (zero-alloc)
}
What it tests: Hot path allocation elimination.
Speedup: 11.4x with zero-copy iteration.
6. Node Benchmarks (hnsw_node_bench.rs)
Record Size Calculation
#![allow(unused)]
fn main() {
record_size/calculate/m{16,32,64}_l{8,16}
}
Result: ~320-340 ps (sub-nanosecond)
What it tests: Constant-time calculation (no runtime overhead).
Serialization
#![allow(unused)]
fn main() {
record_serialization/to_bytes // 126 ns
record_serialization/from_bytes // 194 ns
}
What it tests: Memory copy + validation cost.
Neighbor Access
#![allow(unused)]
fn main() {
neighbor_access/get_neighbors_layer0 // 188 ns (allocates Vec)
neighbor_access/neighbors_iter_layer0 // 10.7 ns (zero-alloc)
}
Speedup: 17.6x with iterator.
Node Conversion
#![allow(unused)]
fn main() {
node_conversion/node_to_record // 73.6 ns
node_conversion/record_to_node // 400 ns
}
What it tests: In-memory representation vs on-disk format.
Interpreting Results
Criterion Output
distance_by_dimension/simd/768
time: [39.783 ns 40.207 ns 40.631 ns]
thrpt: [18.902 Gelem/s 19.101 Gelem/s 19.305 Gelem/s]
Found 114 outliers among 1000 measurements (11.40%)
30 (3.00%) high mild
84 (8.40%) high severe
Reading:
- time: [lower bound, estimate, upper bound] at 95% confidence
- thrpt: Throughput (elements/second)
- outliers: Measurements discarded (system noise)
Comparing Runs
# Baseline
cargo bench --bench distance_bench -- simd/768 > baseline.txt
# After optimization
cargo bench --bench distance_bench -- simd/768 > optimized.txt
# Compare
critcmp baseline.txt optimized.txt
Example output:
group baseline optimized
----- -------- ---------
simd/768 40.2 ns (1.00x) 35.1 ns (1.15x faster)
Best Practices
1. Warm Up System
# Disable CPU frequency scaling
sudo cpupower frequency-set --governor performance
# Drop caches (optional)
sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'
2. Reduce System Noise
# Close unnecessary apps
# Disable background services
# Run benchmarks when system is idle
3. Use Consistent Hardware
- Same machine for before/after comparisons
- Note CPU model, RAM, storage type in results
- Disable turbo boost for reproducibility (optional)
4. Increase Sample Size for Stability
#![allow(unused)]
fn main() {
group.sample_size(1000); // Default: 100
group.measurement_time(Duration::from_secs(10)); // Default: 5s
}
5. Profile Hot Paths
# Install perf
sudo apt install linux-tools-generic
# Run with perf
cargo bench --bench search_bench --no-run
perf record --call-graph=dwarf target/release/deps/search_bench-*
perf report
Regression Testing
Create a CI job to detect performance regressions:
- name: Benchmark
run: cargo bench --workspace -- --save-baseline main
- name: Compare
run: |
cargo bench --workspace -- --baseline main
critcmp main current --threshold 5 # Fail if >5% slower
Generating HTML Reports
# Run benchmarks
cargo bench --workspace
# Open reports
open target/criterion/report/index.html
Report includes:
- Performance graphs over time
- Distribution histograms
- Throughput charts
- Outlier analysis