今天咱们聊一个老生常谈但又永不过时的话题——矩阵乘法。你可能会说:“矩阵乘法?大学线性代数就学过,不就是三个 for 循环嘛?” 没错,最基础的实现确实是这样,简单直接。但在追求极致性能的计算世界里,这三重循环背后可大有文章。不同的实现方式,性能表现可能有天壤之别,差距甚至能达到成百上千倍!

这听起来是不是有点刺激?就像 F1 赛车和老年代步车的速度差一样。为什么会有这么大的差距?现代 CPU 和 GPU 架构、编译器优化、并行计算技术、专用数学库……这些都是影响性能的关键因素。

为了直观地感受这种差异,我最近在我的新装备——一台搭载 AMD Ryzen AI 9 365 处理器(集成 Radeon 880M 显卡)的联想 ThinkBook 16 G7+ 笔记本上,进行了一场矩阵乘法(方阵相乘 C = A * B)的基准测试。我们请来了多位选手,涵盖了从最朴素的实现到利用 CPU 多核、SIMD 指令集,再到调用专业数学库,甚至动用 GPU 加速(OpenCL, Vulkan Compute, ROCm/HIP)的各种方法。

这篇博客,就带大家一起回顾这次基准测试的全过程:从环境介绍,到各位选手的技术特点剖析,再到最终的成绩分析和经验总结。希望能给大家带来一些启发,也满足一下对高性能计算的好奇心。

准备好了吗?系好安全带,我们发车!

软硬件环境

工欲善其事,必先利其器。在开始性能测试之前,了解这次测试所用的硬件和软件环境,有助于我们更好地理解后续的性能数据。

我的核心硬件配置包括一颗 AMD Ryzen AI 9 365 处理器。这是一款比较新的 CPU,拥有 10 个物理核心并支持 20 个线程,基础频率为 2.0 GHz。它具备重要的 AVX、AVX2、FMA 以及 AVX-512 指令集支持(包括 AVX512F, DQ, CD, BW, VL 等多种变体)。虽然它也集成了 NPU(神经网络处理单元),但这次测试我们主要关注其 CPU 和 GPU 的通用计算能力。内存方面,配备了 27.2 GiB(约 32GB 系统显示可用容量)的 DDR5 RAM,内存的大小和速度对于处理大规模矩阵运算的性能至关重要。集成的显卡是 AMD Radeon Graphics (Radeon 880M)。根据 rocminfovulkaninfo 提供的信息,其 GPU 型号标识为 gfx1150(有时也显示为 11.5.0 ),拥有 12 个计算单元 (CU),每个 CU 内含 2 个 SIMD 单元,最大时钟频率可达 2900MHz,并且支持 FP16 和 FP64(双精度)计算。这块集成 GPU 同时支持 Vulkan、OpenCL 以及 AMD 的 ROCm/HIP 平台,为我们的测试提供了多种 GPU 加速的可能性。需要特别指出的是,在运行测试时,我设置了 HSA_OVERRIDE_GFX_VERSION=11.5.1 这个环境变量,这可能会对 HIP 或 hipBLAS 的目标代码生成或运行时行为产生轻微影响,这种做法是因为 rocblasgfx1150的支持还没有实装。

在软件环境方面,我使用的是 Arch Linux 操作系统,这是一个滚动更新的发行版,能让我保持较新的软件包状态。具体的内核版本是 6.14.2-2-cachyos (64-bit),其中 CachyOS 是 Arch 的一个衍生版本,通常会包含一些旨在提升性能的补丁。桌面环境是 KDE Plasma 6.3.4,运行在 Wayland 图形平台上。编译器方面,我主要使用 GCC (g++),其版本会随着 Arch Linux 的更新而变化,但可以确定的是它支持 C++17/20 标准以及 OpenMP 和 AVX/AVX-512 指令。对于 HIP 代码的编译,则依赖 ROCm 工具链中的 hipcc,其底层是 Clang。项目的构建工作由 CMake (版本 3.20 或更高) 负责管理。

核心的库和驱动程序是这次测试的关键组成部分。ROCm 平台需要能够支持 gfx1150gfx1151 这个 GPU 型号,根据测试日志中的 rocminfo 信息,运行时版本为 1.1,扩展版本为 1.6。OpenCL 环境则稍微复杂一些,系统上同时存在两个平台:一个是 AMD APP SDK(提供 OpenCL 2.1,驱动版本 3635.0),另一个是 Mesa rusticl(提供 OpenCL 3.0)。不过,测试时我们选用的是 AMD 官方驱动平台下的 GPU 设备进行测试,设备名为 gfx1151。对于 Vulkan,实例版本为 1.4.309,使用的驱动是 RADV (来自 Mesa 25.0.4),它将设备识别为 。我们使用了 glslc 工具将 GLSL 计算着色器编译成 SPIR-V 格式。系统中还安装 BLAS (Basic Linear Algebra Subprograms) ,它是 Linux 发行版中常见的高性能选择,CMake 的 find_package(BLAS) 能够成功定位到它。同时,开源的 OpenCL BLAS 库 CLBlast 也已安装并能被 CMake 找到。此外,我们还测试了流行的 C++ 模板库 Eigen3 (版本 3.3 以上,以头文件形式提供) 和计算机视觉库 OpenCV (版本 4.x,其核心 core 模块被 CMake 正确找到)。

最后,整个基准测试的框架是 Google Benchmark (v1.9.2)。这是一个在业界广泛使用的 C++ 基准测试库,它提供了便捷的测试环境管理 ( Fixture)、精确的时间测量、自动化的迭代次数调整以及标准化的结果输出功能,确保了我们测试的规范性和可靠性。

为了尽可能地榨取硬件性能,我们在编译过程中采用了一些比较激进的选项。对于 C++ 代码,我们使用 GCC (g++) 编译器,并开启了 -Ofast 优化级别,同时加上 -march=native 参数,让编译器能够根据我本机 CPU 的具体特性(包括其支持的 AVX-512 指令集)来生成最优化的机器码。此外,我们还显式地添加了 -mavx2 -mfma -mavx512f -mavx512dq 标志,以确保代码能够利用这些 SIMD 指令。对于 HIP 代码,我们同样使用了 hipcc(底层为 Clang)的 -Ofast 优化选项。并且,通过 CMake 将 CMAKE_HIP_ARCHITECTURES 设置为 gfx1150(依据 rocminfo 的检测结果),指导编译器为目标 GPU 架构生成代码。OpenCL Kernel 的优化则有所不同,它不是在编译主机代码时指定,而是在运行时调用 clBuildProgram 函数时,通过选项参数传入。一个常用的优化标志是 -cl-fast-relaxed-math,它允许 OpenCL 编译器进行一些可能会稍微影响浮点计算精度但能显著提升运行速度的数学优化。最后,对于 Vulkan 的计算着色器,我们在使用 glslc 工具将其编译成 SPIR-V 格式时,也加上了 -O 选项,以启用编译时优化。

有了这个背景,我们接下来就请出各位选手,看看它们各自有何看家本领。

各路矩阵乘法实现详解

下面,我们将逐一介绍参与本次性能比拼的矩阵乘法实现方法。

Naive (朴素实现)

这位选手是我们最熟悉的,也是一切优化的起点。它严格按照矩阵乘法的定义 C[i][j] = Σ(A[i][k] * B[k][j]) 来实现,使用三层嵌套循环:

// 伪代码示意
for i = 0 to N-1:
  for j = 0 to N-1:
    sum = 0;
    for k = 0 to N-1:
      sum += A[i][k] * B[k][j]; // 或者 A[i*N + k] * B[k*N + j] for row-major 1D array
    C[i][j] = sum; // 或 C[i*N + j] = sum

这种朴素实现的优点在于实现非常简单,逻辑也相当清晰,容易让人理解。然而,它的缺点是性能表现极差。这主要是由几个原因造成的。首先,它的缓存不友好 ( Cache Unfriendly)。在计算过程中,对于 B 矩阵的访问模式是按列进行的(具体来说,在最内层的 k 循环里,j 保持不变,k 递增,访问的是 B[k*N + j]),但数据在内存中是按行(Row-Major)存储的。这种访问模式与存储模式的不匹配导致 CPU 缓存行频繁失效,需要不断地从主内存重新加载数据,极大地降低了内存访问效率。相比之下,对 A 矩阵是按行访问,对 C 矩阵是按元素写入,缓存效率相对较好,但 B 矩阵糟糕的访问模式成为了性能瓶颈。其次,这种实现完全是串行执行的,没有利用现代 CPU 宝贵的多核并行处理能力。最后,它也没有利用 CPU 的 SIMD (Single Instruction, Multiple Data) 部件进行向量化计算,每次运算只处理一个元素的乘加,效率低下。

