The Google Books n-gram importer was OOMing at ~33.79 GB peak heap and
burning ~49% of CPU in __mprotect syscalls during large-vocabulary
imports (English 1-5gram corpus, 5.8M unique words, ~50-100M entries per
2-gram file). This document captures the optimizations that addressed the
bottleneck.
| Symptom | Root cause |
|---|---|
49% CPU in __mprotect |
glibc malloc's mmap/munmap per-large-allocation pattern issuing mprotect syscalls on every region change |
| 33.79 GB peak heap | Unbounded growth of (a) lock-free overlay entries, (b) per-tx in-memory buffers for 50-100M-entry prefix files, (c) doubled HashMap rebuilds in the vocabulary's reverse-index during checkpoints |
| 12× redundant checkpoints at end-of-import | Each worker called save_checkpoint() on exit; with 12 parallel workers shutting down simultaneously, this added ~6 minutes of blocking I/O after all data was already durable |
| # | Optimization | Target |
|---|---|---|
| 1 | mimalloc global allocator |
mprotect CPU |
| 2 | Pre-sized vocabulary lock-free layer | resize-doubling spikes |
| 3 | SmallVec [&str; 5] for token splits |
per-record heap allocs |
| 4 | Zero-alloc parse_ngram_line_ref + push_ref |
per-record String/Vec allocs |
| 5 | xxh3 hashers in merge/MKN hot paths | hash CPU |
| 6 | Two-tier ChildStore replacing im::Vector |
Arc COW/mprotect (libdictenstein) |
| 7 | --cache-files mode |
download/parse decoupling |
| 8 | --tx-chunk-size chunked transactions |
per-tx memory bound |
| 9 | Per-shard lock-free overlay flush threshold | overlay memory bound |
| 10 | Single-merge merge_and_rotate_vocabulary_wal |
doubled reverse-index rebuilds (~1.7 GB saved at peak) |
| 11 | MKN aggregator cancellation flag | graceful shutdown during multi-minute MKN phase |
| 12 | Removed worker-exit checkpoint storm | ~6 min blocking I/O at end-of-import |
| 13 | tokio::task::yield_now() before finalization |
SIGINT responsiveness during synchronous MKN work |
| 14 | Auto-scaled checkpoint interval (every 5 vs 10 files) | I/O cadence at high parallelism |
| 15 | --overlay-budget-gib overlay-heap eviction |
hard bound on resident overlay RAM (the (a) root cause) |
Google Books .gz
│
▼
┌──────────────────────┐
│ HTTP stream OR │ --cache-files (#7) downloads to disk
│ local cached file │ first, then streams from local file
└──────────┬───────────┘
▼
┌──────────────────────┐
│ GzipDecoder ▶ Lines │
└──────────┬───────────┘
▼
┌──────────────────────┐
│ parse_ngram_line_ref │ zero-alloc (#4) — borrowed &str slices
│ NgramRecordRef<'a> │ from the line buffer
└──────────┬───────────┘
▼
┌──────────────────────┐
│ YearAggregator │ push_ref (#4) — only allocates a String
│ ::push_ref │ when the ngram changes
└──────────┬───────────┘
▼
AggregatedNgram
│
▼
┌──────────────────────┐
│ tx_insert_ngram │ SmallVec<[&str; 5]> (#3) — stack-inline
│ → encode via vocab │ token splits for n-gram orders 1-5
└──────────┬───────────┘
▼
StoragePrefixTx
│
▼ chunk_count >= tx_chunk_size (#8) ─┐
┌──────────────────────┐ │
│ commit_and_renew_ │ ◀───────────────────────────┘
│ prefix_tx (chunk) │ bounds per-tx memory; SET-semantics
└──────────┬───────────┘ idempotent on crash recovery
▼
┌──────────────────────┐
│ ShardHandle's │ Per-shard AtomicU64 lockfree_entries
│ lock-free overlay │ counter (#9). Bounded by
└──────────┬───────────┘ --lockfree-flush-threshold
▼ entries > threshold (#9) ─┐
┌──────────────────────┐ │
│ flush_lockfree_over_ │ ◀──────────────────┘
│ threshold │
└──────────────────────┘
The original bug: vocab was checkpointed independently of n-gram shards, so a crash between vocab-checkpoint and shard-checkpoint left them at divergent index points. On reopen, vocab restarted from a stale index, orphaning n-grams encoded with newer indices.
The fix: every checkpoint path (save_checkpoint, periodic_checkpoint)
calls storage.merge_and_rotate_vocabulary_wal() FIRST, then syncs
shards. This single-method-call form replaced the prior two-step
sync_vocabulary() + rotate_vocabulary_wal() pattern — both internally
called merge_into() on the lock-free vocab, doubling the reverse-index
HashMap rebuild and spiking ~3.42 GB transient memory for a 5.8M-word
vocab. The combined method does ONE merge, halving the peak.
all workers exit
│
▼
┌───────────────────────┐
│ save_checkpoint flow │ (skipped on worker exit per #12)
└────────────┬──────────┘
▼
┌───────────────────────┐
│ Final checkpoint save │
└────────────┬──────────┘
▼
┌───────────────────────┐
│ yield_now().await │ (#13) lets SIGINT handler run before
└────────────┬──────────┘ synchronous MKN work blocks the runtime
▼
┌───────────────────────┐
│ MknAggregator │ with_cancellation_flag(&self.interrupted)
│ .compute_all() │ (#11) — Ctrl+C during MKN now exits
└────────────┬──────────┘ cleanly instead of running to completion
▼
┌───────────────────────┐
│ final compact │ checkpoint_vocabulary (one-shot full
│ vocabulary │ re-serialize) — only at finalize, not
└───────────────────────┘ periodically (which uses WAL rotation)
Cargo.toml adds optional mimalloc 0.1 dep + mimalloc-alloc feature
(included transitively in google-books). All three bins
(grammstein, compare_artries, dump_checkpoint) declare
#[global_allocator] gated on cfg(feature = "mimalloc-alloc").
Why it matters: glibc malloc serves large allocations via mmap and frees
via munmap, both of which issue mprotect syscalls to update page
permissions. mimalloc uses thread-local segment heaps with pre-allocated
superpage regions — allocations satisfy from the existing reservation
without per-call mprotect. On a 12-worker English import this drops
__mprotect CPU share from ~49% to roughly background noise.
open_or_create_concurrent_vocabulary_lockfree_with_capacity(path, n)
pre-sizes the lock-free layer's DashMap term cache and the reverse-lookup
Vec to the estimated final size. Without pre-sizing, the structures
geometric-double during import, with each doubling temporarily holding
both old and new tables — several GB of transient overhead for a 5.8M-word
vocab.
estimate_vocabulary_size(config) derives the capacity from language +
min_count (English base 13M, scaled down by min_count factor — higher
thresholds prune more rare words).
NgramStorage::store_ngram(ngram, count) and tx_insert_ngram(...) use
SmallVec<[&str; 5]> (matching the supported n-gram order range). All
splits fit inline on the stack with no heap allocation.
parse_ngram_line_ref(line) -> NgramRecordRef<'_> finds tab positions via
byte scanning instead of split('\t').collect::<Vec<_>>(). The returned
NgramRecordRef borrows ngram text from the input line. Combined with
YearAggregator::push_ref (only allocates a String when the ngram
changes), per-record heap traffic drops from O(line) to O(1) amortized.
merge.rs and mkn.rs switched all internal HashMap/HashSet keys
(byte-encoded n-gram contexts, predecessor/successor index sets) from
SipHash defaults to xxhash_rust::xxh3::Xxh3DefaultBuilder via local
XxHashMap/XxHashSet type aliases. Non-adversarial data, so the speedup
(~32% faster hash on cache-line-sized keys) is free.
Lives in libdictenstein (persistent_artrie_char/nodes/persistent_node.rs).
Replaces im::Vector<SwizzledPtr> with a two-tier enum:
Inline { count: u8, keys: [u32; 4], children: [SwizzledPtr; 4] }— ~85% of nodes, zero heap alloc.Heap { keys: Vec<u32>, children: Vec<SwizzledPtr> }— ~15% of nodes, flat Vec.
Eliminates im::Vector's Arc COW + Arc::make_mut overhead (~7.22 GB
across a full English import) and the mprotect pressure it caused.
process_prefix_file_cached downloads the raw .gz to a local cache via
download_to_cache (atomic .gz.downloading → .gz rename, HTTP 206
Range resume, HTTP 416 recovery), then streams from the cached file via
stream_aggregated_from_cached_file. Decouples download reliability from
parse CPU — a failed HTTP stream no longer wastes CPU already spent
parsing.
Storage-level commit_and_renew_prefix_tx commits the current chunk and
begins a fresh transaction for the same prefix/shard. Caller (the
importer's inner streaming loop) invokes this when chunk_count >= tx_chunk_size. Shard-level commit_chunk commits to the WAL +
persistent trie but does NOT update completed_prefixes; the final
commit_prefix marks the prefix complete.
Crash recovery: SET-semantics inserts make re-importing the prefix idempotent. Uncommitted chunks are lost on crash; committed chunks survive in the WAL.
ShardHandle carries an AtomicU64 lockfree_entries counter (Relaxed
ordering — approximate, used only for threshold decisions). Incremented
on increment_lockfree, reset on sync / flush_lockfree / checkpoint.
ShardCoordinator::flush_lockfree_over_threshold(threshold) does a fast
read-lock check on every shard and only acquires a write lock for the
ones over threshold. Workers on under-threshold shards continue
uninterrupted.
The importer auto-scales the default: 50K for ≥8 parallel workers, 100K
otherwise. --lockfree-flush-threshold overrides.
Discussed above (durability invariant section).
Smaller surgical fixes:
- #11 —
MknAggregator::with_cancellation_flag(&AtomicBool)checks the flag at the top of every shard iteration incompute_allandcompute_continuation_counts. The importer wiresself.interruptedin. - #12 — The
save_checkpoint()call previously fired on every worker exit. The original code is preserved as commented-out (per CLAUDE.md "never disable by deleting" rule) with aDISABLED:rationale block explaining the 12× checkpoint storm. - #13 —
tokio::task::yield_now().awaitbeforecompute_mkn_statslets the runtime drive other tasks (including SIGINT handlers) before the multi-minute synchronous MKN work captures the thread. - #14 —
checkpoint_interval = if config.parallel_downloads >= 8 { 5 } else { 10 }at three sites in the importer. Higher parallelism → faster overall throughput → checkpoints amortize over more work.
Optimizations #8/#9 bounded the inter-checkpoint overlay growth, but the
resident lock-free overlay itself (bottleneck (a)) was still unbounded — it
accumulated for the lifetime of an open shard, because checkpoint() serialized
the overlay snapshot to disk without reclaiming its resident RAM. After
libdictenstein added production overlay-heap eviction (the checkpoint() tail now
evicts the coldest resident overlay nodes down to a resident_budget_bytes,
losslessly — evicted nodes fault back from the durable image on read),
libgrammstein arms it per shard:
ShardHandle.trieis held asSharedARTrie<u64>(Arc<PersistentARTrie>) so the eviction coordinator can hold a weak self-reference;arm_eviction()installs it after open/create. All trie writes remain&self(lock-free overlay), so they deref through theArcunchanged.- Budget policy (
ShardConfig::overlay_eviction_config): a global budgetG(CLI--overlay-budget-gib, default 10 GiB, default-on;0disables) is divided by the number of simultaneously-resident shards. Hash-basedCpuProportional(the default) and an unlimitedmax_open_shardskeep allnum_shardsresident, so the divisor isnum_shards; otherwise the LRU cap bounds residents tomax_open_shards. SoSUM(per-shard budget)over the resident set ≈G, granularity-invariant. A 64 MiB per-shard floor + a finite 200K-node per-checkpoint eviction cap keep the tail from thrashing or latency-spiking; the base preset iswithout_memory_monitor()(no per-shardsysinfothread — the checkpoint tail fires purely onresident > budget). - CX path-compression (also new in libdictenstein) shrinks the on-disk/evicted node form, complementing eviction (it does not shrink the resident hot set).
This converts bottleneck (a) from "unbounded" to "bounded by G" — the resident
overlay is the dominant heap term during bulk ingest, so this is the lever that
makes the <16 GB target reachable.
The fixes are guarded by ~20 unit and integration tests:
src/sources/google_books/sharding/shard.rs::tests— 7 tests forlockfree_entry_count+commit_chunklifecycle + SET-semantics idempotency.src/sources/google_books/sharding/coordinator.rs::tests— 4 tests forflush_lockfree_over_threshold+total_lockfree_entries+commit_chunk_tx.src/sources/google_books/sharding/mkn.rs::tests— 2 cancellation tests (compute_all,compute_continuation_counts).src/sources/google_books/aggregator.rs::tests— 2 push_ref tests (no-alloc same-ngram + equivalence-to-push).src/sources/google_books/storage.rs::tests— 4 tests for chunked transactions + 1 idempotency test formerge_and_rotate_vocabulary_wal- 2 regression tests for the checkpoint-resume bug class.
src/sources/google_books/importer.rs::tests::cache_files— 6 wiremock tests fordownload_to_cache(creates, skips existing, Range resume, 416 recovery, cleanup of both files, idempotent cleanup).src/sources/google_books/parser.rs::tests— 7 tests forparse_ngram_line_ref(unigram/bigram/unicode/wrong-field-count/ empty-ngram/invalid-fields/equivalence-to-owned).src/sources/google_books/sharding/shard.rs::tests— 3 overlay-eviction tests (#15):test_overlay_eviction_is_lossless_and_observable(nodes_evicted > 0+ every evicted key faults back),test_overlay_eviction_bounds_resident_to_budget(a 1 MiB budget reclaims the bulk of a 50K-node overlay —nodes_evicted >= 30K), andtest_overlay_eviction_under_concurrent_writers(no lost writes under writers racing the budget eviction; deterministic across repeats).
In-process micro-benchmark (benches/overlay_eviction.rs — single shard, 1M
distinct n-grams, cargo bench --features google-books --bench overlay_eviction):
| config | ingest throughput | final checkpoint | nodes reclaimed |
|---|---|---|---|
| eviction OFF (unbounded) | ~211K ngrams/s | 448 ms | 0 |
| eviction ON (4 MiB budget) | ~135K ngrams/s | 139 ms | 1,078,092 |
The budget reclaims the entire resident overlay (>1M nodes) — the memory bound works. The 4 MiB budget is a worst-case stress (maximal eviction); it costs ~36% ingest throughput and, in exchange, makes the final checkpoint 3× faster (the overlay was evicted incrementally rather than serialized whole at the end). The production default (10 GiB ÷ resident-shard count, ≥ 64 MiB/shard) evicts far less often, so the real-world throughput cost is much smaller — the tradeoff is "bounded heap, no OOM" vs unbounded growth. The bound is further verified by the in-process eviction tests (fires, reclaims the bulk of the overlay, lossless, concurrency-safe).
Pending — end-to-end peak-RSS on a real corpus. Run the importer with
--overlay-budget-gib 0 (unbounded = old behavior) vs the default 10 GiB under
/usr/bin/time -v (off tmpfs) with CPU affinity (taskset -c 0-11). Acceptance:
peak heap 33.79 GB → <16 GB; __mprotect CPU share 49% → <5% (the latter already
addressed by mimalloc, #1). Caveat: mimalloc retains freed pages, so RSS lags
the live heap and understates the reduction — prefer valgrind --tool=massif
(live-heap profile, not RSS) for the heap-shape confirmation, or read the live
reclamation off eviction_stats().nodes_evicted as the micro-benchmark does. This
needs a real Google Books corpus run; results will land in MEMORY.md and here.