Linux Kernel PSI 深度解析:从内核压力信号到 AI 推理 QoS 与自动弹性伸缩

在生产环境中运行 AI 推理服务时,我们常面临一个困境:传统的 CPU 利用率、内存占用率等指标往往是"事后诸葛亮"——当指标恶化时,服务延迟已经飙升。Linux 内核 4.20 引入的 PSI(Pressure Stall Information)提供了一种全新的视角,它直接量化了任务因资源不足而被"卡住"的时间和程度,让我们能在延迟恶化之前就发现压力,做出主动的调度决策。

本文将深入 PSI 的内核实现机制,探讨如何利用 cgroup2 的 per-cgroup 压力视图,编写 BPF 程序实时跟踪压力信号,并构建一套基于 PSI 的 AI 推理自动弹性伸缩系统。


一、PSI 的设计哲学:为什么传统的指标不够

1.1 "利用率陷阱"

对于 AI 推理这类尾延迟敏感的服务,CPU 利用率 70% 并不意味着一切正常。GPU 推理请求的预处理阶段(Tokenize、张量布局变换)依赖 CPU,当大量推理请求同时涌入时,CPU 调度延迟可能在几毫秒内从亚微秒级暴增至数十毫秒。传统指标看到的是"CPU 还很闲",但 PSI 看到的是"有 40% 的任务在等待 CPU"。


传统指标:
  CPU usage: 65%     ← 看起来一切正常
  Memory used: 8GB   ← 还远没打满

PSI 指标:
  cpu.some avg10=45% ← 45%的任务在等待CPU至少10秒
  memory.full avg60=30% ← 30%的任务同时因内存等待

PSI 的核心洞察是:压力不是"用了多少",而是"等了多久"。它统计的是至少有一个任务因该资源不足而停滞的时间占比。

1.2 PSI 的三类资源

PSI 监控三类资源压力:

资源 含义 典型场景:AI 推理
cpu CPU 调度等待 预处理线程争抢、GPU 调度回调延迟
memory 内存回收/分配等待 KV Cache 分配、OOM 前兆、模型加载
io 块设备 I/O 等待 模型权重加载、KV Cache Swap

每类压力又分为两个维度:

  • some:至少一个任务因该资源停滞的时间占比
  • full:几乎所有可运行任务同时停滞的时间占比(full 是 some 的超集)

二、PSI 内核实现机制

2.1 核心数据结构

PSI 的实现位于 kernel/sched/psi.c,核心是 psi_group 结构:


struct psi_group {
    /* 任务统计 */
    struct task_group_stats *tasks;   /* [PSI_NONISR] 各状态任务计数 */
    /* 时间累积器 */
    u64 avg_total[NR_PSI_STATES];     /* 总停滞时间 */
    u64 avg_last_update;              /* 上次采样时间戳 */
    u64 avg[NR_PSI_STATES];           /* 输出给用户的滑动平均 */
    /* 锁与定时器 */
    struct mutex avgs_lock;
    struct timer_list avg_timer;       /* 周期性定时器,触发平均计算 */
    /* ... */
};

每当任务状态变迁(try_to_wake_up、schedule 等路径),内核会调用 psi_task_change() 和 psi_task_switch() 更新各资源域的任务计数。

2.2 停滞时间计算

PSI 不逐任务计时,而是采样一群任务的全局状态:


/* psi.c 中的核心计算逻辑 */
void psi_account_irqtime(struct task_struct *task, u64 irqtime) { ... }

static u64 calc_timer_delay(enum psi_states state, ...) {
    u64 now = cpu_clock(cpu);
    /* 计算所有资源不足的任务的总停滞时间 */
    u64 *total = group->tasks[state];
    /* 更新滑动窗口平均值 */
    for_each_possible_cpu(cpu)
        aggregate(group->cpus[cpu], ...);
}

核心思想是:在内核采样时刻(psi_clock()),登记此刻因 CPU/内存/IO 不足而停滞的任务数,乘以采样间隔即为停滞时间增量。

2.3 滑动平均算法

PSI 使用指数加权移动平均(EWMA)算法来计算 avg10、avg60、avg300 三个窗口:


新平均 = 旧平均 × e^(-Δt/τ) + 当期停滞比例 × (1 - e^(-Δt/τ))

其中 τ 分别对应 10 秒、60 秒、300 秒。这意味着 avg10 对突发压力高度敏感(适合触发告警),而 avg300 更适合趋势决策(适合容量规划)。

内核在 psi_avgs_work() 中通过高精度定时器每 2 秒执行一次 EWMA 计算:


static void psi_avgs_work(struct work_struct *work) {
    struct psi_group *group =
        container_of(work, struct psi_group, avgs_work);
    u64 now = sched_clock();
    
    mutex_lock(&group->avgs_lock);
    group->avg_last_update = now;
    calculate_averages(group, now);
    mutex_unlock(&group->avgs_lock);
}

