矩阵乘法:矩阵大小差异小,时序差异大


76

我有一个矩阵乘法代码,如下所示:

for(i = 0; i < dimension; i++)
    for(j = 0; j < dimension; j++)
        for(k = 0; k < dimension; k++)
            C[dimension*i+j] += A[dimension*i+k] * B[dimension*k+j];

在此,矩阵的大小由表示dimension。现在,如果矩阵的大小为2000,则运行此代码需要147秒,而如果矩阵的大小为2048,则需要447秒。所以虽然没有区别。的乘积为(2048 * 2048 * 2048)/(2000 * 2000 * 2000)= 1.073,时间差为447/147 =3。有人可以解释为什么会这样吗?我希望它可以线性扩展,但不会发生。我不是在尝试制作最快的矩阵乘法代码,只是在试图理解为什么会这样。

规格:AMD Opteron双核节点(2.2GHz),2G RAM,gcc v 4.5.0

程序编译为 gcc -O3 simple.c

我也在英特尔的icc编译器上运行了此命令,并且看到了类似的结果。

编辑:

正如评论/答案中所建议的那样,我运行的维度为2060的代码需要145秒。

这是完整的程序:

#include <stdlib.h>
#include <stdio.h>
#include <sys/time.h>

/* change dimension size as needed */
const int dimension = 2048;
struct timeval tv; 

double timestamp()
{
        double t;
        gettimeofday(&tv, NULL);
        t = tv.tv_sec + (tv.tv_usec/1000000.0);
        return t;
}

int main(int argc, char *argv[])
{
        int i, j, k;
        double *A, *B, *C, start, end;

        A = (double*)malloc(dimension*dimension*sizeof(double));
        B = (double*)malloc(dimension*dimension*sizeof(double));
        C = (double*)malloc(dimension*dimension*sizeof(double));

        srand(292);

        for(i = 0; i < dimension; i++)
                for(j = 0; j < dimension; j++)
                {   
                        A[dimension*i+j] = (rand()/(RAND_MAX + 1.0));
                        B[dimension*i+j] = (rand()/(RAND_MAX + 1.0));
                        C[dimension*i+j] = 0.0;
                }   

        start = timestamp();
        for(i = 0; i < dimension; i++)
                for(j = 0; j < dimension; j++)
                        for(k = 0; k < dimension; k++)
                                C[dimension*i+j] += A[dimension*i+k] *
                                        B[dimension*k+j];

        end = timestamp();
        printf("\nsecs:%f\n", end-start);

        free(A);
        free(B);
        free(C);

        return 0;
}

9
您理解的关键可能是矩阵乘法不是线性扩展的,您的代码约为O(n^3)
英国广播公司

6
考虑到2048的二次幂,可能与缓存有关吗?
Christian Rau

11
@brc我不知道这与他的问题有什么关系。他完全意识到自己算法的复杂性。你甚至读过这个问题吗?
Christian Rau

3
尝试使用维度= 2060进行测试-这将告诉您问题是否与例如缓存大小有关,或者是否是超对齐问题,例如缓存颠簸或TLB颠簸。
Paul R

2
请注意,对这些矩阵之一进行转置(可以在适当位置完成)将为这些典型大小带来更好的结果(收支平衡点可能会有所不同)。确实,转置为O(n ^ 2)(与O(n ^ 3)乘积),并且对两个矩阵顺序访问内存,从而更好地使用了高速缓存。
Alexandre C.

Answers:


83

这是我的疯狂猜测:缓存

可能是您可以将2行2000 doubles放入缓存中。略小于32kb L1缓存。(同时留出其他必要的空间)

但是当您将其增加到2048时,它会使用整个缓存(并且由于需要其他空间而浪费了一些缓存)

假设高速缓存策略是LRU,则将高速缓存仅溢出一小部分将导致整个行被重复刷新并重新加载到L1高速缓存中。

另一种可能是由于2的幂导致的缓存关联性。尽管我认为处理器是2路L1关联的,所以在这种情况下我认为这并不重要。(但我还是会把这个想法丢掉)

可能的解释2:由于L2缓存上的超对齐,冲突缓存未命中。

您的B数组正在列上进行迭代。这样访问就大步前进了。2k x 2k每个矩阵的总数据大小约为32 MB。这比您的L2缓存大得多。

当数据不完全对齐时,您将在B上具有适当的空间局部性。尽管您要跳行并且每个高速缓存行仅使用一个元素,但是高速缓存行仍保留在L2高速缓存中,以供中间循环的下一次迭代重用。

但是,当数据完全对齐(2048)时,这些跃点将全部以相同的“缓存方式”着陆,并且将远远超过您的L2缓存关联性。因此,所访问的缓存行B不会在下一次迭代中保留在缓存中。相反,它们将需要从ram一直拉出。


3
我同意怀疑缓存。您可以进行一组实验,并绘制运行时与尺寸的关系图。如果是高速缓存,您会在相似大小的附近看到线性,但有一些尖锐的断点,这会使您大步前进并改变线性斜率。
TJD 2011年