这主要是用来做性能基线的,看看其他优化方法能带来多大的提升。

OpenMP (CPU 多核并行)

OpenMP 是一种基于共享内存的并行编程模型,主要通过编译器指令(Pragma)来指导编译器自动生成并行代码。对于矩阵乘法这样的循环密集型任务,它可以轻松地将外层循环(通常是 i 循环)分配给不同的 CPU 核心来执行。

实现上,仅仅是在 Naive 版本的外层循环前加上一句 #pragma omp parallel for

#pragma omp parallel for default(none) shared(A, B, C, N) schedule(static)
for (size_t i = 0; i < N; ++i) {
  // 内层的 j 和 k 循环保持不变
  for (size_t j = 0; j < N; ++j) {
    ValueType sum = 0.0;
    for (size_t k = 0; k < N; ++k) {
      sum += A[i * N + k] * B[k * N + j];
    }
    C[i * N + j] = sum;
  }
}

让我们来解析一下这行 OpenMP 指令中的关键部分。parallel for 是核心指令,它告诉编译器将紧随其后的 for 循环并行化处理。 default(none) 是一个推荐使用的好习惯,它强制程序员明确指定循环内每个变量的作用域,是共享 (shared) 还是线程私有 ( private),以避免潜在的错误。shared(A, B, C, N) 则声明了矩阵 A、B、C 以及大小 N 这些变量在所有并行执行的线程之间是共享的;其中 A 和 B 在计算中是只读的,而 C 虽然会被写入,但由于 OpenMP 默认按行分配任务,不同线程通常写入 C 的不同行,因此一般不会产生写入冲突。最后, schedule(static) 定义了工作分配策略,它静态地将整个循环的迭代空间(这里是 N 次 i 的迭代)预先划分成大致相等的部分,并将这些部分分配给各个线程。对于像矩阵乘法这样每次迭代计算量都差不多的负载均衡型循环,静态调度通常具有较小的运行时开销。

使用 OpenMP 的主要优点在于其实现非常简单,往往只需要在关键循环前添加一行编译器指令(Pragma)就能方便地利用 CPU 的多核资源。相较于完全串行的 Naive 实现,性能通常会有显著提升,理想情况下接近 CPU 的核心数量倍数,尽管实际提升会受到内存带宽、缓存效率等因素的制约。然而,它也有缺点。首先,它并没有解决 Naive 版本中存在的缓存不友好问题,尤其是对 B 矩阵的列式访问模式依然存在,这会限制性能的进一步提升。其次,其性能提升的上限受限于 CPU 的物理核心数以及系统的内存带宽。此外,对于非常小的矩阵规模 N,引入并行计算所带来的额外开销(例如线程的创建、管理和同步等)甚至可能会超过并行执行本身节省的时间,导致性能不升反降。

CPU SIMD (AVX2/AVX-512 + FMA)

SIMD(单指令多数据)是现代 CPU 的重要特性。它允许一条指令同时对多个数据执行相同的操作。比如,AVX2 可以同时处理 4 个 double(256位寄存器),而 AVX-512 可以同时处理 8 个 double(512位寄存器)。FMA (Fused Multiply-Add) 指令则可以将乘法和加法合并为一条指令,进一步提高效率并可能提高精度。

要利用 SIMD,我们通常需要使用编译器特定的内建函数 (Intrinsics)。这使得代码比 Naive 或 OpenMP 版本复杂得多。

AVX2 + FMA (256-bit)

为了利用 AVX2 和 FMA 指令集,我们引入了 immintrin.h 头文件提供的内建函数 (intrinsics)。一个关键的优化思路是调整循环的嵌套顺序,采用 i-k-j 的顺序执行。这种顺序的巧妙之处在于,它使得在最内层的 j 循环中可以高效地进行向量化操作。具体来说,对于固定的 ik,我们可以先把 A[i][k] 这个标量值通过 _mm256_set1_pd() 指令广播 (broadcast) 到一个 256 位向量 a_vec 的所有 4 个 double 元素中。接着,我们从 B 矩阵的第 k 行(内存地址是 &B[k*N + j])加载连续的 4 个 double 数据到向量 b_vec 中。由于 B 矩阵是按行存储的,这种连续加载通常是缓存友好的。我们选用了 _mm256_loadu_pd(),它允许加载非对齐的内存地址,提供了更好的灵活性。同时,我们也从 C 矩阵的第 i 行(内存地址 &C[i*N + j])加载对应的 4 个 double 累加值到 c_vec 中,同样使用 _mm256_loadu_pd() 。核心的计算步骤是执行 FMA(融合乘加)操作,即 c_vec = a_vec * b_vec + c_vec,对应的 intrinsic 是 _mm256_fmadd_pd() 。这一条指令就能同时完成 4 对元素的乘法和加法。最后,我们将计算得到的更新后的 c_vec 通过 _mm256_storeu_pd() 指令写回到 C 矩阵的相应位置。当然,在实现最内层的 j 循环时,我们需要以 4(即 AVX2_DOUBLE_COUNT)为步长进行迭代,并且还需要特别处理循环末尾可能剩余的、不足 4 个的元素,这部分通常会回退到普通的标量计算来完成。

// 伪代码示意 (AVX2 + FMA)
constexpr size_t AVX2_DOUBLE_COUNT = 4;
for (size_t i = 0; i < N; ++i) {
  for (size_t k = 0; k < N; ++k) {
    __m256d a_vec = _mm256_set1_pd(A[i*N + k]); // Broadcast A[i][k]
    for (size_t j = 0; j < N_aligned; j += AVX2_DOUBLE_COUNT) { // Aligned part
      __m256d b_vec = _mm256_loadu_pd(&B[k*N + j]);  // Load 4 doubles from B row k
      __m256d c_vec = _mm256_loadu_pd(&C[i*N + j]);  // Load 4 doubles from C row i
      c_vec = _mm256_fmadd_pd(a_vec, b_vec, c_vec); // Fused Multiply-Add
      _mm256_storeu_pd(&C[i*N + j], c_vec); // Store back to C
    }
    // Handle remaining elements j = N_aligned to N-1 using scalar operations
  }
}

AVX-512 + FMA (512-bit)

AVX-512 + FMA 的实现原理与 AVX2 版本完全相同,主要区别在于它使用了宽度为 512 位的寄存器以及与之配套的内建函数,例如 __m512d 类型、_mm512_set1_pd_mm512_loadu_pd_mm512_fmadd_pd_mm512_storeu_pd。由于寄存器更宽,向量计算的步长也相应地增加到了 8 (AVX512_DOUBLE_COUNT),意味着单条指令可以处理 8 个 double 类型的数据。要成功编译和运行 AVX-512 代码,需要确保 CPU 本身支持该指令集(我们的 Ryzen AI 9 365 处理器满足此条件),并且在编译时通过相应的选项(例如 -mavx512f)告知编译器启用这些指令。

这种基于 SIMD 的优化方法有其显著的优点。首先,它能大幅提升单核 CPU 的计算性能。其次,采用 i-k-j 的循环顺序改善了对 B 矩阵的内存访问模式,使其更加缓存友好。核心优势在于充分利用了 CPU 内部强大的向量处理单元。然而,这种方法也存在明显的缺点。编写和维护 SIMD intrinsics 代码的复杂度相当高,并且可移植性很差,因为它直接依赖于目标 CPU 是否支持特定的指令集。开发者还需要手动处理内存对齐问题(虽然 loadu/storeu 提供了非对齐支持,但对齐加载/存储通常更快)以及循环末尾的边界情况。此外,历史上 AVX-512 指令的执行有时会触发 CPU 降低工作频率以控制功耗和散热,尽管在现代 CPU 上这个问题已经得到了很大程度的缓解,但仍是一个潜在的考虑因素。

SIMD + OpenMP (AVX2/AVX-512 + FMA + OpenMP)

既然 OpenMP 能并行化外层循环,SIMD 能加速内层计算,那把它们结合起来岂不是强强联合?确实如此。

实现方法就是在 SIMD (AVX2 或 AVX-512) 版本的 i-k-j 循环代码的外层 i 循环前,加上 OpenMP 的并行指令:

#pragma omp parallel for default(none) shared(A, B, C, N, N_aligned) schedule(static)
for (size_t i = 0; i < N; ++i) {
  // 内层的 k 和 j (SIMD) 循环保持不变
  for (size_t k = 0; k < N; ++k) {
    // ... SIMD intrinsics code as before ...
  }
}

