Чому GCC генерує такі кардинально різні збірки майже для одного і того ж коду С?


184

Під час написання оптимізованої ftolфункції я виявив дуже дивну поведінку GCC 4.6.1. Дозвольте спочатку показати вам код (для наочності я позначив відмінності):

fast_trunc_one, C:

int fast_trunc_one(int i) {
    int mantissa, exponent, sign, r;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);
    sign = i & 0x80000000;

    if (exponent < 0) {
        r = mantissa << -exponent;                       /* diff */
    } else {
        r = mantissa >> exponent;                        /* diff */
    }

    return (r ^ -sign) + sign;                           /* diff */
}

fast_trunc_two, C:

int fast_trunc_two(int i) {
    int mantissa, exponent, sign, r;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);
    sign = i & 0x80000000;

    if (exponent < 0) {
        r = (mantissa << -exponent) ^ -sign;             /* diff */
    } else {
        r = (mantissa >> exponent) ^ -sign;              /* diff */
    }

    return r + sign;                                     /* diff */
}

Здається, це ж правильно? Добре GCC не погоджується. Після компіляції gcc -O3 -S -Wall -o test.s test.cце збірний вихід:

fast_trunc_one, створено:

_fast_trunc_one:
LFB0:
    .cfi_startproc
    movl    4(%esp), %eax
    movl    $150, %ecx
    movl    %eax, %edx
    andl    $8388607, %edx
    sarl    $23, %eax
    orl $8388608, %edx
    andl    $255, %eax
    subl    %eax, %ecx
    movl    %edx, %eax
    sarl    %cl, %eax
    testl   %ecx, %ecx
    js  L5
    rep
    ret
    .p2align 4,,7
L5:
    negl    %ecx
    movl    %edx, %eax
    sall    %cl, %eax
    ret
    .cfi_endproc

fast_trunc_two, створено:

_fast_trunc_two:
LFB1:
    .cfi_startproc
    pushl   %ebx
    .cfi_def_cfa_offset 8
    .cfi_offset 3, -8
    movl    8(%esp), %eax
    movl    $150, %ecx
    movl    %eax, %ebx
    movl    %eax, %edx
    sarl    $23, %ebx
    andl    $8388607, %edx
    andl    $255, %ebx
    orl $8388608, %edx
    andl    $-2147483648, %eax
    subl    %ebx, %ecx
    js  L9
    sarl    %cl, %edx
    movl    %eax, %ecx
    negl    %ecx
    xorl    %ecx, %edx
    addl    %edx, %eax
    popl    %ebx
    .cfi_remember_state
    .cfi_def_cfa_offset 4
    .cfi_restore 3
    ret
    .p2align 4,,7
L9:
    .cfi_restore_state
    negl    %ecx
    sall    %cl, %edx
    movl    %eax, %ecx
    negl    %ecx
    xorl    %ecx, %edx
    addl    %edx, %eax
    popl    %ebx
    .cfi_restore 3
    .cfi_def_cfa_offset 4
    ret
    .cfi_endproc

Це надзвичайна різниця. Це фактично виявляється і в профілі, fast_trunc_oneприблизно на 30% швидше, ніж fast_trunc_two. Тепер моє запитання: що це викликає?


1
Для тестування я створив тут суть, де ви зможете легко скопіювати / вставити джерело і побачити, чи можете ви відтворити помилку в інших системах / версіях GCC.
orlp

12
Помістіть тестові приклади у власний каталог. Скомпілюйте їх -S -O3 -da -fdump-tree-all. Це створить безліч знімків проміжного представлення. Пройдіться по них (вони пронумеровані) пліч-о-пліч, і ви повинні мати можливість знайти оптимізацію, що відсутня, у першому випадку.
zwol

1
Рекомендація друга: змінити все intна unsigned intта побачити, чи не зникає різниця.
zwol

5
Дві функції, схоже, займаються математикою дещо різними. Хоча результати можуть бути однаковими, вираз (r + shifted) ^ signне такий, як r + (shifted ^ sign). Я думаю, що це заплутує оптимізатор? FWIW, MSVC 2010 (16.00.40219.01) створює списки, майже однакові між собою: gist.github.com/2430454
DCoder

1
@DCoder: О чорт! Я цього не помітив. Однак це не пояснення різниці. Дозвольте мені оновити питання новою версією, де це виключено.
orlp

Відповіді:


256

Оновлено для синхронізації з редакцією ОП

Займаючись кодом, мені вдалося побачити, як GCC оптимізує перший випадок.

Перш ніж ми зможемо зрозуміти, чому вони такі різні, спершу ми повинні зрозуміти, як оптимізується GCC fast_trunc_one().

