Есть ли причина не использовать Java 8 parallelSort?

Я читал этот вопрос о различиях между Java Arrays.sort и Arrays.parallelSort, которому уже несколько лет. Что меня удивило, так это то, что был только один вопрос, в котором упоминалось о недостатках использования parallelSort; а именно, что ускорение уменьшается, если вы используете много вашего процессора.

Предполагая, что вы не находитесь в какой-то специализированной однопоточной среде, всегда ли следует выбирать parallelSort сортировку? Есть ли причина не делать этого? Обратите внимание, что в одном из ответов на вопрос, приведенный выше, упоминается, что если имеется менее 4096 элементов, parallelSort любом случае просто вызывает sort.

Ответы

Ответ 1

Есть некоторые недостатки использования Arrays.parallelSort

  • он использует ForkJoinPool.commonPool() и будет бороться с другими функциями, которые используют его по умолчанию (например, parallel() в потоке)
  • Arrays.parallelSort используемый в пуле Arrays.parallelSort не настраивается (только на глобальном уровне путем увеличения количества потоков в общих пулах)
  • он работает хуже на небольших наборах данных (чаще всего массивы содержат мало элементов, JDK даже признает, что, например, большинство ArrayList остаются пустыми в течение всего срока их службы, что экономит довольно много памяти и времени ЦП, не создавая экземпляров массивов, которые никогда не будут заполнены )

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

Но да, если у вас большие массивы и сортировка является существенной частью времени выполнения ваших приложений, используйте Arrays.parallelSort.

РЕДАКТИРОВАТЬ: И даже если Arrays.parallelSort переключается на нормальную сортировку, если данный массив содержит менее 4096 элементов: все это о намерении - вы хотите параллельную сортировку, если это возможно, которая имеет другое значение, чем просто вызов sort. И быть придирчивым: он действительно работает хуже на небольших массивах, так как он должен выполнить дополнительную проверку, если массив содержит менее 4096 элементов, и некоторые другие проверки о количестве потоков в общих пулах (эти накладные расходы, конечно, незначительны) :),

Ответ 2

Это мало чем отличается от вопроса о том, когда использовать stream() и parallelStream() - это зависит от того, сколько у вас данных. Конечно, большую часть времени при параллельной сортировке 10 элементов будет занимать многопоточная структура, которая находится под колпаком (которая не указана в документации), а не сама сортировка.

Но вы также должны задаться вопросом, почему такие методы введены IMO. Аппаратное обеспечение движется (уже переместилось?) Ко многим процессорам, не более GHz, поэтому параллельная работа - это нормальный курс для любого языка, который хочет остаться живым в течение следующих 20 лет.

Относительно того, сколько данных вам нужно для обеспечения parallelSort sort, а не sort, плюс знание того, что нам нужно как минимум MIN_ARRAY_SORT_GRAN + 1 чтобы получить потенциальную выгоду; написание правильного теста, чтобы доказать, что для этой конкретной установки и запуска вам понадобится хотя бы число X, не так уж сложно. Вы также должны принять во внимание, что некоторые массивы могут быть уже отсортированы (объяснено далее), в то время как некоторые могут быть полностью не отсортированы (например, 5,4,3,2,1), это влечет за собой штрафы за второй.

Взяв некоторые случайные данные и сделав тест:

@Warmup(iterations = 10)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Measurement(iterations = 2, time = 2, timeUnit = TimeUnit.SECONDS)
public class ParallelSort {

    public static void main(String[] args) throws Exception {
        Options opt = new OptionsBuilder()
            .include(ParallelSort.class.getName())
            .build();

        new Runner(opt).run();
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @Fork(1)
    public int[] parallel(ParallelSortExecutionPlan plan) {
        Arrays.parallelSort(plan.ints());
        return plan.ints();
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @Fork(1)
    public int[] nonParallel(ParallelSortExecutionPlan plan) {
        Arrays.sort(plan.ints());
        return plan.ints();
    }
}


@State(Scope.Benchmark)
public class ParallelSortExecutionPlan {

    @Param(value = {"10", "100", "1000", "10000", "100000", "1000000"})
    private int howMany;

    private int[] ints;

    public static void main(String[] args) {
    }

    @Setup(Level.Invocation)
    public void setUp() {
        ints = new int[howMany];
        for (int i = 0; i < howMany; ++i) {
            ints[i] = ThreadLocalRandom.current().nextInt();
        }
    }

    int[] ints() {
        return ints;
    }
}

Просто обратите внимание, что второй класс использует @Setup(Level.Invocation) (если вы немного знакомы с JMH) - здесь это очень острый инструмент; но я использую его, потому что я хочу несортированный массив для каждого Invocation метода. В противном случае, если бы, например, использовался Trial - только первый вызов был бы @Benhcmark массивом, все остальные вызовы метода @Benhcmark уже были бы отсортированы. Для удовольствия, вы можете изменить одну строку на @Setup(Level.Trial) например, и посмотреть результаты, они будут иметь мало смысла.

Запуск этого показывает:

Benchmark                 (howMany)  Mode  Cnt         Score   Error  Units

ParallelSort.nonParallel         10  avgt    2       128.847          ns/op
ParallelSort.parallel            10  avgt    2       116.656          ns/op

ParallelSort.nonParallel        100  avgt    2      1956.746          ns/op
ParallelSort.parallel           100  avgt    2      1963.335          ns/op

ParallelSort.nonParallel       1000  avgt    2     32162.611          ns/op
ParallelSort.parallel          1000  avgt    2     31716.915          ns/op

ParallelSort.nonParallel      10000  avgt    2    423531.663          ns/op
ParallelSort.parallel         10000  avgt    2    201802.609          ns/op

ParallelSort.nonParallel     100000  avgt    2   6503511.987          ns/op
ParallelSort.parallel        100000  avgt    2   1363169.661          ns/op

ParallelSort.nonParallel    1000000  avgt    2  69058738.586          ns/op
ParallelSort.parallel       1000000  avgt    2  13469112.930          ns/op

Довольно ожидаемый результат для меня.

Ответ 3

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

Ключ "достаточно мал". Это не будет одинаковым ответом на все проблемы.

Догма никогда не должна применяться, кроме как в случае этого правила догмы. Также как единственное, что мы никогда не должны терпеть, это нетерпимость. Там где-то есть парадокс Поппера.

Ответ 4

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

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