为什么 ScaLAPACK 矩阵乘法从单节点扩展到双节点后反而变慢?
结论
现有信息不足以确定性能下降的唯一原因。代码中需要先检查两个问题:大矩阵的内存分配和下标计算可能发生 int 溢出;输出公式使用的单位是 GFLOP/s。
两个 MPI 进程会组成 1 × 2 的进程网格,每个进程仍承担节点内全部核心的线程计算。性能排查应关注实际 BLAS 线程数、CPU 绑定、NUMA 分布和 MPI 通信时间。
“互连带宽为 12.5 GB/s”不足以证明通信只需要几秒。还需要测量实际使用的通信路径、传输量,以及通信是否阻塞计算。
先确认单节点基准是否有效
内存分配与下标计算可能发生整数溢出
原代码使用:
double *A = malloc(m_A * k_A * sizeof(double));
m_A 和 k_A 都是 int,所以 m_A * k_A 会先进行 int 乘法,再与 sizeof(double) 相乘。后面的 sizeof(double) 无法避免前一步溢出。
如果实际尺寸为 50000 × 50000,单进程需要保存的元素数是:
50000 × 50000 = 2,500,000,000
这个数超过了常见的 32 位有符号 int 上限 2,147,483,647。有符号整数溢出属于未定义行为,初始化中的 i + j * m_A 也存在同样的风险。
原问题只说尺寸“约为 50K”,是否触发溢出仍取决于实际参数、整数宽度和本地矩阵尺寸。如果确实使用上述尺寸和 32 位 int,应先修复这些表达式,再重新建立性能基准。
下面是 A 的分配与初始化示例,适用于尺寸已验证为正、且 size_t 足以表示所需容量的环境。B、C 也要作同样处理:
#include <stdint.h>
size_t rows_A = (size_t)m_A;
size_t cols_A = (size_t)k_A;
if (cols_A != 0 && rows_A > SIZE_MAX / cols_A) {
MPI_Abort(MPI_COMM_WORLD, EXIT_FAILURE);
}
size_t count_A = rows_A * cols_A;
if (count_A > SIZE_MAX / sizeof(double)) {
MPI_Abort(MPI_COMM_WORLD, EXIT_FAILURE);
}
double *A = malloc(count_A * sizeof(*A));
if (A == NULL && count_A != 0) {
MPI_Abort(MPI_COMM_WORLD, EXIT_FAILURE);
}
#pragma omp parallel for
for (int j = 0; j < k_A; j++) {
for (int i = 0; i < m_A; i++) {
A[(size_t)i + (size_t)j * rows_A] = 1.0;
}
}
这里修复的是应用代码中的容量和下标计算。所链接的 ScaLAPACK、BLAS 是否支持该问题规模,以及 C 声明与库的整数 ABI 是否匹配,还需要结合实际构建配置确认。
检查内存容量、描述符和计算结果
当 M = K = N = 50000 时,三个双精度矩阵的数据合计约为 60 GB,尚未计入库内部缓冲区和其他内存开销。需要确认单节点有足够的可用内存,运行期间没有交换或明显的内存压力。
每次调用 descinit_ 后都应立即检查 info。原代码连续覆盖同一个 info,也没有检查分配结果,初始化失败后可能继续运行。
当前输入全部为 1.0,且 alpha = 1.0、beta = 0.0,因此正确结果中的每个元素都应为 K。可以在计时区间之外检查各进程持有的 C,并通过 MPI 汇总最大误差。确认结果正确后再比较性能。
明确吞吐量单位
原公式为:
2.0 * 1e-9 * M * N * K / elapsed
输出单位是 GFLOP/s。若要直接输出 TFLOP/s,应使用 1e-12:
double tflops = 2.0 * (double)M * (double)N * (double)K
/ elapsed / 1e12;
如果报告的 3.8 和 1.5 已经由原输出换算得到,就没有单位问题;如果直接读取程序输出,则需要重新核对。
为什么两个节点值得单独排查
MPI 进程网格发生了变化
这里每节点只有一个 MPI task,因此节点数等于 MPI 进程数。根据代码中的网格选择逻辑:
| MPI 进程数 | BLACS 网格 |
|---|---|
| 1 | 1 × 1 |
| 2 | 1 × 2 |
| 3 | 1 × 3 |
| 4 | 2 × 2 |
从一个进程增加到两个进程后,计算开始需要跨进程交换数据;增加到四个进程时,网格又变成了二维布局。各配置的通信模式和本地计算形状不同,扩展曲线因此不一定平滑。
1 × 2 网格本身不能解释性能为什么恰好从 3.8 降到 1.5 TFLOP/s,但它值得对照测试。可以在双进程时比较 1 × 2 和 2 × 1;增加每节点的 MPI 进程数后,再比较接近方形的网格。
峰值带宽无法直接换算成 pdgemm 的通信耗时
将一个矩阵的大小除以链路带宽,只能得到一次理想化传输的时间。实际 pdgemm_ 涉及分块数据交换、集合通信,以及同步和本地计算之间的依赖,不能直接等同于一次完整矩阵传输。
需要确认:
- MPI 实际使用了哪种传输通道,是否走预期的高速互连。
- 消息规模接近实际情况时,跨节点通信能达到多大带宽。
pdgemm_执行期间,多少时间用于 MPI,多少时间用于本地 BLAS。- 是否有进程计算较慢,导致其他进程等待。
原程序末尾的 MPI_Barrier 会把进程间的等待计入总时间。这适合衡量整次分布式运算的完成时间,但无法直接区分通信开销和负载不均衡。
检查线程数、绑定和 NUMA
OMP_PLACES=cores 指定 OpenMP 线程可使用的位置,但不能单独确定实际线程数。Slurm 为 task 分配 128 个 CPU,也不能证明所链接的 BLAS 确实启动了 128 个计算线程。
先记录实际使用的 BLAS 实现、线程后端和线程配置。应用初始化循环的 OpenMP 线程与 BLAS 计算线程可能采用不同设置。
对于使用 OpenMP 线程后端的配置,可以先显式设置并验证:
export OMP_NUM_THREADS=128
export OMP_PLACES=cores
export OMP_PROC_BIND=spread
这是一组对照配置,是否合适需要实测。如果库使用自己的线程控制变量,应按该库的配置方式设置;例如实际链接 MKL 时,需要同时核对 MKL_NUM_THREADS。
还要检查 srun --cpu-bind=cores 给每个 task 分配的实际 CPU 集合。一个 task 使用 128 个核心时,如果节点包含多个 NUMA 域,线程与内存页的位置也可能影响性能。原代码并行初始化数组,有助于按首次写入位置分配内存页,但仍需确认初始化线程与计算线程的绑定是否一致。
可比较两种布局:
- 每节点 1 个 MPI 进程,使用节点内全部核心。
- 每节点多个 MPI 进程,每个进程使用更少线程,并结合实际 NUMA 拓扑绑定。
比较时,保持每节点使用的物理核心总数一致,并检查是否存在多个进程的线程争用同一组核心。增加 MPI 进程数可能改善局部性,也可能增加通信,哪一种更快需要实测。
建议的定位顺序
- 修复并验证程序:检查整数溢出、内存分配、描述符返回值和结果误差,记录准确的矩阵尺寸与性能单位。
- 固定运行环境:记录编译选项、MPI、ScaLAPACK、BLAS、线程数和 CPU 绑定,确保单节点与双节点使用相同配置。
- 测量本地计算:在相同线程和绑定条件下测试本地 DGEMM,尽量覆盖分布式计算中可能出现的矩阵形状,而不只测一个大型方阵。
- 测量跨节点通信:验证 MPI 通道和实际带宽,再用 MPI 性能分析工具观察
pdgemm_中的通信与等待时间。 - 比较进程布局与网格:测试每节点不同 MPI 进程数、对应线程数,以及可用的 BLACS 网格。
- 最后调整 block size:已尝试多个 block size 且现象一致,继续只改这个参数,暂时难以缩小原因范围。
各配置应进行预热和多次测量,报告中位数与波动范围。若本地 BLAS 性能稳定、跨节点后 MPI 等待明显增加,应继续检查通信路径与网格;若本地计算本身就慢,则优先排查线程配置、绑定和 NUMA。现有代码尚不足以把性能下降归因于某一个因素。
备注:内容仅供参考。
