☰
GPU矩阵乘法基准测试:GEMM与GEMV性能调优实战
2026/10/21 22:33:50 网站建设 项目流程

简介:cublas_benchmarks 是一套面向 CUDA 高性能计算开发者与深度学习底层优化人员的 C++ 基准测试代码,用于测量和对比 CuBLAS 库执行 GEMM(通用矩阵乘法)与 GEMV(矩阵向量乘法)时的性能表现。资源包共 10 个文件,以 4 个 .cu 与 4 个 .h 源文件为核心,分别实现矩阵/向量数据结构、GEMM 与 GEMV 的 CuBLAS 调用封装及计时逻辑,另含 1 个 Makefile 负责编译链接、1 个 README 说明使用方式,整体约 13KB,结构紧凑、便于直接编译运行。目前已有 1095 人学习下载。读者可借助它设置不同 M、N、K 规模,调用对应 CuBLAS 函数并同步 GPU 记录耗时,从而评估特定硬件与 CUDA 版本下的性能差异,为矩阵运算密集项目的代码优化与计算策略选择提供实测参考。

1. 从一次 kernel 调优翻车说起:这个基准项目到底测什么

有个做推理加速的同行,接手一个矩阵乘法 kernel 的优化任务,改完寄存器分块和 shared memory 布局,本地跑出来快了 18%,兴冲冲合进主线。结果上线后整体吞吐反而掉了 5%。排查了两天才发现:他测的是单次 kernel 的峰值,而真实链路里 GEMM 和 GEMV 交替出现,GEMV 是访存瓶颈,他那套为 GEMM 调优的分块策略把 L2 的驻留数据冲得七零八落,GEMV 的延迟被拉高了一大截。这就是典型的「只测一个算子、不看组合负载」的坑。

cublas_benchmarks 这个项目,干的就是把 GEMM(矩阵乘矩阵)和 GEMV(矩阵乘向量)这两类最核心的线性代数算子在 GPU 上的性能,用一套可复现的基准跑出来。它不依赖某个具体框架,纯 C++ 写测试驱动,直接调底层 BLAS 接口,把不同规模、不同数据类型、不同内存布局下的耗时和有效算力摊开给你看。适合谁?做 CUDA 算子优化、推理引擎、科学计算库的开发者,以及需要给硬件选型或给 kernel 定基线的人。你拿到它,能快速回答一个很实际的问题:我这段矩阵运算,在当前卡上到底该跑出多少 GFLOPS,我的实现离这个数还差多远。

2. 项目结构与技术选型:为什么用 C++ 直连 BLAS 而不是套框架

2.1 目录骨架与构建方式

这个项目的结构很朴素,没有花哨的抽象层。常见做法是根目录下放一个CMakeLists.txt,源码集中在src/,测试用例按算子拆成独立文件,头文件放include/。大致长这样:

cublas_benchmarks/ ├── CMakeLists.txt ├── include/ │ ├── bench_utils.h // 计时、误差校验、结果打印 │ └── blas_wrapper.h // 对 BLAS 接口的薄封装 ├── src/ │ ├── gemm_bench.cpp // GEMM 基准主逻辑 │ ├── gemv_bench.cpp // GEMV 基准主逻辑 │ └── main.cpp // 参数解析与调度 └── scripts/ └── run_all.sh // 批量跑不同规模并汇总

选 C++ 直连 BLAS,而不是用 Python 包一层,原因很直接:Python 的调用开销和 GIL 会污染小规模算子的计时结果。你要测的是 kernel 本身,不是解释器。C++ 编译成原生可执行文件,计时窗口能压到微秒级,配合 CUDA event 或std::chrono的高精度时钟,测出来的数才可信。blas_wrapper.h这层封装的作用是把不同后端(比如 cuBLAS、rocBLAS、oneMKL)的接口差异抹平,换后端时只改这一个文件,测试逻辑不动。

2.2 计时与误差校验的设计

基准项目最容易翻车的地方是计时方法。用 CPU 端时钟去卡 GPU kernel,会把异步启动的延迟算进去,测出来偏大。正确做法是用 CUDA event 在流上打点,或者跑足够多次取稳定值。项目里bench_utils.h一般会提供这样一组工具:

