Skip to content

Commit 6d87825

Browse files
fix(proxy): tune macOS libmalloc and trim allocator pages so long-lived RSS stays bounded (#2879)
## Summary Fixes #2820. Prevents long-lived macOS proxies from retaining every largest transient request-body allocation in libmalloc. The reporter’s production A/B isolated the allocator behavior and verified the two pre-main libmalloc knobs; this PR applies them through a one-time Darwin-only re-exec and adds periodic per-worker pressure relief. - `MallocAggressiveMadvise=1` returns freed pages eagerly. - `MallocLargeCache=0` disables the large-allocation death-row cache. - Operator-set allocator variables are preserved; `HEADROOM_MALLOC_TUNING=0` is the kill switch. - Periodic trim defaults on only for macOS, runs off the event loop, performs no forced Python GC, validates its interval, and is retained/cancelled through the app lifecycle. - Non-Darwin behavior remains unchanged unless explicitly enabled. - Semantically rebased onto current `main`, retaining startup dependency validation, MCP SDK v1 compatibility, and all newer proxy behavior. ## Verification - 147 proxy CLI/config/malloc/MCP-contract tests pass; 1 platform skip. - Ruff check and formatting clean; `git diff --check` clean. - The reporter’s macOS A/B reduced dirty empty malloc regions to zero and lowered steady/startup RSS; the control flow and shutdown lifecycle are covered locally. ## Safety The re-exec is Darwin-only, PID-preserving, loop-guarded, and opt-out. The trim task is per worker because allocator state is per process, and shutdown cancels it explicitly.
1 parent be5b26d commit 6d87825

5 files changed

Lines changed: 491 additions & 0 deletions

File tree

headroom/cli/proxy.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,35 @@ def _get_env_bool_optional(name: str) -> bool | None:
113113
return _get_env_bool(name, False)
114114

115115

116+
# libmalloc reads these before main() runs, so they cannot be set from inside
117+
# the current process — the proxy re-execs itself once to apply them. Without
118+
# them, freed pages from large concurrent request bodies stay resident
119+
# (``vmmap`` shows whole "MALLOC_LARGE (empty)" regions) and long-lived proxy
120+
# RSS only ratchets upward (#2820). Vars the operator already set are left
121+
# untouched; HEADROOM_MALLOC_TUNING=0 disables the re-exec entirely.
122+
_MALLOC_TUNING = {
123+
"MallocAggressiveMadvise": "1", # madvise freed pages back to the OS eagerly
124+
"MallocLargeCache": "0", # no death-row cache for freed large allocations
125+
}
126+
127+
128+
def _reexec_with_malloc_tuning() -> None:
129+
if sys.platform != "darwin":
130+
return
131+
if not _get_env_bool("HEADROOM_MALLOC_TUNING", True):
132+
return
133+
if os.environ.get("_HEADROOM_MALLOC_TUNED") == "1":
134+
return
135+
missing = {k: v for k, v in _MALLOC_TUNING.items() if k not in os.environ}
136+
# Set the loop guard before the re-exec so the replacement process (which
137+
# inherits this environment) skips this path instead of re-execing forever.
138+
os.environ["_HEADROOM_MALLOC_TUNED"] = "1"
139+
if not missing:
140+
return
141+
os.environ.update(missing)
142+
os.execv(sys.executable, [sys.executable, "-m", "headroom.cli", *sys.argv[1:]])
143+
144+
116145
def _get_env_int_optional(name: str) -> int | None:
117146
val = os.environ.get(name)
118147
if val is None or val == "":
@@ -1065,6 +1094,7 @@ def proxy(
10651094
Usage with OpenAI-compatible clients:
10661095
OPENAI_BASE_URL=http://localhost:8787/v1 your-app
10671096
"""
1097+
_reexec_with_malloc_tuning()
10681098
ensure_proxy_dependencies()
10691099

10701100
# Import here to avoid slow startup
@@ -1261,6 +1291,10 @@ def proxy(
12611291
rate_limit_requests_per_minute=rpm if rpm is not None else 60,
12621292
rate_limit_tokens_per_minute=tpm if tpm is not None else 100_000,
12631293
compress_user_messages=_get_env_bool("HEADROOM_COMPRESS_USER_MESSAGES", False),
1294+
periodic_malloc_trim_enabled=_get_env_bool(
1295+
"HEADROOM_MALLOC_TRIM", sys.platform == "darwin"
1296+
),
1297+
malloc_trim_interval_seconds=_get_env_int("HEADROOM_MALLOC_TRIM_INTERVAL_SECONDS", 60),
12641298
min_tokens_to_crush=_get_env_int("HEADROOM_MIN_TOKENS", 500),
12651299
max_items_after_crush=_get_env_int("HEADROOM_MAX_ITEMS", 50),
12661300
exclude_tools=_parse_exclude_tools(None) or None,

headroom/proxy/malloc_trim.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""Return freed-but-retained allocator pages to the OS on long-lived proxies.
2+
3+
Large concurrent Anthropic bodies (0.5-1 MB of JSON parsed, deep-copied and
4+
re-serialized per in-flight request) drive libmalloc and pymalloc to a
5+
high-water mark that is never returned to the OS: after a burst the malloc
6+
zones keep entire regions resident but empty (``vmmap`` lists them as
7+
``MALLOC_LARGE (empty)`` / ``MALLOC_SMALL (empty)``), so process RSS only
8+
ratchets upward. Over a multi-day proxy lifetime under Claude Code traffic
9+
this reaches double-digit GB and starves the host.
10+
11+
Neither runtime returns these pages on its own. macOS exposes
12+
``malloc_zone_pressure_relief(NULL, 0)`` to purge every zone's free pages;
13+
glibc has ``malloc_trim(0)``. ``trim()`` calls that entry point directly: it is
14+
a C call that releases the GIL and reclaims whatever is already on the
15+
allocator's free lists. It deliberately does not run a Python ``gc.collect()``
16+
-- a full cyclic collection holds the GIL, and this periodic task runs off the
17+
event-loop thread precisely so it cannot stall request handling; freeing cyclic
18+
garbage is left to CPython's own automatic collection.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import asyncio
24+
import ctypes
25+
import logging
26+
import sys
27+
import time
28+
29+
logger = logging.getLogger(__name__)
30+
31+
# Interval bounds for the periodic trim task. A non-positive interval would make
32+
# ``asyncio.sleep`` return immediately and spin a continuous collect/trim loop,
33+
# so anything below the minimum falls back to the default.
34+
_DEFAULT_TRIM_INTERVAL_SECONDS = 60
35+
_MIN_TRIM_INTERVAL_SECONDS = 1
36+
37+
# Lazily resolved (platform_tag, foreign_function | None). ``None`` function
38+
# means the platform has no supported trim call and trim() is a no-op.
39+
_relief: tuple[str, object | None] | None = None
40+
41+
42+
def _resolve() -> tuple[str, object | None]:
43+
global _relief
44+
if _relief is not None:
45+
return _relief
46+
try:
47+
libc = ctypes.CDLL(None)
48+
if sys.platform == "darwin":
49+
fn = libc.malloc_zone_pressure_relief
50+
fn.argtypes = [ctypes.c_void_p, ctypes.c_size_t]
51+
fn.restype = ctypes.c_size_t
52+
_relief = ("darwin", fn)
53+
else:
54+
fn = libc.malloc_trim
55+
fn.argtypes = [ctypes.c_size_t]
56+
fn.restype = ctypes.c_int
57+
_relief = ("glibc", fn)
58+
except (OSError, AttributeError):
59+
_relief = ("unsupported", None)
60+
return _relief
61+
62+
63+
def trim() -> int:
64+
"""Return allocator free pages to the OS.
65+
66+
Calls the platform's allocator pressure-relief entry point
67+
(``malloc_zone_pressure_relief`` on macOS, ``malloc_trim`` on glibc). This
68+
is a C call that releases the GIL for its duration and reclaims pages
69+
already on the allocator's free lists. It deliberately does *not* run a
70+
Python ``gc.collect()`` (a full cyclic collection holds the GIL); cyclic
71+
garbage is left to CPython's automatic collection, so this off-thread
72+
periodic task never holds the GIL for a full-heap traversal.
73+
74+
Returns the number of bytes freed on macOS (glibc's ``malloc_trim``
75+
reports only success, so 0 is returned there and on unsupported
76+
platforms).
77+
"""
78+
kind, fn = _resolve()
79+
if fn is None:
80+
return 0
81+
if kind == "darwin":
82+
return int(fn(None, 0)) # type: ignore[operator]
83+
fn(0) # type: ignore[operator]
84+
return 0
85+
86+
87+
async def trim_periodically(interval_seconds: int = 60) -> None:
88+
"""Background task that periodically returns allocator free pages to the OS.
89+
90+
Runs in every worker process (allocator state is per-process). The trim is
91+
the platform's allocator pressure-relief C call
92+
(``malloc_zone_pressure_relief``/``malloc_trim``), dispatched via
93+
``asyncio.to_thread`` so it runs off the event-loop thread. Because it is a
94+
C call that releases the GIL and runs no Python ``gc.collect()``, it holds
95+
the GIL only as briefly as the to_thread hand-off, so a slow purge on a
96+
large heap does not stall request handling. The task exits immediately on
97+
platforms with no supported trim call, so it is a true no-op there.
98+
99+
Args:
100+
interval_seconds: How often to trim (default: 60 seconds). A value below
101+
``_MIN_TRIM_INTERVAL_SECONDS`` (which would busy-loop) falls back to
102+
the default.
103+
"""
104+
_, fn = _resolve()
105+
if fn is None:
106+
# No supported allocator-trim call on this platform (Windows, musl, ...);
107+
# do not spin a wakeup task that can only ever no-op.
108+
logger.debug("MallocTrim: no supported trim on %s; task disabled", sys.platform)
109+
return
110+
111+
if interval_seconds < _MIN_TRIM_INTERVAL_SECONDS:
112+
logger.warning(
113+
"MallocTrim: interval %ss is below the %ds minimum; using default %ds",
114+
interval_seconds,
115+
_MIN_TRIM_INTERVAL_SECONDS,
116+
_DEFAULT_TRIM_INTERVAL_SECONDS,
117+
)
118+
interval_seconds = _DEFAULT_TRIM_INTERVAL_SECONDS
119+
120+
while True:
121+
await asyncio.sleep(interval_seconds)
122+
try:
123+
start = time.perf_counter()
124+
# Off the event-loop thread: the C-level purge can pause for a while
125+
# on a large heap, and that pause must not stall proxy traffic.
126+
freed = await asyncio.to_thread(trim)
127+
elapsed_ms = (time.perf_counter() - start) * 1000
128+
log = logger.info if freed >= (16 << 20) else logger.debug
129+
log(
130+
"MallocTrim: returned %.1f MB to OS in %.0f ms",
131+
freed / 1048576,
132+
elapsed_ms,
133+
)
134+
except Exception as e:
135+
logger.debug("MallocTrim failed: %s", e)

headroom/proxy/models.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from __future__ import annotations
88

99
import logging
10+
import sys
1011
from dataclasses import InitVar, dataclass, field
1112
from datetime import datetime
1213
from typing import Any, Literal
@@ -439,6 +440,17 @@ class ProxyConfig:
439440
# Env: HEADROOM_PERIODIC_TOIN_STATS=0.
440441
periodic_toin_stats_enabled: bool = True
441442

443+
# Periodic allocator trim. Long-lived proxies processing large concurrent
444+
# request bodies ratchet RSS through freed-but-retained allocator pages;
445+
# this returns them to the OS (malloc_zone_pressure_relief on macOS,
446+
# malloc_trim on glibc). Default-on only on macOS, where the retained-page
447+
# ratchet is the documented failure (#2820); an opt-in elsewhere via
448+
# HEADROOM_MALLOC_TRIM=1 so glibc deployments do not silently take on a
449+
# once-a-minute allocator purge they did not ask for. Envs:
450+
# HEADROOM_MALLOC_TRIM=0/1, HEADROOM_MALLOC_TRIM_INTERVAL_SECONDS.
451+
periodic_malloc_trim_enabled: bool = field(default_factory=lambda: sys.platform == "darwin")
452+
malloc_trim_interval_seconds: int = 60
453+
442454
# Stateless mode — disable all filesystem writes for read-only / container deployments
443455
stateless: bool = False
444456

headroom/proxy/server.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@
144144
)
145145
from headroom.proxy.loop_callback_failure_policy import is_known_websocket_callback_failure
146146
from headroom.proxy.loopback_guard import is_loopback_host
147+
from headroom.proxy.malloc_trim import trim_periodically
147148
from headroom.proxy.memory_handler import MemoryConfig, MemoryHandler
148149

149150
# Data models (extracted to headroom/proxy/models.py for maintainability)
@@ -2601,6 +2602,7 @@ async def lifespan(app: FastAPI): # type: ignore[no-untyped-def]
26012602
app.state.ready = False
26022603
app.state.startup_error = None
26032604
app.state.periodic_toin_stats_task = None
2605+
app.state.periodic_malloc_trim_task = None
26042606

26052607
try:
26062608
try:
@@ -2611,6 +2613,12 @@ async def lifespan(app: FastAPI): # type: ignore[no-untyped-def]
26112613
app.state.periodic_toin_stats_task = asyncio.create_task(
26122614
_log_toin_stats_periodically()
26132615
)
2616+
# Per-worker on purpose: allocator state is per-process, so
2617+
# every worker must trim its own zones (no beacon-owner gate).
2618+
if config.periodic_malloc_trim_enabled:
2619+
app.state.periodic_malloc_trim_task = asyncio.create_task(
2620+
trim_periodically(config.malloc_trim_interval_seconds)
2621+
)
26142622
if proxy.usage_reporter:
26152623
await proxy.usage_reporter.start(proxy)
26162624
if proxy.traffic_learner:
@@ -2670,6 +2678,16 @@ async def _timed(coro: Any, *, label: str, timeout: float) -> None:
26702678
)
26712679
app.state.periodic_toin_stats_task = None
26722680

2681+
periodic_malloc_trim_task = app.state.periodic_malloc_trim_task
2682+
if periodic_malloc_trim_task is not None:
2683+
periodic_malloc_trim_task.cancel()
2684+
await _timed(
2685+
asyncio.gather(periodic_malloc_trim_task, return_exceptions=True),
2686+
label="periodic_malloc_trim.stop",
2687+
timeout=3.0,
2688+
)
2689+
app.state.periodic_malloc_trim_task = None
2690+
26732691
if _cc_reconciler is not None:
26742692
await _timed(_cc_reconciler.stop(), label="cc_reconciler.stop", timeout=3.0)
26752693
if _beacon_is_owner[0]:
@@ -5160,6 +5178,10 @@ def _proxy_config_from_env() -> ProxyConfig:
51605178
http2=_get_env_bool("HEADROOM_HTTP2", True),
51615179
http_proxy=os.environ.get("HEADROOM_HTTP_PROXY") or None,
51625180
periodic_toin_stats_enabled=_get_env_bool("HEADROOM_PERIODIC_TOIN_STATS", True),
5181+
periodic_malloc_trim_enabled=_get_env_bool(
5182+
"HEADROOM_MALLOC_TRIM", sys.platform == "darwin"
5183+
),
5184+
malloc_trim_interval_seconds=_get_env_int("HEADROOM_MALLOC_TRIM_INTERVAL_SECONDS", 60),
51635185
proxy_token=os.environ.get("HEADROOM_PROXY_TOKEN") or None,
51645186
offline=_get_env_bool("HEADROOM_OFFLINE", False),
51655187
# Default mode is CACHE (Headroom's coding posture): delta-only compression

0 commit comments

Comments
 (0)