Стоит ли ожидать, что mpf_class GMP будет намного медленнее, чем примитивный тип данных double? - PullRequest
0 голосов
/ 18 января 2019

Я пишу программу на C ++ для генерации масштабов Мандельброта.Все мои комплексные числа изначально были двумя double с (одна для реальной части, одна для сложной части).Это работало довольно быстро;15 секунд на кадр для типа изображения, которое я генерировал.

Из-за эффекта масштабирования я хотел повысить точность для более увеличенных кадров, так как эти кадры имеют такую ​​небольшую разницу между min_x и max_x.Я посмотрел на GMP, чтобы помочь мне с этим.

Теперь, это намного медленнее;15:38 минут за кадр.Настройки для изображения такие же, как и раньше, и алгоритм тот же.Единственное, что изменилось, это то, что я использую mpf_class для десятичных дробей, которые должны быть точными (то есть только комплексные числа).Для сравнения производительности я использовал ту же точность, что и double: mpf_set_default_prec(64);

Меняет ли GMP точность mpf_class для удовлетворения потребностей выражения?Другими словами, если у меня есть два 64-битных объекта mpf_class, и я выполняю с ними вычисления и сохраняю результат в другом mpf_class, возможно ли повысить точность?Я мог бы подумать, что со временем это ухудшит производительность, но я не уверен, что именно это и является причиной моей проблемы.

Мои вопросы : Это падение производительности - просто природа GMP и другиебиблиотеки произвольной точности?Какой совет вы бы дали?

Редактировать 1
Я (то есть всегда) использую флаг -O3 для оптимизации.Я также провел тест, чтобы убедиться, что GMP не увеличивает автоматически точность mpf_class объектов.Таким образом, по-прежнему остается вопрос о причине резкого снижения производительности.

Edit 2
В качестве наглядного примера я скомпилировал следующий код как g++ main.cpp -lgmp -lgmpxx один раз, как показано нижеи один раз с каждым double, замененным на mpf_classdouble он работал за 12,75 секунд, а с mpf_class - за 24:54 минутПочему это так, когда они имеют одинаковую точность?

#include <gmpxx.h>

double linear_map(double d, double a1, double b1, double a2, double b2) {
  double a = (d-a1)/(b1-a1);
  return (a*(b2-a2)) + (a2);
}

int iterate(double x0, double y0) {
  double x, y;
  x = 0;
  y = 0;
  int i;
  for (i = 0; i < 1000 && x*x + y*y <= 65536; i++) {
    double xtemp = x*x - y*y + x0;
    y = 2*x*y + y0;
    x = xtemp;
  }
  return i;
}

int main() {
  mpf_set_default_prec(64);
  for (int j = 0; j < 3200; j++) {
    for (int i = 0; i < 3200; i++) {
      double x = linear_map(i, 0, 3200, -2, 1);
      double y = linear_map(j, 0, 3200, -1.5, 1.5);
      iterate(x, y);
    }
  }
  return 0;
}

1 Ответ

0 голосов
/ 19 января 2019

Как объяснено в комментариях, такого рода замедление вполне ожидаемо от такой библиотеки, как GMP.

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

С другой стороны, во множественных динамических библиотеках точности, таких как GMP, каждая операция представляет собой большую работу - есть несколько ветвей, которые нужно проверить даже для проверки того, имеют ли оба операнда одинаковую / правильную степень точности, и алгоритмы вычисления. реализованы являются общими и адаптированы к конечной точке «высокой точности», что означает, что они не особенно оптимизированы для вашего текущего варианта использования (используя их с той же точностью, что и double); Кроме того, значения GMP могут выделять память при ее создании, что является еще одной дорогостоящей операцией.