// bench_utils.h 片段:基于 CUDA event 的计时器 struct GpuTimer { cudaEvent_t start, stop; GpuTimer() { cudaEventCreate(&start); cudaEventCreate(&stop); } void begin() { cudaEventRecord(start); } float end() { // 返回毫秒 cudaEventRecord(stop); cudaEventSynchronize(stop); float ms = 0; cudaEventElapsedTime(&ms, start, stop); return ms; } ~GpuTimer() { cudaEventDestroy(start); cudaEventDestroy(stop); } };

cudaEventRecord把时间戳插进流里,cudaEventSynchronize等它执行完,cudaEventElapsedTime拿到的是 GPU 侧真实耗时,不受主机端调度抖动影响。参数上,start和stop是两个 event 句柄,创建一次复用即可,别在循环里反复 create/destroy,那本身就是开销。

误差校验同样关键。GEMM 结果对不对,不能只看跑没跑通。常见做法是拿一个 CPU 端的参考实现(朴素三重循环或 Eigen)算一遍,然后比最大相对误差。浮点累加顺序不同,结果不会逐位相等,所以阈值一般设1e-5到1e-3,视数据类型而定。FP16 和 TF32 的容差要放宽,否则你会被一堆「假失败」淹没。

2.3 参数化:规模、布局、数据类型怎么配

基准的价值在于覆盖足够多的组合。项目通常把这几类参数暴露出来:

参数含义典型取值
M, N, K矩阵维度128, 512, 1024, 4096
dtype数据类型FP32, FP16, TF32, INT8
trans转置标志N/N, T/N, N/T
lda/ldb/ldc主维长度通常等于对应维度
alpha/beta缩放系数1.0 / 0.0

trans这个参数特别容易被忽略。实际业务里 A 转置、B 不转置的情况非常普遍,而转置会改变内存访问模式,对性能影响巨大。只测 N/N 布局的基准,参考价值有限。alpha和beta也别固定成 1 和 0,beta=1时 C 矩阵要先读再写,访存压力翻倍,测出来的数才是真实场景下的数。

3. 核心模块拆解:GEMM 与 GEMV 基准的完整链路

3.1 GEMM 基准:从内存分配到结果校验

GEMM 的基准逻辑是一条清晰的流水线:分配设备内存、填充随机数据、预热、计时、校验、释放。下面这段是gemm_bench.cpp的核心骨架:

// gemm_bench.cpp 片段:单次 GEMM 基准 void run_gemm(int M, int N, int K, float alpha, float beta) { size_t bytesA = (size_t)M * K * sizeof(float); size_t bytesB = (size_t)K * N * sizeof(float); size_t bytesC = (size_t)M * N * sizeof(float); float *dA, *dB, *dC; cudaMalloc(&dA, bytesA); cudaMalloc(&dB, bytesB); cudaMalloc(&dC, bytesC); // 用随机数填充,避免全零导致某些优化路径被触发 fill_random(dA, M * K); fill_random(dB, K * N); fill_random(dC, M * N); cublasHandle_t handle; cublasCreate(&handle); // 预热:让 clock 稳定、cache 进入状态 for (int i = 0; i < 3; ++i) { cublasSgemm(handle, CUBLAS_OP_N, CUBLAS_OP_N, M, N, K, &alpha, dA, M, dB, K, &beta, dC, M); } cudaDeviceSynchronize(); GpuTimer timer; timer.begin(); const int iters = 20; for (int i = 0; i < iters; ++i) { cublasSgemm(handle, CUBLAS_OP_N, CUBLAS_OP_N, M, N, K, &alpha, dA, M, dB, K, &beta, dC, M); } float ms = timer.end() / iters; // 有效算力:2*M*N*K 次浮点运算 double gflops = 2.0 * M * N * K / (ms * 1e-3) / 1e9; printf("GEMM %dx%dx%d: %.3f ms, %.2f GFLOPS\n", M, N, K, ms, gflops); cublasDestroy(handle); cudaFree(dA); cudaFree(dB); cudaFree(dC); }

