在 Java 代码中,我可以执行哪些操作来优化 CPU 缓存?

2022-08-31 17:02:24

在编写Java程序时,我是否对CPU如何利用其缓存来存储我的数据有影响?例如,如果我有一个经常被访问的数组,如果它足够小以容纳一个高速缓存行(在 64 位计算机上通常为 128 字节),它是否有帮助?如果我将一个经常使用的对象保持在该限制内,我是否可以期望其成员使用的内存靠近并保留在缓存中?

背景:我正在构建一个压缩的数字树,它在很大程度上受到C语言中的Judy数组的启发。虽然我主要追求它的节点压缩技术,但Judy将CPU缓存优化作为中心设计目标,节点类型以及它们之间切换的启发式方法受到这一点的严重影响。我想知道我是否也有机会获得这些好处?

编辑到目前为止,答案的一般建议是,当你像在Java中一样远离机器时,不要试图对机器级细节进行微优化。我完全同意,所以觉得我必须添加一些(希望)澄清的评论,以更好地解释为什么我认为这个问题仍然有意义。这些如下:

由于计算机的构建方式,有些事情通常更容易处理。我见过Java代码在压缩数据(从内存)上运行得明显更快,即使解压缩必须使用额外的CPU周期。如果数据存储在磁盘上,很明显为什么会这样,但当然在RAM中它是相同的原理。

现在,计算机科学有很多关于这些东西的东西要说的,例如,引用的地方性在C中很好,我想它在Java中仍然很棒,如果它有助于优化运行时做更聪明的事情,也许更是如此。但是你如何完成它可能非常不同。在 C 语言中,我可能会编写代码来管理较大的内存块本身,并对相关数据使用相邻指针。

在Java中,我不能(也不想)知道很多关于特定运行时将如何管理内存的信息。因此,我还必须将优化提升到更高的抽象级别。我的问题基本上是,我该怎么做?对于引用的局部性,在我正在Java中处理的抽象级别上,“紧密结合”是什么意思?同一个对象?同一类型?相同的阵列?

总的来说,我不认为抽象层会改变“物理定律”,比喻说。在 Java 中,每次空间不足时,将数组大小加倍也是一个很好的策略,即使您不再调用也是如此。malloc()


答案 1

使用Java获得良好性能的关键是编写惯用代码,而不是试图智胜JIT编译器。如果你编写代码是为了试图影响它以某种方式在原生指令级别做事,那么你更有可能开枪打自己的脚。

这并不是说像参考的局部性这样的共同原则无关紧要。他们这样做了,但我认为数组等的使用是性能感知的惯用代码,但不是“棘手的”。

HotSpot和其他优化运行时在如何优化特定处理器的代码方面非常聪明。(有关示例,请查看此讨论。如果我是一个专业的机器语言程序员,我会写机器语言,而不是Java。如果我不是,那么认为我可以在优化代码方面比专家做得更好是不明智的。

此外,即使您确实知道为特定CPU实现某些内容的最佳方法,Java的美妙之处在于“一次写入”,即在任何地方运行。“优化”Java代码的巧妙技巧往往使JIT更难识别优化机会。遵循常见习惯用语的简单代码更易于优化程序识别。因此,即使您为测试平台获得了最好的Java代码,该代码也可能在不同的体系结构上执行得很差,或者充其量也无法利用未来JIT中的增强功能。

如果您想要良好的性能,请保持简单。真正聪明的团队正在努力实现它的速度。


答案 2

如果您正在处理的数据主要或完全由基元组成(例如,在数字问题中),我会建议如下。

在初始化时分配固定大小的基元数组的平面结构,并确保其中的数据定期压缩/碎片整理(0->n,其中n是给定元素计数的最小最大索引),以使用for循环进行迭代。这是在 Java 中保证连续分配的唯一方法,压缩进一步提高了引用的局部性。压缩是有益的,因为它减少了迭代未使用元素的需要,减少了条件的数量:当for循环迭代时,终止发生得更早,迭代次数越少=在堆中移动越少=缓存未命中的机会越少。虽然压缩本身会产生开销,但如果您愿意,则只能定期执行此操作(相对于主要处理区域)。

更好的是,您可以在这些预分配的数组中交错值。例如,如果您正在表示 2D 空间中数千个实体的空间变换,并且正在处理每个实体的运动方程,则可能会有一个紧密循环,例如

int axIdx, ayIdx, vxIdx, vyIdx, xIdx, yIdx;

//Acceleration, velocity, and displacement for each
//of x and y totals 6 elements per entity.
for (axIdx = 0; axIdx < array.length; axIdx += 6) 
{
    ayIdx = axIdx+1;
    vxIdx = axIdx+2;
    vyIdx = axIdx+3;
    xIdx = axIdx+4;
    yIdx = axIdx+5;

    //velocity1 = velocity0 + acceleration 
    array[vxIdx] += array[axIdx];
    array[vyIdx] += array[ayIdx];

    //displacement1 = displacement0 + velocity
    array[xIdx] += array[vxIdx];
    array[yIdx] += array[vxIdx];
}

此示例忽略了诸如使用其关联的 (x,y) ...渲染总是需要非基元(因此,引用/指针)。如果您确实需要这样的对象实例,那么您就无法再保证引用的局部性,并且可能会在堆中到处跳转。因此,如果您可以将代码拆分为具有原始密集型处理的部分,如上所示,那么此方法将对您有很大帮助。至少对于游戏来说,AI、动态地形和物理可能是处理器最密集的方面,而且都是数字,所以这种方法可能非常有益。


推荐