129

出于好奇,我决定将我自己的矩阵乘法函数与 BLAS 实现进行基准测试......我可以说对结果最不惊讶:

自定义实现,10 次 1000x1000 矩阵乘法的试验:

Took: 15.76542 seconds.

BLAS 实现,10 次 1000x1000 矩阵乘法的试验:

Took: 1.32432 seconds.

这是使用单精度浮点数。

我的实现:

template<class ValT>
void mmult(const ValT* A, int ADim1, int ADim2, const ValT* B, int BDim1, int BDim2, ValT* C)
{
    if ( ADim2!=BDim1 )
        throw std::runtime_error("Error sizes off");

    memset((void*)C,0,sizeof(ValT)*ADim1*BDim2);
    int cc2,cc1,cr1;
    for ( cc2=0 ; cc2<BDim2 ; ++cc2 )
        for ( cc1=0 ; cc1<ADim2 ; ++cc1 )
            for ( cr1=0 ; cr1<ADim1 ; ++cr1 )
                C[cc2*ADim2+cr1] += A[cc1*ADim1+cr1]*B[cc2*BDim1+cc1];
}

我有两个问题:

  1. 鉴于矩阵-矩阵乘法说:nxm * mxn 需要 n*n*m 次乘法,因此在上述 1000^3 或 1e9 操作的情况下。在我的 2.6Ghz 处理器上,BLAS 怎么可能在 1.32 秒内完成 10*1e9 操作?即使乘法是一个单一的操作并且没有做任何其他事情,它也应该需要大约 4 秒。
  2. 为什么我的实现这么慢?
4

8 回答 8

178

一个很好的起点是Robert A. van de Geijn 和 Enrique S. Quintana-Ortí的伟大著作《矩阵计算编程科学》 。他们提供免费下载版本。

BLAS分为三个级别:

  • 级别 1 定义了一组仅对向量进行运算的线性代数函数。这些功能受益于矢量化(例如使用 SSE)。

  • 2 级函数是矩阵向量运算,例如一些矩阵向量积。这些功能可以按照 Level1 功能来实现。但是,如果您可以提供一个专用的实现,该实现利用一些具有共享内存的多处理器体系结构,则可以提高此功能的性能。

  • 3 级函数是类似于矩阵-矩阵乘积的运算。同样,您可以根据 Level2 函数来实现它们。但是 Level3 函数对 O(N^2) 数据执行 O(N^3) 操作。因此,如果您的平台具有缓存层次结构,那么如果您提供缓存优化/缓存友好的专用实现,则可以提高性能。这在书中有很好的描述。Level3 功能的主要提升来自缓存优化。这种提升大大超过了并行性和其他硬件优化带来的第二次提升。

顺便说一句,大多数(甚至所有)高性能 BLAS 实现都不是在 Fortran 中实现的。ATLAS 用 C 实现。GotoBLAS/OpenBLAS 用 C 实现,其性能关键部分用 Assembler 实现。只有 BLAS 的参考实现是在 Fortran 中实现的。然而,所有这些 BLAS 实现都提供了一个 Fortran 接口,因此它可以与 LAPACK 链接(LAPACK 的所有性能都来自 BLAS)。

优化的编译器在这方面起着次要的作用(对于 GotoBLAS/OpenBLAS,编译器根本不重要)。

恕我直言,没有 BLAS 实现使用诸如 Coppersmith–Winograd 算法或 Strassen 算法之类的算法。可能的原因是:

  • 也许不可能提供这些算法的缓存优化实现(即你会失去更多然后你会赢)
  • 这些算法在数值上不稳定。由于 BLAS 是 LAPACK 的计算内核,因此这是不行的。
  • 尽管这些算法在纸面上具有很好的时间复杂度,但大 O 表示法隐藏了一个大常数,因此它仅开始适用于非常大的矩阵。

编辑/更新:

该主题的新突破性论文是BLIS 论文。他们写得特别好。在我的讲座“高性能计算的软件基础”中,我在他们的论文之后实现了矩阵矩阵产品。实际上我实现了矩阵矩阵产品的几个变体。最简单的变体完全用纯 C 语言编写,代码不到 450 行。所有其他变体仅优化循环

    for (l=0; l<MR*NR; ++l) {
        AB[l] = 0;
    }
    for (l=0; l<kc; ++l) {
        for (j=0; j<NR; ++j) {
            for (i=0; i<MR; ++i) {
                AB[i+j*MR] += A[i]*B[j];
            }
        }
        A += MR;
        B += NR;
    }