逐段看。内存分配用cudaMalloc,大小按维度 × sizeof(dtype)算,注意size_t强转,大矩阵下int会溢出。fill_random不能用全零,因为某些 BLAS 实现对零矩阵有短路优化,测出来的数虚高。预热循环跑 3 次,目的是让 GPU 时钟从 idle 爬到稳定频率,同时把数据带进 cache,跳过预热直接计时,第一次的结果会明显偏慢。

计时循环跑 20 次取平均,iters这个数别太小,否则单次抖动占比大;也别太大,否则散热降频会拉低均值。20 到 50 次是常见区间。算力公式2*M*N*K是 GEMM 的标准浮点运算计数,乘 2 是因为每个输出元素要做 K 次乘加。ms * 1e-3把毫秒转秒,除以1e9得到 GFLOPS。

cublasSgemm的参数顺序是:handle、转置标志 A、转置标志 B、M、N、K、alpha 指针、A 指针、lda、B 指针、ldb、beta 指针、C 指针、ldc。lda是 A 的主维长度,列主序下等于行数 M。这几个参数错一个,结果就全错,而且不一定报错,只是数不对,属于血泪经验级别的坑。

3.2 GEMV 基准:访存瓶颈下的测法差异

GEMV 和 GEMM 的测法有本质区别。GEMV 是访存密集型,算力远低于 GEMM,计时窗口更短,对启动开销更敏感。核心代码:

// gemv_bench.cpp 片段:GEMV 基准 void run_gemv(int M, int N, float alpha, float beta) { float *dA, *dx, *dy; cudaMalloc(&dA, (size_t)M * N * sizeof(float)); cudaMalloc(&dx, (size_t)N * sizeof(float)); cudaMalloc(&dy, (size_t)M * sizeof(float)); fill_random(dA, M * N); fill_random(dx, N); fill_random(dy, M); cublasHandle_t handle; cublasCreate(&handle); // GEMV 单次太快,用多次循环放大计时窗口 const int iters = 200; GpuTimer timer; timer.begin(); for (int i = 0; i < iters; ++i) { cublasSgemv(handle, CUBLAS_OP_N, M, N, &alpha, dA, M, dx, 1, &beta, dy, 1); } float ms = timer.end() / iters; // GEMV 有效带宽:读 A(M*N) + 读 x(N) + 写 y(M) double bytes = ((double)M * N + N + M) * sizeof(float); double gbps = bytes / (ms * 1e-3) / 1e9; printf("GEMV %dx%d: %.4f ms, %.2f GB/s\n", M, N, ms, gbps); }

GEMV 的iters设到 200,因为单次 GEMV 在中小规模下可能只有几微秒,计时精度不够。放大循环次数后取平均,结果才稳。指标也从 GFLOPS 换成 GB/s,因为 GEMV 的瓶颈在显存带宽,不在计算单元。带宽公式里,读 A 是M*N,读 x 是N,写 y 是M,加起来乘sizeof(float)。

cublasSgemv的参数里,incx和incy是向量步长,通常设 1。如果设成别的值,就是带跨步的访问,性能会掉,测的时候要清楚自己在测什么。lda同样是 A 的主维长度,列主序下等于 M。

3.3 结果汇总与批量调度

单次跑一个规模没意义,基准的价值在扫描。main.cpp一般会解析命令行参数,或者读一个配置,然后循环调用run_gemm和run_gemv。scripts/run_all.sh负责把不同规模、不同 dtype 的组合批量跑一遍,输出重定向到文件,方便后续画图或对比。

#!/bin/bash # run_all.sh:批量扫描规模 for size in 128 256 512 1024 2048 4096; do ./bench --op gemm --m $size --n $size --k $size --dtype fp32 ./bench --op gemv --m $size --n $size --dtype fp32 done

这个脚本简单,但实用。跑完把输出粘进表格,就能看出算力随规模的变化曲线。小规模下启动开销占比大,算力上不去;大规模下逐渐逼近硬件峰值,到某个点后因为降频又开始回落。这条曲线就是你判断自己 kernel 好坏的基准线。

4. 避坑与排查:基准测试里那些让你怀疑人生的数

4.1 测出来比理论峰值还高

