一、问题的本质:为什么混部场景下 I/O 隔离比 CPU/内存更难

在 AI 平台的资源调度中,CPU 和内存的隔离机制已经相对成熟:cgroup v2 的 cpu.weight 和 memory.high 可以完成基本的资源划分。但当我们将训练任务(模型 shuffle checkpoint 读写)和推理服务(在线 embedding 检索与 KV-Cache 页面换入换出)部署在同一台物理机上时,I/O 往往成为最难驯服的共享资源。

I/O 的特殊性体现在三个维度:

第一,干扰模式不对称。> 训练任务对磁盘带宽的需求是突发性的——每个 epoch 启动时,dataset 中的大量小文件随机读取会产生高 IOPS 压力;而 checkpoint 写入阶段则变成高吞吐的顺序写。推理服务的要求恰恰相反:要求稳定的低延迟读(权重文件 mmap 加载、KV-Cache spill),允许短时间内吞吐波动。两种负载在同一条 PCIe 总线上的竞争,不是简单的带宽分割能解决的。

第二,存储栈层级繁多。> 从 VFS → page cache → block layer → io_uring submit → NVMe SQ → 介质,每一层都有独立的排队和调度。传统的 cgroup v1 blkio throttling 只能限制带宽绝对上限,无法建模"在保障推理延迟的前提下,让训练吃满剩余带宽"的策略。

第三,io_uring 绕过了传统的 I/O 调度路径。> 在 O_DIRECT + io_uring 方案下,请求直接进入 NVMe 提交队列,跳过了内核 I/O 调度器(mq-deadline/bfq)的优先级排序。这意味着即使你在 cgroup 层面配置了 io.weight,优先级信息可能在提交途中丢失。

二、Linux I/O 优先级体系全景

从底层到应用,Linux 提供了四层优先级控制机制:


┌─────────────────────────────────────────────────┐
│  1. 硬件层:NVMe WRR (Weighted Round Robin)      │
│     → admin command 设置 controller 权重          │
├─────────────────────────────────────────────────┤
│  2. cgroup v2 io.max / io.low / io.weight       │
│     → blk-iocost 模型驱动                         │
├─────────────────────────────────────────────────┤
│  3. 进程级 ionice (ioprio_class/ioprio_level)    │
│     → best-effort/RT/ idle 三档                   │
├─────────────────────────────────────────────────┤
│  4. io_uring 层:SQ 线程 ioprio                  │
│     → SQPOLL 模式下独立设置                       │
└─────────────────────────────────────────────────┘

各层之间可能存在优先级传递断裂。下面逐层深入分析实战配置。

三、cgroup v2 io 控制器与 blk-iocost 模型深度解析

3.1 blk-iocost 核心参数

cgroup v2 的 io controller 可以通过 io.cost.model 配置设备的代价模型参数:


# 假设 /dev/nvme0n1 是主要的训练数据盘
echo "nvme0n1 rbps=1880000 wbps=1740000 riops=250000 wiops=190000" \
  > /sys/fs/cgroup/io.cost.model

各参数含义:

参数含义获取方式
rbps顺序读最大带宽 (Byte/s)fio --name=test --rw=read --bs=1M --size=4G>
wbps顺序写最大带宽同上用 --rw=write>
riops4K 随机读 IOPS上限fio --rw=randread --bs=4k>
wiops4K 随机写 IOPS上限fio --rw=randwrite --bs=4k>

自动校准脚本(生产级):


#!/bin/bash
# auto_calibrate_iocost.sh - 基于 fio 基准测试自动校准 iocost 参数
DEV=$1
SYSFS_IOCOST="/sys/fs/cgroup/io.cost.model"

calibrate() {
  local rw=$1 bs=$2
  fio --name=calib --rw=${rw} --bs=${bs} --size=2G \
      --filename=/dev/${DEV} --direct=1 --numjobs=1 \
      --ioengine=io_uring --output-format=json \
    | jq '.jobs[0].'"${rw}"'.bw_bytes'
}

rbps=$(calibrate read 1M)
wbps=$(calibrate write 1M)
riops=$(calibrate randread 4k)
wiops=$(calibrate randwrite 4k)

# 扣除 5% 系统开销,留出余量
rbps=$((rbps * 95 / 100))
wbps=$((wbps * 95 / 100))
riops=$((riops * 95 / 100))
wiops=$((wiops * 95 / 100))