将 SIMD 指令(无论是 AVX2 还是 AVX-512)与 OpenMP 多线程结合起来的主要优点在于,它能够同时利用 CPU 的多核并行能力和指令级并行(向量化)能力,双管齐下,通常能够逼近甚至达到 CPU 在该任务上的理论最高性能。然而,这种方法的缺点也很明显。首先,它使得代码的复杂度进一步叠加,既有 SIMD intrinsics 的复杂性,也引入了 OpenMP 并行的管理。其次,当计算速度被推向极致时,程序的性能瓶颈很可能从计算本身转移到受限于内存带宽,即 CPU 核心处理数据的速度超过了内存供应数据的速度。最后,为了获得最佳性能,往往还需要仔细调优 OpenMP 的相关参数,例如线程的调度策略 (schedule 子句的选择,如 static, dynamic, guided 等)以及可能的线程绑定、负载均衡等高级线程管理技术。

BLAS (Basic Linear Algebra Subprograms)

BLAS 不是一个具体的库,而是一套标准的 API 规范,定义了基本的向量和矩阵运算接口。许多组织和公司都提供了 BLAS 的实现。这些库通常包含了针对特定硬件(CPU架构、缓存大小、SIMD 指令)高度优化的 C 或 Fortran 代码,甚至是汇编代码。它们内部往往已经实现了复杂的分块 (Blocking/Tiling) 技术来最大化缓存利用率,并且自动使用了 SIMD 和多线程。

我们只需要调用标准的 C 接口 cblas_dgemm (d 表示 double,gemm 表示通用矩阵乘法):

// 伪代码示意
cblas_dgemm(
    CblasRowMajor, // 告诉 BLAS 我们的数据是按行存储的
    CblasNoTrans, CblasNoTrans, // A 和 B 都不需要转置
    N, N, N,        // M, N, K (对于 N x N 矩阵)
    1.0,            // alpha (C = alpha*A*B + beta*C)
    A.data(), N,    // 指向 A 数据及其列数 (leading dimension for RowMajor)
    B.data(), N,    // 指向 B 数据及其列数
    0.0,            // beta (设为 0 表示结果覆盖 C,即 C = A*B)
    C.data(), N     // 指向 C 数据及其列数
);

使用 BLAS 库进行矩阵乘法具有多方面的优点。最突出的一点是使用非常简单,开发者通常只需要调用一个高度优化的库函数(如 cblas_dgemm)即可完成复杂的计算任务,极大地简化了编程工作。其次,由于这些库内部集成了针对特定硬件的大量优化,其性能通常非常好,往往能够接近硬件所能达到的理论计算峰值。再者,BLAS 作为一套标准接口,具有良好的可移植性,只要目标计算平台上存在任何一个符合标准的 BLAS 库实现,代码通常就可以无需修改地运行。同时,调用库函数也使得主程序的代码非常简洁。当然,采用 BLAS 也有其缺点。首先,应用程序在编译链接时需要链接到相应的 BLAS 库文件。其次,也是最关键的一点,最终的性能表现高度依赖于所链接的具体 BLAS 实现库的质量。不同的 BLAS 实现(如 OpenBLAS, Intel MKL, ATLAS 等)在同一硬件上的性能可能会有显著差异。

Eigen & OpenCV

除了 BLAS 这种底层接口,还有很多高级的 C++ 库也提供了矩阵运算功能。我们测试了两个流行的代表:Eigen 和 OpenCV。

Eigen

我们再来看看 Eigen 这个库。它的特点在于,Eigen 是一个 C++ 模板库,以其接口设计的优雅性和强大的“表达式模板”(Expression Templates) 技术而闻名。这种技术允许 Eigen 在编译期间分析和优化复杂的线性代数表达式链,从而避免生成不必要的中间临时对象,并且在很多情况下能够自动地为底层的计算生成 SIMD 指令。在使用上,Eigen 的代码也写起来非常简洁。我们可以先通过 Eigen::Map 将存储在 std::vector 中的原始数据“映射”成 Eigen 库内部的矩阵对象,这个映射过程本身是零内存拷贝开销的。然后,就可以直接使用重载的 * 运算符来执行矩阵乘法了,就像下面这样:

// 伪代码示意 (Map existing data)
Eigen::Map<const EigenMatrixType> A_map(A.data(), N, N);
Eigen::Map<const EigenMatrixType> B_map(B.data(), N, N);
EigenMatrixType C_eigen(N, N); // Eigen's result matrix

matrix_multiply_eigen(A_map, B_map, C_eigen); // C_eigen.noalias() = A_map * B_map;

值得注意的是,代码中使用了 noalias() 方法,这是向 Eigen 明确提示输出矩阵 C 不会与输入矩阵 A 或 B 发生内存重叠(aliasing),使得 Eigen 可以采用更高效、更激进的内部实现来进行优化。

总的来说,Eigen 的优点是其 API 设计非常现代化,易于使用,代码可读性很高。同时,它借助 C++ 模板元编程在编译时进行优化的能力也很强大。不过,它也有缺点。在性能方面,它可能不如那些经过深度手工优化的专用 BLAS 库(最终性能表现很大程度上取决于编译器的优化能力以及具体表达式的复杂程度)。另外,由于大量使用了模板,编译时间可能会相对较长。

OpenCV

接下来是 OpenCV。它的主要特点是作为一个面向计算机视觉处理的综合性库,但其核心模块 (core) 也提供了非常强大的矩阵操作功能,以 cv::Mat 类为核心。cv::Mat 不仅可以自己管理内存,也能够方便地“包装”已经存在的外部数据,避免不必要的拷贝。一个重要的优势是,OpenCV 在执行计算密集型操作(如矩阵乘法)时,通常会尝试利用底层可用的优化机制来加速运算,这可能包括 Intel IPP(Integrated Performance Primitives)、OpenMP 多线程,甚至可能调用系统上安装的 BLAS 库。在使用时,我们可以将 std::vector 中的数据零拷贝地包装成 cv::Mat 对象,指定行数、列数、数据类型(CV_64F 代表 double)和数据指针。然后,我们调用 OpenCV 提供的 cv::gemm 函数来执行矩阵乘法,这个函数的接口与 BLAS 的 gemm 非常相似:

// 伪代码示意
cv::Mat A_cv(N, N, CV_64F, A.data()); // Wrap existing data
cv::Mat B_cv(N, N, CV_64F, B.data());
cv::Mat C_cv(N, N, CV_64F);          // OpenCV result matrix

matrix_multiply_opencv(A_cv, B_cv, C_cv); // cv::gemm(A_cv, B_cv, 1.0, cv::Mat(), 0.0, C_cv);

OpenCV 的优点在于其功能极其丰富,远不止矩阵乘法,涵盖了图像处理和计算机视觉的方方面面。如果你的项目本身就在使用 OpenCV,那么利用它来进行矩阵运算可以实现与其他功能的无缝集成。同时,它可能利用后台存在的多种优化库来提升性能。不过,它的缺点也比较明显,主要是引入了一个相对庞大和复杂的库依赖。如果你的任务仅仅是进行纯粹的线性代数计算,那么引入整个 OpenCV 库可能不是最轻量级的选择。

OpenCL

现在我们转向 OpenCL (Open Computing Language),这是一个旨在实现跨平台、跨设备并行计算的开放标准框架,它允许程序利用包括 CPU、GPU、DSP 乃至于 FPGA 在内的多种计算资源。

使用 OpenCL 进行计算的工作流程通常比较繁琐,涉及多个步骤。首先需要查询可用的 OpenCL 平台(例如 AMD APP SDK)并从中选择一个计算设备(比如我们测试中用到的 gfx1151 GPU)。接着,要创建一个上下文 (Context) ,它像一个容器,用于管理所选设备以及相关的内存对象和命令队列等资源。然后,需要为该设备创建一个命令队列 (Command Queue) ,后续所有的操作指令,如内存拷贝和内核执行,都将通过这个队列提交给设备。核心数据(矩阵 A, B, C)需要存储在设备端的内存缓冲区 ( Buffers) 中,这通过创建 cl_mem 对象来实现;同时,需要将主机(CPU)内存中的输入数据 A 和 B 拷贝到对应的设备缓冲区。计算任务本身由 OpenCL 内核 (Kernel) 定义,内核代码通常写在单独的 .cl 文件(比如我们的 matrix_mult.cl)中;我们需要加载这个源码文件,调用 clBuildProgram 进行编译(此时可以传入 -cl-fast-relaxed-math 等优化选项)并构建成一个 OpenCL 程序 (Program) 对象 ( cl_program),再从这个程序对象中获取到我们要执行的内核函数对象 (cl_kernel)。在执行内核之前,必须通过 clSetKernelArg 将设备缓冲区对象(指向 A, B, C 的 cl_mem)以及矩阵大小 N 等参数传递给内核函数。执行内核时,通过 clEnqueueNDRangeKernel 将任务提交到命令队列,需要指定全局工作项的数量(通常是 N*N,即每个工作项负责计算结果矩阵 C 的一个元素)以及可选的局部工作项大小(即 Workgroup size,例如 16x16,这影响着资源使用和性能)。内核执行完毕后,需要通过 clEnqueueReadBuffer 将设备上 C 缓冲区中的计算结果拷贝回主机内存。最后,也是非常重要的一步,是释放所有创建的 OpenCL 资源,包括内核、程序、缓冲区、命令队列和上下文,以避免内存泄漏。

