perf(compressor): release allocator pages after successful compaction
A successful compaction frees the largest allocation a long session ever drops (the compressed-away message dicts), but Python's arena allocator keeps those pages in the heap — RSS retains the pre-compaction high-water mark until exit. #76905's trim_memory lifecycle covers the gateway/TUI housekeeping loops but not the CLI compression path. Call trim_memory(reason='post-compression') at the compression-success point in ContextCompressor.compress(), following the house pattern (lazy import in try, debug-level log on failure). The helper is glibc-gated, config-gated and rate-limited, so it is a safe no-op on other platforms and cannot fail compression. Re-expresses the intent of #70782 (JonthanaHanh), which reached for a bare gc.collect(); trim_memory is the house mechanism and already wraps a collect.
This commit is contained in:
parent
861ca18c67
commit
f8f475569f
|
|
@ -6730,6 +6730,26 @@ This compaction should PRIORITISE preserving all information related to the focu
|
|||
_strip_persistence_markers(compressed)
|
||||
self._last_compression_made_progress = True
|
||||
|
||||
# A successful compaction just freed the largest allocation a long
|
||||
# session ever drops (the compressed-away message dicts), which makes
|
||||
# this the natural point to hand allocator pages back to the OS.
|
||||
# #76905's trim lifecycle covers the gateway/TUI housekeeping loops but
|
||||
# not the CLI compression path, so RSS keeps the pre-compaction
|
||||
# high-water mark until exit. The helper is glibc-gated, config-gated
|
||||
# and rate-limited, so this is a safe no-op elsewhere. (#70782)
|
||||
try:
|
||||
from hermes_cli.mem_trim import trim_memory
|
||||
|
||||
trim_memory(reason="post-compression")
|
||||
except Exception as exc:
|
||||
# debug, not warning: sibling trim sites all log failures at
|
||||
# debug, and compression must never fail because of a trim.
|
||||
logger.debug(
|
||||
"post-compression memory trim failed: %s: %s",
|
||||
type(exc).__name__,
|
||||
exc,
|
||||
)
|
||||
|
||||
# Batch compaction invalidates micro-compaction state: the batch
|
||||
# marker now holds MORE history than the in-memory rolling summary
|
||||
# (it summarized everything in the window, including exchanges micro
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
"""A successful compaction hands allocator pages back to the OS.
|
||||
|
||||
The compressed-away message dicts are the largest allocation a long session
|
||||
ever frees, but Python's arena allocator keeps those pages in the process heap
|
||||
— RSS retains the pre-compaction high-water mark until exit. #76905's
|
||||
trim_memory lifecycle covers the gateway/TUI housekeeping loops but not the
|
||||
CLI compression path, so compress() now calls
|
||||
``trim_memory(reason="post-compression")`` after a successful pass.
|
||||
|
||||
trim_memory itself is glibc/Linux-gated (a fast no-op on macOS), so these
|
||||
tests monkeypatch the seam rather than asserting on RSS. Salvaged in spirit
|
||||
from #70782 (which reached for a bare gc.collect(); trim_memory is the
|
||||
house mechanism and already wraps a collect).
|
||||
"""
|
||||
import hermes_cli.mem_trim as mem_trim
|
||||
from agent.context_compressor import ContextCompressor
|
||||
|
||||
|
||||
def _compressor(threshold_tokens: int = 24_576) -> ContextCompressor:
|
||||
cc = ContextCompressor(
|
||||
model="test-model",
|
||||
threshold_percent=0.75,
|
||||
protect_first_n=5,
|
||||
protect_last_n=20,
|
||||
quiet_mode=True,
|
||||
config_context_length=40960,
|
||||
provider="test",
|
||||
)
|
||||
cc.threshold_tokens = threshold_tokens # pin; don't couple to window math
|
||||
cc._generate_summary = lambda *a, **k: "Summary of earlier turns."
|
||||
return cc
|
||||
|
||||
|
||||
def _messages(n: int, size: int = 1500) -> list:
|
||||
msgs = [{"role": "system", "content": "sys"}]
|
||||
for i in range(n):
|
||||
role = "user" if i % 2 == 0 else "assistant"
|
||||
msgs.append({"role": role, "content": f"m{i} " + "z" * size})
|
||||
return msgs
|
||||
|
||||
|
||||
def test_successful_compression_trims_memory_once(monkeypatch):
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
mem_trim, "trim_memory", lambda *a, **kw: calls.append(kw) or False
|
||||
)
|
||||
|
||||
cc = _compressor()
|
||||
out = cc.compress(_messages(14), current_tokens=100_000)
|
||||
|
||||
assert len(out) < 15, "sanity: compaction should have made progress"
|
||||
assert len(calls) == 1, "trim_memory must run exactly once per compaction"
|
||||
assert calls[0].get("reason") == "post-compression"
|
||||
|
||||
|
||||
def test_trim_failure_does_not_break_compression(monkeypatch):
|
||||
def boom(*a, **kw):
|
||||
raise RuntimeError("allocator says no")
|
||||
|
||||
monkeypatch.setattr(mem_trim, "trim_memory", boom)
|
||||
|
||||
cc = _compressor()
|
||||
out = cc.compress(_messages(14), current_tokens=100_000)
|
||||
|
||||
assert cc._last_compression_made_progress is True
|
||||
assert isinstance(out, list) and out, "compress() must still return messages"
|
||||
Loading…
Reference in New Issue