В прошлом году я написал серию постов ( часть 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-код