现象:算出来的 GFLOPS 超过了显卡标称的 FP32 峰值。原因通常是计时窗口没覆盖完整,或者编译器把循环优化掉了,或者iters太小导致单次抖动被平均成异常值。解决:检查计时是否用 CUDA event 且同步正确,确认 kernel 真的执行了(可以故意改错一个参数看结果是否变化),把iters提到 50 以上再跑。

4.2 第一次跑特别慢,后面就正常

现象:预热那几次的耗时是稳定值的两三倍。原因是 GPU 时钟从 idle 状态爬升需要时间,同时首次访问的数据不在 cache 里。解决:预热循环至少跑 3 次,且预热用的数据和正式计时用同一块内存,别重新分配。如果还慢,检查是不是有别的进程在占卡。

4.3 结果校验总是失败,但程序不报错

现象:和 CPU 参考实现比对,误差大得离谱。原因多半是lda/ldb/ldc传错了,或者转置标志和实际内存布局对不上。列主序下,不转置的 A 矩阵lda等于行数 M,转置后等于列数 K。解决:先用一个 2x2 的小矩阵手工验算,把每个参数的含义对齐,再放大规模。这个坑我踩过不止一次,小矩阵验算是后悔药。

4.4 不同 dtype 混用导致结果全错

现象:FP16 输入但用 FP32 的接口去调,或者反过来。原因:BLAS 接口对数据类型是强绑定的,cublasSgemm只吃 FP32,cublasHgemm吃 FP16,混了不会编译报错,但结果不对。解决:把 dtype 和接口的对应关系写死在 wrapper 里,用枚举区分,别靠人记。

4.5 大规模下显存不够,程序直接崩

现象:跑到 8192 或更大规模时cudaMalloc返回错误。原因:A、B、C 三块矩阵加上中间 buffer 超了显存。解决:加错误检查,cudaMalloc后判断返回值;大规模测试时考虑分块或者只测单算子不保留中间结果。别裸调cudaMalloc不检查,崩了都不知道哪崩的。

5. 二次开发:把基准改成你自己的性能看板

拿到这个项目,最直接的改造方向是把它变成你日常的性能回归工具。我的习惯是加一个--baseline参数,把上一次的稳定结果存成 JSON,每次跑完自动比对,偏差超过 5% 就标红。这样 kernel 改动引入的性能回退,当场就能发现,不用等上线。

具体做法:在bench_utils.h里加一个结果结构体和序列化函数。

// 结果序列化:存成简单 JSON,方便比对 struct BenchResult { std::string op; int m, n, k; std::string dtype; double metric; // GFLOPS 或 GB/s }; void save_baseline(const std::vector<BenchResult>& results, const std::string& path) { std::ofstream ofs(path); ofs << "[\n"; for (size_t i = 0; i < results.size(); ++i) { const auto& r = results[i]; ofs << " {\"op\":\"" << r.op << "\",\"m\":" << r.m << ",\"n\":" << r.n << ",\"k\":" << r.k << ",\"dtype\":\"" << r.dtype << "\",\"metric\":" << r.metric << "}"; if (i + 1 < results.size()) ofs << ","; ofs << "\n"; } ofs << "]\n"; }

BenchResult把每次跑的关键信息收拢,save_baseline输出成 JSON。比对逻辑单独写一个函数,读旧 JSON,按op + m + n + k + dtype做 key 匹配,算相对偏差。偏差阈值别设太死,GPU 有热噪声,3% 到 5% 是合理区间。

另一个改造点是接入 CI。把run_all.sh挂到流水线上,每次提交跑一遍小规模扫描,结果和 baseline 比。这样性能问题在合并前就暴露了。注意 CI 机器上的卡要和开发机同型号,否则数没有可比性。

验证方法上,我一般会做两件事:一是拿一个已知性能的参考实现(比如 cuBLAS 本身)当上限,看自己的 kernel 能到它的百分之多少;二是固定规模,只改一个变量(比如 block size),看曲线是否单调,如果出现反常的尖峰或凹陷,多半是测量方法有问题,不是 kernel 真的那样。

从那以后我每次改完 kernel,都强制走一遍「小矩阵验算 → 单规模基准 → 全规模扫描 → baseline 比对」这四步,再急也不跳。性能优化这事,测不准比不测更可怕。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询