Как работает недавно представленный Arrays.parallelPrefix(...) в Java 8?

Я наткнулся на Arrays.parallelPrefix, представленный в Java 8.

Этот перегруженный метод выполняет операцию над каждым элементом входного массива накопительным образом. Например, из документов:

Параллельно накапливает каждый элемент данного массива на месте, используя предоставленную функцию. Например, если массив изначально содержит [2, 1, 0, 3] и операция выполняет сложение, то по возвращении массив содержит [2, 3, 3, 6]. Параллельное вычисление префикса обычно более эффективно, чем последовательные циклы для больших массивов.

Итак, как Java выполняет эту задачу parallel когда операция над термином зависит от результата операции предыдущего термина и т.д.?

Я попытался ForkJoinTasks по коду сам, и они используют ForkJoinTasks, но это не так просто, как они объединяют результат, чтобы получить окончательный массив.

Ответ 1

Как объяснено в ответе Erans, эта операция использует свойство ассоциативности функции.

Тогда есть два фундаментальных шага. Первая - это фактическая операция с префиксом (в смысле требования к предыдущему элементу (элементам) для оценки), применяемая параллельно к частям массива. Результатом каждой частичной операции (идентичной результирующему последнему элементу) является смещение для оставшегося массива.

Например, для следующего массива с использованием суммы в качестве операции префикса и четырех процессоров

  4    9    5    1    0    5    1    6    6    4    6    5    1    6    9    3  

мы получаем

  4 → 13 → 18 → 19    0 →  5 →  6 → 12    6 → 10 → 16 → 21    1 →  7 → 16 → 19  
                 ↓                   ↓                   ↓                   ↓  
                19                  12                  21                  19  

Теперь мы используем ассоциативность, чтобы сначала применить операцию префикса к смещению.

                 ↓                   ↓                   ↓                   ↓  
                19         →        31         →        52         →        71  

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

                     19   19   19   19   31   31   31   31   52   52   52   52  
                      ↓    ↓    ↓    ↓    ↓    ↓    ↓    ↓    ↓    ↓    ↓    ↓  
  4   13   18   19   19   24   25   31   37   41   47   52   53   59   68   71  

Когда мы используем тот же пример для восьми потоков,

  4    9    5    1    0    5    1    6    6    4    6    5    1    6    9    3  

  4 → 13    5 →  6    0 →  5    1 →  7    6 → 10    6 → 11    1 →  7    9 → 12  
       ↓         ↓         ↓         ↓         ↓         ↓         ↓         ↓  
      13         6         5         7        10        11         7        12  

       ↓         ↓         ↓         ↓         ↓         ↓         ↓         ↓  
      13    →   19    →   24    →   31    →   41    →   52    →   59    →   71  

           13   13   19   19   24   24   31   31   41   41   52   52   59   59  
            ↓    ↓    ↓    ↓    ↓    ↓    ↓    ↓    ↓    ↓    ↓    ↓    ↓    ↓  
  4   13   18   19   19   24   25   31   37   41   47   52   53   59   68   71  

мы видим, что будет явное преимущество, даже если мы будем использовать более простую стратегию сохранения рабочих фрагментов одинаковыми для обоих этапов, другими словами, принять один свободный рабочий поток на втором этапе. Нам понадобится около ⅛n для первой фазы и ⅛n для второй, для операции потребуется всего ¼n (где n - стоимость оценки последовательного префикса всего массива). Конечно, только грубо и в лучшем случае.

Напротив, когда у нас есть только два процессора

  4    9    5    1    0    5    1    6    6    4    6    5    1    6    9    3  


  4 → 13 → 18 → 19 → 19 → 24 → 25 → 31    6 → 10 → 16 → 21 → 22 → 28 → 37 → 40  
                                     ↓                                       ↓  
                                    31                                      40  

                                     ↓                                       ↓  
                                    31                   →                  71  

                                         31   31   31   31   31   31   31   31  
                                          ↓    ↓    ↓    ↓    ↓    ↓    ↓    ↓  
  4   13   18   19   19   24   25   31   37   41   47   52   53   59   68   71  

Мы можем получить выгоду, только если переназначим работу на втором этапе. Это возможно, как уже было сказано, потому что работа на втором этапе больше не зависит от элементов. Таким образом, мы можем разделить эту операцию произвольно, хотя это усложняет реализацию и может привести к дополнительным издержкам.

Когда мы разделяем работу второй фазы между обоими процессорами, первая фаза требует около ½n, а вторая - ¼n, что дает общее количество, что по-прежнему является преимуществом, если массив достаточно большой.

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

Ответ 2

Суть в том, что оператор является

без побочных эффектов, ассоциативная функция

Это означает, что

(a op b) op c == a op (b op c)

Следовательно, если вы разбиваете массив на две половины и рекурсивно применяете метод parallelPrefix к каждой половине, вы можете позже объединить частичные результаты, применив операцию к каждому элементу второй половины массива с последним элементом первой половины.

Рассмотрим [2, 1, 0, 3] с дополнительным примером. Если вы разделите массив на две половины и выполните операцию на каждой половине, вы получите:

[2, 3]    and    [0, 3]

Затем, чтобы объединить их, вы добавляете 3 (последний элемент первой половины) к каждому элементу второй половины и получаете:

[2, 3, 3, 6]

РЕДАКТИРОВАТЬ: Этот ответ предлагает один способ параллельного вычисления префиксов массива. Это не обязательно самый эффективный способ и не обязательно способ, используемый реализацией JDK. Далее вы можете прочитать о параллельных алгоритмах для решения этой проблемы здесь.

Ответ 3

Я прочитал оба ответа и все еще не мог полностью понять, как это делается, поэтому решил вместо этого привести пример. Вот то, что я придумал, предположим, что это массив, с которого мы начинаем (с 3 процессорами):

7, 9, 6, 1, 8, 7, 3, 4, 9

Таким образом, каждый из 3-х потоков будет работать над ним:

Thread 1:  7, 9, 6
Thread 2:  1, 8, 7
Thread 3:  3, 4, 9

Поскольку документация обязывает ассоциативную функцию, мы можем вычислить сумму в первом потоке и некоторые частичные суммы в тех, а когда известна первая, - все они будут. Посмотрим 7, 9, 6 что станет 7, 9, 6:

7, 9, 6  -> 7, 16, 22

Таким образом, сумма в первом потоке равна 22 - но другие потоки об этом еще не знают, поэтому вместо этого они работают против этого как x например. Таким образом, поток 2, будет:

1, 8, 7 -> 1 (+x), 9 (+x), 16(+x) 

Таким образом, сумма из второго потока будет равна x + 16, поэтому в Thread 3 мы будем иметь:

3, 4, 9 -> 3 (+ x + 16), 7 (+ x + 16), 16 (+ x + 16)

3, 4, 9 -> x + 19, x + 23, x + 32

Таким образом, как только я узнаю x, я знаю и все остальные результаты.

Отказ от ответственности: я не уверен, что это так, как это реализовано (и я попытался посмотреть на код - но это слишком сложно).