起因#
最近偶然看到一篇文章,里面提到一个 Linux 和 Windows 的差异:在 Linux 下,即使机器实际上没有足够的物理内存,一个很大的 malloc 仍然可能成功返回。而 Windows 对 committed memory 的管理要严格得多,类似的 allocation 更容易被直接拒绝。
看到这里,突然想起很多年前负责训练集群时遇到的一个问题。当时一路排查下来,发现恰好和 Linux 的 overcommit 机制有关。
这个 case 的一些具体实现细节已经记不完全了,但背后的问题今天看仍然有意思。于是想把它记录下来,顺便重新整理一下 Linux memory overcommit 背后的 memory model。
当时的背景#
那时我们用 Kubernetes 管理训练集群。用户提交 K8s Job,每个 Job 声明 CPU 和 memory resource,不同 Job 被调度到同一台物理机上。
训练任务里有相当一部分属于 data-intensive workload:从存储持续读取训练数据,通过 NumPy 做加载、转换、shuffle、buffer 管理等。多个 Job colocate 在同一个 Node 上。
我们遇到了一个很奇怪的现象:
一台机器刚开始可以正常运行多个 Job,Kubernetes 从调度层面看也没有明显异常。但随着任务持续运行,有时会在某个 Job 进入新的数据加载阶段之后,整台机器上的 Job 突然一起变得非常慢。
不是 malloc 立刻失败,也不是某个 Job 突然 OOM。更像:
1多个 Job 正常运行
2 ↓
3各自持续加载数据
4 ↓
5实际内存占用逐渐增长
6 ↓
7某个 Job 又增加了一段 memory demand
8 ↓
9跨过某个临界点
10 ↓
11整台 Node 开始明显卡顿
12 ↓
13同机所有 Job 一起变慢排查最后发现,问题和 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。比如用 NumPy 创建一个巨大的数组来存放训练数据。
直觉上,“创建一个 50GB 的数组"意味着机器要立刻拿出 50GB 的物理内存。但实际上不一定。
“申请了一块 memory"可能至少涉及几个不同层面的东西:
| 概念 | 含义 |
|---|---|
| Virtual Address Space | 进程获得的虚拟地址范围 |
| Committed Memory | 系统承诺过的 memory commitment |
| RSS(Resident Set Size) | 实际驻留在物理内存中的部分 |
| Physical Pages | kernel 真正分配出去的物理页 |
这里面最先需要建立的认知是:
Virtual Address Space 不等于 Physical Memory。
现代操作系统不会因为一个进程获得了一块巨大的虚拟地址范围,就立刻准备同等规模的 physical pages。进程拿到的只是一段地址空间。这段地址空间背后对应多少实际的物理内存,取决于程序后续如何使用它。
对于 NumPy 这类大数组 workload,这一点尤其容易观察到。NumPy 的大数组 allocation 叠加 Linux 的 virtual memory 和 demand paging 机制,使得"数组创建成功"和"对应 physical pages 全部 resident"可以发生在完全不同的时间。
需要明确:这不是 NumPy 自己实现的"延迟分配”。NumPy 调用底层的 memory allocator(通常是 malloc 或 mmap),而 Linux kernel 决定了什么时候真正分配物理页。
First Touch:真正的 memory demand 什么时候出现#
上面说虚拟地址不等于物理内存。那物理内存到底什么时候才被真正占用?
答案是:当程序实际访问(touch)某一页虚拟地址时。
模型是这样的:
1申请一个 50GB buffer
2 ↓
3获得对应的 virtual address range
4(此时 physical memory 几乎没有变化)
5 ↓
6程序开始读取数据并写入 buffer
7 ↓
8逐页访问
9 ↓
10page fault
11 ↓
12kernel 分配 physical page
13 ↓
14RSS 逐渐增长也就是说,不是创建 50GB 数组的那一瞬间突然吃掉 50GB RAM。而可能是在之后加载、初始化和写入数据的过程中,随着程序逐步访问不同的地址,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 的 mental model:
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 是这样演进的:
flowchart TD
subgraph earlyPhase ["早期"]
A1["Job A: RSS ↑"]
B1["Job B: RSS ↑"]
C1["Job C: RSS ↑"]
D1["Job D: RSS ↑"]
E1["aggregate demand 远低于 Node capacity"]
end
subgraph latePhase ["后期"]
A2["Job A: RSS ↑↑"]
B2["Job B: RSS ↑↑"]
C2["Job C: RSS ↑↑"]
D2["Job D: 进入新的数据加载阶段"]
E2["aggregate demand → Node capacity"]
F2["cross threshold"]
end
earlyPhase --> latePhase
早期,所有 Job 的 aggregate physical demand 远低于 Node capacity,大家都正常运行。
随着训练持续,各个 Job 的 buffer 逐渐被填充,RSS 稳步增长。然后某个 Job 恰好开始下一阶段的数据加载,又增加了一段 physical memory demand——把整个节点推过了 memory pressure 的临界点。
这里需要注意一件事。这个 Job 不应该被描述为"出问题的 Job"。
它只是最后一个把整个节点推过 threshold 的 workload。之前所有 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 一起变慢。
这和当年的现象正好对应。
不过需要坦白:当年并没有在线上一路追到具体的 kernel 调用栈,确认每一次 stall 都是 direct reclaim 导致。Direct reclaim 是解释这种 latency pattern 的一个重要且合理的机制,但我不能声称它是当年唯一的路径。
I/O-heavy 训练 Workload 的放大效应#
如果只有 anonymous memory 增长导致 direct reclaim,问题可能已经够严重了。但对于 data-intensive 训练 workload,还有一个放大效应。
Physical RAM 不只容纳训练进程的 anonymous memory(比如 NumPy 数组)。还有大量的 page cache——Linux 用来缓存文件系统 I/O 的内存区域。
flowchart LR
RAM["Physical RAM"]
RAM --> Anon["Anonymous Memory
(NumPy arrays、进程堆等)"]
RAM --> PC["Page Cache
(训练数据 I/O 缓存)"]
训练 Job 不断从存储读取数据,这些数据会经过 page cache。同时,NumPy buffer 对应的 anonymous pages 也在持续增长。
随着 anonymous memory 越来越多,memory pressure 上升,kernel 会开始回收 page cache。
对一个 I/O-heavy workload 来说,page cache 被回收意味着:之前缓存在内存里的训练数据没了,下次读取时必须重新从 storage 读。
于是可能出现两个方向同时恶化:
flowchart TD
MP["Memory Pressure"]
MP --> DR["Direct Reclaim"]
MP --> PCR["Page Cache Reclaim"]
DR --> AS["allocation stall
线程被卡住等待回收"]
PCR --> IOUp["更多 I/O 落到 storage
I/O latency ↑"]
AS --> Slow["Job throughput ↓"]
IOUp --> Slow
一边是 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 更主要的作用是:
- Scheduler 层面:影响 Pod 被调度到哪台 Node
- Cgroup 层面:通过 cgroup 设置 memory limit(如果配置了 limit)
这里有好几个不同的数字,容易混在一起:
| 概念 | 来自哪一层 | 含义 |
|---|---|---|
| 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 的 physical capacity。
回到当年的问题#
现在可以用一个更完整的模型重新描述当年发生的事情。
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 最终跨过节点能够承载的范围后,Linux 开始进入越来越激进的 reclaim。某个 Job 只是恰好完成了最后一次推力。随后受到影响的是整台机器上的所有 workload。
完整的链路:
flowchart TD
Start["多个 K8s Job 正常启动"]
Start --> VA["virtual allocations succeed
overcommit=1 不做拒绝"]
VA --> Train["训练持续运行
加载数据、填充 buffer"]
Train --> FT["pages gradually first-touched
page fault → physical page allocation"]
FT --> RSS["anonymous memory / RSS ↑"]
RSS --> Agg["aggregate physical demand ↑"]
Agg --> Cross["cross memory-pressure threshold"]
Cross --> Reclaim["Linux 进入 reclaim"]
Reclaim --> DR["direct reclaim
thread stall"]
Reclaim --> PCR["page cache reclaim
storage I/O ↑"]
DR --> AllSlow["所有 Job 一起变慢"]
PCR --> AllSlow
AllSlow --> Extreme["极端情况:OOM Killer"]
OOM 是可能的最终结果,但不是必然的。在 OOM 之前,系统往往已经在 reclaim 的泥潭里挣扎了很久。用户最先感知到的通常是性能骤降,而不是进程被杀。
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在不同的抽象层里,这个数字背后可能是完全不同的东西:
1Kubernetes scheduler accounting
2 ↓
3cgroup enforcement
4 ↓
5Virtual Address Space
6 ↓
7memory commitment
8 ↓
9resident pages(RSS)
10 ↓
11physical capacity平时这些差异都藏在抽象层后面。只有进入资源压力区间,抽象才开始一层层漏出来。
很多资源问题真正麻烦的地方,并不是资源真的用完了,而是系统的不同层对于"这份资源已经分配给谁"有着不同的定义。
参考资料#
- Linux kernel documentation: vm/overcommit-accounting
- proc(5) — /proc/sys/vm/overcommit_memory
- Kubernetes: Managing Resources for Containers
- LWN.net: Overcommit and OOM