C - есть простой цикл, который делает арифметический расчет;Профилировщик показывает, что это узкое место.Как ускорить это? - PullRequest
18 голосов
/ 31 декабря 2011

Мой первый пост здесь.Отличный сайт и ресурс.

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

Я пытаюсь удалить любую избыточностьи раздувать из библиотеки C астрономических расчетов, которую использует моя программа на C ++.Я запустил простой профилировщик (VerySleepy).

Вот код, который профилировщик показывал как наиболее часто используемый (кроме функций библиотеки C sprintf и т. Д.):

double swi_echeb(const double x, const double* const coef, const int ncf)
{
    int j = ncf - 1;
    double x2, br, brp2, brpp;
    x2 = x * 2.;
    br = 0.;
    brp2 = 0.;  /* dummy assign to silence gcc warning */
    brpp = 0.;

    for (; j >= 0; --j) {                 // <-- 0.39s
        brp2 = brpp;                      // <-- 0.01s
        brpp = br;                        // <-- 0.32s
        br = x2 * brpp - brp2 + coef[j];  // <-- 3.49s ***
    }                                     // <-- 0.14s

    return (br - brp2) * .5;              // <-- 0.06s
}                                         // <-- 0.05s

Thisопределенная функция глубоко встроена в другие, и основная функция «запуска», которую вызывает моя программа, вызывается тысячи раз.

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

Как:

  1. Было бы лучше разделить это утверждение на более мелкие части?:

    br = x2 * brpp;

    br -= brp2;

    br += coef[j];

Любые другие идеи или критические замечания.Я не писал этот код, хотя я добавил const к параметрам функции, так как мне нравится const -корректность.

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

Я знаю, что люди скажут: "Попробуйте!"Поэтому я сделаю и обновлю то, что я получу, если это поможет кому-либо с похожими арифметическими вопросами.

РЕДАКТИРОВАТЬ: публикация результатов, которые я протестировал из предложений

Для того, чтобысамый быстрый, самый медленный, вот что я нашел до сих пор.Профилировщик очень спящий.Компилятором является Visual Studio 2008 Pro Ed.Варианты компиляции для библиотеки и моего приложения:

Debug, C7 format, /O2 /Ob2 /Oi /Ot /Oy /GT /GL /GF /FD /MTd /GS- /Gy /fp:fast /FAs

Ниже приводится предложение Эндрю о выполнении "4 итераций в цикле".До сих пор это был самый быстрый результат.

ОБЩЕЕ ВРЕМЯ, потраченное на функцию (здесь время от других операторов в функции не показано) = 2,08 секунды

for (; index >= 3; index -= 4) {                    // 0.02s
    brp2    = brpp;
    brpp    = br;                                   // 0.02s
    br      = x2 * brpp - brp2 + coef[index];       // 0.25s
    brp2    = brpp;
    brpp    = br;                                   // 0.13s
    br      = x2 * brpp - brp2 + coef[index - 1];   // 0.33s
    brp2    = brpp;
    brpp    = br;                                   // 0.13s
    br      = x2 * brpp - brp2 + coef[index - 2];   // 0.34s
    brp2    = brpp;
    brpp    = br;                                   // 0.14s
    br      = x2 * brpp - brp2 + coef[index - 3];   // 0.42s
}

for (; index >= 0; --index) {                 // 0.03s
    brp2    = brpp;                           // 0.03s
    brpp    = br;
    br      = x2 * brpp - brp2 + coef[index]; // 0.11s
}

Следующим быстрым был исходный неизмененный код с общим временем 2,39 секунды внутри функции, снова включив операторы вне цикла.Обратите внимание, что это меньше, чем мой оригинальный пост.Мой оригинальный пост был неоптимизированным кодом, но поскольку все предлагали его, все мои тесты были впоследствии настолько оптимизированы, насколько я мог получить в VS08:

for (j = ncf - 1; j >= 0; j--) {      // 0.02s
    brp2 = brpp;                      // 0.03s
    brpp = br;                        // 0.07s
    br = x2 * brpp - brp2 + coef[j];  // 2.14s
}

После этого оригинального кода следующим быстрым было представление Дрю о настройке.указатель заранее и используя это. Общее время, потраченное внутри функции, составило 2,49 секунды , включая время из операторов вне цикла:

for (; index >= coef; --index) {         // 0.01s
    brp2    = brpp;
    brpp    = br;                        // 0.06s
    br      = x2 * brpp - brp2 + *index; // 2.24s
}

Я также попробовал сочетание развертывания цикла Эндрю и использования указателя Дрю, но это заняло 2,39 секунды , то же самое, что и неизмененный код.

Исходя из результатов, развертывание цикла - это путь для моего использования.

Ответы [ 10 ]

9 голосов
/ 31 декабря 2011

Похоже, что это проблема с кешем, а не арифметика.

for (; j >= 0; --j) {
    ...
    ... coef[j];
}

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

Можно ли считать вперед?Т.е.

for (int i = 0; i <= j; i++) {
    ...
    ... coef[i];
}

Ваш расчет был бы верным?

9 голосов
/ 31 декабря 2011

Первым делом я бы попытался выполнить итерацию с шагом 4, то есть: j + = 4 (или, в вашем случае, j - = 4) и полуразвернуть цикл.Причина этого заключается в том, что это поможет компилятору оптимизировать SSE и обеспечить пакетный доступ к памяти из основной памяти в кэш.Просто имейте в виду, что вам придется обслуживать последние несколько элементов в случае, если количество циклов не делится на 4. Например:

// Disclaimer: I have not tested this code!
for (; j >= 3; j -= 4) {              
    brp2 = brpp;                      
    brpp = br;                        
    br = x2 * brpp - brp2 + coef[j]; 
    brp2 = brpp;                      
    brpp = br;                        
    br = x2 * brpp - brp2 + coef[j-1]; 
    brp2 = brpp;                      
    brpp = br;                        
    br = x2 * brpp - brp2 + coef[j-2]; 
    brp2 = brpp;                      
    brpp = br;                        
    br = x2 * brpp - brp2 + coef[j-3]; 
}                          
// if (j % 4) != 0 before the loop operation, 
// handle 1, 2 or 3 remaining elements here

Второе, что я хотел бы попробовать, - это предварительно загрузить coeff [j]в реестр непосредственно перед расчетом.Причина этого в том, что вычисления с плавающей запятой конвейерны, а это означает, что доступ к памяти в неправильном месте может отрицательно сказаться на производительности.Сам расчет может быть очень быстрым, но может потребовать 14 инструкций только для того, чтобы поместить данные из кэша в FPU.Добавьте к этому доступ из основной памяти, это может стать еще хуже.Например, попробуйте это (также можно попробовать с развертыванием - = 4 и без него)

// Disclaimer: I have not tested this code!
register double coef1, coef2, coef3, ceof4;
for (; j >= 3; j -= 4) {           
    coef1 = coef[j];    // Preloads the 4 sequential coeffs from 
    coef2 = coef[j-1];  // main memory to cache (if available)
    coef3 = coef[j-2];  
    coef4 = coef[j-3];  
    brp2 = brpp;                      
    brpp = br;                        
    br = x2 * brpp - brp2 + coef1; 
    brp2 = brpp;                      
    brpp = br;                        
    br = x2 * brpp - brp2 + coef2; 
    brp2 = brpp;                      
    brpp = br;                        
    br = x2 * brpp - brp2 + coef3; 
    brp2 = brpp;                      
    brpp = br;                        
    br = x2 * brpp - brp2 + coef4; 
} 

В этом случае переменные удваивают x2, br, brp2, brpp, coef1, coef2, coef3, coef4быть регистры, если это вообще возможно.

Наконец, используя вышесказанное, можете ли вы применить к нему оптимизацию SSE / SSE2?Убедитесь, что это включено в компиляторе GCC (я привык к VS, так что эквивалентом будет включение режима Release, отключение символов отладки, включение оптимизации, SSE2) и тестирование кода без подключенного отладчика.Одно это может оказать существенное влияние на производительность.

Дайте нам знать результаты.Настройка производительности - метод проб и ошибок!

Удачи,

7 голосов
/ 01 января 2012

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

Есть очень простой способ регистрации, который может сделать каждый. Сборка проекта для недавнего процессора. Нужно ли запускать этот код на компьютере с 1995 года?2000?2005?Если программа может рассчитывать на более новый процессор, она может рассчитывать на то, что в ее распоряжении будет больше регистров.

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

