Почему компиляторы C оптимизируют переключение и если по-другому - PullRequest
9 голосов
/ 11 октября 2019

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

В очень узком цикле у меня есть целое число со значением от 0 до 15. Мне нужно получить -1 для значений0, 1, 8 и 9 и 1 для значений 4, 5, 12 и 13.

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

Ссылка здесь: https://godbolt.org/z/WYVBFl

Код:

const int lookup[16] = {-1, -1, 0, 0, 1, 1, 0, 0, -1, -1, 0, 0, 1, 1, 0, 0};

int a(int num) {
    return lookup[num & 0xF];
}

int b(int num) {
    num &= 0xF;

    if (num == 0 || num == 1 || num == 8 || num == 9) 
        return -1;

    if (num == 4 || num == 5 || num == 12 || num == 13)
        return 1;

    return 0;
}

int c(int num) {
    num &= 0xF;
    switch (num) {
        case 0: case 1: case 8: case 9: 
            return -1;
        case 4: case 5: case 12: case 13:
            return 1;
        default:
            return 0;
    }
}

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

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

Кто-нибудь может объяснить, почему существует это несоответствие? Как «правильно» оптимизировать этот запрос?

РЕДАКТИРОВАТЬ:

Уточнение

I Хотите, чтобы решение переключения было самым быстрым, илианалогично «чистое» решение. Однако при компиляции с оптимизацией на моей машине решение if значительно быстрее.

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

С static inline таблица поиска немного ускоряется: Попробуйте онлайн!

Ответы [ 4 ]

6 голосов
/ 11 октября 2019

Если вы явно перечислите все случаи, gcc очень эффективен:

int c(int num) {
    num &= 0xF;
    switch (num) {
        case 0: case 1: case 8: case 9: 
            return -1;
        case 4: case 5: case 12: case 13:
            return 1;
            case 2: case 3: case 6: case 7: case 10: case 11: case 14: case 15: 
        //default:
            return 0;
    }
}

только что скомпилирован в простую проиндексированную ветвь:

c:
        and     edi, 15
        jmp     [QWORD PTR .L10[0+rdi*8]]
.L10:
        .quad   .L12
        .quad   .L12
        .quad   .L9
        .quad   .L9
        .quad   .L11
        .quad   .L11
        .quad   .L9
        .quad   .L9
        .quad   .L12
etc...

Обратите внимание, что если default:без комментариев gcc возвращается к своей версии с вложенными ветвями.

4 голосов
/ 11 октября 2019

Следующий код вычислит ваш поиск без ветвей, без LUT, за ~ 3 такта, ~ 4 полезных инструкции и ~ 13 байт машинного кода x86 с высокой степенью * *.

Это зависитдля целочисленного представления дополнения 2.

Однако вы должны убедиться, что определения типов u32 и s32 действительно указывают на 32-битные целые типы без знака и со знаком. stdint.h типы uint32_t и int32_t были бы подходящими, но я не знаю, доступен ли вам заголовок.

const int lookup[16] = {-1, -1, 0, 0, 1, 1, 0, 0, -1, -1, 0, 0, 1, 1, 0, 0};

int a(int num) {
    return lookup[num & 0xF];
}


int d(int num){
    typedef unsigned int u32;
    typedef signed   int s32;

    // const int lookup[16]     = {-1, -1, 0, 0, 1, 1, 0, 0, -1, -1, 0, 0, 1, 1, 0, 0};
    // 2-bit signed 2's complement: 11 11 00 00 01 01 00 00 11 11 00 00 01 01 00 00
    // Hexadecimal:                   F     0     5     0     F     0     5     0
    const u32 K = 0xF050F050U;

    return (s32)(K<<(num+num)) >> 30;
}

int main(void){
    for(int i=0;i<16;i++){
        if(a(i) != d(i)){
            return !0;
        }
    }
    return 0;
}

Убедитесь сами здесь: https://godbolt.org/z/AcJWWf


При выборе константы

Ваш поиск для 16 очень маленьких констант от -1 до +1 включительно. Каждый вписывается в 2 бита, и их 16, которые мы можем расположить следующим образом:

// const int lookup[16]     = {-1, -1, 0, 0, 1, 1, 0, 0, -1, -1, 0, 0, 1, 1, 0, 0};
// 2-bit signed 2's complement: 11 11 00 00 01 01 00 00 11 11 00 00 01 01 00 00
// Hexadecimal:                   F     0     5     0     F     0     5     0
u32 K = 0xF050F050U;

Поместив их с индексом 0, ближайшим к старшему значащему биту, один сдвиг 2*num будетпоместите знаковый бит вашего 2-битного числа в знаковый бит регистра. Если сдвинуть вправо 2-битное число на 32-2 = 30 бит, знак расширяет его до полного int, что завершает трюк.

4 голосов
/ 11 октября 2019
У компиляторов

C есть особые случаи для switch, потому что они ожидают, что программисты поймут идиому switch и используют ее.

Код типа:

if (num == 0 || num == 1 || num == 8 || num == 9) 
    return -1;

if (num == 4 || num == 5 || num == 12 || num == 13)
    return 1;

не пройдет проверку компетентными C-кодерами;три или четыре обозревателя одновременно восклицали бы: «это должно быть switch

. Для компиляторов C не стоит анализировать структуру операторов if для преобразования в таблицу переходов. Условия для этого должны быть правильными, а количество возможных вариантов в куче if утверждений астрономическое. Анализ сложен и и может привести к отрицательному результату (например, «нет, мы не можем преобразовать эти if s в switch»).

0 голосов
/ 11 октября 2019

Вы можете создать тот же эффект, используя только арифметику:

// produces : -1 -1 0 0 1 1 0 0 -1 -1 0 0 1 1 0 0 ...
int foo ( int x )
{
    return 1 - ( 3 & ( 0x46 >> ( x & 6 ) ) );
}

Несмотря на то, что технически это все же (побитовый) поиск.

Если приведенное выше выглядит слишком загадочно, вытакже можно сделать:

int foo ( int x )
{
    int const y = x & 6;
    return (y == 4) - !y;
}
...