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 的处理流程为:

  1. write() → vfs_write() → 文件对应的 write_iter() 方法
  2. 调用 generic_perform_write() → a_ops->write_begin() 锁定页面
  3. 通过 copy_from_user() 将用户数据拷入 Page Cache
  4. 设置 PG_dirty 标志位,标记页面为脏页
  5. 数据此时在内存中,但尚未落盘

1.2 Dirty Page 状态机


Clean Page ──write()──▶ Dirty Page
    ▲                      │
    │                      │ writeback 线程开始刷盘
    │                      ▼
    │              Writeback Page (PG_writeback)
    │                      │
    │                      │ IO 完成
    │                      ▼
    └───── page_cleaned ── Clean Page

关键的状态转换标志:

  • PG_dirty(bit 4):表示内容已修改,与磁盘不同步
  • PG_writeback(bit 5):表示正在进行 IO 回写
  • PG_reclaim(bit 6):表示正在被内存回收扫描

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 触发三条路径:

  1. 后台阈值触发:当脏页比例超过 dirty_background_ratio,wb_workfn() 开始异步回写
  2. 脏页过期触发:超过 dirty_expire_centisecs 的脏页会被周期性回写
  3. 显式阻塞触发:当脏页超过 dirty_ratio,write() 调用者会被强制阻塞(dirty throttle)

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 模式特征:

  • IO 队列深度突然飙升(从几十到几千)
  • 顺序写入被拆碎为大量随机小页写入(4KB)
  • IO 平均延迟从 50μs 飙升至 50ms+
  • SSD 的 P99 延迟突破 1s

四、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%。

防御这一问题的工程实践路线为:

  1. 预防:通过 O_DIRECT、fallocate 等方式降低脏页产生
  2. 提前:动态调整 vm.dirty_* 阈值,将回写提前分散到长时间段
  3. 隔离:使用 cgroup v2 io.max/io.weight 控制不同 IO 源的带宽
  4. 拦截:通过 BPF-LSM 在超限时限速脏页产生
  5. 监控:建立基于 bpftrace + Prometheus 的可观测体系

从深度学习框架到底层内核机制,横跨 memory 子系统、block layer、BPF 追踪技术的全栈联调思维,是高性能计算场景工程师必须掌握的核心技能。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部