Это более эффективно разветвлять или умножать? - PullRequest
7 голосов
/ 05 февраля 2009

Я пытаюсь оптимизировать небольшую, часто используемую функцию, которая использует старшие биты в unsigned short int, чтобы указать значения массива для суммирования вместе. Сначала я использовал очевидный подход, показанный ниже. Обратите внимание, что развертывание цикла явно не отображается, поскольку это должно быть сделано компилятором.

int total = 0;
for(unsigned short mask = 0x0001, j = 0; mask != 0; mask <<= 1, j++){
    if (i & mask){
        total += value[j];
    }
}

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

int total = 0;
for(unsigned short mask = 0x0001, j = 0; mask != 0; mask <<= 1, j++){
    total += ((i & mask) != 0) * value[j];
}

Обратите внимание, что, поскольку (i & mask) не приводит к логическому ответу, сравнение с 0 приводит к тому, что результатом будет 1 или 0. Хотя этот второй подход исключает оператор if из этого раздела кода, Второе решение должно выполнять умножение 0 или 1 на каждой итерации в дополнение к остальной части уравнения.

Какой код будет работать быстрее?

Ответы [ 12 ]

13 голосов
/ 05 февраля 2009

Какой код будет работать быстрее?

Проверьте это, чтобы узнать.

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

9 голосов
/ 05 февраля 2009

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

Предположим на данный момент, что вы используете позднюю модель Pentium 4. Этот процессор имеет два уровня предикторов ветвления, встроенных в процессор. Если предикторы ветвления могут правильно угадать направление ветвления, я подозреваю, что первое будет самым быстрым. Это наиболее вероятно, если флаги имеют почти все одинаковые значения или большую часть времени они чередуются по очень простой схеме. Если флаги действительно случайны, то предсказатель ветвления будет ошибаться в половине случаев. Для нашего гипотетического 32-этапного Pentium 4 это снизит производительность. Для чипов Pentium 3, Core 2, Core i7 и большинства чипов AMD конвейеры короче, поэтому стоимость прогноза плохих веток намного ниже.

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

7 голосов
/ 05 февраля 2009

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

Во-первых, вы можете легко извлечь биты, установленные с помощью:

unsigned short set_mask= i & -i;
i&= i - 1;

Затем вы можете получить битовый индекс, посчитав биты, установленные в (set_mask - 1). Для этого есть формула с постоянным временем.

Некоторые платформы также имеют встроенную функцию получения битового индекса набора битов, который, вероятно, быстрее. x86 имеет bsr, PPC имеет cntlz.

Таким образом, ответ - версия без множителей, вероятно, самая быстрая:)

4 голосов
/ 05 февраля 2009

А как насчет этой ревизии?

int total = 0;
for (unsigned mask = i & 0xFFFF, j = 0; mask != 0; mask >>= 1, j++){
    total += (mask & 0x0001) * value[j];
}

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


Это показывает, почему измерения важны. Я использую устаревший Sun SPARC. Я написал тестовую программу, как показано, с двумя претендентами из вопроса в качестве теста 0 и теста 1 и моим собственным ответом в качестве теста 2. А затем выполнил временные тесты. «Сумма» печатается как проверка работоспособности - чтобы гарантировать, что все алгоритмы дают одинаковый ответ.

64-битный неоптимизированный:

gcc -m64 -std=c99 -I$HOME/inc -o x x.c -L$HOME/lib/sparcv9 -ljl -lposix4

Test 0: (sum = 1744366)  7.973411 us
Test 1: (sum = 1744366) 10.269095 us
Test 2: (sum = 1744366)  7.475852 us

Приятно: у меня немного быстрее, чем у оригинала, а ускоренная версия медленнее.

64-разрядная оптимизация:

gcc -O4 -m64 -std=c99 -I$HOME/inc -o x x.c -L$HOME/lib/sparcv9 -ljl -lposix4

Test 0: (sum = 1744366)  1.101703 us
Test 1: (sum = 1744366)  1.915972 us
Test 2: (sum = 1744366)  2.575318 us

Черт, моя версия теперь самая медленная. Оптимизатор хорош!

32-разрядная оптимизация:

gcc -O4 -std=c99 -I$HOME/inc -o x x.c -L$HOME/lib -ljl -lposix4

Test 0: (sum = 1744366)  0.839278 us
Test 1: (sum = 1744366)  1.905009 us
Test 2: (sum = 1744366)  2.448998 us

32-битный неоптимизированный:

gcc -std=c99 -I$HOME/inc -o x x.c -L$HOME/lib -ljl -lposix4

Test 0: (sum = 1744366)  7.493672 us
Test 1: (sum = 1744366)  9.610240 us
Test 2: (sum = 1744366)  6.838929 us

Тот же код на (32-битном) Cygwin и не очень старомодном ноутбуке (32-битный, оптимизированный)

Test 0: (sum = 1744366)  0.557000 us
Test 1: (sum = 1744366)  0.553000 us
Test 2: (sum = 1744366)  0.403000 us

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

Испытательный жгут (кричите, если хотите код timer.h и timer.c):

#include <stdio.h>
#include "timer.h"

static volatile int value[] =
{
    12, 36, 79, 21, 31, 93, 24, 15,
    56, 63, 20, 47, 62, 88,  9, 36,
};

static int test_1(int i)
{
    int total = 0;
    for (unsigned short mask = 0x0001, j = 0; mask != 0; mask <<= 1, j++)
    {
        if (i & mask)
            total += value[j];
    }
    return(total);
}

static int test_2(int i)
{
    int total = 0;
    for (unsigned short mask = 0x0001, j = 0; mask != 0; mask <<= 1, j++)
    {
        total += ((i & mask) != 0) * value[j];
    }
    return(total);
}

static int test_3(int i)
{
    int total = 0;
    for (unsigned mask = i & 0xFFFF, j = 0; mask != 0; mask >>= 1, j++)
    {
        total += (mask & 0x0001) * value[j];
    }
    return(total);
}

typedef int(*func_pointer)(int);

static func_pointer test[] = { test_1, test_2, test_3 };

#define DIM(x)(sizeof(x)/sizeof(*(x)))

int main()
{
    int i, j, k;
    char buffer[32];
    for (i = 0; i < DIM(test); i++)
    {
        Clock t;
        long sum = 0;
        clk_init(&t);
        clk_start(&t);
        for (j = 0; j < 0xFFFF; j += 13)
        {
            int rv;

            for (k = 0; k < 1000; k++)
                rv = (*test[i])(j);
            sum += rv;
        }
        clk_stop(&t);
        printf("Test %d: (sum = %ld) %9s us\n", i, sum,
               clk_elapsed_us(&t, buffer, sizeof(buffer)));
    }
}

Я не потратил время на выяснение, почему мой код медленнее при оптимизации.

3 голосов
/ 05 февраля 2009

Это зависит полностью от компилятора, набора машинных инструкций и, возможно, фазы луны.

Нет конкретного правильного ответа из-за этого. Если вы действительно хотите знать, проверьте вывод сборки из компилятора.

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

Итак, правильный ответ: это зависит.

1 голос
/ 10 февраля 2011

Очевидное решение:

int total = 0;
for(unsigned j = 0; j < 16; j++){
    total += -(i>>j & 1) & value[j];
}
1 голос
/ 05 февраля 2009

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

switch (i) {
    case 0: break;
    case 1: total = value[0]; break;
    case 2: total = value[1]; break;
    case 3: total = value[1] + value[0]; break;
    case 4: total = value[2]; break;
    case 5: total = value[2] + value[0]; break;
    ...
}

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

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

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

1 голос
/ 05 февраля 2009

Попробуйте

total += (-((i & mask) != 0)) & value[j];

вместо

total += ((i & mask) != 0) * value[j];

Это позволяет избежать умножения. Будет ли ветвление или нет, зависит от того, достаточно ли умен компилятор, чтобы найти код без ветвей для - (foo! = 0). (Это возможно, но я бы немного удивился.)

(Конечно, это зависит от представления двух дополнений; стандарт С не зависит от этого.)

Вы могли бы помочь такому компилятору, если предположить, что 32-битные целые числа со знаком >> передают бит знака:

total += (((int)((i & mask) << (31 - j))) >> 31) & value[j];

Таким образом, сдвиньте возможно установленный бит влево в старшую значащую позицию, приведите к типу int со знаком, затем вернитесь вправо до самой младшей позиции, получив либо все 0, либо все 1 в соответствии с приведенной выше реализацией определенные предположения. (Я не проверял это.)

Другая возможность: рассматривать блоки, скажем, по 4 бита за раз. Есть 16 различных последовательностей добавления; Вы можете отправлять развернутый код для каждого из них без каких-либо тестов в каждом блоке кода. Здесь есть надежда, что один косвенный скачок будет стоить менее 4 тестов и веток.

Обновление: Используя леса Джонатана Леффлера, метод 4 бита за раз является самым быстрым с большим запасом на моем MacBook. Отрицание - и оказывается примерно таким же, как умножение. Интересно, умножает ли процессор особые случаи, такие как 0 и 1, быстрее (или не такой особый случай, если он быстрее для множителей с наибольшим количеством битов или с множеством битов в целом).

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

#include <stdio.h>
#include <time.h>

static int value[] =
{
    12, 36, 79, 21, 31, 93, 24, 15,
    56, 63, 20, 47, 62, 88,  9, 36,
};

static int test_1(int i)
{
    int total = 0;
    for (unsigned short mask = 0x0001, j = 0; mask != 0; mask <<= 1, j++)
    {
        if (i & mask)
            total += value[j];
    }
    return(total);
}

static int test_2(int i)
{
    int total = 0;
    for (unsigned short mask = 0x0001, j = 0; mask != 0; mask <<= 1, j++)
    {
        total += ((i & mask) != 0) * value[j];
    }
    return(total);
}

static int test_3(int i)
{
    int total = 0;
    for (unsigned mask = i & 0xFFFF, j = 0; mask != 0; mask >>= 1, j++)
    {
        total += (mask & 0x0001) * value[j];
    }
    return(total);
}

static int test_4(int i)
{
    int total = 0;
    for (unsigned mask = i & 0xFFFF, j = 0; mask != 0; mask >>= 1, j++)
    {
        total += -(mask & 0x0001) & value[j];
    }
    return(total);
}

static int test_5(int i)
{
    int total = 0;
    const int *p = value;
    for (unsigned mask = i & 0xFFFF; mask != 0; mask >>= 4, p += 4)
    {
        switch (mask & 0xF)
        {
        case 0x0: break;
        case 0x1: total += p[0]; break;
        case 0x2: total += p[1]; break;
        case 0x3: total += p[1] + p[0]; break;
        case 0x4: total += p[2]; break;
        case 0x5: total += p[2] + p[0]; break;
        case 0x6: total += p[2] + p[1]; break;
        case 0x7: total += p[2] + p[1] + p[0]; break;
        case 0x8: total += p[3]; break;
        case 0x9: total += p[3] + p[0]; break;
        case 0xA: total += p[3] + p[1]; break;
        case 0xB: total += p[3] + p[1] + p[0]; break;
        case 0xC: total += p[3] + p[2]; break;
        case 0xD: total += p[3] + p[2] + p[0]; break;
        case 0xE: total += p[3] + p[2] + p[1]; break;
        case 0xF: total += p[3] + p[2] + p[1] + p[0]; break;
        }
    }
    return(total);
}

typedef int(*func_pointer)(int);

static func_pointer test[] = { test_1, test_2, test_3, test_4, test_5 };

#define DIM(x)(sizeof(x)/sizeof(*(x)))

int main()
{
    int i, j, k;
    for (i = 0; i < DIM(test); i++)
    {
        long sum = 0;
        clock_t start = clock();
        for (j = 0; j <= 0xFFFF; j += 13)
        {
            int rv;

            for (k = 0; k < 1000; k++)
                rv = (*test[i])(j);
            sum += rv;
        }
        clock_t stop = clock();
        printf("(sum = %ld) Test %d: %8.6f s\n", sum, i + 1, 
               (stop - start) / (1.0 * CLOCKS_PER_SEC));
    }
}

Результаты (gcc -O4 -std=c99 branchmult2.c):

(sum = 1744366) Test 1: 0.225497 s
(sum = 1744366) Test 2: 0.221127 s
(sum = 1744366) Test 3: 0.126301 s
(sum = 1744366) Test 4: 0.124750 s
(sum = 1744366) Test 5: 0.064877 s

Редактировать 2: Я решил, что тест будет более реалистичным без квалификатора volatile.

1 голос
/ 05 февраля 2009

почему бы не сделать это (при условии, что я 32-битный)

  for (i2 = i; i2; i2 = i3) {
    i3 = i2 & (i2-1);
    last_bit = i2-i3;
    a = last_bit & 0xffff;
    b = (last_bit << 16);
    j = place[a] + big_place[b];
    total += value[j];
  }

Где место - это таблица размером 2 ^ 15 + 1 такая, что место [0] = 0, место [1] = 1, место [2] = 2, место [4] = 3, место [8] = 4 ... место [15] = 16 (остальные значения не указываются не имеет значения). и big_place почти идентичен: big_place [0] = 0, big_place [1] = 17 .... big_place [15] = 32.

1 голос
/ 05 февраля 2009

Единственный реальный способ определить истинность утверждения - это проверить. Имея это в виду, я бы согласился с предыдущими постами, в которых говорится, попробуйте!

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

...