矩阵-矩阵乘积的整体性能取决于这些循环。大约 99.9% 的时间都花在这里。在其他变体中,我使用内在函数和汇编代码来提高性能。您可以在此处查看教程中的所有变体:

ulmBLAS:GEMM(矩阵-矩阵乘积)教程

结合 BLIS 论文,可以很容易地理解英特尔 MKL 等库如何获得这样的性能。以及为什么使用行或列主要存储并不重要!

最终的基准测试在这里(我们将项目称为 ulmBLAS):

ulmBLAS、BLIS、MKL、openBLAS 和 Eigen 的基准

另一个编辑/更新:

我还写了一些关于 BLAS 如何用于数值线性代数问题的教程,例如求解线性方程组:

高性能 LU 分解

(例如,Matlab 使用此 LU 分解来求解线性方程组。)

我希望抽出时间来扩展本教程,以描述和演示如何像在PLASMA中那样实现高度可扩展的 LU 分解并行实现。

好的,你开始吧:编码缓存优化的并行 LU 分解

PS:我也做了一些提高uBLAS性能的实验。实际上,提高 uBLAS 的性能非常简单(是的,玩文字游戏 :)):

uBLAS 上的实验

这里有一个与BLAZE类似的项目:

对 BLAZE 的实验

于 2012-07-10T20:23:36.977 回答
29

所以首先BLAS只是一个大约50个功能的接口。该接口有许多相互竞争的实现。

首先,我将提到一些基本上不相关的事情:

  • Fortran vs C,没有区别
  • 诸如 Strassen 之类的高级矩阵算法,实现不使用它们,因为它们在实践中没有帮助

大多数实现以或多或少明显的方式将每个操作分解为小维矩阵或向量操作。例如,一个大的 1000x1000 矩阵乘法可以分解成一个 50x50 矩阵乘法的序列。

这些固定大小的小维度操作(称为内核)使用其目标的几个 CPU 特性硬编码在特定于 CPU 的汇编代码中:

  • SIMD 式指令
  • 指令级并行
  • 缓存感知

此外,在典型的 map-reduce 设计模式中,这些内核可以使用多个线程(CPU 内核)相互并行执行。

看看 ATLAS,它是最常用的开源 BLAS 实现。它有许多不同的竞争内核,在 ATLAS 库构建过程中,它会在它们之间进行竞争(有些甚至是参数化的,因此同一个内核可以有不同的设置)。它尝试不同的配置,然后为特定目标系统选择最佳配置。

(提示:这就是为什么如果您使用 ATLAS,最好为您的特定机器手动构建和调整库,然后使用预构建的库。)

于 2013-06-26T14:10:15.830 回答
16

首先,有比您使用的更有效的矩阵乘法算法。

其次,您的 CPU 一次可以执行多于一条指令。

您的 CPU 每个周期执行 3-4 条指令,如果使用 SIMD 单元,则每条指令处理 4 个浮点数或 2 个双精度数。(当然这个数字也不准确,因为 CPU 通常每个周期只能处理一条 SIMD 指令)

第三,您的代码远非最佳:

  • 您正在使用原始指针,这意味着编译器必须假设它们可能有别名。您可以指定编译器特定的关键字或标志来告诉编译器它们没有别名。或者,您应该使用原始指针以外的其他类型来解决问题。
  • 您通过对输入矩阵的每一行/列执行简单的遍历来破坏缓存。您可以使用阻塞在矩阵的较小块上执行尽可能多的工作,该块适合 CPU 缓存,然后再继续下一个块。
  • 对于纯粹的数字任务,Fortran 几乎是无与伦比的,而 C++ 需要大量的哄骗才能达到类似的速度。可以做到,并且有一些库演示了它(通常使用表达式模板),但这并非易事,而且不会发生
于 2009-08-20T12:12:12.687 回答
11

我不具体了解 BLAS 的实现,但有更有效的矩阵乘法算法,其复杂度优于 O(n3)。一个众所周知的是Strassen 算法

于 2009-08-20T00:15:17.457 回答
4

第二个问题的大多数论点 - 汇编程序,拆分成块等(但不是少于 N^3 的算法,它们确实是过度开发的) - 发挥了作用。但是你的算法的低速度主要是由矩阵大小和三个嵌套循环的不幸排列造成的。您的矩阵太大,以至于它们无法立即放入高速缓存中。您可以重新排列循环,以便尽可能多地在缓存中的一行上完成,这样可以显着减少缓存刷新(顺便说一句,分成小块具有类似的效果,最好是块上的循环以类似方式排列)。下面是方阵的模型实现。在我的计算机上,与标准实现(如您的)相比,它的时间消耗约为 1:10。换句话说:永远不要沿着“

    void vector(int m, double ** a, double ** b, double ** c) {
      int i, j, k;
      for (i=0; i<m; i++) {
        double * ci = c[i];
        for (k=0; k<m; k++) ci[k] = 0.;
        for (j=0; j<m; j++) {
          double aij = a[i][j];
          double * bj = b[j];
          for (k=0; k<m; k++)  ci[k] += aij*bj[k];
        }
      }
    }

