【问题标题】:How to get around for loop bottleneck for constant time operations?如何解决恒定时间操作的循环瓶颈?
【发布时间】:2012-05-02 02:52:26
【问题描述】:

为了好玩而开发一个与规则无关的扑克模拟器。测试枚举中的瓶颈,以及总是从“唯一”数组中拉出的手,我发现了一个有趣的瓶颈。我测量了运行每个变体低于 1,000,000,000 次的平均计算时间,然后取其中最好的 100 次重复来让 JIT 和 Hotspot 发挥它们的魔力。我发现在计算时间(6ns vs 27ns)之间存在差异

public int getRank7(int ... cards) {
  int q = (cards[0] >> 16) | (cards[1] >> 16) | (cards[2] >> 16) | (cards[3] >> 16) | (cards[4] >> 16) | (cards[5] >> 16) | (cards[6] >> 16);
  int product = ((cards[0] & 0xFF) * (cards[1] & 0xFF) * (cards[2] & 0xFF) * (cards[3] & 0xFF) * (cards[4] & 0xFF) * (cards[5] & 0xFF) * (cards[6] & 0xFF));
  if(flushes[q] > 0) return flushes[q];
  if(unique[q] > 0) return unique[q];
  int x = Arrays.binarySearch(products, product);
  return rankings[x];
}

public int getRank(int ... cards) {
  int q = 0;
  long product = 1;
  for(int c : cards) {
    q |= (c >> 16);
    product *= (c & 0xFF);
  }
  if(flushes[q] > 0) return flushes[q];
  if(unique[q] > 0) return unique[q];
  int x = Arrays.binarySearch(products, product);
  return rankings[x];
}

问题肯定是 for 循环,而不是在函数顶部添加处理乘法。我对此有点困惑,因为我在每个场景中运行相同数量的操作......我意识到我在这个函数中总是有 6 张或更多卡,所以我通过将其更改为

public int getRank(int c0, int c1, int c2, int c3, int c4, int c5, int ... cards)

但是随着卡片数量的增加,我也会遇到同样的瓶颈。有什么办法可以绕过这个事实,如果没有,有人可以向我解释为什么相同数量的操作的 for 循环要慢得多吗?

【问题讨论】:

  • 实际上,您在每个场景中运行的操作数量并不相同。在第一个示例中,如果 flushes[q] > 0unique[q] > 0 则跳过乘法。在第二个例子中,你总是做乘法。你确定这不会影响时间吗?
  • 阳性。我已经在完全删除乘法的情况下对其进行了测试,这在运行时没有变化。在检查之后将它移动到唯一只会为每对/两对/set/boat/quad类型的手增加一个额外的for循环的开销。作为一个概念证明,它通常是循环逻辑的一些隐藏方面,而不是循环本身定义的操作之一,我将其中使用的每个变量(x &card.length)移动到没有变化的方法参数,并且还尝试从cards.length切换到静态变量,没有任何变化。

标签: java performance algorithm time-complexity


【解决方案1】:

我想你会发现最大的区别在于分支。您的 for 循环场景需要在 for 循环的每次迭代中进行检查和条件分支。您的 CPU 将尝试预测将采用哪个分支,并相应地流水线指令,但是当它预测错误时(每个函数调用至少一次,因为循环终止),流水线停止,这是非常昂贵的。

要尝试的一件事是具有固定上限的常规 for 循环(而不是基于数组长度的循环); Java JRE 可能会展开这样的循环,这将导致与您的更高效版本相同的操作序列。

【讨论】:

  • 我肯定会更多地研究分支。至于您关于切换到常规 for 循环的建议,我能做的最好的是将其更改为实例变量或方法参数,这两者似乎都没有任何效果 - 还有什么我应该尝试的吗?感谢您对分支的解释,我编写此程序的主要原因是了解更多有关时间复杂度和瓶颈的信息。您是否可以在任何地方向我提供有关机器所采取的实际步骤的可靠信息,以及它为什么会影响性能?使用 7 个 if 语句会产生同样的影响吗?
  • @sgrif 我的建议是尝试一个实际的常量——在本例中为“7”。如果它加速了,你知道这是因为 JIT 展开了你的循环,而减速是由于分支造成的。如果没有,它可能仍在分支 - 但 JIT 可能不够聪明,无法展开循环。有关分支(错误)预测的更多信息,请参阅:en.wikipedia.org/wiki/Branch_predictor
  • 是的,if 语句同样糟糕 - 代价是 CPU 猜测代码将采用哪个分支时出错。如果您真的想进行优化,最好的方法可能是为每种手型编写硬编码评估器,或者编写一些代码生成字节码并在运行时对其进行 JIT。
  • 不能使用硬编码常量。尝试它只是为了看看是否存在性能差异。跑了 23ns,所以稍微改进了一下。至于对每种类型的手进行硬编码,这绝对是确保优化的最佳方式,但或多或​​少违背了编写它的目的。我唯一要硬编码的是5张卡的优化,因为无论如何都需要这样做。至于在运行时生成字节码和 JITing ......实际上有几个地方对我来说真的很有用。这只是打开了另一罐蠕虫。 XD
  • 要尝试的另一件事是使用调试器检查每个函数生成的字节码和本机代码。可能您也可以使用低级分析器来查看时间花费在哪里。
【解决方案2】:

增强的for 循环需要设置一个迭代器,当您只有少量项目时,这相对昂贵。

如果您编写一个传统的for 循环,看看您的时间安排会很有趣:

for (int i = 0; i < cards.length; ++i)
{
    q |= (cards[i] >> 16);
    product *= (cards[i] & 0xFF);
}

但即使这样也可能比第一个示例稍慢,因为存在一些循环开销(增加索引,将其与长度进行比较,然后分支到循环的开头)。

在任何情况下,循环开销都会为每次迭代增加一个增量、一个比较和一个分支。这种比较很可能需要一个指针取消引用才能到达cards.length。循环开销比您在循环中所做的工作要昂贵得多,这很合理。

【讨论】:

  • 在我发布之前尝试过 - 时间没有明显变化。
  • 如果您在 for 循环之前执行“int N = cards.length”,然后使用 N 而不是 cards.length 作为循环保护,该怎么办?否则,您必须在每次通过循环时取消引用卡片对象,才能找到长度。
  • 刚刚尝试过你的改变,希思。也将 x 的 init 移到顶部。 “int q=0,i=cards.length,x=0;” - 似乎没有任何效果。据我所知,JVM 足够聪明,可以自行进行类似的微优化。
  • Jim,您能否详细解释一下为什么 for 循环会增加如此巨大的开销?即使运行了数十亿次,这些步骤都不应该花费一个 ns。
  • 当然,这些步骤需要时间。递增i 需要获取、递增、存储(除非它保存在寄存器中)。比较icards.length 需要时间,分支到循环的开头也是如此。即使在 2 GHz 下,每次迭代的循环开销也至少需要一纳秒。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2017-02-25
  • 2020-11-18
  • 1970-01-01
  • 2015-12-04
  • 1970-01-01
  • 1970-01-01
  • 2015-08-10
相关资源
最近更新 更多