Как компилятор так хорошо оптимизирует эту факториальную функцию?

Итак, я посмотрел на магию, которая есть O3 в GCC (ну, собственно, я собираюсь использовать Clang, но это то же самое с GCC, и я предполагаю, что большая часть оптимизатора была вытащена от GCC до Clang).

Рассмотрим эту программу на C:

int foo(int n) {
    if (n == 0) return 1;
    return n * foo(n-1);
}

int main() {
    return foo(10);
}

Первое, что я был довольно WOW-ed в (который был также WOW-ed в этом вопросе - qaru.site/info/53943/...), заключалось в том, как int foo(int) (a основная факториальная функция) компилируется в замкнутый цикл. Для этого это сборка ARM:

    .globl  _foo
    .align  2
    .code   16
    .thumb_func _foo
_foo:
    mov r1, r0
    movs    r0, #1
    cbz r1, LBB0_2
LBB0_1:
    muls    r0, r1, r0
    subs    r1, #1
    bne LBB0_1
LBB0_2:
    bx  lr

Блейми, подумал я. Это очень интересно! Полностью плотный цикл для факториала. ВАУ. Это не оптимизация хвостового вызова, так как, ну, это не хвостовой вызов. Но, похоже, он сделал очень похожую оптимизацию.

Теперь посмотрите main:

    .globl  _main
    .align  2
    .code   16
    .thumb_func _main
_main:
    movw    r0, #24320
    movt    r0, #55
    bx  lr

Это просто взорвало мой разум, чтобы быть честным. Он просто полностью обходит foo и возвращает 3628800, который равен 10!.

Это заставляет меня понять, как ваш компилятор может часто выполнять гораздо лучшую работу, чем вы можете оптимизировать свой код. Но возникает вопрос: как ему удается сделать такую ​​хорошую работу? Итак, кто-нибудь может объяснить (возможно, связав соответствующий код), как работают следующие оптимизации:

  • Начальная оптимизация foo должна быть замкнутой петлей.

  • Оптимизация, где main просто идет и возвращает результат напрямую, а не выполняет foo.

Еще один интересный побочный эффект этого вопроса состоял бы в том, чтобы показать еще несколько интересных оптимизаций, которые может сделать GCC/Clang.

Ответы

Ответ 1

Если вы скомпилируете с помощью gcc -O3 -fdump-tree-all, вы увидите, что первый дамп, в котором рекурсия была преобразована в цикл, - foo.c.035t.tailr1. Это означает, что такая же оптимизация, которая обрабатывает другие хвостовые вызовы, также обрабатывает этот слегка расширенный случай. Рекурсия в форме n * foo(...) или n + foo(...) не так сложна для ручного управления (см. Ниже), и поскольку можно точно описать, как компилятор может автоматически выполнить эту оптимизацию.

Оптимизация main намного проще: вложение может превратить это в 10 * 9 * 8 * 7 * 6 * 5 * 4 * 3 * 2 * 1 * 1, и если все операнды умножения являются константами, то умножение может быть выполнено во время компиляции.

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

Сначала создайте вспомогательную функцию. Он ведет себя точно как foo(n), за исключением того, что его результаты умножаются на дополнительный параметр f.

int foo(int n)
{
    return foo_helper(n, 1);
}

int foo_helper(int n, int f)
{
    if (n == 0) return f * 1;
    return f * n * foo(n-1);
}

Затем поверните рекурсивные вызовы foo в рекурсивные вызовы foo_helper и положите на фактор-параметр, чтобы избавиться от умножения.

int foo(int n)
{
    return foo_helper(n, 1);
}

int foo_helper(int n, int f)
{
    if (n == 0) return f;
    return foo_helper(n-1, f * n);
}

Поверните это в цикл:

int foo(int n)
{
    return foo_helper(n, 1);
}

int foo_helper(int n, int f)
{
restart:
    if (n == 0) return f;
    {
        int newn = n-1;
        int newf = f * n;
        n = newn;
        f = newf;
        goto restart;
    }
}

Наконец, inline foo_helper:

int foo(int n)
{
    int f = 1;
restart:
    if (n == 0) return f;
    {
        int newn = n-1;
        int newf = f * n;
        n = newn;
        f = newf;
        goto restart;
    }
}

(Естественно, это не самый разумный способ записи функции вручную).