Как вообще пользоваться этим ForkJoinPool(ом)?
Создал два метода для вычисления чисел Фибоначчи: один с обычной рекурсией, а другой с ForkJoinPool(ом). Проблема в том, что метод fibonacci1 в разы, если не в сотни раз, быстрее метода fibonacci. Объясните, пожалуйста, почему. Как вообще правильно пользоваться ForkJoinPool(ом)?
public class MyRecursiveAction extends RecursiveTask<Long> {
private static ForkJoinPool forkJoinPool = ForkJoinPool.commonPool();
public static void main(String[] args) {
long l1, l2;
l1 = System.nanoTime();
fibonacci1(40);
l2 = System.nanoTime();
System.out.println(l2 - l1);
l1 = System.nanoTime();
fibonacci(40);
l2 = System.nanoTime();
System.out.println(l2 - l1);
}
private long n;
private MyRecursiveAction(long n) {
this.n = n;
}
public static long fibonacci(long n) {
return forkJoinPool.invoke(new MyRecursiveAction(n));
// return new MyRecursiveAction(n).invoke();
}
public static long fibonacci1(long n) {
if (n == 0 || n == 1)
return n;
if (n > 0)
return fibonacci1(n - 1) + fibonacci1(n - 2);
else
return fibonacci1(n + 2) - fibonacci1(n + 1);
}
@Override
protected Long compute() {
if (n == 0 || n == 1)
return n;
if (n > 0) {
MyRecursiveAction myRecursiveAction1 = new MyRecursiveAction(n - 1);
forkJoinPool.invoke(myRecursiveAction1);
// myRecursiveAction1.fork();
MyRecursiveAction myRecursiveAction2 = new MyRecursiveAction(n - 2);
forkJoinPool.invoke(myRecursiveAction2);
// myRecursiveAction2.fork();
// return myRecursiveAction1.join() + myRecursiveAction2.join();
return myRecursiveAction1.join() + myRecursiveAction2.join();
} else {
MyRecursiveAction myRecursiveAction1 = new MyRecursiveAction(n + 1);
forkJoinPool.invoke(myRecursiveAction1);
// myRecursiveAction1.fork();
MyRecursiveAction myRecursiveAction2 = new MyRecursiveAction(n + 2);
forkJoinPool.invoke(myRecursiveAction2);
// myRecursiveAction2.fork();
// return myRecursiveAction2.join() - myRecursiveAction1.join();
return myRecursiveAction2.join() - myRecursiveAction1.join();
}
}
}
Ответы (1 шт):
Все ExecutorService предназначены для выполнения относительно тяжеловесных задач в выделенных потоках. Ими можно пользоваться, например, при обработке фото - разбить фотографию на n областей и загрузить их в n потоков, а потом объединить результат. На многоядерных процессорах, скорее всего, ускорение обработки будет в n раз, так как потоки будут работать на выделенных ядрах. Или можно сделать обработку запросов в веб-сервисе: диспетчер получает запрос и отдаёт его в пул на обработку; когда в пуле освободится поток, он подхватит запрос и приступит к вычислениям. Если вычисления гораздо тяжелее, чем накладные расходы на порождение потоков, диспетчеризацию потоков, ожидание завершения потоков, то использование пула заметно прибавит производительности по сравнению с однопоточным выполнение.
Но в вашем случае операция сложения настолько легковесна, что накладные расходы на работу с пулом многократно превышают затраты на собственно вычисления.
UPD
Пример: поиск делителей
Для примера запустим параллельный поиск делителей целого числа. У алгоритма поиска есть два параметра - максимальная длина интервала для перебора и число порождаемых подзадач.
Если интервал поиска делителей, назначенный задаче, слишком велик, т.е. (b-a) > intervalLength, то задача порождает несколько подзадач, для каждой нарезая уменьшенные интервалы.
То есть одна задача в конечном итоге порождает целое дерево подзадач. Чтобы не перебирать всё, когда делитель уже найден, каждая задача получает экземпляр класса StopFlag с булевским флагом. Подзадача, нашедшая делитель, поднимает этот флаг, и все остальные подзадачи ничего не считают.
Подзадачи запускаются вызовом task.fork() - этот метод ставит подзадачу на исполнение в тот же пул, который использовался для запуска родительской задачи.
Сразу скажу - пример дурацкий, исключительно для иллюстрации распараллеливания.
package org.example;
import java.util.ArrayList;
import java.util.concurrent.ForkJoinTask;
import java.util.concurrent.RecursiveTask;
@SuppressWarnings("serial")
class Divisors extends RecursiveTask<Long> {
private long n;
private long a;
private long b;
private int forkFactor ;
private long length;
private StopFlag stopFlag;
protected static class StopFlag {
public boolean done = false;
public boolean isDone() {
return done;
}
}
public Divisors(long n, long a, long b, int forkFactor, long length, StopFlag stopFlag) {
this.n = n;
if (0 == a % 2) {
a += 1;
}
this.a = a;
this.b = b;
this.forkFactor = forkFactor;
this.length = length;
if (stopFlag == null) {
this.stopFlag = new StopFlag();
} else {
this.stopFlag = stopFlag;
}
}
public Divisors(long n, long a, long b, int forkFactor, long length) {
this(n,a,b,forkFactor, length, null);
}
protected Long compute() {
// System.out.println("Compute started, thread " + Thread.currentThread().getId());
if (stopFlag.isDone()) {
return 0L;
}
if ((b - a) < length) {
// Interval is short enough to do the brute force search
return bruteForce();
} else {
// Split the interval and start subtasks
return divideAndConquer();
}
}
private Long divideAndConquer() {
ArrayList<ForkJoinTask<Long>> results = new ArrayList<ForkJoinTask<Long>>();
long step = (b-a)/forkFactor + 1;
for (long i = a; i < b; i+= step) {
ForkJoinTask<Long> task = new Divisors(n, i, i+step, forkFactor, length, stopFlag);
results.add(task.fork());
}
for (ForkJoinTask<Long> result : results) {
long divisor = result.join();
if (divisor > 0) {
return divisor;
}
}
return 0L;
}
private Long bruteForce() {
for (long i = a; i <= b; i += 2) {
long res = n % i;
if (res == 0) {
System.out.println("Found");
stopFlag.done = true;
return i;
}
}
return 0L;
}
}
К этой задаче приделан вот такой пускач:
package org.example;
import java.math.BigInteger;
import java.util.Random;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinTask;
public class MainDivisors {
/**
* if {@code (b-a) > intervalLength} then fork the task into {@code forkFactor} subtasks
*/
private static final long intervalLength = 10000000L;
private static int forkFactor = 4;
private static ForkJoinPool forkJoinPool = new ForkJoinPool(forkFactor);
public static void main(String[] args) {
long t1, t2;
BigInteger p = BigInteger.probablePrime(32, new Random());
BigInteger q = p.nextProbablePrime();
long n = p.longValue()*q.longValue();
System.out.println("Factoring " + n + " = " + p + "*" + q);
System.out.println("Single thread");
{
t1 = System.nanoTime();
long result = divisorSingleThread(n);
t2 = System.nanoTime();
System.out.println("Divisor: " + result + ", time: " + (t2 - t1));
}
System.out.println("Pool");
{
t1 = System.nanoTime();
long result = divisorPooled(n, forkFactor, intervalLength);
t2 = System.nanoTime();
System.out.println("Divisor: " + result + ", time: " + (t2 - t1));
}
}
public static Long divisorPooled(long n, int forkFactor, long intervalLength) {
if (n %2 == 0) {
return 2L;
}
long a = 3;
long b = (long) (Math.floor(Math.sqrt(n))) + 1;
ForkJoinTask<Long> task = new Divisors(n, a, b, forkFactor, intervalLength);
ForkJoinTask<Long> result = forkJoinPool.submit(task);
return result.join();
}
public static Long divisorSingleThread(long n) {
if (n %2 == 0) {
return 2L;
}
long a = 3;
long b = (long) (Math.floor(Math.sqrt(n))) + 1;
for (long i = a; i < b; i += 2) {
long res = n % i;
if (res == 0) {
return i;
}
}
return 0L;
}
}
Как показали испытания, ускорение в 2-3 раза. Пример высосан из пальца, и накладные расходы на организацию параллельности сопоставимы с выигрышем производительности, но зато демонстрирует основные идеи MapReduce:
- разбиение проблемного пространства (partition),
- применение вычислительной функции к полученным слайсам (map),
- сведение результатов от разных партиций (reduce).
UPD2
Правильный пример
Несмотря на то, что автор вопроса уже принял мой ответ, я таки его расширю и дополню - покажу правильный пример. Пример, приведённый выше, равно как и пример в документации неверны.
Проблема обоих примеров в том, что задача, поставленная в пул, порождает подзадачи и ждёт их завершения. То есть один из потоков пула блокируется. При наличии заблокированных потоков пул создаёт новые потоки для решения задач, что стоит отнюдь не копейки.
Правильное решение должно разделять map и reduce. Map делается в одном месте, а reduce в другом. Не нужно ничего ждать. После порождения подзадач работа родительской задачи завершается, чтобы освободить ресурс. Результат работы порожденных задач должен обрабатываться в другом месте.
Чтобы передавать поменьше параметров в задачу, я сгруппировал их в два класса. Interval задаёт интервал перебора делителей.
package org.example;
public class Interval {
public final long a;
public final long b;
public Interval(long a, long b) {
super();
this.a = a;
this.b = b;
}
public long length() {
return b - a;
}
}
Полностью асинхронное решение требует две вещи: общее хранилище для результата и примитив синхронизации для сигнала, что все задачи завершены и решение сформировано.
Для хранения результата в Java есть класс CompletableFuture<Long>. Метод isDone говорит, что результат готов, метод get позволяет результат получить, метод join блокирует вызывающий поток до тех пор, пока результат не будет готов. Результат устанавливается методом complete, который сохраняет результат внутри, взводит isDone и разблокирует ожидание join. Там есть ещё много что, но для примера достаточно.
Для подсчета числа завершенных задач я использовал примитив Phaser. Он содержит внутренний счетчик активных участников, который можно увеличивать вызовами метода bulkRegister, и уменьшать вызовами arriveAndDeregister. Обнуления счетчика можно ждать методом awaitAdvance. В фазере есть ещё функционал, но для управления задачами поиска делителя достаточно этого. Для простоты я обернул эти функции в класс CountLock:
package org.example;
import java.util.concurrent.Phaser;
public class CountLock {
private final Phaser lock;
private int phase;
public CountLock() {
lock = new Phaser();
phase = lock.getPhase();
}
public void addTasks(int count) {
lock.bulkRegister(count);
}
public void completeTask() {
lock.arriveAndDeregister();
}
public void await() {
lock.awaitAdvance(phase);
}
}
Параметры процесса решения - максимальный интервал перебора, число порождаемых подзадач, счетчик активных задач и хранилище ожидаемого результата, - я убрал в отдельный класс Params. Чисто для эстетики - не люблю длинные цепочки параметров в вызовах.
package org.example;
import java.util.concurrent.CompletableFuture;
public class Params {
public final int forkFactor;
public final long maxInterval;
public final CompletableFuture<Long> result;
public final CountLock lock;
public Params(int forkFactor, long maxInterval, CountLock lock, CompletableFuture<Long> result) {
super();
this.forkFactor = forkFactor;
this.maxInterval = maxInterval;
this.result = result;
this.lock = lock;
}
}
Все приготовления сделаны, можно готовить.
Так как результат теперь хранится в отдельном месте, то тип изменён с RecursiveTask<V> на RecursiveAction.
package org.example;
import java.util.concurrent.RecursiveAction;
@SuppressWarnings("serial")
class Divisors extends RecursiveAction {
private long n;
private Interval interval;
private Params params;
public Divisors(long n, Interval i, Params params) {
this.n = n;
this.params = params;
this.interval = i;
}
protected void compute() {
try {
if (params.result.isDone()) {
return;
}
if (interval.length() < params.maxInterval) {
// Interval is short enough to do the brute force search
bruteForce();
} else {
// Split the interval and start subtasks
divideAndConquer();
}
} finally {
params.lock.completeTask();
}
}
private void divideAndConquer() {
long step = interval.length() / params.forkFactor + 1;
params.lock.addTasks(params.forkFactor);
for (long i = interval.a; i < interval.b; i += step) {
RecursiveAction task = new Divisors(n, new Interval(i, i + step), params);
task.fork();
}
}
private void bruteForce() {
long a = interval.a;
if (0 == a % 2) {
a += 1;
}
long b = interval.b;
for (long i = a; i <= b; i += 2) {
long res = n % i;
if (res == 0) {
System.out.println("Found: " + i);
params.result.complete(i);
return;
}
}
}
}
Более чем 10-кратный прирост скорости прячется внутри новой организации методов bruteForce, compute и divideAndConquer.
bruteForce теперь не возвращает результат, а сохраняет его в общее хранилище, заодно взводя флаг о завершении процесса счёта: params.result.complete(i).
divideAndConquer бьет задачи на подзадачи и увеличивает счётчик активных задач: params.lock.addTasks(params.forkFactor); Обратите внимание, счётчик увеличивается до того как задачи ставятся на исполнение, иначе можно влететь в race condition - в порождённой задаче complete будет вызван до того, как в порождающей addTasks.
compute при завершении уменьшает счетчик задач на единицу:
try {
// ...
} finally {
params.lock.completeTask();
}
Тест производительности. У меня четырехядерный процессор с восемью физическими потоками, поэтому я задал размер пула равным семи. Performance monitor показывает загрузку всех ядер, то есть пул успешно распараллелил задачу, и ядра на 100% заняты счётом - никто никого не ждёт.
Одна задача перебирает в интервале не длиннее одного миллиона. Варьирование этого параметра может повлиять на производительность - если поставить слишком маленький интервал, накладные затраты на переключение задач будут выше затрат на счёт. Если поставить слишком большим, то максимум параллельности достигнут не будет.
package org.example;
import java.math.BigInteger;
import java.util.Random;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveAction;
public class MainDivisors {
/**
* if {@code (b-a) > intervalLength} then fork the task into {@code forkFactor} subtasks
*/
private static final long intervalLength = 1000000L;
private static int forkFactor = 7;
private static ForkJoinPool forkJoinPool = new ForkJoinPool(forkFactor);
public static void main(String[] args) {
long t1, t2;
BigInteger p = BigInteger.probablePrime(31, new Random());
BigInteger q = p.nextProbablePrime();
long n = p.longValue()*q.longValue();
System.out.println("Factoring " + n + " = " + p + "*" + q);
// long n = 2130663859L*2130663869L; // 4539728501355410471
// System.out.println("Factoring " + n + " = 2130663859L*2130663869");
System.out.println("Single thread");
{
long start = System.nanoTime();
long result = divisorSingleThread(n);
t1 = System.nanoTime() - start;
System.out.println("Divisor: " + result + ", time: " + t1);
}
System.out.println("Pool");
{
long start = System.nanoTime();
long result = divisorPooled(n, forkFactor, intervalLength);
t2 = System.nanoTime() - start;
System.out.println("Divisor: " + result + ", time: " + t2);
}
System.out.println("Speed up " + ((double)t1)/t2 + " times");
}
public static Long divisorPooled(long n, int forkFactor, long intervalLength) {
if (n %2 == 0) {
return 2L;
}
long a = 3;
long b = (long) (Math.floor(Math.sqrt(n))) + 1;
CountLock lock = new CountLock();
CompletableFuture<Long> result = new CompletableFuture<Long>();
Params params = new Params(forkFactor, intervalLength, lock, result);
RecursiveAction task = new Divisors(n, new Interval(a, b), params);
lock.addTasks(1);
forkJoinPool.submit(task);
lock.await();
if (result.isDone()) {
return result.join();
} else {
return 0L;
}
}
public static Long divisorSingleThread(long n) {
if (n %2 == 0) {
return 2L;
}
long a = 3;
long b = (long) (Math.floor(Math.sqrt(n))) + 1;
for (long i = a; i < b; i += 2) {
long res = n % i;
if (res == 0) {
return i;
}
}
return 0L;
}
}
Тест раскладывает на множители 62-х битное число: произведение двух случайных 31-битных простых. То есть нужно перебрать порядка миллиарда вариантов.
При разложении числа 4539728501355410471 = 2130663859*2130663869 поиск делителей в цикле находит делитель за 8-11 секунд. Не могу сказать, откуда такой разброс берётся. Какие-то фокусы планировщика Windows или JIT компилятора Java.
Параллельный алгоритм находит делитель за 0.04 - 0.06 секунд. Ускорение в среднем в 200 раз. Это не опечатка, двести раз.
Factoring 4539728501355410471 = 2130663859L*2130663869
Single thread
Divisor: 2130663859, time: 10414989800
Pool
Found: 2130663859
Divisor: 2130663859, time: 48492800
Speed up 214.77394169856143 times
Обсуждение
Объясняется такой выигрыш просто. По умолчанию ForkJoinPool использует стек для хранения задач, поступивших на счёт. То есть последние выполняются первыми, и в нашем случае счёт начинается с конца. Так как тестовое число есть произведение двух больших сомножителей, то его находят практически сразу.
Если же в качестве теста взять произведение трёх 20-битных сомножителей, то лобовой перебор побеждает с разгромным счётом. Тоже в двести раз быстрее ))
Если снова вернуться к произведению двух больших сомножителей, и убрать оптимизацию, прекращающую счёт в задачах, когда найден ответ, то ускорение будет в 2-2.5 раз. Разброс вызван тем, что время счёта в цикле варьируется от 8.5 до 11 секунд, а параллельный счёт всегда считает за 4.5 секунд.