Чому компілятори C оптимізують перемикач і якщо інакше


9

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

У дуже тісному циклі у мене є ціле число зі значенням від 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 дадуть однакові результати, і я сподівався, що зможу прочитати біт-хаки, щоб самостійно прийти до ефективної реалізації, оскільки моє рішення (заява переключення - в іншій формі) була досить повільною.

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

Хтось може пояснити, чому існує ця невідповідність? Який "правильний" спосіб оптимізації цього запиту?

Редагувати:

Уточнення

Я хочу, щоб рішення комутатора було найшвидшим, або аналогічно «чистим» рішенням. Однак при компіляції з оптимізаціями на моїй машині рішення if значно швидше.

Я написав швидку програму для демонстрації, і TIO має ті самі результати, що і в мене на локальному рівні: Спробуйте в Інтернеті!

З static inlineтаблицею пошуку трохи прискорюється: Спробуйте це в Інтернеті!


4
Я підозрюю, що відповідь - "Компілятори не завжди роблять розумний вибір". Я просто скомпілював ваш код до об'єкта з GCC 8.3.0 -O3, і він компілювався cдо чогось, ймовірно, гіршого за aабо b( cмав два умовні стрибки плюс кілька бітних маніпуляцій, порівняно лише з одним умовним стрибком і більш простим бітом manip для b), але все ж краще, ніж наївний предмет за предметами тестів. Я не впевнений, що ви насправді просите тут; простий факт полягає в тому, що оптимізуючий компілятор може перетворити будь-яке з них у будь-який з інших, якщо він цього захоче, і немає жорстких і швидких правил для того, що він буде чи не буде робити.
ShadowRanger

Моє питання полягає в тому, що мені це потрібно швидко, але рішення if не є надмірно ретельним. Чи є спосіб отримати компілятор для достатньої оптимізації чистішого рішення? Хтось може пояснити, чому він не може цього зробити?
LambdaBeta

Я б почав, визначивши принаймні функції як статичні, або, навіть, краще, вбудовуючи їх.
wildplasser

@wildplasser робить це швидше, але ifвсе одно б'є switch(дивно пошук стає ще швидшим) [TIO слідкувати]
LambdaBeta

@LambdaBeta Неможливо сказати компілятору оптимізувати певним чином. Ви зауважите, що clang та msvc генерують для них зовсім інший код. Якщо вам все одно і просто хочеться, що найкраще працює на gcc, виберіть це. Оптимізація компілятора заснована на евристиці, і вони не дають оптимального рішення у всіх випадках; Вони намагаються бути хорошими в середньому випадку, не оптимальними у всіх випадках.
Кубік

Відповіді:


6

Якщо ви чітко перераховуєте всі випадки, 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 повертається до своєї вкладеної версії гілки.


1
@LambdaBeta Ви повинні розглянути питання про неприйняття моєї відповіді та прийняття цієї, оскільки сучасні процесори Intel можуть робити два паралельно індексованих читання / цикл пам'яті, тоді як пропускна здатність мого фокусу, ймовірно, 1 пошук / цикл. З іншого боку, можливо, мій хак підходить до 4-х напрямної векторизації з SSE2 pslld/ psradабо їх 8-стороннім еквівалентом AVX2. Багато що залежить від інших особливостей вашого коду.
Iwillnotexist Idonotexist

4

Компілятори 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;

не пройшов огляд компетентними кодерами С; три-чотири рецензенти одночасно вигукували "це має бути switch!"

Компіляторам C не варто аналізувати структуру ifоператорів для перетворення в таблицю стрибків. Умови для цього повинні бути справедливими, і кількість варіацій, яка можлива в купі ifтверджень, є астрономічною. Аналіз є складним і, ймовірно, вийде негативним (як у: "ні, ми не можемо перетворити ці ifs у switch").


Я знаю, саме тому я почав з перемикача. Однак рішення if у моєму випадку значно швидше. Я в основному запитую, чи є спосіб переконати компілятора скористатися кращим рішенням для комутатора, оскільки він зміг знайти шаблон у ifs, але не комутатор. (Мені спеціально не подобаються, тому що вони не такі чіткі чи
доступні

Оголошені, але не прийняті, оскільки настрої - це саме та причина, чому я поставив це питання. Я хочу використовувати перемикач, але це занадто повільно в моєму випадку, я хочу уникати, ifякщо це взагалі можливо.
LambdaBeta

@LambdaBeta: Чи є якісь причини уникати таблиці пошуку? Зробіть це static, і використовуйте призначені для C99 ініціалізатори, якщо ви хочете зробити більш зрозумілим, що ви призначаєте, і це, очевидно, ідеально добре.
ShadowRanger

1
Я б почав хоча б відкидати низький біт, щоб оптимізатору було менше роботи.
R .. GitHub СТОП ДОПОМОГАЙТЕ

@ShadowRanger На жаль, це все-таки повільніше, ніж if(див. Редагування). @R .. Я розробив повне побітове рішення для компілятора, яким я зараз користуюся. На жаль, у моєму випадку це enumзначення, а не оголені цілі числа, тому бітові хаки не дуже рентабельні.
LambdaBeta

4

У наведеному нижче коді буде обчислено ваше відділення без вікна пошуку, без LUT, за ~ 3 тактових цикла, ~ 4 корисних інструкції та ~ 13 байт inlineвисокомобільного машинного коду x86.

Це залежить від цілого представлення комплементу 2.

Однак ви повинні переконатися, що u32і s32typedefs дійсно вказують на 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-бітного числа в біт знаків регістра. Зсуваючи право двозначне число на 32-2 = 30 біт знак - розширює його на повне int, виконуючи трюк.


Це може бути найчистішим способом зробити це за допомогою magicкоментаря, що пояснює, як відновити його. Чи можете ви пояснити, як ви придумали це?
LambdaBeta

Прийнято, оскільки це може бути "чистим", а також швидким. (через якусь магію препроцесора :) < xkcd.com/541 >)
LambdaBeta

1
!!(12336 & (1<<x))-!!(771 & (1<<x));
Перемогла

0

Ви можете створити той же ефект, використовуючи лише арифметику:

// 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;
}
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.