Как я могу оптимизировать интенсивную вычислением программу C++ с известным узким местом?

Я разрабатываю некоторое научное программное обеспечение для своего университета. Это пишется в C++ в Windows (VS2008). Алгоритм должен вычислить некоторые значения для большого количества матричных пар, то есть, в ядре находится итерация цикла по матрицам, собирая некоторые данные, например:

sumA = sumAsq = sumB = sumBsq = diffsum = diffsumsq = return = 0;
for (int y=0; y < height; ++y)
{
    for (int x=0; x < width; ++x)
    { 
        valA = matrixA(x,y);
        valB = matrixB(x,y);
        sumA+=valA;
        sumAsq+=valA*valA;
        sumB+=valB;
        sumBsq+=valB*valB;
        diffsum+=valA-valB;
        diffsumsq+=(valA-valB)*(valA-valB);
    }
}
return = sumA + sumB / sumAsq + sumBsq * diffsum * diffsumsq

Эта стандартная программа является выполняемыми миллионами времен для другого matrixA, matrixB пары. Моя проблема состоит в том, что эта программа является чрезвычайно медленной, скомпилирована в режиме Release со всей активированной оптимизацией. Используя "pause-when-busy-and-inspect" метод отладчика, я установил, что программа находится в этом цикле фактически каждый раз, даже при том, что, как Вы могли бы ожидать, эта стандартная программа окружается целым набором ответвлений управления и условий. Что озадачивает меня, большинство - то, что во время его выполнения в двухпроцессорной основанной на xeon системе, программа использует одно из этих 4 ядер (не удивительно, это является однопоточным на данный момент), но только приблизительно до 25% его предела, и с относительно большими колебаниями, где я ожидал бы устойчивую, 100%-ю загрузку, пока программа не завершается.

Текущая версия является на самом деле переписыванием, созданный с оптимизацией производительности в памяти. Я был опустошен, когда я узнал, что это на самом деле медленнее, чем оригинал. Предыдущая версия использовала матрицы Повышения, которые я заменил матрицами OpenCV, установив их, чтобы быть более чем в 10 раз быстрее в сравнении времени выполнения умножения два 1000x100 матрицы. Я получаю доступ к матрице путем ручного разыменования необработанного указателя на его данные, которые я надеялся, получит меня некоторая производительность. Я сделал стандартную программу вычисления мультилинией #define макрос, чтобы осуществить его встраивание и избежать вызовов функции и возвратов. Я улучшил математику позади вычислений так, чтобы окончательное значение было вычислено в единственной передаче через матрицы (старая версия требует двух передач). Я ожидал получать огромные усиления, и все же противоположное верно. Я нигде не около эффективности моей старой программы, не говоря уже о коммерческом программном обеспечении для конкретного приложения.

Я задавался вопросом, имело ли это, возможно, некоторое отношение к матричным данным, являющимся 8-разрядными символами, я однажды видел, что доступ к плаваниям был на самом деле медленнее, чем к, удваивается в моей старой программе, возможно, символы еще медленнее, так как процессор получает данные в 32-разрядных блоках (этот Xeon, вероятно, захватывает даже 64 бита). Я также полагал, что превращение матриц в векторы избежало конструкции цикла в цикле, а также некоторой формы векторизации, как, например, вычисление данных для 4 (меньше?еще?) последовательные ячейки матрицы на единственном повторении цикла. Какие-либо другие идеи?

Править: фактический код в новой, основанной на OpenCV версии:

const char *Aptr, *Bptr;
double sumA = 0, sumB = 0, sumAsq = 0, sumBsq = 0, diffsum = 0, diffsumsq = 0;
char Aval, Bval;

for (int y=0; y < height; ++y)
{
    Aptr = (char*)(AMatrix.imageData + AMatrix.widthStep * y);
    Bptr = (char*)(BMatrix.imageData + BMatrix.widthStep * y);
    for (int x=0; x < width; ++x)
    {
        Aval = Aptr[x];
        Bval = Bptr[x];

        sumA+=Aval;
        sumB+=Bval;
        sumAsq+=Aval*Aval;
        sumBsq+=Bval*Bval;
        diffsum+=Aval-Bval;
        diffsumsq+=(Aval-Bval)*(Aval-Bval);
    }
}
9
задан neuviemeporte 29 July 2010 в 13:17
поделиться

10 ответов

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

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

