Skip to content

Commit bb72522

Browse files
committed
feat(mem): CBM_MAX_MEMORY_MB explicit memory-budget override
Add a CBM_MAX_MEMORY_MB env var that overrides the ram_fraction × total_RAM budget with an explicit cap in MiB. Lets RAM-constrained hosts (no cgroup) cap the in-memory graph, and lets containers pin a budget below the cgroup limit so headroom is left for sibling processes. Same precedence as CBM_WORKERS: explicit override > implicit detection. Budget math is extracted into a pure, testable cbm_mem_resolve_budget() since cbm_mem_init is one-shot per process. Logs source=env|ram_fraction on mem.init and warns on invalid/clamped values. Closes #580. Signed-off-by: Sam Li <yangsec888@gmail.com>
1 parent 684e35b commit bb72522

4 files changed

Lines changed: 128 additions & 2 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,7 @@ codebase-memory-mcp config reset auto_index # reset to default
444444
| `CBM_DIAGNOSTICS` | `false` | Set to `1` or `true` to enable periodic diagnostics output to `/tmp/cbm-diagnostics-<pid>.json`. |
445445
| `CBM_DOWNLOAD_URL` | *(GitHub releases)* | Override the download URL for updates. Used for testing or self-hosted deployments. |
446446
| `CBM_LOG_LEVEL` | `info` | Set the minimum log level. Accepted values (case-insensitive): `debug`, `info`, `warn`, `error`, `none` — or their numeric equivalents `0``4` matching the internal enum. Logs go to stderr; stdout is reserved for MCP JSON-RPC. |
447+
| `CBM_MAX_MEMORY_MB` | *(50% of detected RAM)* | Explicit memory budget in MiB, overriding the default `ram_fraction × total RAM`. Caps the in-memory graph budget on RAM-constrained hosts, and lets containers pin a budget *below* the detected cgroup limit to leave headroom for sibling processes. Clamped to physical/cgroup RAM; non-positive/invalid values are ignored with a warning. |
447448
| `CBM_WORKERS` | *(detected)* | Override the parallel-indexing worker count returned by `cbm_default_worker_count`. Useful inside containers where `sysconf(_SC_NPROCESSORS_ONLN)` reports host CPUs rather than the cgroup's effective quota. Range 1–256; invalid values are ignored with a warning. |
448449

