Объединение параллельных потоков

Предположим, что у меня есть два массива int[] input1 и input2. Я хочу принимать только положительные числа из первого, брать разные числа из второго, объединять их вместе, сортировать и хранить в результирующем массиве. Это можно выполнить с помощью потоков:

int[] result = IntStream.concat(Arrays.stream(input1).filter(x -> x > 0), 
                   Arrays.stream(input2).distinct()).sorted().toArray();

Я хочу ускорить задачу, поэтому я считаю, что поток параллелен. Обычно это означает, что я могу вставить .parallel() где угодно между построением потока и работой терминала, и результат будет таким же. JavaDoc для IntStream.concat говорит, что результирующий поток будет параллельным, если любой из входных потоков будет параллельным. Поэтому я думал, что создание потока parallel() потока потока input1 или потока input2 или конкатенированного потока приведет к такому же результату.

На самом деле я ошибся: если я добавлю .parallel() к результирующему потоку, кажется, что входные потоки остаются последовательными. Кроме того, я могу пометить входные потоки (любой из них или обоих) как .parallel(), а затем превратить результирующий поток в .sequential(), но вход остается параллельным. Так что на самом деле существует 8 возможностей: любой из входных 1, input2 и конкатенированный поток может быть параллельным или нет:

int[] sss = IntStream.concat(Arrays.stream(input1).filter(x -> x > 0),
                Arrays.stream(input2).distinct()).sorted().toArray();
int[] ssp = IntStream.concat(Arrays.stream(input1).filter(x -> x > 0),
                Arrays.stream(input2).distinct()).parallel().sorted().toArray();
int[] sps = IntStream.concat(Arrays.stream(input1).filter(x -> x > 0), 
                Arrays.stream(input2).parallel().distinct()).sequential().sorted().toArray();
int[] spp = IntStream.concat(Arrays.stream(input1).filter(x -> x > 0), 
                Arrays.stream(input2).parallel().distinct()).sorted().toArray();
int[] pss = IntStream.concat(Arrays.stream(input1).parallel().filter(x -> x > 0),
                Arrays.stream(input2).distinct()).sequential().sorted().toArray();
int[] psp = IntStream.concat(Arrays.stream(input1).parallel().filter(x -> x > 0),
                Arrays.stream(input2).distinct()).sorted().toArray();
int[] pps = IntStream.concat(Arrays.stream(input1).parallel().filter(x -> x > 0),
                Arrays.stream(input2).parallel().distinct()).sequential().sorted().toArray();
int[] ppp = IntStream.concat(Arrays.stream(input1).parallel().filter(x -> x > 0),
                Arrays.stream(input2).parallel().distinct()).sorted().toArray();

I сравнили все версии для разных размеров ввода (используя JDK 8u45 64 бит на Core i5 4xCPU, Win7) и получили разные результаты для каждого случая:

Benchmark           (n)  Mode  Cnt       Score       Error  Units
ConcatTest.SSS      100  avgt   20       7.094 ±     0.069  us/op
ConcatTest.SSS    10000  avgt   20    1542.820 ±    22.194  us/op
ConcatTest.SSS  1000000  avgt   20  350173.723 ±  7140.406  us/op
ConcatTest.SSP      100  avgt   20       6.176 ±     0.043  us/op
ConcatTest.SSP    10000  avgt   20     907.855 ±     8.448  us/op
ConcatTest.SSP  1000000  avgt   20  264193.679 ±  6744.169  us/op
ConcatTest.SPS      100  avgt   20      16.548 ±     0.175  us/op
ConcatTest.SPS    10000  avgt   20    1831.569 ±    13.582  us/op
ConcatTest.SPS  1000000  avgt   20  500736.204 ± 37932.197  us/op
ConcatTest.SPP      100  avgt   20      23.871 ±     0.285  us/op
ConcatTest.SPP    10000  avgt   20    1141.273 ±     9.310  us/op
ConcatTest.SPP  1000000  avgt   20  400582.847 ± 27330.492  us/op
ConcatTest.PSS      100  avgt   20       7.162 ±     0.241  us/op
ConcatTest.PSS    10000  avgt   20    1593.332 ±     7.961  us/op
ConcatTest.PSS  1000000  avgt   20  383920.286 ±  6650.890  us/op
ConcatTest.PSP      100  avgt   20       9.877 ±     0.382  us/op
ConcatTest.PSP    10000  avgt   20     883.639 ±    13.596  us/op
ConcatTest.PSP  1000000  avgt   20  257921.422 ±  7649.434  us/op
ConcatTest.PPS      100  avgt   20      16.412 ±     0.129  us/op
ConcatTest.PPS    10000  avgt   20    1816.782 ±    10.875  us/op
ConcatTest.PPS  1000000  avgt   20  476311.713 ± 19154.558  us/op
ConcatTest.PPP      100  avgt   20      23.078 ±     0.622  us/op
ConcatTest.PPP    10000  avgt   20    1128.889 ±     7.964  us/op
ConcatTest.PPP  1000000  avgt   20  393699.222 ± 56397.445  us/op