至于 OpenCL 内核代码 (matrix_mult.cl),它是用 OpenCL C 语言编写的,这是一种基于 C99 标准并加入了一些并行计算扩展的语言。在我们的矩阵乘法内核中,每个工作项(work-item,可以理解为一个轻量级线程)通过内建函数 get_global_id(0)get_global_id(1) 获取它在整个 N x N 计算网格中的全局坐标 (对应于列 col 和行 row) 。然后,每个工作项独立地执行内层的 k 循环,累加计算出 C[row][col] 的最终值。由于我们使用 double 作为数据类型,内核代码中需要包含 #pragma OPENCL EXTENSION cl_khr_fp64 : enable 指令来显式启用双精度浮点数支持。

OpenCL 的主要优点在于其理论上的跨平台和跨硬件厂商的兼容性,以及能够充分利用 GPU 等设备的大规模并行计算能力。然而,它的缺点也很突出:编程模型相对复杂,开发者需要手动管理平台、设备、上下文、内存、同步等诸多细节,导致代码通常比较冗长。此外,数据需要在主机和设备之间进行显式传输,这会引入额外的延迟和带宽开销,对于计算量不够大的任务,这部分开销甚至可能超过计算本身带来的加速,导致性能不升反降(得不偿失)。同时,OpenCL 应用的实际性能也可能受到具体硬件厂商驱动程序质量的影响。

CLBlast

CLBlast 可以被认为是 OpenCL 生态系统中的 BLAS 实现。它的设计目标是提供一套与传统 BLAS 接口兼容的 API,但其内部的计算逻辑是基于 OpenCL 标准实现的,因此能够在任何支持 OpenCL 的 GPU(或其他加速设备)上运行。

在使用方面,调用 CLBlast 比起手动编写和管理 OpenCL 内核要简单得多。首先,仍然需要一个已经初始化好的 OpenCL 环境,包括上下文 (context) 和命令队列 (command queue),我们可以直接复用之前为纯 OpenCL 实现准备好的全局上下文 g_clContext。接着,同样需要创建 OpenCL 内存缓冲区来存放输入和输出矩阵,并且需要将主机数据拷贝到输入缓冲区。完成这些准备工作后,核心步骤就是调用 CLBlast 提供的 clblast::Gemm<ValueType>(...) 函数(这里使用了 C++ 模板接口,ValueType 会自动推导精度)。调用时,需要传入描述矩阵布局(行主序或列主序)、是否需要转置输入矩阵、矩阵的维度 (M, N, K)、alpha 和 beta 标量值、指向设备端 OpenCL 缓冲区对象的指针、各矩阵的 leading dimension(对于行主序,通常是列数),以及用于执行的 OpenCL 命令队列。CLBlast 库会负责在内部调用它预先编译和优化好的 OpenCL 内核来完成实际的计算。计算完成后,开发者仍然需要像使用普通 OpenCL 一样,将结果从设备端的 C 缓冲区拷贝回主机内存。

CLBlast 的主要优点在于它提供了标准的 BLAS 接口,极大地简化了利用 OpenCL 进行 GPU 加速的矩阵运算编程。同时,由于 CLBlast 库内部的核函数通常经过了开发者精心的优化(可能运用了更复杂的分块技术、共享内存优化等),其性能往往会优于开发者自己编写的相对简单的 OpenCL 内核。然而,它也有缺点。首先,它依赖于目标系统必须正确安装和配置了 OpenCL 运行时环境以及 CLBlast 库本身。其次,和所有基于分离内存模型的 GPU 加速方案一样,它仍然无法避免主机与设备之间的数据传输开销,这在处理小规模问题或带宽受限时可能会成为性能瓶颈。

Vulkan Compute

接着是 Vulkan Compute。Vulkan 本身主要是作为下一代、高性能的图形渲染 API 而设计的,但它也内置了强大的通用计算 (GPGPU) 能力,通过计算着色器 (Compute Shaders) 来实现。

利用 Vulkan 进行计算的工作流程可以说比 OpenCL 更为繁琐和底层。大致需要经历以下步骤:首先是初始化 Vulkan 实例 (Instance) ,然后选择一个合适的物理设备 (Physical Device),通常是 GPU,并基于此创建逻辑设备 (Logical Device) 以及获取用于提交计算任务的计算队列 (Compute Queue)。计算逻辑本身需要写在计算着色器中(比如我们的 matrix_mult.comp ),这个着色器通常使用 GLSL 语言编写,然后需要用专门的编译器(如 glslc -O)将其编译成 Vulkan 的标准中间表示 SPIR-V 格式;加载这个 SPIR-V 代码来创建一个着色器模块 (Shader Module) (VkShaderModule)。数据存储方面,需要在设备上显式地分配内存 (Memory) ( VkDeviceMemory),并创建 Vulkan 缓冲区 (Buffers) (VkBuffer) 来存放输入矩阵 A、B 和输出矩阵 C。这涉及到复杂的内存类型选择、内存分配、缓冲区创建以及将缓冲区绑定到分配好的内存上。将主机(CPU)的数据拷贝到设备缓冲区通常需要通过一个临时的、主机可见的中转缓冲区 ( Staging Buffer) 来完成。为了让着色器能够访问这些缓冲区资源,需要定义描述符 (Descriptors)。这包括设置描述符集布局 ( Descriptor Set Layout) (VkDescriptorSetLayout) 来声明着色器需要哪些资源(例如三个存储缓冲区),然后创建描述符池 ( Descriptor Pool) (VkDescriptorPool),从中分配具体的描述符集 (Descriptor Set) (VkDescriptorSet) ,最后将我们创建的缓冲区信息“连接”或更新到这个描述符集中。有了着色器模块和描述符,接下来要创建计算管线 (Compute Pipeline) 。这需要先创建管线布局 (Pipeline Layout) (VkPipelineLayout) ,它关联了着色器使用的描述符集布局,然后基于管线布局和着色器模块创建计算管线对象 (VkPipeline) 。实际的指令提交是通过命令缓冲 (Command Buffer) 完成的。需要从命令池 (Command Pool) (VkCommandPool) 分配一个命令缓冲区,然后开始记录命令:首先要绑定(激活)我们创建好的计算管线和包含资源信息的描述符集,然后调用 vkCmdDispatch 来启动计算。vkCmdDispatch 需要指定要启动的工作组 (Workgroup) 的数量,这个数量通常需要根据矩阵大小 N 和着色器中定义的工作组内线程数(local_size)来计算得出。命令记录完成后,将命令缓冲区提交 (Submit) 到之前获取的计算队列去执行。由于提交是异步的,需要使用 Vulkan 的同步原语,如围栏 (Fence) 或信号量 (Semaphore),来等待 GPU 计算完成。计算结束后,同样需要将设备上 C 缓冲区的结果通过中转缓冲区等方式拷贝回主机内存。最后一步,是按照创建的相反顺序,仔细地销毁所有创建的 Vulkan 对象(管线、布局、描述符、池、缓冲区、内存、设备、实例等),释放资源。

我们的计算着色器 (matrix_mult.comp) 是用 GLSL (OpenGL Shading Language) 编写的。代码顶部的 layout (local_size_x = 16, local_size_y = 16) 定义了每个工作组包含 16x16=256 个工作项(线程)。 layout(set = 0, binding = ...) 则指定了着色器如何通过描述符集(这里是第 0 个集)的绑定点(binding 0, 1, 2)来访问我们传入的缓冲区 A, B, C。在 main 函数内部,gl_GlobalInvocationID.xy 这个内建变量给出了当前工作项在整个计算网格中的全局坐标 (id.x 对应列,id.y 对应行)。核心的计算逻辑与 OpenCL 内核非常相似,也是通过一个 k 循环来累加计算出 C[id.y * N + id.x] 的值。

