Linux 内核硬件中断机制深度实战:从 IRQ 到 softirq,AI 推理场景的硬中断延迟治理

一、问题的提出:为什么 AI 推理系统要关注硬中断?

大多数后端工程师对"中断"的理解停留在"硬件通知 CPU 有事要做"的抽象层面。但当你把一个 LLM 推理服务部署在单节点 8×GPU 的机型上时,网络 P99 延迟从 2ms 忽然飙升到 47ms,CPU 利用率看似不高,IO 也在水位线以下——top 里却多出一个 ksoftirqd 内核线程异常活跃。这时候,"中断"就是你需要直面的第一嫌疑人。

本文不是 API 文档复读,而是一次从硬件引脚到内核调度器、从 ftrace 到 eBPF 观测、从理论到生产实践的完整硬中断机制拆解。我们将回答三个核心问题:

二、硬件中断的解剖学:从 APIC 到 irq_desc

2.1 中断的物理路径

当网卡收到一个数据包,它通过 PCIe Memory Write Transaction 把描述符写入 RX Ring,然后通过 MSI-X 向目标 CPU 的 Local APIC 发送一个中断消息。在 x86-64 上,这对应以下硬件级步骤:

1. 网卡 → PCIe TLP (MSI-X write) → IOMMU 重定向 (若启用 VT-d)
2. IOMMU → LAPIC ← 中断向量号 + 目标 CPU APIC ID
3. LAPIC 在目标 CPU 上触发 INTR 引脚
4. CPU 在指令边界检查中断(RFLAGS.IF=1 时)
5. CPU 从中断向量号索引 IDT → 门描述符 → 跳转到 irq_entries_start

关键细节:中断并不直接打断指令执行,而是在当前指令完成后、下一条指令之前才响应。对于乱序执行的现代 CPU,这个边界检查的频率大约是每字节指令的间隙。

2.2 内核中断描述符:irq_desc 结构

每个中断向量在内核中对应一个 struct irq_desc(定义于 kernel/irq/irqdesc.c),它包含了中断处理的核心元信息:

struct irq_desc {
    struct irq_common_data  irq_common_data;
    struct irq_data         irq_data;
    unsigned int __percpu   *kstat_irqs;
    irq_flow_handler_t      handle_irq;    // 流处理层(如 handle_level_irq)
    struct irqaction        *action;       // 驱动注册的 handler 链表
    unsigned int            depth;         // disable 嵌套计数
    unsigned int            irq_count;     // 当前处理中计数(防止重入)
    unsigned long           last_unhandled;
    unsigned int            irqs_unhandled;
    raw_spinlock_t          lock;
    const char              *name;         // /proc/interrupts 中显示的名字
};

irqaction 是链表结构——多个设备可以共享同一个中断线(如多队列网卡的不同向量),每个驱动注册自己的 handler、dev_id、flags(IRQF_SHARED)。

2.3 IDT 与 irq_entries_start

在 arch/x86/kernel/idt.c 中,中断描述符表(IDT)被设置为一组门描述符。值得注意的是,Linux 使用中断门(Interrupt Gate)而非陷阱门——关键在于中断门会自动清除 EFLAGS.IF 位,也就是 x86 上硬件自动关中断(NMI 和某些异常依然能触发)。这是为什么 ISR(中断服务例程)默认运行在关中断状态——必须尽快完成或转交 softirq。

// arch/x86/entry/entry_64.S 简化逻辑
SYM_CODE_START(irq_entries_start)
    // 向量号通过宏迭代生成,vector 0x20 到 0xff
    .rept 0xe0  // 224 个外部中断向量
    UNWIND_HINT_IRET_REGS
    pushq $(\rst - irq_entries_start)    // 异常帧
    jmp interrupt_entry                 // C 入口
    .endr
SYM_CODE_END(irq_entries_start)

interrupt_entry 最终调用 dispatch_external_irq() → apic_eoi() → handle_irq() → ISR 链表调用,然后再进入 irq_exit() 按需调度 softirq。

三、上半部与下半部(Top Half / Bottom Half)的本质划分

3.1 设计动因

如果所有工作都在 ISR 中做(IRQF_DISABLED 标志后已移除,标志着 Linux 全面转向下半部模型),那么系统会面临:

  • 中断遮蔽时间过长:IF=0 期间,同级/低级中断无法响应
  • 实时性劣化:调度器 tick、时钟中断被屏蔽
  • 延迟波动剧烈:ISR 执行时间直接贡献到尾延迟

因此 Linux 采用 Top Half(关中断)+ Bottom Half(开中断、可延迟) 的经典分层。

3.2 下半部机制的演进史

