return [=] (int from) {
return double(from - mid_from) / w_from * w_to + mid_to;
};
}
template
static auto scaled_cmplx(A scaler_x, B scaler_y)
{
return [=](int x, int y) {
return cmplx{scaler_x(x), scaler_y(y)};
};
}
3. В функции
mandelbrot_iterations
просто увеличим количество итераций, чтобы программа выполняла больше вычислений:
static auto mandelbrot_iterations(cmplx c)
{
cmplx z {};
size_t iterations {0};
const size_t max_iterations {100000};
while (abs(z) < 2 && iterations < max_iterations) {
++iterations;
z = pow(z, 2) + c;
}
return iterations;
}
4. Далее перед нами часть функции
main
, которую также не нужно изменять:
int main()
{
const size_t w {100};
const size_t h {40};
auto scale (scaled_cmplx(
scaler(0, w, -2.0, 1.0),
scaler(0, h, -1.0, 1.0)
));
auto i_to_xy ([=](int x) {
return scale(x % w, x / w);
});
5. В функции
to_iteration_count
больше не вызываем mandelbrot_iterations(x_to_xy(x))
непосредственно, этот вызов делается асинхронно с помощью std::async
:
auto to_iteration_count ([=](int x) {
return async(launch::async,
mandelbrot_iterations, i_to_xy(x));
});
6. Перед внесением последнего изменения функция
to_iteration_count
возвращала количество итераций, нужных конкретной координате, чтобы алгоритм Мандельброта сошелся. Теперь она возвращает переменную типа future
, которая станет содержать то же значение позднее, когда оно будет рассчитано асинхронно. Из-за этого требуется вектор для хранения всех значений типа future
, добавим его. Выходной итератор, который мы предоставили функции transform
в качестве третьего аргумента, должен быть начальным итератором для нового вектора выходных данных r
:
vector v (w * h);
vector> r (w * h);
iota(begin(v), end(v), 0);
transform(begin(v), end(v), begin(r),
to_iteration_count);
7. Вызов
accumulate
, который выводит результат на экран, больше не получает значения типа size_t
в качестве второго аргумента, вместо этого он получает значения типа future
. Нужно адаптировать его к данному типу (если бы мы изначально использовали тип auto&
, то этого бы не требовалось), а затем вызвать x.get()
, где мы получили доступ к x
, чтобы дождаться появления значения.
auto binfunc ([w, n{0}] (auto output_it, future&x )
mutable {
*++output_it = (x.get()> 50 ? '*' : ' ');
if (++n % w == 0) { ++output_it = '\n'; }
return output_it;
});
accumulate(begin(r), end(r),
ostream_iterator{cout}, binfunc);
}
8. Компиляция и запуск программы дадут результат, который мы видели ранее. Увеличение количества итераций и для оригинальной версии приведет к тому, что распараллеленная версия отработает быстрее. На моем компьютере, имеющем четыре ядра ЦП, поддерживающих гиперпараллельность (что дает восемь виртуальных ядер), я получаю разные результаты для GCC и clang. В лучшем случае программа ускоряется в 5,3 раза, а в худшем — в 3,8. Результаты, конечно, будут различаться для разных машин.
Как это работает
Сначала важно понять всю программу, поскольку далее становится ясно, что вся работа, зависящая от ЦП, происходит в одной строке кода в функции
main
:
transform(begin(v), end(v), begin(r), to_iteration_count);
Вектор
v
содержит все индексы, соотнесенные с комплексными координатами, по которым мы итерируем в алгоритме Мандельброта. Результат каждой итерации сохраняется в векторе r
.В оригинальной программе в данной строке тратится все время, требуемое для расчета фрактального изображения. Весь код, находящийся перед ним, выполняет подготовку, а весь код, следующий за ним, нужен лишь для вывода информации на экран. Это значит, что распараллеливание выполнения этой строки критически важно для производительности.
Одним из возможных подходов к распараллеливанию является разбиение всего линейного диапазона от
begin(v)
до end(v)
на фрагменты одного размера и равномерное их распределение между ядрами. Таким образом, все ядра выполнят одинаковый объем работы. Если бы мы использовали параллельную версию функции std::transform
с параллельной политикой выполнения, то все бы так и произошло. К сожалению, это неверная стратегия для нашей задачи, поскольку каждая точка множества Мандельброта показывает индивидуальное количество итераций.Наш подход заключается в том, что мы заполним вектор, в котором содержатся отдельные символы, элементами
future
, чьи значения будут высчитаны асинхронно. Поскольку вектор-источник и вектор-приемник содержат w*h
элементов, что для нашего случая означает 100*40
, у нас есть вектор, содержащий 4000 значений типа future
, которые высчитываются асинхронно. Если бы наша система имела 4000 ядер ЦП, то это означало бы запуск 4000 потоков, которые выполнили бы перебор множества Мандельброта действительно конкурентно. В обычной системе, имеющей меньшее количество ядер, ЦП будут обрабатывать один асинхронный элемент за другим.В то время как вызов
transform
с асинхронной версией to_iteration_count
сам по себе не выполняет расчеты, а лишь подготавливает потоки и объекты типа future
, он возвращает значение практически мгновенно. Оригинальная версия программы к этому моменту будет заблокирована, поскольку итерации занимают слишком много времени.Распараллеленная версия программы также может быть заблокирована. Функция, которая выводит на экран все наши значения в консоли, должна получать доступ к результатам, находящимся внутри объектов типа
future
. Чтобы это сделать, она вызывает функцию x.get()
для всех значений. Идея заключается в следующем: пока она ждет вывода первого значения, множество других значений высчитываются одновременно. Поэтому, если метод get()
для первого значения типа future
возвращается, то следующий объект типа future
тоже может быть готов к выводу на экран!Если размер вектора будет гораздо больше, то возникнет некоторая измеряемая задержка при создании и синхронизации всех этих значений. В нашем случае задержка не так велика. На моем ноутбуке с процессором Intel i7, имеющем четыре ядра, которые могут работать в режиме гиперпараллельности (что дает восемь виртуальных ядер), параллельная версия этой программы работала в 3–5 раз быстрее оригинальной программы. Идеальное распараллеливание сделало бы программу в восемь раз быстрее. Конечно, это ускорение будет различаться для разных компьютеров, поскольку зависит от множества факторов.
Небольшая автоматическая библиотека для распараллеливания с использованием std::future
Большую часть сложных задач можно разбить на подзадачи. Из всех подзадач можно построить направленный ациклических граф