三、用户态接口

3.1 procfs 接口

PSI 通过 /proc/pressure/ 暴露系统级压力:


$ cat /proc/pressure/cpu
some avg10=2.04 avg60=4.64 avg300=1.65 total=123456789
$ cat /proc/pressure/memory
some avg10=0.50 avg60=1.20 avg300=0.30 total=98765432
full avg10=0.30 avg60=0.80 avg300=0.10 total=54321000
$ cat /proc/pressure/io
some avg10=0.00 avg60=0.10 avg300=0.05 total=12345678

每行格式为:部分聚合 窗口平均=百分比 总累积微秒。total 字段表示系统启动以来该资源总停滞时间,适合做差值计算。

3.2 轮询与事件通知

PSI 文件支持 poll() 和 select(),可以在压力超过阈值时触发事件,实现"事件驱动"的压力监控,避免频繁轮询:


int fd = open("/proc/pressure/memory", O_RDONLY);
struct pollfd pfd = { .fd = fd, .events = POLLPRI | POLLERR };

// 设置压力阈值(通过 write 写入)
// 格式:<资源> <窗口秒> <阈值微秒>
dprintf(fd, "some 60 1000000");  // 60秒窗口停滞超过1秒时通知

while (1) {
    int ret = poll(&pfd, 1, -1);
    if (ret > 0 && (pfd.revents & POLLPRI)) {
        // 压力超阈值!
        trigger_auto_scaling();
    }
}

3.3 cgroup2 per-cgroup 压力视图

对于容器化 AI 服务,更精确的方式是 per-cgroup PSI。cgroup2 在组内 memory.pressure、cpu.pressure、io.pressure 中暴露了各容器的压力值:


# 查看某命名空间的内存压力
$ cat /sys/fs/cgroup/system.slice/ai-inference/memory.pressure
some avg10=2.04 avg60=4.64 avg300=1.65 total=123456789
full avg10=0.30 avg60=0.80 avg300=0.10 total=54321000

这意味着你可以看到"推理服务 A 在内存压力下挣扎,而训练任务 B 一清二白",精准判断谁是噪音邻居(noisy neighbor)。


四、BPF 程序跟踪 PSI 事件

4.1 BPF tracepoints

内核为 PSI 暴露了 tracepoints,BPF 程序可以直接钩取压力更新事件:


tracepoint/psi/psi_memstall_enter    // 进入内存停滞
tracepoint/psi/psi_memstall_leave    // 退出内存停滞
tracepoint/psi/psi_ioenter           // 进入 IO 停滞
tracepoint/psi/psi_ioleave           // 退出 IO 停滞
tracepoint/psi/psi_irqtime           // IRQ 时间统计

4.2 BPF 实时压力监控程序

编写 BPF 程序追踪推理进程的内存停滞事件:


# psi_monitor.py (BCC 风格伪代码)
from bcc import BPF

bpf_text = """
#include <uapi/linux/ptrace.h>
#include <linux/sched.h>

struct stall_event {
    u64 delta_us;
    u32 pid;
    char comm[TASK_COMM_LEN];
    u8  resource;  // 0=cpu, 1=memory, 2=io
};

BPF_HASH(start_info, u32, u64);
BPF_PERF_OUTPUT(stall_events);

TRACEPOINT_PROBE(psi, memstall_enter) {
    u32 pid = bpf_get_current_pid_tgid() >> 32;
    // 只跟踪推理服务进程
    char comm[TASK_COMM_LEN];
    bpf_get_current_comm(&comm, sizeof(comm));
    if (comm[0] != 'i' || comm[1] != 'n' || comm[2] != 'f')
        return 0;  // 过滤
    
    u64 ts = bpf_ktime_get_ns();
    start_info.update(&pid, &ts);
    return 0;
}

TRACEPOINT_PROBE(psi, memstall_leave) {
    u32 pid = bpf_get_current_pid_tgid() >> 32;
    u64 *tsp = start_info.lookup(&pid);
    if (!tsp) return 0;
    
    u64 delta_us = (bpf_ktime_get_ns() - *tsp) / 1000;
    // 只报告超过 100 微秒的停滞
    if (delta_us < 100) return 0;
    
    struct stall_event e = {};
    e.delta_us = delta_us;
    e.pid = pid;
    bpf_get_current_comm(&e.comm, sizeof(e.comm));
    e.resource = 1;
    stall_events.perf_submit(args, &e, sizeof(e));
    start_info.delete(&pid);
    return 0;
};
"""

b = BPF(text=bpf_text)
print("追踪 AI 推理服务的内存停滞事件...")

