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