Вірите чи ні, fast_trunc_one()оптимізується до цього:

int fast_trunc_one(int i) {
    int mantissa, exponent;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);

    if (exponent < 0) {
        return (mantissa << -exponent);             /* diff */
    } else {
        return (mantissa >> exponent);              /* diff */
    }
}

Це створює абсолютно таку ж збірку, що і оригінал fast_trunc_one()- реєструйте назви та все.

Зауважте, що xorдля монтажу немає s fast_trunc_one(). Це те, що подарувало мені.


Як так?


Крок 1: sign = -sign

Спочатку давайте розглянемо signзмінну. Оскільки sign = i & 0x80000000;існують лише два можливі значення sign:

  • sign = 0
  • sign = 0x80000000

Тепер зрозуміло , що в обох випадках sign == -sign. Тому коли я змінюю початковий код на цей:

int fast_trunc_one(int i) {
    int mantissa, exponent, sign, r;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);
    sign = i & 0x80000000;

    if (exponent < 0) {
        r = mantissa << -exponent;
    } else {
        r = mantissa >> exponent;
    }

    return (r ^ sign) + sign;
}

Він виробляє таку саму збірку, що і оригінал fast_trunc_one(). Я пошкодую вам збірку, але вона ідентична - зареєструйте назви та все.


Крок 2: Математичне скорочення:x + (y ^ x) = y

signможе приймати лише одне з двох значень, 0або 0x80000000.

  • Коли x = 0, x + (y ^ x) = yто тривіальне дотримується.
  • Додавання і xoring за 0x80000000допомогою одного і того ж. Він перевертає шматочок знака. Тому x + (y ^ x) = yтакож має місце коли x = 0x80000000.

Тому x + (y ^ x)зводиться до y. І код спрощує це:

int fast_trunc_one(int i) {
    int mantissa, exponent, sign, r;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);
    sign = i & 0x80000000;

    if (exponent < 0) {
        r = (mantissa << -exponent);
    } else {
        r = (mantissa >> exponent);
    }

    return r;
}

Знову ж таки, це компілюється до тієї самої збірки - реєструйте імена та всі.


Наведена вище версія остаточно зводиться до цього:

int fast_trunc_one(int i) {
    int mantissa, exponent;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);

    if (exponent < 0) {
        return (mantissa << -exponent);             /* diff */
    } else {
        return (mantissa >> exponent);              /* diff */
    }
}

що в значній мірі саме те, що GCC створює в зборах.


То чому компілятор не оптимізує fast_trunc_two()те саме?

Ключова частина в цьому fast_trunc_one()- x + (y ^ x) = yоптимізація. У fast_trunc_two()в x + (y ^ x)вираженні розколюється по галузі.

Я підозрюю, що може бути достатньо, щоб заплутати GCC, щоб не здійснити цю оптимізацію. (Потрібно буде підняти ^ -signгілку і злити її в r + signкінці.)

Наприклад, це створює таку ж збірку, як fast_trunc_one():

int fast_trunc_two(int i) {
    int mantissa, exponent, sign, r;

    mantissa = (i & 0x07fffff) | 0x800000;
    exponent = 150 - ((i >> 23) & 0xff);
    sign = i & 0x80000000;

    if (exponent < 0) {
        r = ((mantissa << -exponent) ^ -sign) + sign;             /* diff */
    } else {
        r = ((mantissa >> exponent) ^ -sign) + sign;              /* diff */
    }

    return r;                                     /* diff */
}

4
Редагувати. Схоже, я відповів на ревізію дві. Поточна редакція перевернула два приклади і трохи змінила код ... це заплутано.
Таємничий

2
@nightcracker Не хвилюйся. Я оновив свою відповідь для синхронізації з поточною версією.
Містичний

1
@Mysticial: ваше остаточне твердження вже не відповідає дійсності нової версії, що робить вашу відповідь недійсною (вона не відповідає найважливішому питанню "Чому GCC генерує такі кардинально різні збірки" .)
orlp

11
Відповідь оновлено знову. Я не впевнений, чи достатньо це задовольняє. Але я не думаю, що я можу зробити набагато краще, не знаючи, як саме працює відповідна оптимізація GCC.
Таємничий

4
@Mysticial: Строго кажучи, якщо підписаний тип неправильно використовується в цьому коді, майже всі трансформації, які робить тут компілятор, є випадками, коли поведінка не визначена ...
R .. GitHub STOP HELPING ICE

63