使用 Vulkan Compute 的优点在于它是一个现代的图形 API,设计上旨在降低驱动程序的 CPU 开销。如果应用本身就需要进行图形渲染,那么使用 Vulkan Compute 可以更好地与渲染流程集成,共享资源和上下文。同时,Vulkan 提供了非常细粒度的控制能力,让开发者可以进行深度的性能优化。然而,其缺点也非常突出:API 极其繁琐,初始化和设置过程非常复杂,导致代码量巨大,开发效率相对较低。Vulkan 的主要设计目标仍然是图形渲染,虽然计算能力强大,但在通用计算方面的生态系统、高级库支持和易用性上,可能相对于 OpenCL 或 NVIDIA 的 CUDA/AMD 的 HIP 来说稍弱一些。并且,与 OpenCL 一样,主机与设备之间的数据传输开销依然是存在的,需要仔细管理。

HIP (Heterogeneous-Compute Interface for Portability)

现在我们来了解 HIP (Heterogeneous-Compute Interface for Portability)。HIP 是 AMD ROCm (Radeon Open Compute) 计算平台的核心组成部分之一,它旨在提供一个与 NVIDIA CUDA 非常相似的 C++ GPU 编程模型。其主要目标之一就是为了简化将现有的 CUDA 代码移植到 AMD GPU 上运行的过程。

使用 HIP 进行 GPU 计算的主机端(Host Code)工作流程相对 OpenCL 和 Vulkan 来说要简洁不少,更接近 CUDA 的风格。首先,需要使用 hipMalloc() 函数在目标 GPU 设备上为输入矩阵 A、B 和输出矩阵 C 分配设备内存。然后,通过 hipMemcpy() 函数(并指定 hipMemcpyHostToDevice 作为传输方向)将主机内存中的 A 和 B 数据传输到先前分配好的设备内存中。核心的计算任务是通过启动内核函数 (matrix_multiply_hip_kernel) 来完成的,这里使用了和 CUDA 非常相似的 <<<GridDim, BlockDim>>> 语法来指定内核的执行配置。GridDim 定义了要启动的线程块(类似于 OpenCL 的工作组)的数量,而 BlockDim 则定义了每个线程块中包含的线程数量(例如我们可以设为 16x16)。通常,Grid 的维度需要根据矩阵总大小 N 和选择的 Block 维度来计算得出,以确保覆盖整个计算任务。由于内核启动是异步的,主机代码需要调用 hipDeviceSynchronize() 来等待 GPU 上的所有计算任务完成。计算结束后,再使用 hipMemcpy()(这次指定 hipMemcpyDeviceToHost)将设备内存中得到的 C 矩阵结果传回到主机内存。最后,务必使用 hipFree() 函数释放之前在设备上分配的所有内存。在整个过程中,推荐使用我们定义的 HIP_CHECK() 宏(它内部会调用 hipGetErrorString)来检查每次 HIP API 调用的返回值,以便及时发现并处理错误。

而 HIP 的设备端代码 (matrix_mult_hip.hip 文件),是使用标准的 C++ 语法加上 HIP 的一些扩展来编写的。用 __global__ 关键字修饰的函数就是可以在主机端通过 <<<...>>> 语法启动的内核函数。在内核函数内部,我们可以访问一些内建的变量,如 blockIdx(当前线程块在网格中的索引)、threadIdx(当前线程在线程块内的索引)以及 blockDim (线程块的维度)。通过组合这些变量,我们可以计算出当前线程在整个计算任务中的全局 ID(对应于结果矩阵的 rowcol),这与 OpenCL/Vulkan 中获取全局 ID 的方式(如 get_global_idgl_GlobalInvocationID)是类似的。我们的矩阵乘法内核的核心计算逻辑(即内层的 k 循环)与之前看到的 OpenCL 和 Vulkan 内核基本是相同的。

总的来说,HIP 的主要优点在于它提供了 C++ 接口,相比 OpenCL 的 C API 或者极其繁琐的 Vulkan API 来说,更易于使用和学习。同时,它与 CUDA 语法的高度相似性,为开发者将现有 CUDA 代码迁移到 AMD 平台提供了极大的便利。作为 ROCm 平台的一部分,HIP 与 AMD 的 GPU 驱动程序和工具链(如 hipcc 编译器)紧密集成,通常能获得较好的性能和兼容性。然而,HIP 也有其缺点。它主要针对的是 AMD GPU(尽管通过 HIP Clang 项目也提供了在某些 NVIDIA GPU 上运行的能力,但这并非其主要目标)。使用 HIP 需要安装相对庞大的 ROCm SDK。并且,与所有基于分离式内存模型的 GPU 计算方案一样,主机与设备之间的数据传输开销依然是需要考虑的性能因素。

hipBLAS

最后登场的是 hipBLAS。你可以把它理解为 HIP 生态中的 BLAS 库,它的地位类似于 cuBLAS 在 CUDA 生态中的角色,或是 CLBlast 在 OpenCL 世界中的作用。hipBLAS 是 ROCm 平台官方提供的、使用 HIP 技术进行 GPU 加速的基础线性代数子程序库。

使用 hipBLAS 的流程与使用其他 GPU BLAS 库类似,也比直接编写 HIP 内核要简单。首先,前提是必须已经有了可用的 HIP 运行时环境。在使用 hipBLAS 函数之前,需要创建一个 hipBLAS 句柄 (handle),这是一个管理库内部状态的对象,通过 hipblasHandle_t handle; hipblasCreate(&handle); 来完成初始化。内存管理方面,与使用 HIP 内核一样,需要使用 hipMalloc 在 GPU 设备上为输入矩阵 A、B 和输出矩阵 C 分配内存,并且需要使用 hipMemcpy 将主机端的数据传输到设备端的 A 和 B 缓冲区。核心的计算步骤是调用 hipblasDgemm() 函数(d 表示 double 类型)。这个函数的参数列表与我们之前看到的 cblas_dgemm 非常相似,主要区别在于:需要传入之前创建的 hipBLAS 句柄;并且,传入的 A、B、C 矩阵指针必须是指向设备内存的指针。此外,还需要指定矩阵的操作方式,例如是否需要转置( HIPBLAS_OP_N 表示不转置)。一个需要特别注意的细节是:hipBLAS(像许多传统的 BLAS 库一样)默认期望接收列主序 (Column-Major) 存储的数据。然而,我们在 C++ 中通常使用行主序 (Row-Major) 存储。如果我们的输入 A 和 B 都是行主序,并且希望计算得到行主序的结果 C = A * B,直接调用 hipblasDgemm 时需要小心处理数据布局问题。一种常见的技巧是利用数学上的等价关系 CT = B T * AT 来计算。具体做法是在调用 hipblasDgemm 时,告诉它我们要计算 BT * AT(即传入 HIPBLAS_OP_T 作为 A 和 B 的操作符),并且交换传入的 A 和 B 的设备指针以及它们的 Leading Dimension (lda, ldb),同时也要交换矩阵维度 M 和 N。这样计算得到的结果实际上是 C 的转置(按列主序存储的 CT,其内存布局恰好与行主序的 C 相同)。当然,更直接的方法是检查你使用的 hipBLAS 版本是否提供了直接支持行主序布局的接口或设置。不过,在我们的 matrix_multiply_hipblas 实现中,我们假设它内部已经通过某种方式(可能是转置技巧,或是利用了新接口)正确处理了数据布局,以提供与 cblas_dgemm 相似的行为。计算调用发出后,由于是异步执行,需要调用 hipDeviceSynchronize() 来确保 hipBLAS 操作在 GPU 上完成同步。然后,使用 hipMemcpy 将结果从设备端的 C 缓冲区拷贝回主机内存。最后,不要忘记使用 hipblasDestroy(handle) 销毁 hipBLAS 句柄以释放资源。同样,建议使用 HIPBLAS_CHECK() 宏来检查每次 hipBLAS API 调用的状态,确保没有发生错误。

hipBLAS 的主要优点是它提供了标准的 BLAS 接口,使得利用 AMD GPU 进行高性能线性代数计算变得相对容易。库内部包含了由 AMD 官方针对其 GPU 架构深度优化的 HIP 内核,因此其性能通常非常高,能够很好地发挥硬件潜力。当然,它也有缺点。使用 hipBLAS 依赖于系统正确安装了 ROCm/HIP 开发环境以及 hipBLAS 库本身。和所有 GPU 加速方案一样,主机与设备之间的数据传输开销依然存在。并且,开发者需要特别注意处理数据是按行主序还是列主序存储的问题,以确保函数调用和参数设置正确无误。

好了,各位选手都已介绍完毕。从简单的串行循环,到复杂的 GPU 编程,涵盖了各种主流的性能优化思路和技术栈。接下来,让我们看看它们在实际测试中的表现如何!

基准测试方法

为了确保不同实现之间的比较是公平的,我们采用了 Google Benchmark 这个流行的 C++ 基准测试框架。我们还专门设计了一个测试固件(Fixture),命名为 MatrixMultFixture,它负责管理每一次具体测试运行之前的准备工作(SetUp)和之后的清理工作(TearDown)。