449450
```bash

src/foundation/mem.c

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
#include <mimalloc.h>
1818
#include <stdatomic.h>
1919
#include <stdio.h>
20+
#include <stdlib.h>
2021

2122
#ifdef _WIN32
2223
#ifndef WIN32_LEAN_AND_MEAN
@@ -106,6 +107,30 @@ static void check_pressure(size_t rss) {
106107

107108
/* ── Public API ────────────────────────────────────────────────── */
108109

110+
size_t cbm_mem_resolve_budget(size_t total_ram, double ram_fraction, const char *max_memory_mb) {
111+
if (ram_fraction <= 0.0 || ram_fraction > MAX_RAM_FRACTION) {
112+
ram_fraction = DEFAULT_RAM_FRACTION;
113+
}
114+
size_t budget = (size_t)((double)total_ram * ram_fraction);
115+
116+
/* Explicit CBM_MAX_MEMORY_MB override (positive integer MiB) wins over the
117+
* fraction-derived default. Clamp to total_ram when known so we never claim
118+
* more than physical/cgroup RAM. Invalid / non-positive values are ignored
119+
* (caller logs a warning). */
120+
if (max_memory_mb != NULL && max_memory_mb[0] != '\0') {
121+
char *end = NULL;
122+
long long want_mb = strtoll(max_memory_mb, &end, CBM_DECIMAL_BASE);
123+
if (end != max_memory_mb && want_mb > 0) {
124+
size_t want = (size_t)want_mb * MB_DIVISOR;
125+
if (total_ram > 0 && want > total_ram) {
126+
want = total_ram;
127+
}
128+
budget = want;
129+
}
130+
}
131+
return budget;
132+
}
133+
109134
void cbm_mem_init(double ram_fraction) {
110135
int expected = 0;
111136
if (!atomic_compare_exchange_strong(&g_initialized, &expected, 1)) {
@@ -124,13 +149,39 @@ void cbm_mem_init(double ram_fraction) {
124149
mi_option_set(mi_option_purge_delay, 0); /* immediate purge, no 1s delay */
125150

126151
cbm_system_info_t info = cbm_system_info();
127-
g_budget = (size_t)((double)info.total_ram * ram_fraction);
152+
153+
/* CBM_MAX_MEMORY_MB env override: an explicit memory budget in MiB that
154+
* takes precedence over the ram_fraction-derived value. Lets RAM-
155+
* constrained hosts cap the in-memory graph budget, and lets containers
156+
* pin a budget *below* the detected cgroup limit so headroom is left for
157+
* sibling processes (e.g. an MCP client/parent). Same precedence shape as
158+
* the CBM_WORKERS override: explicit override > implicit detection. (#580) */
159+
char env_buf[CBM_SZ_32];
160+
const char *env = cbm_safe_getenv("CBM_MAX_MEMORY_MB", env_buf, sizeof(env_buf), NULL);
161+
g_budget = cbm_mem_resolve_budget(info.total_ram, ram_fraction, env);
162+
163+
const char *budget_source = "ram_fraction";
164+
if (env != NULL && env[0] != '\0') {
165+
char *end = NULL;
166+
long long want_mb = strtoll(env, &end, CBM_DECIMAL_BASE);
167+
if (end != env && want_mb > 0) {
168+
budget_source = "env";
169+
if (info.total_ram > 0 && (size_t)want_mb * MB_DIVISOR > info.total_ram) {
170+
char cap_mb[CBM_SZ_32];
171+
snprintf(cap_mb, sizeof(cap_mb), "%zu", info.total_ram / MB_DIVISOR);
172+
cbm_log_warn("mem.max.clamped", "requested_mb", env, "cap_mb", cap_mb);
173+
}
174+
} else {
175+
cbm_log_warn("mem.max.invalid", "value", env, "fallback", "ram_fraction");
176+
}
177+
}
128178

129179
char budget_mb[CBM_SZ_32];
130180
char ram_mb[CBM_SZ_32];
131181
snprintf(budget_mb, sizeof(budget_mb), "%zu", g_budget / MB_DIVISOR);
132182
snprintf(ram_mb, sizeof(ram_mb), "%zu", info.total_ram / MB_DIVISOR);
133-
cbm_log_info("mem.init", "budget_mb", budget_mb, "total_ram_mb", ram_mb);
183+
cbm_log_info("mem.init", "budget_mb", budget_mb, "total_ram_mb", ram_mb, "source",
184+
budget_source);
134185
}
135186

136187
size_t cbm_mem_rss(void) {

src/foundation/mem.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,19 @@
1212
#include <stddef.h>
1313

1414
/* Initialize memory budget = ram_fraction * total_physical_ram.
15+
* The CBM_MAX_MEMORY_MB env var, when set to a positive integer, overrides
16+
* this with an explicit budget in MiB (clamped to physical/cgroup RAM).
1517
* Thread-safe: only the first call takes effect.
1618
* Configures mimalloc options for reduced upfront memory. */
1719
void cbm_mem_init(double ram_fraction);
1820

21+
/* Pure budget resolver shared by cbm_mem_init (exposed for testing).
22+
* Returns ram_fraction * total_ram, unless `max_memory_mb` is a positive
23+
* integer string (the CBM_MAX_MEMORY_MB override) — then it returns that many
24+
* MiB, clamped to total_ram when total_ram > 0. Invalid / non-positive
25+
* overrides fall back to the fraction-derived value. Reads no globals/env. */
26+
size_t cbm_mem_resolve_budget(size_t total_ram, double ram_fraction, const char *max_memory_mb);
27+
1928
/* Current RSS in bytes via mi_process_info().
2029
* Falls back to OS-specific queries when MI_OVERRIDE=0 (ASan builds). */
2130
size_t cbm_mem_rss(void);

tests/test_mem.c

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,64 @@ TEST(mem_init_second_call_noop) {
290290
PASS();
291291
}
292292

293+
/* ── CBM_MAX_MEMORY_MB budget override (pure resolver) ────────────
294+
* cbm_mem_init is one-shot per process, so the override logic lives in the
295+
* pure cbm_mem_resolve_budget() helper which we can exercise directly. (#580) */
296+
297+
#define CBM_TEST_MB ((size_t)1024 * 1024)
298+
299+
TEST(resolve_budget_no_override_uses_fraction) {
300+
/* No env override → ram_fraction × total_ram. */
301+
size_t total = 8192 * CBM_TEST_MB;
302+
ASSERT_EQ(cbm_mem_resolve_budget(total, 0.5, NULL), 4096 * CBM_TEST_MB);
303+
ASSERT_EQ(cbm_mem_resolve_budget(total, 0.25, ""), 2048 * CBM_TEST_MB);
304+
PASS();
305+
}
306+
307+
TEST(resolve_budget_invalid_fraction_defaults) {
308+
/* Out-of-range fractions fall back to the 0.5 default. */
309+
size_t total = 8192 * CBM_TEST_MB;
310+
ASSERT_EQ(cbm_mem_resolve_budget(total, 0.0, NULL), 4096 * CBM_TEST_MB);
311+
ASSERT_EQ(cbm_mem_resolve_budget(total, -1.0, NULL), 4096 * CBM_TEST_MB);
312+
ASSERT_EQ(cbm_mem_resolve_budget(total, 1.5, NULL), 4096 * CBM_TEST_MB);
313+
PASS();
314+
}
315+
316+
TEST(resolve_budget_override_wins) {
317+
/* The key use case: pin a budget *below* the fraction default. */
318+
size_t total = 8192 * CBM_TEST_MB;
319+
ASSERT_EQ(cbm_mem_resolve_budget(total, 0.5, "2048"), 2048 * CBM_TEST_MB);
320+
/* Override above the fraction default is also honored (up to total_ram). */
321+
ASSERT_EQ(cbm_mem_resolve_budget(total, 0.5, "6144"), 6144 * CBM_TEST_MB);
322+
PASS();
323+
}
324+
325+
TEST(resolve_budget_override_clamped_to_total) {
326+
/* Override larger than physical/cgroup RAM clamps to total_ram. */
327+
size_t total = 1024 * CBM_TEST_MB;
328+
ASSERT_EQ(cbm_mem_resolve_budget(total, 0.5, "100000"), total);
329+
PASS();
330+
}
331+
332+
TEST(resolve_budget_override_when_total_unknown) {
333+
/* Detection failed (total_ram == 0): override still yields a usable budget
334+
* and is not clamped to zero. */
335+
ASSERT_EQ(cbm_mem_resolve_budget(0, 0.5, "512"), 512 * CBM_TEST_MB);
336+
PASS();
337+
}
338+
339+
TEST(resolve_budget_invalid_override_falls_back) {
340+
/* Non-numeric, zero, and negative overrides are ignored. */
341+
size_t total = 8192 * CBM_TEST_MB;
342+
size_t fraction_budget = 4096 * CBM_TEST_MB;
343+
ASSERT_EQ(cbm_mem_resolve_budget(total, 0.5, "abc"), fraction_budget);
344+
ASSERT_EQ(cbm_mem_resolve_budget(total, 0.5, "0"), fraction_budget);
345+
ASSERT_EQ(cbm_mem_resolve_budget(total, 0.5, "-512"), fraction_budget);
346+
PASS();
347+
}
348+
349+
#undef CBM_TEST_MB
350+
293351
/* ── Arena integration tests ──────────────────────────────────── */
294352

295353
TEST(arena_alloc_and_destroy) {
@@ -653,6 +711,13 @@ SUITE(mem) {
653711
RUN_TEST(mem_init_negative_fraction);
654712
RUN_TEST(mem_init_over_one_fraction);
655713
RUN_TEST(mem_init_second_call_noop);
714+
/* CBM_MAX_MEMORY_MB budget override */
715+
RUN_TEST(resolve_budget_no_override_uses_fraction);
716+
RUN_TEST(resolve_budget_invalid_fraction_defaults);
717+
RUN_TEST(resolve_budget_override_wins);
718+
RUN_TEST(resolve_budget_override_clamped_to_total);
719+
RUN_TEST(resolve_budget_override_when_total_unknown);
720+
RUN_TEST(resolve_budget_invalid_override_falls_back);
656721
/* Arena integration */
657722
RUN_TEST(arena_alloc_and_destroy);
658723
RUN_TEST(arena_grow_tracks_sizes);

0 commit comments

Comments
 (0)