public static BigInteger streamedParallel(int n) {
if(n < 2) return BigInteger.valueOf(1);
return IntStream.rangeClosed(2, n).parallel().mapToObj(BigInteger::valueOf).reduce(BigInteger::multiply).get();
}
К сожалению, в статье 5nw нет подробностей замера производительности. Сколько тестов проводилось? Был ли разогрев? Оценивалась ли погрешность замеров времени? Не выкосил ли JIT-компилятор часть вычислений, потому что их результаты не использовались? А если использовались (например, полученное число выводилось в файл), то исключён ли факт использования из подсчёта времени? В этом плане хочу сказать спасибо Алексею Шипилёву, который своей библиотекой JMH, а также многочисленными презентациями и статьями привил какую-никакую культуру бенчмаркинга в Java-сообществе.
У меня будет такой код бенчмарка:
import org.openjdk.jmh.infra.Blackhole;
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.IntStream;
import java.math.BigInteger;
@Warmup(iterations=5)
@Measurement(iterations=10)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Benchmark)
@Fork(2)
public class Factorial {
@Param({"10", "100", "1000", "10000", "50000"})
private int n;
public static BigInteger naive(int n) {
BigInteger r = BigInteger.valueOf(1);
for (int i = 2; i <= n; ++i)
r = r.multiply(BigInteger.valueOf(i));
return r;
}
public static BigInteger streamed(int n) {
if(n < 2) return BigInteger.valueOf(1);
return IntStream.rangeClosed(2, n).mapToObj(BigInteger::valueOf).reduce(BigInteger::multiply).get();
}
public static BigInteger streamedParallel(int n) {
if(n < 2) return BigInteger.valueOf(1);
return IntStream.rangeClosed(2, n).parallel().mapToObj(BigInteger::valueOf).reduce(BigInteger::multiply).get();
}
@Benchmark
public void testNaive(Blackhole bh) {
bh.consume(naive(n));
}
@Benchmark
public void testStreamed(Blackhole bh) {
bh.consume(streamed(n));
}
@Benchmark
public void testStreamedParallel(Blackhole bh) {
bh.consume(streamedParallel(n));
}
}
Я сравнил три реализации — наивную, на обычном потоке и на параллельном потоке. Разумно проверить алгоритм для различных значений n — 10, 100, 1000, 10000 и 50000, чтобы представить динамику изменения результатов. Проводится пять итераций разогрева и десять итераций с замером, всё это повторяется дважды (с перезапуском Java-машины), то есть для каждого теста делается 20 замеров. Обратите внимание на чёрную дыру (Blackhole): она нужна, чтобы JIT-компилятор не удалил результат вычислений, решив, что он всё равно не используется.
Протестировал я на ноутбуке с процессором Core i7-4702MQ (8 хардварных тредов). Получаются вот такие результаты:
Benchmark (n) Mode Cnt Score Error Units
Factorial.testNaive 10 avgt 20 0.298 ± 0.005 us/op
Factorial.testNaive 100 avgt 20 5.113 ± 0.195 us/op
Factorial.testNaive 1000 avgt 20 278.601 ± 9.586 us/op
Factorial.testNaive 10000 avgt 20 32232.618 ± 889.512 us/op
Factorial.testNaive 50000 avgt 20 972369.158 ± 29287.950 us/op
Factorial.testStreamed 10 avgt 20 0.339 ± 0.021 us/op
Factorial.testStreamed 100 avgt 20 5.432 ± 0.246 us/op
Factorial.testStreamed 1000 avgt 20 268.366 ± 4.859 us/op
Factorial.testStreamed 10000 avgt 20 30429.526 ± 435.611 us/op
Factorial.testStreamed 50000 avgt 20 896719.207 ± 7995.599 us/op
Factorial.testStreamedParallel 10 avgt 20 6.470 ± 0.515 us/op
Factorial.testStreamedParallel 100 avgt 20 11.280 ± 1.094 us/op
Factorial.testStreamedParallel 1000 avgt 20 74.174 ± 2.647 us/op
Factorial.testStreamedParallel 10000 avgt 20 2826.643 ± 52.831 us/op
Factorial.testStreamedParallel 50000 avgt 20 49196.070 ± 464.634 us/op
Наивный тест в целом подтверждает теоретическое предположение о квадратичой сложности алгоритма. Разделим время на n?:
n = 10: 0.002980
n = 100: 0.000511
n = 1000: 0.000279
n = 10000: 0.000322
n = 50000: 0.000389
Прирост на больших значениях, вероятно, связан с увеличением расхода памяти и активизацией сборщика мусора.
Нераспараллеленный поток для малых n работает ожидаемо несколько большее время (на 13% для n = 10 и на 6% для n = 100): всё же обвязка Stream API что-то съедает. Однако удивительно, что для больших n потоковый вариант работает на 4-8% быстрее, хотя алгоритмически выглядит так же. Очередное подтверждение тому, что о производительности опасно рассуждать теоретически, не проводя замеры. Оптимизации JIT-компилятора, кэширование процессора, предсказание ветвления и прочие факторы очень трудно учесть в теории.
Распараллеленный поток ожидаемо существенно медленнее для очень коротких операций. Однако прирост скорости наблюдается уже при n = 1000, когда полный расчёт занимает около 270 мкс в последовательном режиме и только 75 в параллельном. Это отлично согласуется со Stream Parallel Guidance (спасибо apangin за ссылку), где сказано, что распараллеливать имеет смысл задачи, которые выполняются дольше 100 мкс. При больших же значениях n распараллеленный поток на коне: мы получаем прирост скорости в 18 раз. В целом это согласуется с приростом у 5nw, помноженным на число потоков (1.7/0.8*8 = 17).
У ForkJoinPool очень маленький оверхед, поэтому даже миллисекундная задача получает выигрыш по скорости. При этом алгоритмы вида «разделяй и властвуй» естественным образом перекладываются на параллельные потоки, благодаря чему параллельный поток может оказаться существенно быстрее последовательного. Stream API многие ругают, но за параллелизмом всё же будущее.
Комментарии (44)
PsyHaSTe
15.04.2015 15:45+1Добавьте, пожалуйста, улучшенный наивный алгоритм в сравнение, интересно, сколько получится
lany Автор
16.04.2015 08:06Я бы не назвал ваш алгоритм наивным. Он по сути ближе к древовидному, просто вы устанавливаете фиксированную глубину дерева. Пусть он называется fourBlocks :-) Если уж сравнивать, интересно ещё проверить версию с отдельным подсчётом степеней двойки (причём в параллельном и в последовательном варианте).
Изменённый код бенчмаркаimport org.openjdk.jmh.infra.Blackhole; import org.openjdk.jmh.annotations.*; import java.util.concurrent.TimeUnit; import java.util.stream.IntStream; import java.math.BigInteger; @Warmup(iterations=5) @Measurement(iterations=10) @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.MICROSECONDS) @State(Scope.Benchmark) @Fork(2) public class Factorial { private static final BigInteger ONE = BigInteger.valueOf(1); @Param({"10", "100", "1000", "10000", "50000"}) private int n; public static BigInteger naive(int n) { BigInteger r = ONE; for (int i = 2; i <= n; ++i) r = r.multiply(BigInteger.valueOf(i)); return r; } public static BigInteger streamed(int n) { if(n < 2) return ONE; return IntStream.rangeClosed(2, n).mapToObj(BigInteger::valueOf).reduce(BigInteger::multiply).get(); } public static BigInteger streamedParallel(int n) { if(n < 2) return ONE; return IntStream.rangeClosed(2, n).parallel().mapToObj(BigInteger::valueOf).reduce(BigInteger::multiply).get(); } public static BigInteger fourBlocks(int n) { if(n < 2) return ONE; BigInteger r1 = ONE, r2 = ONE, r3 = ONE, r4 = ONE; int i; for (i = n; i > 4; i -= 4) { r1 = r1.multiply(BigInteger.valueOf(i)); r2 = r2.multiply(BigInteger.valueOf(i - 1)); r3 = r3.multiply(BigInteger.valueOf(i - 2)); r4 = r4.multiply(BigInteger.valueOf(i - 3)); } int mult = i == 4 ? 24 : i == 3 ? 6 : i == 2 ? 2 : 1; return r1.multiply(r2).multiply(r3.multiply(r4)).multiply(BigInteger.valueOf(mult)); } public static BigInteger streamedShift(int n) { if(n < 2) return ONE; int p = 0, c = 0; while ((n >> p) > 1) { p++; c += n >> p; } return IntStream.rangeClosed(2, n).map(i -> i >> Integer.numberOfTrailingZeros(i)) .mapToObj(BigInteger::valueOf).reduce(BigInteger::multiply).get().shiftLeft(c); } public static BigInteger streamedParallelShift(int n) { if(n < 2) return ONE; int p = 0, c = 0; while ((n >> p) > 1) { p++; c += n >> p; } return IntStream.rangeClosed(2, n).parallel().map(i -> i >> Integer.numberOfTrailingZeros(i)) .mapToObj(BigInteger::valueOf).reduce(BigInteger::multiply).get().shiftLeft(c); } @Benchmark public void testNaive(Blackhole bh) { bh.consume(naive(n)); } @Benchmark public void testStreamed(Blackhole bh) { bh.consume(streamed(n)); } @Benchmark public void testStreamedParallel(Blackhole bh) { bh.consume(streamedParallel(n)); } @Benchmark public void testFourBlocks(Blackhole bh) { bh.consume(fourBlocks(n)); } @Benchmark public void testStreamedShift(Blackhole bh) { bh.consume(streamedShift(n)); } @Benchmark public void testStreamedParallelShift(Blackhole bh) { bh.consume(streamedParallelShift(n)); } }
lany Автор
16.04.2015 08:31Вот результаты:
Benchmark (n) Mode Cnt Score Error Units Factorial.testFourBlocks 10 avgt 20 0.409 ± 0.027 us/op Factorial.testFourBlocks 100 avgt 20 4.752 ± 0.147 us/op Factorial.testFourBlocks 1000 avgt 20 113.801 ± 7.159 us/op Factorial.testFourBlocks 10000 avgt 20 10626.187 ± 54.785 us/op Factorial.testFourBlocks 50000 avgt 20 281522.808 ± 13619.674 us/op Factorial.testNaive 10 avgt 20 0.297 ± 0.002 us/op Factorial.testNaive 100 avgt 20 5.060 ± 0.036 us/op Factorial.testNaive 1000 avgt 20 277.902 ± 1.311 us/op Factorial.testNaive 10000 avgt 20 32471.921 ± 1092.640 us/op Factorial.testNaive 50000 avgt 20 970355.227 ± 64386.653 us/op Factorial.testStreamed 10 avgt 20 0.326 ± 0.002 us/op Factorial.testStreamed 100 avgt 20 5.393 ± 0.190 us/op Factorial.testStreamed 1000 avgt 20 265.550 ± 1.772 us/op Factorial.testStreamed 10000 avgt 20 29871.366 ± 234.457 us/op Factorial.testStreamed 50000 avgt 20 894549.237 ± 5453.425 us/op Factorial.testStreamedParallel 10 avgt 20 6.114 ± 0.500 us/op Factorial.testStreamedParallel 100 avgt 20 10.719 ± 0.786 us/op Factorial.testStreamedParallel 1000 avgt 20 72.225 ± 0.509 us/op Factorial.testStreamedParallel 10000 avgt 20 2811.977 ± 14.599 us/op Factorial.testStreamedParallel 50000 avgt 20 49501.716 ± 729.646 us/op Factorial.testStreamedParallelShift 10 avgt 20 6.684 ± 0.549 us/op Factorial.testStreamedParallelShift 100 avgt 20 11.176 ± 0.779 us/op Factorial.testStreamedParallelShift 1000 avgt 20 71.056 ± 3.918 us/op Factorial.testStreamedParallelShift 10000 avgt 20 2641.108 ± 142.571 us/op Factorial.testStreamedParallelShift 50000 avgt 20 46480.544 ± 405.648 us/op Factorial.testStreamedShift 10 avgt 20 0.402 ± 0.006 us/op Factorial.testStreamedShift 100 avgt 20 5.086 ± 0.039 us/op Factorial.testStreamedShift 1000 avgt 20 237.279 ± 1.566 us/op Factorial.testStreamedShift 10000 avgt 20 27572.709 ± 135.489 us/op Factorial.testStreamedShift 50000 avgt 20 874699.213 ± 53645.087 us/op
fourBlocks сравним с наивным при n = 100 и даёт существенный выигрыш уже на 1000. Думаю, внутри 18-кратного увеличения производительности параллельного метода выигрыш от параллелизма максимум четырёхкратный (всё же 4 ядра HT — это не 8 ядер), а остальное — от правильного разбиения данных, так как параллельный алгоритм выигрывает у вашего всего в 5.7 раз при n = 50000. Битовый сдвиг вопреки ожиданиям большого прироста не дал. Вероятно, внутри себя BigInteger сам хорошо с этим справляется и незначительную экономию мы видим лишь за счёт уменьшения потребляемой памяти и облегчения работы сборщику мусора.PsyHaSTe
16.04.2015 10:52Спасибо, интересные результаты. Жалко на шарпе не могу проверить — не нашел пока бенчмарков, поддерживающих многопоток. Существующие только запускают на неактивном ядре все тесты, чтобы от переключения контекста на «виндовых» ядрах не менялись результаты тестирования. Тем более, стандартная библиотека по каким-то эвристикам не всегда параллелит на все ядра, типа если задача не тяжелая… Хотя очевидно, что выигрыш мог бы быть достигнут этим.
Krypt
16.04.2015 15:33+1Где-нибудь можно посмотреть исходники этих реализаций? *зачёркнуто* Чукча не читатель
У меня получилось вот так:
Naive
2,214s
Tree
1,118s
OptimizedThreading
0,273s
Время — среднее за 10 проходов, каких-то исключительных пиков не наблюдалось в отдельных методах
Система — Core i5 2500, Win7 x64
Другими словами — мне дописывать статью, или уже бессмысленно? :)
lany Автор
16.04.2015 16:23+1Я не знаю, о чём вы собрались писать статью :-) Моя была о том, что Java Stream API исключительно круто сочетается с алгоритмами вида «разделяй и властвуй». Если вы хотите о чём-то другом написать, пожалуйста, пишите :-)
PsyHaSTe
16.04.2015 18:07Эх, аналог на шарпе ускоряет максимум в 2 раза, увы… Даже не знаю, кого винить, оригинальную имплементацию BigInteger или кривой TPL…
static BigInteger StreamedParallel(int n) { return n < 2 ? 1 : Enumerable.Range(2, n - 1).AsParallel().Aggregate(BigInteger.One, (acc, elm) => acc * elm, (i, j) => i * j, i => i); }
Особенно печально, что у вас StreamedParallel выполняется за 700 микросекунд, в то время как на шарпе это 700 миллисекунд. Ну и ускорение относительно naive только в 2 раза, как я уже сказал… Всегда считал, что шарп быстрее джавы во всем. Серьезный удар.PsyHaSTe
16.04.2015 18:16Честно говоря, даже не верится, что на джаве такие результаты. Вон, человек скидывал тестирование на GMP, даже на плюсах получается 23мс, что в 4 раза больше testStreamed 5,5 мс, в которой нет никакой параллелизации, на которую можно было бы списать такой прирост.
lany Автор
16.04.2015 18:21Вы путаете что-то. В колонке (n) число, от которого брали факториал. Вас должны интересовать строки с n = 50000. А результата 700 микросекунд я вообще не вижу.
PsyHaSTe
16.04.2015 18:28+1Блин, я осел, всё это время смотрел на колонку СКО, а не на результаты измерений. Тогда всё в порядке, извиняюсь за ложную тревогу.
Но всё-равно обидно. Streamed в шарпе и в джаве по скорости совпадают, а вот многопоточный вариант абсолютно в выполнении ничего не меняет — 5% прирост относительно однопоточного варианта. Никакого x19 прироста не наблюдается.Krypt
16.04.2015 19:23x19 от простого мультитридинга быть не может. В идеальном варианте только ускорение в N раз, где N — число физических ядер. Там ещё что-то происходит.
Мне на await/async удалось получить ускорение порядка 3.5 раза на 4х ядрах, но там есть пара извращений.
xoxulin
16.04.2015 11:39А я правильно понимаю, что таким же образом можно распараллелить и посчитать на GPU?
lany Автор
16.04.2015 11:44+1В принципе можно, но с помощью Java и в одну строчку — вряд ли. Ждём результатов проекта Sumatra.
xoxulin
16.04.2015 11:44Если использовать целочисленные вектора размерности 3 или 4 то можно еще быстрее посчитать?
lany Автор
16.04.2015 11:53Не понимаю, о чём вы. Приведите код, иллюстрирующий вашу идею.
xoxulin
16.04.2015 12:13Я про OpenGL www.opengl.org/wiki/Data_Type_(GLSL)#Vectors
Если правильно понял идею, то формируя входной буфер из целочисленных векторов int4(x,y,z,w) и скармливая шейдеру, который для каждого вектора посчитает произведение его координат, шейдер вернет новый буфер в 4 раза короче исходного, его снова отдаем на вычисление тому же шейдеру и тд, пока не останется буфер из одного числа. Таким образом используя ядра GPU, можно достигнуть большей степени параллелизации. Единственное ограничение 32-битный int, так мы быстро упремся.lany Автор
16.04.2015 12:22У меня очень маленький опыт программирования шейдеров. Тут основная проблема в том, что нужно перемножать числа длиной в тысячи и десятки тысяч цифр. Умножение длинных чисел весьма нетривиальная процедура. В Java BigInteger используется три алгоритма в зависимости от длины чисел — наивный, алгоритм Карацубы и алгоритм Тума—Кука. GPU не заточены напрямую под длинную арифметику, вам потребуется библиотека. Не знаю, насколько хорошо с этим в GLSL.
PsyHaSTe
16.04.2015 12:27Факториал 12 уже не влезает в int32, соответственно 24! не влезет уже в лонги, поэтому на видюхе вряд ли что удастся посчитать. Если изобрести собственный Biginteger, чтобы считать на видео/SSE, манипулируя только байтами-вордами да интами, то тогда наверное можно.
xoxulin
16.04.2015 12:25Вангую, что в Open CL на крайний случай есть 64-bit и вектора длиной до 16. Вполне вероятно, что и аналогичные алгоритмы тоже могут иметь место.
Krypt
16.04.2015 15:11Гм. А как значения из таблицы перевести в среднее время? Подскажите для не знакомых с этим фреймворком.
encyclopedist
16.04.2015 15:44Score = время работы в микросекундах. Там в последнем столбце единицы измерения указаны.
Krypt
16.04.2015 15:53А как этот фреймворк параллелит длинное умножение? Мне просто интересно, откуда 20ти кратный прирост, что я упустил
И не смогли бы вы запустить на вашей машине оригинальный код?
Измерить время можно с помощью System.Diagnostics.Stopwatch. Либо я могу скинуть тестовый кодlany Автор
16.04.2015 16:21Каждое отдельное умножение не параллелится, разумеется. Параллелятся ветки дерева умножений. Не сказал бы, что это фреймворк — это стандартная библиотека Java. Фреймворк JMH использовался только для замера производительности, а сама реализация факториала (в самом верху поста) не требует ничего кроме Java SE 1.8.
Сравнивать напрямую Java и C# может быть не очень уместно. Не факт, что реализация BigInteger в них похожа. И представлять в памяти, и умножать длинные целые можно кучей способов.
А что требуется установить, чтобы скомпилировать ваш код? Я с шарпами как-то не работаю. Лучше вы мой код у себя запустите ;-)Krypt
16.04.2015 16:52Я пытался параллелить только корень, но это уже даёт 100% загрузку процессора. В идеале, распаралеливание, на 4 потока может дать только 4х кратный прирост.
Узким местом, впрочем, оказалось умножение последнего элемента — при любая финальной комбинация вызывается за 0.54 секунды
Собственно вот (однократная проверка, быстродействие, по наблюдениям, плавает в пределах 5%):
2-25000: 00:00:00.2458574 // thread 1
25001-50000: 00:00:00.3246583 // thread 2
finalizing: 00:00:00.5395141
total: 00:00:00.8882113
C# использует алгоритм Карацубы для умножения длинных чисел.
Но умножение, как мне показалось, реализовано крайне неэффективно. Собственно, тот же битовый сдвиг даёт прирост скорости почти в 2 раза для финальной операции. Собственно большенство плясок там именно вокруг этого.
Для того, чтобы скомпилировать код можно обойтись одним .NET Framework'ом, но лучше — какую-нибудь Visual Studio с поддержкой C#. Скинуть код смогу только этак в 22:00 МСКlany Автор
16.04.2015 18:16В Java для таких больших чисел уже Тоом—Кук используется. Ну очевидно, что у Java получается гораздо быстрее сделать последнее умножение. Я добавил отладочный вывод, получилось, что задача разбивается на 16 последовательных подзадач 2..3125, 3126..6250 и т. д. до 46876..50000, а потом результаты перемножаются древовидным способом, но по факту всё равно получается, что последнее умножение — это произведение результатов 2..25000 и 25001..50000. В перемножаемых числах 329183 и 379175 бит. Возможно, в Java реализация умножения действительно сделана лучше. Либо вам всё же стоит разогреть свой код.
5nw
16.04.2015 18:56Вряд ли в С# Карацуба, я больше склоняюсь к тому что там немного оптимизированный квадрат
PsyHaSTe
16.04.2015 19:22System.Numerics.BigInteger uses the Karatsuba algorithm or standard schoolbook multiplication, depending on the size of
stackoverflow.com/a/3066062/2559709Krypt
16.04.2015 19:29Я тоже руководствовался этим ответом, но надо бы декомпилировать System.Numerics. Я этого не сделал из-за того, что рефлектор с первого раза неймспейс не подцепил :)
PsyHaSTe
16.04.2015 19:33+1Существует таинство открытых исходников .Net, известное только посвященным. Для просмотра исходников не нужен рефлектор, можно даже увидеть их с комментариями, оригинальными названиями переменных и директивами условной компиляции (чего декомпилятор не умеет и не будет уметь никогда)
http://referencesource.microsoft.com/#System.Numerics/System/Numerics/BigInteger.cs,1543
http://referencesource.microsoft.com/#System.Numerics/System/Numerics/BigIntegerBuilder.cs,609
DanteXXI
20.04.2015 15:04-1Спасибо за интересную статью. Хотелось бы ещё в тесте увидеть такую реализацию факториала:
public static BigInteger recursiveFactorial(int i) { BigInteger r = BigInteger.valueOf(1); if (i == 1) { return r; } r = BigInteger.valueOf(i); return r.multiply(factorial(i - 1)); }
PsyHaSTe
20.04.2015 19:08Нет, не хочется :)
А если серьезно, то имхо, стек сдохнет. Сколько каждый BigInteger занимает памяти, умножаем на по крайней мере 50000 кадров стека, а стек-то всего пара мегабайт, если мне память не изменяет.
Повезет, если компилятор развернет хвостовой вызов. Но в таком случае получим тот же цикл, пример которого в сравнении есть.lany Автор
21.04.2015 15:32+2Java-компилятор не может развернуть хвостовой вызов.
Сколько занимает BigInteger — никого не волнует, он лежит в куче. Но глубина стека в 50000 — это действительно очень много, StackOverflowError случится гораздо раньше.grossws
21.04.2015 17:16В clojure есть специальная форма (recur ...), которая позволяет сделать вид, что выполняется tail recursive вызов.
В scala есть автоматическая оптимизация tail rec с ограничениями (proper tail call). Т. е. оптимизируется только в случае возможности переиспользования stack frame. Плюс есть аннотация@tailrec
, гарантирующая, что при невозможности этой оптимизации будет compile-time error. Причём, в случае scala для применения оптимизации необходимо гарантировать, что метод не будет переопределён (private или final). См. tech.pro/blog/2112/scala-tail-recursion-optimisation-and-comparison-to-javalany Автор
21.04.2015 17:51+1Я всё это знаю. Я говорил конкретно про Java. В Java хвостовая рекурсия, насколько я понимаю, не может быть реализована без нарушения JLS.
grossws
21.04.2015 17:54А она не отдана просто на откуп JIT'у? Т. е. unspecified? На SO видел что-то в этом роде, но пруфлинки на сайт ibm уже к тому моменту протухли.
dom1n1k
Я правильно понимаю, что без распараллеливания «быстрый» алгоритм оказался лишь незначительно быстрее наивного?
lany Автор
Нераспараллеленный поток не является «быстрым» алгоритмом, потому что он выполняется слева направо без всяких хитростей. А вот если распараллеленный ограничить одним ядром (например, используя свойство java.util.concurrent.ForkJoinPool.common.parallelism), он будет жрать одно ядро, но выполнится быстрее последовательного потока.