在每次测试设置 (SetUp) 阶段,程序会首先根据 Google Benchmark 框架传入的参数来确定当前要测试的方阵大小 N 。然后,它会分配好主机(CPU)内存,通常是使用 std::vector<ValueType> 来存储输入矩阵 A 和 B,以及一个用于存放 CPU、SIMD 或部分 GPU 计算结果的输出矩阵 C。接着,使用随机数填充输入矩阵 A 和 B。如果本次测试涉及 Eigen 或 OpenCV 库,也会在这个阶段为它们分配好各自的特定类型的结果矩阵(如 C_eigen, C_cv)。需要注意的是,对于像 OpenCL、Vulkan、HIP 这些需要维护全局上下文状态的技术,它们的初始化(例如通过 initOpenCL, initVulkan 等函数)和最终的清理(例如 cleanupOpenCL 等)并不是在每次 SetUpTearDown 中执行的,而是在整个基准测试程序开始运行时的 main 函数入口处进行一次初始化,并在 main 函数结束前进行一次全局清理。这样做可以避免重复初始化和销毁这些重量级上下文带来的开销。

接下来是测试执行阶段,这部分由 Google Benchmark 的宏来驱动。每个不同的矩阵乘法实现都对应着一个独立的 Benchmark 测试函数,例如 BENCHMARK_F(MatrixMultFixture, BM_Naive) 就代表了对 Naive 实现的测试。在每个这样的测试函数内部,核心是一个由 Google Benchmark 控制的 for (auto _ : state) 循环。在这个循环体内,我们会调用当前正在被测试的矩阵乘法函数,比如 matrix_multiply_naive(A, B, C, N)。Google Benchmark 框架会非常智能地自动调整这个循环需要运行的次数,以确保能够获得稳定可靠的计时结果。对于那些需要数据映射或包装的库(比如 Eigen 和 OpenCV),映射(Map)或创建包装对象(如 cv::Mat)的操作通常也在这个循环内部进行,但因为它们通常是零拷贝或低开销的操作,所以对性能测量的影响较小。而对于 GPU 加速的实现(包括 OpenCL、Vulkan、HIP、CLBlast、hipBLAS),调用它们对应的执行函数通常会封装一系列操作:可能包括创建(或复用)设备端的内存缓冲区、将输入数据从主机传输到设备(Host-to-Device)、启动 GPU 上的计算内核、等待内核执行完成(同步)、以及将计算结果从设备传输回主机(Device-to-Host)。

测试清理 (TearDown) 阶段相对简单,主要任务是释放 SetUp 阶段分配的各种主机内存资源,例如调用 A.clear(), B.clear(), C.clear() 等方法。

在测试范围方面,我们选择了一系列 N 的值进行测试,具体包括 64, 128, 256, 512 和 1024。选择这些 2 的幂次方的值是基准测试中的常见做法,这有助于我们观察性能随着问题规模(矩阵大小)的变化趋势,尤其是在对数坐标轴上。

关于性能度量,Google Benchmark 主要测量并报告 real_time,也就是我们通常说的墙上时钟时间。基于这个测得的时间(单位通常是纳秒 ns)和当前的矩阵大小 N,我们计算了一个更具信息量的核心性能指标——GFLOPS(Giga Floating-point Operations Per Second,即每秒十亿次浮点运算次数)。我们使用的计算公式是 GFLOPS = (2.0 * N^3) / (time_ns / 1e9)。这里我们假设标准的方阵乘法需要进行 2 * N^3 次浮点运算(大约 N^3 次乘法和 N^3 次加法)。所有的测试结果最终被输出保存到一个 JSON 格式的文件中,名为 benchmark_results.json,方便后续处理。

最后,为了直观地展示和比较各实现的性能,我们进行了结果可视化。我们使用 Python 语言及其强大的数据处理库 pandas 和绘图库 matplotlib 来读取之前生成的 JSON 文件。程序会解析数据,计算 GFLOPS,然后绘制性能对比图。在图表中,X 轴表示矩阵大小 N(我们采用了以 2 为底的对数尺度,以更好地展示幂次关系),Y 轴表示性能 GFLOPS(同样采用对数尺度,以适应巨大的性能差异)。通过这样的图表,我们可以一目了然地看到不同实现之间的性能差距以及它们各自随问题规模变化的趋势。

现在,让我们看看最终的成绩单!

性能数据分析

请看下面这张根据测试结果绘制的性能对比图:

Matrix Multiplication Performance Comparison Plot

要解读这张性能对比图,首先看坐标轴的设置。X 轴代表矩阵的大小 N,范围从 64 覆盖到 1024,并且采用了以 2 为底的对数尺度。Y 轴则表示计算性能,单位是 GFLOPS(即每秒执行十亿次浮点运算),同样也采用了对数尺度。之所以选择对数尺度,是因为不同实现之间的性能差异可能非常大,使用对数尺度能够将差距悬殊的数据点都清晰地呈现在同一张图上,同时也更便于观察性能随矩阵大小 N 变化的相对趋势。图的右侧提供了图例,清楚地列出了参与本次性能测试的所有实现方法名称,以及它们在图中所对应的线条标记(如圆点、方块、三角等)和颜色,方便我们识别每一条曲线代表哪种实现。

从整体趋势来看,我们可以观察到几个明显的现象。第一,大部分实现的性能都随着矩阵大小 N 的增长而提升,表现为图上的曲线大致呈上升趋势。这符合预期,因为对于更大的 N,总的计算量(其复杂度为 O(N^3))相对于一些固定的或者增长较慢的开销(例如函数调用的开销、GPU 数据传输的启动延迟、线程创建的成本等)来说,所占的比例越来越大。这使得并行处理和各种优化的效果能够更充分地体现出来。同时,处理更大的计算任务也更有利于摊销内存访问的延迟。第二,不同实现之间的性能表现存在巨大的数量级差异。这一点非常惊人,从图中最底部的 Naive 实现到最顶部的 hipBLAS 实现(在 N=1024 这个点上),性能差距竟然超过了 100,000 倍!具体来说,Naive 实现此时大约只有 0.0006 GFLOPS,而 hipBLAS 则达到了约 102 GFLOPS。这个巨大的反差极其有力地证明了进行性能优化的必要性和巨大潜力。第三,我们注意到部分曲线在 N 增长到较大值时,其上升趋势开始减缓,趋于平缓,甚至在某些情况下可能略微下降。这通常标志着该实现在当前条件下遇到了性能瓶颈。这个瓶颈可能是多种多样的,比如内存带宽已经达到饱和,无法更快地供给数据;或者是 CPU 或 GPU 的缓存容量不足以容纳更大的工作集,导致缓存命中率下降;也可能是 GPU 的核心利用率已经接近极限;亦或是某些未被充分优化的开销随着 N 呈线性或更高阶增长,开始抵消计算本身的加速。

为了更深入地分析这些性能数据,我们可以将实现方法大致分为几个组别来进行详细对比。

首先看 CPU 基础组,比较的是最简单的 Naive 实现和仅使用 OpenMP 并行的版本。Naive 实现(图中黄色 + 号标记)无疑是性能最低的,它的曲线在对数坐标图上几乎是一条贴近底部的水平线,增长极为缓慢,在 N=1024 时仅能达到约 0.6 GFLOPS。相比之下,OpenMP 版本(橙色方块)利用了 CPU 的 20 个线程,性能有了明显的改善,在 N=1024 时达到了约 4 GFLOPS,是 Naive 性能的 6 到 7 倍。尽管如此,与更高级的优化方法相比,这个速度仍然很慢,而且其性能曲线相对平坦,暗示着简单的多核并行可能很快就遇到了内存带宽等瓶颈。

接下来是 CPU SIMD 组,这里我们考察了使用 AVX2、AVX-512 指令集以及它们与 OpenMP 结合的效果。单线程的 AVX2+FMA 实现(深蓝色圆点)已经展现出了向量化的威力,其性能在 N=1024 时约有 1.7 GFLOPS,在 N 小于 512 时甚至略优于纯 OpenMP 版本。更进一步的 AVX512+FMA(绿色三角)则更快,因为它使用的 512 位向量一次可以处理两倍于 AVX2 的数据量,在 N=1024 时达到了约 2.4 GFLOPS。当我们把 SIMD 和多线程结合起来,性能得到了巨大的飞跃。AVX2+FMA_OMP(红色菱形)在 N=1024 时性能达到了约 9.5 GFLOPS,这比单线程的 AVX2 快了 5 倍以上,也比纯 OpenMP 快了 2 倍多。而本组的冠军,也是所有 CPU 实现中的佼佼者,当属 AVX512+FMA_OMP(紫色倒三角)。它结合了最宽的 SIMD 向量和多核并行能力,在 N=1024 时跑出了惊人的 15 GFLOPS,相比 AVX2+OMP 版本又提升了大约 60%。这条性能曲线在所有纯 CPU 实现中处于最顶端的位置。

