Java 为什么边界检查没有';你不会被淘汰吗?
我编写了一个简单的示例,以了解当通过按位and计算数组时,是否可以消除边界检查。这就是几乎所有哈希表的基本功能:它们计算Java 为什么边界检查没有';你不会被淘汰吗?,java,optimization,microbenchmark,bounds-check-elimination,Java,Optimization,Microbenchmark,Bounds Check Elimination,我编写了一个简单的示例,以了解当通过按位and计算数组时,是否可以消除边界检查。这就是几乎所有哈希表的基本功能:它们计算 h & (table.length - 1) 作为表的索引,其中h是hashCode或派生值。这表明边界检查并没有被消除 我的基准测试思想非常简单:计算两个值i和j,这两个值都保证是有效的数组索引 i是循环计数器。当它被用作数组索引时,边界检查被消除 j计算为x&(table.length-1),其中x是在每次迭代中更改的某个值。当它被用作数组索引时,边界检查不会
h & (table.length - 1)
作为表的索引,其中h
是hashCode
或派生值。这表明边界检查并没有被消除
我的基准测试思想非常简单:计算两个值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];
}
相反。时间上的差异可能是15%(我尝试过的不同变体之间的差异非常一致)。我的问题是:
- 除了取消绑定支票之外,还有其他可能的原因吗
- 有什么复杂的原因我不明白为什么
j
没有绑定检查消除
答案摘要
MarkoTopolnik的回答表明,这一切都更加复杂,取消边界检查并不能保证是一场胜利,特别是在他的计算机上,“正常”代码比“屏蔽”代码慢。我猜这是因为它允许一些额外的优化,这在这种情况下实际上是有害的(考虑到当前CPU的复杂性,编译器甚至很难确定)
leventov的回答清楚地表明,数组边界检查是在“蒙面”中完成的,它的消除使代码与“正常”一样快
Donal Fellows指出了这样一个事实,屏蔽对于零长度的表不起作用,因为x&(0-1)
等于x
。因此,编译器能做的最好的事情就是用零长度检查替换绑定检查。但这仍然是值得的,因为零长度检查可以很容易地移出循环
建议的优化
由于等价性a[x&(a.length-1)]
当且仅当a.length==0
时抛出,编译器可以执行以下操作:
- 对于每个数组访问,检查是否已通过按位and计算索引
- 如果是,请检查两个操作数中是否有一个计算为长度减1
- 如果是,则用零长度检查替换边界检查
- 让现有的优化来解决这个问题
这样的优化应该非常简单和便宜,因为它只查看图中的父节点。与许多复杂的优化不同,它永远不会有害,因为它只会用一个稍微简单的检查来代替一个检查;所以没有问题,即使它不能从循环中移出
我会将此发布到热点开发人员邮件列表
新闻
约翰·罗斯提交了一份申请,已经有一份“又快又脏”的申请了
不,这显然是由于没有足够的智能边界检查消除造成的
我扩展了Marko Topolnik的基准:
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
@OperationsPerInvocation(BCElimination.N)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 10, time = 1)
@State(Scope.Thread)
@Threads(1)
@Fork(2)
public class BCElimination {
public static final int N = 1024;
private static final Unsafe U;
private static final long INT_BASE;
private static final long INT_SCALE;
static {
try {
Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
U = (Unsafe) f.get(null);
} catch (Exception e) {
throw new IllegalStateException(e);
}
INT_BASE = U.arrayBaseOffset(int[].class);
INT_SCALE = U.arrayIndexScale(int[].class);
}
private final int[] table = new int[BCElimination.N];
@Setup public void setUp() {
final Random random = new Random();
for (int i=0; i<table.length; ++i) table[i] = random.nextInt();
}
@GenerateMicroBenchmark public int normalIndex() {
int result = 0;
final int[] table = this.table;
int x = 0;
for (int i=0; i<=table.length-1; ++i) {
x += i;
final int j = x & (table.length-1);
result ^= table[i] + j;
}
return result;
}
@GenerateMicroBenchmark public int maskedIndex() {
int result = 0;
final int[] table = this.table;
int x = 0;
for (int i=0; i<=table.length-1; ++i) {
x += i;
final int j = x & (table.length-1);
result ^= i + table[j];
}
return result;
}
@GenerateMicroBenchmark public int maskedIndexUnsafe() {
int result = 0;
final int[] table = this.table;
long x = 0;
for (int i=0; i<=table.length-1; ++i) {
x += i * INT_SCALE;
final long j = x & ((table.length-1) * INT_SCALE);
result ^= i + U.getInt(table, INT_BASE + j);
}
return result;
}
}
2.第二个问题是热点开发人员邮件列表,而不是StackOverflow,IMHO。为了安全地消除边界检查,有必要证明
h & (table.length - 1)
保证在表
中生成有效索引。如果table.length
为零,则不会出现这种情况(因为您将得到和-1
,这是一个有效的noop)。如果<代码>表,长度< <代码>不是2的幂(您将丢失信息;考虑“代码>表。长度< /代码>为17”的情况)。
热点编译器如何知道这些坏条件不是真的?它必须比程序员更为保守,因为程序员可以更多地了解系统的高级约束(例如,数组从来都不是空的,并且总是作为一个二次幂的元素数)。首先,两个测试之间的主要区别肯定是边界检查消除;然而,这影响机器代码的方式与天真的期望相去甚远
我的猜测是:
边界检查作为循环出口点的作用比作为额外代码的作用更大,这会引入开销
循环退出点阻止了我从发出的机器代码中剔除的以下优化:
- 循环展开(在所有情况下都是如此)李>
- 此外,首先对所有展开的步骤执行从数组阶段的获取,然后对所有步骤执行xoring到累加器
如果循环可以在任何步骤中中断,则此分段将导致为从未实际执行的循环步骤执行工作
考虑一下对代码的这种轻微修改:
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
@OperationsPerInvocation(Measure.N)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
@State(Scope.Thread)
@Threads(1)
@Fork(1)
public class Measure {
public static final int N = 1024;
private final int[] table = new int[N];
@Setup public void setUp() {
final Random random = new Random();
for (int i = 0; i < table.length; ++i) {
final int x = random.nextInt();
table[i] = x == 0? 1 : x;
}
}
@GenerateMicroBenchmark public int normalIndex() {
int result = 0;
final int[] table = this.table;
int x = 0;
for (int i = 0; i <= table.length - 1; ++i) {
x += i;
final int j = x & (table.length - 1);
final int entry = table[i];
result ^= entry + j;
if (entry == 0) break;
}
return result;
}
@GenerateMicroBenchmark public int maskedIndex() {
int result = 0;
final int[] table = this.table;
int x = 0;
for (int i = 0; i <= table.length - 1; ++i) {
x += i;
final int j = x & (table.length - 1);
final int entry = table[j];
result ^= i + entry;
if (entry == 0) break;
}
return result;
}
}
为循环提供在任何步骤上提前退出的方法。(我还引入了一个保护,以确保没有数组条目实际上是0。)
在我的机器上,结果如下:
Benchmark Mode Samples Mean Mean error Units
o.s.Measure.maskedIndex avgt 5 1.378 0.229 ns/op
o.s.Measure.normalIndex avgt 5 0.924 0.092 ns/op
正如人们普遍预期的那样,“正常指数”变量的速度要快得多
但是,让我们取消附加检查:
现在我的结果是:
Benchmark Mode Samples Mean Mean error Units
o.s.Measure.maskedIndex avgt 5 1.130 0.065 ns/op
o.s.Measure.normalIndex avgt 5 1.229 0.053 ns/op
“隐藏指数”的反应是可预测的(减少了开销),但“正常指数”突然变得更糟。这显然是由于额外的优化步骤和我的特定CPU型号之间的不匹配
我的观点是:
这种详细级别的性能模型非常不稳定,正如我在CPU上看到的,甚至不稳定。我看到了一个可能的原因:表[I]
导致顺序访问模式,而表[j]
则更不规则。仅一次或两次缓存未命中就足以解释15%的差异。顺便说一句,除了过滤掉所有不在缓存中的内容外,还可以使用选项-XX:CompileCommand=print,*Benchmark.time*
if (entry == 0) break;
Benchmark Mode Samples Mean Mean error Units
o.s.Measure.maskedIndex avgt 5 1.378 0.229 ns/op
o.s.Measure.normalIndex avgt 5 0.924 0.092 ns/op
// if (entry == 0) break;
Benchmark Mode Samples Mean Mean error Units
o.s.Measure.maskedIndex avgt 5 1.130 0.065 ns/op
o.s.Measure.normalIndex avgt 5 1.229 0.053 ns/op