Из этих результатов я могу только заключить, что распараллеливание шага distinct() снижает общую производительность (по крайней мере, в моих тестах).

Итак, у меня есть следующие вопросы:

  • Существуют ли какие-либо официальные рекомендации о том, как лучше использовать распараллеливание с конкатенированными потоками? Не всегда возможно проверить все возможные комбинации (особенно при конкатенации более двух потоков), поэтому иметь некоторые "эмпирические правила" было бы неплохо.
  • Кажется, что если я конкатенирую потоки, созданные непосредственно из коллекции/массива (без промежуточных операций, выполняемых до конкатенации), то результаты не так сильно зависят от местоположения parallel(). Это правда?
  • Существуют ли какие-либо другие случаи, кроме конкатенации, где результат зависит от того, в какой момент расчленен потоковый конвейер?

Ответ 1

Спецификация точно описывает то, что вы получаете - если учесть, что в отличие от других операций мы говорим не о одном конвейере, а о трех разных Stream, которые сохраняют свои свойства независимо от других.

В спецификации указано: "Результирующий поток [...] параллелен, если любой из входных потоков параллелен". и вот что вы получаете; если входной поток параллелен, результирующий поток параллелен (но вы можете повернуть его последовательно после этого). Но изменение результирующего потока на параллельный или последовательный не изменяет характер входных потоков и не подает параллельный и последовательный поток в concat.

Что касается последствий производительности, обратитесь к документации параграф "Потоковые операции и трубопроводы" :

Промежуточные операции далее подразделяются на операции без состояния и состояния. Операции без сохранения, такие как filter и map, не сохраняют состояние из ранее увиденного элемента при обработке нового элемента - каждый элемент может обрабатываться независимо от операций над другими элементами. Операции с состоянием, такие как distinct и sorted, могут включать состояние из ранее увиденных элементов при обработке новых элементов.

Операции со статусом могут потребовать обработки всего ввода до получения результата. Например, нельзя сортировать поток до тех пор, пока не будет видно все элементы потока. В результате при параллельном вычислении некоторые конвейеры, содержащие промежуточные операции с промежуточным состоянием, могут потребовать нескольких проходов на данные или могут потребовать буферизации значительных данных. Трубопроводы, содержащие исключительно промежуточные операции без состояния, могут обрабатываться за один проход, будь то последовательный или параллельный, с минимальной буферизацией данных.

Вы выбрали две именованные операции с состоянием и объединили их. Таким образом, операция .sorted() результирующего потока требует буферизации всего содержимого, прежде чем он сможет начать сортировку, что подразумевает завершение операции distinct. Очевидно, что отдельная операция трудно распараллеливать, поскольку потоки должны синхронизироваться относительно уже увиденных значений.

Итак, чтобы ответить на первый вопрос, его вопрос не о concat, а просто о том, что distinct не работает при параллельном выполнении.

Это также делает ваш второй вопрос устаревшим, поскольку вы выполняете совершенно разные операции в двух конкатенированных потоках, поэтому вы не можете делать то же самое с предварительно конкатенированной коллекцией/массивом. Конкатенация массивов и запуск distinct в результирующем массиве вряд ли приведут к лучшим результатам.

Что касается вашего третьего вопроса, то поведение flatMap в отношении потоков parallel может быть источником сюрпризов...