double swi_echeb(const double x, const double* const coef, const int ncf)
{
    const double *j = &coef[ncf - 1];
    // (stuff...)

    while (true) { 
        // (stuff...)

        br = x2 * brpp - brp2 + *j;
        if ( j == coef )
            break;
        --j;
    }  
}
4 голосов
/ 01 января 2012

Основная «проблема» с этим кодом заключается в том, что у вас есть критический путь вдоль br. Вы не можете начать вычислять следующую итерацию до того, как полностью завершите предыдущую. Это также запрещает инструкции вектора: нечего векторизовать.

У меня сложилось впечатление, что числовые коэффициенты всегда (однозначные?) И время выполнения зависит от количества обращений к этой функции.

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

3 голосов
/ 01 января 2012

Эта операция является небольшим изменением суммы префикса / сканирования. (Это сканирование 3-op, 2-истории). Ключевым ограничителем производительности здесь, скорее всего, является сериализация (ваших математических операций в канале инструкций), вызванная зависимостями перекрестного цикла, поэтому развертывание последовательного цикла вряд ли здесь сильно поможет.

Существуют стандартные способы распараллеливания сумм префиксов (см. Википедию), которые можно использовать для ускорения этого. Даже с одним потоком вы сможете значительно повысить свою эффективность, разделив массив коэффициентов на 4 подмассива и вычислив некоторый префикс для каждого из них в каждой итерации цикла - четыре потока вычислений независимы и будут должным образом распределены по конвейеру. вашим оборудованием. Более того, поскольку они независимы, вы (или ваш компилятор, если вам повезет, но я сомневаюсь в этом) можете использовать SSE или AVX на x86 для параллельной обработки массива.

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

3 голосов
/ 31 декабря 2011

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

  • Следует попробовать понятие Эндрю о развертывании цикла.
  • Определенно убедитесь, что у вас включена оптимизация с -O3

После этого вам нужно взглянуть на язык ассемблера или параллелизм.

2 голосов
/ 06 января 2012

Если вы придерживаетесь предложения Дрю об использовании указателя, а не индексации массива, может потребоваться ограничить указатель, чтобы увидеть какое-либо преимущество:

...
double *restrict index = coef + ncf - 1;
...
for (; index >= coef; --index) {
    brp2    = brpp;
    brpp    = br;
    br      = x2 * brpp - brp2 + *index;
}

Этоможет помочь оптимизации кэша, потому что компилятор может быть уверен, что никто не изменит значение, на которое указывает index.


Кроме того, в прошлом году я опубликовал аналогичную проблему, которая получила рядответы.Обязательно посмотрите .

2 голосов
/ 01 января 2012

Это тот случай, когда профилировщик находит то, что вы ожидаете.Посмотрите на код: у вас есть какая-то настройка цикла («о, он запускается один раз, ничего не займет») и цикл.Внутри цикла у вас есть два назначения («нет, это дешево») и ровно одна строка кода, которая выполняет умножение, два сложения и ссылку на массив.

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

Лучше всего подняться на один или два уровня.Как вы можете уменьшить количество раз, когда эта функция вызывается?Вызывается ли он с одними и теми же параметрами несколько раз, чтобы вы могли кэшировать результаты?Есть ли места, где вы можете использовать меньше коэффициентов, сокращая количество циклов?

2 голосов
/ 31 декабря 2011

Каковы типичные значения для ncf?Основная причина, по которой я спрашиваю, состоит в том, что вы перебираете coef в обратном направлении.Непоследовательный доступ не позволяет использовать кэш 1004 *.

0 голосов
/ 02 января 2012

Вам нужна полная точность двойного?Вместо этого перемещение во всплывающее окно может сэкономить время.

Хотя оптимизатор должен это выяснить, добавление явной подсказки в регистр для объявлений переменных не повредит:

register double x2, br, brp2, brpp;

Вы также можете попробоватьперемещение coef в регистр:

register double* rc;
rc = coef;
. . .
br = x2 * brpp - brp2 + rc[j];

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

brp2 = brpp;     
brpp = br;
br = x2 * brpp;
br -= brp2;
br += rc[j];

Вы можете взглянуть на сгенерированный код, чтобы увидеть, есть ли какие-либоочевидная неэффективность.

...