Почему GCC генерирует такую ​​радикально отличную сборку для почти одного и того же C-кода?

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. Теперь мой вопрос: что вызывает это?

orlp
источник
1
Для тестовых целей я создал суть здесь , где вы можете легко копировать / вставить источник и посмотреть , если вы можете воспроизвести ошибку в других системах / версий GCC.
orlp
12
Поместите тестовые наборы в собственный каталог. Скомпилируйте их с -S -O3 -da -fdump-tree-all. Это создаст много снимков промежуточного представления. Пройдите по ним (они пронумерованы) бок о бок, и вы сможете найти недостающую оптимизацию в первом случае.
Звол
1
Предложение второе: измените все intна unsigned intи посмотрите, исчезнет ли разница.
Звол
5
Две функции, кажется, делают немного по-другому. Хотя результаты могут быть одинаковыми, выражение (r + shifted) ^ signне совпадает с r + (shifted ^ sign). Я думаю, что это сбивает с толку оптимизатор? FWIW, MSVC 2010 (16.00.40219.01) создает списки, которые практически идентичны друг другу: gist.github.com/2430454
DCoder,
1
@DCoder: Блин! Я этого не заметил. Это не объяснение разницы, хотя. Позвольте мне обновить вопрос с новой версией, где это исключено.
orlp

Ответы:

256

Обновлен для синхронизации с редактированием OP

Работая с кодом, мне удалось увидеть, как 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тогда тривиально.
  • Добавление и ксоринг по одному и 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 */
}
Mysticial
источник
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;
}

Затем вы можете войти, как указал Мистикал, переменная знака полностью исчезнет для написанного кода. Я бы не ожидал, что компилятор увидит, что переменная sign исчезнет, ​​поэтому вы должны были сделать это сами, а не заставлять компилятор пытаться это выяснить.

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

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

Суть в том, что вы указали компилятору другой источник и ожидали одинаковых результатов. Проблема не в выводе компилятора, а в ожиданиях пользователя. Для конкретного компилятора и процессора довольно легко продемонстрировать добавление одной строки кода, которая значительно замедляет работу всей функции. Например, почему меняется a = b + 2; а = b + с + 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 из OP, но не такой длинный, как вторая.

Код от другого компилятора (который я не буду называть), компилирующий для 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.

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

Charphacy
источник
10
Это компилятор, который вы не назовете сверхсекретным суперкомпилятором?
orlp
4
Сверхсекретный компилятор, вероятно, Intel icc. У меня есть только 32-битный вариант, но он производит код, очень похожий на этот.
Янус Троелсен
5
Я также считаю, что это ICC. Компилятор знает, что процессор способен к параллелизму на уровне команд, и поэтому обе ветви могут быть вычислены одновременно. Накладные расходы на условное перемещение намного ниже накладных расходов на ложное предсказание ветвления.
Филип Навара