|
| 1 | +--- |
| 2 | +layout: overview-large |
| 3 | +title: Измерение производительности |
| 4 | + |
| 5 | +disqus: true |
| 6 | + |
| 7 | +partof: parallel-collections |
| 8 | +num: 8 |
| 9 | +outof: 8 |
| 10 | +language: ru |
| 11 | +--- |
| 12 | + |
| 13 | +## Производительность JVM |
| 14 | + |
| 15 | +При описании модели производительности выполнения кода на JVM иногда ограничиваются несколькими комментариями, и как результат-- не всегда становится хорошо понятно, что в силу различных причин написанный код может быть не таким производительным или расширяемым, как можно было бы ожидать. В этой главе будут приведены несколько примеров. |
| 16 | + |
| 17 | +Одной из причин является то, что процесс компиляции выполняющегося на JVM приложения не такой, как у языка со статической компиляцией (как можно увидеть здесь \[[2][2]\]). Компиляторы Java и Scala обходятся минимальной оптимизацией при преобразовании исходных текстов в байткод JVM. При первом запуске на большинстве современных виртуальных Java-машин байткод преобразуется в машинный код той архитектуры, на которой он запущен. Это преобразование называется компиляцией "на лету" или JIT-компиляцией (JIT от just-in-time). Однако из-за того, что компиляция "на лету" должна быть быстрой, уровень оптимизации при такой компиляции остается низким. Более того, чтобы избежать повторной компиляции, компилятор HotSpot оптимизирует только те участки кода, которые выполняются часто. Поэтому тот, кто пишет тест производительности, должен учитывать, что программа может показывать разную производительность каждый раз, когда ее запускают: многократное выполнение одного и того же куска кода (то есть, метода) на одном экземпляре JVM может демонстрировать очень разные результаты замеров производительности в зависимости от того, оптимизировался ли определенный код между запусками. Более того, измеренное время выполнения некоторого участка кода может включать в себя время, за которое произошла сама оптимизация JIT-компилятором, что сделает результат измерения нерепрезентативным. |
| 18 | + |
| 19 | +Кроме этого, результат может включать в себя потраченное на стороне JVM время на осуществление операций автоматического управления памятью. Время от времени выполнение программы прерывается и вызывается сборщик мусора. Если исследуемая программа размещает хоть какие-нибудь данные в куче (а большинство программ JVM размещают), значит сборщик мусора должен запуститься, возможно, исказив при этом результаты измерений. Можно нивелировать влияние сборщика мусора на результат, запустив измеряемую программу множество раз, и тем самым спровоцировав большое количество циклов сборки мусора. |
| 20 | + |
| 21 | +Одной из распространенных причин ухудшения производительности является оборачивание и разворачивание (boxing и unboxing), неявно происходящее в случаях, когда примитивный тип передается аргументом в обобщенный (generic) метод. Чтобы примитивные типы можно было передать в метод с параметром обобщенного типа, они во время выполнения преобразуются в представляющие их объекты. Этот процесс замедляет выполнение, а кроме того порождает необходимость в дополнительном выделении памяти и, соответственно, создает дополнительный мусор в куче. |
| 22 | + |
| 23 | +В качестве распространенной причины ухудшения параллельной производительности можно назвать соперничество за память (memory contention), возникающее из-за того, что программист не может явно указать, где следует размещать объекты. Фактически, из-за влияния сборщика мусора, это соперничество может произойти на более поздней стадии жизни приложения, а именно после того, как объекты начнут перемещаться в памяти. Такие влияния нужно учитывать при написании теста. |
| 24 | + |
| 25 | +## Пример микротеста производительности |
| 26 | + |
| 27 | +Существует несколько подходов, позволяющих избежать описанных выше эффектов во время измерений. В первую очередь следует убедиться, что JIT-компилятор преобразовал исходный текст в машинный код (и что последний был оптимизирован), прогнав микротест производительности достаточное количество раз. Этот процесс известен как фаза разогрева (warm-up). |
| 28 | + |
| 29 | +Для того, чтобы уменьшить число помех, вызванных сборкой мусора от объектов, размещенных другими участками программы или несвязанной компиляцией "на лету", требуется запустить микротест на отдельном экземпляре JVM. |
| 30 | + |
| 31 | +Кроме того, запуск следует производить на серверной версии HotSpot JVM, которая выполняет более агрессивную оптимизацию. |
| 32 | + |
| 33 | +Наконец, чтобы уменьшить вероятность того, что сборка мусора произойдет посреди микротеста, лучше всего добиться выполнения цикла сборки мусора перед началом теста, а следующий цикл отложить настолько, насколько это возможно. |
| 34 | + |
| 35 | +В стандартной библиотеке Scala предопределен трейт `scala.testing.Benchmark`, спроектированный с учетом приведенных выше соображений. Ниже приведен пример тестирования производительности операции `map` многопоточного префиксного дерева: |
| 36 | + |
| 37 | + import collection.parallel.mutable.ParTrieMap |
| 38 | + import collection.parallel.ForkJoinTaskSupport |
| 39 | + |
| 40 | + object Map extends testing.Benchmark { |
| 41 | + val length = sys.props("length").toInt |
| 42 | + val par = sys.props("par").toInt |
| 43 | + val partrie = ParTrieMap((0 until length) zip (0 until length): _*) |
| 44 | + |
| 45 | + partrie.tasksupport = new ForkJoinTaskSupport(new scala.concurrent.forkjoin.ForkJoinPool(par)) |
| 46 | + |
| 47 | + def run = { |
| 48 | + partrie map { |
| 49 | + kv => kv |
| 50 | + } |
| 51 | + } |
| 52 | + } |
| 53 | + |
| 54 | +Метод `run` содержит код микротеста, который будет повторно запускаться для измерения времени своего выполнения. Объект `Map`, расширяющий трейт `scala.testing.Benchmark`, запрашивает передаваемые системой параметры уровня параллелизма `par` и количества элементов дерева `length`. |
| 55 | + |
| 56 | +После компиляции программу, приведенную выше, следует запустить так: |
| 57 | + |
| 58 | + java -server -cp .:../../build/pack/lib/scala-library.jar -Dpar=1 -Dlength=300000 Map 10 |
| 59 | + |
| 60 | +Флаг `server` требует использовать серверную VM. Флаг `cp` означает "classpath", то есть указывает, что файлы классов требуется искать в текущем каталоге и в jar-архиве библиотеки Scala. Аргументы `-Dpar` и `-Dlength`-- это уровень параллелизма и количество элементов соответственно. Наконец, `10` означает что тест производительности будет запущен на одной и той же JVM именно 10 раз. |
| 61 | + |
| 62 | +Устанавливая уровень параллелизма `par` в `1`, `2`, `4` и `8`, получаем следующее время выполнения |
| 63 | +на четырехъядерном i7 с поддержкой гиперпоточности: |
| 64 | + |
| 65 | + Map$ 126 57 56 57 54 54 54 53 53 53 |
| 66 | + Map$ 90 99 28 28 26 26 26 26 26 26 |
| 67 | + Map$ 201 17 17 16 15 15 16 14 18 15 |
| 68 | + Map$ 182 12 13 17 16 14 14 12 12 12 |
| 69 | + |
| 70 | +Можно заметить, что на первые запуски требуется больше времени, но после оптимизации кода оно уменьшается. Кроме того, мы можем увидеть что гиперпотоковость не дает большого преимущества в нашем примере, это следует из того, что увеличение количества потоков от `4` до `8` не приводит к значительному увеличению производительности. |
| 71 | + |
| 72 | +## Насколько большую коллекцию стоит сделать параллельной? |
| 73 | + |
| 74 | +Этот вопрос задается часто, но ответ на него достаточно запутан. |
| 75 | + |
| 76 | +Размер коллекции, при котором оправданы затраты на параллелизацию, в действительности зависит от многих факторов. Некоторые из них (но не все) приведены ниже: |
| 77 | + |
| 78 | +- Архитектура системы. Различные типы CPU имеют различную архитектуру и различные характеристики масштабируемости. Помимо этого, машина может быть многоядерной, а может иметь несколько процессоров, взаимодействующих через материнскую плату. |
| 79 | +- Производитель и версия JVM. Различные виртуальные машины применяют различные оптимизации кода во время выполнения и реализуют различные механизмы синхронизации и управления памятью. Некоторые из них не поддерживают `ForkJoinPool`, возвращая нас к использованию `ThreadPoolExecutor`, что приводит к увеличению накладных расходов. |
| 80 | +- Поэлементная нагрузка. Величина нагрузки, оказываемой обработкой одного элемента, зависит от функции или предиката, которые требуется выполнить параллельно. Чем меньше эта нагрузка, тем выше должно быть количество элементов для получения ускорения производительности при параллельном выполнении. |
| 81 | +- Выбранная коллекция. Например, разделители `ParArray` и `ParTrieMap` перебирают элементы коллекции с различными скоростями, а значит разницу количества нагрузки при обработке каждого элемента создает уже сам перебор. |
| 82 | +- Выбранная операция. Например, у `ParVector` намного медленнее методы трансформации (такие, как `filter`) чем методы получения доступа (как `foreach`) |
| 83 | +- Побочные эффекты. При изменении областей памяти несколькими потоками или при использовании механизмов синхронизации внутри тела `foreach`, `map`, и тому подобных, может возникнуть соперничество. |
| 84 | +- Управление памятью. Размещение большого количества объектов может спровоцировать цикл сборки мусора. В зависимости от способа передачи ссылок на новые объекты, цикл сборки мусора может занимать больше или меньше времени. |
| 85 | + |
| 86 | +Даже рассматривая вышеперечисленные факторы по отдельности, не так-то просто рассуждать о влиянии каждого, а тем более дать точный ответ, каким же должен быть размер коллекции. Чтобы в первом приближении проиллюстрировать, каким же он должен быть, приведем пример выполнения быстрой и не вызывающей побочных эффектов операции сокращения параллельного вектора (в нашем случае-- суммированием) на четырехъядерном процессоре i7 (без использования гиперпоточности) на JDK7: |
| 87 | + |
| 88 | + import collection.parallel.immutable.ParVector |
| 89 | + |
| 90 | + object Reduce extends testing.Benchmark { |
| 91 | + val length = sys.props("length").toInt |
| 92 | + val par = sys.props("par").toInt |
| 93 | + val parvector = ParVector((0 until length): _*) |
| 94 | + |
| 95 | + parvector.tasksupport = new collection.parallel.ForkJoinTaskSupport(new scala.concurrent.forkjoin.ForkJoinPool(par)) |
| 96 | + |
| 97 | + def run = { |
| 98 | + parvector reduce { |
| 99 | + (a, b) => a + b |
| 100 | + } |
| 101 | + } |
| 102 | + } |
| 103 | + |
| 104 | + object ReduceSeq extends testing.Benchmark { |
| 105 | + val length = sys.props("length").toInt |
| 106 | + val vector = collection.immutable.Vector((0 until length): _*) |
| 107 | + |
| 108 | + def run = { |
| 109 | + vector reduce { |
| 110 | + (a, b) => a + b |
| 111 | + } |
| 112 | + } |
| 113 | + } |
| 114 | + |
| 115 | +Сначала запустим тест производительности с `250000` элементами и получим следующие результаты для `1`, `2` и `4` потоков: |
| 116 | + |
| 117 | + java -server -cp .:../../build/pack/lib/scala-library.jar -Dpar=1 -Dlength=250000 Reduce 10 10 |
| 118 | + Reduce$ 54 24 18 18 18 19 19 18 19 19 |
| 119 | + java -server -cp .:../../build/pack/lib/scala-library.jar -Dpar=2 -Dlength=250000 Reduce 10 10 |
| 120 | + Reduce$ 60 19 17 13 13 13 13 14 12 13 |
| 121 | + java -server -cp .:../../build/pack/lib/scala-library.jar -Dpar=4 -Dlength=250000 Reduce 10 10 |
| 122 | + Reduce$ 62 17 15 14 13 11 11 11 11 9 |
| 123 | + |
| 124 | +Затем уменьшим количество элементов до `120000` и будем использовать `4` потока для сравнения со временем сокращения последовательного вектора: |
| 125 | + |
| 126 | + java -server -cp .:../../build/pack/lib/scala-library.jar -Dpar=4 -Dlength=120000 Reduce 10 10 |
| 127 | + Reduce$ 54 10 8 8 8 7 8 7 6 5 |
| 128 | + java -server -cp .:../../build/pack/lib/scala-library.jar -Dlength=120000 ReduceSeq 10 10 |
| 129 | + ReduceSeq$ 31 7 8 8 7 7 7 8 7 8 |
| 130 | + |
| 131 | +Похоже, что `120000` близко к пограничному значению в этом случае. |
| 132 | + |
| 133 | +В качестве еще одного примера возьмем метод `map` (метод трансформации) коллекции `mutable.ParHashMap` и запустим следующий тест производительности в той же среде: |
| 134 | + |
| 135 | + import collection.parallel.mutable.ParHashMap |
| 136 | + |
| 137 | + object Map extends testing.Benchmark { |
| 138 | + val length = sys.props("length").toInt |
| 139 | + val par = sys.props("par").toInt |
| 140 | + val phm = ParHashMap((0 until length) zip (0 until length): _*) |
| 141 | + |
| 142 | + phm.tasksupport = new collection.parallel.ForkJoinTaskSupport(new scala.concurrent.forkjoin.ForkJoinPool(par)) |
| 143 | + |
| 144 | + def run = { |
| 145 | + phm map { |
| 146 | + kv => kv |
| 147 | + } |
| 148 | + } |
| 149 | + } |
| 150 | + |
| 151 | + object MapSeq extends testing.Benchmark { |
| 152 | + val length = sys.props("length").toInt |
| 153 | + val hm = collection.mutable.HashMap((0 until length) zip (0 until length): _*) |
| 154 | + |
| 155 | + def run = { |
| 156 | + hm map { |
| 157 | + kv => kv |
| 158 | + } |
| 159 | + } |
| 160 | + } |
| 161 | + |
| 162 | +Для `120000` элементов получаем следующие значения времени на количестве потоков от `1` до `4`: |
| 163 | + |
| 164 | + java -server -cp .:../../build/pack/lib/scala-library.jar -Dpar=1 -Dlength=120000 Map 10 10 |
| 165 | + Map$ 187 108 97 96 96 95 95 95 96 95 |
| 166 | + java -server -cp .:../../build/pack/lib/scala-library.jar -Dpar=2 -Dlength=120000 Map 10 10 |
| 167 | + Map$ 138 68 57 56 57 56 56 55 54 55 |
| 168 | + java -server -cp .:../../build/pack/lib/scala-library.jar -Dpar=4 -Dlength=120000 Map 10 10 |
| 169 | + Map$ 124 54 42 40 38 41 40 40 39 39 |
| 170 | + |
| 171 | +Теперь уменьшим число элементов до `15000` и сравним с последовательным хэш-отображением: |
| 172 | + |
| 173 | + java -server -cp .:../../build/pack/lib/scala-library.jar -Dpar=1 -Dlength=15000 Map 10 10 |
| 174 | + Map$ 41 13 10 10 10 9 9 9 10 9 |
| 175 | + java -server -cp .:../../build/pack/lib/scala-library.jar -Dpar=2 -Dlength=15000 Map 10 10 |
| 176 | + Map$ 48 15 9 8 7 7 6 7 8 6 |
| 177 | + java -server -cp .:../../build/pack/lib/scala-library.jar -Dlength=15000 MapSeq 10 10 |
| 178 | + MapSeq$ 39 9 9 9 8 9 9 9 9 9 |
| 179 | + |
| 180 | +Для выбранных в этом случае коллекции и операции есть смысл сделать вычисление параллельным при количестве элементов больше `15000` (в общем случае хэш-отображения и хэш-множества возможно делать параллельными на меньших количествах элементов, чем требовалось бы для массивов или векторов). |
| 181 | + |
| 182 | +## Ссылки |
| 183 | + |
| 184 | +1. [Anatomy of a flawed microbenchmark, Brian Goetz][1] |
| 185 | +2. [Dynamic compilation and performance measurement, Brian Goetz][2] |
| 186 | + |
| 187 | + [1]: http://www.ibm.com/developerworks/java/library/j-jtp02225/index.html "flawed-benchmark" |
| 188 | + [2]: http://www.ibm.com/developerworks/library/j-jtp12214/ "dynamic-compilation" |
| 189 | + |
| 190 | + |
| 191 | + |
0 commit comments