echo "${DEV} rbps=${rbps} wbps=${wbps} riops=${riops} wiops=${wiops}" | \
  tee ${SYSFS_IOCOST}
echo "Device ${DEV} iocost calibrated"
}

3.2 混部场景的 cgroup 配置策略

核心思路:用 io.low 保障推理带宽底线,用 io.weight 训练吃剩余>。


# 1. 创建推理服务的 cgroup(优先级高)
mkdir /sys/fs/cgroup/inference-svc
echo "nvme0n1 rbps=800000 wbps=400000 riops=100000 wiops=50000" > \
  /sys/fs/cgroup/inference-svc/io.low

# io.low 保证推理服务至少获得 800MB/s 读带宽和 100k IOPS

# 2. 创建训练任务的 cgroup
mkdir /sys/fs/cgroup/ai-training
echo "nvme0n1 rbps=1500000 wbps=1200000 riops=200000 wiops=150000" > \
  /sys/fs/cgroup/ai-training/io.max

# io.max 限制训练任务的绝对上限

# 3. 设置 io.weight(当两者都在竞争时)
echo "default 100" > /sys/fs/cgroup/io.weight
echo 200 > /sys/fs/cgroup/inference-svc/io.weight   # 推理获得 2x 权重
echo 100 > /sys/fs/cgroup/ai-training/io.weight     # 训练获得 1x 权重

配置有效性验证:


# 查看当前 I/O 统计
cat /sys/fs/cgroup/inference-svc/io.stat
# 输出示例:
# 8:0 rbytes=128391168 wbytes=30621696rios=4898 wios=1101 dbytes=0 dios=0

# 查看 iocost 的延迟统计
cat /sys/fs/cgroup/io.cost.qos

四、io_uring 的 io_uring 优先级控制

4.1 SQ 线程 ioprio 配置

在 SQPOLL(kernel-side polling)模式下,io_uring 内核提交线程的 I/O 优先级直接决定了请求到达 block layer 的调度顺序:


#include <liburing.h>
#include <sys/resource.h>
#include <sys/syscall.h>

void setup_sqthread_ioprio(struct io_uring *ring, int ioprio_class, int ioprio_level) {
    struct io_uring_params p = { 0 };
    
    // 启用 SQPOLL + 设置 idle 时间
    p.flags |= IORING_SETUP_SQPOLL;
    p.sq_thread_idle = 2000; // 2ms idle
    
    io_uring_queue_init_params(32, ring, &p);
    
    // 方法 1:通过 setsockopt 风格的 io_uring_register
    // 需要 Linux 6.7+
    int ret = io_uring_register_ioprio(ring, ioprio_class, ioprio_level);
    if (ret < 0) {
        perror("io_uring_register_ioprio");
    }
}

/* 
 * ioprio_class:
 *   IOPRIO_CLASS_RT     (1) - 仅 root,需 CAP_SYS_ADMIN
 *   IOPRIO_CLASS_BE     (2) - best-effort,推荐生产使用
 *   IOPRIO_CLASS_IDLE   (3) - 仅当无其他 I/O 时才运行
 * 
 * ioprio_level: 0-7(RT/BE),0 = 最高优先级
 */

Python 封装(通过 ctypes):


import ctypes
import os
from contextlib import contextmanager

libc = ctypes.CDLL("libc.so.6")

SYS_ioprio_set = 251  # x86_64
IOPRIO_CLASS_SHIFT = 13

IOPRIO_CLASS_NONE = 0
IOPRIO_CLASS_RT   = 1
IOPRIO_CLASS_BE   = 2
IOPRIO_CLASS_IDLE = 3

def ioprio_set(which, who, ioprio_class, ioprio_level):
    """设置当前线程或进程的 I/O 优先级"""
    ioprio = (ioprio_class << IOPRIO_CLASS_SHIFT) | ioprio_level
    ret = libc.syscall(SYS_ioprio_set, which, who, ioprio)
    if ret != 0:
        raise OSError(f"ioprio_set failed: {os.strerror(ctypes.get_errno())}")
    return ret

# 使用示例:在 io_uring SQPOLL 线程启动后设置
# ioprio_set(1, 0, IOPRIO_CLASS_BE, 0)  # 推理服务:最高 BE 优先级
# ioprio_set(1, 0, IOPRIO_CLASS_BE, 6)  # 训练任务:较低 BE 优先级