机制引入版本特点当代地位
BH (Bottom Halves)早期全局32个静态链表已废弃
Tasklet2.3基于 softirq 的 per-CPU 链表,同类型不并发受限使用
softirq2.332种静态,同类型可在不同CPU并发核心机制
Workqueue2.5可睡眠、可调度、运行在进程上下文广泛使用
Threaded IRQ2.6.30kthread 包装 ISR,可睡眠生产推荐

现代内核(6.x)中活跃的 softirq 类型只有 10 种:

// include/linux/interrupt.h
enum {
    HI_SOFTIRQ = 0,        // 高优先级 tasklet
    TIMER_SOFTIRQ,         // 定时器 (hrtimer 退化时)
    NET_TX_SOFTIRQ,        // 网络发送
    NET_RX_SOFTIRQ,        // 网络接收 ← AI 推理的关键瓶颈
    BLOCK_SOFTIRQ,         // 块层 completions (bio)
    IRQ_POLL_SOFTIRQ,      // NAPI poll 回退
    TASKLET_SOFTIRQ,       // 通用 tasklet
    SCHED_SOFTIRQ,         // CFS 负载均衡
    HRTIMER_SOFTIRQ,       // 高精度定时器
    RCU_SOFTIRQ,           // RCU 回调执行
    NR_SOFTIRQS
};

3.3 softirq 的完整调度链路

一次 RX 数据包在 NAPI poll 中的完整执行路径:

hardirq (IRQ handler)
  └── napi_schedule()           // 关闭设备中断,触发 softirq
      └── raise_softirq_irqoff(NET_RX_SOFTIRQ)
          └── __raise_softirq_irqoff()
              └── 设置 CPU 的 softirq pending 位图 (local_cpu_offset)

── 下一个 irq_exit() 或local_bh_enable() 检查点 ──

do_softirq()
  └── __do_softirq()
      └── h->action[h->action_ret]  // net_rx_action()
          └── mydriver_poll()      // 驱动 NAPI poll 函数
              └── napi_gro_receive()
                  └── netif_receive_skb()
                      └── ip_rcv()  // 进入协议栈

注意 raise_softirq_irqoff 带 _irqoff 后缀——它在已经关中断(hardirq 上下文)中调用,只需设置 pending 位图而不需要同步。在 irq_exit() 时内核会检查:如果当前不在中断上下文且有 pending softirq,则调用 invoke_softirq()。

四、x86-64 vs ARM64:关中断的真实代价

4.1 x86-64:CLI/STI 与内存序

// arch/x86/include/asm/irqflags.h
static inline notrace unsigned long arch_local_irq_disable(void)
{
    unsigned long flags;
    asm volatile("# arch_local_irq_disable\n\t"
                 "pushfq ; popq %0 ; cli"
                 : "=rm" (flags)
                 :
                 : "memory", "cc");
    return flags;
}

cli 指令会:

在生产中使用 local_irq_save() 的常见陷阱是持有时间过长。eBPF 工具 irqoff 可以帮助定位:

# 追踪关中断超过 50μs 的代码段
bpftrace -e '
kprobe:arch_local_irq_disable {
    @start[tid] = nsecs;
}
kprobe:arch_local_irq_enable /@start[tid]/ {
    $dur = nsecs - @start[tid];
    if ($dur > 50000) {
        printf("IRQ OFF for %llus at %s\n", $dur/1000, kstack);
    }
    delete(@start[tid]);
}'

实际某 AI 推理节点上(8×A100 + 2×25Gbps NIC),我们发现 CUDA Kernel 启动路径中某驱动模块的 local_irq_save() 持续了 120μs,原因是保护了一个过大的自旋锁区域。优化后 P99 推理延迟降低了 40%。

4.2 ARM64:DAIF 操作与 GICv3 交互

// arch/arm64/include/asm/irqflags.h
static inline void arch_local_irq_disable(void)
{
    asm volatile(
        "msr    daifset, #2     // 屏蔽 IRQ\n\t"
        "isb"
        :
        :
        : "memory", "cc");
}

ARM64 上 daifset 操作 DAIF 寄存器中的 I 位(bit 7)。注意关键差异:

  • ARM64 的 msr daifset, #2 在 3 个 cycle 内完成
  • 但需要 isb 保证流水线刷新(约 5-10 cycles)
  • 相比 x86 的 cli,ARM64 的中断屏蔽/开启延迟更低但确定性更差(依赖 GIC distributor 的响应延迟)

GICv3 的IRI(Interrupt Routing Information)在 AI 集群中尤为重要——当使用 GPUDirect RDMA 时,NIC 和 GPU 可能共享 SPI(Shared Peripheral Interrupt),错误的路由配置会导致 GPU 侧 MMU 故障 IRQ 被 CPU 错误的 pin 接收。

五、NAPI 与网络中断深度分析

5.1 NAPI 轮询机制的数学直觉

