如何在Scala中编写基数为3的数字时避免装箱

时间:2018-04-02 20:59:11

标签: scala performance boxing

我想在基数3中编写数字,表示为固定长度Array[Byte]

以下是一些尝试:

val byteBoard = Array.fill(9)(1.toByte)

val cache: Seq[(Int, Int)] = (0 to 8).map(i => (i, math.pow(3d, i.toDouble).toInt))

@Benchmark
def composePow(): Unit = {
  val _ = (0 to 8).foldLeft(0) { case (acc, i) => acc + math.pow(3d, i.toDouble).toInt * byteBoard(i) }
}

@Benchmark
def composeCachedPowWithFold(): Unit = {
  val _ = cache.foldLeft(0) { case (acc, (i, k)) => acc + k * byteBoard(i).toInt }
}

@Benchmark
def composeCachedPowWithForeach(): Unit = {
  var acc = 0
  cache.foreach { case (i, k) => acc = acc + k * byteBoard(i)}
}

@Benchmark
def composeUnrolled(): Unit = {
  val _ = byteBoard(0) +
    3 * byteBoard(1) +
    3 * 3 * byteBoard(2) +
    3 * 3 * 3 * byteBoard(3) +
    3 * 3 * 3 * 3 * byteBoard(4) +
    3 * 3 * 3 * 3 * 3 * byteBoard(5) +
    3 * 3 * 3 * 3 * 3 * 3 * byteBoard(6) +
    3 * 3 * 3 * 3 * 3 * 3 * 3 * byteBoard(7) +
    3 * 3 * 3 * 3 * 3 * 3 * 3 * 3 * byteBoard(8)
}

您能否确认以下结论:

  • composePow:装箱+类型转换以使用math.pow
  • composeCachedPowWithFold:拳击因为fold是参数化方法
  • composeCachedPowWithForeach:没有拳击,较少惯用的Scala(局部突变)
  • composeUnrolled:没有拳击

并解释为什么4.比3.更快?

PS:以下是JMH基准测试的结果

[info] IndexBenchmark.composeCachedPowWithFold                    thrpt   10    7180844,823 ± 1015310,847  ops/s
[info] IndexBenchmark.composeCachedPowWithForeach                 thrpt   10   14234192,613 ± 1449571,042  ops/s
[info] IndexBenchmark.composePow                                  thrpt   10    1515312,179 ±   34892,700  ops/s
[info] IndexBenchmark.composeUnrolled                             thrpt   10  152297653,110 ± 2237446,053  ops/s

1 个答案:

答案 0 :(得分:1)

我大多同意你对案例1,2,4的分析,但第三种变体真的很有趣!

我同意你的前两个版本:foldLeft不是@specialized,所以,是的,有一些装箱拆箱。但是math.pow对整数算术来说是邪恶的,所有这些转换只会产生额外的开销。

现在让我们仔细看看第三个变种。它是如此缓慢,因为你正在构建一个可变状态的闭包。查看scala -print的输出。这是您的方法被重写为:

private def composeCachedPowWithForeach(): Unit = {
  var acc: runtime.IntRef = scala.runtime.IntRef.create(0);
  anon$1.this.cache().foreach({
    ((x0$3: Tuple2) => 
      anon$1.this.
        $anonfun$composeCachedPowWithForeach$1(acc, x0$3))
  })
};

以下是foreach中使用的函数:

final <artifact> private[this] def 
$anonfun$composeCachedPowWithForeach$1(
   acc$1: runtime.IntRef, x0$3: Tuple2
): Unit = {
  case <synthetic> val x1: Tuple2 = x0$3;
  case4(){
    if (x1.ne(null))
      {
        val i: Int = x1._1$mcI$sp();
        val k: Int = x1._2$mcI$sp();
        matchEnd3({
          acc$1.elem = acc$1.elem.+(k.*(anon$1.this.byteBoard().apply(i)));
          scala.runtime.BoxedUnit.UNIT
        })
      }
    else
      case5()
  };
  case5(){
    matchEnd3(throw new MatchError(x1))
  };
  matchEnd3(x: scala.runtime.BoxedUnit){
    ()
  }
};

您看到模式匹配显然产生了大量代码。我不确定它是否会对开销产生很大影响。我个人觉得更有趣的是runtime.IntRef部分。这是一个对象,它在代码中保留与var acc对应的可变变量。即使它在代码中看起来像一个简单的局部变量,它也必须以某种方式从闭包中引用,因此被包装到一个对象中,然后被驱逐到堆中。我假设在堆上访问这个可变变量会导致大部分开销。

与此相反,如果byteBoard作为参数传递,那么第四个变体中的任何内容都不会留下函数的堆栈帧:

private def composeUnrolled(): Unit = {
  val _: Int = 
    anon$1.this.byteBoard().apply(0).+  
    (3.*(anon$1.this.byteBoard().apply(1))).+
    (9.*(anon$1.this.byteBoard().apply(2))).+
    (27.*(anon$1.this.byteBoard().apply(3))).+
    (81.*(anon$1.this.byteBoard().apply(4))).+
    (243.*(anon$1.this.byteBoard().apply(5))).+
    (729.*(anon$1.this.byteBoard().apply(6))).+
    (2187.*(anon$1.this.byteBoard().apply(7))).+
    (6561.*(anon$1.this.byteBoard().apply(8)));
  ()
};

基本上没有控制流可言,实际上没有任何方法调用(apply用于访问数组元素,并不算数),而且整体而言它只是一个简单的算术运算,甚至可以适合处理器的寄存器。这就是为什么它如此之快。

当你在这里时,你可能想要对这两种方法进行基准测试:

def ternaryToInt5(bytes: Array[Byte]): Int = {
  var acc = 0
  val n = bytes.size
  var i = n - 1
  while (i >= 0) {
    acc *= 3
    acc += bytes(i)
    i -= 1
  }
  acc
}

def ternaryToInt6(bytes: Array[Byte]): Int = {
  bytes(0) + 
  3 * (bytes(1) + 
  3 * (bytes(2) + 
  3 * (bytes(3) + 
  3 * (bytes(4) + 
  3 * (bytes(5) + 
  3 * (bytes(6) + 
  3 * (bytes(7) + 
  3 * (bytes(8)))))))))
}

此外,如果您经常使用字节数组,您可能会发现this syntactic sugar有用。