在前一篇文章Java的大内存分页支持,曾经谈及Java矩阵乘法程序效率低下的两个原因,Cache Miss和TLB Miss。在那篇文章中,我们通过使用大内存分页,消除了TLB Miss对性能的影响,性能因而提高了60%以上(76秒 -> 45 秒)。但Cache Miss对性能的影响依旧存在。
CPU的一级数据缓存(L1 Data Cache)通常采用组相联的方式来缓存数据,数据缓存是以Cache Line为单位进行的,即缓存不命中时,相邻的一组数据将被载入Cache Line,而不仅仅是当前数据。Core 2架构处理器L1 Data Cache是8路组相联的缓存,每个Cache Line大小为64字节,总共是32K字节。所以,为减少Cache Miss的出现,每个程序指令读取的数据最好是相邻的。而在传统的矩阵乘法程序中,却恰恰违背了这一点。
for (int i = 0; i < 2048; i++)
for (int j = 0; j < 2048; j++)
for (int k = 0; k < 2048; k++)
res[i][j] += mul1[i][k] * mul2[k][j];
每次乘法运算中,mul1二维数组所取的值都与上一次乘法中所取的值相距8KB(2048*4)之远。显然,几乎每次乘法运算中都将出现一次Cache Miss。通过改进算法,我们可以保证每次取值都相邻,从而大大减少Cache Miss的可能。
for (int i = 0; i < 2048; i++)
for (int k = 0; k < 2048; k++)
for (int j = 0; j < 2048; j++)
res[i][j] += mul1[i][k] * mul2[k][j];
很简单,只需将循环中J和K的位置调换一下,就能够保证所有的数组(res, mul1, mul2)每次取值都与上一次相邻。最终,程序因为Cache Miss的大大减少而效率大增。改进后的程序,运行时间为13秒,性能比使用大内存页的情况还提高了两倍多(45秒 -> 13秒)。实际上,改进后的程序还带来另外一个影响,即由于读取的数据位置相邻,TLB Miss的频率也大为下降。经测试,同样的程序在大内存页(2M)和普通内存页(4K)的情况下,运行性能是一样的,均耗时13秒。
Cache Miss和TLB Miss的解决似乎都很简单,但要如何发现这些问题却不容易,许多Java性能监控工具通常也不能给出相关的分析。接下来的文章,将介绍如何发现这些微妙的Java性能问题。