-O0编译的朴素DGEMM比-O1更快?求性能异常原因与排查方向
朴素DGEMM在-O1下性能劣于-O0的问题分析
测试代码
朴素DGEMM实现
void dgemm_naive(const int M, const int N, const int K, const double alpha, const double *A, const int lda, const double *B, const int ldb, const double beta, double *C, const int ldc) { for (int i = 0; i < M; ++i) { for (int j = 0; j < N; ++j) { double cij = beta * C[ldc * i + j]; double abij{0.0}; for (int k = 0; k < K; ++k) { abij += A[lda * i + k] * B[ldb * k + j]; } C[ldc * i + j] = cij + alpha * abij; } } }
测试框架与计时代码
#include <chrono> #include <cstdio> #include <new> void dgemm_naive(const int M, const int N, const int K, const double alpha, const double *A, const int lda, const double *B, const int ldb, const double beta, double *C, const int ldc); template <typename T> inline T min(T x, T y) { return (((x) < (y)) ? (x) : (y)); } void run_test(const int m, const int n, const int k, double alpha, double beta, void (*dgemm)(const int, const int, const int, const double, const double *, const int, const double *, const int, const double, double *, const int), const int niter) { double *A = new (std::nothrow) double[m * k]; double *B = new (std::nothrow) double[k * n]; double *C = new (std::nothrow) double[m * n]; if (A == nullptr || B == nullptr || C == nullptr) // handle case where new returned null { // Do error handling here printf("Could not allocate memory\n"); } for (int i = 0; i < (m * k); i++) { A[i] = i + 1; } for (int i = 0; i < (k * n); i++) { B[i] = -i - 1; } for (int i = 0; i < (m * n); i++) { C[i] = 0.0; } auto t1 = std::chrono::high_resolution_clock::now(); for (int i = 0; i < niter; i++) { dgemm(m, n, k, alpha, A, k, B, n, beta, C, n); } auto t2 = std::chrono::high_resolution_clock::now(); auto ms_int = std::chrono::duration_cast<std::chrono::milliseconds>(t2 - t1); // // To make sure computations don't get optimized out. // printf("C[0, 0] = %f\n", C[0]); // printf("C[M-1, N-1] = %f\n", C[(m-1)*n+(n-1)]); delete[] A; delete[] B; delete[] C; printf("Time: %ld ms\n", static_cast<long>(ms_int.count())); } int main() { int m = 1024, k = 1024, n = 1024; double alpha = 1.0, beta = 0.0; run_test(m, n, k, alpha, beta, dgemm_naive, 1); return 0; }
测试结果
在AMD Ryzen 7 3800XT上使用clang 18.1.3或gcc 13.3.0编译,得到如下计时结果:
-O0 Time: 30120 ms -O1 Time: 54488 ms
Intel i7-8550U笔记本上也存在类似相对差异(整体速度更慢)。
已完成的分析
通过cachegrind和perf分析发现,-O1编译版本存在缓存缺失问题。查看汇编与LLVM IR可知,循环顺序未改变,仅将循环不变的索引计算移到了循环外,仅保留对C、A、B的三次读取。
待解答的问题
- 我的假设是否正确:缓存缺失一定源于内存加载顺序的改变?(已验证内存对齐无问题)
- 下一步的排查方向是什么?
问题1解答
你的假设不正确。缓存缺失的诱因不止内存加载顺序一种,即使加载顺序不变,编译器优化也可能通过以下方式影响缓存行为:
- 指令调度与内存访问时机冲突:-O1会进行指令重排,可能让CPU在等待某条内存加载完成时提前发起其他内存请求,打乱缓存行的预取模式;
- 循环展开降低缓存复用:-O1可能对循环做部分展开,导致单次循环内访问的内存地址范围变大,超出缓存行覆盖范围,降低数据复用率;
- 寄存器分配间接影响内存访问:-O0会把更多变量存在栈上,而-O1优先用寄存器保存中间值,这可能改变内存访问的时机和批量性,影响缓存命中效率。
问题2下一步排查方向
- 细粒度缓存事件统计:用
perf stat -e L1-dcache-loads,L1-dcache-load-misses,L2-dcache-load-misses,L3-dcache-load-misses,dTLB-load-misses对比-O0和-O1各层级缓存及TLB的缺失率,定位问题发生的层级; - 检查循环展开与向量化:查看编译后的汇编代码,确认-O1是否开启了循环展开或自动向量化(即使未指定-O3),这类优化会改变内存访问的步长和批量模式;
- 量化数据复用率:手动计算两种编译版本下矩阵B的列访问(代码中B为按列访问,本身效率较低)在缓存中的复用次数,验证-O1是否降低了复用率;
- 逐步关闭-O1优化项:尝试添加
-fno-inline、-fno-loop-unroll、-fno-schedule-insns等选项,逐一关闭-O1的优化特性,定位是哪项优化导致性能退化; - 监测内存带宽利用率:用工具对比两种版本的内存带宽占用情况,确认-O1是否因内存访问模式更零散导致带宽利用率下降,进而拖慢整体速度。
内容的提问来源于stack exchange,提问作者ligro
相关产品推荐
相关产品推荐

