BLAS如何获得如此出色的性能?


108

出于好奇,我决定将自己的矩阵乘法函数与BLAS实现进行基准测试……我对结果并不感到惊讶:

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

Took: 15.76542 seconds.

BLAS实施,10次10​​00x1000矩阵乘法试验:

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以上的运算中。BLAS 2.6Ghz处理器如何在1.32秒内执行10 * 1e9操作?即使乘法是单个操作,并且没有其他任何事情要做,它也需要大约4秒钟。
  2. 为什么我的执行速度这么慢?

17
BLAS已通过该领域的专家在一侧和另一侧进行了优化。我认为它是利用芯片上的SIMD浮点单元并发挥许多技巧来改善缓存行为...
dmckee ---前主持人小猫

3
您仍然如何在1.3秒内在2.63E9周期/秒的处理器上执行1E10操作?
DeusAduro

9
多个执行单元,管道和单指令多数据((SIMD),这意味着对多个操作数同时执行相同的操作)。一些编译器可以将SIMD单元定位在通用芯片上,但是您几乎总是必须显式打开电源,这有助于了解它们的全部工作原理(en.wikipedia.org/wiki/SIMD)。确保高速缓存未命中几乎是绝对困难的部分。
dmckee ---前主持人小猫,

13
假设是错误的。有更好的已知算法,请参阅Wikipedia。
MSalters

2
@DeusAduro:在我的回答中,如何编写可与本征竞争的矩阵矩阵产品?我发布了一个有关如何实现高效缓存矩阵矩阵产品的小例子。
Michael Lehn

Answers:


141

一个很好的起点是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中实现。在Fortran中仅实现BLAS的参考实现。但是,所有这些BLAS实现都提供了一个Fortran接口,因此可以将其与LAPACK链接(LAPACK从BLAS获得所有性能)。

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

恕我直言,没有BLAS实施使用诸如Coppersmith–Winograd算法或Strassen算法之类的算法。我不确定原因,但这是我的猜测:

  • 也许不可能为这些算法提供缓存优化的实现(即,您失去的更多,然后您将获胜)
  • 这些算法在数值上不稳定。由于BLAS是LAPACK的计算核心,因此这是不行的。

编辑/更新:

关于该主题的最新突破性论文是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论文一起,可以很容易地理解像Intel MKL这样的库如何获得这种性能。以及为什么使用行或列主存储都无所谓!

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

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

另一个编辑/更新:

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

高性能LU分解

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

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

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

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

uBLAS的实验

这是一个与BLAZE相似的项目:

BLAZE的实验


3
新链接到“ ulmBLAS,BLIS,MKL,openBLAS和Eigen的基准”:apfel.mathematik.uni-ulm.de/~lehn/ulmBLAS/#toc3
Ahmed Fasih

原来IBM的ESSL使用Strassen的算法的变化- ibm.com/support/knowledgecenter/en/SSFHY8/essl_welcome.html
本-阿尔布雷希特

2
大多数链接都死了
的Aurelien皮埃尔

TSoPMC的PDF可以在作者页面上找到,网址
Alex Shpilkin

尽管Coppersmith-Winograd算法在纸上具有很好的时间复杂度,但是Big O符号隐藏了一个很大的常数,因此,它对于可笑的大型矩阵才开始变得可行。
DiehardTheTryhard

26

因此,首先BLAS只是大约50个功能的接口。接口有许多竞争的实现。

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

  • Fortran与C没什么区别
  • 诸如Strassen之类的高级矩阵算法,实现中不会使用它们,因为它们在实践中无济于事

大多数实现都以或多或少明显的方式将每个运算分为小尺寸矩阵或矢量运算。例如,大的1000x1000矩阵乘法可以分解为50x50矩阵乘法的序列。

这些固定大小的小尺寸操作(称为内核)使用其目标的多个CPU功能以特定于CPU的汇编代码进行硬编码:

  • SIMD样式的指令
  • 指令级并行
  • 缓存意识

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

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

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


