From 692c81e2d2b24187b6e8dfa39f586dc0361f0f42 Mon Sep 17 00:00:00 2001 From: Naadir Jeewa Date: Mon, 31 Aug 2026 17:20:45 +0100 Subject: [PATCH 1/2] feat(memory): adopt mimalloc allocator Signed-off-by: Naadir Jeewa Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- ARCHITECTURE.md | 6 +- Cargo.lock | 26 ++++ crates/aft/Cargo.toml | 1 + crates/aft/src/lib.rs | 4 + crates/aft/src/memory.rs | 200 ++++++++--------------------- crates/aft/src/subc/mod.rs | 3 +- crates/aft/src/test_allocations.rs | 13 +- 7 files changed, 98 insertions(+), 155 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9083d67fb..de242b794 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -165,7 +165,7 @@ 2. When a project root becomes unbound (no active routes/channels remaining and no pending binds), the subc daemon quiesces it: marks the actor context as subc unbound, invalidates the configure generation, retires search/callgraph/semantic build receivers, cancels queued and pending artifact work, cancels all queued maintenance jobs (returning `"maintenance_cancelled"` answers, except for active `Lsp` drains which are allowed to run/finish), and discards deferred configure maintenance. Transient unbind deliberately keeps the watcher and resident artifacts warm so a host restart can rebind without a full verification scan. Receiver generation/epoch pairs prevent already-dequeued results from committing after teardown or replacement, while per-artifact publication epochs prevent superseded workers from publishing stale disk pointers. When a new route is bound, the root is reactivated, clearing the quiesced and evicted flags. 3. After the idle TTL, and only while the root still has no bound or pending route, evict root-scoped artifact handles (callgraph store, search index, semantic index, borrowed indexes, symbol data, and inspect SQLite caches) via `evict_idle_artifacts`; stop and bounded-join the watcher on a detached reaper thread; and shut down reopenable LSP clients in the background. Subsequent queries trigger asynchronous index reloads. Because edits during watcher downtime go unobserved, advance artifact publication epochs and invalidate the verify memo, forcing `WarmVerifyPlan::Strict` re-verification on a later bind. The process-wide tree-sitter parser cache and shared `aft.db` connection are not per-root resources. 4. If the unbound root directory no longer exists, remove its idle executor actor and drop its LSP, bash watchdog, channels, and registries on a detached teardown thread. Purge detached-session replay and wake state for that root; a missing-directory root cannot be rebound by the plugin. If cleanup of an idle or deleted root is blocked, a detailed reap blocker census (`ReapBlockerCensus`) tracks and exposes the specific blockers (such as active route channels, quiescing status, background bash waits, or pending/queued maintenance tasks) within the subc health report -- `crates/aft/src/subc/health.rs`. -5. Under macOS and Linux, after sweeping idle roots or periodically on transport ticks when reported allocator slack is >= 1 GiB, request memory pressure relief from the OS allocator via `relieve_allocator_pressure` to reclaim unused pages -- `crates/aft/src/memory.rs`. +5. After sweeping idle roots, request forced mimalloc collection via `relieve_allocator_pressure`. Periodically sample mimalloc statistics on the detached `aft-mem-relief` thread and collect when retained committed memory is at least 1 GiB. The SubC transport and stdin ticks only perform a cheap cadence comparison -- `crates/aft/src/memory.rs`. 6. Track process-wide and root-scoped memory usage (including SQLite allocator metrics and OS RSS memory) via memory snapshots returned in status reports -- `crates/aft/src/memory.rs`, `crates/aft/src/commands/status.rs`. Status runtime counts expose live watcher runtimes, live actor roots, and open routes. Key status memory roots by `ProjectRootId` on all platforms to prevent path-casing/verbatim comparison mismatches. To prevent large status payloads from exceeding metrics cache limits, the per-root detail breakdown in status payloads and health check metrics is capped (e.g. at the top 8 roots by attributed bytes), and the remaining entries are rolled up in a compact summarized footprint -- `crates/aft/src/subc/health.rs`, `crates/aft/src/memory.rs`. **Codebase inspection flow:** @@ -362,8 +362,8 @@ **MemoryEstimate / MemorySnapshot:** - Purpose: Track, attribute, and report process-wide and subsystem-specific memory usage. - Location: `crates/aft/src/memory.rs` -- Pattern: Diagnostic structures and OS memory allocator hooks. -- Contains: Subsystem memory estimation helpers, SQLite allocator query bindings (`sqlite3_memory_used`), platform-specific resident set size (RSS), and macOS kernel physical footprint (`phys_footprint_bytes` via `proc_pid_rusage RUSAGE_INFO_V4`) queries. Periodic allocator slack scans run on a detached background-priority `aft-mem-relief` thread because allocator inspection can block. Transport and stdin ticks only perform a cheap cadence check. +- Pattern: Diagnostic structures and mimalloc collection hooks. +- Contains: Subsystem memory estimation helpers, SQLite allocator query bindings (`sqlite3_memory_used`), mimalloc committed/requested byte telemetry, platform-specific resident set size (RSS), and macOS kernel physical footprint (`phys_footprint_bytes` via `proc_pid_rusage RUSAGE_INFO_V4`) queries. Periodic allocator slack scans and forced collection run on the detached background-priority `aft-mem-relief` thread. Transport and stdin ticks only perform a cheap cadence check. **FleetStatusClient:** - Purpose: Publish AFT's project-scoped status segment to the fleet status-holder plane (`prefrontal-core`). diff --git a/Cargo.lock b/Cargo.lock index a3a370fa9..c8c44a6c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -48,6 +48,7 @@ dependencies = [ "log", "lsp-types", "memchr", + "mimalloc", "ndarray", "notify", "ort", @@ -683,6 +684,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "cty" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b365fabc795046672053e29c954733ec3b05e4be654ab130fe8f1f94d7051f35" + [[package]] name = "darling" version = "0.20.11" @@ -1819,6 +1826,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "libmimalloc-sys" +version = "0.1.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" +dependencies = [ + "cc", + "cty", +] + [[package]] name = "libredox" version = "0.1.15" @@ -1940,6 +1957,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "mimalloc" +version = "0.1.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" +dependencies = [ + "libmimalloc-sys", +] + [[package]] name = "minimal-lexical" version = "0.2.1" diff --git a/crates/aft/Cargo.toml b/crates/aft/Cargo.toml index 7374698cd..cf5e629b6 100644 --- a/crates/aft/Cargo.toml +++ b/crates/aft/Cargo.toml @@ -32,6 +32,7 @@ crossbeam-channel = "0.5" parking_lot = "0.12" portable-pty = "0.9" libc = "0.2" +mimalloc = { version = "0.1.52", features = ["extended"] } getrandom = "0.3" tree-sitter = "0.26" tree-sitter-typescript = "0.23.2" diff --git a/crates/aft/src/lib.rs b/crates/aft/src/lib.rs index 66c6003ab..53218521d 100644 --- a/crates/aft/src/lib.rs +++ b/crates/aft/src/lib.rs @@ -46,6 +46,10 @@ // Response::error instead of panicking. Confirmed zero .unwrap()/.expect() in // production error paths as of v0.6.3 audit. +#[cfg(not(test))] +#[global_allocator] +static GLOBAL_ALLOCATOR: mimalloc::MiMalloc = mimalloc::MiMalloc; + pub mod agent_child_env; pub mod alert_records; pub mod alert_state; diff --git a/crates/aft/src/memory.rs b/crates/aft/src/memory.rs index 227be74cc..246a9e77c 100644 --- a/crates/aft/src/memory.rs +++ b/crates/aft/src/memory.rs @@ -228,7 +228,6 @@ pub struct AllocatorMemorySnapshot { } impl AllocatorMemorySnapshot { - #[cfg(any(target_os = "macos", all(target_os = "linux", target_env = "gnu")))] fn measured(bytes_in_use: u64, size_allocated: u64) -> Self { Self { status: "measured", @@ -239,10 +238,6 @@ impl AllocatorMemorySnapshot { } } - // Not cfg-gated to the fallback platforms: linux-gnu also uses this at - // RUNTIME when the host glibc predates mallinfo2 (< 2.33), which only - // manifests on release binaries built against an old glibc floor. - #[cfg_attr(target_os = "macos", allow(dead_code))] fn not_estimated(reason: &'static str) -> Self { Self { status: "not_estimated_on_this_platform", @@ -622,81 +617,34 @@ fn nonnegative_i64_to_u64(value: i64) -> u64 { u64::try_from(value).unwrap_or(0) } -#[cfg(target_os = "macos")] -fn allocator_memory_snapshot() -> AllocatorMemorySnapshot { - let mut statistics = std::mem::MaybeUninit::::zeroed(); - unsafe { - libc::malloc_zone_statistics(libc::malloc_default_zone(), statistics.as_mut_ptr()); - } - let statistics = unsafe { statistics.assume_init() }; - AllocatorMemorySnapshot::measured( - usize_to_u64(statistics.size_in_use), - usize_to_u64(statistics.size_allocated), - ) +pub const fn allocator_backend_name() -> &'static str { + "mimalloc" } -#[cfg(all(target_os = "linux", target_env = "gnu"))] fn allocator_memory_snapshot() -> AllocatorMemorySnapshot { - // mallinfo2 exists only in glibc >= 2.33. Release Linux binaries link - // against an older glibc floor (cross gnu images, kept old so dlopen and - // wide distro compatibility hold), so a link-time reference to the symbol - // fails the release build even though native CI (glibc 2.35) links fine. - // Resolve it at runtime instead and report honestly when it is absent. - use std::sync::OnceLock; - type Mallinfo2Fn = unsafe extern "C" fn() -> libc::mallinfo2; - static MALLINFO2: OnceLock> = OnceLock::new(); - let resolved = MALLINFO2.get_or_init(|| { - let symbol = unsafe { libc::dlsym(libc::RTLD_DEFAULT, c"mallinfo2".as_ptr()) }; - if symbol.is_null() { - None - } else { - // SAFETY: glibc declares mallinfo2 as `struct mallinfo2 (*)(void)`; - // the signature matches Mallinfo2Fn exactly. - Some(unsafe { std::mem::transmute::<*mut libc::c_void, Mallinfo2Fn>(symbol) }) - } - }); - let Some(mallinfo2) = resolved else { - return AllocatorMemorySnapshot::not_estimated("mallinfo2_requires_glibc_2_33"); + let Ok(statistics) = mimalloc::MiMalloc::stats_json() else { + return AllocatorMemorySnapshot::not_estimated("mimalloc_statistics_unavailable"); + }; + let Ok(statistics) = serde_json::from_slice::(statistics.to_bytes()) else { + return AllocatorMemorySnapshot::not_estimated("mimalloc_statistics_invalid"); + }; + let current = |field: &str| { + statistics + .get(field) + .and_then(|value| value.get("current")) + .and_then(Value::as_u64) + }; + let Some(bytes_in_use) = current("malloc_requested") else { + return AllocatorMemorySnapshot::not_estimated("mimalloc_statistics_incomplete"); + }; + let Some(size_allocated) = current("committed") else { + return AllocatorMemorySnapshot::not_estimated("mimalloc_statistics_incomplete"); }; - let statistics = unsafe { mallinfo2() }; - let mapped_bytes = statistics.hblkhd as u64; - let bytes_in_use = (statistics.uordblks as u64).saturating_add(mapped_bytes); - let size_allocated = (statistics.arena as u64).saturating_add(mapped_bytes); AllocatorMemorySnapshot::measured(bytes_in_use, size_allocated) } -#[cfg(all(target_os = "linux", target_env = "gnu"))] -type MallocTrimFn = unsafe extern "C" fn(libc::size_t) -> libc::c_int; - -/// Resolve glibc's optional trimming primitive without creating a link-time -/// dependency on a symbol that musl and alternate allocators do not provide. -#[cfg(all(target_os = "linux", target_env = "gnu"))] -fn resolved_malloc_trim() -> Option { - use std::sync::OnceLock; - static MALLOC_TRIM: OnceLock> = OnceLock::new(); - MALLOC_TRIM - .get_or_init(|| { - let symbol = unsafe { libc::dlsym(libc::RTLD_DEFAULT, c"malloc_trim".as_ptr()) }; - if symbol.is_null() { - None - } else { - // SAFETY: glibc declares malloc_trim as `int (size_t)`; - // the signature matches MallocTrimFn exactly. - Some(unsafe { std::mem::transmute::<*mut libc::c_void, MallocTrimFn>(symbol) }) - } - }) - .as_ref() - .copied() -} - -#[cfg(not(any(target_os = "macos", all(target_os = "linux", target_env = "gnu"))))] -fn allocator_memory_snapshot() -> AllocatorMemorySnapshot { - AllocatorMemorySnapshot::not_estimated("platform_allocator_statistics_unavailable") -} - -#[cfg(target_os = "macos")] unsafe extern "C" { - fn malloc_zone_pressure_relief(zone: *mut libc::malloc_zone_t, goal: usize) -> usize; + fn mi_collect(force: bool); } /// Allocator slack (mapped-but-unused arena bytes) above which opportunistic @@ -705,8 +653,7 @@ pub const ALLOCATOR_SLACK_RELIEF_THRESHOLD_BYTES: u64 = 1024 * 1024 * 1024; /// Minimum spacing between allocator slack scans. /// -/// Linux `mallinfo2()` walks every glibc arena under allocator locks. Keep that -/// work off the transport thread and do not repeat it on each maintenance tick. +/// Keep allocator statistics and collection off the transport thread. pub const ALLOCATOR_SLACK_SCAN_MIN_INTERVAL: std::time::Duration = std::time::Duration::from_secs(300); @@ -759,43 +706,25 @@ pub fn spawn_allocator_slack_scan_if_due( .is_ok() } -/// Ask the platform allocator to return unused pages after a process-wide idle -/// gate. Callers own that gate because allocator pressure relief can add -/// latency. Linux invokes glibc's optional `malloc_trim(0)` when the symbol is -/// available; non-glibc allocators intentionally remain a no-op. -#[cfg(target_os = "macos")] +/// Ask mimalloc to return all unused pages after a process-wide idle gate. +/// Callers own that gate because forced collection can add latency. pub fn relieve_allocator_pressure() -> AllocatorPressureRelief { let rss_before_bytes = process_rss_bytes(); let allocator_before = allocator_memory_snapshot(); - let bytes_released = unsafe { malloc_zone_pressure_relief(std::ptr::null_mut(), 0) }; + // SAFETY: `mi_collect` is provided by the linked mimalloc global allocator. + unsafe { mi_collect(true) }; let allocator_after = allocator_memory_snapshot(); let rss_after_bytes = process_rss_bytes(); - AllocatorPressureRelief { - bytes_released: usize_to_u64(bytes_released), - rss_before_bytes, - rss_after_bytes, - allocator_before, - allocator_after, - } -} - -#[cfg(target_os = "linux")] -pub fn relieve_allocator_pressure() -> AllocatorPressureRelief { - let rss_before_bytes = process_rss_bytes(); - let allocator_before = allocator_memory_snapshot(); - #[cfg(target_env = "gnu")] - if let Some(malloc_trim) = resolved_malloc_trim() { - // SAFETY: resolved_malloc_trim verifies the symbol and its C ABI - // signature before returning the function pointer. - unsafe { malloc_trim(0) }; - } - let allocator_after = allocator_memory_snapshot(); - let rss_after_bytes = process_rss_bytes(); - let bytes_released = allocator_before + let allocator_released = allocator_before .size_allocated .zip(allocator_after.size_allocated) .map(|(before, after)| before.saturating_sub(after)) .unwrap_or(0); + let rss_released = rss_before_bytes + .zip(rss_after_bytes) + .map(|(before, after)| before.saturating_sub(after)) + .unwrap_or(0); + let bytes_released = allocator_released.max(rss_released); AllocatorPressureRelief { bytes_released, rss_before_bytes, @@ -872,6 +801,20 @@ mod tests { assert_eq!(signed_difference(5, 8), -3); } + #[test] + fn allocator_backend_is_mimalloc() { + assert_eq!(allocator_backend_name(), "mimalloc"); + } + + #[test] + fn allocator_snapshot_uses_mimalloc_statistics() { + let snapshot = allocator_memory_snapshot(); + assert_eq!(snapshot.status, "measured"); + assert!(snapshot.bytes_in_use.is_some()); + assert!(snapshot.size_allocated.is_some()); + assert!(snapshot.retained_slack_bytes.is_some()); + } + #[test] fn slack_relief_requires_large_measured_slack() { let threshold = ALLOCATOR_SLACK_RELIEF_THRESHOLD_BYTES; @@ -923,32 +866,20 @@ mod tests { .is_some()); } - #[cfg(any(target_os = "macos", all(target_os = "linux", target_env = "gnu")))] #[test] fn allocator_snapshot_reports_measured_slack() { let allocator = allocator_memory_snapshot(); - if allocator.status == "measured" { - let in_use = allocator.bytes_in_use.expect("allocator bytes in use"); - let allocated = allocator.size_allocated.expect("allocator size allocated"); - assert_eq!( - allocator.retained_slack_bytes, - Some(allocated.saturating_sub(in_use)) - ); - } else { - assert_eq!(allocator.status, "not_estimated_on_this_platform"); - assert_eq!(allocator.bytes_in_use, None); - assert_eq!(allocator.size_allocated, None); - assert_eq!(allocator.retained_slack_bytes, None); - assert_eq!( - allocator.not_estimated, - Some("mallinfo2_requires_glibc_2_33") - ); - } + let in_use = allocator.bytes_in_use.expect("allocator bytes in use"); + let allocated = allocator.size_allocated.expect("allocator size allocated"); + assert_eq!(allocator.status, "measured"); + assert_eq!( + allocator.retained_slack_bytes, + Some(allocated.saturating_sub(in_use)) + ); } - #[cfg(target_os = "linux")] #[test] - fn linux_allocator_pressure_relief_smoke() { + fn allocator_pressure_relief_smoke() { let mut allocation = vec![0u8; 32 * 1024 * 1024]; for byte in allocation.iter_mut().step_by(4096) { *byte = 1; @@ -957,30 +888,10 @@ mod tests { drop(allocation); let relief = relieve_allocator_pressure(); - std::hint::black_box(relief); - - #[cfg(target_env = "gnu")] - assert!( - resolved_malloc_trim().is_some(), - "glibc malloc_trim must be available for the Linux relief path" - ); - } - - #[cfg(not(any(target_os = "macos", all(target_os = "linux", target_env = "gnu"))))] - #[test] - fn allocator_snapshot_is_honest_when_platform_counters_are_unavailable() { - let allocator = allocator_memory_snapshot(); - assert_eq!(allocator.status, "not_estimated_on_this_platform"); - assert_eq!(allocator.bytes_in_use, None); - assert_eq!(allocator.size_allocated, None); - assert_eq!(allocator.retained_slack_bytes, None); - assert_eq!( - allocator.not_estimated, - Some("platform_allocator_statistics_unavailable") - ); + assert_eq!(relief.allocator_before.status, "measured"); + assert_eq!(relief.allocator_after.status, "measured"); } - #[cfg(target_os = "macos")] #[test] #[ignore = "bounded live RSS experiment; run explicitly after allocator changes"] fn allocator_pressure_relief_warm_then_idle_measurement() { @@ -1012,5 +923,6 @@ mod tests { ); assert_eq!(relief.allocator_before.status, "measured"); assert_eq!(relief.allocator_after.status, "measured"); + assert!(relief.bytes_released > 0); } } diff --git a/crates/aft/src/subc/mod.rs b/crates/aft/src/subc/mod.rs index 034abd6f3..46484839d 100644 --- a/crates/aft/src/subc/mod.rs +++ b/crates/aft/src/subc/mod.rs @@ -3602,8 +3602,7 @@ where next_standing_pass_at = tokio::time::Instant::now() + standing::STANDING_MAINTENANCE_INTERVAL; } - // Scan and trim allocator arenas on a detached thread. On glibc, - // mallinfo2() walks every arena under allocator locks, so the + // Sample and collect mimalloc pages on a detached thread. The // transport thread must only evaluate the scan cadence here. #[cfg(any(target_os = "macos", target_os = "linux"))] { diff --git a/crates/aft/src/test_allocations.rs b/crates/aft/src/test_allocations.rs index 9b2035160..11abd9240 100644 --- a/crates/aft/src/test_allocations.rs +++ b/crates/aft/src/test_allocations.rs @@ -1,7 +1,8 @@ -use std::alloc::{GlobalAlloc, Layout, System}; +use mimalloc::MiMalloc; +use std::alloc::{GlobalAlloc, Layout}; use std::cell::Cell; -struct CountingAllocator; +struct CountingAllocator(MiMalloc); thread_local! { static COUNTING: Cell = const { Cell::new(false) }; @@ -11,21 +12,21 @@ thread_local! { unsafe impl GlobalAlloc for CountingAllocator { unsafe fn alloc(&self, layout: Layout) -> *mut u8 { record_allocation(); - unsafe { System.alloc(layout) } + unsafe { self.0.alloc(layout) } } unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { - unsafe { System.dealloc(ptr, layout) } + unsafe { self.0.dealloc(ptr, layout) } } unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { record_allocation(); - unsafe { System.realloc(ptr, layout, new_size) } + unsafe { self.0.realloc(ptr, layout, new_size) } } } #[global_allocator] -static GLOBAL: CountingAllocator = CountingAllocator; +static GLOBAL: CountingAllocator = CountingAllocator(MiMalloc); fn record_allocation() { if COUNTING.try_with(Cell::get).unwrap_or(false) { From 36f6c668683a13ceca224fe90328ade4b9767391 Mon Sep 17 00:00:00 2001 From: Naadir Jeewa Date: Mon, 31 Aug 2026 18:21:45 +0100 Subject: [PATCH 2/2] fix(memory): retain native allocator relief Signed-off-by: Naadir Jeewa Co-authored-by: alfonso-aft <289616620+alfonso-aft@users.noreply.github.com> --- ARCHITECTURE.md | 4 +- benchmarks/allocator-daemon/README.md | 84 +++++++++++++++++++++++++++ crates/aft/src/memory.rs | 82 +++++++++++++++++++++++++- crates/aft/src/subc/health.rs | 23 ++++++-- 4 files changed, 183 insertions(+), 10 deletions(-) create mode 100644 benchmarks/allocator-daemon/README.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index de242b794..01eac43f5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -362,8 +362,8 @@ **MemoryEstimate / MemorySnapshot:** - Purpose: Track, attribute, and report process-wide and subsystem-specific memory usage. - Location: `crates/aft/src/memory.rs` -- Pattern: Diagnostic structures and mimalloc collection hooks. -- Contains: Subsystem memory estimation helpers, SQLite allocator query bindings (`sqlite3_memory_used`), mimalloc committed/requested byte telemetry, platform-specific resident set size (RSS), and macOS kernel physical footprint (`phys_footprint_bytes` via `proc_pid_rusage RUSAGE_INFO_V4`) queries. Periodic allocator slack scans and forced collection run on the detached background-priority `aft-mem-relief` thread. Transport and stdin ticks only perform a cheap cadence check. +- Pattern: Diagnostic structures with dual-domain idle reclamation. +- Contains: Subsystem memory estimation helpers, SQLite allocator query bindings (`sqlite3_memory_used`), mimalloc committed/requested byte telemetry for Rust-owned heap allocations, platform-specific resident set size (RSS), and macOS kernel physical footprint (`phys_footprint_bytes` via `proc_pid_rusage RUSAGE_INFO_V4`) queries. Native libraries such as SQLite, tree-sitter, and ONNX Runtime can allocate through the platform allocator instead of Rust `GlobalAlloc`; mimalloc statistics therefore do not represent the full process. Idle relief runs `mi_collect(true)` plus the platform relief primitive (`malloc_trim(0)` on glibc or `malloc_zone_pressure_relief` on macOS) on the detached background-priority `aft-mem-relief` thread. Transport and stdin ticks only perform a cheap cadence check. The fleet health memory field names and byte units remain stable across allocator backends. **FleetStatusClient:** - Purpose: Publish AFT's project-scoped status segment to the fleet status-holder plane (`prefrontal-core`). diff --git a/benchmarks/allocator-daemon/README.md b/benchmarks/allocator-daemon/README.md new file mode 100644 index 000000000..2cdad4a8f --- /dev/null +++ b/benchmarks/allocator-daemon/README.md @@ -0,0 +1,84 @@ +# AFT allocator daemon benchmark + +Compare the parent system-allocator build with the mimalloc build under the same long-lived SubC daemon workload. + +This benchmark is an evidence protocol. It does not contain accepted allocator results. Record results only after both arms run on the same host with the same repository roots and configuration. + +## Coverage boundary + +The mimalloc arm installs mimalloc through Rust `GlobalAlloc`. Rust-owned heap allocations use mimalloc. Native libraries can still allocate through the platform allocator. This includes SQLite, tree-sitter, ONNX Runtime, and other C or C++ dependencies unless their build explicitly routes `malloc` through mimalloc. + +The idle relief pass therefore covers both domains: + +- `mi_collect(true)` releases unused mimalloc pages. +- `malloc_trim(0)` requests glibc native-heap relief on Linux. +- `malloc_zone_pressure_relief(NULL, 0)` requests native-zone relief on macOS. + +Process RSS, macOS physical footprint, SQLite bytes, and subsystem estimates remain independent checks. Mimalloc statistics do not represent the full process. + +## Required arms + +| Arm | Build | Purpose | +|---|---|---| +| `system` | Parent commit of the mimalloc change | Baseline platform allocator behavior | +| `mimalloc` | PR branch | Rust allocator change with dual-domain idle relief | + +Build both binaries from clean worktrees. Do not compare binaries with different AFT features or root-index code. + +## Required workload + +Use at least seven real Git roots. Include small, medium, and large roots. Use the same absolute root paths and selected search, semantic, and callgraph indexes for both arms. + +Run these phases in order: + +1. **Cold build**: Clear only AFT index storage. Start the isolated SubC daemon. Wait until every selected root artifact reaches a terminal state. +2. **Steady serving**: Issue a fixed reader corpus at a fixed rate while the daemon remains bound. Include read, grep, glob, outline, and callgraph queries. +3. **Idle eviction**: Close every route. Wait for the configured idle-root eviction boundary. Confirm that the daemon reports each root eviction. +4. **Post-relief idle**: Keep the daemon alive for at least two allocator scan intervals. Do not submit new work. + +Use an isolated connection file, config root, data root, and log root for each arm. Never point this benchmark at the production SubC daemon. + +## Sampling + +Sample at five-second intervals. Record these columns: + +```text +timestamp,arm,phase,pid,rss_bytes,phys_footprint_bytes,vm_swap_bytes,cpu_percent,thread_count,open_routes,live_actor_roots,allocator_slack_bytes,allocator_slack_measured,sqlite_bytes,total_attributed_bytes +``` + +Linux obtains RSS and swap from `/proc//status`. macOS obtains RSS and physical footprint from `proc_pidinfo` and `proc_pid_rusage`, matching AFT's `memory.rs` implementation. Obtain allocator, SQLite, root, and route values from the existing SubC health memory and runtime rollups. Keep field names and byte units unchanged. + +Capture these events with timestamps: + +- daemon ready +- each root artifact completion +- steady-serving start and stop +- each idle-root eviction +- each allocator pressure-relief log +- daemon shutdown + +## Controls + +- Use the same host without other build or indexing work. +- Run the arms in alternating order across at least three pairs. +- Reboot or allow the host to return to the same memory-pressure baseline before each pair. +- Keep power mode, CPU governor, semantic backend, model cache, and root revisions fixed. +- Preserve model downloads between arms. Clear generated AFT indexes between arms. +- Exclude a pair when either arm has a root failure, daemon restart, transport timeout, or changed Git revision. + +## Report + +Report each pair separately and then report the median difference. Include: + +- peak RSS during cold build +- peak macOS physical footprint during cold build +- p50 and p99 reader latency during steady serving +- artifact build completion time +- RSS and physical footprint immediately before eviction +- RSS and physical footprint after each relief pass +- final RSS, physical footprint, and swap after post-relief idle +- allocator slack, SQLite bytes, and attributed bytes at every phase boundary + +Do not use RSS alone on macOS. `MADV_FREE` can leave reclaimable pages visible in RSS after the allocator surrendered them. Physical footprint is the user-visible held-memory check for that platform. + +Do not claim that mimalloc reclaims native allocations from mimalloc statistics. Attribute a reduction to the combined relief pass unless a dedicated native-allocation experiment isolates the allocator domain. diff --git a/crates/aft/src/memory.rs b/crates/aft/src/memory.rs index 246a9e77c..4864d54a1 100644 --- a/crates/aft/src/memory.rs +++ b/crates/aft/src/memory.rs @@ -646,6 +646,67 @@ fn allocator_memory_snapshot() -> AllocatorMemorySnapshot { unsafe extern "C" { fn mi_collect(force: bool); } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AllocatorReliefCoverage { + pub mimalloc: bool, + pub platform_allocator: bool, +} + +pub const fn allocator_relief_coverage() -> AllocatorReliefCoverage { + AllocatorReliefCoverage { + mimalloc: true, + platform_allocator: cfg!(any( + target_os = "macos", + all(target_os = "linux", target_env = "gnu") + )), + } +} + +#[cfg(all(target_os = "linux", target_env = "gnu"))] +type MallocTrimFn = unsafe extern "C" fn(libc::size_t) -> libc::c_int; + +/// Resolve glibc's optional trimming primitive without a link-time dependency. +#[cfg(all(target_os = "linux", target_env = "gnu"))] +fn resolved_malloc_trim() -> Option { + use std::sync::OnceLock; + static MALLOC_TRIM: OnceLock> = OnceLock::new(); + MALLOC_TRIM + .get_or_init(|| { + let symbol = unsafe { libc::dlsym(libc::RTLD_DEFAULT, c"malloc_trim".as_ptr()) }; + if symbol.is_null() { + None + } else { + // SAFETY: glibc declares malloc_trim as `int (size_t)`. + Some(unsafe { std::mem::transmute::<*mut libc::c_void, MallocTrimFn>(symbol) }) + } + }) + .as_ref() + .copied() +} + +#[cfg(target_os = "macos")] +unsafe extern "C" { + fn malloc_zone_pressure_relief(zone: *mut libc::malloc_zone_t, goal: usize) -> usize; +} + +fn relieve_platform_allocator_pressure() -> u64 { + #[cfg(target_os = "macos")] + { + return usize_to_u64(unsafe { malloc_zone_pressure_relief(std::ptr::null_mut(), 0) }); + } + #[cfg(all(target_os = "linux", target_env = "gnu"))] + { + if let Some(malloc_trim) = resolved_malloc_trim() { + // SAFETY: resolved_malloc_trim validated the symbol's C ABI. + unsafe { malloc_trim(0) }; + } + return 0; + } + #[cfg(not(any(target_os = "macos", all(target_os = "linux", target_env = "gnu"))))] + { + 0 + } +} /// Allocator slack (mapped-but-unused arena bytes) above which opportunistic /// pressure relief is worth the zone-lock contention it briefly causes. @@ -706,13 +767,15 @@ pub fn spawn_allocator_slack_scan_if_due( .is_ok() } -/// Ask mimalloc to return all unused pages after a process-wide idle gate. -/// Callers own that gate because forced collection can add latency. +/// Ask both allocator domains to return unused pages after a process-wide idle +/// gate. Rust allocations use mimalloc. Native libraries can still allocate +/// through the platform allocator, so its relief primitive remains necessary. pub fn relieve_allocator_pressure() -> AllocatorPressureRelief { let rss_before_bytes = process_rss_bytes(); let allocator_before = allocator_memory_snapshot(); // SAFETY: `mi_collect` is provided by the linked mimalloc global allocator. unsafe { mi_collect(true) }; + let platform_released = relieve_platform_allocator_pressure(); let allocator_after = allocator_memory_snapshot(); let rss_after_bytes = process_rss_bytes(); let allocator_released = allocator_before @@ -724,7 +787,7 @@ pub fn relieve_allocator_pressure() -> AllocatorPressureRelief { .zip(rss_after_bytes) .map(|(before, after)| before.saturating_sub(after)) .unwrap_or(0); - let bytes_released = allocator_released.max(rss_released); + let bytes_released = allocator_released.max(platform_released).max(rss_released); AllocatorPressureRelief { bytes_released, rss_before_bytes, @@ -814,6 +877,19 @@ mod tests { assert!(snapshot.size_allocated.is_some()); assert!(snapshot.retained_slack_bytes.is_some()); } + #[test] + fn pressure_relief_covers_rust_and_native_allocators() { + let coverage = allocator_relief_coverage(); + assert!(coverage.mimalloc); + #[cfg(any(target_os = "macos", all(target_os = "linux", target_env = "gnu")))] + assert!(coverage.platform_allocator); + } + + #[cfg(all(target_os = "linux", target_env = "gnu"))] + #[test] + fn glibc_native_relief_is_runtime_resolved() { + assert!(resolved_malloc_trim().is_some()); + } #[test] fn slack_relief_requires_large_measured_slack() { diff --git a/crates/aft/src/subc/health.rs b/crates/aft/src/subc/health.rs index 28286b3b9..ccfaaae3e 100644 --- a/crates/aft/src/subc/health.rs +++ b/crates/aft/src/subc/health.rs @@ -1510,11 +1510,24 @@ mod tests { // No actors registered: ready rollup with zero roots and process totals. assert_eq!(memory.get("status").and_then(Value::as_str), Some("ready")); assert_eq!(memory.get("roots_total").and_then(Value::as_u64), Some(0)); - assert!(memory.get("total_attributed_bytes").is_some()); - assert!(memory.get("rss_bytes").is_some()); - assert!(memory - .get("allocator_slack_bytes") - .is_some_and(Value::is_u64)); + for key in [ + "total_attributed_bytes", + "sqlite_bytes", + "allocator_slack_bytes", + ] { + assert!( + memory.get(key).is_some_and(Value::is_u64), + "memory.{key} must remain an unsigned byte count" + ); + } + for key in ["rss_bytes", "phys_footprint_bytes"] { + assert!( + memory + .get(key) + .is_some_and(|value| value.is_u64() || value.is_null()), + "memory.{key} must remain an optional unsigned byte count" + ); + } assert!(memory .get("allocator_slack_measured") .is_some_and(Value::is_boolean));