还有一点:在我的计算机上,这个实现甚至比用 BLAS 例程 cblas_dgemm 替换所有更好(在你的计算机上试试!)。但更快(1:4)是直接调用 Fortran 库的 dgemm_ 。我认为这个例程实际上不是 Fortran 而是汇编代码(我不知道库中有什么,我没有源代码)。我完全不清楚为什么 cblas_dgemm 没有那么快,因为据我所知,它只是 dgemm_ 的包装器。

于 2013-11-30T20:11:55.680 回答
3

这是一个现实的加速。有关在 C++ 代码上使用 SIMD 汇编器可以完成的操作的示例,请参阅一些示例iPhone 矩阵函数- 这些比 C 版本快 8 倍以上,甚至没有“优化”汇编 - 还没有流水线和那里是不必要的堆栈操作。

此外,您的代码不是“限制正确” - 编译器如何知道当它修改 C 时,它没有修改 A 和 B?

于 2009-08-20T00:10:54.977 回答
2

对于MM乘法中的原始代码,大多数操作的内存引用是导致性能不佳的主要原因。内存的运行速度比缓存慢 100-1000 倍。

大部分加速来自于在 MM 乘法中为这个三重循环函数采用循环优化技术。使用了两种主要的循环优化技术;展开和阻塞。关于展开,我们展开最外面的两个循环并将其阻塞以供缓存中的数据重用。外循环展开通过减少在整个操作过程中不同时间对相同数据的内存引用次数来帮助暂时优化数据访问。以特定数字阻塞循环索引有助于将数据保留在缓存中。您可以选择针对 L2 缓存或 L3 缓存进行优化。

https://en.wikipedia.org/wiki/Loop_nest_optimization

于 2016-05-02T12:07:50.533 回答
-25

因为许多的原因。

首先,Fortran 编译器经过高度优化,并且该语言允许它们如此。C 和 C++ 在数组处理方面非常松散(例如,指针指向同一内存区域的情况)。这意味着编译器无法提前知道要做什么,并被迫创建通用代码。在 Fortran 中,您的案例更加精简,编译器可以更好地控制发生的事情,从而允许他进行更多优化(例如使用寄存器)。

另一件事是 Fortran 以列方式存储内容,而 C 以行方式存储数据。我没有检查您的代码,但请注意您如何执行产品。在 C 语言中,您必须逐行扫描:这样您就可以沿着连续内存扫描数组,从而减少缓存未命中。缓存未命中是低效率的第一个来源。

第三,这取决于您使用的 blas 实现。一些实现可能是用汇编程序编写的,并针对您使用的特定处理器进行了优化。netlib 版本是用 fortran 77 编写的。

此外,你正在做很多操作,其中大部分是重复的和多余的。获得索引的所有这些乘法对性能都是有害的。我真的不知道这是如何在 BLAS 中完成的,但是有很多技巧可以防止昂贵的操作。

例如,您可以通过这种方式重新编写代码

template<class ValT>
void mmult(const ValT* A, int ADim1, int ADim2, const ValT* B, int BDim1, int BDim2, ValT* C)
{
if ( ADim2!=BDim1 ) throw std::runtime_error("Error sizes off");

memset((void*)C,0,sizeof(ValT)*ADim1*BDim2);
int cc2,cc1,cr1, a1,a2,a3;
for ( cc2=0 ; cc2<BDim2 ; ++cc2 ) {
    a1 = cc2*ADim2;
    a3 = cc2*BDim1
    for ( cc1=0 ; cc1<ADim2 ; ++cc1 ) {
          a2=cc1*ADim1;
          ValT b = B[a3+cc1];
          for ( cr1=0 ; cr1<ADim1 ; ++cr1 ) {
                    C[a1+cr1] += A[a2+cr1]*b;
           }
     }
  }
} 

试试看,我相信你会节省一些东西。

在您的第一个问题上,原因是如果您使用简单的算法,矩阵乘法的比例为 O(n^3)。有些算法可以更好地扩展

于 2009-08-19T23:36:42.470 回答