Вложенная Java 8 параллельная для каждого цикла работает плохо. Ожидается ли такое поведение?

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

Проблема: Если вы вставляете два цикла Java 8 stream.parallel(). forEach, а все задачи независимы, неактивны и т.д. - за исключением того, что они отправляются в общий пул FJ, то вложенность параллельный цикл внутри параллельного цикла выполняет гораздо слабее, чем вложение последовательного цикла внутри параллельного цикла. Еще хуже: если операция, содержащая внутренний цикл, синхронизирована, вы получите DEADLOCK.

Демонстрация проблемы производительности

Без "синхронизированного" вы все равно можете наблюдать проблему с производительностью. Вы найдете демо-код для этого: http://svn.finmath.net/finmath%20experiments/trunk/src/net/finmath/experiments/concurrency/NestedParallelForEachTest.java (см. JavaDoc там для более подробного описания).

Наша настройка выглядит следующим образом: у нас есть вложенный поток .parallel(). forEach().

  • Внутренний цикл является независимым (безстоящим, без помех и т.д. - за исключением использования общего пула) и потребляет всего 1 секунду в худшем случае, а именно, если обрабатывается последовательным.
  • Половина задач внешнего цикла потребляет за 10 секунд до этого цикла.
  • Половина потребляет 10 секунд после этого цикла.
  • Следовательно, каждая нить потребляет 11 секунд (в худшем случае). * У нас есть логическое значение, которое позволяет переключать внутренний цикл с параллельного() на последовательный().

Теперь: отправив 24 внешних цикла в пул с parallelism 8, мы ожидаем 24/8 * 11 = 33 секунды в лучшем случае (на 8-ядерном или более высоком компьютере).

Результат:

  • С внутренним последовательным циклом: 33 секунды.
  • С внутренним параллельным циклом: > 80 секунд (у меня было 92 секунды).

