【问题标题】:Scala parallel collection runtime puzzlingScala 并行集合运行时令人费解
【发布时间】:2011-12-20 00:54:01
【问题描述】:

编辑:我的样本量太小。当我在 8 个 CPU 上运行真实数据时,我看到速度提高了 7.2 倍。在我的代码中添加 4 个字符并不太破旧;)

我目前正在尝试向管理层“推销”使用 Scala 的好处,尤其是在使用 CPU 进行扩展时。为此,我创建了一个简单的测试应用程序,该应用程序执行大量矢量数学运算,并且有点惊讶地发现运行时在我的四核机器上并没有明显好转。有趣的是,我发现运行时在您第一次浏览集合时是最差的,并且在随后的调用中变得更好。并行集合中是否有一些懒惰的东西导致了这种情况,或者我只是做错了?应该注意的是,我来自 C++/C# 世界,所以我完全有可能以某种方式搞砸了我的配置。无论如何,这是我的设置:

InteliJ Scala 插件

Scala 2.9.1.final

Windows 7 64 位,四核处理器(无超线程)

import util.Random

  // simple Vector3D class that has final x,y,z components a length, and a '-' function
  class Vector3D(val x:Double,  val y:Double, val z:Double)
  {
    def length = math.sqrt(x*x+y*y+z*z)
    def -(rhs : Vector3D ) = new Vector3D(x - rhs.x, y - rhs.y, z - rhs.z)
  }

object MainClass {

  def main(args : Array[String]) =
  {
    println("Available CPU's: " + Runtime.getRuntime.availableProcessors())
    println("Parallelism Degree set to: " + collection.parallel.ForkJoinTasks.defaultForkJoinPool.getParallelism);
    // my position
    val myPos = new Vector3D(0,0,0);

    val r = new Random(0);

    // define a function nextRand that gets us a random between 0 and 100
    def nextRand = r.nextDouble() * 100;

    // make 10 million random targets
    val targets = (0 until 10000000).map(_ => new Vector3D(nextRand, nextRand, nextRand)).toArray
    // take the .par hit before we start profiling
    val parTargets = targets.par

    println("Created " + targets.length + " vectors")

    // define a range function
    val rangeFunc : (Vector3D => Double) = (targetPos) => (targetPos - myPos).length

    // we'll select ones that are <50
    val within50 : (Vector3D => Boolean) = (targetPos) => rangeFunc(targetPos) < 50

    // time it sequentially
    val startTime_sequential = System.currentTimeMillis()
    val numTargetsInRange_sequential = targets.filter(within50)
    val endTime_sequential = System.currentTimeMillis()
    println("Sequential (ms): " + (endTime_sequential - startTime_sequential))

    // do the parallel version 10 times
    for(i <- 1 to 10)
    {

      val startTime_par = System.currentTimeMillis()
      val numTargetsInRange_parallel = parTargets.filter(within50)
      val endTime_par = System.currentTimeMillis()

      val ms = endTime_par - startTime_par;
      println("Iteration[" + i + "] Executed in " + ms + " ms")
    }
  }
}

这个程序的输出是:

Available CPU's: 4
Parallelism Degree set to: 4
Created 10000000 vectors
Sequential (ms): 216
Iteration[1] Executed in 227 ms
Iteration[2] Executed in 253 ms
Iteration[3] Executed in 76 ms
Iteration[4] Executed in 78 ms
Iteration[5] Executed in 77 ms
Iteration[6] Executed in 80 ms
Iteration[7] Executed in 78 ms
Iteration[8] Executed in 78 ms
Iteration[9] Executed in 79 ms
Iteration[10] Executed in 82 ms

那么这里发生了什么?我们做过滤器的前 2 次,它变慢了,然后速度加快了?我知道并行性启动成本是固有的,我只是想弄清楚在我的应用程序中表达并行性的意义,特别是我希望能够向管理人员展示一个运行 3-4 次的程序在四核盒子上更快。这不是一个好问题吗?

想法?

【问题讨论】:

  • 如果您正在寻找有关如何销售管理的一些想法,您可以查看scala-boss.heroku.com/#1(使用箭头键查看下一张幻灯片)。
  • 一般来说,并行数组优于并行向量,至少在将 concats 添加到向量之前是这样。
  • @huynhjl - 当我看到前两部漫画中描绘的我的生活时,我知道这种展示是值得的。谢谢!

标签: scala runtime scalability multicore parallel-processing


