【问题标题】:Why the bounds check doesn't get eliminated?为什么边界检查没有被消除?
【发布时间】:2014-03-09 07:16:37
【问题描述】:

我写了一个简单的benchmark 来确定当数组通过按位与计算时是否可以消除边界检查。这基本上是几乎所有哈希表所做的:它们计算

h & (table.length - 1)

作为table 的索引,其中hhashCode 或派生值。 results 表明边界检查没有被消除。

我的基准测试的想法非常简单:计算两个值 ij,保证两者都是有效的数组索引。

  • 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 &amp; (0-1) 等于 x。所以编译器能做的最好的事情就是用零长度检查代替边界检查。但恕我直言,这仍然值得,因为零长度检查可以轻松移出循环。

建议优化

由于等价的a[x &amp; (a.length - 1)] 抛出当且仅当a.length == 0,编译器可以执行以下操作:

  • 对于每个数组访问,检查索引是否已通过按位与计算。
  • 如果是,请检查任一操作数是否计算为长度减一。
  • 如果是这样,请将边界检查替换为零长度检查。
  • 让现有的优化来处理它。

这样的优化应该非常简单且便宜,因为它只查看SSA 图中的父节点。与许多复杂的优化不同,它永远不会是有害的,因为它只是用稍微简单的检查代替了一项检查;所以没有问题,即使它不能移出循环。

我会将其发布到热点开发邮件列表。

新闻

John Rose 提交了一个RFE 并且已经有一个“快速而肮脏的”patch

【问题讨论】:

  • 我看到了一个可能的原因:table[i] 导致顺序访问模式,而table[j] 则更加不规则。仅仅一两次缓存未命中就足以造成 15% 的差异。
  • 顺便说一句,-XX:CompileCommand=print,*Benchmark.time* 选项除了过滤掉您不感兴趣的所有内容外,还提供了更好的打印输出(不显示实际寄存器名称的占位符)。
  • 这个link 倾向于暗示只有当“数组由索引变量的线性函数索引”时,HotSpot 才消除检查。
  • @MarkoTopolnik:这很奇怪,你能把你的代码贴在某个地方吗?关于上面提到的“获取下一个值”:我将x += i 替换为x += 1,这样访问是顺序的,除了一次环绕,但没有太大变化。我也试过消除x,设置j = i &amp; (table.length-1),相当于j = i,但似乎阻止了绑定检查消除。
  • 你试过x % (table.length-1)而不是x &amp; (table.length-1)吗?也许编译器不够聪明,无法在编译时找出按位的界限。

标签: java optimization microbenchmark bounds-check-elimination


【解决方案1】:

首先,您的两个测试之间的主要区别肯定是边界检查消除;然而,这对机器代码的影响远非天真的期望所暗示的那样。

我的猜想:

边界检查作为循环退出点比作为引入开销的附加代码更强烈

循环退出点阻止了我从发出的机器代码中剔除的以下优化:

  • 循环展开(在所有情况下都是如此);
  • 此外,从数组中提取阶段首先对所有展开的步骤进行,然后异或到累加器对所有步骤进行。

如果循环可以在任何步骤中断,则此分段将导致执行的工作循环步骤从未实际执行过。

考虑一下您的代码的这种轻微修改:

@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;
  }
}

只有一个区别:我添加了支票

if (entry == 0) break;

为循环提供一种在任何步骤中提前退出的方法。 (我还引入了一个守卫来确保没有数组条目实际上是 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

“正常索引”变体比通常预期的要快得多。

但是,让我们删除额外的检查

// 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

“屏蔽索引”的响应可预测(减少开销),但“正常索引”突然更糟。这显然是由于额外的优化步骤与我的特定 CPU 模型之间的不匹配造成的。

我的观点:

如此详细的性能模型非常不稳定,正如我在 CPU 上看到的那样,甚至不稳定。

【讨论】:

  • 您认为“首先从数组阶段获取所有展开的步骤”是罪魁祸首,对吧?有趣!
  • 理想情况下,我们应该比较笔记。上述技术将 BCE 的效果与额外的分段优化的效果隔离开来,因此看看它在您方面做了什么会很有趣。
  • 是的,我们应该这样做。这里的 cmets 相当不适合这个。我想,这可能是一个有趣的问题,你介意发布吗?否则,请给我发送电子邮件至 @gmail.com。
  • 我已将其作为问题发布:stackoverflow.com/questions/21738690/…
【解决方案2】:
  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;
    }
}

结果:

Benchmark                                Mean   Mean error    Units
BCElimination.maskedIndex               1,235        0,004    ns/op
BCElimination.maskedIndexUnsafe         1,092        0,007    ns/op
BCElimination.normalIndex               1,071        0,008    ns/op


2. 第二个问题是针对热点开发邮件列表而不是 StackOverflow,恕我直言。

【讨论】:

  • 除了我检查了机器代码和 1) maskedIndex 有边界检查 normalIndex 没有; 2) normalIndex 使用了明显流线型的代码,其中循环展开并重新排序为两个阶段; 3) maskedIndex 在我的机器上仍然更快(提高了 8%)。
  • @MarkoTopolnik 这真的很奇怪,但更多的是关于你的 CPU 行为的特殊性,而不是这个问题的主题
  • 你的意思是,这个问题可以和CPU行为的特殊性分开?这是一个奇怪的想法......无论如何,这些是我的结果:maskedIndex 1.152 ns/op; maskedUnsafeIndex 1.116 ns/op; normalIndex 1.220 ns/op. 正常索引仍然是我机器上最慢的。
  • 奇怪我没想过用Unsafe来验证我的猜想!
  • @maaartinus 请注意,引入 Unsafe 并不能真正确定问题,因为使用不同的指令来计算数组偏移量(以及其他差异)。每个都只是产生不同的机器代码,没有明确的比较方式。
【解决方案3】:

为了安全地消除边界检查,有必要证明

h & (table.length - 1)

保证生成table的有效索引。如果table.length 为零,则不会(因为你最终会得到&amp; -1,一个有效的noop)。如果 table.length 不是 2 的幂(您会丢失信息;考虑 table.length 是 17 的情况),它也不会有用。

HotSpot 编译器如何知道这些不良条件不成立?它必须比程序员更保守,因为程序员可以更多地了解系统上的高级约束(例如,数组永远不会为空,并且总是作为元素的数量是幂的 -二)。

【讨论】:

  • 我不明白你关于 2 的幂的评论。如果 hk 是非负整数,那么 h &amp; k 是一个非负整数,最多为 h 并且最多k.
  • @ruakh 从技术上讲这不是一个安全条件,但它会产生可怕的分布。考虑有 17 个桶的情况;您最终将把所有内容都放入存储桶 0 或(很少)16。h&amp;(ary.length-1) 运作良好的 only 情况是当数组的大小是 2 的幂 (> =1),并且编译器没有提供简单的证明。
  • 我不关注。如果它是“技术上不是安全条件”,而编译器的目标仅仅是“安全地消除边界检查”,那么它与编译器有什么关系呢?为什么编译器需要能够证明?
  • @ruakh:同意,编译器应该不在乎。它可以用table.length &gt; 0检查代替边界检查,让程序员不用担心分布问题。
猜你喜欢
  • 2021-06-16
  • 2011-11-16
  • 1970-01-01
  • 1970-01-01
  • 2010-10-12
  • 2012-03-07
  • 1970-01-01
  • 2012-03-22
  • 2019-10-06
相关资源
最近更新 更多