Вычислить число Фибоначчи (рекурсивный подход) во время компиляции (constexpr) в С++ 11

Я написал программу вычисления числа Фибоначчи во время компиляции (constexpr) проблема с использованием методов метапрограммирования шаблонов, поддерживаемых в С++ 11. Цель из этого заключается в вычислении разницы во времени выполнения между шаблоном метапрограммирования и старым традиционным подходом.

// Template Metaprograming Approach
template<int  N>
constexpr int fibonacci() {return fibonacci<N-1>() + fibonacci<N-2>(); }
template<>
constexpr int fibonacci<1>() { return 1; }
template<>
constexpr int fibonacci<0>() { return 0; }



// Conventional Approach
 int fibonacci(int N) {
   if ( N == 0 ) return 0;
   else if ( N == 1 ) return 1;
   else
      return (fibonacci(N-1) + fibonacci(N-2));
} 

Я запускал обе программы для N = 40 в моей системе GNU/Linux и измерял время и что это обычное решение (1,15 секунды) примерно в два раза медленнее, чем решение на основе шаблонов (0,55 секунды). Это значительное улучшение, поскольку оба подхода основаны на рекурсии.

Чтобы понять это, я скомпилировал программу (-fdump-tree-all) в g++ и обнаружил, что компилятор фактически сгенерировал 40 различных функций (например, fibonacci < 40 > , fibonacci < 39 > ... & л Фибоначи; 0 > ).

constexpr int fibonacci() [with int N = 40] () {
  int D.29948, D.29949, D.29950;
  D.29949 = fibonacci<39> ();
  D.29950 = fibonacci<38> ();
  D.29948 = D.29949 + D.29950;
  return D.29948;
}

constexpr int fibonacci() [with int N = 39] () {
  int D.29952, D.29953, D.29954;
  D.29953 = fibonacci<38> ();
  D.29954 = fibonacci<37> ();
  D.29952 = D.29953 + D.29954;
  return D.29952;
}
...
...
...
constexpr int fibonacci() [with int N = 0] () {
  int D.29962;
  D.29962 = 0;
  return D.29962;
}

Я также отлаживал программу в GDB и обнаружил, что все вышеперечисленные функции выполнялось ровно столько раз, сколько с обычным рекурсивным подходом. Если обе версии программы выполняют функцию равное количество раз (рекурсивное), то как это достигается с помощью методов метапрограммирования шаблонов? Я также хотел бы узнать ваше мнение о том, как и почему подход, основанный на метапрограмме шаблона, занимает половину времени по сравнению с другой версией? Может ли эта программа быть выполнена быстрее, чем текущая?

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

Моя машина GNU/Linux с GCC 4.8.1, и я использовал оптимизацию -o3 для обеих программ.

Ответ 1

Попробуйте следующее:

template<size_t N>
struct fibonacci : integral_constant<size_t, fibonacci<N-1>{} + fibonacci<N-2>{}> {};

template<> struct fibonacci<1> : integral_constant<size_t,1> {};
template<> struct fibonacci<0> : integral_constant<size_t,0> {};

С clang и -Os, это составляет примерно 0,5 с и работает в ноль для N=40. Ваш "обычный" подход составляет примерно 0,4 с и работает в 0,8 с. Для проверки результат 102334155 правильный?

Когда я попробовал собственное решение constexpr, компилятор запустил пару минут, а затем я остановил его, потому что, по-видимому, память была заполнена (компьютер начал замораживать). Компилятор пытался вычислить конечный результат, и ваша реализация крайне неэффективна для использования во время компиляции.

В этом решении экземпляры шаблонов в N-2, N-1 повторно используются при создании экземпляра N. Таким образом, fibonacci<40> фактически известно во время компиляции как значение, и во время выполнения нечего делать. Это подход с динамическим программированием, и, конечно, вы можете сделать то же самое во время выполнения, если вы сохраните все значения в 0 через N-1 перед вычислением в N.

