mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
perf(mem): timer trims run one full collection per quiet period, never during a turn
The housekeeping tick and the TUI idle reaper now call trim_memory_when_idle: allocator trim only, plus one full gc.collect() once no turn has run in this process for 5 minutes (again after a later agent close re-arms it). Nobody is waiting then, so the pause harms no one, and whatever cyclic garbage remains (openai SDK resource cycles) is still reclaimed. The quiet clock is the existing process-wide live-turn counter in review_idle_queue.
This commit is contained in:
1 parent
2cf25ce078
commit
0488f459be
5 files changed
+56
-15
No files matched your search
@@ -116,12 +116,17 @@ class ReviewIdleQueue:
|
||||
self._thread = threading.Thread(target=self._run, daemon=True, name="bg-review-idle-queue")
|
||||
self._thread.start()
|
||||
|
||||
def _quiet_for(self) -> float:
|
||||
"""Seconds this process has been turn-free (0 while a turn runs)."""
|
||||
def quiet_period(self) -> tuple[float, Optional[float]]:
|
||||
"""Process-wide quiet clock: (seconds since the last live turn ended, the stamp that quiet period began
|
||||
at). ``(0.0, None)`` while any turn runs or before the first turn ends."""
|
||||
with self._lock:
|
||||
if self._live_turns > 0 or self._quiet_since is None:
|
||||
return 0.0
|
||||
return self._now() - self._quiet_since
|
||||
return 0.0, None
|
||||
return self._now() - self._quiet_since, self._quiet_since
|
||||
|
||||
def _quiet_for(self) -> float:
|
||||
"""Seconds this process has been turn-free (0 while a turn runs)."""
|
||||
return self.quiet_period()[0]
|
||||
|
||||
def _pop_dispatchable(self) -> Optional[_PendingReview]:
|
||||
"""Oldest aged-out item, else the oldest item once quiet+idle hold."""
|
||||
|
||||
+3
-3
@@ -4440,9 +4440,9 @@ def _housekeeping_deferred_fts_retry() -> None:
|
||||
|
||||
|
||||
def _housekeeping_memory_trim() -> None:
|
||||
"""Messaging-gateway counterpart to the TUI idle reaper; timer lane, so allocator-trim only (#139763)."""
|
||||
from hermes_cli.mem_trim import trim_memory
|
||||
trim_memory(reason="messaging gateway housekeeping", collect=False)
|
||||
"""Messaging-gateway counterpart to the TUI idle reaper; timer lane: full GC only once idle (#139763)."""
|
||||
from hermes_cli.mem_trim import trim_memory_when_idle
|
||||
trim_memory_when_idle(reason="messaging gateway housekeeping")
|
||||
|
||||
|
||||
def _housekeeping_checkpoint_prune() -> None:
|
||||
|
||||
+39
-3
@@ -153,9 +153,9 @@ def trim_memory(
|
||||
housekeeping tick, agent close, TUI turn completion, post-compression): a full collection walks
|
||||
the whole shared heap with the GIL held, 250-750 ms on a multi-million-object gateway, freezing
|
||||
every thread (adapters, streams, API requests) while it runs. Most of what those paths free goes
|
||||
by reference counting; cycles are left to CPython's automatic collection and their pages are
|
||||
returned by the next trim. The forced collection stays where it costs no one or memory is the
|
||||
emergency: the idle reaper (only when every session is quiescent), agent-cache pressure eviction.
|
||||
by reference counting; cycles wait for ``trim_memory_when_idle``, the timer lanes' one full
|
||||
collection per quiet period. The forced collection otherwise stays only where memory is the
|
||||
emergency (agent-cache pressure eviction) or the process is single-session (slash worker).
|
||||
"""
|
||||
enabled, configured_cooldown, log_every_n, info_log_min_delta_mb = _config_settings()
|
||||
if not enabled:
|
||||
@@ -198,3 +198,39 @@ def trim_memory(
|
||||
logger.warning(
|
||||
"memory trim failed after %s: %s: %s", reason or "cleanup", type(exc).__name__, exc)
|
||||
return False
|
||||
|
||||
|
||||
# A gateway that has been turn-free this long has nobody waiting on it, so the full collection the busy-path
|
||||
# trims skip can run here without freezing anyone. Python 3.14's incremental collector advances only on new
|
||||
# allocations, so a quiet process otherwise keeps closed agents' reference cycles (and their pages) forever.
|
||||
IDLE_COLLECT_AFTER_SECONDS = 300.0
|
||||
_idle_collect_lock = threading.Lock()
|
||||
_idle_collected_for: float | None = None # quiet-period stamp the last idle collection covered
|
||||
_agent_closed_since_idle_collect = False
|
||||
|
||||
|
||||
def note_agent_closed() -> None:
|
||||
"""A closed agent may leave reference cycles: re-arm the idle collection even within one quiet period."""
|
||||
global _agent_closed_since_idle_collect
|
||||
_agent_closed_since_idle_collect = True
|
||||
|
||||
|
||||
def trim_memory_when_idle(*, reason: str) -> bool:
|
||||
"""Timer-lane trim: allocator release only, plus ONE full collection per quiet period once no turn has run
|
||||
in this process for ``IDLE_COLLECT_AFTER_SECONDS`` (again only after a later agent close)."""
|
||||
global _idle_collected_for, _agent_closed_since_idle_collect
|
||||
from agent.review_idle_queue import QUEUE
|
||||
|
||||
quiet_for, quiet_since = QUEUE.quiet_period()
|
||||
with _idle_collect_lock:
|
||||
due = quiet_since is not None and quiet_for >= IDLE_COLLECT_AFTER_SECONDS and (
|
||||
quiet_since != _idle_collected_for or _agent_closed_since_idle_collect)
|
||||
if due:
|
||||
_idle_collected_for, _agent_closed_since_idle_collect = quiet_since, False
|
||||
if not due or not _config_settings()[0]:
|
||||
return trim_memory(reason=reason, collect=False)
|
||||
started = time.perf_counter()
|
||||
freed = gc.collect()
|
||||
logger.info("idle full collection: reason=%s quiet_s=%d unreachable=%d duration_ms=%.1f",
|
||||
reason, quiet_for, freed, (time.perf_counter() - started) * 1000)
|
||||
return trim_memory(force=True, reason=f"{reason} after idle collection", collect=False)
|
||||
+3
-3
@@ -1010,9 +1010,9 @@ class AIAgent(
|
||||
|
||||
@staticmethod
|
||||
def _trim_process_memory() -> None:
|
||||
"""Return freed heap pages to the OS (glibc-only). No forced full collection: in a gateway this runs
|
||||
on every agent close, and a full pass over the shared heap freezes every other session's thread."""
|
||||
from hermes_cli.mem_trim import trim_memory
|
||||
"""Return freed pages to the OS; no full GC here (it would freeze every other session), the idle one runs."""
|
||||
from hermes_cli.mem_trim import note_agent_closed, trim_memory
|
||||
note_agent_closed()
|
||||
trim_memory(force=True, reason="agent close", collect=False)
|
||||
|
||||
def _finalize_owned_session_row(self) -> None:
|
||||
|
||||
@@ -280,8 +280,8 @@ def _reap_idle_sessions() -> None:
|
||||
logger.debug("idle reaper periodic trim deferred: a session is busy or attached")
|
||||
return
|
||||
try:
|
||||
from hermes_cli.mem_trim import trim_memory
|
||||
trim_memory(reason="idle reaper periodic trim")
|
||||
from hermes_cli.mem_trim import trim_memory_when_idle
|
||||
trim_memory_when_idle(reason="idle reaper periodic trim")
|
||||
except Exception as exc: # debug, not warning — a persistent failure would repeat every scan.
|
||||
logger.debug("idle reaper memory trim failed: %s: %s", type(exc).__name__, exc)
|
||||
|
||||
|
||||
Reference in new issue
Block a user