关于C#:矩阵乘法:矩阵大小差异小,时序差异大

Matrix multiplication: Small difference in matrix size, large difference in timings

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

1
2
3
4
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秒。

以下是完整的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#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("
secs:%f
"
, end-start);

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

        return 0;
}


这是我的疯狂猜测:缓存

可以将2行2000 double放入缓存中。略小于32kb的一级缓存。(离开房间时其他必要的东西)

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

假设缓存策略为lru,只溢出一点点缓存将导致整个行重复刷新并重新加载到l1缓存中。

另一种可能是缓存关联性,这是由于二者的强大功能。虽然我认为处理器是双向的L1关联的,所以我认为在这种情况下它并不重要。(但我还是要把这个想法抛到一边)

可能的解释2:二级缓存上的超级对齐导致冲突缓存未命中。

您的B数组正在该列上迭代。所以进入是大步前进的。您的总数据大小是2k x 2k,每个矩阵大约32MB。这比二级缓存大得多。

当数据没有完全对齐时,您将在B上拥有合适的空间位置。尽管您正在跳行,并且每个缓存线只使用一个元素,但缓存线仍保留在二级缓存中,以便在中间循环的下一次迭代中重用。

但是,当数据完全对齐(2048)时,这些跃点都将以相同的"缓存方式"着陆,并且将远远超出二级缓存的关联性。因此,B的访问缓存线在下一次迭代中不会停留在缓存中。相反,它们将需要从RAM一直拉过来。


你肯定得到了我所说的缓存共振。这类似于别名,但并不完全相同。让我解释一下。好的。

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

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

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

(有时,索引和标记是散列,例如,将其他位的一些XOR转换为作为索引的中档位。更为罕见的是,有时索引,更为罕见的是标记,比如将缓存线地址模取为质数。这些更复杂的指数计算试图解决共振问题,我在这里解释。它们都会受到某种形式的共振,但是最简单的位场提取方案会受到常见访问模式的共振,正如您所发现的那样。)好的。

所以,典型值…"Opteron双核"有许多不同的型号,我在这里没有看到任何具体说明您拥有哪一个的型号。随机选择一本,我在AMD网站上看到的最新手册,适用于AMD系列15H型号00H-0FH的BIOS和内核开发人员指南(BKDG),2012年3月12日。好的。

(15H系列=推土机系列,最新的高端处理器-BKDG提到双核,尽管我不知道产品编号,这正是您所描述的。但是,不管怎样,共振的概念同样适用于所有的处理器,只是缓存大小和关联性等参数可能会有所不同。)好的。

来自P.33:好的。

The AMD Family 15h processor contains a 16-Kbyte, 4-way predicted L1
data cache with two 128- bit ports. This is a write-through cache that
supports up to two 128 Byte loads per cycle. It is divided into 16
banks, each 16 bytes wide. [...] Only one load can be performed from a
given bank of the L1 cache in a single cycle.

Ok.

总而言之:好的。

  • 64字节缓存线=>缓存线内6个偏移位好的。

  • 16kb/4路=>共振为4kb。好的。

    即地址位0-5是缓存线偏移量。好的。

  • 16kb/64b缓存线=>2^14/2^6=2^8=256缓存线。(修正错误:我最初把这个算错了128。我已经修复了所有依赖项。)好的。

  • 4路关联=>256/4=64个索引在缓存数组中。我(英特尔)称这些为"套"。好的。

    也就是说,您可以将缓存视为32个条目或集合的数组,每个条目包含4条缓存线及其标记。(比这更复杂,不过没关系)。好的。

(顺便说一下,术语"set"和"way"有不同的定义。)好的。

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

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

现在看看你的程序。好的。

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

循环k是最里面的循环。基本类型是双字节,8字节。如果dimension=2048,即2K,那么循环访问的B[dimension*k+j]的连续元素将间隔2048*8=16K字节。它们都将映射到同一组一级缓存——它们在缓存中都有相同的索引。这意味着,缓存中没有256条缓存线可供使用,而是只有4条缓存的"4向关联性"。好的。