Вы говорите, что коэффициент использования никогда не превышает 25%. Может ли это быть из-за того, что используется только одно из 4 ядер?

Вы говорите, что загрузка часто падает ниже 25%. Это говорит о том, что, возможно, поток блокирует выполнение файлового ввода-вывода. Если это так, ваши паузы должны уловить это в действии и подтвердить это. Если это так, вы могли бы ускорить ввод-вывод, используя большие блоки или, возможно, открывая / закрывая реже.Обратите внимание, что улучшения вашего внутреннего цикла сократят время, затрачиваемое на этот цикл, но не сократят время, затрачиваемое на ввод-вывод, поэтому процент времени, затрачиваемого на ввод-вывод, увеличится (вызывая очевидное снижение использования) , пока вы не уменьшите его размер.

Использование на самом деле не очень полезное число. На самом деле это всего лишь индикатор разделения ЦП / ввода-вывода, и он вообще не говорит вам, слишком много вы делаете из того или другого.

Как сказал @renick, избавьтесь от вычисления адресов. Вы должны быть в состоянии пройти через этот цикл на уровне языка ассемблера и увидеть, что он не делает ничего больше, чем если бы вы надели шляпу «гуру» и написали сборку самостоятельно.

В любом случае векторизация может быть большим выигрышем.

1
ответ дан 4 December 2019 в 19:32
поделиться

Разные мысли:

  • Вы говорите, что вам удается достичь загрузки процессора только около 25%. Я могу придумать для этого две причины:
    1. Вы меняете местами. Какой размер у ваших матриц? Умещаются ли они полностью в физической памяти? Посмотрите на использование памяти вашим приложением и размер рабочего набора.
    2. Остальной код вашего приложения блокирует ввод-вывод. Выполняет ли код, окружающий вашу основную процедуру, какие-либо операции ввода-вывода? Это может быть блокировка там на большие промежутки времени, но, конечно, вы не видите этого, используя технику «пауза-когда-занято-и-проверяй», потому что всякий раз, когда процесс снова разблокируется, он возвращается прямо в ваше ядро, требующее интенсивных вычислений. рутина.
  • Взгляните на ассемблерный код своей основной процедуры. Выглядит ли это разумно?
  • Вам действительно нужно вычислять diffsum внутри цикла? Похоже, что вы могли бы сделать diffsum = sumA-sumB один раз вне цикла, но могут быть числовые соображения, которые не позволят вам сделать это.
  • Как уже прокомментировал Реник, это выглядит как основная цель для оптимизации SSE. Опять же, вы должны убедиться, что компилятор генерирует разумный код сборки (если вы используете встроенные функции, а не пишете сборку самостоятельно).
  • Если вы не хотите писать код SSE самостоятельно, по крайней мере убедитесь, что установлен флаг SSE вашего компилятора. Это позволит компилятору использовать модуль SSE вместо FPU для скалярных операций с плавающей запятой, что само по себе улучшит производительность, поскольку FPU на основе стека на x86, как известно, плохо подходит для генерации кода компилятора.
3
ответ дан 4 December 2019 в 19:32
поделиться

Вам следует попытаться избавиться от циклов и вместо этого попробовать векторизовать операции. Используя такую ​​библиотеку, как Eigen , ваш код будет выглядеть примерно так:

Eigen::MatrixXd matrixA(height, width);
Eigen::MatrixXd matrixB(height, width);
double sumA = matrixA.sum();
double sumAsq = matrixA.cwise().square().sum();
double sumB = matrixB.sum();
double sumBsq = matrixB.cwise().square().sum();

Eigen::MatrixXd diff = matrixA - matrixB;
double diffSum = diff.sum();
double diffSumSq = diff.cwise().square().sum();

return sumA + sumB / sumAsq + sumBsq * diffSum * diffSumSq;
1
ответ дан 4 December 2019 в 19:32
поделиться

«25% от своего предела и с относительно большими колебаниями, при которых я ожидал бы устойчивой, 100% -ной нагрузки, пока программа не завершится».

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

Я бы также рекомендовал использовать одну из математических библиотек, например Eigen , ATLAS или GSL

0
ответ дан 4 December 2019 в 19:32
поделиться

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

vala = *matrixA++; 
valb = *matrixB++; 

и, поскольку вы выполняете простые сложения и вычитания, посмотрите на SSE/SSE2 и т.д. в зависимости от возможностей вашего целевого процессора и вашей арифметики (целочисленной, с плавающей точкой и т.д.).

