Linux Kernel Dirty Page Writeback 与 IO 拥塞联调深度实战:AI 训练集群稳定性工程
引言:一个被忽视的训练稳定性杀手
在大规模 AI 集群中,工程师往往会将精力集中在 GPU 算力利用率、通信带宽和模型收敛速度上,却容易忽略一个底层问题:Page Cache Dirty Page 突然集中回写引发的 IO 拥塞,会导致 GPU 训练出现间歇性"卡死"(GPU starvation)。这种现象表现为训练 throughput 周期性骤降,PyTorch dataloader 出现莫名其妙的等待,NCCL 通信超时,甚至导致大规模训练任务崩溃。
本文从 Linux 内核内存管理与 block layer 子系统出发,深度分析 dirty page 回写的完整生命周期、IO 拥塞的形成机制,并提供一套工程化的诊断与调优方法论。我们将结合 bpftrace 动态追踪、cgroup v2 memory/IO 控制器、BPF-LSM 拦截技术,来构建一个完整的"脏页风暴"防御体系。
一、Linux Page Cache 与 Dirty Page 机制深度剖析
1.1 Page Cache 的核心数据结构
Linux 内核通过 struct page 和 struct folio(自 5.16 引入)来管理页缓存。当一个文件被读取或写入时,其内存页面会被加入 Page Cache,由 address_space 结构组织起来:
struct address_space {
struct inode *host; // 关联的 inode
struct xarray i_pages; // 页面索引树(XArray)
unsigned long nrpages; // 总页数
const struct address_space_operations *aops;
gfp_t gfp_mask;
};
// Folio — 多页缓存单元(Linux 5.16+)
struct folio {
unsigned long flags; // PG_dirty, PG_writeback 等标志
struct list_head lru; // LRU 链表节点
struct address_space *mapping;
void *private; // 文件系统私有数据
// 复合页的物理页信息...
};
当用户空间进程对文件进行 write() 系统调用时,Page Cache 的处理流程为:
write()→vfs_write()→ 文件对应的write_iter()方法- 调用
generic_perform_write()→a_ops->write_begin()锁定页面 - 通过
copy_from_user()将用户数据拷入 Page Cache - 设置
PG_dirty标志位,标记页面为脏页 - 数据此时在内存中,但尚未落盘
PG_dirty(bit 4):表示内容已修改,与磁盘不同步PG_writeback(bit 5):表示正在进行 IO 回写PG_reclaim(bit 6):表示正在被内存回收扫描- 后台阈值触发:当脏页比例超过
dirty_background_ratio,wb_workfn()开始异步回写 - 脏页过期触发:超过
dirty_expire_centisecs的脏页会被周期性回写 - 显式阻塞触发:当脏页超过
dirty_ratio,write() 调用者会被强制阻塞(dirty throttle) - IO 队列深度突然飙升(从几十到几千)
- 顺序写入被拆碎为大量随机小页写入(4KB)
- IO 平均延迟从 50μs 飙升至 50ms+
- SSD 的 P99 延迟突破 1s
- 预防:通过 O_DIRECT、fallocate 等方式降低脏页产生
- 提前:动态调整 vm.dirty_* 阈值,将回写提前分散到长时间段
- 隔离:使用 cgroup v2 io.max/io.weight 控制不同 IO 源的带宽
- 拦截:通过 BPF-LSM 在超限时限速脏页产生
- 监控:建立基于 bpftrace + Prometheus 的可观测体系
1.2 Dirty Page 状态机
Clean Page ──write()──▶ Dirty Page
▲ │
│ │ writeback 线程开始刷盘
│ ▼
│ Writeback Page (PG_writeback)
│ │
│ │ IO 完成
│ ▼
└───── page_cleaned ── Clean Page
关键的状态转换标志:
1.3 Writeback 控制参数
内核通过 /proc/sys/vm/ 目录下的参数控制 writeback 行为,AI 训练场景中需要重点关注:
# 脏页占总内存的百分比阈值,超过后进程 write() 阻塞开始刷盘
cat /proc/sys/vm/dirty_ratio # 默认 20(20%)
cat /proc/sys/vm/dirty_background_ratio # 默认 10(10%)
# 基于字节数的精确控制(CentOS 8+ / Ubuntu 22.04+ 默认)
cat /proc/sys/vm/dirty_bytes # 绝对字节数
cat /proc/sys/vm/dirty_background_bytes
# 脏页过期时间(单位:百分之一秒)
cat /proc/sys/vm/dirty_expire_centisecs # 默认 3000(30秒)
cat /proc/sys/vm/dirty_writeback_centisecs # 默认 500(5秒)
# 内存回收相关
cat /proc/sys/vm/swappiness # 默认 60
cat /proc/sys/vm/vfs_cache_pressure # 默认 100
二、Dirty Page 回写路径的完整内核追踪
2.1 Writeback 内核线程
每个 bdi(Backing Device Information)设备都有一个 wb_workform 工作队列和一个 bdi_writeback 结构:
// include/linux/backing-dev.h
struct backing_dev_info {
struct list_head bdi_list;
unsigned long ra_pages; // 预读窗口
unsigned long io_pages; // IO 页数
struct bdi_writeback *wb; // writeback 单元
struct list_head wb_list;
unsigned long avg_write_bandwidth;
unsigned long min_bandwidth;
unsigned long max_bandwidth;
// ...
};
struct bdi_write_back {
struct backing_dev_info *bdi;
unsigned long state;
unsigned long last_old_flush;
struct list_head b_dirty; // 脏页 inode 链表
struct list_head b_io; // 等待回写的 inode
struct list_head b_more_io; // 需要更多回写的
struct wb_writeback_work *work;
unsigned long dirty_ratelimit; // 当前回写速率
// ...
};
Writeback 触发三条路径:
2.2 Dirty Page Rate Limiting — 内核流控器
内核使用 dirty_throttle_control 结构中的令牌桶算法来控制回写速率,避免 IO 拥塞:
// mm/page-writeback.c
static void balance_dirty_pages(struct bdi_writeback *wb)
{
struct dirty_throttle_control *dtc = wb->dtc;
unsigned long dirty = global_node_page_state(NR_FILE_DIRTY);
unsigned long thresh = global_dirty_limit();
unsigned long bw = wb->avg_write_bandwidth;
// 计算目标回写带宽
pause = HZ * written / (bw + 1);
pause = min(pause, HZ / 10); // 最长 100ms
pause = max(pause, 1); // 最短 1ms
__set_current_state(TASK_KILLABLE);
io_schedule_timeout(pause);
}
当 AI 训练进程持续写入 checkpoint、日志或数据预处理结果时,dirty page 累积速度可能远超过磁盘带宽,此时内核会强制 throttle 写入进程。如果该进程正好持有 dataloader 的锁或网络通信的缓冲区,就会导致整个训练流水线停顿。
2.3 io_schedule_timeout 的连锁反应
当 write() 调用被 balance_dirty_pages() 阻塞后,其影响链路为:
训练写线程 → write() 阻塞 → dataloader buffer 满
↓
GPU 数据供给中断 → GPU utilization 跌落
↓
NCCL AllReduce 等待 → 通信超时检测触发
↓
训练框架 watchdog 超时 → 触发 checkpoint 保存 → 更多脏页
↓
恶性循环
三、IO 拥塞诊断方法论
3.1 使用 bpftrace 追踪 Dirty Page 生命周期
下面的 bpftrace 脚本跟踪 dirty page 的产生、回写和清空全过程:
#!/usr/bin/bpftrace
// 追踪脏页标记(set_page_dirty)
kprobe:set_page_dirty {
$page = (struct page *)arg0;
$mapping = $page->mapping;
$inode = $mapping->host;
printf("DIRTY: pid=%d comm=%s ino=%lu size=%lu offset=%lu\n",
pid, comm, $inode->i_ino,
$inode->i_size, $page->index);
}
// 追踪 writeback 开始
kprobe:wb_workfn {
printf("WB_START: time=%llu\n", nsecs);
}
// 追踪 balance_dirty_pages 阻塞
kprobe:balance_dirty_pages {
printf("THROTTLE: pid=%d comm=%s at_time=%llu\n",
pid, comm, nsecs);
}
// 追踪 IO 完成
kprobe:end_page_writeback {
@completed++;
printf("WB_COMPLETE: total=%llu\n", @completed);
}
interval:s:5 {
print(@dirty_counts);
}
执行诊断命令:
sudo bpftrace dirty_trace.bt -o dirty_trace.log &
# 训练启动后 10 分钟
cat /proc/vmstat | grep -E "dirty|writeback|nr_"
3.2 使用 /proc/vmstat 实时监控
# 监控脏页和回写统计
watch -n 1 'grep -E "dirty|writeback|nr_" /proc/vmstat'
关键指标说明:
| 指标 | 含义 | 异常阈值 |
|---|---|---|
nr_dirty |
当前脏页数量 | 持续 > dirty_ratio 阈值 50% |
nr_writeback |
正在回写的页数 | 持续 > 1000 |
nr_dirty_background_thresh |
后台回写触发次数 | 频繁触发 |
pgpgin / pgpgout |
页面换入/换出计数 | 非预期时异常 |
pswpin / pswpout |
交换分区读写 | 非零即为异常 |
3.3 使用 blktrace + blkparse 分析 IO 模式
# 捕获块设备 IO 模式
sudo blktrace -d /dev/nvme0n1 -o trace &
# 运行训练任务...
# 解析并统计 IO 延迟分布
blkparse -i trace -d trace.bin
btt -i trace.bin -l trace.latencies
# 输出:IO 提交的延迟分布、完成的延迟分布、Q2C(队列到完成)时间
典型的"脏页风暴"IO 模式特征:
四、AI 训练场景工程化解决方案
4.1 方案一:Dirty Page 预防 — 内存分配策略
核心思路:在训练启动前,提前分配并预热 Page Cache,避免 checkpoint 写入时临时产生大量脏页。
# preallocate_fscache.py
import os
import mmap
def preallocate_pagecache(filepath, size_gb):
"""预分配 page cache 空间,减少脏页突发"""
size = size_gb * 1024 * 1024 * 1024
with open(filepath, 'w+b') as f:
# fallocate 预分配文件(不产生脏页)
os.posix_fallocate(f.fileno(), 0, size)
# 建议内核该文件会被顺序访问
os.posix_fadvise(f.fileno(), 0, size,
os.POSIX_FADV_SEQUENTIAL)
# 对 mmap 区域进行 madvise
mm = mmap.mmap(f.fileno(), size)
# MADV_HUGEPAGE - 建议内核使用大页
mm.madvise(mmap.MADV_HUGEPAGE)
mm.close()
if __name__ == "__main__":
preallocate_pagecache("/data/checkpoints/next.ckpt", 10)
4.2 方案二:Dirty Page 速率控制 — 动态调节阈值
核心思路:训练运行时动态降低 dirty_ratio,提前开始回写,避免脏页积压导致的突发风暴。
# vm_dirty_tuner.py — 动态调节 vm.dirty 参数
import time
import os
import signal
class DirtyPageTuner:
"""
AI 训练场景下的 Dirty Page 自动调优器
"""
# 模式配置
PROFILES = {
"normal": {
"dirty_ratio": 20,
"dirty_background_ratio": 10,
"dirty_expire_centisecs": 3000,
"dirty_writeback_centisecs": 500,
"swappiness": 10,
},
"checkpoint_writing": {
# checkpoint 写入前降低阈值,提前开始回写
"dirty_ratio": 5,
"dirty_background_ratio": 2,
"dirty_expire_centisecs": 1000,
"dirty_writeback_centisecs": 200,
"swappiness": 0,
},
"recovery": {
# 训练完成后恢复默认
"dirty_ratio": 30,
"dirty_background_ratio": 15,
"dirty_expire_centisecs": 5000,
"dirty_writeback_centisecs": 1000,
"swappiness": 10,
}
}
def __init__(self):
self.original = {}
self._save_defaults()
def _write_proc(self, path, value):
with open(f"/proc/sys/vm/{path}", 'w') as f:
f.write(str(value))
def _read_proc(self, path):
with open(f"/proc/sys/vm/{path}", 'r') as f:
return f.read().strip()
def _save_defaults(self):
keys = ["dirty_ratio", "dirty_background_ratio",
"dirty_expire_centisecs", "dirty_writeback_centisecs",
"swappiness"]
for k in keys:
self.original[k] = self._read_proc(k)
def apply_profile(self, profile_name):
profile = self.PROFILES[profile_name]
for key, value in profile.items():
self._write_proc(key, value)
print(f"[TUNER] Set {key} = {value}")
def get_stats(self):
"""获取当前脏页统计"""
stats = {}
with open('/proc/vmstat', 'r') as f:
for line in f:
parts = line.split()
if parts[0] in ('nr_dirty', 'nr_writeback',
'nr_unstable', 'nr_dirtied',
'nr_written', 'nr_dirty_threshold',
'nr_dirty_background_threshold'):
stats[parts[0]] = int(parts[1])
return stats
def monitor_loop(self, interval=2.0):
"""持续监控并报警"""
while True:
stats = self.get_stats()
dirty = stats.get('nr_dirty', 0)
thresh = stats.get('nr_dirty_threshold', 1)
ratio = dirty / thresh * 100 if thresh else 0
if ratio > 80:
print(f"[ALERT] Dirty pages at {ratio:.1f}% "
f"of threshold! ({dirty}/{thresh})")
self.apply_profile("checkpoint_writing")
elif ratio < 30:
print(f"[INFO] Dirty pages safe: {ratio:.1f}%")
time.sleep(interval)
# 集成到训练启动脚本
if __name__ == "__main__":
tuner = DirtyPageTuner()
# 训练开始前:降低阈值预回写
tuner.apply_profile("checkpoint_writing")
import subprocess
proc = subprocess.Popen([
"python", "train.py",
"--config", "config.yaml"
])
try:
proc.wait()
finally:
# 训练完成后恢复
tuner.apply_profile("recovery")
4.3 方案三:IO 拥塞避免 — BPF-LSM 脏页拦截
利用 BPF-LSM 技术在内核 file_receive 等挂载点注入策略,实现更细粒度的控制:
// dirty_limit.bpf.c — BPF-LSM 程序拦截大脏页产生行为
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#define MAX_DIRTY_PAGES_PER_SEC 500000 // 每秒最大脏页数
#define THRESHOLD_ACTION 1
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 1024);
__type(key, u32); // pid
__type(value, u64); // 脏页计数
} dirty_page_counter SEC(".maps");
struct {
__uint(type, BPF_MAP_TYPE_ARRAY);
__uint(max_entries, 1);
__type(key, u32);
__type(value, u64); // 全局计数
} global_dirty_count SEC(".maps");
// 挂载点:写操作标记脏页时
SEC("lsm/set_page_dirty")
int BPF_PROG(limit_dirty_pages, struct page *page)
{
u32 pid = bpf_get_current_pid_tgid() >> 32;
u64 *count, global = 0;
u64 key = 0;
// 更新 per-pid 计数
count = bpf_map_lookup_elem(&dirty_page_counter, &pid);
if (count) {
__sync_fetch_and_add(count, 1);
} else {
u64 init = 1;
bpf_map_update_elem(&dirty_page_counter, &pid, &init, BPF_ANY);
}
// 检查全局计数
u64 *gcount = bpf_map_lookup_elem(&global_dirty_count, &key);
if (gcount && *gcount > MAX_DIRTY_PAGES_PER_SEC) {
// 超过限制,返回 -EAGAIN 让应用自行退避
bpf_printk("DIRTY LIMIT: pid=%d throttle!\n", pid);
return -EAGAIN;
}
if (gcount)
__sync_fetch_and_add(gcount, 1);
return 0;
}
struct timer_ctx {
u32 unused;
};
// 定时器:每秒重置计数器(通过 BPF timer)
struct {
__uint(type, BPF_MAP_TYPE_ARRAY);
__uint(max_entries, 1);
__type(key, u32);
__type(value, struct bpf_timer);
} timer_map SEC(".maps");
SEC("tp_btf/sched_process_fork")
int reset_dirty_counter()
{
u32 key = 0;
u64 zero = 0;
bpf_map_update_elem(&global_dirty_count, &key, &zero, BPF_ANY);
return 0;
}
char LICENSE[] SEC("license") = "GPL";
对应的加载和控制程序:
# dirty_limit_loader.py
from bcc import BPF
import time
import ctypes
bpf_text = r"""
// 上述 BPF 代码...
"""
class DirtyPageLimiter:
def __init__(self):
self.bpf = BPF(text=bpf_text)
print("[BPF-LSM] 程序已加载")
def set_limit(self, max_pages_per_sec):
"""设置每秒最大脏页数"""
limit_map = self.bpf.get_table("max_dirty_limit")
key = ctypes.c_uint(0)
val = ctypes.c_ulong(max_pages_per_sec)
limit_map[key] = val
print(f"[BPF-LSM] 脏页限制设置为 {max_pages_per_sec} pages/sec")
def get_stats(self):
"""获取各进程的脏页产生速率"""
counter = self.bpf.get_table("dirty_page_counter")
results = {}
for key, value in counter.items():
pid = key.value
count = value.value
results[pid] = count
return results
def monitor(self):
"""实时监控"""
while True:
time.sleep(5)
stats = self.get_stats()
total = sum(stats.values())
print(f"[MONITOR] Total dirty pages/sec: {total}")
if total > 500000:
self.set_limit(300000)
print("[MONITOR] Reducing limit to 300000")
if __name__ == "__main__":
limiter = DirtyPageLimiter()
limiter.monitor()
4.4 方案四:cgroup v2 I/O Weight 隔离
使用 cgroup v2 的 IO 控制器来隔离 checkpoint 写入的磁盘带宽,保证数据读取的 IO 优先级:
# 训练数据读取组:高 IO 权重
mkdir /sys/fs/cgroup/training_data
echo "800" > /sys/fs/cgroup/training_data/io.weight
echo "8:0 rbps=2147483647 wbps=2147483647 riops=2147483647 wiops=2147483647" \
> /sys/fs/cgroup/training_data/io.max
# checkpoint 写入组:受控权重
mkdir /sys/fs/cgroup/checkpoint_io
echo "100" > /sys/fs/cgroup/checkpoint_io/io.weight
# 限制 checkpoint 不能占用超过 2GB/s 写入带宽
echo "8:0 wbps=2147483648" > /sys/fs/cgroup/checkpoint_io/io.max
# 将进程绑定到对应 cgroup
echo $DATALOADER_PID > /sys/fs/cgroup/training_data/cgroup.procs
echo $CKPT_WRITER_PID > /sys/fs/cgroup/checkpoint_io/cgroup.procs
4.5 方案五:用户空间 Page Cache 旁路
对于 AI 训练中的 checkpoint 写入,可以绕过 Page Cache,使用 O_DIRECT 直接 IO:
// direct_write_checkpoint.c
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#define BUFFER_SIZE (4 * 1024 * 1024) // 4MB 对齐缓冲区
int write_checkpoint_direct(const char *path, void *data, size_t len)
{
// O_DIRECT 绕过 Page Cache,避免产生脏页
int fd = open(path, O_WRONLY | O_CREAT | O_DIRECT | O_TRUNC, 0644);
if (fd < 0) {
perror("open O_DIRECT failed");
return -1;
}
// O_DIRECT 要求内存对齐(对齐到块设备逻辑块大小,通常 512B 或 4KB)
void *buf;
if (posix_memalign(&buf, 4096, BUFFER_SIZE) != 0) {
perror("posix_memalign");
close(fd);
return -1;
}
size_t remaining = len;
char *src = (char *)data;
while (remaining > 0) {
size_t chunk = (remaining > BUFFER_SIZE) ? BUFFER_SIZE : remaining;
memcpy(buf, src, chunk);
// 确保写入大小是块大小的倍数
size_t aligned_chunk = ((chunk + 511) / 512) * 512;
ssize_t written = write(fd, buf, aligned_chunk);
if (written < 0) {
perror("write failed");
break;
}
src += chunk;
remaining -= chunk;
}
// 确保数据完全落盘
fdatasync(fd);
close(fd);
free(buf);
return 0;
}
五、生产环境综合案例
5.1 问题场景
某 AI 集群(A100 × 8 × 16 节点)训练 175B 模型,每个 GPU 每 30 分钟保存一次 checkpoint(每个 checkpoint ~2.8GB)。当 128 个 GPU 同时开始保存 checkpoint 时,数据预处理节点的 Page Cache 在 10 秒内积累约 360GB 脏页,触发 writeback 风暴。
5.2 症状数据
| 指标 | 正常值 | 风暴期间(无调优) |
|---|---|---|
| GPU Utilization | 95%+ | 42% |
| NCCL AllReduce 时间 | 12ms | 850ms |
| NVMe 写入带宽 | 2.1GB/s | 6.8GB/s(无序突发) |
| IO P99 延迟 | 85μs | 12000μs(12ms) |
| 训练 throughput | 156 samples/s | 41 samples/s |
5.3 调优措施与效果
第一阶段:降低 dirty_page 阈值
echo 5 > /proc/sys/vm/dirty_ratio
echo 2 > /proc/sys/vm/dirty_background_ratio
echo 1500 > /proc/sys/vm/dirty_expire_centisecs
效果:回写提前发生,风暴峰值降低 60%。但仍有约 20% 的 throughput 损失。
第二阶段:checkpoint O_DIRECT + 时间错位
# 各 GPU 错开 checkpoint 时间(错开 30 秒)
ckpt_time = base_time + gpu_rank * 30
save_checkpoint(path, use_direct_io=True)
效果:风暴基本消失,throughput 恢复至 92%。
第三阶段:cgroup v2 IO 隔离 + BPF-LSM 兜底
实施完整的 IO 隔离方案后,最终效果:
| 指标 | 调优前 | 调优后 |
|---|---|---|
| GPU Utilization | 42% | 96% |
| Checkpoint 时间 | 一次性 30s | 分散 30×1s |
| 训练 throughput | 41 samples/s | 152 samples/s |
| IO P99 延迟 | 12ms | 120μs |
5.4 持续监控 Dashboard
建议部署的 Prometheus 监控指标:
# prometheus-alerts.yml
groups:
- name: dirty_page_alerts
rules:
- alert: DirtyPageSpike
expr: |
node_vmstat_nr_dirty / node_vmstat_nr_dirty_threshold > 0.7
for: 30s
labels:
severity: warning
annotations:
summary: "Dirty pages approaching threshold on {{ $labels.instance }}"
description: "Dirty page ratio: {{ $value | humanizePercentage }}"
- alert: WritebackCongestion
expr: |
rate(node_vmstat_nr_written[1m]) < 1000
and node_vmstat_nr_writeback > 500
for: 1m
labels:
severity: critical
annotations:
summary: "Writeback IO congestion detected"
六、总结
在大规模 AI 训练场景中,Dirty Page 回写风暴是一个典型的"非计算性能瓶颈"。它不消耗 GPU FLOPS,不占用网络带宽,却能通过 IO 拥塞间接导致 GPU starvation,使得训练 throughput 下降 60-70%。
防御这一问题的工程实践路线为:
从深度学习框架到底层内核机制,横跨 memory 子系统、block layer、BPF 追踪技术的全栈联调思维,是高性能计算场景工程师必须掌握的核心技能。

发表评论 取消回复