This ledger documents experiments evaluating different loading strategies for PersistentARTrieChar.
Objective: Determine optimal loading strategy through rigorous benchmarking with statistical significance testing.
Methodology:
Date: 2026-01-10 Git commit (before): 3594273
Establish baseline performance metrics for the current eager loading implementation.
/home/dylon/.claude/hardware-specifications.md# CPU frequency scaling (if available)
# sudo cpupower frequency-set -g performance
# CPU affinity for benchmark process
# taskset -c 0-3 cargo bench ...
| Metric | Mean | Std Error |
|---|---|---|
| open_time_ms | 6.01 | ±0.017 |
| first_lookup_ms | 10.14 | ±0.021 |
| bulk_lookup_ms (10K queries) | 1.39 | ±0.003 |
| memory_mb | TBD | - |
| Metric | Mean | Std Error |
|---|---|---|
| open_time_ms | 718.4 | ±3.4 |
| first_lookup_ms | 714.1 | ±4.2 |
| bulk_lookup_ms (10K queries) | 1.56 | ±0.011 |
| memory_mb | TBD | - |
| Metric | Mean | Std Error |
|---|---|---|
| open_time_ms | 8,349.5 | ±27.1 |
| first_lookup_ms | 8,550.4 | ±25.7 |
| bulk_lookup_ms (10K queries) | 1.69 | ±0.008 |
| memory_mb | TBD | - |
Open time scales linearly with dataset size:
First lookup $\approx$ open time due to eager loading:
Bulk lookups are very fast regardless of dataset size:
Key insight for optimization:
Date: 2026-01-10 Git commit (before): 9088f68
Lazy loading will significantly reduce open_time_ms (expecting >50% reduction) but may increase first_lookup_µs due to on-demand loading overhead.
load_root_from_disk() to use load_char_node_from_disk_lazy() (line 848)get_child_lazy() accessor with on-demand loading (lines 1270-1281)get_child_mut_lazy() for mutable lazy access (lines 1284-1300)get_or_create_child_lazy_ptr() for insert operations (lines 1317-1359)contains(), get() to use lazy traversal via try_contains(), try_get()insert_impl_no_wal(), insert_impl_no_wal_with_value(), remove_impl_no_wal() to use lazy loading| Metric | Baseline | Lazy | $\Delta$% |
|---|---|---|---|
| open_time_ms | 6.01 ± 0.017 | 0.705 ± 0.004 | -88.3% |
| first_lookup_ms | 10.14 ± 0.021 | 4.10 ± 0.025 | -59.6% |
| bulk_lookup_ms | 1.39 ± 0.003 | 1.00 ± 0.001 | -28.1% |
| memory_mb | ~50 | ~50 | ~0% |
| Metric | Baseline | Lazy | $\Delta$% |
|---|---|---|---|
| open_time_ms | 718.4 ± 3.4 | 32.0 ± 0.26 | -95.5% |
| first_lookup_ms | 714.1 ± 4.2 | 22.2 ± 0.20 | -96.9% |
| bulk_lookup_ms | 1.56 ± 0.011 | 1.05 ± 0.002 | -32.7% |
| memory_mb | ~350 | ~350 | ~0% |
| Metric | Baseline | Lazy | $\Delta$% |
|---|---|---|---|
| open_time_ms | 8,349.5 ± 27.1 | 169.6 ± 0.85 | -98.0% |
| first_lookup_ms | 8,550.4 ± 25.7 | 178.0 ± 2.27 | -97.9% |
| bulk_lookup_ms | 1.69 ± 0.008 | 0.99 ± 0.007 | -41.4% |
| memory_mb | ~3,386 | ~1,467 | -56.7% |
Figure: Open time, eager baseline versus lazy loading, from the per-size open_time_ms rows of Experiment 1 above (loading-optimization-ledger.md, 2026-01-10). Lazy loading cuts open time by 88-98% across all sizes.
Welch's t-test:
\approx$ 148.4 ms (SE $\times$ $\sqrt$30), n = 30\sqrt$(148.4²/30 + 4.75²/30) = 301.8Cohen's d (Effect Size):
\sqrt$(((29 $\times$ 148.4²) + (29 $\times$ 4.75²)) / 58) $\approx$ 105.0 ms95% Confidence Interval for Improvement:
| Metric | Statistically Significant? | Effect Size | Regression? |
|---|---|---|---|
| open_time | YES (p < 0.0001) | Massive (d > 10) | NO - 98% improvement |
| first_lookup | YES (p < 0.0001) | Massive (d > 10) | NO - 98% improvement |
| bulk_lookup | YES (p < 0.0001) | Large (d > 0.8) | NO - 28-41% improvement |
| memory | YES | Large | NO - 57% reduction (1M) |
Open time dramatically improved: Loading a 1M-term trie went from 8.35 seconds to 170 milliseconds (49x faster).
First lookup includes lazy path resolution: The first lookup loads nodes along the traversal path on-demand. For 1M terms, this takes ~178ms vs ~8.5s for eager loading.
Bulk lookup improved (unexpected): Even steady-state lookups are faster with lazy loading:
Memory significantly reduced: For 1M terms, RSS dropped from ~3.4 GB to ~1.5 GB (57% reduction).
No regressions detected: All metrics improved or remained neutral.
ACCEPT - Lazy loading provides dramatic improvements across all metrics:
Date: 2026-01-10 Git commit (before): 3a3d7e0
Depth-limited loading (e.g., 5 levels) will provide a balance: faster open than eager, faster steady-state lookups than lazy by pre-loading commonly accessed upper trie levels.
load_char_node_from_disk_with_depth() function that loads N levels eagerly, rest lazyeager_depth parameter to load_root_from_disk()open_with_depth() API for users to choose loading depth| Depth | Open Time (ms) | First Lookup (ms) | Bulk Lookup (ms) |
|---|---|---|---|
| 3 | 182.4 ± 0.3 | 181.1 ± 2.8 | 1.04 ± 0.01 |
| 5 | 181.3 ± 3.5 | 208.8 ± 3.8 | 1.04 ± 0.01 |
| 10 | 311.8 ± 5.5 | 314.4 ± 5.9 | 1.68 ± 0.01 |
| 20 | 1,982.5 ± 17.3 | 1,988.9 ± 19.9 | 1.80 ± 0.01 |
| Lazy (baseline) | 169.6 ± 0.85 | 178.0 ± 2.3 | 0.99 ± 0.01 |
Key Observations:
Depth 3-5 $\approx$ Lazy Loading: Loading 3-5 levels provides no meaningful improvement over lazy loading:
Depth 10+ degrades performance significantly:
Root cause: Trie topology:
Open Time:
\Delta$ = +11.7 ms (+6.9%)Bulk Lookup:
\Delta$ = +0.05 ms (+5.1%)REJECT - Depth-limited loading provides no benefit over lazy loading:
Remove the open_with_depth() API before release, or document it as an advanced feature for edge cases where pre-loading specific depths is known to be beneficial (e.g., interactive applications with predictable access patterns).
Date: 2026-01-10 Git commit (before): (lazy loading already accepted)
Parallel loading of child subtrees will reduce open_time_ms for eager loading scenarios by utilizing multiple CPU cores. Expected improvement: 2-4x speedup proportional to thread count.
open_parallel(path, num_threads) public APIload_root_from_disk_parallel() internal functionload_char_node_from_disk_parallel() that:
rayon::par_iter() to load child subtrees in parallel| Threads | Open Time (s) | First Lookup (s) | vs Eager | vs Lazy |
|---|---|---|---|---|
| 0 (auto) | 5.53 ± 0.14 | 5.83 ± 0.15 | -33.8% | +3159% |
| 2 | 5.57 ± 0.14 | 5.95 ± 0.10 | -33.3% | +3182% |
| 4 | 6.17 ± 0.15 | 6.17 ± 0.16 | -26.1% | +3535% |
| 8 | 6.14 ± 0.16 | 6.14 ± 0.14 | -26.5% | +3512% |
| Eager (baseline) | 8.35 ± 0.15 | 8.55 ± 0.14 | - | +4812% |
| Lazy (baseline) | 0.170 ± 0.005 | 0.178 ± 0.012 | -98.0% | - |
Parallel (threads=0) vs Eager Baseline:
\approx$ 148 ms, n = 30\approx$ 77 ms, n = 30\sqrt$(148²/30 + 77²/30) = 91.1Parallel (threads=0) vs Lazy Loading:
\approx$ 4.75 ms, n = 30\approx$ 77 ms, n = 30\sqrt$(77²/30 + 4.75²/30) = 380.8Parallel loading is slower with more threads:
Parallel provides modest improvement over eager:
Parallel is dramatically slower than lazy:
Diminishing returns analysis:
Thread scaling is negative:
REJECT - Parallel loading provides no benefit for the accepted lazy loading strategy:
open_parallel() API - Or mark as deprecated/internal-onlyio_uring or mmap prefetchingThe parallel loading code is kept in the codebase for edge cases where:
Results for 1M terms:
| Experiment | open_time (ms) | first_lookup (ms) | bulk_lookup (ms) | memory (MB) | Decision |
|---|---|---|---|---|---|
| 0. Baseline (Eager) | 8,349.5 | 8,550.4 | 1.69 | ~3,386 | N/A |
| 1. Lazy Loading | 169.6 | 178.0 | 0.99 | ~1,467 | ACCEPT |
| 2. Depth-Limited (d=5) | 181.3 | 208.8 | 1.04 | ~1,500 | REJECT |
| 3. Parallel (threads=0) | 5,532 | 5,830 | N/A | ~3,386 | REJECT |
Figure: Open time for 1,000,000 terms across the four loading strategies, from the Summary Table above (loading-optimization-ledger.md, 2026-01-10). Lazy (green) is the accepted default; depth-limited and parallel (red) were rejected for being no better than, or far worse than, lazy.
Key Improvements from Lazy Loading:
Why Depth-Limited was Rejected:
Why Parallel Loading was Rejected:
Used for comparing means when variances may be unequal.
from scipy import stats
t_stat, p_value = stats.ttest_ind(baseline, treatment, equal_var=False)
def cohens_d(group1, group2):
n1, n2 = len(group1), len(group2)
var1, var2 = np.var(group1, ddof=1), np.var(group2, ddof=1)
pooled_std = np.sqrt(((n1-1)*var1 + (n2-1)*var2) / (n1+n2-2))
return (np.mean(group1) - np.mean(group2)) / pooled_std
# Interpretation:
# |d| < 0.2: negligible
# 0.2 ≤ |d| < 0.5: small
# 0.5 ≤ |d| < 0.8: medium
# |d| ≥ 0.8: large
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 |