再来看 CPU 专业库组,我们比较了 BLAS、Eigen 和 OpenCV 这三个库的表现。BLAS(紫色 V 形标记,需要勘误:根据图表重新读取数据,BLAS 在 N=1024 时的性能约为 53 GFLOPS,而非之前可能误读的数值)表现非常出色,其性能几乎与我们手动编写的最优 CPU 代码 ( AVX512+FMA_OMP) 不相上下,甚至更高,达到了约 53 GFLOPS。这充分说明我们系统上安装的 BLAS 库(很可能是 OpenBLAS)内部已经进行了极其高效的优化,很可能充分利用了 SIMD 指令和多线程技术。同样令人瞩目的是 OpenCVLib(天蓝色圆点),它的性能紧随 BLAS 之后,在 N=1024 时甚至略微超过了 BLAS,达到了约 54 GFLOPS。这表明 OpenCV 的 gemm 函数背后有非常强大的优化实现作为支撑,很可能在其底层调用了高度优化的 BLAS 库或者其他类似 IPP 的性能核心库。然而,EigenLib(粉色星号)在本次测试中的表现却出乎意料地差,其性能甚至还不如只用了 OpenMP 的 Naive 版本,在 N=1024 时仅约 0.7 GFLOPS。这与 Eigen 库通常所拥有的高性能声誉形成了鲜明对比。造成这种反常结果的可能原因有很多,比如可能是我们在测试代码中对 Eigen 的使用方式不够优化(例如 Eigen::Map 的开销是否被错误地计入了测量时间?虽然可能性不大),或者是编译器未能针对 Eigen 的表达式模板技术进行充分的优化,亦或是当前使用的特定 Eigen 版本与我们的测试环境之间存在某种兼容性或性能上的问题。因此,对于 Eigen 的这个测试结果,我们需要持谨慎态度,不应将其直接推广为 Eigen 库性能普遍不行,这很可能与本次测试的具体配置和环境有关。

最后是 GPU 加速组,包含了使用 OpenCL、Vulkan、HIP 以及对应的 BLAS 库 CLBlast 和 hipBLAS 的实现。从通用趋势来看,所有 GPU 实现在处理较小规模的矩阵时(例如 N=64),其性能往往低于那些优化得较好的 CPU 方法(如 BLAS 或 AVX+OMP),甚至可能还不如 Naive + OpenMP。这主要是因为 GPU 计算涉及到数据在主机和设备之间的传输(CPU 到 GPU,GPU 再到 CPU)以及启动 GPU 内核本身的开销,对于计算量不够大的小任务来说,这些固定开销在总时间中所占的比例非常高。但是,随着矩阵规模 N 的增大,GPU 无与伦比的大规模并行计算优势开始显现,它们的性能曲线迅速爬升,最终超越了所有纯 CPU 的实现方法。

在手写内核方面(即我们自己编写计算逻辑的 OpenCL、Vulkan 和 HIP 内核),OpenCL(青色菱形)表现相当不错,在 N=1024 时达到了约 58 GFLOPS,并且其性能曲线比较陡峭,显示出良好的随问题规模增长的扩展性。Vulkan(绿色上三角)的性能也很好,但在 N=1024 时约 29 GFLOPS,略低于 OpenCL 和 HIP Kernel。考虑到 Vulkan API 本身的复杂性,这个结果也算在合理范围内,可能在驱动程序或者我们的着色器优化方面还有提升空间。HIP(灰色 X 标记)的表现则有些奇怪,在 N=64 时性能异常地低(这可能是一个测量错误、初始化问题或者特定小尺寸下的性能陷阱),但在 N 增大到 128 及以后,其性能迅速赶上并与 OpenCL 非常接近,在 N=1024 时也达到了约 57 GFLOPS。这表明对于我们编写的这种相对简单的计算内核,HIP 和 OpenCL 在这块 AMD GPU 上的底层执行效率是相似的。

当我们转向使用 GPU BLAS 库时,性能再次跃升。CLBlast(棕色菱形)作为 OpenCL 生态的 BLAS 库,其性能远超我们手写的 OpenCL 内核,在 N=1024 时达到了约 95 GFLOPS。这充分体现了专业库内部进行深度 Kernel 优化的巨大价值,它们很可能运用了更高级的技术,比如更优化的内存访问模式、数据分块(Tiling)以及对 GPU 共享内存(Shared Memory/LDS)的有效利用等。而最终的全场总冠军则归属于 hipBLAS(红色下三角)。作为 AMD ROCm 平台原生的 BLAS 库,它的表现最为出色,在 N=1024 时性能成功突破了 100 GFLOPS 大关,达到了约 102 GFLOPS。这通常意味着 hipBLAS 能够最充分地挖掘和利用 AMD GPU 的底层硬件特性和指令。

简单总结一下这次性能测试的亮点与槽点。毋庸置疑的性能王者是在 N=1024 规模下表现最佳的 hipBLAS (GPU) 和 CLBlast (GPU) 这两个 GPU BLAS 库。在纯 CPU 阵营中,系统 BLAS 库、OpenCV 库以及我们手动结合了 AVX512+FMA+OMP 的实现是顶尖的竞争者。这次测试最令人惊叹的是性能提升的幅度:从最基础的 Naive 实现到性能最高的 hipBLAS,在 N=1024 时,性能差距竟然超过了 17 万倍(计算约为 102 GFLOPS / 0.0006 GFLOPS ≈ 170,000)!这极大地凸显了优化的价值。GPU 的优势也非常明显,在我们的测试中,大约从 N=256 这个规模开始,顶级的 GPU 实现就能够超越所有 CPU 实现,并且随着 N 的继续增大,GPU 相对于 CPU 的性能优势愈发显著。这也验证了专业库的重要性:像 BLAS、CLBlast、hipBLAS 以及表现出色的 OpenCV 这些库,由于其内部封装了大量针对特定硬件的深度优化细节(如分块、指令调度、内存管理等),它们往往能够提供比我们自己手动进行的优化(尤其是相对简单的手写 GPU 内核)更好的性能。当然,测试中也出现了一些“槽点”或者说需要注意的地方。Eigen 库的表现与预期相差甚远,其在此次测试中性能不佳的原因有待进一步探究。另外,HIP 实现在 N=64 时出现的异常低性能点也提醒我们,对于基准测试中的个别异常数据点,需要谨慎对待,其结果可能是无效的,需要进一步排查原因。

总而言之,这次基准测试展示了采用不同技术路径所能带来的巨大性能差异。从最基础的三重 CPU 循环到涉及复杂硬件交互的 GPU 编程,每一种优化策略和实现方式都有其背后的原理和适用的场景。

深入思考、讨论与注意事项

尽管这次性能测试为我们提供了许多直观的数据,但它也引发了一些值得深入的思考,并且我们在解读这些结果时必须认识到其存在的一些限制和注意事项。

首先,结果具有很强的硬件依赖性。我们所有的测试都是在一台特定的 AMD Ryzen AI 9 处理器配备 Radeon 880M 集成显卡的平台上进行的。如果换用不同的硬件,例如 Intel 的 CPU 或者 NVIDIA 的 GPU,那么各种实现的性能排名和具体数值可能会发生巨大的变化。比如,Intel CPU 在配合其自家的 MKL (Math Kernel Library) 时通常能展现出极佳的性能;而对于 NVIDIA GPU,则需要使用 CUDA 编程模型以及 cuBLAS 库才能发挥其最大潜力。

其次,编译器和所使用的库的版本也会对结果产生影响。我们使用的 GCC 或 Clang 的具体版本、所选择的编译优化选项(例如使用 -Ofast 替代 -O3 可能会带来速度提升,但有时会牺牲浮点计算的精度或对标准的严格符合性)、以及像 BLAS、OpenCV、Eigen 这些数学库的具体版本及其编译方式(例如 OpenBLAS 在编译时可以选择不同的线程模型或 CPU 目标),都可能导致最终性能数据发生变化。例如,如果在 Intel CPU 上将 BLAS 库从 OpenBLAS 更换为 MKL,结果可能截然不同。

再者,测试中使用的数据类型和矩阵的特性也是关键因素。本次测试统一使用了 double(64位双精度浮点数)类型的方阵。如果改为使用 float(32位单精度浮点数),性能通常会更高,因为单精度数据量减半,内存带宽压力更小,同时 SIMD 指令一次能够处理的数据元素数量翻倍,并且部分硬件本身就对单精度计算有更快的支持。此外,我们的测试是针对稠密方阵进行的,对于具有特殊结构(如稀疏矩阵、对称矩阵、带状矩阵等)的矩阵,应当使用专门为此设计的存储格式、算法和库才能获得高效的性能。