Така природа компіляторів. Якщо припустити, що вони пройдуть найшвидший чи найкращий шлях, то досить помилково. Той, хто означає, що вам не потрібно нічого робити в коді для оптимізації, оскільки "сучасні компілятори" заповнюють бланк, роблять найкращу роботу, роблять найшвидший код і т. Д. Насправді я бачив, як gcc погіршується від 3.x до 4.x на руці принаймні. 4.x, можливо, до цього моменту наздогнав до 3.x, але на початку цього він створив повільніший код. З практикою ви можете навчитися писати свій код, щоб компілятор не мав працювати так важко, і в результаті дається більш послідовний і очікуваний результат.

Помилка тут - це ваші очікування щодо того, що буде вироблятися, а не того, що було фактично вироблено. Якщо ви хочете, щоб компілятор генерував той самий вихід, подайте йому той самий вхід. Математично не однаковий, не наче однаковий, але насправді однаковий, ніяких різних шляхів, жодних операцій обміну чи розповсюдження від однієї версії до іншої. Це гарна вправа розуміння того, як написати свій код і побачити, що з ним робити компілятори. Не допускайте помилки, якщо вважати, що одна версія gcc для однієї цілі процесора за один день дала певний результат, що це правило для всіх компіляторів і всього коду. Вам потрібно використовувати багато компіляторів і безліч цілей, щоб відчути, що відбувається.

gcc досить неприємний, я запрошую вас зазирнути за завісу, подивитися на кишки gcc, спробувати додати ціль або щось змінити самостійно. Він ледве тримається разом за допомогою клейкої стрічки та провідного дроту. Додатковий рядок коду додається або видаляється в критичних місцях, і він руйнується. Справа в тому, що він взагалі створив корисний код, а не турбуватися про те, чому він не відповідає іншим очікуванням.

ви подивилися, які різні версії gcc виробляють? 3.x і 4.x, зокрема 4.5 проти 4.6 проти 4.7 тощо? і для різних цільових процесорів, x86, arm, mips тощо, або різних ароматів x86, якщо це власний компілятор, який ви використовуєте, 32-бітний проти 64-бітовий тощо? І тоді llvm (кланг) для різних цілей?

Mystical зробив чудову роботу в продуманому процесі, необхідному для вирішення проблеми аналізу / оптимізації коду, очікуючи, що компілятор придумає щось із цього, ну, не очікував жоден "сучасний компілятор".

Не потрапляючи в математичні властивості, код цієї форми

if (exponent < 0) {
  r = mantissa << -exponent;                       /* diff */
} else {
  r = mantissa >> exponent;                        /* diff */
}
return (r ^ -sign) + sign;                           /* diff */

збирається привести компілятор до A: реалізувати його в цій формі, виконати if-then-else, а потім сходити за загальним кодом, щоб закінчити та повернути. або B: збережіть гілку, оскільки це хвостовий кінець функції. Також не турбуйтеся з використанням або збереженням r.

if (exponent < 0) {
  return((mantissa << -exponent)^-sign)+sign;
} else {
  return((mantissa << -exponent)^-sign)+sign;
}

Тоді ви можете потрапити, як Mystical вказав, змінна знака зникає всі разом для коду, як написано. Я не очікував, що компілятор побачить, як змінна ознака піде, тому ви повинні зробити це самостійно, а не змушувати компілятора намагатися розібратися в цьому.

Це прекрасна можливість перекопатися у вихідному коді gcc. Здається, ви знайшли випадок, коли оптимізатор бачив одне в одному, а потім інше в іншому. Потім зробіть наступний крок і подивіться, чи не можете ви отримати gcc, щоб побачити цей випадок. Кожна оптимізація є, тому що якась особа чи група визнали оптимізацію та навмисно вклали її. Щоб ця оптимізація була там і працювала кожен раз, коли хтось повинен її помістити (а потім протестувати її, а потім підтримувати її у майбутньому).

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

Суть полягає в тому, що ви подавали компілятор іншого джерела і очікували однакових результатів. Проблема полягає не у виході компілятора, а в очікуванні користувача. Досить легко продемонструвати для конкретного компілятора та процесора додавання одного рядка коду, який робить цілу функцію значно повільнішою. Наприклад, чому змінюється a = b + 2; до a = b + c + 2; викликати _fill_in_the_blank_compiler_name_ генерувати кардинально інший і повільніший код? Відповідь, звичайно, що це компілятор, подається різним кодом на вході, тому цілком справедливо, щоб компілятор генерував різні результати. (ще краще, коли ви поміняєте місцями два неспоріднених рядка коду і змушуєте різко змінити результат) Немає очікуваного зв’язку між складністю та розміром введення до складності та розміру виводу.

for(ra=0;ra<20;ra++) dummy(ra);

