Skip to content

Fix: bound uvm-ublk-daemon memory growth under pause/resume churn - #179

Open
YXalix wants to merge 2 commits into
kvcache-ai:mainfrom
YXalix:fix_leak
Open

Fix: bound uvm-ublk-daemon memory growth under pause/resume churn#179
YXalix wants to merge 2 commits into
kvcache-ai:mainfrom
YXalix:fix_leak

Conversation

@YXalix

@YXalix YXalix commented Aug 17, 2026

Copy link
Copy Markdown

What

Bound uvm-ublk-daemon memory growth under pause/resume churn, via two commits:

  • 8939f2b fix(overlaybd): offload premerged index prune scan and trigger it by cache growth — the prune scan runs as one spawn_blocking task over std::fs (one blocking op per scan, not per directory entry), triggers by cache growth (one scan per max_dir_bytes / 8 of new writes instead of one queued scan per write), and no longer pins the merged index while queued.
  • 355692b fix(ublk-daemon): adopt jemalloc — global tikv-jemallocator with the background_threads feature (compile-time background_thread:true), mirroring src/bin/server.rs, so freed pages return to the OS instead of lingering in glibc arenas. No run-time malloc_conf export is kept; the default 10s dirty/muzzy decay is what the validation numbers below actually ran with.

Stress replay of the issue's workload — 600 sandboxes, 100 running concurrently,
continuous pause/resume (16,128 timing samples per run)

Build daemon anon_mmap pause P50/P95 resume P50/P95 total P50/avg
baseline (no fixes) 28.39 GB 1.123 / 1.723 s 0.812 / 1.144 s 8.043 / 13.406 s
8939f2b only (prune) 1.54 GB 0.322 / 0.606 s 0.193 / 0.388 s 4.172 / 10.754 s
both commits 0.72 GB 0.338 / 0.669 s 0.190 / 0.443 s 4.303 / 10.826 s

Why

Issue #171: a 600-VM pause/resume stress cycle grows daemon anonymous memory at 20–40 MB/s up to tens of GB. memleak-bpfcc points at the premerged-index write path. Two compounding mechanisms:

  1. Prune amplification — every artifact write spawned a task whose tail ran a full cache-dir prune via tokio::fs::ReadDir (one blocking-pool round-trip per entry — the 101k read_dir allocations in the trace; a large cache dir becomes tens of thousands of queued blocking ops). Each queued task also pinned its Arc<ReadOnlyIndex> (can be hundreds of MB) and a manifest Vec<(PathBuf, u64, SystemTime)> of the whole directory (the ~27.5 MB grow_one stack in the trace).
  2. Allocator retention — glibc's dynamic mmap threshold climbs to 32 MB under multi-MB alloc/free churn, after which large buffers come off arena heaps and freed arena pages are never returned to the OS. Part of the reported "leak" is memory that was freed but retained.

On this workload the baseline reproduces the reported behavior (28.39 GB daemon anon memory); the two commits together bring it to 0.72 GB (−97%) — see Validation.

Related issue

Closes #171

Scope and non-goals

Included: only the two commits above — storage/overlaybd/src/lsmt/file/{helper,tests}.rs (prune path) and storage/ublk-daemon/{Cargo.toml,src/main.rs} (allocator).

Non-goals: no redesign of the cache layout (e.g. incremental artifact registry or generation directories — considered, deferred until scan cost is shown to matter beyond this fix); no changes to the premerged artifact format; no unrelated refactoring.

Design and behavior changes

  • Scan offload: the scan/remove loop moved into a single spawn_blocking task using std::fs; entries deleted concurrently with the scan are skipped instead of aborting the prune.
  • Growth-based trigger: per-cache-dir state (keyed by cache_dir, like the other premerged helpers) charges written artifact bytes; once growth since the last scan reaches max_dir_bytes / 8 (floored at one byte, so tiny budgets cannot degenerate into a scan per write), a guarded swap elects exactly one scanner. At most one scan per dir runs at a time, and bytes charged during a running scan are voided when it ends — scans neither overlap nor run back to back, and their frequency is bounded by write rate, not dir size. Bursts coalesce into one scan; idle or slow caches never scan. Failed writes charge nothing (tmp file is removed, no net growth).
  • Early index release: drop(merged) before the lock-release/prune tail, so queued tasks stop pinning the merged index.
  • Allocator: the daemon now mirrors src/bin/server.rs exactly — tikv-jemallocator with the background_threads feature, which compiles jemalloc with a build-time background_thread:true so purging runs off the allocation path; dirty/muzzy decay keeps jemalloc's 10s defaults. A run-time malloc_conf export is deliberately not kept (see Risks and reviewer notes).
  • Behavior change: scan frequency moves from once per write to once per max_dir_bytes / 8 of new bytes per cache dir (≥ 8 MiB given the 64 MiB minimum budget, so it cannot degenerate).