也就是说,在这个循环中,每隔4次迭代,您可能会错过一次缓存。不好的。好的。

(事实上,事情有点复杂。但以上是一个很好的第一理解。上述b条目的地址为虚拟地址。所以物理地址可能略有不同。此外,推土机有一种方式预测缓存,可能使用虚拟地址位,这样它就不必等待虚拟到物理地址的转换。但是,在任何情况下:您的代码的"共振"值为16K。一级数据缓存的共振值为16K。不好。)]好的。

如果只将维度更改一点点,例如更改为2048+1,则数组B的地址将分布在所有缓存集上。而且,您将获得更少的缓存未命中。好的。

填充阵列是一种相当常见的优化,例如将2048更改为2049,以避免这种共振SRT。但是"缓存阻塞是一个更重要的优化。http://suif.斯坦福.edu/papers/lam-asplos91.pdf好的。

除了缓存线共振,这里还有其他事情。例如,一级缓存有16个银行,每个银行有16个字节宽。当尺寸=2048时,内部回路中的连续B访问将始终转到同一个银行。所以他们不能并行进行-如果A访问恰好转到同一个银行,您将丢失。好的。

我不认为,看看它,这是大缓存共振。好的。

而且,是的,可能有化名。例如,STLF(存储到加载转发缓冲区)可能只使用一个小的位字段进行比较,并得到错误的匹配。好的。

(实际上,如果您考虑一下,缓存中的共振就像是别名,与位字段的使用有关。共振是由多个缓存线映射到同一个集合而不是展开或结束引起的。Alisaing是由基于不完整地址位的匹配引起的。)好的。

总的来说,我对调优的建议是:好的。

  • 尝试缓存阻塞而不进行任何进一步的分析。我这么说是因为缓存阻塞很容易,而且很可能这就是您所需要做的。好的。

  • 之后,使用vtune或oprof。抑或是诡计多端。或者…好的。

  • 更好的是,使用一个调优良好的库例程来进行矩阵乘法。好的。

  • 好啊。


    有几种可能的解释。一个可能的解释是神秘的暗示:有限资源(缓存或TLB)的耗尽。另一种可能的情况是假混叠暂停,当连续的内存访问被一些二次幂的倍数(通常为4KB)分隔时,可能会出现这种情况。

    您可以通过绘制一系列值的时间/维度^3来缩小工作范围。如果你已经打开了一个高速缓存或者已经用尽了TLB的覆盖范围,你将会看到一个或多或少平坦的部分,然后在2000年到2048年间急剧上升,接着是另一个平坦的部分。如果您看到与锯齿相关的暂停,您将看到一个或多或少平坦的图形,在2048年有一个狭窄的尖峰向上。

    当然,这是有诊断力的,但不是决定性的。如果您想要最终知道减速的根源是什么,那么您需要了解性能计数器,它可以明确地回答这类问题。


    我知道这太老了,不过我要咬一口。这是(正如人们所说)一个缓存问题,是什么导致了2倍左右的速度放缓。但还有一个问题:太慢了。如果你看你的计算循环。

    1
    2
    3
    4
    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元素的缓存。

    如果您将此更改为:

    1
    2
    3
    4
    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];

    您得到了完全相同的结果(模双加法关联性错误),但它更适合缓存(本地)。我试过了,它有了很大的改进。这可以概括为

    Don't multiply matrices by definition, but rather, by rows

    加速示例(我更改了您的代码以将维度作为参数)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    $ 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

    作为一个额外的好处(以及使这个问题相关的原因)是这个循环不受前面问题的影响。

    如果你已经知道这一切,那么我道歉!


    一些答案提到了二级缓存问题。

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

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

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

    用不同的矩阵大小测试它,你可能会看到二级未命中率突然增加。