С вашим решением компилятор может оценить fibonacci<N>() во время компиляции, но не требуется. В вашем случае все или часть вычислений остается на время выполнения. В моем случае все вычисления выполняются во время компиляции, поэтому никогда не заканчиваются.

Ответ 2

Причина в том, что ваше решение во время выполнения не является оптимальным. Для каждого числа фидов функции называются несколько раз. Последовательность фибоначчи имеет перекрывающиеся подзадачи, поэтому, например, fib(6) вызывает fib(4), а fib(5) также вызывает fib(4).

Основанный на шаблонах подход использует (неосторожно) подход с использованием динамического программирования, что означает, что он сохраняет значения для ранее рассчитанных чисел, избегая повторения. Таким образом, когда fib(5) вызывает fib(4), номер уже был рассчитан, когда fib(6) сделал.

Я рекомендую искать "динамическое программирование фибоначчи" и пытаться это сделать, это должно резко ускорить процесс.

Ответ 3

Добавление -O1 (или выше) в GCC4.8.1 сделает fibonacci < 40 > () константой времени компиляции, и весь код, сгенерированный шаблоном, исчезнет из вашей сборки. Следующий код

int foo()
{
  return fibonacci<40>();
}

приведет к выводу сборки

foo():
    movl    $102334155, %eax
    ret

Это дает наилучшую производительность во время выполнения.

Однако, похоже, что вы строите без оптимизации (-O0), так что вы получаете что-то совсем другое. Вывод сборок для каждой из 40 функций фибоначчи выглядит в основном идентичным (за исключением случаев 0 и 1)

int fibonacci<40>():
    pushq   %rbp
    movq    %rsp, %rbp
    pushq   %rbx
    subq    $8, %rsp
    call    int fibonacci<39>()
    movl    %eax, %ebx
    call    int fibonacci<38>()
    addl    %ebx, %eax
    addq    $8, %rsp
    popq    %rbx
    popq    %rbp
    ret

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

Теперь сравните это с сборкой из обычного подхода

fibonacci(int):
    pushq   %rbp
    pushq   %rbx
    subq    $8, %rsp
    movl    %edi, %ebx
    movl    $0, %eax
    testl   %edi, %edi
    je  .L2
    movb    $1, %al
    cmpl    $1, %edi
    je  .L2
    leal    -1(%rdi), %edi
    call    fibonacci(int)
    movl    %eax, %ebp
    leal    -2(%rbx), %edi
    call    fibonacci(int)
    addl    %ebp, %eax
    .L2:
    addq    $8, %rsp
    popq    %rbx
    popq    %rbp
    ret

Каждый раз, когда вызывается функция, необходимо проверить, является ли N 0 или 1 и действовать соответствующим образом. Это сравнение не требуется в версии шаблона, потому что оно встроено в функцию через магию шаблонов. Я предполагаю, что не оптимизированная версия кода шаблона выполняется быстрее, потому что вы избегаете этих сравнений и также не должны иметь прогнозов пропущенных ветвей.

Ответ 4

Возможно, просто используйте более эффективный алгоритм?

constexpr pair<double, double> helper(size_t n, const pair<double, double>& g)
{
    return n % 2
        ? make_pair(g.second * g.second + g.first * g.first, g.second * g.second + 2 * g.first * g.second)
        : make_pair(2 * g.first * g.second - g.first * g.first, g.second * g.second + g.first * g.first);
}

constexpr pair<double, double> fibonacciRecursive(size_t n)
{
    return n < 2
        ? make_pair<double, double>(n, 1)
        : helper(n, fibonacciRecursive(n / 2));
}

constexpr double fibonacci(size_t n)
{
    return fibonacciRecursive(n).first;
}

Мой код основан на идее, описанной Д. Кнутом в первой части его "Искусство компьютерного программирования". Я не могу вспомнить точное место в этой книге, но я уверен, что там был описан алгоритм.