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个静态链表 | 已废弃 |
| Tasklet | 2.3 | 基于 softirq 的 per-CPU 链表,同类型不并发 | 受限使用 |
| softirq | 2.3 | 32种静态,同类型可在不同CPU并发 | 核心机制 |
| Workqueue | 2.5 | 可睡眠、可调度、运行在进程上下文 | 广泛使用 |
| Threaded IRQ | 2.6.30 | kthread 包装 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 直连 PCIe | NIC queue N → NUMA-local CPU N | baseline |
| 跨 NUMA(NIC in socket 0) | 全部 queue → Socket 0 CPU | +5% 吞吐,但 NUMA penalty |
| 混合部署(推理含 GPU) | NIC queue 与 GPU 同 NUMA CPU | NUMA 避免 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 训练数据加载流水线。

发表评论 取消回复