mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-11 09:25:35 +08:00
Spill mode was entered only DURING extraction, when the charge crossed
budget - budget/16, and the final sweep at extraction end ran only if that
latch had already fired. A run that ended extraction just under the latch
kept every result in memory, and the phases that cannot spill -- registry
build, cross-LSP prepare (all_defs, per-language registries, surface rows,
module index) and resolve (edges into the graph buffer, cross-LSP appends
into the cached results) -- then grew the process well past the budget.
openclaw at 8 workers with a 4079 MB budget: 3678 MB charged at extraction
end, 4076 after registry build, 4933 after cross-LSP prepare, 5829 in
resolve (+43 %). The same repo at 18 workers latched during extraction and
stayed under budget.
Extraction end now projects the post-extraction growth from the result
counts (defs, calls + usages, files) and enters spill mode when charged +
growth would cross the latch, so the existing final sweep parks every
cached result before registry build. The per-unit costs (1280 B/def,
160 B/ref, 8 KiB/file) are fitted on the measured charge growth from the
parallel_extract to the parallel_resolve mark on five local corpora
(go, django, kotlin, rust, php) and cover openclaw; the model under-reads by
at most 7 % (inside the budget/16 margin) and over-reads by up to 46 %, the
safe side: a spill never changes graph content. The decision is a pure
function of counts (no RSS-window sampling) and is logged as
mem.post_extract.projection.
A charge test seam (cbm_mem_set_charged_for_tests) pins cbm_mem_charged(),
which otherwise reads the process footprint, so the "ended just under the
latch" case is testable deterministically.
Proof (release build, M5 Pro, CBM_WORKERS=8, peak charged = the budget
metric's high-water mark):
go, CBM_MEM_BUDGET_MB=2000 before: extract 1348 -> registry 1497 ->
lsp_prepare 2168 -> resolve 2660 MB (+33 % over budget, no spill)
after: projection charged 1335 + growth 1909 > line 1875 -> spill,
21,882 results parked; extract 413 -> registry 558 -> lsp_prepare 1151
-> resolve 1297, semantic 1746 MB peak: every phase under budget.
Node/edge dumps byte-identical (335,191 nodes / 1,813,857 edges); wall
29.2 s vs 29.0 s.
go, 40000 MB (generous): decision=keep, no spill, identical dumps, wall
27.4 s vs 27.5 s.
django, 400 MB: resolve mark 455 -> 277 MB after the spill, dumps
identical; the remaining peak (476 MB) is the semantic pass's retained
set on top of the graph floor, which this change does not address.
Refs #2184
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
369 lines
19 KiB
C
369 lines
19 KiB
C
/*
|
|
* mem.h — Unified memory management via mimalloc.
|
|
*
|
|
* Provides budget tracking based on actual RSS (not partial vmem tracking).
|
|
* Uses mi_process_info() as the single source of truth for memory pressure.
|
|
* Replaces the old vmem.h budget-tracked virtual memory allocator.
|
|
*/
|
|
#ifndef CBM_MEM_H
|
|
#define CBM_MEM_H
|
|
|
|
#include <stdbool.h>
|
|
#include <stddef.h>
|
|
|
|
/* Tiered default fraction for MCP startup: 25% on <=16GB, 35% on <=32GB, else 50%. */
|
|
double cbm_mem_ram_fraction_for_total(size_t total_ram_bytes);
|
|
|
|
/* Initialize memory budget = ram_fraction * total_physical_ram.
|
|
* The CBM_MEM_BUDGET_MB env var, when set to a positive integer, overrides
|
|
* this with an explicit budget in MiB (clamped to physical/cgroup RAM).
|
|
* Thread-safe: only the first call takes effect.
|
|
* Configures mimalloc options for reduced upfront memory. */
|
|
void cbm_mem_init(double ram_fraction);
|
|
|
|
/* Worker-only initialization cap carried on the build-bound internal argv.
|
|
* The existing user override is still resolved first; a lower explicit
|
|
* CBM_MEM_BUDGET_MB wins, while a larger/default budget is capped. */
|
|
void cbm_mem_init_with_cap(double ram_fraction, size_t hard_cap_bytes);
|
|
|
|
/* Result of cbm_mem_resolve_budget: the resolved budget plus the metadata
|
|
* cbm_mem_init logs — so the parse/clamp logic lives in exactly ONE place and
|
|
* the caller never re-parses the env string. */
|
|
typedef struct {
|
|
size_t budget; /* resolved budget in bytes */
|
|
const char *source; /* "ram_fraction" | "CBM_MEM_BUDGET_MB" | "daemon_worker_cap" */
|
|
bool clamped; /* override was valid but exceeded total_ram → clamped down */
|
|
bool invalid; /* override was present but unparseable / out-of-range / ≤0 */
|
|
bool hard_capped; /* internal worker hard cap reduced the resolved budget */
|
|
} cbm_mem_budget_t;
|
|
|
|
/* Pure budget resolver shared by cbm_mem_init (exposed for testing).
|
|
* Returns ram_fraction * total_ram, unless `budget_mb` is a STRICTLY valid
|
|
* positive integer string (the CBM_MEM_BUDGET_MB override) — then it returns
|
|
* that many MiB, clamped to total_ram when total_ram > 0. Trailing garbage,
|
|
* overflow (ERANGE), and non-positive values are rejected (invalid=true) and
|
|
* fall back to the fraction-derived value. Reads no globals/env. */
|
|
cbm_mem_budget_t cbm_mem_resolve_budget(size_t total_ram, double ram_fraction,
|
|
const char *budget_mb);
|
|
|
|
/* Pure capped variant used by supervised workers and deterministic tests. */
|
|
cbm_mem_budget_t cbm_mem_resolve_budget_capped(size_t total_ram, double ram_fraction,
|
|
const char *budget_mb, size_t hard_cap_bytes);
|
|
|
|
/* Reduce a budget derived from TOTAL ram to something the machine can actually
|
|
* spare right now, keeping a headroom reserve for the OS and other processes.
|
|
* `available` of 0 means the platform could not answer and the budget stands.
|
|
* Pure, so the policy is testable without a machine in a particular state. */
|
|
size_t cbm_mem_clamp_to_available(size_t budget, size_t available);
|
|
|
|
/* Current RSS in bytes via mi_process_info().
|
|
* Falls back to OS-specific queries when MI_OVERRIDE=0 (ASan builds). */
|
|
size_t cbm_mem_rss(void);
|
|
|
|
/* Peak RSS in bytes. */
|
|
size_t cbm_mem_peak_rss(void);
|
|
/* High-water mark of cbm_mem_charged() as seen by its callers (the extract
|
|
* gate reads it per file pull, the phase marks per pass): the peak of the
|
|
* budget metric itself, next to the RSS peak that counts reclaimable pages. */
|
|
size_t cbm_mem_peak_charged(void);
|
|
|
|
/* The number the budget is enforced against: what the OS charges this
|
|
* process. On macOS that is phys_footprint -- resident_size keeps every page
|
|
* mimalloc has purged until the kernel reclaims it, and the kernel proof
|
|
* (2026-09-13) had the worker at 17.4 GB RSS with 5.5 GB charged after
|
|
* extraction. Elsewhere the two are the same number. Falls back to RSS when
|
|
* the footprint is unavailable. */
|
|
size_t cbm_mem_charged(void);
|
|
|
|
/* Bytes mimalloc currently has committed (its own counter). With the core
|
|
* backed by mimalloc this is the allocator-level truth: tracked live bytes
|
|
* plus fragmentation and metadata, minus nothing the OS may or may not have
|
|
* reclaimed yet. 0 when unavailable. */
|
|
size_t cbm_mem_allocator_committed(void);
|
|
|
|
/* Total budget in bytes. */
|
|
size_t cbm_mem_budget(void);
|
|
|
|
/* TEST HOOK: overwrite the budget directly, bypassing cbm_mem_init's
|
|
* init-once guard (a setenv+re-init dance in tests is a silent no-op once
|
|
* some earlier init won the guard — the poisoned budget then leaks into
|
|
* every later budget consumer in the process). Does NOT flip the init
|
|
* guard: a later cbm_mem_init still initializes normally. Callers must
|
|
* save cbm_mem_budget() first and restore it before their assertions.
|
|
* Never call from production code. */
|
|
void cbm_mem_set_budget_for_tests(size_t bytes);
|
|
|
|
/* TEST HOOK: pin cbm_mem_charged() (and so cbm_mem_over_budget()) to `bytes`;
|
|
* 0 restores the live reading. The charge is the process footprint, which a
|
|
* test cannot steer, so a gate keyed to it ("extraction ended just under the
|
|
* spill latch", #2184) is otherwise untestable. A pinned reading does not move
|
|
* cbm_mem_peak_charged(). Callers restore 0 before their assertions. Never call
|
|
* from production code. */
|
|
void cbm_mem_set_charged_for_tests(size_t bytes);
|
|
|
|
/* Returns true if current RSS exceeds the budget. */
|
|
bool cbm_mem_over_budget(void);
|
|
|
|
/* True when memory should be handed back NOW: our budget is exceeded, or the
|
|
* machine itself is short. Use this for spill/reclaim decisions; use
|
|
* cbm_mem_over_budget() for the decision to stop a run. System pressure must
|
|
* relieve, never abort — the spike may not even be ours. */
|
|
bool cbm_mem_should_relieve(void);
|
|
|
|
/* Per-worker budget hint: budget / num_workers. */
|
|
size_t cbm_mem_worker_budget(int num_workers);
|
|
|
|
/* True only when the SYSTEM is genuinely short of memory, not merely when this
|
|
* process is over its advisory budget.
|
|
*
|
|
* The budget is a static fraction of TOTAL ram, so on a large host it refuses
|
|
* work the machine can plainly do: measured 2026-09-13, the linux kernel needs
|
|
* 31.75 GB of a 48 GB host against a 24 GB budget, and the previous release
|
|
* completed the very same index by overshooting to 33.56 GB. Aborting on the
|
|
* budget alone therefore turned a working index into a refusal.
|
|
*
|
|
* So the budget keeps its job as the BACKPRESSURE trigger (workers park, peers
|
|
* return transients) and this answers the different question of whether giving
|
|
* up is warranted. Returns false when availability is unknown: never abort on
|
|
* a guess. */
|
|
bool cbm_mem_system_under_pressure(void);
|
|
|
|
/* Return unused pages to the OS. Call between files to bound per-file peak. */
|
|
void cbm_mem_collect(void);
|
|
|
|
/* What the OS charges this process for memory-pressure purposes. On macOS
|
|
* that is phys_footprint, which EXCLUDES pages the allocator has already
|
|
* marked reusable (MADV_FREE_REUSABLE) -- resident_size still counts them, so
|
|
* after a large free the two can differ by gigabytes. Elsewhere equals RSS.
|
|
* 0 when unavailable. */
|
|
size_t cbm_mem_footprint(void);
|
|
|
|
/* Hand freed memory back to the OS on every allocator this process uses:
|
|
* mimalloc (mi_collect) and, on macOS, the system zones
|
|
* (malloc_zone_pressure_relief). Never glibc's malloc_trim: on Linux mimalloc
|
|
* owns malloc, and the reference alone breaks the static release link. Costs
|
|
* a few ms; call at phase boundaries after a bulk release, never per
|
|
* allocation. */
|
|
void cbm_mem_release_to_os(void);
|
|
|
|
/* ── Memory map: where does the process's memory actually live? ──────
|
|
*
|
|
* A growth diagnostic that is honest about what it cannot see. Walking the
|
|
* allocator reports live bytes for the CALLING thread's heap only, so a leak
|
|
* on another thread — or memory never routed through the allocator — would be
|
|
* invisible to a naive per-subsystem tally. Every consumer therefore gets
|
|
* three independent totals plus an explicit residual, so an incomplete map
|
|
* announces itself instead of looking balanced:
|
|
*
|
|
* os_committed_bytes what the OS charges the process
|
|
* live_bytes allocator blocks live on THIS thread's heap
|
|
* residual os_committed - live_bytes (other threads, allocator
|
|
* retention, or non-allocator memory)
|
|
*
|
|
* Reading the triple localises growth without guessing:
|
|
* live grows → leak on this thread's heap; buckets say which size
|
|
* residual grows, live flat → another thread's heap, retention, or OS/CRT memory
|
|
* both flat but RSS grows → mapped files or stacks, not the heap
|
|
*
|
|
* The bucket histogram splits live bytes by size class, so a distinctive class
|
|
* ("thousands of 384-byte blocks appeared") identifies the allocation without
|
|
* per-callsite instrumentation.
|
|
*/
|
|
enum { CBM_MEM_MAP_BUCKETS = 8 };
|
|
|
|
typedef struct {
|
|
/* Is plain malloc actually served by the configured allocator on this
|
|
* build? If false, every allocator tuning in cbm_mem_init (immediate
|
|
* purge, page reclaim, arena policy) is inert for ordinary allocations,
|
|
* the walk below sees nothing, and freed pages stay committed until the
|
|
* platform's own heap decides otherwise. Checked FIRST when reading a
|
|
* map: a false here explains a zero live_bytes, and is a platform-parity
|
|
* defect in its own right (#581 on Windows). */
|
|
bool malloc_is_allocator_owned;
|
|
size_t os_committed_bytes;
|
|
size_t os_rss_bytes;
|
|
size_t live_bytes;
|
|
size_t live_blocks;
|
|
size_t area_committed_bytes;
|
|
size_t area_reserved_bytes;
|
|
/* Live bytes by size class: <=64, <=256, <=1K, <=4K, <=16K, <=64K, <=1M, rest */
|
|
size_t bucket_bytes[CBM_MEM_MAP_BUCKETS];
|
|
size_t bucket_blocks[CBM_MEM_MAP_BUCKETS];
|
|
} cbm_mem_map_t;
|
|
|
|
/* Inclusive upper bound of each size class; the last bucket is open-ended and
|
|
* reports 0. */
|
|
size_t cbm_mem_map_bucket_limit(int bucket);
|
|
|
|
/* Snapshot the current memory map. Returns false only when out is NULL. If the
|
|
* allocator declines the walk, the OS totals are still filled and live_bytes
|
|
* stays 0, so the residual carries the whole process and the caller can SEE
|
|
* that the walk contributed nothing rather than reading 0 as "no leak". */
|
|
bool cbm_mem_map_collect(cbm_mem_map_t *out);
|
|
|
|
/* OS totals only: fills os_rss_bytes / os_committed_bytes and leaves the
|
|
* allocator walk out entirely, so live_bytes stays 0 and the residual carries
|
|
* the whole process.
|
|
*
|
|
* Required for anything running on a background thread. Reading the
|
|
* allocator's per-thread bookkeeping (even just mi_theap_get_default) races a
|
|
* thread exiting concurrently, which rewrites it from _mi_thread_done ->
|
|
* _mi_theap_default_set; TSan reports that pairing directly. The walk is only
|
|
* safe where no other thread can be exiting -- tests and explicitly requested
|
|
* diagnostics -- so the periodic snapshot uses this instead. */
|
|
bool cbm_mem_map_collect_os(cbm_mem_map_t *out);
|
|
|
|
/* ── Allocator ownership audit ───────────────────────────────────────
|
|
*
|
|
* "Does the allocator own our memory" is not one question, it is one per size
|
|
* class. An override can capture small allocations while large or huge ones go
|
|
* elsewhere, and the predicate used to route frees can itself be wrong for a
|
|
* class — which silently hands real allocator blocks to the wrong deallocator.
|
|
* That is a leak at best and undefined behaviour at worst, so it has to be
|
|
* measured across the size range rather than probed once.
|
|
*
|
|
* Verified at startup and assertable in tests, so an unowned class is a loud
|
|
* failure instead of a slow ratchet nobody attributes for months (#581).
|
|
*/
|
|
enum { CBM_MEM_OWNERSHIP_CLASSES = 6 };
|
|
|
|
typedef struct {
|
|
size_t probe_bytes[CBM_MEM_OWNERSHIP_CLASSES]; /* size probed per class */
|
|
bool owned[CBM_MEM_OWNERSHIP_CLASSES]; /* predicate said "ours" */
|
|
bool allocated[CBM_MEM_OWNERSHIP_CLASSES]; /* the probe itself worked */
|
|
int owned_count;
|
|
int probed_count;
|
|
bool all_owned; /* every successfully probed class came back owned */
|
|
} cbm_mem_ownership_audit_t;
|
|
|
|
/* Allocate one block per size class, ask the routing predicate whether the
|
|
* allocator owns it, then free it. Pure measurement: mutates no globals. */
|
|
void cbm_mem_audit_ownership(cbm_mem_ownership_audit_t *out);
|
|
|
|
/* Foreign-pointer accounting from the allocation interposer: how many pointers
|
|
* reaching our deallocators were classified as NOT allocator-owned. On a
|
|
* correctly wired build this stays at or near zero. A climbing count means
|
|
* either an un-intercepted allocation path or — as seen while landing the
|
|
* Windows interposer — a predicate misclassifying real allocator blocks.
|
|
* Either way it is a defect signal, which is why soak/smoke can gate on it.
|
|
* Both read zero on platforms with no interposer. */
|
|
size_t cbm_mem_foreign_pointer_count(void);
|
|
size_t cbm_mem_owned_pointer_count(void);
|
|
|
|
/* Interposer hook: classify one pointer arriving at a deallocator. */
|
|
void cbm_mem_record_pointer_ownership(bool owned);
|
|
|
|
/* ── Phase map: WHICH code path does the memory stay in? ─────────────
|
|
*
|
|
* The allocator walk answers "how much and what size"; it cannot answer "who".
|
|
* When the growth does not route through the allocator at all — the Windows
|
|
* case in #581 — attribution has to come from the one metric that does see it:
|
|
* OS committed bytes. Marks bracket a critical path, and each mark attributes
|
|
* the committed-bytes delta since the previous mark to the previous label. Over
|
|
* many requests the label whose total climbs monotonically is where memory
|
|
* stays.
|
|
*
|
|
* Deliberately coarse and honest about it:
|
|
* - OS committed is process-wide, so a concurrently-busy thread lands noise
|
|
* in whichever label is open. Signal comes from ACCUMULATION over many
|
|
* passes, never from a single delta.
|
|
* - Marks must bracket the WHOLE path with no unlabelled gaps, or the leak
|
|
* hides in the gap. Label the tail explicitly.
|
|
* - Off unless CBM_MEM_PHASES=1, so the hot path pays one atomic load.
|
|
*/
|
|
void cbm_mem_phase_mark(const char *label);
|
|
|
|
/* Drop all accumulated phase totals (call once at the start of a measurement). */
|
|
void cbm_mem_phase_reset(void);
|
|
|
|
/* True when CBM_MEM_PHASES=1 turned phase attribution on for this process.
|
|
* Instruments that walk large structures (the result census) gate on it. */
|
|
bool cbm_mem_phases_enabled(void);
|
|
|
|
/* CBM_MEM_ALLOCATOR_STATS=1: write the allocator's own reserved / committed /
|
|
* purged table into the log at `tag`. The worker leaves through _Exit, so
|
|
* mimalloc's at-exit statistics never appear; this is the way to see them.
|
|
* Inert unless the variable is set. */
|
|
void cbm_mem_allocator_stats_log(const char *tag);
|
|
|
|
/* Write the phase table as a JSON array of {label, bytes, hits}, biggest total
|
|
* first. Returns bytes written (0 when disabled or empty). */
|
|
int cbm_mem_phase_report_json(char *out, size_t size);
|
|
|
|
/* ── Windows region census: which POOL holds the memory? ─────────────
|
|
*
|
|
* The allocator walk can only describe memory the allocator owns. When the
|
|
* process commits far more than the allocator holds — 440 MB private against
|
|
* 34.7 MiB of arenas (#581) — the remainder is by definition somewhere else,
|
|
* and "somewhere else" has to be named before it can be fixed. Guessing
|
|
* between the CRT heap, private VirtualAlloc, mapped files and thread stacks
|
|
* costs a soak run per guess; asking the OS costs one pass.
|
|
*
|
|
* Two independent walks, deliberately overlapping so they cross-check:
|
|
* VirtualQuery over the whole address space → committed bytes by region TYPE
|
|
* GetProcessHeaps + HeapWalk → what the CRT heaps hold
|
|
*
|
|
* committed_private is the total the OS charges us for anonymous memory; the
|
|
* allocator's arenas and the CRT heaps are both drawn from it, so
|
|
* committed_private - crt_heap_committed - <arena committed>
|
|
* is the unattributed remainder. A census where that remainder is the part
|
|
* that grows says the leak is in neither heap — which is a different bug from
|
|
* either heap leaking, and points at raw VirtualAlloc or stacks instead.
|
|
*
|
|
* Windows-only; returns false everywhere else so callers must handle absence
|
|
* rather than silently reading zeros as "nothing there".
|
|
*/
|
|
enum { CBM_MEM_TRACKED_REGIONS = 6 };
|
|
|
|
typedef struct {
|
|
size_t committed_total; /* all MEM_COMMIT regions */
|
|
size_t committed_private; /* MEM_PRIVATE: heaps, stacks, raw VirtualAlloc */
|
|
size_t committed_mapped; /* MEM_MAPPED: file/section views */
|
|
size_t committed_image; /* MEM_IMAGE: loaded modules */
|
|
size_t reserved_total; /* MEM_RESERVE, uncommitted */
|
|
size_t crt_heap_committed; /* committed bytes across all CRT heaps */
|
|
size_t crt_heap_busy; /* bytes in live allocations inside them */
|
|
unsigned crt_heap_count;
|
|
unsigned regions;
|
|
/* The single largest committed private region, and how many are >=1MiB.
|
|
* One enormous region means one reservation (allocator metadata, a pagemap)
|
|
* rather than many leaked blocks — a different bug with a different fix. */
|
|
/* Private commit split by region size. Growth invariant to every allocator
|
|
* option means the memory is not the allocator's; the size profile of what
|
|
* accumulates says what it IS — a swarm of small commits, mid-sized blocks,
|
|
* or thread-stack-sized reservations. */
|
|
size_t private_small_bytes; /* regions < 64 KiB */
|
|
size_t private_medium_bytes; /* 64 KiB .. 1 MiB */
|
|
size_t private_large_bytes; /* >= 1 MiB */
|
|
unsigned private_small_count;
|
|
unsigned private_medium_count;
|
|
unsigned private_large_count;
|
|
/* Identity, not just size: base and extent of the largest private regions,
|
|
* so they can be matched against the allocator's arena addresses instead of
|
|
* guessed at by toggling options one at a time. */
|
|
void *large_base[CBM_MEM_TRACKED_REGIONS];
|
|
size_t large_size[CBM_MEM_TRACKED_REGIONS];
|
|
unsigned large_tracked;
|
|
size_t largest_private_region;
|
|
unsigned private_regions_1mb;
|
|
/* Base of that region: a base that stays put while the size climbs proves
|
|
* ONE allocation growing, and lets it be matched against the allocator's
|
|
* arena addresses to name the owner. */
|
|
void *largest_private_base;
|
|
bool heap_walk_ok; /* false → crt_* are unknown, not zero */
|
|
} cbm_mem_win_census_t;
|
|
|
|
/* Snapshot the OS-level region census. Returns false when unavailable
|
|
* (non-Windows, or out is NULL). */
|
|
bool cbm_mem_win_census(cbm_mem_win_census_t *out);
|
|
|
|
/* Emit one census line tagged with the call site, so a growth series can be
|
|
* read straight out of the daemon log. No-op unless CBM_MEM_CENSUS=1. */
|
|
void cbm_mem_census_log(const char *tag);
|
|
|
|
/* Census logging gate: CBM_MEM_CENSUS=1. Off by default — HeapWalk locks each
|
|
* CRT heap, so this is a diagnostic, never a hot-path metric. */
|
|
bool cbm_mem_census_enabled(void);
|
|
|
|
#endif /* CBM_MEM_H */
|