2
不只是缓存大小-当矩阵在2048种情况下进行超级对齐时,您可以开始看到缓存颠簸,TLB颠簸等问题。尝试以2060为例,看看会发生什么...
Paul R

我以Dimension = 2060运行了它,花了145秒。从解释2来看,这也应该是不良的空间位置。对于尺寸> = 2048,需要从RAM中获取B的缓存行,对吗?
2011年

2
@AhmedMasud我也不认为使用可以times解释他的问题。
克里斯蒂安·劳

3
由于高速缓存的工作方式,一个N向高速缓存最多只能容纳具有相同地址以大2的幂为模的N条高速缓存行。(除非您告诉我您拥有哪种处理器型号,否则我不知道确切的数字。)当N = 2048时,b所有人访问的高速缓存行的地址都具有相同的模数,并且是2的幂。这样他们就会发生冲突。(Google:“冲突缓存
丢失

33

您肯定会得到我所说的缓存共振。这类似于别名,但不完全相同。让我解释。

缓存是一种硬件数据结构,它提取地址的一部分并将其用作表中的索引,这与软件中的数组不同。(实际上,我们在硬件中称它们为数组。)缓存数组包含数据和标记的缓存行-有时数组中每个索引有一个这样的条目(直接映射),有时是几个这样的(N向集关联性)。提取地址的第二部分,并将其与存储在阵列中的标签进行比较。索引和标签一起唯一地标识了高速缓存行存储器地址。最后,其余的地址位将标识高速缓存行中的哪些字节以及访问的大小。

通常,索引和标签是简单的位域。所以一个内存地址看起来像

  ...Tag... | ...Index... | Offset_within_Cache_Line

(有时索引和标签是散列,例如,其他几位与索引的中间范围位进行了异或运算。很少有的时候,有时是索引,也很少有标签,例如将高速缓存行地址取模质数。这些更复杂的索引计算是为了解决共振问题,我将在此处进行解释。所有方法都遭受某种形式的共振,但是最简单的位域提取方案在常见的访问模式下也会产生共振。

因此,典型值...“ Opteron Dual Core”的型号很多,在这里我看不到任何指定您拥有的型号。我随机选择一个最新的手册,我在AMD网站上看到了有关适用于AMD系列15h型号00h-0Fh的Bios和内核开发人员指南(BKDG),2012年3月12日。

(家庭15h = Bulldozer系列,最新的高端处理器-BKDG提到了双核,尽管我不知道确切描述的产品编号。但是,无论如何,相同的共振思想适用于所有处理器,只是缓存大小和关联性等参数可能会有所不同。)

从第33页起:

AMD系列15h处理器包含一个带有两个128位端口的16 KB 4路预测L1数据高速缓存。这是一个直写式高速缓存,每个周期最多支持两个128字节的加载。它分为16个存储区,每个存储区宽16个字节。[...]在一个周期内,只能从给定的L1缓存库中执行一次加载。

总结一下:

  • 64字节缓存行=>缓存行内有6个偏移量位

  • 16KB / 4路=>谐振为4KB。

    即地址位0-5是缓存行偏移量。

  • 16KB / 64B高速缓存行=> 2 ^ 14/2 ^ 6 = 2 ^ 8 = 256个高速缓存中的高速缓存行。
    (错误修复:我最初将其错误计算为128。我已经修复了所有依赖项。)

  • 高速缓存阵列中的4种方式关联=> 256/4 = 64个索引。我(英特尔)称这些为“集合”。

    也就是说,您可以将缓存视为由32个条目或集合组成的数组,每个条目包含4个缓存行及其标签。(比这还复杂,但这没关系)。

(顺便说一句,术语“集合”和“方式”具有不同的定义。)

  • 最简单的方案中有6个索引位,即位6-11。

    这意味着在索引位(位6-11)中具有完全相同值的所有高速缓存行将映射到相同的高速缓存集。

现在看一下您的程序。

C[dimension*i+j] += A[dimension*i+k] * B[dimension*k+j];

循环k是最里面的循环。基本类型是双精度8个字节。如果Dimension = 2048,即2K,则B[dimension*k+j]循环访问的连续元素将相隔2048 * 8 = 16K字节。它们都将映射到L1高速缓存的同一集合-它们在高速缓存中都具有相同的索引。这意味着,在高速缓存中没有可用的256个高速缓存行,而只有4-高速缓存的“四向关联”。

也就是说,您可能会在此循环中每4次迭代遇到一次缓存未命中的情况。不好。

(实际上,情况要复杂一些。但是,上面的内容是一个很好的初步理解。上面提到的B条目的地址是虚拟地址。因此物理地址可能略有不同。此外,Bulldozer拥有一种预测性缓存的方式,可能使用虚拟地址位,这样就不必等待虚拟地址到物理地址的转换,但是无论如何:您的代码具有16K的“共振”,L1数据缓存具有16K的共振。 。)]

如果仅稍稍更改维度,例如更改为2048 + 1,则数组B的地址将散布在所有缓存集合中。而且您将显着减少缓存未命中率。

填充阵列是一种相当普遍的优化方法,例如将2048更改为2049,以避免这种共振。但是“缓存阻止是一个更为重要的优化。http://suif.stanford.edu/papers/lam-asplos91.pdf


除了高速缓存线谐振,这里还有其他事情。例如,L1高速缓存具有16个存储区,每个存储区宽16个字节。维度= 2048时,内循环中连续的B访问将始终进入同一存储体。因此它们不能并行运行-如果A访问恰好进入同一银行,您将输掉。

从外观上看,我认为这没有缓存共鸣那么大。

而且,是的,可能会有混叠现象。例如,STLF(存储到加载转发缓冲区)可能仅使用较小的位域进行比较,并且得到错误的匹配。

(实际上,如果考虑一下,缓存中的共振就像别名,与位域的使用有关。共振是由映射同一集合的多个缓存行引起的,不会被散布。别名是由基于不完整地址的匹配引起的位。)


总体而言,我的调优建议:

  1. 尝试进行缓存阻止,无需进一步分析。我之所以这样说是因为缓存阻塞很容易,这很可能就是您所要做的。

  2. 之后,使用VTune或OProf。或Cachegrind。要么 ...

  3. 更好的是,使用调整良好的库例程来进行矩阵乘法。


2
非常有趣的答案(+1),但格式和编辑却很糟糕:)我已尽最大努力对其进行了一些改进。
UncleZeiv 2012年

