diag: report whether the allocator owns this build's allocations

Adds mem_malloc_owned to every diagnostics snapshot: a real malloc/free pair
probed with mi_is_in_heap_region rather than an inference from build flags,
because the Windows static-CRT override is defined at compile time yet can
fail to take effect at link time -- which is exactly what happens here.

This single bit distinguishes 'nothing is live' from 'the allocator does not
own this build', which are opposite conclusions drawn from the same zero
live_bytes. On Windows it reads false, which means every allocator tuning in
cbm_mem_init -- immediate purge, purge-decommits, page reclaim, arena policy
-- is inert for ordinary allocations there, while POSIX gets all of it. That
is the parity gap behind #581, and reading this bit first would have replaced
a long elimination hunt with one measurement.

Also reverts the interim _heapmin trim explored while localising #581. It cut
the ten-minute Windows soak from 1168 MB to 232 MB but never reached the
13-to-15 MB flatness POSIX achieves, and it cost roughly four times the query
latency because it walks the whole heap on the request path. Trimming a heap
the allocator should have owned treats the symptom; the override itself is
the fix.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-07-25 14:20:32 +02:00
parent d8443bcc0d
commit 6cefa40cd9
4 changed files with 28 additions and 86 deletions
+28 -26
View File
@@ -475,32 +475,34 @@ static void write_diagnostics(void) {
}
char snapshot[CBM_SZ_4K];
int length = snprintf(snapshot, sizeof(snapshot),
"{\n"
" \"uptime_s\": %ld,\n"
" \"rss_bytes\": %zu,\n"
" \"peak_rss_bytes\": %zu,\n"
" \"heap_committed_bytes\": %zu,\n"
" \"peak_committed_bytes\": %zu,\n"
" \"page_faults\": %zu,\n"
" \"fd_count\": %d,\n"
" \"query_count\": %d,\n"
" \"query_errors\": %d,\n"
" \"query_total_us\": %lld,\n"
" \"query_avg_us\": %lld,\n"
" \"query_max_us\": %lld,\n"
" \"mem_malloc_owned\": %s,\n"
" \"mem_live_bytes\": %zu,\n"
" \"mem_live_blocks\": %zu,\n"
" \"mem_area_committed_bytes\": %zu,\n"
" \"mem_residual_bytes\": %zu,\n"
" \"mem_buckets\": [%s],\n"
" \"mem_phases\": [%s],\n"
" \"pid\": %d\n"
"}\n",
uptime, current_rss, peak_rss, current_commit, peak_commit, page_faults,
fds, qcount, qerrors, qtime, qavg, qmax, map.malloc_is_allocator_owned ? "true" : "false", map.live_bytes, map.live_blocks,
map.area_committed_bytes, residual, buckets, phases, (int)getpid());
int length =
snprintf(snapshot, sizeof(snapshot),
"{\n"
" \"uptime_s\": %ld,\n"
" \"rss_bytes\": %zu,\n"
" \"peak_rss_bytes\": %zu,\n"
" \"heap_committed_bytes\": %zu,\n"
" \"peak_committed_bytes\": %zu,\n"
" \"page_faults\": %zu,\n"
" \"fd_count\": %d,\n"
" \"query_count\": %d,\n"
" \"query_errors\": %d,\n"
" \"query_total_us\": %lld,\n"
" \"query_avg_us\": %lld,\n"
" \"query_max_us\": %lld,\n"
" \"mem_malloc_owned\": %s,\n"
" \"mem_live_bytes\": %zu,\n"
" \"mem_live_blocks\": %zu,\n"
" \"mem_area_committed_bytes\": %zu,\n"
" \"mem_residual_bytes\": %zu,\n"
" \"mem_buckets\": [%s],\n"
" \"mem_phases\": [%s],\n"
" \"pid\": %d\n"
"}\n",
uptime, current_rss, peak_rss, current_commit, peak_commit, page_faults, fds,
qcount, qerrors, qtime, qavg, qmax,
map.malloc_is_allocator_owned ? "true" : "false", map.live_bytes, map.live_blocks,
map.area_committed_bytes, residual, buckets, phases, (int)getpid());
if (length <= 0 || (size_t)length >= sizeof(snapshot) ||
!diag_write_file(DIAG_SNAPSHOT_TMP_NAME, snapshot, (size_t)length, false) ||
!diag_native_rename(DIAG_SNAPSHOT_TMP_NAME, DIAG_SNAPSHOT_NAME)) {
-42
View File
@@ -28,7 +28,6 @@
#endif
#include <windows.h>
#include <psapi.h>
#include <malloc.h>
#elif defined(__APPLE__)
#include <mach/mach.h>
#else
@@ -334,47 +333,6 @@ size_t cbm_mem_worker_budget(int num_workers) {
void cbm_mem_collect(void) {
mi_collect(true);
#ifdef _WIN32
/* mi_collect only returns pages the mimalloc heaps own. On Windows the
* static-CRT override does not capture every allocation — a heap walk of a
* process holding tens of megabytes reports zero live blocks — so those
* allocations are freed to the CRT heap instead, which keeps the pages
* committed rather than returning them. The purge_delay=0 configuration
* above therefore never applies to them, which is precisely why repeated
* request-scoped store open/close ratcheted committed memory on Windows
* only while POSIX stayed flat (#581). _heapmin asks the CRT to hand its
* unused pages back, which is the missing half of "return unused pages to
* the OS" on this platform. */
(void)_heapmin();
#endif
}
void cbm_mem_collect_periodic(void) {
#ifdef _WIN32
/* Platform parity, not whack-a-mole. On POSIX every free can return its
* pages immediately (purge_delay=0 above), so a long-lived process stays
* flat no matter which code path allocated. Windows has no equivalent for
* CRT-heap memory, so trimming only at one call site leaves every other
* path ratcheting — the residual growth #581 still showed after the
* request-scoped store churn was fixed. A bounded periodic trim gives the
* whole process the same "freed means returned" property.
*
* Rate-limited because _heapmin walks the heap: at most once per interval,
* driven from the server's existing idle/maintenance tick, so a busy
* request loop never pays it more than once per second. */
enum { MEM_TRIM_MIN_INTERVAL_MS = 1000 };
static atomic_llong last_trim_ms = ATOMIC_VAR_INIT(0);
long long now = (long long)cbm_now_ms();
long long previous = atomic_load_explicit(&last_trim_ms, memory_order_relaxed);
if (previous != 0 && now - previous < MEM_TRIM_MIN_INTERVAL_MS) {
return;
}
if (!atomic_compare_exchange_strong_explicit(&last_trim_ms, &previous, now,
memory_order_acq_rel, memory_order_relaxed)) {
return; /* another thread is trimming this interval */
}
(void)_heapmin();
#endif
}
/* ── Memory map (see mem.h for how to read the triple) ─────────────── */
-7
View File
@@ -78,13 +78,6 @@ size_t cbm_mem_worker_budget(int num_workers);
/* Return unused pages to the OS. Call between files to bound per-file peak. */
void cbm_mem_collect(void);
/* Bounded, rate-limited page return for a long-lived process's maintenance
* tick. No-op on POSIX, where every free already returns its pages
* (purge_delay=0); on Windows it trims the CRT heap, which otherwise keeps
* freed pages committed no matter which code path freed them (#581). Safe to
* call from a hot loop: at most one trim per second, process-wide. */
void cbm_mem_collect_periodic(void);
/* ── Memory map: where does the process's memory actually live? ──────
*
* A growth diagnostic that is honest about what it cannot see. Walking the
-11
View File
@@ -10419,14 +10419,6 @@ static void release_request_store(cbm_mcp_server_t *srv) {
srv->store = NULL;
free(srv->current_project);
srv->current_project = NULL;
/* The close above frees a whole connection's worth of page cache and
* schema. Handing those pages back to the OS is what keeps a long-lived
* daemon flat across thousands of request-scoped stores (#581): without
* it, committed memory ratcheted ~2 MB per request on Windows, where freed
* CRT pages stay committed, while POSIX stayed flat because the allocator
* is configured to purge immediately. Placed here rather than inside
* cbm_store_close so only this request-scoped churn pays for it. */
cbm_mem_collect();
}
char *cbm_mcp_handle_tool(cbm_mcp_server_t *srv, const char *tool_name, const char *args_json) {
@@ -11138,7 +11130,6 @@ static int poll_for_input_unix(cbm_mcp_server_t *srv, int fd, FILE *in) {
}
if (pr == 0) {
cbm_mcp_server_evict_idle(srv, STORE_IDLE_TIMEOUT_S);
cbm_mem_collect_periodic();
return 0;
}
return SKIP_ONE;
@@ -11167,7 +11158,6 @@ static int poll_for_input_unix(cbm_mcp_server_t *srv, int fd, FILE *in) {
}
if (pr == 0) {
cbm_mcp_server_evict_idle(srv, STORE_IDLE_TIMEOUT_S);
cbm_mem_collect_periodic();
return 0;
}
return SKIP_ONE;
@@ -11211,7 +11201,6 @@ int cbm_mcp_server_run(cbm_mcp_server_t *srv, FILE *in, FILE *out) {
}
if (wr == WAIT_TIMEOUT) {
cbm_mcp_server_evict_idle(srv, STORE_IDLE_TIMEOUT_S);
cbm_mem_collect_periodic();
continue;
}
#else