def print_event(cpu, data, size):
    event = b["stall_events"].event(data)
    print(f"PID={event.pid:6d} comm={event.comm.decode():16s} "
          f"memstall={event.delta_us:8d} us")

b["stall_events"].open_perf_buffer(print_event)
while True:
    b.perf_buffer_poll()

4.3 实际采样输出

在高负载推理场景下运行该 BPF 程序,可以看到:


PID=18452 comm=inference-serv memstall=    2840 us
PID=18453 comm=inference-serv memstall=    5210 us
PID=18452 comm=inference-serv memstall=   12300 us  ← 接近超时阈值
PID=18454 comm=inference-serv memstall=     960 us

这些微秒级的停滞正是 GPU 推理请求等待 CPU 侧预处理完成时产生的。通过 delta_us 分布直方图(BPF_HISTOGRAM),你可以确定 P99 延迟峰值与内存停滞的强相关性。


五、基于 PSI 的 AI 推理自动弹性伸缩

5.1 伸缩决策框架

传统方案依赖请求队列深度或 QPS,但这些是结果的体现。PSI 是压力信号的早期指标,能提前 30-60 秒预警:


请求数 ↑ → CPU 停滞增加(10s 后 avg10 上升)→ 最终队列积压、超时
              ↑
         在这里主动扩容!

5.2 决策矩阵

压力信号 持续时间 建议动作
cpu.some avg10 > 30% > 30s 扩容 +1 实例
cpu.full avg10 > 10% > 15s 紧急扩容 +2 实例
memory.some avg60 > 5% > 60s 检查 KV Cache 配置
memory.full avg10 > 10% > 10s OOM 预警,立刻扩容 + 降负载压力
io.some avg10 > 20% > 30s 模型加载慢,预热后重启

5.3 伸缩器原型实现


# psi_autoscaler.py - 基于 PSI 的推理服务弹性伸缩器
import time
import os
import subprocess

class PSIAutoScaler:
    def __init__(self, 
                 cgroup_path: str,
                 thresholds: dict,
                 scale_up_step: int = 1,
                 cooldown: int = 60):
        self.cgroup = cgroup_path
        self.thresholds = thresholds  # {'cpu_some_10': 30.0, ...}
        self.step = scale_up_step
        self.cooldown = cooldown
        self.last_scale = 0
        self.consecutive_triggers = 0
    
    def read_psi(self, resource: str = 'cpu') -> dict:
        """读取 cgroup PSI 数据"""
        path = os.path.join(self.cgroup, f'{resource}.pressure')
        with open(path) as f:
            line = f.readline()  # some 行
            parts = line.split()
            result = {}
            for p in parts[1:]:
                k, v = p.split('=')
                result[k] = float(v)
            return result
    
    def should_scale_up(self) -> bool:
        """判断是否应扩容"""
        cpu = self.read_psi('cpu')
        mem = self.read_psi('memory')
        
        # CPU 调度停滞:avg10 > 30% 持续 3 个周期
        cpu_trigger = cpu.get('avg10', 0) > self.thresholds.get('cpu_some_10', 30)
        
        # 内存停滞:avg60 > 5%
        mem_trigger = mem.get('avg60', 0) > self.thresholds.get('mem_some_60', 5)
        
        return cpu_trigger or mem_trigger
    
    def scale_up(self, target_name: str = 'ai-inference'):
        """执行扩容"""
        now = time.time()
        if now - self.last_scale < self.cooldown:
            print(f"[冷却中] 距上次扩容 {int(now - self.last_scale)}s")
            return
        
        self.consecutive_triggers += 1
        if self.consecutive_triggers < 3:
            print(f"[触发计数 {self.consecutive_triggers}/3] 压力上升中,等待确认...")
            return
        
        replicas = self._get_current_replicas(target_name) + self.step
        print(f"[执行扩容] {target_name}: {replicas - self.step} → {replicas}")
        subprocess.run(['kubectl', 'scale', f'deployment/{target_name}',
                       f'--replicas={replicas}'], check=True)
        
        self.last_scale = now
        self.consecutive_triggers = 0
    
    def run(self, interval: int = 10):
        """主循环"""
        print(f"PSI Autoscaler 开始监控 {self.cgroup}")
        while True:
            try:
                if self.should_scale_up():
                    self.scale_up()
                else:
                    self.consecutive_triggers = 0
            except Exception as e:
                print(f"[错误] {e}")
            time.sleep(interval)

# 实际启动
if __name__ == '__main__':
    scaler = PSIAutoScaler(
        cgroup_path='/sys/fs/cgroup/system.slice/ai-inference',
        thresholds={
            'cpu_some_10': 30.0,
            'cpu_full_10': 10.0,
            'mem_some_60': 5.0,
        },
        cooldown=60
    )
    scaler.run(interval=10)

5.4 反抖动策略

