端侧小模型在多核 CPU 上的绑核与亲和性设置实战:sched_setaffinity 调优

封面信息图

在端侧嵌入式设备、边缘服务器或工控机上运行小参数量大语言模型(如 0.5B~3B 的轻量 SLM)时,不少开发者会遇到一个典型现象:明明 CPU 整体占用率已经拉满到了 80%90%,但模型的生成速率(Tokens/s)却出现周期性剧烈抖动,首字延迟(TTFT)偶尔暴增 23 倍。

perftrace-cmd 一抓调度事件就会发现真相:Linux 默认的 CFS(Completely Fair Scheduler)调度器为了实现全核负载均衡,会把正在进行密集矩阵乘法(GEMM)运算的推理工作线程频繁地在不同 CPU 核心之间迁移(Task Migration)。

这种跨核迁移在端侧 AI 推理场景下是极其致命的:

  1. CPU 缓存失效(Cache Thrashing):每次跨核搬迁,L1/L2 数据缓存瞬间清空,需要重新从 L3 甚至 DDR 物理内存加载权重切片,LLC Cache Miss 暴增;
  2. 异构多核(big.LITTLE)短板效应:ARM 架构下如果 4 个并行推理线程中有 1 个被调度到了小核(LITTLE Core),根据多线程 Barrier 同步机制,其余 3 个跑在大核(Cortex-A78/X3)上的线程只能空转等待小核算完,整体吞吐直接暴跌;
  3. 超线程(SMT)资源争抢:在 x86 架构下,两个同属一个物理核心的超线程如果同时跑 AVX-512/AMX 算子,会争抢浮点执行单元(FPU)引发端口拥塞。

要榨干多核 CPU 的算力,必须通过 sched_setaffinity 系统调用实现显式进程与线程级 CPU 亲和性锁定(Core Pinning)


1. 探查 CPU 物理拓扑与架构特征

在写代码之前,必须先理清硬件的物理核心、超线程以及跨核 Cluster 布局。通过查看 sysfs 接口获取真实拓扑:

# 查看 CPU 核心编号、物理 Package、Core ID、L1/L2/L3 缓存共享关系
lscpu -e=CPU,NODE,SOCKET,CORE,L1D,L2,L3,MAXMHZ

# 在 ARM 异构平台上查看每个核的最大频率,区分大核与小核
for cpu in /sys/devices/system/cpu/cpu[0-9]*; do
    echo "$cpu: $(cat $cpu/cpufreq/cpuinfo_max_freq 2>/dev/null || echo 'N/A') kHz"
done

假设在一台 8 核 ARM 设备上(CPU 03 为小核 A55,CPU 47 为大核 A78),我们必须将端侧推理线程严格隔离在 CPU 47 上,而把日志收集、网络 I/O 线程扔给 CPU 03。


2. 基于 sched_setaffinity 的 C++ 绑核组件实现

以下封装了一个跨 Linux 平台的轻量线程亲和性管理组件,可在推理线程初始化时精准锁定物理大核:

#define _GNU_SOURCE
#include <sched.h>
#include <pthread.h>
#include <unistd.h>
#include <iostream>
#include <vector>
#include <system_error>

class ThreadAffinityManager {
public:
    // 将当前调用线程绑定到指定的一个或多个 CPU 核心
    static bool bindCurrentThread(const std::vector<int>& cpu_ids) {
        cpu_set_t cpuset;
        CPU_ZERO(&cpuset);
        
        for (int cpu_id : cpu_ids) {
            CPU_SET(cpu_id, &cpuset);
        }

        pthread_t current_thread = pthread_self();
        int rc = pthread_setaffinity_np(current_thread, sizeof(cpu_set_t), &cpuset);
        if (rc != 0) {
            std::cerr << "绑定线程到 CPU 失败, 错误码: " << rc << std::endl;
            return false;
        }
        return true;
    }

    // 检查当前线程目前实际运行在哪个物理核上
    static int getCurrentCpu() {
        return sched_getcpu();
    }
};

// 模拟端侧模型推理线程的工作循环
void* inference_worker(void* arg) {
    int target_core = *reinterpret_cast<int*>(arg);
    
    // 1. 在线程启动之初立刻执行绑核
    if (!ThreadAffinityManager::bindCurrentThread({target_core})) {
        std::cerr << "线程绑定核心 " << target_core << " 失败!" << std::endl;
    } else {
        std::cout << "推理工作线程成功锁定在 CPU Core [" << target_core << "]" << std::endl;
    }

    // 2. 模型前向推理循环(GEMV / Attention 算子)
    while (true) {
        // 执行权重计算...
        // 验证运行核心是否发生漂移
        int actual_core = ThreadAffinityManager::getCurrentCpu();
        if (actual_core != target_core) {
            std::cerr << "警告: 线程发生非预期漂移到 Core " << actual_core << std::endl;
        }
        break; // 仅做单次演示
    }
    return nullptr;
}

3. 推理引擎层面的 OpenMP 与线程池亲和性配置

如果你使用的是 llama.cpponnxruntimeMNN 等现有推理框架,除了在系统级写 C++ 代码绑核外,还可以利用 OpenMP 环境变量配合 taskset 阻断调度器漂移:

# 1. 禁用 OpenMP 线程在核心间动态迁移
export OMP_PROC_BIND=CLOSE

# 2. 确保每个 OpenMP 线程绑定到独立物理核心,跳过相邻超线程
export OMP_PLACES=cores

# 3. 指定线程数精确匹配大核数量(例如 4 个大核)
export OMP_NUM_THREADS=4

# 4. 使用 taskset 启动推理进程,限定在 CPU 4,5,6,7 上
taskset -c 4-7 ./llama-cli -m ./models/qwen2.5-1.5b-q4_k_m.gguf -p "请介绍操作系统内存分段与分页" -n 256

4. 实测性能对比数据

在搭载 4 个 Cortex-A55 (小核) + 4 个 Cortex-A78 (大核) 的 RK3588 开发板上,运行 Qwen2.5-1.5B (4-bit 量化) 模型的实测基准测试:

测试配置首字延迟 (TTFT)生成吞吐 (Tokens/s)L2 Cache Miss 率P99 抖动延迟
未优化 (默认 CFS 调度)480 ms11.2 t/s18.7%1250 ms
8 线程全开 (大小核混跑)520 ms9.8 t/s (受小核拖累)24.3%1480 ms
绑核 4 线程 (仅锁 4 大核)210 ms18.6 t/s4.2%230 ms

5. 核心架构总结与避坑指南

  1. 宁少勿滥,绝不大小核混跑:在并行矩阵计算中,线程池的完成时间取决于最慢的那颗核心。4 个大核全速运转的产出远远高于“4 大核 + 4 小核”混合调度。
  2. 排除 CPU0:在 Linux 系统中,CPU0 往往承担了大量硬件中断(Hard IRQ)和定时器处理任务。如果绑核,尽量避开 CPU0,将其留给操作系统内核底半部与外设驱动。
  3. 避免与 UI/I/O 线程同核争抢:端侧若存在摄像头拉流、网络传输等 I/O 密集型任务,应将其单独绑定至小核(如 CPU 0~2),彻底隔绝 I/O 中断对 AI 矩阵运算流水线的打断。
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