NAPI (New API) 的核心是中断抑制 + 自适应轮询。当数据包到达率超过某个阈值时,内核从纯中断模式切换到轮询模式,避免"中断风暴"(livelock)。

关键参数:

  • dev_weight:单个 NAPI poll 允许处理的最大数据包数(默认 64)
  • budget:全局所有 NAPI 设备的总处理配额(net.core.netdev_budget,默认 300)
  • gro_flush_timeout:GRO 合并超时(默认 50us)

吞吐量 vs 延迟的权衡可以用以下公式量化:

当 RTT_interrupt < (T_poll - T_int/B) × B 时,NAPI 优于纯纯中断模式

其中:
- RTT_interrupt:中断处理往返时间(~2-5μs on modern CPU)
- T_poll:poll 函数执行时间(与权重相关)
- T_int:单次中断处理时间
- B:每次 poll 处理的数据包数

对于 AI 推理场景(大量小对象 RPC,如 4KB 请求 + 8KB 响应),NAPI 的默认 dev_weight=64 往往导致单个 poll 周期处理 64 个包的 GRO 合并,P99 延迟从 8μs 增加到 150μs。推荐将其调整为 16 并开启 irq_affinity:

# 调整网络接收队列的 poll 配额
echo 16 > /sys/class/net/eth0/queues/rx-0/napi_weight  # 伪路径,实际通过 ethtool 或驱动参数

# 更实用的做法:调整全局 budget
sysctl -w net.core.netdev_budget=200        # 减少每轮处理量
sysctl -w net.core.netdev_budget_usecs=2000 # 限制 poll 时间预算(ms→μs)

5.2 多 queue 网卡的 IRQ Affinity 算法

对于 Mellanox ConnectX-6 Dx(128 队列)或 AWS ENA(与 vCPU 数一致),最优的 IRQ-CPU 绑定不是"均匀分布"。在 NUMA 拓扑下:

拓扑模式推荐策略性能差异
同 NUMA,CPU 直连 PCIeNIC queue N → NUMA-local CPU Nbaseline
跨 NUMA(NIC in socket 0)全部 queue → Socket 0 CPU+5% 吞吐,但 NUMA penalty
混合部署(推理含 GPU)NIC queue 与 GPU 同 NUMA CPUNUMA 避免 30-60ns 延迟

生产实践中的最优算法:

#!/usr/bin/env python3
"""NIC-IRQ affinity optimizer for AI inference nodes"""
import glob, json, os

def get_nic_queues(dev):
    return sorted(glob.glob(f'/sys/class/net/{dev}/queues/rx-*'))

def get_cpu_numa(cpu):
    with open(f'/sys/devices/system/cpu/cpu{cpu}/topology/numa_node') as f:
        return int(f.read().strip())

def get_irq_num(queue_path):
    # 从 /proc/interrupts 匹配队列名
    ...

def assign_affinity(dev, cpus_by_weight):
    for i, q in enumerate(get_nic_queues(dev)):
        cpu = cpus_by_weight[i % len(cpus_by_weight)]
        irq = get_irq_num(q)
        with open(f'/proc/irq/{irq}/smp_affinity_list', 'w') as f:
            f.write(str(cpu))
        print(f"RX queue {i} (irq {irq}) → CPU {cpu}")

# 典型 AI 节点:2 NUMA, 每 socket 32 cores
# 保留 core 0-1 给系统 + ksoftirqd, 2-31 给 NIC
assign_affinity('eth0', list(range(2, 32)))

六、生产环境 eBPF 观测实践

6.1 用 BPF 追踪软中断延迟

// softirq_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, u32);      // vector
    __type(value, u64);    // enter timestamp
} start SEC(".maps");

struct {
    __uint(type, BPF_MAP_TYPE_HISTOGRAM);
    __uint(max_entries, 256);
    __type(key, u32);
    __type(value, u64);
} lat_hist SEC(".maps");

SEC("tp/irq/softirq_entry")
int trace_softirq_enter(struct trace_event_raw_softirq *ctx)
{
    u32 vec = ctx->vec;
    u64 ts = bpf_ktime_get_ns();
    bpf_map_update_elem(&start, &vec, &ts, BPF_ANY);
    return 0;
}

SEC("tp/irq/softirq_exit")
int trace_softirq_exit(struct trace_event_raw_softirq *ctx)
{
    u32 vec = ctx->vec;
    u64 *tsp = bpf_map_lookup_elem(&start, &vec);
    if (!tsp) return 0;
    
    u64 delta_us = (bpf_ktime_get_ns() - *tsp) / 1000;
    bpf_map_delete_elem(&start, &vec);
    
    // 直方图记录:NET_RX_SOFTIRQ = 2
    u64 key = ilog2(delta_us);  // log2 bucket
    u64 *cnt = bpf_map_lookup_elem(&lat_hist, &key);
    if (cnt) __sync_fetch_and_add(cnt, 1);
    return 0;
}

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

