ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Ray 分布式 Actor 内存排查手记:大模型 KV 缓存驻留下跨节点泄漏治理

Ray 分布式 Actor 内存排查手记:大模型 KV 缓存驻留下跨节点泄漏治理 在以 Ray Serve 和 Ray Core 构建的大规模分布式大模型在线推理服务体系中有状态的 ActorStateful Actor构成了整个计算网格的骨架。为了承载千亿参数大模型的多轮对话Multi-turn Conversation与长文本生成我们通常会在 Ray Actor 内部持久化模型显存上下文并在节点本地的 Plasma 共享内存对象存储Object Store或主机内存中驻留跨轮次复用的 KV Cache。然而随着系统在生产环境以万级并发连续运行数周我们遭遇了极其隐蔽且致命的“内存慢性爬升Slow Memory Leak”难题每逢业务高峰期过去数小时后各计算节点的内存使用率不仅没有平稳回落反而持续在高位震荡攀升最终频繁触发 Linux 宿主机的 OOM Killer将运行关键任务的 Ray Worker 进程瞬间强杀引发多节点任务重调度的级联震荡。为了彻底消除这个阻碍大促全天候稳定运行的隐形地雷我带领基础架构团队深入 Ray 底层分布式对象管理源码展开了一场跨越 Python 垃圾回收、Plasma C 内存引用计数与系统级内存碎片的深度溯源与排障实战。内存泄漏现场拓扑与微观特征提取出现内存异常的节点集群由 64 台配备双路 AMD EPYC 处理器与 512GB 物理内存的高性能服务器组成每台节点运行着一个部署在 Kubernetes 上的 Ray Head/Worker Pod通过 Ray Serve 承载对话推理网关服务。1. 现场故障指标特征全局对象数不降反升通过 Prometheus 监控发现尽管夜间业务请求量已经降至白天的 10%但集群内部常驻的活跃 Actor 实例数依然保持饱满且 Ray 内部维护的 Object Ref 计数器持续单调递增。RSS 内存与 Plasma 共享内存双重泄漏宿主机的物理驻留内存Resident Set Size, RSS以每小时约 4.5GB 的速率稳步爬升与此同时本地节点预分配的 120GB Plasma Object Store 使用率长期死锁在 95% 以上无法释放出可供新请求复用的页表空间。常规 Pythongc.collect()失效在 Worker 内部手动触发全量垃圾回收仅能释放几兆字节的临时对象底层数十吉字节的物理内存依旧岿然不动。溯源深水区Plasma 引用悬空与循环闭包陷阱针对这一复杂现象我们兵分两路分别从 Ray 分布式对象树Distributed Object Graph和底层 C 原生分配器进行剖析。1.ray memory深度透视与孤儿对象Orphan Object追踪我们首先在 Ray Head 节点执行内存快照转储穿透分析未释放对象的内存引用链# 抓取当前节点 Object Store 内所有未释放对象的详细生命周期分布 ray memory --stats-only ray memory --filter-typeACTOR_TASK --limit50转储报告暴露出了关键异常内存中滞留着数十万个类型为plasma::PlasmaObject的中间张量引用其所属的任务状态显示早已为FINISHED但REF_COUNT外部引用计数却始终为 1。顺藤摸瓜排查发现在实现流式跨节点输出Streaming Generation时上游推理调度 Actor 将包含 KV 缓存切片元数据的ray.ObjectRef作为参数传递给了下游的任务回调闭包# 致命泄漏根源闭包函数意外捕获了长期存活的 ObjectRef class StreamingInferenceActor: def __init__(self): self.active_sessions {} def generate_stream(self, session_id: str, prompt_ref: ray.ObjectRef): # 错误示范将 ObjectRef 强绑定在局部回调闭包内并存入实例成员字典 def on_token_generated(token_id): # 闭包隐式持有了 prompt_ref 的引用 self.notify_gateway(session_id, token_id, prompt_ref) self.active_sessions[session_id] on_token_generated # 客户端由于网络抖动意外断开未调用 cleanup导致 session_id 无法从字典移除当客户端因网络断连发生异常退出时上游并未收到显式的结束指令导致active_sessions字典永久持有该闭包进而导致 Python 运行时的引用计数机制无法回收prompt_ref。而只要有一个活跃的ObjectRef句柄存在Ray 分布式对象管理器Distributed Memory Manager就绝对不敢从 Plasma 中回收对应的底层物理内存造成底层数以百计的显存与内存张量被永久锁定。2. glibc 内存分配器导致的严重物理页碎片Memory Fragmentation在解决了上述 Python 层的引用悬空后我们发现节点的 RSS 内存依然偏高。通过/proc/[pid]/smaps_rollup对 Ray Worker 进程进行物理内存采样发现了另一个隐蔽的元凶——glibc ptmalloc 的内存空洞陷阱。在频繁分配与释放数十兆字节模型 KV Cache 切片的过程中glibc 的默认多堆内存分配器在经历数百万次交替调用后产生了极其严重的内存页碎片。许多释放掉的内存由于物理页内混杂了少量的微小元数据对象无法直接被brk/mmap归还给 Linux 内核导致操作系统认为该进程依然强占着庞大的物理内存空间。生产级治理方案与架构硬化实操针对上述层层剖析出的物理与逻辑根源我们在 Ray 调度网格中推进了三项针对性硬化改造。1. 显式生命周期接管与生命周期卫兵Lifecycle Guard彻底废弃全局持久化引用的隐式依赖引入基于上下文管理器与弱引用weakref的自动生命周期卫兵。所有跨节点传递的对象引用必须强制设置生存时间TTL并在客户端会话终结或超时发生时立即显式注销import weakref import ray from typing import Dict class SafeSessionLifecycleManager: def __init__(self, session_timeout_seconds: float 30.0): self._timeout session_timeout_seconds # 使用弱引用字典存储会话回调避免跨作用域死循环强引用 self._session_callbacks: Dict[str, weakref.ref] {} def register_session(self, session_id: str, obj_ref: ray.ObjectRef): # 显式将 ObjectRef 交由统一生命周期代理管理超时后主动释放句柄 ray.experimental.internal_kv.put(fsession:{session_id}, active, ttlself._timeout) def force_cleanup_session(self, session_id: str, obj_ref: ray.ObjectRef): 显式通知 Ray 集群立即从本地 Plasma 中销毁该张量 del self._session_callbacks[session_id] # 主动通知 Ray 运行时撤销引用 ray._private.internal_api.free([obj_ref], local_onlyFalse)2. 注入 jemalloc 彻底治理内存碎片在构建 Ray Worker 容器镜像时彻底剥离默认的 glibc 内存分配器强制链接现代高性能内存分配器jemalloc并通过精细的环境变量配置开启积极的后台空闲物理页向内核退还策略# 在 Dockerfile 中集成 jemalloc 运行时 RUN apt-get update apt-get install -y libjemalloc-dev ENV LD_PRELOAD/usr/lib/x86_64-linux-gnu/libjemalloc.so # 配置 jemalloc开启后台线程每 1000 毫秒衰减并主动回收空闲 dirty 页 ENV MALLOC_CONFbackground_thread:true,dirty_decay_ms:1000,muzzy_decay_ms:2000,abort_conf:true3. Worker 进程的主动轮转与平滑退场对于无法完全避免微量语言级内存泄露的复杂业务逻辑在 Ray 启动参数中开启基于处理任务数的健康生命周期轮转机制# 配置 Ray Actor 的自愈型重启配额 ray.remote( max_restarts-1, # 允许无限制自动重启 max_task_retries3, # 瞬时故障自动重试 max_concurrency100 ) class ResilientInferenceWorker: def __init__(self): self.processed_requests 0 def process(self, request_payload): self.processed_requests 1 # 当处理完成 100,000 次推理请求后优雅注销并由 Ray 调度器拉起全新的干净 Worker if self.processed_requests 100000: ray.actor.exit_actor() return self._do_inference(request_payload)治理实战成效复盘经过上述端到端综合治理后我们在相同压力环境下重新对 64 节点生产集群进行了 72 小时连续不间断压测验证监控指标治理前表现治理后表现改进效果24 小时节点 RSS 内存增长超过 108GB持续暴涨稳定在 4.2GB 波动范围内内存泄漏完全被收敛Plasma 共享内存利用率长期死锁在 95%~98%平稳维持在 45%~60% 弹性区间缓存复用与回收完全正常单日 OOM Killer 强杀频次平均 14 次/天0 次彻底杜绝进程意外崩溃P99 推理响应时延伴随 GC 抖动达 1,200ms稳定在 145ms 纯推理耗时消除 GC 引起的调度长尾分布式系统的健壮性往往藏在那些看似最微不足道的引用计数与内存分配细节中。只有敢于深入运行时与操作系统的底层沟壑看透每一个字节从申请、流转到底层回收的完整链路架构师才能在惊涛骇浪的高并发业务场景下让庞大的分布式计算集群保持行云流水般的从容与稳定。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进