ATLAS不再是最常用的开源BLAS实现。OpenBLAS(GotoBLAS的一个分支)和BLIS(GotoBLAS的重构)已经超越了它。
罗伯特·范·德·吉恩

1
@ ulaff.net:也许。这是六年前写的。我认为目前最快的BLAS实施(当然是在Intel上)是Intel MKL,但它不是开源的。
安德鲁·托马佐斯

14

首先,与您正在使用的算法相比,有一种更有效的矩阵乘法算法。

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

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

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

  • 您正在使用原始指针,这意味着编译器必须假定它们可能是别名。您可以指定特定于编译器的关键字或标志来告诉编译器它们不是别名。另外,您应该使用原始指针以外的其他类型来解决问题。
  • 您通过对输入矩阵的每一行/每一列进行幼稚遍历来破坏缓存。您可以使用阻塞在移入下一个块之前,在适合CPU缓存的较小矩阵矩阵上执行尽可能多的工作。
  • 对于纯数字任务,Fortran几乎是无与伦比的,并且C ++需要大量的哄骗才能达到类似的速度。可以做到这一点,并且有一些库对此进行了演示(通常使用表达式模板),但是它并非微不足道,而且并非只是发生。

谢谢,我按照Justicle的建议添加了限制正确代码的代码,没有看到太大的改进,我喜欢按块的想法。出于好奇,在不知道CPU缓存大小的情况下,如何正确选择一个最佳代码?
DeusAduro

2
你不知道 为了获得最佳代码,您需要知道CPU的缓存大小。当然,这样做的缺点是您正在有效地对代码进行硬编码,以在一个 CPU系列上获得最佳性能。
jalf

2
至少内部回路在此避免了大负荷。看起来这是为已经转置的一个矩阵编写的。这就是为什么它仅比BLAS慢一个数量级的原因!但是,是的,由于缺少缓存阻止,它仍然很麻烦。您确定Fortran会有所帮助吗?我认为您在这里所能获得的就是restrict(没有别名)是默认设置,这与C / C ++不同。(很遗憾,ISO C ++没有restrict关键字,因此您必须在将__restrict__其作为扩展名的编译器上使用)。
彼得·科德斯

11

我不太了解BLAS的实现,但是有比矩阵O(n3)更好的高效矩阵乘法运算法则。众所周知的是Strassen算法


8
由于以下两个原因,未在数字中使用Strassen算法:1)它不稳定。2)您节省了一些计算,但是随之而来的代价是您可以利用缓存层次结构。实际上,您甚至会失去性能。
Michael Lehn 2013年

4
对于紧密构建在BLAS库源代码上的Strassen算法的实际实现,最近发布了一个出版物:SC16中的“ Strassen Algorithm Reloaded ”,即使问题大小为1000x1000,它也比BLAS具有更高的性能。
Jianjian Huang

4

第二个问题的大多数论点-汇编程序,拆分为块等(但并非少于N ^ 3算法,它们确实开发过度)-发挥了作用。但是,算法的低速度主要是由矩阵大小以及三个嵌套循环的不幸排列造成的。您的矩阵太大,以至于它们无法立即放入缓存中。您可以重新排列循环,以便尽可能多地对高速缓存中的行进行处理,这样可以大大减少高速缓存的刷新(将BTW拆分为小块具有类似的效果,最好是对块上的循环进行类似布置)。下面是正方形矩阵的模型实现。在我的计算机上,与标准实现(如您自己的)相比,其时间消耗约为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替换所有实现更好(在计算机上尝试!)。但是,直接调用Fortran库的dgemm_更快(1:4)。我认为该例程实际上不是Fortran,而是汇编代码(我不知道库中有什么,我没有源代码)。我完全不清楚为什么cblas_dgemm不能这么快,因为据我所知它只是dgemm_的包装。


3

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

