Как время компиляции может быть (экспоненциально) быстрее, чем время выполнения?

Приведенный ниже код вычисляет числа Фибоначчи по экспоненциально медленному алгоритму:

#include <cstdlib>
#include <iostream>

#define DEBUG(var) { std::cout << #var << ": " << (var) << std::endl; }

constexpr auto fib(const size_t n) -> long long
{
    return n < 2 ? 1: fib(n - 1) + fib(n - 2);
}

int main(int argc, char *argv[])
{
    const long long fib91 = fib(91);

    DEBUG( fib91 );
    DEBUG( fib(45) );

    return EXIT_SUCCESS;
}

И я вычисляю 45-й номер Фибоначчи во время выполнения, а 91-й - во время компиляции.

Интересным фактом является то, что GCC 4.9 компилирует код и вычисляет fib91 за долю секунды, но требуется время, чтобы выплюнуть fib(45).

Мой вопрос: если GCC достаточно умен, чтобы оптимизировать вычисление fib(91) и не принимать экспоненциально медленный путь, что мешает ему сделать то же самое для fib(45)?

Означает ли это, что GCC создает две скомпилированные версии функции fib, где один является быстрым, а другой экспоненциально медленным?

Вопрос заключается не в том, как компилятор оптимизирует вычисление fib(91) (да, он использует своего рода memoization), но если он знает, как оптимизировать функцию fib, почему она не делает то же самое для fib(45)? И есть ли две отдельные компиляции функции fib? Один медленный, а другой быстрый?

Ответ 1

GCC, вероятно, memoizing constexpr функции (включение вычисления Θ (n) fib(n)). Это безопасно для компилятора, потому что функции constexpr являются чисто функциональными.

Сравните алгоритм компилятора Θ (n) (используя memoization) с вашим алгоритмом времени выполнения (φ n) (где φ является золотым соотношением), и внезапно становится совершенно ясно, что компилятор работает намного быстрее.

Из страницы constexpr на cppreference (выделено мной):

Спецификатор constexpr объявляет, что возможно оценивать значение функции или переменной во время компиляции.

Спецификатор constexpr делает не объявить, что он требуется, чтобы оценить значение функции или переменной во время компиляции. Поэтому можно только догадываться, какие эвристики GCC использует, чтобы выбрать, следует ли оценивать во время компиляции или времени выполнения, когда языковые правила не требуются для вычисления времени компиляции. Он может выбирать либо в каждом конкретном случае, и по-прежнему быть правильным.

Если вы хотите, чтобы принудительно компилятор оценивал вашу функцию constexpr во время компиляции, вот простой трюк, который это сделает.

constexpr auto compute_fib(const size_t n) -> long long
{
    return n < 2 ? n : compute_fib(n - 1) + compute_fib(n - 2);
}

template <std::size_t N>
struct fib
{
    static_assert(N >= 0, "N must be nonnegative.");
    static const long long value = compute_fib(N);
};

В остальной части кода вы можете получить доступ к fib<45>::value или fib<91>::value с гарантией того, что они будут оцениваться во время компиляции.

Ответ 2

В момент компиляции компилятор может memoize получить результат функции. Это безопасно, потому что функция является constexpr и, следовательно, всегда будет возвращать тот же результат из тех же самых входов.

Во время выполнения теоретически можно было сделать то же самое. Однако большинство программистов на C++ хмурились над прогонами оптимизации, которые приводят к скрытым выделениям памяти.

Ответ 3

Когда вы запрашиваете, чтобы fib (91) дал значение вашему const fib91 в исходном коде, компилятор вынужден вычислить это значение из вас const expr. Он не компилирует функцию (как вы, кажется, думаете), просто она видит, что для вычисления fib91 ей нужны fib (90) и fib (89), чтобы вычислить ей нужно fib (87)... так, пока он не вычислит fib (1). Это алгоритм $O (n) $, и результат вычисляется достаточно быстро.

Однако, когда вы попросите оценить fib (45) во время выполнения, компилятор должен выбрать, используя фактический вызов функции или прекомпопулировать результат. В конце концов он решает использовать скомпилированную функцию. Теперь скомпилированная функция должна точно выполнить экспоненциальный алгоритм, который вы решили, что компилятор не может реализовать memoization для оптимизации рекурсивной функции (подумайте о необходимости выделить некоторый кеш и понять, сколько значений сохранить и как управлять их между вызовами функций).