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

时间:2011-10-26 16:28:12

标签: c performance algorithm matrix-multiplication

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

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;
}

5 个答案:

答案 0 :(得分:81)

这是我的猜测:缓存

可能是您可以将2行2000 double放入缓存中。这比32kb的L1缓存要小得多。 (同时留出其他必要的东西)

但是当你把它提升到2048时,它会使用 整个 缓存(并且你会溢出一些因为你需要其他东西的空间)

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

另一种可能性是由于二次幂而导致的缓存关联性。虽然我认为处理器是双向L1关联的,所以在这种情况下我认为不重要。 (但无论如何我都会把这个想法扔出去)

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

您的B数组正在列上进行迭代。因此访问是跨越式的。您的总数据大小为2k x 2k,每个矩阵大约为32 MB。这比你的二级缓存大得多。

当数据未完全对齐时,您将在B上具有合适的空间局部性。虽然您正在跳跃行并且每个高速缓存行只使用一个元素,但是高速缓存行保留在L2高速缓存中以便在下一次中间迭代时重用循环。

然而,当数据完全对齐时(2048),这些跳跃将全部落在相同的“缓存方式”上,并且将远远超过您的L2缓存关联性。因此,B的访问缓存行不会保留在缓存中以进行下一次迭代。 相反,他们需要从ram中完全拉入。

答案 1 :(得分:31)

你肯定会得到我称之为缓存共振的内容。这类似于别名,但不完全相同。让我解释一下。

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

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

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

(有时索引和标记是哈希值,例如其他位的几个XOR到作为索引的中间位中。更少见,有时是索引,更少有标记,就像采取缓存行一样这些更复杂的指数计算试图解决共振问题,我在这里解释。所有这些都会受到某种形式的共振,但最简单的位域提取方案会对常见的访问模式产生共振,正如您所发现的那样。 )

所以,典型的价值......有很多不同型号的&#34; Opteron Dual Core&#34;,我在这里看不到任何指定你拥有的东西。随机选择一本,我在2012年3月12日的AMD网站Bios and Kernel Developer's Guide (BKDG) for AMD Family 15h Models 00h-0Fh上看到的最新手册。

(家庭15h = Bulldozer家族,最新的高端处理器--BKDG提到双核心,虽然我不知道产品编号正是你所描述的。但是,无论如何,共振的想法一致适用于所有处理器,只是缓存大小和关联性等参数可能会有所不同。)

从第33页开始:

  

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

总结一下:

  • 64字节缓存行=&gt;高速缓存行中的6个偏移位

  • 16KB / 4-way =&gt;共振为4KB。

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

  • 16KB / 64B缓存行=&gt; 2 ^ 14/2 ^ 6 = 2 ^ 8 =缓存中的256个缓存行 (修正:我最初把它误算为128.我修复了所有依赖项。)

  • 4 way associative =&gt; 256/4 =缓存阵列中的64个索引。我(英特尔)称这些&#34;设置&#34;。

    即。您可以将缓存视为包含32个条目或集合的数组,每个条目包含4个缓存行和其标记。 (它比这更复杂,但没关系。)

(顺便说一句,条款&#34;设置&#34;和&#34;方式&#34;有varying definitions。)

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

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

现在看看你的节目。

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

循环k是最里面的循环。基类型为double,8个字节。如果dimension = 2048,即2K,则循环访问的B[dimension*k+j]的连续元素将相隔2048 * 8 = 16K字节。它们都将映射到同一组L1缓存 - 它们在缓存中都具有相同的索引。这意味着,缓存中没有256个缓存行可供使用,而只有4个 - #4; 4向关联性&#34;缓存。

即。你可能会在这个循环周围的每4次迭代中得到一个缓存未命中。不好。

(实际上,情况稍微复杂一些。但以上是一个很好的初步了解。上面提到的B的条目地址是一个虚拟地址。所以物理地址可能略有不同。而且,Bulldozer有办法预测缓存,可能使用虚拟地址位,因此它不必等待虚拟到物理地址转换。但是,在任何情况下:您的代码都具有16K的共振性能。数据缓存的共振为16K。不好。)]

如果您稍微更改尺寸,例如到2048 + 1,然后阵列B的地址将分布在所有缓存集中。而且你会得到更少的缓存未命中。

填充数组是一种相当常见的优化,例如改变2048到2049年,以避免这种共振。但是&#34;缓存阻塞是一个更重要的优化。 http://suif.stanford.edu/papers/lam-asplos91.pdf


除了缓存线共振之外,还有其他事情在这里发生。例如,L1高速缓存具有16个库,每个库宽16个字节。当维度= 2048时,内循环中的连续B访问将始终到达同一个库。所以他们无法并行 - 如果A访问恰好进入同一家银行,你就会失败。

我不认为,看着它,这与缓存共振一样大。

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

(实际上,如果你考虑一下,缓存中的共振就像是混叠,与位域的使用有关。共振是由多个缓存线映射同一组引起的,而不是传播的.Aliisaing是由基于匹配引起的在不完整的地址位上。)


总体而言,我对调整的建议:

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

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

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

答案 2 :(得分:17)

有几种可能的解释。一个可能的解释是 Mysticial 暗示:有限资源(缓存或TLB)耗尽。另一种可能的可能是伪混叠失速,当连续的存储器访问被一些2的幂(通常是4KB)的倍数分开时,可能会发生这种错误。

您可以通过为一系列值绘制时间/维度^ 3来开始缩小工作范围。如果你已经吹掉缓存或疲惫的TLB范围,你会看到一个或多或少的平坦部分,然后是2000年到2048年之间的急剧上升,接着是另一个平坦部分。如果你看到与别名相关的档位,你会看到一个或多或少的平面图,在2048处有一个窄的尖峰。

当然,这具有诊断能力,但尚无定论。如果您想最终了解减速的来源,您将需要了解性能计数器,它可以明确地回答这类问题。

答案 3 :(得分:8)

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

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

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

设置command line parameters,使它们与CPU的L2参数匹配。

使用不同的矩阵大小进行测试,您可能会看到L2未命中率的突然增加。

答案 4 :(得分:8)

我知道这太久了,但我会咬一口。正如所说的那样(缓存问题)是导致两次幂减速的原因。但是还有另外一个问题:它太慢了。如果你看看你的计算循环。

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 的最后一个元素,但是整个'维度'的距离是最后一个元素的两倍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

作为奖励(以及与此问题相关的原因)是这个循环不会遇到上一个问题。

如果您已经知道所有这些,那么我道歉!