Compatibility and operations

  • Public API or generated protocol: none — internal overlaybd helper and daemon allocator only.
  • Configuration or defaults: none — the 1/8 scan fraction is a code constant; jemalloc defaults are unchanged.
  • Snapshot manifest, artifact layout, or storage format: none — premerged artifacts keep the existing PMIDX001 v1 format; only eviction scheduling changes.
  • Upgrade and rollback: the two fixes are independent; server and daemon binaries can roll forward/back independently with no on-disk coordination.
  • Host requirements, permissions, ports, or dependencies: adds vendored tikv-jemallocator to uvm-ublk-daemon (the same crate and feature the server already uses; bundled jemalloc, no system package needed); slightly larger daemon binary; no new ports or permissions.

Validation

  • make fmt
  • make clippy
  • make test-unit
  • Relevant Rust integration tests
  • make -C services test (required when services/ changes)
  • Generated clients/server regenerated with the documented make target
  • Documentation updated

Skipped checks and reasons:

  • This change does not affect the relevant components.

Risks and reviewer notes

  • Election: growth accounting is per cache dir; a write fetch_adds its bytes, and the crossing writer wins an in-flight flag swap — exactly one scan fires per threshold crossing per dir. Losing writers keep their bytes accumulated until the elected scan voids them at its end. Relaxed ordering suffices because the counter guards no other memory.
  • Allocator: an earlier iteration exported dirty_decay_ms:1000,muzzy_decay_ms:1000,background_thread:true via a malloc_conf symbol, which the prefixed jemalloc build (--with-jemalloc-prefix=_rjem_) silently never reads — the validated 0.72 GB therefore reflects the default decay this PR now ships verbatim. The run-time export was dropped rather than kept as a silent-failure footgun.
  • Key files: storage/overlaybd/src/lsmt/file/helper.rs, storage/ublk-daemon/src/main.rs.

Checklist

  • The PR contains one coherent change and no unrelated formatting or refactoring.
  • New behavior is covered by tests, or I explained why testing is impractical.
  • Logs and examples contain no credentials, tokens, or private registry information.
  • I did not manually edit generated code without updating its source and regenerating it.

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

🔍 OpenCodeReview found 4 issue(s) in this PR.

  • ✅ Successfully posted inline: 4 comment(s)

const PREMERGED_INDEX_PRUNE_SCAN_FRACTION: u64 = 8;