【解决方案1】:

怎么样

val numTargetsInRange_sequential = parTargets.filter(within50)

?

此外,使用地图而不是过滤操作,您可能会获得更令人印象深刻的结果。

【讨论】:

    【解决方案2】:

    您患有微基准病。您很可能正在对 JIT 编译阶段进行基准测试。您需要先通过预运行预热您的 JIT。

    也许最好的办法是使用像 http://code.google.com/p/caliper/ 这样的微基准测试框架,它会为您处理所有这些。

    编辑:有一个很好的 SBT Template 用于 Caliper 基准测试 Scala 项目,参考 from this blog post

    【讨论】:

      【解决方案3】:

      事情确实会加速,但这与并行与顺序无关,您不是在比较苹果和苹果。 JVM 有一个 JIT(即时)编译器,它只会在代码使用一定次数后编译一些字节代码。因此,您在第一次迭代中看到的是尚未经过 JIT 编译的代码的执行速度较慢,以及正在进行的 JIT 编译本身的时间。删除 .par 使其全部 顺序 这是我在我的机器上看到的(迭代次数减少了 10 倍,因为我使用的是旧机器):

      Sequential (ms): 312
      Iteration[1] Executed in 117 ms
      Iteration[2] Executed in 112 ms
      Iteration[3] Executed in 112 ms
      Iteration[4] Executed in 112 ms
      Iteration[5] Executed in 114 ms
      Iteration[6] Executed in 113 ms
      Iteration[7] Executed in 113 ms
      Iteration[8] Executed in 117 ms
      Iteration[9] Executed in 113 ms
      Iteration[10] Executed in 111 ms
      

      但这都是顺序的!您可以通过使用 JVM -XX:+PrintCompilation(在 JAVA_OPTS 中设置或使用 -J-XX:+PrintCompilation scala 选项来查看 JVM 在 JIT 方面所做的事情。在第一次迭代中,您将看到大量 JVM 打印语句显示什么是 JIT -ed,然后它稍后会稳定下来。

      所以为了比较苹果和苹果,你首先运行没有标准杆,然后添加标准杆并运行相同的程序。在我的双核上,当使用 .par 我得到:

      Sequential (ms): 329
      Iteration[1] Executed in 197 ms
      Iteration[2] Executed in 60 ms
      Iteration[3] Executed in 57 ms
      Iteration[4] Executed in 58 ms
      Iteration[5] Executed in 59 ms
      Iteration[6] Executed in 73 ms
      Iteration[7] Executed in 56 ms
      Iteration[8] Executed in 60 ms
      Iteration[9] Executed in 58 ms
      Iteration[10] Executed in 57 ms
      

      一旦稳定,或多或少会加速 2 倍。

      在相关说明中,您要注意的另一件事是装箱和拆箱,尤其是在与 Java 进行比较时。像 filter 这样的 scala 库高阶函数正在对原始类型进行装箱和取消装箱,这通常是那些将代码从 Java 转换为 Scala 的人最初失望的根源。

      虽然它不适用于这种情况,因为 for 超出了时间范围,但使用 for 而不是 while 也有一些成本,但 2.9.1 编译器应该做得不错使用-optimize scalac 标志。

      【讨论】:

        【解决方案4】:

        除了前面提到的 JIT 优化之外,您需要评估的一个关键概念是您的问题是否倾向于并行化:拆分、线程协调和连接的固有成本会影响并行处理的优势。 Scala 对您隐藏了这种复杂性,但您确实需要知道何时应用它以获得良好的结果。

        在您的情况下,尽管您正在执行大量操作,但每个操作本身对 CPU 来说几乎是微不足道的。要查看运行中的并行集合,请尝试以单元为单位进行繁重的操作。

        对于类似的 Scala 演示文稿,我使用了一个简单(低效)的算法来计算一个数字是否是素数: def isPrime(x:Int) = (2 to x/2).forall(y=&gt;x%y!=0)

        然后使用您提出的相同逻辑来确定集合中的素数:

        val col = 1 to 1000000
        col.filter(isPrime(_))  // sequential
        col.par.filter(isPrime(_)) // parallel
        

        CPU 行为确实显示了两者之间的区别:

        在 4 核 CPU 中并行收集的时间大约缩短了 3.5 倍。

        【讨论】:

          猜你喜欢
          • 2023-03-12
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 2018-07-02
          • 2020-06-19
          • 2020-10-30
          相关资源
          最近更新 更多