跳过正文
  1. Posts/

从一次训练集群卡顿,理解 Linux Overcommit

·5677 字·12 分钟
目录

起因
#

最近偶然看到一篇文章,里面提到一个 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跨过某个临界点
1011整台 Node 开始明显卡顿
1213同机所有 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 Pageskernel 真正分配出去的物理页

这里面最先需要建立的认知是:

Virtual Address Space 不等于 Physical Memory。

现代操作系统不会因为一个进程获得了一块巨大的虚拟地址范围,就立刻准备同等规模的 physical pages。进程拿到的只是一段地址空间。这段地址空间背后对应多少实际的物理内存,取决于程序后续如何使用它。

对于 NumPy 这类大数组 workload,这一点尤其容易观察到。NumPy 的大数组 allocation 叠加 Linux 的 virtual memory 和 demand paging 机制,使得"数组创建成功"和"对应 physical pages 全部 resident"可以发生在完全不同的时间。

需要明确:这不是 NumPy 自己实现的"延迟分配”。NumPy 调用底层的 memory allocator(通常是 mallocmmap),而 Linux kernel 决定了什么时候真正分配物理页。

First Touch:真正的 memory demand 什么时候出现
#

上面说虚拟地址不等于物理内存。那物理内存到底什么时候才被真正占用?

答案是:当程序实际访问(touch)某一页虚拟地址时。

模型是这样的:

 1申请一个 50GB buffer
 2 3获得对应的 virtual address range
 4(此时 physical memory 几乎没有变化)
 5 6程序开始读取数据并写入 buffer
 7 8逐页访问
 910page fault
1112kernel 分配 physical page
1314RSS 逐渐增长

也就是说,不是创建 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 策略:

模式行为
0Heuristickernel 按照一些启发式规则决定是否拒绝过大的 allocation
1Always overcommit永远不在 allocation 时拒绝(除非地址空间本身不够)
2Strict accounting根据物理内存 + swap 做严格的 commit accounting

当年集群的配置是 vm.overcommit_memory = 1

Mode 1 的 mental model:

1allocation time
23不做 commit accounting 检查,直接允许
45...
67runtime:程序 touch pages
89尝试兑现 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 不足
1011当前线程进入 direct reclaim
1213等待 reclaim 完成
1415thread 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 requestKubernetes scheduler调度 accounting,影响 placement
K8s limitKubernetes → cgroupcgroup 层面的 memory 上限
Virtual memoryLinux VM进程的虚拟地址空间
Committed memoryLinux VM系统承诺过的 memory
RSSLinux VM实际 resident 的物理页
Physical pagesHardware真正被占用的物理内存

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 被留到了 runtime

Overcommit 只是这个资源调度 trade-off 在 virtual memory 上的一种具体表现。

真正困难的不是"这台机器有多少 GB 内存",而是系统应该在什么时候、根据什么信息,决定是否接受新的资源承诺。

结论
#

回到那个简单的:

1memory: 100Gi

在不同的抽象层里,这个数字背后可能是完全不同的东西:

 1Kubernetes scheduler accounting
 2 3cgroup enforcement
 4 5Virtual Address Space
 6 7memory commitment
 8 9resident pages(RSS)
1011physical capacity

平时这些差异都藏在抽象层后面。只有进入资源压力区间,抽象才开始一层层漏出来。

很多资源问题真正麻烦的地方,并不是资源真的用完了,而是系统的不同层对于"这份资源已经分配给谁"有着不同的定义。

参考资料
#

🏛️ 111qqz 的技术博客 · 15 年博客历史 (2011 - 2026)
发布于 2026-09-09

💡 觉得有启发?欢迎互动交流!

如果你在阅读、编译运行或系统优化中有任何疑问、思考或更好的解法,欢迎在下方发表评论,或通过邮件直接探讨。

本文链接:https://111qqz.com/2026/09/linux-overcommit-training-cluster/ 知识共享署名-非商业性使用 4.0 国际许可 (CC BY-NC 4.0)

相关文章