EDIT: MMX SSE2 intrinsics - это функции, которые отображаются один к одному с SIMD инструкциями CPU. Посмотрите эти страницы microsoft, чтобы начать, и дополнительно я советую заглянуть на сайт Intel для IA-32/ Intel64 руководства программиста или аналогичные руководства от AMD.

Я также очень рекомендую эту книгу по оптимизации для архитектур Intel. Она объяснит все скрытые возможности вашего процессора и компилятора.

3
ответ дан 4 December 2019 в 19:32
поделиться

Можете ли вы проверить ассемблерный код, генерируемый этим циклом? Если вы используете только 25% процессора, возможно, этот цикл связан с памятью. Там около восьми локальных переменных, и я полагаю, что компилятор не отображает их все в регистры, поэтому в каждом цикле выполняется много операций с памятью. Одно из соображений - написать этот цикл на ассемблере.

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

2
ответ дан 4 December 2019 в 19:32
поделиться

Если бы я был на вашем месте, я бы попытался выяснить, что именно вызывает разницу в производительности между старым и новым кодом. Возможно, матрицы boost используют какое-то кэширование или ленивую/нетерпеливую оценку.

1
ответ дан 4 December 2019 в 19:32
поделиться

Сохранять матрицы с такими же параметрами вне цикла? Я думаю, что тебе нужно немного сэкономить.

0
ответ дан 4 December 2019 в 19:32
поделиться

TLDR: сначала оптимизируйте алгоритм умножения матриц, затем следите за количеством темпоралов, затем оптимизируйте внутренние распределения матриц.

Длинный ответ:

Я думаю, что наиболее важным моментом является оптимизация умножения матриц. Умножение матриц для самого интуитивного алгоритма - это O(n^3) (что огромно даже для маленьких матриц).

Для примера, для умножения матрицы 2x2 у вас есть 16 операций умножения ("мо"). Для умножения матрицы 3x3 у вас есть 27 mo, а для 4x4 - 64 mo.

Я не уверен, как это реализовано в вашем случае, но если это интуитивный алгоритм (как тройной for цикл), изменение этого алгоритма на матричное умножение с использованием LU-разложенных матриц должно резко увеличить вашу производительность.

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

Далее, рассмотрите возможность использования кэшированных значений вместо повторения операций сложения в diffsumsq:

старый код:

diffsum+=valA-valB; // compute difference once
diffsumsq+=(valA-valB)*(valA-valB); // compute it two more times, then multiply

новый код:

diff = valA-valB; // compute difference once
diffsum+= diff; // use computed difference
diffsumsq+=diff*diff; // just multiply

Второй вариант в три раза быстрее на вычислении разности (два for цикла - операции x* y выполняются только один раз вместо трех).

Далее можно следить за количеством временных объектов: каждая бинарная операция создает временный объект (что означает выделение в памяти еще одной матрицы x*y и копирование значений). Например, выражение:

diffsumsq+=(valA-valB)*(valA-valB);

создает временный объект для разности в первой парантезе, затем еще один для разности во второй, затем еще один для произведения.

В моем примере лучше написать

diff = valA;
diff -= valB;

вместо

diff = valA - valB;

так как таким образом вы избежите выделения временного, которое присваивается diff (во втором варианте).

Еще одна вещь, за которой нужно следить, это потребление памяти: поскольку эта функция выполняется часто, вы можете либо предварительно выделить память, либо объединить используемые матрицы и повторно использовать память вместо создания новых объектов.

Я уверен, что есть и другие вещи, за которыми нужно следить.

Edit: Как вы можете умножать матрицы? Нужно, чтобы они сопоставлялись по столбцам x строкам. То есть, количество столбцов в valA должно быть равно количеству строк в valB (если я правильно помню свои умножения матриц).

Еще один момент:

Я сделал процедуру вычисления в виде многострочный макрос #define, чтобы обеспечить ее инлайнинг и чтобы избежать вызовов и возвратов функций.

Для оптимизации кода на C++ макросы не нужны. Чтобы избежать вызовов и возвратов функций, используйте встроенныефункции. Макросы имеют свои проблемы.

0
ответ дан 4 December 2019 в 19:32
поделиться

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

1
ответ дан 4 December 2019 в 19:32
поделиться
Другие вопросы по тегам:

Похожие вопросы: