Подтвердить что ты не робот

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

Эта конкретная функция глубоко встроена в другие, а основная функция "kick-off", которую вызывает моя программа, называется тысячами раз.

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

Как

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

    br = x2 * brpp;

    br -= brp2;

    br += coef[j];

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

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

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

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

В порядке от самого быстрого до самого медленного, вот что я нашел до сих пор. Профилирование - VerySleepy. Компилятор - это 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
}

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

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

4b9b3361

Ответ 1

В первую очередь я бы попытался выполнить итерацию с шагом 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; 
} 

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

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

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

Желаем удачи,

Ответ 2

Это проблема с кешем, а не арифметическая.

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

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

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

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

Будет ли ваш расчет действительным?

Ответ 3

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

Там очень простой трюк, который может сделать любой. Создайте проект для недавнего процессора. Требуется ли этот код для запуска на компьютере с 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

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

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

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

Ответ 5

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

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

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

Ответ 6

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

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

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

Ответ 7

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

Ответ 8

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

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

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

Ответ 9

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

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

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


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

Ответ 10

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

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

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];

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