Время выполнения параллельной коллекции Scala вызывает недоумение

#scala #runtime #scalability #multicore #parallel-processing

#скала #время выполнения #масштабируемость #многоядерный #параллельная обработка

Вопрос:

Редактировать: размер моей выборки был слишком мал. Когда я сравнил его с реальными данными на 8 процессорах, я увидел увеличение скорости в 7,2 раза. Не слишком потрепанный для добавления 4 символов в мой код;)

В настоящее время я пытаюсь «продать» руководству преимущества использования Scala, особенно когда речь идет о масштабировании с помощью процессоров. С этой целью я создал простое тестовое приложение, которое выполняет кучу векторной математики, и был немного удивлен, обнаружив, что время выполнения на моей четырехъядерной машине было не заметно лучше. Интересно, что я обнаружил, что время выполнения является худшим при первом просмотре коллекции и улучшается при последующих вызовах. Есть ли какие-то ленивые вещи в параллельной коллекции, которые вызывают это, или я просто делаю это неправильно? Следует отметить, что я родом из мира C / C #, поэтому вполне возможно, что я каким-то образом перепутал свою конфигурацию. Несмотря на это, вот моя настройка:

Плагин InteliJ Scala

Scala 2.9.1.финал

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 раза быстрее на четырехъядерном компьютере. Это просто не очень хорошая проблема?

Идеи?

Комментарии:

1. Если вы ищете какую-то идею о том, как продавать менеджмент, вы можете ознакомиться с scala-boss.heroku.com/#1 (используйте клавиши со стрелками, чтобы просмотреть следующие слайды).

2. В общем, предпочитайте параллельные массивы параллельным векторам, по крайней мере, до тех пор, пока к векторам не будут добавлены конкаты.

3. @huynhjl — Я понял, что презентация чего-то стоит, когда увидел свою жизнь, изображенную в первых двух комиксах. Спасибо!

Ответ №1:

У вас болезнь микро-бенчмарка. Скорее всего, вы проводите сравнительный анализ фазы компиляции JIT. Сначала вам нужно разогреть JIT с помощью предварительного запуска.

Вероятно, лучшая идея — использовать инфраструктуру микро-бенчмаркинга, такую как http://code.google.com/p/caliper / который справляется со всем этим за вас.

Редактировать: существует хороший шаблон SBT для Caliper для тестирования Scala-проектов, на который ссылается this blog post

Ответ №2:

Все действительно ускоряется, но это не имеет ничего общего с параллельным и последовательным, вы не сравниваете яблоки с яблоками. JVM имеет JIT-компилятор (just in time), который будет компилировать некоторый байтовый код только после того, как код будет использован определенное количество раз. Итак, то, что вы видите на первых итерациях, — это более медленное выполнение кода, который еще не обработан 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 с точки зрения JIT, используя JVM -XX: PrintCompilation (установить в JAVA_OPTS или использовать -J-XX: PrintCompilation параметр scala. На первых итерациях вы увидите большое количество операторов печати JVM, показывающих, что обрабатывается, затем это стабилизируется позже.

Итак, чтобы сравнить яблоки с яблоками, вы сначала запускаете без паритета, затем добавляете паритет и запускаете ту же программу. На моем двухъядерном процессоре при использовании .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. Функции высокого порядка библиотеки scala, такие как filter, выполняют боксирование и отмену боксирования примитивных типов, и это, как правило, является источником первоначального разочарования для тех, кто преобразует код из Java в Scala.

Хотя в данном случае это неприменимо, поскольку for находится вне времени, существует также некоторая стоимость использования for вместо while , но компилятор 2.9.1 должен выполнять достойную работу при использовании флага -optimize scalac.

Ответ №3:

Помимо упомянутых ранее оптимизаций JIT, ключевая концепция, которую вам необходимо оценить, заключается в том, зависит ли ваша проблема от распараллеливания: существует неотъемлемая стоимость разделения, координации потоков и объединения, которая весит против преимущества параллельного выполнения. Scala скрывает от вас эту сложность, но вам нужно знать, когда применять это для получения хороших результатов.

В вашем случае, хотя вы выполняете огромное количество операций, каждая операция сама по себе почти тривиальна для процессора. Чтобы увидеть параллельные коллекции в действии, попробуйте выполнить операцию, которая требует больших затрат на единицу продукции.

Для аналогичной презентации Scala я использовал простой (неэффективный) алгоритм для вычисления того, является ли число простым: def isPrime(x:Int) = (2 to x/2).forall(y=>x%y!=0)

Затем используйте ту же логику, которую вы представили, для определения простых чисел в коллекции:

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

Поведение процессора действительно показало разницу между обоими:
простые числа: последовательные против параллельных

Время было примерно в 3,5 раза лучше для параллельных коллекций в 4-ядерном процессоре.

Ответ №4:

как насчет

 val numTargetsInRange_sequential = parTargets.filter(within50)
 

?

Кроме того, вы, вероятно, получите более впечатляющие результаты с помощью map, а не операции фильтрации.