/// Artifact bytes written since the last prune scan.
static PREMERGED_INDEX_BYTES_SINCE_SCAN: AtomicU64 = AtomicU64::new(0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
This accounting counter is process-global, but the prune operation is scoped to dir. If two callers use different cache directories (or the same directory with different limits), bytes charged by one are accumulated and can reset the counter for the other; the next scan may therefore be triggered for the wrong directory and the first directory can grow indefinitely without a scan. Keep the byte counter keyed by cache directory/limit (or otherwise scope the election to the cache being pruned).

/// one caller to run the next prune scan, once growth since the last scan
/// reaches `max_dir_bytes` / [`PREMERGED_INDEX_PRUNE_SCAN_FRACTION`].
fn charge_premerged_index_prune(written_bytes: u64, max_dir_bytes: u64) -> bool {
let threshold = max_dir_bytes / PREMERGED_INDEX_PRUNE_SCAN_FRACTION;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

performance · medium
Because max_dir_bytes is an externally constructible public policy field, values below 8 are possible even though hybrid() clamps its result. Integer division makes threshold zero for those values, so every successful write performs an atomic swap and elects a prune scan, defeating the intended amortization. Validate/reject such limits or clamp the threshold to a nonzero, explicitly supported minimum.


entries.sort_by_key(|(_, _, modified): &(PathBuf, u64, SystemTime)| *modified);
for (path, len, _) in entries {
entries.sort_by_key(|entry| entry.modified);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

performance · medium
An elected writer still awaits a full directory scan that collects every artifact and sorts all entries by modification time. With a large cache directory this is O(n) metadata plus O(n) memory and O(n log n) CPU, and the blocking-pool worker remains occupied for the entire operation, causing latency spikes and potentially delaying unrelated blocking tasks. Consider a bounded/incremental eviction strategy or an independently scheduled maintenance task if large directories are expected.

@Hygge-Gezelligheid

Copy link
Copy Markdown
Contributor

Thanks for the fix and the detailed validation results. While reviewing the changes, I noticed three points that I would like to confirm:

  1. tikv-jemallocator appears to use prefixed symbols, while the code exports malloc_conf rather than _rjem_malloc_conf. In my Linux build, jemalloc referenced _rjem_malloc_conf, and the exported malloc_conf appeared to be a separate symbol.

  2. The byte counter is reset before the selected prune scan finishes, and I did not find an in-progress guard. It therefore seems possible for another threshold crossing to start a second scan while the first is still running.

  3. PREMERGED_INDEX_BYTES_SINCE_SCAN is process-global, while each prune operation is scoped to a specific cache directory. If multiple cache directories are used, their accounting may affect each other.

Could you please confirm whether these observations are correct, or whether there is additional context that prevents these cases?

.get_or_init(|| Mutex::new(HashMap::new()))
.lock()
.await
.entry(cache_dir.to_path_buf())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

performance · medium
The registry key is the raw PathBuf, so aliases such as a relative path versus its absolute form (or paths containing ..) for the same cache directory create independent scan_in_progress flags. Concurrent callers using those spellings can then run overlapping full-directory scans, defeating the stated per-cache-dir serialization and increasing blocking-pool pressure. Normalize the path before using it as the registry key, or enforce a canonical/normalized representation at the API boundary.

Comment on lines +514 to +517
let previous = self
.bytes_since_scan
.fetch_add(written_bytes, Ordering::Relaxed);
let total = previous.wrapping_add(written_bytes);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · low
Using fetch_add together with wrapping_add allows the accounting counter to wrap to a value below threshold. After enough accumulated writes (or an unusually large written_bytes), a threshold crossing can be missed and pruning can be deferred until the counter wraps again. Use saturating/checked atomic accounting so any overflow remains at or above the trigger.

Comment on lines +514 to +518
let previous = self
.bytes_since_scan
.fetch_add(written_bytes, Ordering::Relaxed);
let total = previous.wrapping_add(written_bytes);
total >= threshold && !self.scan_in_progress.swap(true, Ordering::AcqRel)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

performance · medium
bytes_since_scan is updated before checking scan_in_progress, while scan_finished resets the counter before clearing the flag. A writer can execute fetch_add, then the scanner can reset the counter and clear the flag, and finally that writer's swap(true) can succeed using its pre-reset total. This permits the back-to-back scan that the method explicitly intends to suppress. The election and reset need a single synchronized state transition (for example, one atomic state containing both fields, or a mutex/CAS protocol that revalidates the counter after acquiring the scan flag).

Ok(()) => total = total.saturating_sub(len),
match std::fs::remove_file(&entry.path) {
Ok(()) => total = total.saturating_sub(entry.len),
Err(err) if err.kind() == ErrorKind::NotFound => {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
When another writer or pruning process removes this artifact after the scan, NotFound means its bytes are no longer part of the directory. Leaving total unchanged makes the loop believe the cache is still over budget and can delete additional valid artifacts, causing unnecessary cache eviction. Subtract entry.len in this branch as well.

Suggestion:

Suggested change
Err(err) if err.kind() == ErrorKind::NotFound => {}
Err(err) if err.kind() == ErrorKind::NotFound => {
total = total.saturating_sub(entry.len);
}

}

#[tokio::test]
async fn test_premerged_index_prune_state_is_per_dir_and_elects_one_scan() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test · low
The added test covers basic per-directory identity and single election, but not the filesystem race where an enumerated artifact disappears before remove_file, nor the interleaving between elect_scan and scan_finished that can trigger a back-to-back scan. Tests for these cases would help protect the new accounting and concurrency invariants.

Comment on lines +502 to +504
Component::ParentDir => {
normalized.pop();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
Leading parent components in relative paths are discarded. For example, ../cache normalizes to cache, and a/../../cache also collapses too far, so distinct cache directories can share one prune state and suppress each other's scans. Preserve .. whenever there is no normal component available to pop (while handling roots/prefixes separately).

Suggestion:

Suggested change
Component::ParentDir => {
normalized.pop();
}
Component::ParentDir => {
let can_pop = matches!(normalized.components().next_back(), Some(Component::Normal(_)));
if can_pop {
normalized.pop();
} else if !normalized.is_absolute() {
normalized.push(component.as_os_str());
}
}

Comment on lines +553 to +554
account.bytes_since_scan = 0;
account.scan_in_progress = false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
This drops all bytes written while the scan was running. A scan can finish enumerating its snapshot, then concurrent writers can add enough artifacts to exceed max_dir_bytes; resetting their charges means no follow-up scan occurs, and the directory can remain over budget indefinitely if the burst stops. Reset the pre-scan charge when electing, retain charges accumulated during the scan, and have completion elect/run a follow-up scan when that retained amount crosses the threshold.

Comment on lines +495 to +504
.entry(cache_dir.to_path_buf())
.or_insert_with(|| {
Arc::new(PremergedIndexPruneState {
account: StdMutex::new(PremergedIndexScanAccount {
bytes_since_scan: 0,
scan_in_progress: false,
}),
})
})
.clone()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
This process-wide map retains a strong Arc and copied PathBuf for every cache directory ever passed to the public cache-opening API. Callers using transient/per-image directories will grow STATES for the lifetime of the process, even after all writes and scans finish. Store Weak<PremergedIndexPruneState> values and remove/recreate stale entries, as the digest-lock registry already does, to bound registry lifetime.

Comment on lines +512 to +517
account.bytes_since_scan = account.bytes_since_scan.saturating_add(written_bytes);
if account.bytes_since_scan >= threshold && !account.scan_in_progress {
account.bytes_since_scan = 0;
account.scan_in_progress = true;
return true;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
A newly created process state starts at zero, so this is no longer guaranteed to inspect an already oversized on-disk cache on the first write. After a restart, cache-limit reduction, or earlier failed cleanup, pruning is deferred until new artifacts total max_dir_bytes / 8 (at least 8 MiB with the production minimum), even though the directory may already exceed max_dir_bytes. There is no startup/maintenance prune elsewhere; initialize a new directory state so its first successful write elects a scan, or otherwise perform an initial size check.

Comment on lines +537 to +540
pub(super) fn scan_aborted(&self) {
let mut account = self.account.lock().unwrap_or_else(|err| err.into_inner());
account.scan_in_progress = false;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
The charge that elected the failed scan was already reset to zero in elect_scan, but this abort path restores only the gate. Therefore, when a scan fails without at least another threshold of concurrent writes, the next ordinary write does not re-elect as documented; cleanup can be postponed by another full threshold after a transient read_dir, metadata, or join failure. Preserve/restore the triggering threshold on abort (while retaining concurrent charges) so the next write retries.

Comment on lines +2545 to +2549
let a = premerged_index_prune_state(&cache_a).await;
assert!(Arc::ptr_eq(
&a,
&premerged_index_prune_state(&cache_a).await
));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test · medium
This test only invokes elect_scan sequentially, so it does not exercise the concurrency invariant implied by the shared per-directory state: simultaneous writers could both race around scan election or charge accounting while this test still passes. Please add a concurrent test (for example, synchronized Tokio tasks issuing charges against the same Arc) and assert that exactly one caller is elected and pending growth is preserved.

Suggestion:

Suggested change
let a = premerged_index_prune_state(&cache_a).await;
assert!(Arc::ptr_eq(
&a,
&premerged_index_prune_state(&cache_a).await
));
let a = premerged_index_prune_state(&cache_a).await;
assert!(Arc::ptr_eq(
&a,
&premerged_index_prune_state(&cache_a).await
));
// Exercise election and accounting from concurrent callers here.

Comment on lines +2553 to +2555
// Budget 64 triggers one scan per 8 new bytes (`max_dir_bytes` / 8).
let budget = 64;
assert!(!a.elect_scan(7, budget));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · low
The assertions encode private policy details (max_dir_bytes / 8, counter reset timing, and the exact follow-up-scan state machine) rather than an externally observable pruning contract. This makes the test unnecessarily brittle: a valid change to the amortization threshold or accounting strategy would require rewriting it even if cache pruning behavior remains correct. Consider keeping this as a focused state-machine unit test only if these details are an intentional invariant; otherwise prefer a behavior-oriented test that writes artifacts and verifies the directory is bounded.

Suggestion:

Suggested change
// Budget 64 triggers one scan per 8 new bytes (`max_dir_bytes` / 8).
let budget = 64;
assert!(!a.elect_scan(7, budget));
// Verify pruning behavior through artifact writes and the resulting directory size.

YXalix added 2 commits August 18, 2026 11:25
…cache growth

Every artifact write scheduled a full tokio::fs prune of the cache dir:
one blocking-pool round-trip per directory entry.

- Run the scan as one spawn_blocking task.
- Trigger a scan per 1/8th of max_dir_bytes of new writes, at most one
  per dir at a time; growth charged during a scan is retained and chains
  a follow-up scan when it crosses the trigger.
- Drop the merged index before the lock-release/prune tail so queued
  writes stop pinning it.
glibc malloc leaves RSS plateaued high after pause/resume churn frees
multi-MB buffers over and over (issue kvcache-ai#171).

- Swap in tikv-jemallocator with background_threads, mirroring
  src/bin/server.rs.
Comment on lines +489 to +490
static STATES: OnceLock<Mutex<HashMap<PathBuf, Arc<PremergedIndexPruneState>>>> =
OnceLock::new();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
This process-global registry retains a strong Arc for every distinct raw PathBuf indefinitely. A long-running daemon that receives many cache-directory paths can therefore grow this map without bound; equivalent relative/absolute or symlinked paths also get separate states and can scan the same physical directory concurrently. Canonicalize the key and use weak references or an eviction/removal policy if the set of cache directories is not strictly fixed.

Comment on lines +499 to +500
bytes_since_scan: 0,
scan_in_progress: false,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
The accounting starts at zero and only grows when this process successfully writes an artifact; it never inspects an existing directory. Because the cache directory persists across restarts and max_dir_bytes can change, a pre-populated or already-oversized cache will not be pruned until another max_dir_bytes / 8 bytes are written. With low write activity, this can leave the cache over its configured limit indefinitely, whereas the previous implementation checked after every successful write. Consider initializing the state with an initial scan/election (or otherwise ensuring the first use prunes an existing over-limit directory).

Comment on lines +537 to +540
pub(super) fn scan_aborted(&self) {
let mut account = self.account.lock().unwrap_or_else(|err| err.into_inner());
account.scan_in_progress = false;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
On an aborted scan, the charge that elected the scan was already reset in elect_scan, and this method only clears the gate. If the scan fails before any concurrent writes add credit, the next write must accumulate a full new threshold before retrying; repeated transient read/join failures can therefore keep an oversized cache unpruned. Preserve a retry-pending/threshold charge when aborting (or explicitly schedule a bounded retry) instead of discarding the election.

Comment on lines +2545 to +2549
let a = premerged_index_prune_state(&cache_a).await;
assert!(Arc::ptr_eq(
&a,
&premerged_index_prune_state(&cache_a).await
));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test · low
This only checks sequential lookups. It does not exercise concurrent first-time initialization of the global state map, so a check-then-insert implementation could still create multiple Arcs for the same directory and lose accounting while these assertions pass. Add concurrent lookups (for example, several tasks synchronized by a barrier) and assert all returned Arcs are pointer-identical.

@YXalix

YXalix commented Aug 18, 2026

Copy link
Copy Markdown
Author

Thanks for the fix and the detailed validation results. While reviewing the changes, I noticed three points that I would like to confirm:

  1. tikv-jemallocator appears to use prefixed symbols, while the code exports malloc_conf rather than _rjem_malloc_conf. In my Linux build, jemalloc referenced _rjem_malloc_conf, and the exported malloc_conf appeared to be a separate symbol.
  2. The byte counter is reset before the selected prune scan finishes, and I did not find an in-progress guard. It therefore seems possible for another threshold crossing to start a second scan while the first is still running.
  3. PREMERGED_INDEX_BYTES_SINCE_SCAN is process-global, while each prune operation is scoped to a specific cache directory. If multiple cache directories are used, their accounting may affect each other.

Could you please confirm whether these observations are correct, or whether there is additional context that prevents these cases?

@Hygge-Gezelligheid thanks for the careful review — all three observations are correct. Here is where they stand now:

  1. malloc_conf symbol prefix — Correct, a plain malloc_conf export is silently ignored under prefixed builds. The fix: avoids a runtime export entirely. The daemon now mirrors src/bin/server.rs bit for bit — allocator, feature, no run-time conf.
  2. Missing in-progress guard — Correct, the counter could reset while a scan was still running and a second scan could start. The fix: the per-dir account keeps the counter and a scan_in_progress flag behind one mutex, so charging, election, and the scan-finished reset are atomic steps — at most one scan runs per cache dir at a time.
  3. Process-global counter — Correct, a single global counter mixed accounting across cache dirs. The fix: the accounting is now a per-dir registry keyed by cache_dir, each with its own counter and gate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Anon_mmap memory growth (20–40 MB/s) in uvm-ublk-daemon during VM pause/resume cycle

2 participants