4.2 优先级反转问题与解法

一个常见的陷阱:SQPOLL 线程在提交完一批请求后去调度下一个 worker,但此时下一个 worker 的 ioprio 还未生效,导致请求以错误的优先级提交。

解决方案——在每次提交前显式设置线程级 ioprio:


// 定义 io_uring 提交操作的 priority hint
// 通过 IOSQE_URGENT flag 标记高优先级请求 (Linux 6.6+)
static inline void prep_high_prio_read(struct io_uring_sqe *sqe,
                                         void *buf, unsigned len, off64_t off) {
    io_uring_prep_read(sqe, fd, buf, len, off);
    sqe->ioprio |= IOPRIO_PRIO_VALUE(IOPRIO_CLASS_BE, 0);  // 最高优先级
    sqe->flags |= IOSQE_FIXED_FILE;  // 使用预注册文件,减少 fd 管理开销
}

static inline void prep_lo_prio_shuffle(struct io_uring_sqe *sqe,
                                         void *buf, unsigned len, off64_t off) {
    io_uring_prep_read(sqe, fd, buf, len, off);
    sqe->ioprio |= IOPRIO_PRIO_VALUE(IOPRIO_CLASS_BE, 6);  // 低优先级
}

五、NVMe 硬件队列层隔离

5.1 WRR (Weighted Round Robin) 调度

现代 NVMe 控制器内部支持 WRR 机制,可以为不同的提交队列(SQ)配置权重:


# 通过 nvme-cli 查看当前 WRR 配置
nvme dir-receive /dev/nvme0 -D 1 -O 1 -H

# 设置 SQ 权重:SQ 0 = 推理服务,SQ 1 = 训练任务
# Ratio 3:1 意味着推理服务的 NVMe 调度频次是训练的 3 倍
nvme set-feature /dev/nvme0 -f 0x07 -v 0x0301
# feature 0x07 = Arbitration Burst / WRR configuration

5.2 中断与 polling 队列分离

对于 NVMe 多队列架构,推荐将 poll queue 分配给推理服务(放弃低延迟),将中断邮箱队列分配给训练吞吐:


# 查看当前队列与 CPU 的绑定
cat /sys/class/nvme/nvme0/device/msix

# 将 queue 0-3 绑定 CPU 0-3(训练任务 NUMA node)
echo "0" > /sys/block/nvme0n1/mq/0/cpu_list
echo "1" > /sys/block/nvme0n1/mq/1/cpu_list
echo "2" > /sys/block/nvme0n1/mq/2/cpu_list
echo "3" > /sys/block/nvme0n1/mq/3/cpu_list

# 将 queue 4-7 绑定 CPU 4-7(推理服务 NUMA node)并启用 polling
echo "4" > /sys/block/nvme0n1/mq/4/cpu_list
echo "5" > /sys/block/nvme0n1/mq/5/cpu_list
echo "poll" > /sys/block/nvme0n1/mq/4/qpoll
echo "poll" > /sys/block/nvme0n1/mq/5/qpoll

六、生产级可观测性:eBPF 监控 I/O 延迟分布

6.1 BPF 程序骨架


// io_latency.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(max_entries, 10240);
    __type(key, u64);    // cgroup ID
    __type(value, u64);  // 累积延迟 (ns)
} cgroup_iotal SEC(".maps");

struct {
    __uint(type, BPF_MAP_TYPE_HISTOGRAM);
    __uint(max_entries, 64);  // log2 直方图
    __type(key, u64);         // cgroup ID
    __type(value, u64);       // 计数
} io_lat_hist SEC(".maps");

SEC("tp_btf/block_rq_complete")
int BPF_PROG(trace_block_complete, struct request *rq, int error,
             unsigned int nr_bytes) {
    u64 cgroup_id = bpf_get_current_cgroup_id();
    u64 delta = bpf_ktime_get_ns() - rq->start_time_ns;
    
    // 累加延迟
    u64 *total = bpf_map_lookup_elem(&cgroup_iotal, &cgroup_id);
    if (total) __sync_fetch_and_add(total, delta);
    else { u64 d = delta; bpf_map_update_elem(&cgroup_iotal, &cgroup_id, &d, BPF_ANY); }
    
    // 记录直方图
    u64 slot = bpf_log2l(delta / 1000);  // 微秒级分桶
    if (slot < 64) bpf_map_increment(&io_lat_hist, &cgroup_id);
    
    return 0;
}

