#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, а не операции фильтрации.