Чому компілятори 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.