仅凭平均值决策很容易引起抖动,关键的三层保护:

  1. 连续触发确认:连续 N 个周期(如 3 次)压力超标才执行
  2. 冷却期:扩容后 60 秒内不再扩容(让新实例稳定)
  3. 平局滤波:avg10 > 阈值且 avg60 也 > 阈值时才行动
  4. 
    avg10 > 30% AND avg60 > 15%  → 确认扩容
    avg10 > 30% BUT avg60 < 15%  → 观望(可能是突发尖刺)
    

    六、生产部署实战

    6.1 容器内 PSI 可见性

    要让容器能看到自己的压力信号,确保:

    
    # cgroup v2 + cgroup.subtree_control 包含 cpu memory io
    securityContext:
      # 不要做无意义的 seccomp 过滤阻断 PSI 读取
    
    
    # 在容器内验证
    cat /proc/pressure/cpu        # 宿主机全局压力
    cat /sys/fs/cgroup/cpu.pressure  # 本容器 cgroup 的压力
    

    注意:容器默认只看到其所在 cgroup 的压力(per-cgroup PSI),看不到全局压力。这也是优势——你看到的是自身"切蛋糕"大小的后果。

    6.2 与 systemd 集成

    systemd 自 245 起原生支持 PSI 阈值:

    
    # /etc/systemd/system/ai-inference.service
    [Service]
    MemoryPressureWatch=on
    MemoryPressureThresholdSec=5s       # 内存压力超5秒触发通知
    CPUPressureWatch=on  
    CPUPressureThresholdSec=10s
    

    systemd 通过 sd-bus 发出 org.freedesktop.systemd1.Service.PressureWatch 事件,可以配合 systemctl show --property=MemoryPressureUSec 查询。

    6.3 记录历史数据(Prometheus + node_exporter)

    node_exporter 自 1.5 起暴露 PSI 指标:

    
    # prometheus 监控规则
    - alert: AIInferenceHighMemoryPressure
      expr: node_psi_memory_seconds{job="ai-inference"} > 5
      for: 1m
      labels:
        severity: warning
      annotations:
        summary: "推理实例 {{ $labels.instance }} 内存压力异常"
        
    - alert: AIInferenceCPUPressure
      expr: rate(node_psi_cpu_seconds{job="ai-inference"}[5m]) > 0.3
      for: 3m
      labels:
        severity: critical
    

    6.4 eBPF 热路径优化

    对于超高 QPS 推理服务,频繁的 PSI 统计本身可能引入微妙开销。建议:

    • 使用 nostat + 关掉 psi=1(内核启动参数,关闭 PSI 统计)
    • 对延迟极度敏感的服务,可关闭 PSI:psi=0
    • 大多数场景下 PSI 开销 < 0.1%,无需关闭

    七、与传统方案的对比与取舍

    维度 PSI 方案 传统方案(QPS/CPU)
    预警提前量 30-60 秒 几乎为 0
    噪音邻居感知 直接量化 间接推断
    尾延迟相关性 强(直接测量停滞) 弱(仅看均值)
    部署复杂度 中(需内核 4.20+) 低
    可解释性 需要团队学习 全员熟悉
    适用场景 QoS 敏感、弹性伸缩 简单阈值告警

    7.1 不依赖 PSI 的场景

    PSI 不是万能的。以下场景它帮助不大:

    • 纯 GPU 计算瓶颈:推理延迟由 GPU kernel 运行时间主导,CPU/IO 停滞很少
    • 网络带宽饱和:PSI 不监控网络带宽压力
    • 冷启动延迟:PSI 反映的是持续压力,不覆盖冷启动拉镜像等一次性事件

    八、总结与展望

    PSI 为 AI 推理服务提供了一种全新的压力感知范式:从"看结果"转向"看原因"。通过 per-cgroup 压力视图,你可以精准定位噪音邻居;通过 BPF 追踪,你可以关联微秒级停滞与尾延迟毛刺;通过事件驱动的伸缩器,你可以提前扩容而不是被动救火。

    生产建议总结:

    1. 全量开启 PSI,通过 Grafana 建立压力仪表盘
    2. 为推理服务单独设置 cgroup,独立观察 PSI
    3. 使用 avg10 + avg60 双窗口确认策略避免误扩
    4. 实现 BPF 探针收集停滞时间 P99 分布,作为延迟根因分析的佐证
    5. 与现有 Kubernetes HPA 互补:HPA 处理 QPS 维度,PSI Autoscaler 处理内核资源压力维度
    6. 随着 CXL 内存分层和 CCF(云原生基础设施)的成熟,PSI 将在 AI 推理的边缘计算、Serverless 场景中发挥更大价值——毕竟在那些场景下,"资源紧张导致尾延迟飙升"正是最大的敌人。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部