Я взял вашу программу и слегка изменил ее, чтобы сделать ее параметрической по используемому типу (с #define), уменьшив сторону квадрата выборки (с 3200 до 800, чтобы ускорить тестирование) и добавив аккумулятор возвращаемого значения iterate, чтобы напечатать его в конце, чтобы проверить, все ли работает одинаково между различными версиями, и чтобы убедиться, что оптимизатор не сбрасывает цикл полностью.

Версия double на моей машине занимает примерно 0,16 секунды, и, столкнувшись с профилировщиком, демонстрирует совершенно плоский профиль в пламенном графе; все происходит в iterate.

flamegraph of double-based version

Версия GMP вместо этого, как и ожидалось, занимает 45 секунд (в 300 раз; вы говорили о замедлении в 60 раз, но сравнивали с неоптимизированным базовым случаем) и более разнообразна:

flamegraph of GMP-based version

Как и прежде, iterate занимает почти все время (поэтому мы можем полностью игнорировать linear_map, если речь идет об оптимизации). Все эти «башни» являются вызовами в коде GMP; материал __gmp_expr<...> не особенно важен - это всего лишь шаблонный шаблон, позволяющий оценивать сложные выражения без слишком большого количества временных значений, и он полностью встроен. Большая часть времени уходит на вершину этих башен, где выполняются фактические расчеты.

Действительно, в конечном итоге большую часть времени проводят в примитивах GMP и в распределении памяти:

caller/callee times for GMP-based version

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

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

  double x, y;
  x = 0;
  y = 0;
  int i;
    for (i = 0; i < 1000 && x*x + y*y <= 65536; i++) {
      T xtemp = x*x - y*y + x0;

(T - это общий тип, который я использую, определенный как double или mpf_class)

Здесь вы вычисляете x*x и y*y дважды на каждой итерации. Мы можем оптимизировать его как:

T x = 0, y = 0, xsq, ysq;
for(i = 0; i < 1000; i++) {
  xsq = x*x;
  ysq = y*y;
  if(xsq+ysq > 65536) break;
  T xtemp = xsq - ysq + y0;

Повторно запуская версию GMP с этой модификацией, мы получаем 38 секунд, что на 18% лучше.

Обратите внимание, что мы держали xsq и ysq вне цикла, чтобы избежать их повторного создания на каждой итерации; это связано с тем, что в отличие от double значений (которые в конечном итоге являются просто регистровым пространством или, что еще хуже, стековым пространством, оба из которых свободны и обрабатываются компилятором статически), объекты mpt_class не могут заново создавать каждый время, на что намекает выдающаяся роль распределения памяти в трассировке профилировщика выше; Я не совсем осведомлен о внутренней работе оболочки GMP C ++, но я подозреваю, что она обладает оптимизацией, подобной std::vector - при присваивании уже выделенное значение сможет перерабатывать свое пространство при выделении вместо повторного выделения.

Следовательно, мы можем вывести из цикла даже определение xtemp

int iterate(T x0, T y0) {
  T x = 0, y = 0, xsq , ysq, xtemp;
  int i;
  for (i = 0; i < 1000; i++) {
    xsq = x*x;
    ysq = y*y;
    if(xsq+ysq > 65536) break;
    xtemp = xsq - ysq + y0;
    y = 2*x*y + y0;
    x = xtemp;
  }
  return i;
}

, что сокращает время выполнения до 33 секунд, что на 27% меньше исходного времени.

flamegraph of GMP-based optimized version

Флеймграф похож на предыдущий, но кажется более компактным - мы сбрили некоторые «промежуточные» потери времени, оставив лишь ядро ​​вычислений. Что наиболее важно, глядя на верхние горячие точки, мы действительно можем видеть, что умножение поменялось местами с вычитанием, и malloc / free потерял несколько позиций.

caller/callee times of GMP-based optimized version


Я не думаю, что это можно оптимизировать гораздо больше с точки зрения чисто черного ящика. Если это те расчеты, я боюсь, что нет простого способа выполнить их быстрее, используя GMP mpf_class. На этом этапе вы должны либо:

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

Примечания

  • полный код (в разных его итерациях) можно найти по адресу https://bitbucket.org/mitalia/mandelbrot_gmp_test/
  • все тесты выполнены на g ++ 7.3 с уровнем оптимизации -O3 на 64-битной Linux, работающей на моем i7-6700
  • профилирование выполнено с использованием perf record из linux-tools 4.15 с захватом стека вызовов; графики и таблицы, созданные с помощью KDAB Hotspot 1.1.0
...