You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

OpenMP嵌套线程下多插槽SpGEMM性能不达预期问题排查

解决双插槽NUMA系统上分区SpGEMM的嵌套并行性能问题

我之前在双插槽Skylake系统上做过类似的NUMA优化工作,你的场景里性能远低于预期的问题,大概率是嵌套并行的线程亲和性、内存本地性、线程调度开销这几个点没处理好,下面是具体的排查方向和解决思路:

1. 内层线程未正确绑定到本地插槽核心

你设置了OMP_PROC_BIND=spread,close,外层2线程会被spread到两个socket,但内层的24线程是否真的限制在当前socket的核心上?GCC的OpenMP默认可能不会严格继承外层的亲和性范围,导致内层线程跨socket调度,直接拉低性能。

解决办法:

在SpGEMM函数的并行循环里显式指定线程数和绑定策略,确保内层线程只在当前socket的24核运行:

void SpGEMM(Matrix A, Matrix B, Matrix C, int num_threads) {
    // 其他预处理逻辑...
    #pragma omp parallel for num_threads(num_threads) proc_bind(close) schedule(static)
    for (int k = 0; k < A.ncols; ++k) {
        // 基于外积的SpGEMM计算逻辑
    }
}

proc_bind(close)会让内层线程紧密绑定到当前外层线程所在的NUMA节点核心,避免跨socket调度。

2. 共享矩阵B的跨NUMA访问瓶颈

你的方案里B是全局共享的,单插槽时B在本地内存所以快,但双插槽分区后,其中一个socket的线程访问B会走远端NUMA链路,延迟是本地的3-5倍,这是性能暴跌的核心原因之一。

解决办法:

给每个socket复制一份B的本地副本,让每个分区的SpGEMM只访问本地内存里的B:

// 引入NUMA库头文件
#include <numa.h>

// 在每个section里分配本地B副本
#pragma omp section {
    // 获取当前线程所在的NUMA节点
    int local_node = numa_node_of_cpu(sched_getcpu());
    // 分配本地内存存储B副本
    Matrix B_local;
    B_local.nrows = B.nrows;
    B_local.ncols = B.ncols;
    B_local.data = numa_alloc_onnode(sizeof(double)*B.nrows*B.ncols, local_node);
    // 复制数据到本地内存
    memcpy(B_local.data, B.data, sizeof(double)*B.nrows*B.ncols);
    
    // 调用SpGEMM时使用本地B副本
    SpGEMM(A_upper, B_local, C_upper, 24);
    
    // 释放本地内存
    numa_free(B_local.data, sizeof(double)*B.nrows*B.ncols);
}

这样两个socket的线程都只访问自己本地的B副本,彻底消除跨NUMA访问的开销。

3. 循环内嵌套并行的线程创建开销过大

你把omp parallel sections放在迭代循环内部,每次迭代都要创建/销毁外层2线程和内层48线程,频繁的线程调度开销会严重吞噬计算时间,这也是性能只有400 MFLOPS的关键因素。

解决办法:

把并行区域移到迭代循环外面,让线程只初始化一次,循环在每个section内部执行:

omp_set_nested(1);
omp_set_dynamic(0);
omp_set_num_threads(2);

double total_ave = 0;

#pragma omp parallel sections reduction(+:total_ave)
{
    #pragma omp section {
        int local_node = numa_node_of_cpu(sched_getcpu());
        Matrix B_local = {B.nrows, B.ncols, numa_alloc_onnode(sizeof(double)*B.nrows*B.ncols, local_node)};
        memcpy(B_local.data, B.data, sizeof(double)*B.nrows*B.ncols);
        
        double ave_msec = 0;
        for (int i = 0; i < ITERS; ++i) {
            double start = omp_get_wtime();
            SpGEMM(A_upper, B_local, C_upper, 24);
            double end = omp_get_wtime();
            ave_msec += (end - start) * 1000 / ITERS;
        }
        total_ave += ave_msec;
        numa_free(B_local.data, sizeof(double)*B.nrows*B.ncols);
    }
    #pragma omp section {
        int local_node = numa_node_of_cpu(sched_getcpu());
        Matrix B_local = {B.nrows, B.ncols, numa_alloc_onnode(sizeof(double)*B.nrows*B.ncols, local_node)};
        memcpy(B_local.data, B.data, sizeof(double)*B.nrows*B.ncols);
        
        double ave_msec = 0;
        for (int i = 0; i < ITERS; ++i) {
            double start = omp_get_wtime();
            SpGEMM(A_lower, B_local, C_lower, 24);
            double end = omp_get_wtime();
            ave_msec += (end - start) * 1000 / ITERS;
        }
        total_ave += ave_msec;
        numa_free(B_local.data, sizeof(double)*B.nrows*B.ncols);
    }
}

printf("Total average MFLOPS: %.2f\n", (TOTAL_FLOPS * 2) / (total_ave / 1000));

这样线程只创建一次,避免了每次循环的调度开销。

4. 确认数据分区的内存本地性

确保A_upper、C_upper是分配在socket 0的本地内存,A_lower、C_lower分配在socket 1的本地内存。如果这些数据是在全局内存分配的,可能被NUMA调度器放到远端节点,导致本地线程访问远端数据。

解决办法:

用numa_alloc_onnode显式指定数据分配的NUMA节点,比如在主线程里提前分配:

// 分配A_upper到socket 0
A_upper.data = numa_alloc_onnode(sizeof(double)*A_upper.nrows*A_upper.ncols, 0);
// 分配A_lower到socket 1
A_lower.data = numa_alloc_onnode(sizeof(double)*A_lower.nrows*A_lower.ncols, 1);
// 同理分配C_upper和C_lower到对应节点

5. 调整环境变量的亲和性设置

可以修改OMP_PLACES为更明确的核心范围,确保外层线程精准绑定到两个socket:

export OMP_PLACES="{0}:24,{1}:24"  # 明确指定socket 0的24核和socket 1的24核
export OMP_PROC_BIND=close,close   # 外层close到socket,内层close到当前socket的核心

按照上面的步骤调整后,应该能把性能提升到接近1400 MFLOPS的预期值。

内容的提问来源于stack exchange,提问作者Ernie

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.14 09:17:53