6.2 追踪硬中断频率

# 统计每 CPU 每秒各类硬中断次数
bpftrace -e '
tracepoint:irq:irq_handler_entry {
    @[args->irq, comm] = count();
}
interval:s:5 {
    print(@);
    clear(@);
}'

6.3 定位 ksoftirqd 过载的根因

在某生产节点上观察到 ksoftirqd/3 持续 80% CPU 利用率,用 perf + trace 定位:

# 查看 softirq 触发频率分布
watch -n1 'cat /proc/softirqs'

# 使用 eBPF 找到 poll 函数调用栈
bpftrace -e '
kprobe:net_rx_action {
    @poll_stacks[comm, kstack] = count();
}'

结论是:某推理节点的 TCP 长连接 keepalive interval 过短(5s),导致大量 keepalive ACK 在 softirq 中处理,配合 NAPI 的 GRO 合并形成了"假性中断风暴"。调整后 net.ipv4.tcp_keepalive_time=600,ksoftirqd CPU 从 80% 降到 5%。

七、AI 推理场景的硬中断治理清单

以下 checklist 来源于多个 AI 推理集群的调优经验汇总,按优先级排列:

7.1 中断亲和性(第一优先级)

  • [ ] 确认 NIC 所在 NUMA 节点(lspci -vt → RC → 对应 CPU socket)
  • [ ] 将 NIC RX/TX queue 绑定与 NIC 同 NUMA 的 CPU core
  • [ ] 禁用 irqbalance(它会将中断重新分布,破坏你已经调优的亲和性)
  • [ ] 为每个 RX queue 保留独立 CPU,不与推理 worker 共享

7.2 softirq 治理(第二优先级)

  • [ ] 调低 net.core.netdev_budget 到 200(减少单次 poll 量以降低延迟)
  • [ ] 启用 RPS/RFS 仅在多 queue 未能完全隔离时作为补充
  • [ ] 如果NIC支持 hardware timestamping,确认 PHC 同步正确(phc2sys)
  • [ ] 监控 /proc/softirqs 中 NET_RX 的增长速率

7.3 内核参数调优

# /etc/sysctl.d/99-ai-inference-network.conf
# 网络接收路径
net.core.rmem_max = 16777216
net.core.rmem_default = 1048576
net.ipv4.tcp_rmem = 4096 1048576 16777216

# keepalive 优化避免伪造中断
net.ipv4.tcp_keepalive_time = 600
net.ipv4.tcp_keepalive_intvl = 30

# NAPI 预算控制
net.core.netdev_budget = 200
net.core.netdev_budget_usecs = 2000
net.core.dev_weight_rx_bias = 1

# RPS 备用分流(仅在 queue < CPU 时启用)
echo f > /sys/class/net/eth0/queues/rx-0/rps_cpus  # f = CPU 0-3

7.4 驱动层优化(第三优先级)

  • [ ] 启用 Adaptive RX/TX Coalesce(ethtool -C eth0 rx-usecs 100 tx-usecs 100)
  • [ ] 对于低延迟推理场景,考虑关闭 NAPI(纯中断模式配合 dev_weight=1)
  • [ ] 更新网卡固件(Mellanox FW 20.35+ 修复了 GRO split buffer 导致的额外中断 bug)
  • [ ] 确认 PCIe MaxPayloadSize 和 MaxReadRequestSize 设置为最优(MPS=512, MRR=4096)

八、前沿趋势:io_uring 与中断模型的演进

io_uring 正在从"异步 IO 框架"演化为"绕过 softirq 的通用数据平面"。关键演进包括:

对于 AI 推理中的 KV Cache 加载、模型 checkpoint、请求日志落盘,配合 io_uring 的 polling 模式可以彻底将中断延迟服务(< 2μs)与硬中断路径解耦。未来的方向是 eBPF/XDP 在驱动层(SmartNIC/DPU)完成负载均衡和请求过滤,CPU 只处理"计算"而非"中断"。

九、总结

硬中断与 softirq 是 Linux 性能调优中"看似简单实则深渊"的领域。在 AI 推理场景下,它的影响不只是吞吐量的线性增减——更是尾延迟(P999)抖动的核心来源。三条核心原则:

在下一篇文章中,我们将深入讨论 io_uring polling 模式如何与 NVMe 队列深度自适应结合,构建"零中断"的 AI 训练数据加载流水线。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
/* 跳过导航链接 (无障碍) */ .skip-link { position: absolute; top: -100px; left: 15px; z-index: 99999; padding: 8px 16px; background: #007bff; color: #fff; font-size: 14px; border-radius: 0 0 4px 4px; text-decoration: none; transition: top 0.2s; } .skip-link:focus { top: 0; outline: 3px solid #0056b3; }