Він виготовляв десь 60-100 ліній асемблера. Це розкрутило цикл. Я не рахував рядки, якщо ви подумаєте про це, він повинен додати, скопіювати результат на вхід до виклику функції, зробити виклик функції, три операції мінімум. тож залежно від цілі, що, мабуть, 60 інструкцій принаймні, 80, якщо чотири на цикл, 100, якщо п'ять на цикл тощо.


Чому ви скасували свою відповідь? Од, здавалося, не погоджувався і з редакцією ;-).
Пітер - Відновіть Моніку

@ PeterA.Schneider всі його відповіді, схоже, були скасовані в ту ж дату. Я думаю, що хтось із його (вкраденими?) Даними облікового запису це зробив.
trinity420

23

Mysticial вже дав чудове пояснення, але я подумав би додати, FWIW, що насправді немає нічого принципового в тому, чому компілятор зробив би оптимізацію для одного, а не іншого.

clangНаприклад, компілятор LLVM надає однаковий код для обох функцій (крім імені функції), даючи:

_fast_trunc_two:                        ## @fast_trunc_one
        movl    %edi, %edx
        andl    $-2147483648, %edx      ## imm = 0xFFFFFFFF80000000
        movl    %edi, %esi
        andl    $8388607, %esi          ## imm = 0x7FFFFF
        orl     $8388608, %esi          ## imm = 0x800000
        shrl    $23, %edi
        movzbl  %dil, %eax
        movl    $150, %ecx
        subl    %eax, %ecx
        js      LBB0_1
        shrl    %cl, %esi
        jmp     LBB0_3
LBB0_1:                                 ## %if.then
        negl    %ecx
        shll    %cl, %esi
LBB0_3:                                 ## %if.end
        movl    %edx, %eax
        negl    %eax
        xorl    %esi, %eax
        addl    %edx, %eax
        ret

Цей код не такий короткий, як перша версія gcc з ОП, але не такий, як другий.

Код іншого компілятора (якого я не називатиму), компілюючи для x86_64, створює це для обох функцій:

fast_trunc_one:
        movl      %edi, %ecx        
        shrl      $23, %ecx         
        movl      %edi, %eax        
        movzbl    %cl, %edx         
        andl      $8388607, %eax    
        negl      %edx              
        orl       $8388608, %eax    
        addl      $150, %edx        
        movl      %eax, %esi        
        movl      %edx, %ecx        
        andl      $-2147483648, %edi
        negl      %ecx              
        movl      %edi, %r8d        
        shll      %cl, %esi         
        negl      %r8d              
        movl      %edx, %ecx        
        shrl      %cl, %eax         
        testl     %edx, %edx        
        cmovl     %esi, %eax        
        xorl      %r8d, %eax        
        addl      %edi, %eax        
        ret                         

що захоплює тим, що вона обчислює обидві сторони if а потім використовує умовний хід у кінці, щоб вибрати потрібну.

Компілятор Open64 створює наступне:

fast_trunc_one: 
    movl %edi,%r9d                  
    sarl $23,%r9d                   
    movzbl %r9b,%r9d                
    addl $-150,%r9d                 
    movl %edi,%eax                  
    movl %r9d,%r8d                  
    andl $8388607,%eax              
    negl %r8d                       
    orl $8388608,%eax               
    testl %r8d,%r8d                 
    jl .LBB2_fast_trunc_one         
    movl %r8d,%ecx                  
    movl %eax,%edx                  
    sarl %cl,%edx                   
.Lt_0_1538:
    andl $-2147483648,%edi          
    movl %edi,%eax                  
    negl %eax                       
    xorl %edx,%eax                  
    addl %edi,%eax                  
    ret                             
    .p2align 5,,31
.LBB2_fast_trunc_one:
    movl %r9d,%ecx                  
    movl %eax,%edx                  
    shll %cl,%edx                   
    jmp .Lt_0_1538                  

та подібний, але не тотожний код для fast_trunc_two.

У будь-якому випадку, коли йдеться про оптимізацію, це лотерея - це те, що вона є ... Не завжди легко дізнатися, чому ваш код складається компільованим певним чином.


10
Це компілятор, якому ви не назвете якогось найпотаємнішого суперкомпілятора?
orlp

4
Компілятор Top Secret - це, мабуть, Intel icc. У мене є лише 32-бітний варіант, але він створює код, дуже подібний до цього.
Янус Троельсен

5
Я також вважаю, що це ICC. Компілятор знає, що процесор здатний задавати паралелізм рівня інструкцій, і тому обидві гілки можна обчислити одночасно. Витрати на умовний хід набагато нижчі, ніж накладні помилкові прогнози гілок.
Філіп Навара
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.