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]; |
这里,矩阵的大小用
规格:AMD Opteron双核节点(2.2GHz),2G RAM,GCC V 4.5.0
编译为
我也在英特尔的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
但是当你把它提升到2048年时,它会使用整个缓存(你会溢出一些,因为你需要其他东西的空间)
假设缓存策略为lru,只溢出一点点缓存将导致整个行重复刷新并重新加载到l1缓存中。
另一种可能是缓存关联性,这是由于二者的强大功能。虽然我认为处理器是双向的L1关联的,所以我认为在这种情况下它并不重要。(但我还是要把这个想法抛到一边)
可能的解释2:二级缓存上的超级对齐导致冲突缓存未命中。
您的
当数据没有完全对齐时,您将在B上拥有合适的空间位置。尽管您正在跳行,并且每个缓存线只使用一个元素,但缓存线仍保留在二级缓存中,以便在中间循环的下一次迭代中重用。
但是,当数据完全对齐(2048)时,这些跃点都将以相同的"缓存方式"着陆,并且将远远超出二级缓存的关联性。因此,
你肯定得到了我所说的缓存共振。这类似于别名,但并不完全相同。让我解释一下。好的。
缓存是硬件数据结构,它提取地址的一部分并将其用作表中的索引,与软件中的数组不同。(实际上,我们在硬件中称它们为数组。)缓存数组包含缓存数据行和标记-有时数组中每个索引都有一个这样的条目(直接映射),有时有几个这样的条目(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,那么循环访问的
也就是说,在这个循环中,每隔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参数匹配。
用不同的矩阵大小测试它,你可能会看到二级未命中率突然增加。