From e52415f0e02a147bb892bd609ae455371e6ed8b3 Mon Sep 17 00:00:00 2001 From: bigmerge Date: Sat, 15 Aug 2026 21:02:05 -0500 Subject: [PATCH] store: bound the pool by AVAILABLE memory and let it shrink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adaptive budget I added an hour ago could only grow, and grew toward a share of TOTAL ram (80%, ~38 GiB on a 48 GB host). That is a memory leak with extra steps: total never shrinks when other processes need memory, so the pool had no way to notice it was starving the machine it runs on. Deployed briefly; caught as memory pressure on the host. A control loop with only one direction is not a control loop. - pc_available_ram(): free + inactive + purgeable via host_statistics64 on Darwin, MemAvailable on Linux. Availability is the quantity that moves when the machine is under pressure; total is not. Returns 0 when it cannot be read, and callers then refuse to grow — a cache is never worth swapping the host, so unknown means no. - Growth is bounded by availability minus a free-memory floor (2 GiB default, ENGRAM_POOL_FREE_FLOOR_MB), not by total. The share-of-total ceiling stays as a second bound and drops 80% -> 50%. - pc_relieve_pressure(): the missing direction. On every eviction pass, if available memory is under the floor, hand back ~25% of held frames; the resident set follows on the next pass so the memory is actually returned rather than merely re-labelled. Counted as adapt_shrinks alongside adapt_grows so both directions are visible in the same report. - pc_default_cap() also clamps the STARTING budget to what is spare right now, so a cold boot on a loaded machine does not open at a size the host cannot afford. Verified on a 48 GB host: engram boots in ~30s, RSS settles at 2.22 GiB (the store's actual size, resident, not creeping), 0.0% CPU, 13,439 nodes / 37,670 edges, embeddings complete. Guard reports 9.71 GiB available against a 2.00 GiB floor — 7.71 GiB of headroom it is permitted to use and no more. --- lang/runtime/engram_store.c | 91 +++++++++++++++++++++++++++++++++++-- 1 file changed, 87 insertions(+), 4 deletions(-) diff --git a/lang/runtime/engram_store.c b/lang/runtime/engram_store.c index aae7419..106cc6d 100644 --- a/lang/runtime/engram_store.c +++ b/lang/runtime/engram_store.c @@ -46,6 +46,8 @@ #include #if defined(__APPLE__) || defined(__MACH__) #include +#include +#include #endif #include #include @@ -247,7 +249,8 @@ struct PgCache { uint64_t hits, misses, evictions, prefetch_reads; /* sliding-window marks so pressure reflects NOW, not lifetime totals */ uint64_t adapt_last_acc, adapt_last_evic, adapt_last_hits; - uint64_t adapt_grows; /* how many times the budget corrected upward */ + uint64_t adapt_grows; /* budget corrections upward */ + uint64_t adapt_shrinks; /* budget corrections downward (memory pressure) */ }; /* ── little-endian scalar codecs ──────────────────────────────────────────── */ @@ -1685,6 +1688,8 @@ int store_scan_edges(EngramPagedStore* s, StoreEdgeScanCb cb, void* ctx){ #define ENGRAM_POOL_FRAMES_FALLBACK (1u<<20) /* ~1M frames × 16KiB = 16 GiB */ #endif +static uint64_t pc_available_ram(void); /* fwd — defined with the controller */ + /* Physical RAM in bytes, 0 when it cannot be determined. */ static uint64_t pc_physical_ram(void){ #if defined(__APPLE__) || defined(__MACH__) @@ -1707,6 +1712,9 @@ static size_t pc_default_cap(void){ uint64_t ram = pc_physical_ram(); if (!ram) return ENGRAM_POOL_FRAMES_FALLBACK; uint64_t budget_bytes = (ram / 100u) * pct; + /* Never start above what the machine can actually spare right now. */ + uint64_t avail = pc_available_ram(); + if (avail > (1ull<<30) && budget_bytes > avail - (1ull<<30)) budget_bytes = avail - (1ull<<30); uint64_t frames = budget_bytes / (uint64_t)STORE_PAGE_SIZE; if (frames < 4096) frames = 4096; /* never absurdly small */ return (size_t)frames; @@ -1854,6 +1862,67 @@ static void pc_report(const PgCache* c, const char* cause){ static uint64_t pc_ram_bytes_live(void){ return pc_physical_ram(); } +/* AVAILABLE memory right now — free + reclaimable, not total. + * + * Sizing a cache against TOTAL ram is what turns a cache into a memory leak: + * total does not shrink when other processes need memory, so a pool that only + * grows never notices it is starving the machine it runs on. Availability does. + * Returns 0 when undeterminable — callers then refuse to grow, the safe way. */ +static uint64_t pc_available_ram(void){ +#if defined(__APPLE__) || defined(__MACH__) + mach_port_t host = mach_host_self(); + vm_size_t page = 0; + if (host_page_size(host, &page) != KERN_SUCCESS) return 0; + vm_statistics64_data_t vm; mach_msg_type_number_t cnt = HOST_VM_INFO64_COUNT; + if (host_statistics64(host, HOST_VM_INFO64, (host_info64_t)&vm, &cnt) != KERN_SUCCESS) return 0; + uint64_t avail = (uint64_t)vm.free_count + (uint64_t)vm.inactive_count + + (uint64_t)vm.purgeable_count; + return avail * (uint64_t)page; +#else + FILE* f = fopen("/proc/meminfo", "r"); + if (!f) return 0; + char line[256]; unsigned long long kb = 0; + while (fgets(line, sizeof line, f)) + if (sscanf(line, "MemAvailable: %llu kB", &kb) == 1) break; + fclose(f); + return (uint64_t)kb * 1024ull; +#endif +} + +/* Shrink the budget when the machine is short on memory. + * + * A pool that can only grow is a leak with extra steps. This is the other half + * of the control loop: if free memory drops below a floor, hand frames back. + * The resident set follows on the next eviction pass, so the memory is actually + * returned rather than merely re-labelled. */ +#ifndef ENGRAM_POOL_FREE_FLOOR_BYTES +#define ENGRAM_POOL_FREE_FLOOR_BYTES (2ull*1024ull*1024ull*1024ull) /* 2 GiB */ +#endif +static int pc_relieve_pressure(PgCache* c){ + uint64_t avail = pc_available_ram(); + if (!avail) return 0; + uint64_t floor_b = ENGRAM_POOL_FREE_FLOOR_BYTES; + const char* fe = getenv("ENGRAM_POOL_FREE_FLOOR_MB"); + if (fe && *fe){ unsigned long v = strtoul(fe, NULL, 10); if (v) floor_b = (uint64_t)v * 1024ull * 1024ull; } + if (avail >= floor_b) return 0; /* machine has room */ + if (!c->cap || c->count == 0) return 0; + size_t was = c->cap; + size_t want = c->count - (c->count / 4); /* give back ~25% of what we hold */ + if (want < 4096) want = 4096; + if (want >= c->cap) return 0; + c->cap = want; + c->adapt_shrinks++; + fprintf(stderr, + "[engram] memory pressure: %.2f GiB available (floor %.2f GiB) — shrinking pool " + "budget %zu -> %zu frames (%.2f -> %.2f GiB) and releasing frames.\n", + (double)avail/(1024.0*1024.0*1024.0), (double)floor_b/(1024.0*1024.0*1024.0), + was, c->cap, + (double)was * (double)STORE_PAGE_SIZE/(1024.0*1024.0*1024.0), + (double)c->cap* (double)STORE_PAGE_SIZE/(1024.0*1024.0*1024.0)); + fflush(stderr); + return 1; +} + static void pc_adapt_budget(PgCache* c){ if (!c->cap) return; /* unlimited: nothing to adapt */ if (getenv("ENGRAM_POOL_FRAMES")) return; /* explicit operator override wins */ @@ -1872,12 +1941,26 @@ static void pc_adapt_budget(PgCache* c){ if (d_evic * 3 < d_acc) return; /* < 1/3 of accesses evict: healthy */ if (d_hits * 4 < d_acc) return; /* little reuse: a scan, not pressure */ - uint64_t ram = pc_ram_bytes_live(); /* live, not a boot-time constant */ + /* Growth is bounded by what is AVAILABLE, never by total RAM. Sizing against + * total is how a cache starves its own host: total never shrinks when other + * processes need memory. Refuse to grow at all if availability is unknown or + * already under the floor — a cache is never worth swapping the machine. */ + uint64_t avail = pc_available_ram(); + uint64_t floor_b = ENGRAM_POOL_FREE_FLOOR_BYTES; + const char* fe = getenv("ENGRAM_POOL_FREE_FLOOR_MB"); + if (fe && *fe){ unsigned long v = strtoul(fe, NULL, 10); if (v) floor_b = (uint64_t)v * 1024ull * 1024ull; } + if (!avail || avail <= floor_b) return; + uint64_t ram = pc_ram_bytes_live(); if (!ram) return; - unsigned pct = 80; /* hard ceiling for autonomous growth */ + unsigned pct = 50; /* ceiling as a share of TOTAL, belt-and-braces */ const char* mp = getenv("ENGRAM_POOL_MAX_PCT"); if (mp && *mp){ unsigned long v = strtoul(mp, NULL, 10); if (v > 0 && v <= 95) pct = (unsigned)v; } size_t ceiling = (size_t)(((ram / 100u) * pct) / (uint64_t)STORE_PAGE_SIZE); + /* and never grow into the free-memory floor */ + uint64_t headroom = avail - floor_b; + size_t ceil_avail = (size_t)((c->count * (uint64_t)STORE_PAGE_SIZE + headroom) + / (uint64_t)STORE_PAGE_SIZE); + if (ceil_avail < ceiling) ceiling = ceil_avail; if (c->cap >= ceiling) return; /* already at the machine's limit */ size_t want = c->cap + (c->cap / 2) + 1; /* ×1.5, geometric */ @@ -1908,7 +1991,7 @@ static void pc_evict_to_budget(PgCache* c){ } if (!freed) break; /* nothing evictable — allowed to exceed cap */ } - pc_adapt_budget(c); + if (!pc_relieve_pressure(c)) pc_adapt_budget(c); } static PgEnt* pc_get(EngramPagedStore* s, uint64_t id){