Tenho a seguinte constexpr
função de Fibonacci:
constexpr auto fibo(unsigned int number) -> unsigned long long {
if (number < 2) return number;
return fibo(number - 1) + fibo(number - 2);
}
Se eu comparar com o código abaixo, obtenho a seguinte saída:
benchmark name samples iterations est run time
mean low mean high mean
std dev low std dev high std dev
-------------------------------------------------------------------------------
Benchmark fibo(46) 100 1 2.66 m
1.60356 s 1.60231 s 1.60477 s
6.30399 ms 5.51902 ms 7.32077 ms
Quando altero o number
parâmetro para unsigned int const &number
, a velocidade melhora significativamente:
benchmark name samples iterations est run time
mean low mean high mean
std dev low std dev high std dev
-------------------------------------------------------------------------------
Benchmark fibo(46) 100 1 17.7672 ms
177.013 us 176.272 us 177.9 us
4.13731 us 3.56318 us 4.80496 us
Por que há um aumento tão grande de velocidade ao passar o parâmetro por referência?
Código de benchmarking:
#include "Fibonacci.hpp"
#include "catch2/catch_message.hpp"
#include <catch2/benchmark/catch_benchmark.hpp>
#include <catch2/catch_test_macros.hpp>
#include <catch2/generators/catch_generators.hpp>
#include <catch2/generators/catch_generators_all.hpp>
#include <catch2/generators/catch_generators_range.hpp>
#include <sstream>
#include <utility>
TEST_CASE("Fibonacci calculation", "[Fibonacci Benchmark Suite]") {
auto entry = GENERATE(table<unsigned long long, unsigned long long>({
{0, 0},
{1, 1},
{2, 1},
{3, 2},
{4, 3},
{5, 5},
{46, 1836311903} // Runs long!
}));
auto input = std::get<0>(entry);
auto expected = std::get<1>(entry);
std::ostringstream oss;
oss << "Benchmark fibo(" << input << ")";
auto benchmarkName = oss.str();
BENCHMARK(std::move(benchmarkName)) {
return fibo(input);
};
CAPTURE(input, expected);
REQUIRE(fibo(input) == expected);
}
(Nota: Este código também parece um pouco estranho, já que a compilação com o GCC 14.2.1 às vezes fazia com que ele consumisse toda a minha RAM antes de ser morto pelo kernel do Linux. Adicionei os sinalizadores -fconstexpr-ops-limit=100000000000000
e -fconstexpr-cache-depth=50
, mas eles não parecem ter ajudado)
Que caso de teste curto, mas incrivelmente estranho, para compiladores!
Nunca vi o problema de parada afetar diretamente a compilação antes, mas o GCC, ao tentar honrar a especificação recursiva deste código, sofre um aumento exponencial do tempo de compilação e do uso de memória com valores maiores de
N
uma constante conhecida fixa no tempo de compilação ao calcularfibo(N)
.Os limites seguros para
N
onde o GCC pode resolver completamentefibo(N)
uma constante de tempo de compilação sãoN <= 35
para chamada por valor eN <= 28
para chamada por referência. Não tenho ideia de por que os limites superiores são diferentes, apenas que são. O código C é enganosamente semelhante, mas o assembler gerado é radicalmente diferente!Também testei no MSVC C/C++ 17 e no Intel ICX 2024.1, mas obtive apenas um comportamento normal e desempenho bastante semelhante para ambos. O Intel ICX 2025 parece armazenar em cache os valores que já calculou se você acertar as opções do compilador gofaster. Não consigo reproduzir essa observação transitória no momento. "-O3 -Wall" não é suficiente.
Dei uma olhada neste código em detalhes porque a rotina recursiva não altera o valor de,
number
então eu não esperava ver muita diferença entre chamada por valor e chamada por referência.Usando GCC para uma pequena
N <= 28
constante conhecida em tempo de compilação, isso é verdade - ele compila para carregar a constante mágicafibo(N)
com exatamente o mesmo código gerado para ambos e um tempo de compilação muito longo (que cresce exponencialmente comN
).Criei uma versão canônica de brinquedo o mais simples possível em [Godbolt]( https://godbolt.org/z/h6G1z7WPh ). Por favor, sejam gentis com o site e não alimentem com N maiúsculo! Godbolt consegue sobreviver
N=36
em um dia bom se o vento estiver soprando na direção certa. Prepare-se para perder o processo se subir mais alto.Você pode tentar várias combinações comentando as linhas dentro ou fora. N=28,29 (cbr) e 35,36 (cbv) são os casos de valor fixo mais interessantes, juntamente com o
N
desconhecido no momento da compilação.A enorme diferença de velocidade não tem quase nada a ver com chamada por valor versus chamada por referência e tudo a ver com geração de código extremamente sofisticada quando o caminho cbr é tomado.
Ele praticamente coloca tudo em registradores e usa passos muito maiores, decrementando
N
em 5 para cada nível de recursão e então terminando em uma tabela de consulta quandoN<= 5
.O primeiro caso com chamada por valor não é nada fora do comum. Exceto para valores pequenos de
N<35
onde a compilação é muito lenta para carregar o valor constantefibo(N)
. A otimização geral consiste em desenrolar a recursão em um nível e escrever seu próprio código duas vezes (aproximadamente 300 bytes).A geração de código do segundo caso é extraordinária, pelo menos no GCC-O3 (tentarei com mais afinco provocar o mesmo comportamento no Intel e no GCC). Ela utiliza truques que me sugerem que foi cuidadosamente otimizada para ter um desempenho incrivelmente bom neste tipo específico de benchmark.
Consegui obter três cenários, mas vou restringir esta discussão aos dois principais. Para
N<29
valores conhecidos em tempo de compilação, a compilação leva uma eternidade para carregar o valor constantefibo(N)
. Valores constantes maiores interromperão o compilador (ou farão com que o sistema operacional o faça) devido ao uso exponencial de memória ou timeouts (no Godbolt). Para ocultar o valor do compilador, coloquei-o em uma string que é boa o suficiente para gerar código genérico para qualquer valorN
(aproximadamente 160 bytes).Examinei o código do montador gerado pelo compilador GCC para as duas rotinas de exemplo e, em seguida, reconstruí o código C funcionalmente equivalente abaixo para ajudar a explicar mais facilmente como o código de chamada por referência obtém sua velocidade aparentemente mágica.
Ele utilizou um truque de recursão de cauda para minimizar a profundidade da recursão. Ele retira pedaços de 5 do valor de
N
para cada chamada recursiva e, em seguida, cria uma tabela de consulta paraN=1
through5
! É uma transformação de código bastante notável em relação ao código-fonte original.Aqui está o código.
fibo_cbv
é a geração de código embutido relativamente normal, típica de compiladores modernos, mas que faz uso intenso da pilha.fibo_cbr
é o código incomum gerado pelo GCC para esta função de benchmark específica. Este último é realmente bastante engenhoso! Meu código C não é exatamente equivalente ao assembler compilado, pois precisei ajustá-lo um pouco para que funcionasse corretamente.A propósito, recursão é uma maneira terrível de calcular esses valores!
Também notei, durante os experimentos, que se eu lesse uma variável,
N
o código de chamada por referência do GCC se tornava ainda mais complicado usando uma estratégia de potências de 2 em vez de subtrair pedaços de 8 ou 4 do valor inicial deN
. Não tenho certeza de qual aspecto do código de teste levou a esse caminho. Não posso reproduzi-lo agora, mas tenho certeza de que não tive alucinações com essa versão do código gerado.Vale ressaltar também que
N=92
parafibo(92)
é o valor mais alto que você pode atingir sem transbordar parauint64_t
.Não consegui fazer com que nenhum outro compilador C/C++ que testei, além do GCC, fizesse os cálculos de tempo de compilação extensivos necessários para calcular
fibo(N)
uma constante estática de forma recursiva lenta.A resposta curta para o motivo de ser mais rápido fazer chamadas por referência é que ele usa um algoritmo radicalmente diferente que não se parece em nada com o código fonte C original, mas é funcionalmente equivalente.
Nada a ver com o mecanismo de chamada por referência vs chamada por valor.