Пример минимального запуска GCC с анализом разборки x86
Давайте посмотрим, как GCC может автоматически выполнять оптимизацию хвостовых вызовов для нас, посмотрев на сгенерированную сборку.
Это послужит чрезвычайно конкретным примером того, что было упомянуто в других ответах, таких как https://stackoverflow.com/a/9814654/895245, что оптимизация может преобразовывать рекурсивные вызовы функций в цикл.
Это, в свою очередь, экономит память и повышает производительность, поскольку доступ к памяти часто является основным фактором, замедляющим работу программ .
В качестве входных данных мы даем GCC неоптимизированный факториал на основе наивного стека:
tail_call.c
#include <stdio.h>
#include <stdlib.h>
unsigned factorial(unsigned n) {
if (n == 1) {
return 1;
}
return n * factorial(n - 1);
}
int main(int argc, char **argv) {
int input;
if (argc > 1) {
input = strtoul(argv[1], NULL, 0);
} else {
input = 5;
}
printf("%u\n", factorial(input));
return EXIT_SUCCESS;
}
GitHub upstream .
Компилировать и разбирать:
gcc -O1 -foptimize-sibling-calls -ggdb3 -std=c99 -Wall -Wextra -Wpedantic \
-o tail_call.out tail_call.c
objdump -d tail_call.out
где -foptimize-sibling-calls
- имя обобщения оконечных вызовов согласно man gcc
:
-foptimize-sibling-calls
Optimize sibling and tail recursive calls.
Enabled at levels -O2, -O3, -Os.
как указано в: Как проверить, выполняет ли gcc оптимизацию хвостовой рекурсии?
Я выбираю -O1
, потому что:
- оптимизация не выполняется с
-O0
. Я подозреваю, что это потому, что отсутствуют необходимые промежуточные преобразования.
-O3
создает нечестиво эффективный код, который не будет очень познавательным, хотя он также оптимизирован с помощью хвостового вызова.
Разборка с -fno-optimize-sibling-calls
:
0000000000001145 <factorial>:
1145: 89 f8 mov %edi,%eax
1147: 83 ff 01 cmp $0x1,%edi
114a: 74 10 je 115c <factorial+0x17>
114c: 53 push %rbx
114d: 89 fb mov %edi,%ebx
114f: 8d 7f ff lea -0x1(%rdi),%edi
1152: e8 ee ff ff ff callq 1145 <factorial>
1157: 0f af c3 imul %ebx,%eax
115a: 5b pop %rbx
115b: c3 retq
115c: c3 retq
С -foptimize-sibling-calls
:
0000000000001145 <factorial>:
1145: b8 01 00 00 00 mov $0x1,%eax
114a: 83 ff 01 cmp $0x1,%edi
114d: 74 0e je 115d <factorial+0x18>
114f: 8d 57 ff lea -0x1(%rdi),%edx
1152: 0f af c7 imul %edi,%eax
1155: 89 d7 mov %edx,%edi
1157: 83 fa 01 cmp $0x1,%edx
115a: 75 f3 jne 114f <factorial+0xa>
115c: c3 retq
115d: 89 f8 mov %edi,%eax
115f: c3 retq
Основное различие между ними заключается в том, что:
-fno-optimize-sibling-calls
использует callq
, что является типичным неоптимизированным вызовом функции.
Эта инструкция помещает адрес возврата в стек, увеличивая его.
Кроме того, эта версия также делает push %rbx
, что толкает %rbx
в стек .
GCC делает это, потому что сохраняет edi
, который является первым аргументом функции (n
) в ebx
, затем вызывает factorial
.
GCC должен сделать это, потому что он готовится к другому вызову factorial
, который будет использовать новый edi == n-1
.
Он выбирает ebx
, потому что этот регистр сохраняется вызываемым абонентом: Какие регистры сохраняются при вызове функции linux x86-64 , поэтому подзвон к factorial
не изменит его и не потеряет n
.
-foptimize-sibling-calls
не использует каких-либо инструкций, которые перемещают в стек: он только goto
переходит в factorial
с инструкциями je
и jne
.
Следовательно, эта версия эквивалентна циклу while без каких-либо вызовов функций. Постоянное использование стека.
Протестировано в Ubuntu 18.10, GCC 8.2.