char LICENSE[] SEC("license") = "GPL";

6.2 用户态聚合脚本


#!/usr/bin/env python3
"""io_monitor.py - 实时查看混部节点各 cgroup I/O 延迟分布"""
import time
import ctypes
from bcc import BPF

bpf = BPF(src_file="io_latency.bpf.c")

HISTO_BUCKETS = [
    "<32us", "32us-64us", "64us-128us", "128us-256us",
    "256us-512us", "512us-1ms", "1ms-2ms", "2ms-4ms",
    "4ms-8ms", "8ms-16ms", "16ms-32ms", "32ms-64ms",
    "64ms-128ms", "128ms-256ms", "256ms-512ms", ">512ms"
]

def print_histogram(hist_map, cgroup_name):
    hist = bpf.get_table("io_lat_hist")
    total = sum(v.value for v in hist.values())
    print(f"\n=== {cgroup_name} I/O Latency Distribution (total={total}) ===")
    for i, bkt in enumerate(HISTO_BUCKETS[:hist.next-i]):
        v = hist[i].value if i < len(HISTO_BUCKETS) else 0
        pct = v / total * 100 if total > 0 else 0
        print(f"  {bkt:>12s}: {'█' * int(pct):50s} {pct:5.1f}%")

if __name__ == "__main__":
    print("Tracing I/O latency distribution... Ctrl+C to stop")
    while True:
        time.sleep(5)
        bpf["io_lat_hist"].clear()

七、故障案例与生产经验

案例 1:推理 P999 延迟从 2ms 突增到 50ms

排查过程:

  1. iotop> 发现训练任务开始 epoch 切换,产生突发随机读
  2. io.cost.qos> 发现 vrate> 已经降到 0% —— 训练超过了 QoS 限制造成了惩罚
  3. 根因:训练脚本使用了 O_DIRECT> + io_uring>,sqopoller 的 ioprio 设置为 idle>
  4. idle 优先级下,当推理服务也提交 I/O 时,NVMe WRR 仍然优先调度 idle 级别的低队列深度请求
  5. 修复方案:

    • 训练 sqpoller ioprio 从 idle> 调整为 BE class 5>(而非 idle>)
    • 给推理 cgroup 设置 io.low rbps=600000 riops=80000>

    案例 2:checkpoint 写入触发 OOM —— 未预期的 page cache 竞争

    根因分析:虽然训练使用 O_DIRECT> 读取数据集,但 checkpoint 写入走 page cache,脏页积累触发 dirty_ratio> 回写,在高 iocost 竞争下阻塞推理的 mmap page fault。

    解决:

    
    # 训练 cgroup 单独限制内存 dirty page 上限
    echo 1 > /sys/fs/cgroup/ai-training/memory.dirty.limit
    echo "G" > /sys/fs/cgroup/ai-training/memory.dirty.ratio  # 限制 10% cgroup 内存
    

    八、总结:混部 I/O 优先级策略速查表

    场景推荐配置
    在线推理io.low rbps=60% riops=50%> + sqthread BE class 0> + NVMe poll queue
    离线训练io.max rbps=90% riops=90%> + sqthread BE class 5> + NVMe interrupt queue
    紧急数据加载(如 checkpoint 恢复)临时切换到 io.low>,完成后降回 io.max>
    批处理 ETL 任务io.low 0> + idle class> + NVMe WRR weight 1

    在生产中,没有银弹配置。有效的混部 I/O 隔离需要逐层校准>(硬件 iocost model → cgroup io 参数 → io_uring ioprio → NVMe WRR),结合 eBPF 监控持续调优。

    关键认知:cgroup v2 io 控制器不是带宽限制器,而是 I/O 延迟分配器>。理解 blk-iocost 的 vrate(虚拟时间速率)和 delay 机制,才能真正用好这套工具。


    关于作者>:ybb.press 主要方向为 Linux 内核子系统、高性能网络与存储、AI基础设施工程。

    >

    代码声明>:本文示例代码要求 Linux 6.6+(需要 IORING_REGISTER_IOPRIO 支持)和 blk-iocost 启用。完整可运行代码仓库参见 ybb.press 代码示例区。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论