另外,性能衡量指标 GFLOPS 并非故事的全部。GFLOPS 是衡量核心计算吞吐量的一个重要指标,但它并不能完全代表实际应用中的性能。特别是对于 GPU 计算而言,数据在主机(CPU)和设备(GPU)之间传输所需的时间(例如通过 hipMemcpy, clEnqueueWrite/ReadBuffer 等函数完成)是整个任务总耗时中不可或缺的一部分。我们本次使用 Google Benchmark 进行的测试,很可能主要测量的是循环内部核心计算部分的时间,而没有完整包含数据准备和结果取回的开销。在实际应用中,我们需要关注的是端到端 ( end-to-end) 的完整执行时间。对于处理小规模矩阵的情况,数据传输的开销甚至可能超过计算本身的时间,成为主导因素。

同时,我们还需要考虑实现的复杂度与易用性之间的权衡。那些性能最高的实现,比如 hipBLAS 或 CLBlast,虽然从使用者的角度看可能相对简单(只需调用几个库函数),但它们依赖于用户正确安装和配置特定的 SDK(如 ROCm)和运行环境。而如果选择手动编写 SIMD intrinsics 或者 GPU 内核代码(如 OpenCL, Vulkan, HIP kernel),虽然可能获得对性能更精细的控制,但这需要开发者具备非常深厚的底层硬件知识和并行编程经验,并且往往伴随着大量的开发、调试和优化时间。相比之下,Naive 和 OpenMP 实现起来最简单,但性能也最差。因此,在实际项目中选择哪种实现方法,需要在性能需求、开发成本、代码可移植性、长期维护性等多个维度之间进行综合考量和权衡。

我们还应该意识到,在缓存优化方面,我们手动编写的 CPU SIMD 和 GPU 内核(OpenCL/Vulkan/HIP)都相对简单,没有实现复杂的数据分块 (Blocking/Tiling) 优化。分块是一种将大矩阵切分成若干个更小的子矩阵(块),然后按块进行计算的高级优化技术。它的主要目的是最大化利用 CPU 或 GPU 缓存的容量和带宽,显著提高缓存命中率,这通常是高性能 BLAS 库能够取得极致性能的核心秘诀之一。如果我们也为手动编写的实现加入复杂的分块逻辑,它们的性能数据可能会得到进一步提升,但这会使得代码的复杂度急剧增加。

最后,测试中出现的 Eigen 库性能异常低以及 HIP 在 N=64 时性能异常点这两个情况,也提醒我们对待基准测试结果需要持有批判性的眼光。当遇到与预期严重不符的数据时,不应草率地直接下结论,而应尝试去分析可能的原因,比如是否存在代码逻辑错误、编译选项问题、测量误差、后台进程干扰、或者是特定环境下的兼容性问题等等。只有经过仔细排查和验证,我们才能对测试结果更有信心。

结论与展望

经过这场涵盖了 CPU 与 GPU、串行与并行、手动优化代码与专业数学库等多种实现方式的矩阵乘法性能基准测试,我们可以梳理出一些相当清晰的结论。

首先,优化至关重要。我们看到了最基础的 Naive 实现与那些经过高度优化的实现之间存在着令人瞠目结舌的巨大性能鸿沟。这充分说明,对于计算密集型任务而言,选择合适的算法和实现技术是提升性能的绝对关键。其次,充分利用硬件特性能够带来显著回报。无论是现代 CPU 的多核能力(可以通过 OpenMP 等技术利用)还是其 SIMD 指令集(可以通过 intrinsics 手动编码或依赖库的自动向量化),都能带来可观的性能提升;将多核并行与 SIMD 向量化结合起来,通常能够帮助我们压榨出 CPU 在该任务上的性能极限。再者,GPU 加速的潜力是巨大的。对于规模足够大的计算任务(在我们的测试中,大约从 N=256 开始),GPU 所拥有的大规模并行计算能力能够提供远超任何 CPU 实现的性能。我们还认识到要善用专业数学库。像 BLAS(及其各种实现如 OpenBLAS, MKL, AOCL-BLAS)、CLBlast、hipBLAS(或对应 NVIDIA 平台的 cuBLAS)这样的专业库,由于其内部封装了开发者针对特定硬件进行的大量底层优化,使用它们往往是同时获得高性能和高开发效率的最佳途径。即便是像 OpenCV 这样的高级库,其高性能也可能得益于内部对这些底层优化库的依赖。然而,我们也必须认识到,性能优化领域没有所谓的“银弹”,即没有任何一种方法能在所有情况下都做到最优。例如,处理小规模问题时,CPU 实现可能因为避免了数据传输开销而表现更佳;而当问题规模增大后,GPU 的优势才能充分体现。不同的硬件平台、不同的精度要求(单精度 vs 双精度)、以及项目可投入的开发资源限制等因素,都会影响最终的最优选择。最后,这一切都指向了持续学习与实践的重要性。高性能计算是一个日新月异、不断发展的领域,新的硬件架构、编程模型、编译器优化技术层出不穷。只有保持强烈的好奇心,不断学习新的知识,并且亲自动手去测试、去验证,才能真正掌握性能优化的钥匙,为自己的应用找到最合适的加速方案。

希望这次关于矩阵乘法性能的探索之旅,能够帮助大家对不同的计算技术及其性能表现有一个更直观、更深入的认识。从那朴素的三重循环到动辄上百 GFLOPS 的惊人速度,这背后凝聚的是计算机体系结构设计、并行计算理论以及软件工程实践的无数智慧结晶。或许下次当你面临需要处理大规模矩阵运算的任务时,能够回想起今天我们一起回顾的这些形形色色的选手们,从而更有信心地为你的应用程序选择那个最合适、最高效的加速方案!

附录:基准测试结果

ImplementationMatrix Size (N)Real Time (ns)Performance (GFLOPS)
Naive64640,5610.818
Naive1285,250,4210.799
Naive25642,393,8110.791
Naive512569,762,9810.471
Naive10243,447,583,1010.623
OpenMP64149,2703.512
OpenMP1281,036,5904.046
OpenMP2566,844,2824.903
OpenMP51262,077,0424.324
OpenMP1024578,410,6143.713
AVX2+FMA64311,1781.685
AVX2+FMA1282,505,6851.674
AVX2+FMA25619,324,4941.736
AVX2+FMA512152,734,9501.758
AVX2+FMA10241,237,421,6111.735
AVX512+FMA64221,9512.362
AVX512+FMA1281,702,1582.464
AVX512+FMA25614,094,4452.381
AVX512+FMA512107,877,8802.488
AVX512+FMA1024921,593,9932.330
AVX2+FMA_OMP6490,2765.808
AVX2+FMA_OMP128664,5526.311
AVX2+FMA_OMP2563,656,0769.178
AVX2+FMA_OMP51227,922,7879.613
AVX2+FMA_OMP1024216,519,9719.918
AVX512+FMA_OMP6486,8966.033
AVX512+FMA_OMP128427,9949.799
AVX512+FMA_OMP2562,648,92612.667
AVX512+FMA_OMP51218,439,35514.558
AVX512+FMA_OMP1024140,055,38215.333
Eigen64904,7850.579
Eigen12812,846,5930.326
Eigen25632,201,9971.042
Eigen512284,153,4140.945
Eigen10242,316,560,8420.927
OpenCV6433,32615.732
OpenCV12873,44357.110
OpenCV256538,50162.311
OpenCV5124,811,56955.790
OpenCV102436,290,27059.175
BLAS6410,60949.420
BLAS12873,92956.734
BLAS256535,02162.716
BLAS5125,210,26151.521
BLAS102436,608,52958.661
Vulkan64258,6502.027
Vulkan128850,2224.933
Vulkan2562,015,57016.648
Vulkan51215,517,30417.300
Vulkan102469,655,18330.830
OpenCL6469,3977.555
OpenCL128147,86128.367
OpenCL256593,37656.548
OpenCL5125,842,25345.947
OpenCL102438,429,52855.881
CLBlast6461,0028.595
CLBlast128127,00733.024
CLBlast256426,35878.700
CLBlast5123,740,45371.765
CLBlast102420,777,060103.358
HIP64856,032,7390.000612
HIP128171,22524.496
HIP256613,60354.684
HIP5125,788,91146.371
HIP102438,210,71256.201
hipBLAS642,080,4840.252
hipBLAS1282,146,9781.954
hipBLAS2562,691,23212.468
hipBLAS5125,960,23345.038
hipBLAS102421,356,498100.554