【发布时间】:2014-03-09 07:16:37
【问题描述】:
我写了一个简单的benchmark 来确定当数组通过按位与计算时是否可以消除边界检查。这基本上是几乎所有哈希表所做的:它们计算
h & (table.length - 1)
作为table 的索引,其中h 是hashCode 或派生值。 results 表明边界检查没有被消除。
我的基准测试的想法非常简单:计算两个值 i 和 j,保证两者都是有效的数组索引。
-
i是循环计数器。当它被用作数组索引时,边界检查就被消除了。 -
j被计算为x & (table.length - 1),其中x是每次迭代时都会发生变化的一些值。当它被用作数组索引时,边界检查不会被消除。
相关部分如下:
for (int i=0; i<=table.length-1; ++i) {
x += result;
final int j = x & (table.length-1);
result ^= i + table[j];
}
其他实验使用
result ^= table[i] + j;
相反。时间上的差异可能是 15%(在我尝试过的不同变体中非常一致)。我的问题:
- 除了绑定检查消除之外,还有其他可能的原因吗?
- 是否有一些复杂的原因我无法理解为什么
j没有绑定检查消除?
答案总结
MarkoTopolnik 的回答表明这一切都更加复杂,并且不能保证消除边界检查是成功的,尤其是在他的计算机上,“正常”代码比“屏蔽”代码慢。我猜这是因为它允许一些额外的优化,这在这种情况下实际上是有害的(考虑到当前 CPU 的复杂性,编译器甚至很难确定)。
leventov 的回答清楚地表明,数组边界检查是在“屏蔽”中完成的,并且它的消除使代码与“正常”一样快。
Donal Fellows 指出这样一个事实,即屏蔽不适用于零长度表,因为 x & (0-1) 等于 x。所以编译器能做的最好的事情就是用零长度检查代替边界检查。但恕我直言,这仍然值得,因为零长度检查可以轻松移出循环。
建议优化
由于等价的a[x & (a.length - 1)] 抛出当且仅当a.length == 0,编译器可以执行以下操作:
- 对于每个数组访问,检查索引是否已通过按位与计算。
- 如果是,请检查任一操作数是否计算为长度减一。
- 如果是这样,请将边界检查替换为零长度检查。
- 让现有的优化来处理它。
这样的优化应该非常简单且便宜,因为它只查看SSA 图中的父节点。与许多复杂的优化不同,它永远不会是有害的,因为它只是用稍微简单的检查代替了一项检查;所以没有问题,即使它不能移出循环。
我会将其发布到热点开发邮件列表。
新闻
【问题讨论】:
-
我看到了一个可能的原因:
table[i]导致顺序访问模式,而table[j]则更加不规则。仅仅一两次缓存未命中就足以造成 15% 的差异。 -
顺便说一句,
-XX:CompileCommand=print,*Benchmark.time*选项除了过滤掉您不感兴趣的所有内容外,还提供了更好的打印输出(不显示实际寄存器名称的占位符)。 -
这个link 倾向于暗示只有当“数组由索引变量的线性函数索引”时,HotSpot 才消除检查。
-
@MarkoTopolnik:这很奇怪,你能把你的代码贴在某个地方吗?关于上面提到的“获取下一个值”:我将
x += i替换为x += 1,这样访问是顺序的,除了一次环绕,但没有太大变化。我也试过消除x,设置j = i & (table.length-1),相当于j = i,但似乎阻止了绑定检查消除。 -
你试过
x % (table.length-1)而不是x & (table.length-1)吗?也许编译器不够聪明,无法在编译时找出按位的界限。
标签: java optimization microbenchmark bounds-check-elimination