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

При написании оптимизированной функции 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

Обновлено для синхронизации с редактированием 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 для 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 */
}

Ответ 2

Это природа компиляторов. Предполагать, что они пойдут по самому быстрому или лучшему пути, совершенно неверно. Любой, кто подразумевает, что вам не нужно ничего делать с вашим кодом для оптимизации, потому что "современные компиляторы" заполняют пробелы, делают лучшую работу, делают самый быстрый код и т.д. На самом деле я видел, что gcc ухудшается с 3.x до 4.x на руке по крайней мере. 4.x к этому моменту мог догнать до 3.x, но на ранних этапах он создавал более медленный код. С практикой вы можете научиться писать свой код, чтобы компилятору не приходилось работать так усердно, и в результате вы получите более согласованные и ожидаемые результаты.

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

gcc довольно противный, я приглашаю вас заглянуть за кулисы, посмотреть на внутренности gcc, попытаться добавить цель или что-то изменить самостоятельно. Это только скреплено клейкой лентой и проволокой. Дополнительная строка кода добавлена или удалена в критических местах, и она рушится. Тот факт, что он вообще создал полезный код - это то, что нужно для того, чтобы порадовать его, а не беспокоиться о том, почему он не соответствует другим ожиданиям.

Вы смотрели на какие разные версии GCC производят? 3.х и 4.х в частности 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, переменная знака полностью исчезнет для написанного кода. Я бы не ожидал, что компилятор увидит, что переменная 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, если пять на цикл, и т.д.

Ответ 3

Mystical уже дал большое объяснение, но я думал, что добавлю, FWIW, что нет ничего принципиального в том, почему компилятор сделает оптимизацию для одного, а не для другого.

Компилятор LLVM clang, например, дает тот же код для обеих функций (кроме имени функции), предоставляя:

_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.

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