Вопрос: Можете ли вы подтвердить это поведение? Это чего-то можно ожидать от структуры? (Я немного более осторожен сейчас с утверждением, что это ошибка, но я лично считаю, что это связано с ошибкой в ​​реализации ForkJoinTask. Замечание: я разместил это в concurrency -interest (см. http://cs.oswego.edu/pipermail/concurrency-interest/2014-May/012652.html), но до сих пор я не получил подтверждения оттуда).

Демонстрация тупика

Следующий код DEADLOCK

    // Outer loop
    IntStream.range(0,numberOfTasksInOuterLoop).parallel().forEach(i -> {
        doWork();
        synchronized(this) {
            // Inner loop
            IntStream.range(0,numberOfTasksInInnerLoop).parallel().forEach(j -> {
                doWork();
            });
        }
    });

где numberOfTasksInOuterLoop = 24, numberOfTasksInInnerLoop = 240, outerLoopOverheadFactor = 10000 и doWork - некоторый безгосударственный процессор.

Вы найдете полный демо-код на http://svn.finmath.net/finmath%20experiments/trunk/src/net/finmath/experiments/concurrency/NestedParallelForEachAndSynchronization.java (см. JavaDoc там для более подробного описания).

Ожидается ли такое поведение? Обратите внимание, что в документации по параллельным потокам Java не упоминается проблема с вложением или синхронизацией. Кроме того, не упоминается тот факт, что оба используют общий fork-join-pool.

Обновление

Еще один тест по проблеме производительности можно найти на http://svn.finmath.net/finmath%20experiments/trunk/src/net/finmath/experiments/concurrency/NestedParallelForEachBenchmark.java - этот тест выполняется без какой-либо операции блокировки (нет Thread.sleep и не синхронизирован). Я собрал еще несколько замечаний здесь: http://christian-fries.de/blog/files/2014-nested-java-8-parallel-foreach.html

Обновление 2

Похоже, что эта проблема и более серьезный DEADLOCK с семафорами были исправлены в Java8 u40.

Ответ 1

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

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

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

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

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


Обновление: путем профилирования и просмотра кода кажется, что ForkJoinPool пытается использовать поток ожидания для "кражи работы", но с использованием другого кода в зависимости от того, является ли Thread рабочим потоком или некоторым другой поток. В результате рабочий поток фактически ждет около 80% времени и очень мало работает, пока другие потоки действительно не способствуют вычислению...


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

import java.lang.reflect.UndeclaredThrowableException;
import java.util.concurrent.*;
import java.util.function.IntConsumer;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

public class NestedParallelForEachTest1 {
    static final boolean isInnerStreamParallel = true;

    // Setup: Inner loop task 0.01 sec in worse case. Outer loop task: 10 sec + inner loop. This setup: (100 * 0.01 sec + 10 sec) * 24/8 = 33 sec.
    static final int numberOfTasksInOuterLoop = 24;  // In real applications this can be a large number (e.g. > 1000).
    static final int numberOfTasksInInnerLoop = 100; // In real applications this can be a large number (e.g. > 1000).
    static final int concurrentExecutionsLimitForStreams = 8;

    public static void main(String[] args) throws InterruptedException, ExecutionException {
        System.out.println(System.getProperty("java.version")+" "+System.getProperty("java.home"));
        new NestedParallelForEachTest1().testNestedLoops();
        E.shutdown();
    }

    final static ThreadPoolExecutor E = new ThreadPoolExecutor(
        concurrentExecutionsLimitForStreams, concurrentExecutionsLimitForStreams,
        2, TimeUnit.MINUTES, new SynchronousQueue<>(), (r,e)->r.run() );

    public static void parallelForEach(IntStream s, IntConsumer c) {
        s.mapToObj(i->E.submit(()->c.accept(i))).collect(Collectors.toList())
         .forEach(NestedParallelForEachTest1::waitOrHelp);
    }
    static void waitOrHelp(Future f) {
        while(!f.isDone()) {
            Runnable r=E.getQueue().poll();
            if(r!=null) r.run();
        }
        try { f.get(); }
        catch(InterruptedException ex) { throw new RuntimeException(ex); }
        catch(ExecutionException eex) {
            Throwable t=eex.getCause();
            if(t instanceof RuntimeException) throw (RuntimeException)t;
            if(t instanceof Error) throw (Error)t;
            throw new UndeclaredThrowableException(t);
        }
    }
    public void testNestedLoops(NestedParallelForEachTest1 this) {
        long start = System.nanoTime();
        // Outer loop
        parallelForEach(IntStream.range(0,numberOfTasksInOuterLoop), i -> {
            if(i < 10) sleep(10 * 1000);
            if(isInnerStreamParallel) {
                // Inner loop as parallel: worst case (sequential) it takes 10 * numberOfTasksInInnerLoop millis
                parallelForEach(IntStream.range(0,numberOfTasksInInnerLoop), j -> sleep(10));
            }
            else {
                // Inner loop as sequential
                IntStream.range(0,numberOfTasksInInnerLoop).sequential().forEach(j -> sleep(10));
            }
            if(i >= 10) sleep(10 * 1000);
        });
        long end = System.nanoTime();
        System.out.println("Done in "+TimeUnit.NANOSECONDS.toSeconds(end-start)+" sec.");
    }
    static void sleep(int milli) {
        try {
            Thread.sleep(milli);
        } catch (InterruptedException ex) {
            throw new AssertionError(ex);
        }
    }
}

Ответ 2

Немного приведя код. Я не вижу таких же результатов с обновлением Java 8 45. Несомненно, это накладные расходы, но он очень мал по сравнению с тем временем, о котором вы говорите.

Потенциал для тупика ожидается, поскольку вы потребляете все доступные потоки в пуле с внешним циклом, не оставляя нити, оставшихся для выполнения внутреннего цикла.

Следующая программа печатает

isInnerStreamParallel: false, isCPUTimeBurned: false
java.util.concurrent.ForkJoinPool.common.parallelism = 8
Done in 33.1 seconds.
isInnerStreamParallel: false, isCPUTimeBurned: true
java.util.concurrent.ForkJoinPool.common.parallelism = 8
Done in 33.0 seconds.
isInnerStreamParallel: true, isCPUTimeBurned: false
java.util.concurrent.ForkJoinPool.common.parallelism = 8
Done in 32.5 seconds.
isInnerStreamParallel: true, isCPUTimeBurned: true
java.util.concurrent.ForkJoinPool.common.parallelism = 8
Done in 32.6 seconds.

Код

import java.util.stream.IntStream;

public class NestedParallelForEachTest {
    // Setup: Inner loop task 0.01 sec in worse case. Outer loop task: 10 sec + inner loop. This setup: (100 * 0.01 sec + 10 sec) * 24/8 = 33 sec.
    static final int numberOfTasksInOuterLoop = 24;  // In real applications this can be a large number (e.g. > 1000).
    static final int numberOfTasksInInnerLoop = 100;                // In real applications this can be a large number (e.g. > 1000).
    static final int concurrentExecutionsLimitForStreams    = 8;    // java.util.concurrent.ForkJoinPool.common.parallelism

    public static void main(String[] args) {
        testNestedLoops(false, false);
        testNestedLoops(false, true);
        testNestedLoops(true, false);
        testNestedLoops(true, true);
    }

    public static void testNestedLoops(boolean isInnerStreamParallel, boolean isCPUTimeBurned) {
        System.out.println("isInnerStreamParallel: " + isInnerStreamParallel + ", isCPUTimeBurned: " + isCPUTimeBurned);
        long start = System.nanoTime();

        System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism",Integer.toString(concurrentExecutionsLimitForStreams));
        System.out.println("java.util.concurrent.ForkJoinPool.common.parallelism = " + System.getProperty("java.util.concurrent.ForkJoinPool.common.parallelism"));

        // Outer loop
        IntStream.range(0, numberOfTasksInOuterLoop).parallel().forEach(i -> {
//            System.out.println(i + "\t" + Thread.currentThread());
            if(i < 10) burnTime(10 * 1000, isCPUTimeBurned);

            IntStream range = IntStream.range(0, numberOfTasksInInnerLoop);
            if (isInnerStreamParallel) {
                // Inner loop as parallel: worst case (sequential) it takes 10 * numberOfTasksInInnerLoop millis
                range = range.parallel();
            } else {
                // Inner loop as sequential
            }
            range.forEach(j -> burnTime(10, isCPUTimeBurned));

            if(i >= 10) burnTime(10 * 1000, isCPUTimeBurned);
        });

        long end = System.nanoTime();

        System.out.printf("Done in %.1f seconds.%n", (end - start) / 1e9);
    }

    static void burnTime(long millis, boolean isCPUTimeBurned) {
        if (isCPUTimeBurned) {
            long end = System.nanoTime() + millis * 1000000;
            while (System.nanoTime() < end)
                ;

        } else {
            try {
                Thread.sleep(millis);
            } catch (InterruptedException e) {
                throw new AssertionError(e);
            }
        }
    }
}

Ответ 3

Я могу подтвердить, что это все еще проблема производительности в 8u72, хотя она больше не будет тупиковой. Параллельные операции терминала выполняются с ForkJoinTask экземплярами вне ForkJoinPool, что означает, что каждый параллельный поток по-прежнему разделяет общий пул .

Чтобы продемонстрировать простой патологический случай:

import java.util.concurrent.ForkJoinPool;
import java.util.stream.IntStream;

public class ParallelPerf {

    private static final Object LOCK = new Object();

    private static void runInNewPool(Runnable task) {
        ForkJoinPool pool = new ForkJoinPool();
        try {
            pool.submit(task).join();
        } finally {
            pool.shutdown();
        }
    }

    private static <T> T runInNewPool(Callable<T> task) {
        ForkJoinPool pool = new ForkJoinPool();
        try {
            return pool.submit(task).join();
        } finally {
            pool.shutdown();
        }
    }

    private static void innerLoop() {
        IntStream.range(0, 32).parallel().forEach(i -> {
//          System.out.println(Thread.currentThread().getName());
            try {
                Thread.sleep(5);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
    }

    public static void main(String[] args) {
        System.out.println("==DEFAULT==");
        long startTime = System.nanoTime();
        IntStream.range(0, 32).parallel().forEach(i -> {
            synchronized (LOCK) {
                innerLoop();
            }
//          System.out.println(" outer: " + Thread.currentThread().getName());
        });
        System.out.println(System.nanoTime() - startTime);

        System.out.println("==NEW POOLS==");
        startTime = System.nanoTime();
        IntStream.range(0, 32).parallel().forEach(i -> {
            synchronized (LOCK) {
                runInNewPool(() -> innerLoop());
            }
//          System.out.println(" outer: " + Thread.currentThread().getName());
        });
        System.out.println(System.nanoTime() - startTime);
    }
}

Второй прогон проходит innerLoop до runInNewPool вместо прямого вызова. На моей машине (i7-4790, 8 потоков процессора) я получаю ускорение в 4 раза:

==DEFAULT==
4321223964
==NEW POOLS==
1015314802

Разоблачение других операторов печати делает проблему очевидной:

[...]
ForkJoinPool.commonPool-worker-6
ForkJoinPool.commonPool-worker-6
ForkJoinPool.commonPool-worker-6
 outer: ForkJoinPool.commonPool-worker-6
ForkJoinPool.commonPool-worker-3
ForkJoinPool.commonPool-worker-3
[...]
ForkJoinPool.commonPool-worker-3
ForkJoinPool.commonPool-worker-3
 outer: ForkJoinPool.commonPool-worker-3
ForkJoinPool.commonPool-worker-4
ForkJoinPool.commonPool-worker-4
[...]

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

И результат использования отдельных экземпляров ForkJoinPool:

[...]
ForkJoinPool-1-worker-0
ForkJoinPool-1-worker-6
ForkJoinPool-1-worker-5
 outer: ForkJoinPool.commonPool-worker-4
ForkJoinPool-2-worker-1
ForkJoinPool-2-worker-5
[...]
ForkJoinPool-2-worker-7
ForkJoinPool-2-worker-3
 outer: ForkJoinPool.commonPool-worker-1
ForkJoinPool-3-worker-2
ForkJoinPool-3-worker-5
[...]

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

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

Это проблема со всеми терминальными операциями, а не только с forEach, поскольку все они запускают задачи в общем пуле. Я использую методы runInNewPool выше в качестве обходного пути, но, надеюсь, в какой-то момент он будет встроен в стандартную библиотеку.