В прошлом году я написал серию постов ( часть 1 , часть 2 , часть 3 ) об использовании новой платформы Java fork / join для симуляции Монте-Карло.
Во-первых, обновление. Возвращаясь к коду, я обнаружил ошибку в реализации треугольника. К счастью, это игрушечный пример, поскольку он сделал результаты неточными. Моя вина, что не юнит-тестирование. Я бы по-прежнему не предлагал использовать этот код для чего-либо, кроме изучения вилки / соединения.
Нить локальных случайных чисел
Чтобы перейти к более интересным вещам: я читал заметки о выпуске Oracle для Java SE 7 и заметил, что они включают новую возможность одновременных случайных чисел. Так как симуляция Монте-Карло генерирует миллионы случайных чисел, мне было очень интересно посмотреть, как это повлияет на его производительность.
В Javadoc for ThreadLocalRandom упоминается конфликт, когда несколько потоков используют обычные Math.random()
. Regular Math.random()
использует атомарные переменные для хранения текущего начального числа, так что два вызывающих потока одновременно получают псевдослучайные числа последовательно, а не одно и то же число дважды. В моем случае я использую отдельный экземпляр Random
для каждой случайной переменной в симуляции, но эти экземпляры распределяются по всем прогонам симуляции. В результате, существует большая вероятность раздоров, поэтому мы должны ожидать улучшения.
ThreadLocalRandom
не создается непосредственно; вместо этого есть статический метод, current()
который создает новый экземпляр при первом вызове из данного потока. Таким образом, переходя от Math.random()
к ThreadLocalRandom
мы меняем две вещи о программе:
- Мы больше не имеем права доступа к одному экземпляру генератора случайных чисел из нескольких потоков.
- Вместо того, чтобы создавать экземпляр генератора случайных чисел для каждой случайной переменной, мы создаем один экземпляр для каждого потока.
Random
против ThreadLocalRandom
Чтобы установить базовый уровень, вот новый прогон с использованием регулярного Math.random()
:
StopWatch 'Monte Carlo NPV': running time (millis) = 44637 ----------------------------------------- ms % Task name ----------------------------------------- 12202 027% Sequential 02576 006% DivideByTwo (children=2, min fork size=100) 02465 006% DivideByTwo (children=2, min fork size=500) 02615 006% DivideByTwo (children=2, min fork size=1000) 02515 006% DivideByTwo (children=2, min fork size=2000) 02502 006% DivideByP (children=8, min fork size=100) 02490 006% DivideByP (children=8, min fork size=500) 02445 005% DivideByP (children=8, min fork size=1000) 02450 005% DivideByP (children=8, min fork size=2000) 02477 006% Sqrt(n) (children=-1, min fork size=100) 02458 006% Sqrt(n) (children=-1, min fork size=500) 02466 006% Sqrt(n) (children=-1, min fork size=1000) 02468 006% Sqrt(n) (children=-1, min fork size=2000) 02508 006% Parfor (children=20000, min fork size=500)
Как обсуждалось в предыдущих статьях, переход от последовательного к параллельному гораздо важнее, чем способ разделения задачи.
Изменение очень незначительное:
// double u = r.nextDouble(); double u = ThreadLocalRandom.current().nextDouble();
Результирующее изменение является существенным:
StopWatch 'Monte Carlo NPV': running time (millis) = 34942 ----------------------------------------- ms % Task name ----------------------------------------- 11347 032% Sequential 02004 006% DivideByTwo (children=2, min fork size=100) 01831 005% DivideByTwo (children=2, min fork size=500) 01838 005% DivideByTwo (children=2, min fork size=1000) 01784 005% DivideByTwo (children=2, min fork size=2000) 01781 005% DivideByP (children=8, min fork size=100) 01782 005% DivideByP (children=8, min fork size=500) 01772 005% DivideByP (children=8, min fork size=1000) 01776 005% DivideByP (children=8, min fork size=2000) 01781 005% Sqrt(n) (children=-1, min fork size=100) 01788 005% Sqrt(n) (children=-1, min fork size=500) 01805 005% Sqrt(n) (children=-1, min fork size=1000) 01799 005% Sqrt(n) (children=-1, min fork size=2000) 01854 005% Parfor (children=20000, min fork size=500)
Улучшение составляет около 25%, что стоит того.
наблюдения
Интересно, что последовательный случай показывает меньшее улучшение, чем параллельный случай. Это имеет тенденцию показывать, что между разными потоками, обращающимися к одному и тому же генератору случайных чисел, возникла подлинная конкуренция, а не просто издержки из-за использования атомарных переменных.
Также интересно, что в последовательном случае наблюдается улучшение. Это показывает, что не весь прирост производительности был создан за счет устранения конфликтов. Это говорит о том, что даже в обычном коде Java есть преимущество использования, ThreadLocalRandom
если потребуется много случайных чисел. Это похоже на разницу между StringBuffer
и StringBuilder
; перенося безопасность потока на создание экземпляров, а не во время использования, можно улучшить производительность. Возможно, в Java-программировании существуют другие случаи, когда synchronized
используются блоки кода, которые были бы более производительными с отдельными ThreadLocal
экземплярами.
В приведенных выше числах случай «разделить на два» с двумя дочерними элементами и наименьшим размером фрагмента оказывается значительно хуже, чем в других вариантах. Обратите внимание, что этот метод порождает большинство задач, но из-за того, как работает структура fork / join, это не означает, что он порождает больше потоков, чем другие. Последующие прогоны по-прежнему показали некоторую разницу, но не так, как отмечено, поэтому это может быть не значительным.
Вывод
Фреймворк Java fork / join, на мой взгляд, является ценным вкладом в Java SE 7. ThreadLocalRandom
Класс может быть значительно меньшим и менее сложным дополнением, но, похоже, у него есть веские основания для его использования, возможно, даже в «обычном» Java-код