起因#
最近偶然看到一篇文章,里面提到一个 Linux 和 Windows 的差异:在 Linux 下,即使机器实际上没有足够的物理内存,一个很大的 malloc 仍然可能成功返回。而 Windows 对 committed memory 的管理要严格得多,类似的 allocation 更容易被直接拒绝。
看到这里,突然想起很多年前负责训练集群时遇到的一个问题。当时一路排查下来,发现恰好和 Linux 的 overcommit 机制有关。
这个 case 的一些具体实现细节已经记不完全了,但背后的问题今天看仍然有意思。于是想把它记录下来,顺便重新整理一下 Linux memory overcommit 背后的 memory model。
后来我又翻到了当年留下的几份排查记录。很多已经模糊的细节重新对上了,也发现当时记录下来的现象比我记忆里的版本更具体。原本我是靠记忆在重建这个 case,现在有了当年的原始日志,可以回头校准一遍。下面讲到的现象都来自当年的日志。
当时的背景#
那时我们用 Kubernetes 管理训练集群。用户提交 K8s Job,每个 Job 声明 CPU 和 memory resource,不同 Job 被调度到同一台物理机上。
训练任务里有相当一部分属于 data-intensive workload:从存储持续读取训练数据,通过 NumPy 做加载、转换、shuffle、buffer 管理等。多个 Job colocate 在同一个 Node 上。
翻出当年的排查记录后,有一组现象特别清晰。某台 GPU Node 上同时跑着 6 个训练 Job,它们在晚上 20:45 左右先后启动。一开始一切正常。但从 21:50 开始,第一个 Job 陷入了几乎不再向前推进的状态。随后其他 Job 也在 22:48、23:04、23:26、23:27、23:29 陆续卡住。
不是同时失败。每个 Job 是各自跑到不同阶段后,分别陷入 stall 的。
卡住的位置高度集中在涉及大块 NumPy 内存申请和转换的代码附近。而且"卡住"并不意味着完全冻结——部分任务偶尔还能非常缓慢地向前推进一点点。
最反直觉的是第二天发生的事:08:17,有人手动关闭了其中一个已经卡了几个小时的 Job。从这个时间点开始,另外 5 个 Job 很快全部恢复了正常执行。
16 个 Job 同时启动
2 ↓
3并没有同时失败
4 ↓
5各自 workload 推进到不同阶段后
6在不同时间陆续陷入 stall
7 ↓
8系统仍然有极慢的 progress
9 ↓
10kill 掉其中一个 Job
11 ↓
12剩余 Job 几乎同时恢复这组现象至少说明三件事:问题具有明显的 aggregate resource contention 特征;最后卡住的那个 Job 并不是单独的 root cause;系统不是严格意义上的 deadlock,因为仍然存在微弱 progress,而且释放一个大 workload 后可以立即恢复。
排查最后发现,问题和 Linux 的 memory overcommit 有关。当时集群统一配置了 vm.overcommit_memory = 1,也就是 always overcommit。
这里不打算展开 overcommit 的参数细节,更想回答一个更基础的问题:
一个程序说自己"申请了 100GB 内存",到底意味着什么?
进一步说:Kubernetes 认为一个 Job 有 100Gi memory、Linux 允许进程申请 100GB virtual memory、进程实际 RSS 有 100GB、机器真的交出了 100GB physical pages——这些是同一件事吗?
显然不是。而当年那个问题,恰好就出在这几层概念之间的缝隙里。
创建了一块内存,不代表 RAM 立刻增加同样大小#
训练 workload 里经常需要创建很大的 buffer。当年多个 Job 卡住的位置,高度集中在类似这样的代码附近:
1data = np.zeros(shape).astype(np.float32)这行代码看起来人畜无害,但从 memory 角度看其实挺激进。np.zeros(shape) 在没有显式指定 dtype 时,默认创建 float64 数组。随后 .astype(np.float32) 通常会创建一个新的 float32 array。也就是说,逻辑上这里同时存在一个 float64 source 和一个 float32 destination。相比直接写 np.zeros(shape, dtype=np.float32),memory demand 天然更高。
但不能简单地把逻辑上的 allocation size 直接当成瞬时 physical memory 占用。Linux demand paging 使得 logical allocation 与实际 resident physical pages 的增长不同步——代码在逻辑上创建了很大的 memory demand,但这个 demand 并不一定在 np.zeros 返回的那一刻完整表现为 RSS。后续 astype、数据填充和其他访问逐步 touch pages,physical demand 才真正暴露出来。
这恰好是 overcommit 和 first-touch 的一个真实案例。
直觉上,“创建一个巨大的数组"意味着机器要立刻拿出同等大小的物理内存。但实际上不一定。
“申请了一块 memory"可能至少涉及几个不同层面的东西:
| 概念 | 含义 |
|---|---|
| Virtual Address Space | 进程获得的虚拟地址范围 |
| Committed Memory | 系统承诺过的 memory commitment |
| RSS(Resident Set Size) | 实际驻留在物理内存中的部分 |
| Physical Pages | kernel 真正分配出去的物理页 |
这里面最先需要建立的认知是:
Virtual Address Space 不等于 Physical Memory。
现代操作系统不会因为一个进程获得了一块巨大的虚拟地址范围,就立刻准备同等规模的 physical pages。进程拿到的只是一段地址空间。这段地址空间背后对应多少实际的物理内存,取决于程序后续如何使用它。
对于 np.zeros(shape).astype(np.float32) 这类操作,这一点尤其容易观察到。NumPy 的大数组 allocation 叠加 Linux 的 virtual memory 和 demand paging 机制,使得"数组创建成功"和"对应 physical pages 全部 resident"可以发生在完全不同的时间。
需要明确:这不是 NumPy 自己实现的"延迟分配”。NumPy 调用底层的 memory allocator(通常是 malloc 或 mmap),而 Linux kernel 决定了什么时候真正分配物理页。
First Touch:真正的 memory demand 什么时候出现#
上面说虚拟地址不等于物理内存。那物理内存到底什么时候才被真正占用?
答案是:当程序实际访问(touch)某一页虚拟地址时。
模型是这样的:
1申请逻辑上很大的 buffer
2 ↓
3virtual allocation 成功
4 ↓
5RSS 不一定立即按照 buffer 的完整逻辑大小增长
6 ↓
7程序开始读取数据并写入 buffer
8 ↓
9逐页访问
10 ↓
11page fault
12 ↓
13kernel 分配 physical page
14 ↓
15后续访问逐步产生实际 physical demand也就是说,**logical allocation size 和 resident physical memory 的增长在时间上可能明显解耦。**不是 np.zeros(shape) 返回的那一瞬间就吃掉了对应大小的 RAM。更可能是在之后 astype、数据填充和写入的过程中,随着程序逐步访问不同的地址,physical pages 才逐渐被 materialize 出来。
这里有一个容易混淆的点:page fault 不等于 swap。Page fault 是一个正常的机制——进程第一次访问一个还没有被映射到物理页的虚拟地址,kernel 会通过 page fault 把物理页分配上去。这在进程正常启动和使用内存的过程中一直在发生。
回到当年的现象。这恰好解释了为什么很多 Job 一开始都可以正常启动——它们在启动阶段创建了各自需要的 buffer,获得了虚拟地址空间,但实际的 physical memory 占用还远没有达到声称的大小。
问题不是一启动就 OOM,而是运行了一段时间后,随着数据不断被加载和写入,physical pages 逐渐被 touch 出来,节点才慢慢进入压力区间。
Overcommit:系统应该在什么时候拒绝你#
理解了 virtual address 和 physical page 的脱钩之后,接下来的问题是:操作系统应该怎么管理这种脱钩?
假设一个进程申请 100GB virtual memory。操作系统是否应该在这一刻就要求自己能够保证:
未来任何时候,如果这个进程真的把 100GB 全部 touch 一遍,我都有能力提供完整的 100GB backing memory。
如果所有 allocation 都按照 worst case 做 admission control,系统会非常保守。
因为现实里有很多 allocation 最终不会完全 materialize:
- 进程 reserve 了很大的 address space,但只使用其中一部分
fork创建子进程时,父子共享页面(COW),大部分页面不会真的被复制- 提前按最大规模创建 buffer,但 workload 每次并不会真的达到 peak
- Sparse 使用模式:分配了连续的大数组,只访问其中稀疏的一些位置
如果系统把所有这些 allocation 都按照"一定会全部兑现"来做 accounting,那可运行的进程数量会远小于实际可以承载的数量。
于是有了 overcommit 的思路:
允许系统接受超过当前可兑现 physical capacity 的 memory commitment。
因为这些承诺未必会同时被兑现。
可以这样理解:memory commitment 更像一种承诺——进程说"我可能需要这么多”,而 kernel 说"好的,先给你"。但真正的 physical pages 只在进程 touch 到的时候才被兑现。
Overcommit 赌的是:不是所有承诺都会同时被要求兑付。
大多数时候,这个赌注是赢的。
vm.overcommit_memory = 1#
Linux 通过 vm.overcommit_memory 这个 sysctl 控制 overcommit 策略:
| 值 | 模式 | 行为 |
|---|---|---|
| 0 | Heuristic | kernel 按照一些启发式规则决定是否拒绝过大的 allocation |
| 1 | Always overcommit | 永远不在 allocation 时拒绝(除非地址空间本身不够) |
| 2 | Strict accounting | 根据物理内存 + swap 做严格的 commit accounting |
当年集群的配置是 vm.overcommit_memory = 1。
Mode 1 的 心智模型:
1allocation time
2 ↓
3不做 commit accounting 检查,直接允许
4 ↓
5...
6 ↓
7runtime:程序 touch pages
8 ↓
9尝试兑现 physical memory这里需要准确理解因果关系。
overcommit=1 本身并不会"制造"任何 reclaim 或 OOM。它做的事情是:让 memory commitment 与 physical capacity 更容易脱钩。
资源不足不一定在 allocation 阶段暴露,而可能被推迟到之后真正使用这些 pages 的时候。
换句话说,问题不是发生在"承诺"的时候,而是发生在"兑现"的时候。
多个训练 Job:问题是慢慢积累的#
回到当年的场景。一台 Node 上可能同时跑着好几个训练 Job。
每个 Job 都在不断做类似的事情:
1read training data → populate NumPy buffers → touch more pages → RSS grows它们的 aggregate physical demand 是逐渐演进的:早期,所有 Job 的 aggregate demand 远低于 Node 可承受的范围,大家都正常运行。随着训练持续,各个 Job 的 buffer 逐渐被填充,RSS 稳步增长。
当年排查记录里最有说服力的一个现象是:并不需要重启 Node,也不需要把所有任务清空。只要人工关闭其中一个 memory-heavy 的 Job,其他原本已经卡了几个小时的任务就会迅速恢复正常。
而且这个现象后来在不止一台机器上重复出现过。另一批排查记录中也有类似的模式:几个不同用户的大 Job colocate 在同一台机器上,一起陷入 stall;人工删除其中一两个 memory-heavy Job 后,其他任务立即恢复。
这也是今天重新看这个 case 时,我更倾向于把它理解成 severe shared memory pressure,而不是某一个进程自身的 allocation bug。
这里需要注意一件事。最后一个被 kill 的 Job 不应该被描述为"出问题的 Job"。
它只是碰巧被选中关闭的那个。之前所有 Job 的 RSS 增长都在贡献同样的压力。
Trigger 不等于 root cause。
为什么不是 OOM,而是所有 Job 一起变慢#
physical memory 开始紧张之后,会发生什么?
很多人直觉上会想到 OOM Killer。但 Linux 在真正启动 OOM Killer 之前,会先尝试通过 reclaim 把内存腾出来。
Reclaim 可能涉及多种机制——page cache reclaim、writeback、swap(如果开启)、compaction 等。不需要逐个展开。这里只讲其中一个对 latency 影响最直接的机制:direct reclaim。
正常情况下,kernel 维护着一定量的 free pages。当一个线程需要新的 physical page(比如通过 page fault),kernel 可以直接从 free list 里拿。
但当 free pages 低于某个阈值,并且 kswapd(后台 reclaim 线程)还没来得及回收足够的页面时,正在申请 physical page 的线程本身会被迫进入 reclaim 的 slow path。
也就是说:
1Job 正常执行
2 ↓
3touch new page
4 ↓
5page fault
6 ↓
7需要 physical page
8 ↓
9free pages 不足
10 ↓
11当前线程进入 direct reclaim
12 ↓
13等待 reclaim 完成
14 ↓
15thread stall这个线程不会收到一个 ENOMEM。它只是被卡住了——等待 kernel 腾出足够的空闲页面。
用户观察到的是:Job 突然变得非常慢,而不是报错退出。
而且因为 memory pressure 是 Node 级别的,同一台机器上的所有 Job 都可能在 page fault 时遇到 direct reclaim。于是表现为:所有 Job 一起变慢。
这和当年的现象正好对应。
当年的 Grafana 记录里还有一个细节:**任务严重卡住的时候,从宿主机高层内存指标看,并不是已经到了严格意义上的 0 free / 100% exhausted。**甚至在 kill Job 之前,机器的内存占用还在非常缓慢地继续增长。
这意味着不应该用"内存彻底用完了"来描述当时的状态。更准确的 心智模型 是:
1不是:
2RAM = 100% → 突然开始 reclaim
3
4而是:
5memory pressure 越来越高
6→ reclaim 越来越频繁
7→ useful progress 越来越少严重 reclaim 并不要求"最后一个 byte 的 RAM 已经被使用"。Linux 会根据 watermarks 等条件在真正完全耗尽之前就开始 reclaim。aggregate demand 把 Node 推进了严重的 memory-pressure 区间,就足以让所有 workload 的 progress 急剧下降。
不过需要坦白:当年并没有在线上一路追到具体的 kernel 调用栈,确认每一次 stall 都是 direct reclaim 导致。Direct reclaim / reclaim thrashing 是根据观测到的现象建立的机制解释,而不是当年通过 trace 得到的已证实调用栈。我们没有当时的 pgscan_direct、allocstall、PSI 等精确指标。它是解释这种 latency pattern 的一个重要且合理的机制,但我不能声称它是当年唯一的路径。
I/O-heavy 训练 Workload 的放大效应#
如果只有 anonymous memory 增长导致 direct reclaim,问题可能已经够严重了。但对于 data-intensive 训练 workload,还有一个放大效应。
Physical RAM 不只容纳训练进程的 anonymous memory(比如 NumPy 数组)。还有大量的 page cache——Linux 用来缓存文件系统 I/O 的内存区域。
训练 Job 不断从存储读取数据,这些数据会经过 page cache。同时,NumPy buffer 对应的 anonymous pages 也在持续增长。
随着 anonymous memory 越来越多,memory pressure 上升,kernel 会开始回收 page cache。
对一个 I/O-heavy workload 来说,page cache 被回收意味着:之前缓存在内存里的训练数据没了,下次读取时必须重新从 storage 读。
于是可能出现两个方向同时恶化:
一边是 page allocation 变慢(direct reclaim),另一边是数据读取变慢(page cache 没了,重新走 storage I/O)。两者同时作用在同一个 workload 上。
同样需要说明:这是对 data-intensive 训练场景下严重 memory pressure 的一种合理机制解释,并不是在声称当年已经确认的唯一 root cause。
Kubernetes 为什么没有解决这个问题#
可能会有一个疑问:Kubernetes 不是做了资源管理吗?Job 声明了 memory request 和 limit,K8s 应该已经做了资源隔离和调度,为什么还会出这种问题?
先看 Job YAML 里写的:
1resources:
2 requests:
3 memory: 100Gi这行配置的作用是告诉 K8s scheduler:调度这个 Job 时,目标 Node 需要有至少 100Gi 的可分配内存额度。
但这不意味着 Linux 已经锁住了 100GB physical RAM 专门给这个 Job。
K8s 的 memory request 和 memory limit 是两个不同的东西:
- memory request 主要用于 scheduler 层面的 resource accounting,影响 Pod 被调度到哪台 Node
- memory limit 才对应 cgroup 层面的 hard memory enforcement——通过 cgroup 设置实际的内存上限
这里有好几个不同的数字,容易混在一起:
| 概念 | 来自哪一层 | 含义 |
|---|---|---|
| K8s request | Kubernetes scheduler | 调度 accounting,影响 placement |
| K8s limit | Kubernetes → cgroup | cgroup 层面的 memory 上限 |
| Virtual memory | Linux VM | 进程的虚拟地址空间 |
| Committed memory | Linux VM | 系统承诺过的 memory |
| RSS | Linux VM | 实际 resident 的物理页 |
| Physical pages | Hardware | 真正被占用的物理内存 |
Kubernetes 的资源模型没有替代 Linux 的内存管理。它只是叠在 Linux / cgroup 上面的另一层资源抽象。
进一步说,这里存在两个不同层面的 memory pressure:
- Global memory pressure:整台 Node 的物理内存不够用
- Memcg(memory cgroup)pressure:某个 cgroup 自己的 memory 用量接近 limit
Node 还有内存,不代表某个 cgroup 不会进入 reclaim。反过来,某个 Job 没达到自己的 limit,也不代表整台 Node 不会出现 aggregate memory pressure。
当年的问题恰好是后一种:每个 Job 可能都没有超过自己的 limit,但它们的 aggregate physical demand 加在一起,把整台 Node 推进了严重的 memory-pressure 区间。
回到当年的问题#
现在可以用一个更完整的模型重新描述当年发生的事情。
Node 上同时运行着多个 data-intensive 训练 Job。vm.overcommit_memory=1 使 memory commitment 可以在 allocation 阶段顺利通过——kernel 不做 commit accounting 检查,进程想申请多少虚拟内存就给多少。
真正的 physical memory demand 不是在 allocation 时出现的,而是随着训练数据不断被加载、buffer 不断被写入,通过 page fault 逐步兑现。
当所有 Job 的 aggregate physical demand 把节点推进严重的 memory-pressure 区间后,Linux 开始进入越来越激进的 reclaim。不需要 RAM 真正用到 100%——pressure 越高,reclaim 越频繁,useful progress 越少。
完整的链路比较长,拆成上下两段:
OOM 是可能的最终结果,但不是必然的——在上面的链路里,它只是一个虚线分支。OOM Killer 是另一套 policy:overcommit 决定"之前允许承诺多少",真正进入 OOM 后,kernel 如何选择 victim 又由 oom_score / oom_score_adj 等机制决定。这两个概念不要混在一起。
在 OOM 之前,系统往往已经在 reclaim 的泥潭里挣扎了很久。用户最先感知到的通常是性能骤降,而不是进程被杀。
后来我们做了一个实验:挑了 6 台机器,把 vm.overcommit_memory 从 1(always overcommit)灰度修改为 0(heuristic overcommit)。第二天查看这些机器,没有再观察到之前那种明显的大规模 stall。但由于同期用户也调整了自己的 workload,这个实验只能算 supporting evidence,而不是一个干净的 controlled experiment。需要说明的是,0 是 heuristic overcommit,不是 disable overcommit——严格的 commit accounting 是 mode 2。
所有证据都指向 severe memory pressure,而 overcommit=1 是让这种压力能够被推迟到 runtime 暴露的重要条件之一。但我们没有 kernel trace、没有 reclaim stall 的精确耗时分布、没有 PSI 指标,也没有能彻底排除其他细粒度资源竞争的 controlled experiment。这些工程现象能支持一个合理而有解释力的 memory model,但它们并不构成一份完整的 kernel trace。
回头看这个 case,对于共享训练集群来说,内存超卖最危险的状态不一定是 OOM。
OOM 至少意味着 failure is explicit——victim 被 kill,resources 被释放,系统可能恢复。更难处理的反而是:系统没有 OOM,任务没有 crash,Node 没有 reboot,但所有 workload 都在极慢地 progress。资源竞争和 reclaim 消耗了绝大多数时间。
系统 technically alive,但 useful progress 接近于零。
这和当年的真实记录高度吻合:Job 卡几个小时但没有死;memory 仍缓慢增加;偶尔某些 Job 能前进一点;kill 一个大 Job 后其他任务迅速恢复。
Overcommit 是一种 Admission Control#
如果把视角再拉远一点,overcommit 面临的 trade-off 其实在很多系统里反复出现。
CPU oversubscription、K8s requests vs actual usage、memory overcommit、GPU memory scheduling、cluster capacity planning——这些问题都在回答同一个问题:
资源 admission control 到底应该按照什么口径做?
可以按 worst-case peak 做:
1安全,不会出现 runtime 资源不足
2但 utilization 低,很多资源空着没人用也可以允许 oversubscription:
1utilization 高,同样的硬件跑更多 workload
2但 tail risk 被留到了 runtimeOvercommit 只是这个资源调度 trade-off 在 virtual memory 上的一种具体表现。
真正困难的不是"这台机器有多少 GB 内存",而是系统应该在什么时候、根据什么信息,决定是否接受新的资源承诺。
总结#
回到那个简单的:
1memory: 100Gi在不同的抽象层里,这个数字背后可能是完全不同的东西:
平时这些差异都藏在抽象层后面。只有进入资源压力区间,抽象才开始一层层漏出来。
很多资源问题真正麻烦的地方,并不是资源真的用完了,而是系统的不同层对于"这份资源已经分配给谁"有着不同的定义。
这次找到当年的排查记录后,还有一层更具体的认识:资源超卖的风险,不只是"承诺最终无法兑现"。更麻烦的是,在彻底无法兑现之前,系统可能已经付出很大代价努力维持这些承诺。 Overcommit 的代价不是只有 OOM。latency collapse、throughput collapse 本身就是 failure mode。