mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
perf(memory): return idle allocator pages to the OS
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
InfinityPacer
parent
dc52317b51
commit
9af903e677
@@ -2,11 +2,13 @@
|
||||
内存回收装饰器模块
|
||||
提供装饰器用于在函数执行后立即回收内存
|
||||
"""
|
||||
import ctypes
|
||||
import gc
|
||||
import functools
|
||||
import logging
|
||||
import psutil
|
||||
import os
|
||||
import sys
|
||||
from typing import Callable, Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -83,6 +85,45 @@ def get_memory_usage() -> float:
|
||||
return 0.0
|
||||
|
||||
|
||||
# jemalloc 的 MALLCTL_ARENAS_ALL:对全部 arena 执行 purge
|
||||
_JEMALLOC_ARENAS_ALL_PURGE = b"arena.4096.purge"
|
||||
|
||||
|
||||
def release_allocator_memory() -> Optional[str]:
|
||||
"""
|
||||
把 C 分配器中已释放但尚未归还的空闲页交还操作系统。
|
||||
|
||||
``gc.collect()`` 只把 Python 对象交还分配器,jemalloc 默认没有后台回收线程,空闲 arena
|
||||
的脏页会一直计入 RSS,glibc 的堆顶碎片同理。官方镜像通过 ``LD_PRELOAD`` 加载 jemalloc 时
|
||||
调用 ``mallctl`` purge;源码部署等使用 glibc 的环境调用 ``malloc_trim(0)``。两者都只归还
|
||||
空闲页、不影响存活对象,其他平台或符号不存在时不做任何事。
|
||||
|
||||
Returns:
|
||||
实际使用的机制(``jemalloc`` 或 ``glibc``),未执行时返回 None
|
||||
"""
|
||||
if not sys.platform.startswith("linux"):
|
||||
return None
|
||||
try:
|
||||
# CDLL(None) 取当前进程的全局符号表,包含 LD_PRELOAD 进来的 jemalloc
|
||||
libc = ctypes.CDLL(None)
|
||||
except OSError:
|
||||
return None
|
||||
mallctl = getattr(libc, "mallctl", None) # 动态符号:仅 jemalloc 提供
|
||||
if mallctl is not None:
|
||||
mallctl.argtypes = [ctypes.c_char_p, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_size_t]
|
||||
mallctl.restype = ctypes.c_int
|
||||
if mallctl(_JEMALLOC_ARENAS_ALL_PURGE, None, None, None, 0) == 0:
|
||||
return "jemalloc"
|
||||
return None
|
||||
malloc_trim = getattr(libc, "malloc_trim", None) # 动态符号:仅 glibc 提供
|
||||
if malloc_trim is not None:
|
||||
malloc_trim.argtypes = [ctypes.c_size_t]
|
||||
malloc_trim.restype = ctypes.c_int
|
||||
malloc_trim(0)
|
||||
return "glibc"
|
||||
return None
|
||||
|
||||
|
||||
def memory_monitor(threshold_mb: Optional[float] = None) -> Callable:
|
||||
"""
|
||||
内存监控装饰器,当内存使用超过阈值时自动触发垃圾回收
|
||||
|
||||
@@ -4,7 +4,7 @@ import gc
|
||||
|
||||
from app.application.backup import BackupArtifact
|
||||
from app.application.database import get_database_governance
|
||||
from app.runtime.gc import get_memory_usage
|
||||
from app.runtime.gc import get_memory_usage, release_allocator_memory
|
||||
from app.runtime.log import logger
|
||||
from app.scheduler.contract import _SchedulerOwnerBase
|
||||
|
||||
@@ -30,9 +30,14 @@ class SchedulerMaintenanceOwner(_SchedulerOwnerBase):
|
||||
"""
|
||||
memory_before = get_memory_usage()
|
||||
collected = gc.collect()
|
||||
# gc 只把对象还给分配器,还需让分配器把空闲页交还系统,RSS 才会真正下降
|
||||
allocator = release_allocator_memory()
|
||||
memory_after = get_memory_usage()
|
||||
memory_freed = memory_before - memory_after
|
||||
logger.info(f"主动内存回收完成,回收对象数: {collected},释放内存: {memory_freed:.2f} MB")
|
||||
logger.info(
|
||||
f"主动内存回收完成,回收对象数: {collected},分配器归还: {allocator or '未执行'},"
|
||||
f"释放内存: {memory_freed:.2f} MB"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def agent_heartbeat() -> None:
|
||||
|
||||
@@ -285,7 +285,11 @@ RUN mkdir -p \
|
||||
# final 阶段: 安装运行时依赖和配置最终镜像
|
||||
FROM prepare_package AS final
|
||||
|
||||
# jemalloc 默认按 CPU 数×4 建 arena 且没有后台回收线程,空闲 arena 的脏页会长期计入 RSS。
|
||||
# 后台线程按 5 秒衰减归还脏页(muzzy 直接归还,避免 MADV_FREE 页继续计入 RSS);
|
||||
# arena 限制为 8 个,带 GIL 的 Python 大块分配竞争很小。用户可在 compose 中覆盖 MALLOC_CONF。
|
||||
ENV LD_PRELOAD="/usr/local/lib/libjemalloc.so" \
|
||||
MALLOC_CONF="background_thread:true,max_background_threads:1,narenas:8,dirty_decay_ms:5000,muzzy_decay_ms:0" \
|
||||
MOVIEPILOT_DOCKER_SUPERVISOR="true"
|
||||
|
||||
# 引入支持 amr 编码的静态 ffmpeg
|
||||
|
||||
105
tests/test_runtime_allocator_release.py
Normal file
105
tests/test_runtime_allocator_release.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""分配器空闲页归还:按进程实际加载的分配器选择 jemalloc purge 或 glibc malloc_trim。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
|
||||
import pytest
|
||||
|
||||
from app.runtime import gc as runtime_gc
|
||||
from app.scheduler.maintenance import SchedulerMaintenanceOwner
|
||||
|
||||
|
||||
class _FakeSymbol:
|
||||
"""模拟 ctypes 导出函数,记录调用参数与签名设置。"""
|
||||
|
||||
def __init__(self, result: int = 0):
|
||||
self.result = result
|
||||
self.calls: list[tuple] = []
|
||||
self.argtypes = None
|
||||
self.restype = None
|
||||
|
||||
def __call__(self, *args):
|
||||
self.calls.append(args)
|
||||
return self.result
|
||||
|
||||
|
||||
class _FakeLib:
|
||||
"""只暴露给定符号的伪 libc,缺失符号与真实 ctypes 一样返回 AttributeError。"""
|
||||
|
||||
def __init__(self, **symbols):
|
||||
self._symbols = symbols
|
||||
|
||||
def __getattr__(self, name):
|
||||
try:
|
||||
return self._symbols[name]
|
||||
except KeyError:
|
||||
raise AttributeError(name) from None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def linux(monkeypatch):
|
||||
monkeypatch.setattr(runtime_gc.sys, "platform", "linux")
|
||||
|
||||
|
||||
def _use_lib(monkeypatch, lib) -> None:
|
||||
monkeypatch.setattr(runtime_gc.ctypes, "CDLL", lambda _name: lib)
|
||||
|
||||
|
||||
def test_jemalloc_purges_all_arenas(linux, monkeypatch):
|
||||
mallctl = _FakeSymbol()
|
||||
trim = _FakeSymbol()
|
||||
_use_lib(monkeypatch, _FakeLib(mallctl=mallctl, malloc_trim=trim))
|
||||
|
||||
assert runtime_gc.release_allocator_memory() == "jemalloc"
|
||||
assert mallctl.calls == [(b"arena.4096.purge", None, None, None, 0)]
|
||||
assert mallctl.restype is ctypes.c_int
|
||||
# jemalloc 生效时不再调用 glibc 接口
|
||||
assert trim.calls == []
|
||||
|
||||
|
||||
def test_jemalloc_failure_does_not_fall_back_to_glibc(linux, monkeypatch):
|
||||
mallctl = _FakeSymbol(result=22)
|
||||
trim = _FakeSymbol()
|
||||
_use_lib(monkeypatch, _FakeLib(mallctl=mallctl, malloc_trim=trim))
|
||||
|
||||
assert runtime_gc.release_allocator_memory() is None
|
||||
assert trim.calls == []
|
||||
|
||||
|
||||
def test_glibc_trims_when_jemalloc_absent(linux, monkeypatch):
|
||||
trim = _FakeSymbol(result=1)
|
||||
_use_lib(monkeypatch, _FakeLib(malloc_trim=trim))
|
||||
|
||||
assert runtime_gc.release_allocator_memory() == "glibc"
|
||||
assert trim.calls == [(0,)]
|
||||
|
||||
|
||||
def test_unknown_allocator_is_a_no_op(linux, monkeypatch):
|
||||
_use_lib(monkeypatch, _FakeLib())
|
||||
|
||||
assert runtime_gc.release_allocator_memory() is None
|
||||
|
||||
|
||||
def test_non_linux_platform_skips_native_calls(monkeypatch):
|
||||
monkeypatch.setattr(runtime_gc.sys, "platform", "darwin")
|
||||
|
||||
def unexpected(_name):
|
||||
raise AssertionError("非 Linux 平台不应加载 C 库")
|
||||
|
||||
monkeypatch.setattr(runtime_gc.ctypes, "CDLL", unexpected)
|
||||
|
||||
assert runtime_gc.release_allocator_memory() is None
|
||||
|
||||
|
||||
def test_full_gc_releases_allocator_after_collect(monkeypatch):
|
||||
order: list[str] = []
|
||||
monkeypatch.setattr("app.scheduler.maintenance.gc.collect", lambda: order.append("collect") or 3)
|
||||
monkeypatch.setattr(
|
||||
"app.scheduler.maintenance.release_allocator_memory", lambda: order.append("release") or "jemalloc"
|
||||
)
|
||||
monkeypatch.setattr("app.scheduler.maintenance.get_memory_usage", lambda: 100.0)
|
||||
|
||||
SchedulerMaintenanceOwner.full_gc()
|
||||
|
||||
assert order == ["collect", "release"]
|
||||
Reference in New Issue
Block a user