九月中旬,我在笔记里写下过一句判断:「绝对值大不是泄漏的证据,斜率才是。」
依据是三次采样。5 分 20 秒里,那台 Mac 上五个常驻进程的内存一位都没变:21GB、36GB、6259MB、2128MB、5408MB,三次一模一样。斜率为零,我就判了:常驻的东西太多、稳态就吃满,跟泄漏无关。砍掉两个大户,收工。
今天上午我又看了一眼同一台机器。一个文本向量服务,开机 17 分钟时 1.6GB,现在 22GB。中间几个点几乎连成一条直线,每小时涨 0.23 到 0.26GB。旁边的语音转文字服务停在 58GB,交换区 41984MB 已经用掉 40989MB。
那句判据本身没毛病,毛病出在我测斜率的方式上。而且斜率其实就躺在我九月的记录里,第 1 章最后一段会指给你看。
想直接要那个除法的看第 2 章;想知道这次我的数字哪里还不能信,看第 5 章。
1. 机器,和那次误判
M1 Max,64GB 统一内存。统一内存的意思是 CPU 和 GPU 共用一块内存,显存就是内存。上面常驻几个给我自己的 AI 助手用的小模型服务:语音转文字、文本向量(把一段话变成一串数字,拿来做语义搜索)、语音合成。前两个跑在 MLX 上,就是苹果自家的那个机器学习框架。
九月那次是 kernel panic,日志签名很典型:一个系统守护进程 182 秒没去看门狗那里报到,内存压缩器 100% 满,外加一串 Jetsam 记录。Jetsam 就是 macOS 内存不够时出来杀进程的那个机制。说白了,内存压力大到连系统进程都排不上队,看门狗以为机器死了,直接重启。
大家的第一反应都是「Python 内存泄漏」,我也是。先采了一轮,发现用错了工具(后面清单第 1 条会说),换工具重采三次,三次持平,于是推翻泄漏,改判超配。
现在翻回那份记录,有两行当时没当回事:正式采样前 7 分钟,我顺手查过一次,语音转文字服务是 33GB,向量服务是 1574MB;正式采样的第一个点,它俩是 36GB 和 2128MB。7 分钟里一个涨了 3GB,一个涨了 554MB,斜率就在我自己手里,只是它们没被算进「那三个点」。
2. 一个除法:你至少要盯多久
能看出斜率的最短观察时间 ≈ 读数分辨率 ÷ 斜率
就是一个除法。代这次的数:
- macOS 自带的
footprint工具,大进程按整 GB 显示(我拿到的都是「21 GB」「58 GB」这种),几个 GB 的进程才显示到 MB(「5408 MB」)。那几个大进程的分辨率就是 1GB - 向量服务这三天半的平均斜率:0.235GB/小时
1 GB ÷ 0.235 GB/小时 ≈ 4.3 小时
我的窗口:5 分 20 秒
按这个斜率,5 分 20 秒只会涨约 21MB,是读数分辨率的 1/48
21MB 落在一个显示「21 GB」的读数上,末位根本不会动。三个点一模一样,跟「它正在泄漏」完全不矛盾。我把「仪器看不见」读成了「没有变化」。
还有更糟的一层。当时向量服务才 2128MB,这个量级 footprint 精确到 MB,21MB 的变化照理看得出来,可它也纹丝不动。而前面那 7 分钟它明明涨了 554MB。最可能的解释是:这类服务的内存只在处理请求时涨,请求是一阵一阵来的,我那 5 分钟正好赶上它闲着。这条我没法坐实,第 5 章说为什么。
所以那个除法还得补半句:
斜率要按「处理了多少请求」算,别按墙钟时间算
窗口里没流量,任何泄漏的斜率都是 0
拿这个去看别人说的「我盯了十分钟,内存很稳,没泄漏」就很好拆。先问他读数精确到多少,再问那十分钟里服务干了多少活。两个答案都拿不出来,这句话就没什么信息量。
3. 停住不涨就健康吗?58GB 这个数有点眼熟
向量服务在涨。语音转文字服务正好反过来:开机 17 分钟时 4.9GB,两天后 57.9GB,又过了 20 个小时还是 57.9GB,今天 58GB。按我九月那条判据,它斜率为零,该归到「稳态吃满」那边,算正常。
可 58 这个数太巧了。我在同一台机器、语音服务用的那个 Python 环境里,把 MLX 的默认值读了出来:
内存总量: 64.0 GiB
系统建议给 GPU 的工作集上限: 60.8 GiB
MLX 默认缓存上限(cache limit): 60.8 GiB
MLX 用完一块 GPU 内存,不会马上还给系统,而是先放进自己的缓存池,下次要同样大小的直接拿来用,省掉重新申请的开销。这个池子的上限,默认就等于系统建议给 GPU 的全部工作集,也就是 60.8GiB。
换句话说,一台 64GB 的机器,单个进程的缓存默认被允许长到 60.8GiB。语音服务停在 58GB,看起来是一路长到了这个天花板底下,然后被按住。它停住,是因为撞顶了。
两个服务自己的代码我都 grep 过,set_cache_limit、clear_cache 一次也没调。
4. 一个四十来行的探针:缓存什么时候涨
光知道默认值还不够,我想知道它在什么条件下涨。写了个小脚本,同一个 MLX 版本(0.32.2),跑三组,每组 120 次「申请一块 GPU 内存、算一下、扔掉」,最后停下来空闲 15 秒:
| 组 | 第 50 次后缓存 | 第 100 次后缓存 | 结束后空闲 15 秒 | 空闲时进程 footprint |
|---|---|---|---|---|
| 每次随机 1–4MB | 206.1 MB | 369.6 MB | 403.5 MB | 422 MB |
| 每次固定 4MB | 8 MB | 8 MB | 16 MB | 34 MB |
| 随机 1–4MB + 上限 128MB | 127.0 MB | 126.7 MB | 128.6 MB | 147 MB |
看出来三件事:
- 尺寸固定,缓存基本不涨;尺寸一变,一路往上爬。 403.5MB 对 16MB,差 25 倍。真实服务里每段音频长短不一,每批文本长度不一,申请的尺寸天天在变。
- 闲着不还。 停下来等 15 秒,缓存一个字节都没少。所以「闲着的时候内存也不降」既证明不了它在泄漏,也证明不了它没泄漏。
- 设了上限就停在上限。 128MB 的上限,最后就是 128.6MB。footprint 跟着缓存走,大致是缓存再加十几 MB 的底子。
这个形状跟线上看到的对得上:尺寸多变,缓存一直涨,涨到上限停下。向量服务每次处理的东西小,可能还在爬;语音服务每次的块大,早就爬到了顶。
然后把账算一遍:
账面上可能占用的内存 = 常驻进程数 × 单进程缓存上限
2 个 MLX 服务 × 60.8 GiB = 121.6 GiB
物理内存 64 GiB,账面开到了 1.9 倍
每个进程都觉得整台机器是它的。10 月 13 日那次重启前 6 分钟,Jetsam 报告里两个 Python 进程一个 56.8GB、一个 48.7GB,加起来 105.5GB(账面数,含换出去的部分,第 5 章细说),是整机内存的 1.6 倍。这笔账兑现过一回了。
今天上午还有一个挺骗人的读数:系统的内存压力工具说「可用 58%」,同一时刻交换区快满了。闲着的缓存页被压缩、换到了硬盘上,物理内存腾出来了,所以压力工具很乐观。可这些页还归进程所有,哪天它又要用,就得从硬盘读回来。光看「可用百分比」判断机器安不安全,会被这一层骗到。
5. 这次的数字哪里还不能信
- 我没在两个服务进程内部直接读到缓存大小。
mx.get_cache_memory()只能在进程里面调,外面挂不进去。「58GB 是缓存」是推断,证据三条:footprint 里那 58GB 几乎全记在 GPU 缓冲区那一类下面;它停在默认上限稍下方;服务自己的代码里没设过上限。三条都是间接证据,依赖库内部有没有自己设上限我也没查。如果它其实是库里哪儿真泄漏、只是恰好停在 58GB,第 3、4 章都得改。 - 向量服务那条直线一共 4 个点,前 3 个来自 Jetsam 报告里每个进程记的页数,时间点不是我挑的;最后 1 个是 footprint。我拿同一时刻两种来源对过:语音服务 57.9GB 对 58GB,语音合成 5.3GB 对 5407MB,单个进程对得上。但这个页数我九月就栽过一次:把所有进程的加起来报了 117GB,实际物理占用只有 29.9GB。所以第 4 章那个 105.5GB 只能当「账面」看,里面含被压缩、被换到硬盘上的部分,不等于同一时刻真占了这么多物理内存。
- 探针是合成负载,用的是全 1 数组,不是真模型。它能说明「这个版本 MLX 的缓存在尺寸多变时会涨、闲着不还、上限管用」,说明不了真服务一定是这么涨的。
- 第 2 章「那 5 分钟没接到请求」是猜的,两个服务的日志都不记请求。
- 10 月 13 日那次重启的原因我没查实,系统存崩溃报告的那个目录里没有 panic 文件。
- 修法还没上。 给两个服务设上限要重启它们,这一步我还没做。设了上限之后会不会因为频繁重新申请内存变慢,也没测。
6. 下次怀疑内存泄漏,按这个顺序查
- 在 Apple Silicon 上看
footprint报的phys_footprint,别看 RSS。GPU 缓冲区不算进 RSS:九月那次语音服务 RSS 3797MB、footprint 36GB,差 9.5 倍;另一个服务 RSS 427MB、footprint 21GB,差 50 倍。 - 定观察窗口之前先做那个除法:读数分辨率 ÷ 你怀疑的斜率。算出来要 4 小时,就别拿 5 分钟下结论。
- 窗口里要有真实请求。记下这段时间处理了多少请求,按请求数算斜率。
- 看到它停住了,先查它停在哪儿。停在某个默认上限附近(MLX 是系统建议的 GPU 工作集,64GB 的机器上是 60.8GiB),就是缓存撞顶,账要按「常驻进程数 × 上限」算。
- 跑 MLX 的常驻服务,启动时显式调一次
mx.set_cache_limit(),按同时常驻几个进程来分。 - 判断机器安不安全,看交换区用了多少,别只看「可用内存百分比」。