而且您的代码不是“ 限制正确的 ”-编译器如何知道在修改C时,它不是在修改A和B?


确定是否调用了类似mmult(A ...,A ...,A)的函数;您肯定不会得到预期的结果。再一次,尽管我没有尝试击败/重新实现BLAS,但只是看到它的速度有多快,因此不考虑错误检查,而只是基本功能。
DeusAduro

3
很抱歉,要明确地说,我的意思是,如果在指针上加上“ restrict”,则会得到更快的代码。这是因为每次修改C时,编译器都不必重新加载A和B-大大加快了内部循环的速度。如果您不相信我,请检查反汇编。
Justicle

@DeusAduro:这不是错误检查-编译器可能无法优化内部循环中对B []数组的访问,因为它可能无法确定A和C指针永远不会为B别名数组。如果存在别名,则在执行内部循环时B数组中的值可能会更改。将对B []值的访问提升到内部循环之外,并将其放在局部变量中,可使编译器避免对B []的连续访问。
Michael Burr,2009年

1
嗯,所以我首先尝试在VS 2008中使用'__restrict'关键字,将其应用于A,B和C。结果没有变化。但是,将对B的访问权限从最内部的循环移动到外部的循环可以将时间缩短约10%。
DeusAduro

1
抱歉,我不确定VC,但是要使用GCC,您需要启用-fstrict-aliasing。这里还有一个关于“限制”的更好的解释:cellperformance.beyond3d.com/articles/2006/05/…–
Justicle

2

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

加快速度的大部分来自采用循环优化技术来实现MM乘法中的三重循环功能。使用了两种主要的循环优化技术:展开和阻止。关于展开,我们展开最外面的两个循环,并对其进行阻塞,以便在缓存中重复使用数据。外循环展开通过在整个操作过程中的不同时间减少对同一数据的内存引用数量,有助于在时间上优化数据访问。将循环索引阻止为特定数量,有助于将数据保留在缓存中。您可以选择针对L2缓存或L3缓存进行优化。

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


-24

因为许多的原因。

首先,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)。有一些算法可以更好地扩展


36
对不起,这个答案是完全错误的。BLAS实现不是用fortran编写的。关键性能代码是用汇编编写的,而如今最常用的代码是用C编写的。另外,BLAS将行/列顺序指定为接口的一部分,并且实现可以处理任何组合。
Andrew Tomazos

10
是的,这个答案完全错误的。不幸的是,它充满了常见的废话,例如,由于Fortran,声称BLAS更快。获得20(!)的正面评价是一件坏事。现在,由于Stackoverflow的普及,这种废话甚至进一步蔓延!
Michael Lehn 2013年

12
我认为您正在将未优化的参考实现与生产实现相混淆。参考实现仅用于指定库的接口和行为,出于历史原因,它是用Fortran编写的。它不是用于生产。在生产中,人们使用优化的实现,这些实现表现出与参考实现相同的行为。我研究了ATLAS(支持Octave-Linux“ MATLAB”)的内部结构,可以确认第一手内部使用C / ASM编写。商业实施也几乎可以肯定。
Andrew Tomazos

5
@KyleKanos:是的,这是ATLAS的来源:sourceforge.net/projects/math-atlas/files/Stable/3.10.1 据我所知,这是最常用的开源可移植BLAS实现。它用C / ASM编写。像Intel这样的高性能CPU制造商也提供了针对其芯片进行了优化的BLAS实现。我保证英特尔库的低端部分是用(duuh)x86汇编语言编写的,而且我很确定中级部分将用C或C ++编写。
Andrew Tomazos

9
@KyleKanos:你很困惑。Netlib BLAS是参考实现。参考实现比优化实现要慢得多(请参阅性能比较)。当有人说他们在集群上使用netlib BLAS时,并不意味着他们实际上在使用netlib参考实现。那将是愚蠢的。这仅表示他们正在使用与netlib blas具有相同接口的lib。
安德鲁·托马佐斯
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.