真好 小错字:256个的高速缓存行,而不是128
塔耶

感谢捕获:2 ^ 8 =256。我将尝试更正,但是我敢打赌我不会捕获所有依赖项。回到我在英特尔工作时,我写了一点“自由文本电子表格”,该文本允许将公式放在文本中:输入新数字,然后传播修正。(我是在本科生写的;也许我可以复活。)
Krazy Glew,2014年

17

有几种可能的解释。一种可能的解释是Mysticial的建议:耗尽有限的资源(高速缓存或TLB)。另一种可能的可能性是错误的别名停顿,当连续的内存访问被某些2的倍数(通常为4KB)的倍数分隔时,就会发生这种情况。

您可以通过绘制一系列值的时间/维度^ 3,来缩小工作范围。如果您炸毁了缓存或TLB耗尽,则将看到一个或多或少的平坦部分,然后在2000到2048之间急剧上升,然后是另一个平坦部分。如果您看到与锯齿相关的停顿,您将看到一个或多或少的平面图,在2048处有一个尖峰。

当然,这具有诊断能力,但不是结论性的。如果您想确切地知道减速的根源是什么,您将需要了解性能计数器,它们可以明确回答此类问题。


+1,我什至从未听说过这种情况下的假锯齿。但是从硬件设计方面考虑,这是有道理的。
Mysticial 2011年

9

几个答案提到了L2缓存问题。

您实际上可以使用缓存模拟验证这一点。Valgrind的cachegrind工具可以做到这一点。

valgrind --tool=cachegrind --cache-sim=yes your_executable

设置命令行参数,使其与您的CPU的L2参数匹配。

用不同的矩阵尺寸进行测试,您可能会发现L2丢失率突然增加。


9

我知道这太老了,但我会咬一口。(已经说过)缓存问题导致速度减慢到大约2的幂。但这还有另一个问题:太慢了。如果您看一下您的计算循环。

for(i = 0; i < dimension; i++)
    for(j = 0; j < dimension; j++)
        for(k = 0; k < dimension; k++)
            C[dimension*i+j] += A[dimension*i+k] * B[dimension*k+j];

最内层的循环每次迭代将k改变1,这意味着您距离A使用的最后一个元素仅1倍,整个“维度”距离B的最后一个元素则增加了一倍。这并没有利用B元素的缓存。

如果将其更改为:

for(i = 0; i < dimension; i++)
    for(j = 0; j < dimension; j++)
        for(k = 0; k < dimension; k++)
            C[dimension*i+k] += A[dimension*i+j] * B[dimension*j+k];

您会得到完全相同的结果(模加法加法关联性错误),但是缓存友好性更高(local)。我尝试了一下,它带来了实质性的改进。可以总结为

不要按定义乘矩阵,而是按行乘


加速示例(我更改了代码以将尺寸作为参数)

$ diff a.c b.c
42c42
<               C[dimension*i+j] += A[dimension*i+k] * B[dimension*k+j];
---
>               C[dimension*i+k] += A[dimension*i+j] * B[dimension*j+k];
$ make a
cc     a.c   -o a
$ make b
cc     b.c   -o b
$ ./a 1024

secs:88.732918
$ ./b 1024

secs:12.116630

值得一提的是(这个问题与此相关)的原因是,该循环不会遇到先前的问题。

如果您已经知道所有这些,那么对不起!


+1更好的算法总是会带来更大的不同-不管哪种类型的缓存(甚至有一个),它都更快。
杰里·耶利米
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.