Eu gostaria de tentar fornecer uma resposta um pouco mais abrangente depois que isso foi discutido com o comitê de padrões C ++. Além de ser membro do comitê C ++, também sou desenvolvedor dos compiladores LLVM e Clang.
Fundamentalmente, não há como usar uma barreira ou alguma operação na sequência para realizar essas transformações. O problema fundamental é que a semântica operacional de algo como uma adição de inteiro é totalmente conhecida pela implementação. Pode simulá-los, sabe que não podem ser observados por programas corretos e está sempre livre para movê-los.
Poderíamos tentar evitar isso, mas teria resultados extremamente negativos e, no final das contas, falharia.
Primeiro, a única maneira de evitar isso no compilador é dizer a ele que todas essas operações básicas são observáveis. O problema é que isso impediria a grande maioria das otimizações do compilador. Dentro do compilador, essencialmente não temos bons mecanismos para modelar que o tempo é observável, mas nada mais. Não temos nem um bom modelo de quais operações demoram . Por exemplo, a conversão de um inteiro não assinado de 32 bits em um inteiro não assinado de 64 bits leva tempo? Leva tempo zero em x86-64, mas em outras arquiteturas leva tempo diferente de zero. Não há uma resposta genericamente correta aqui.
Mas mesmo se tivermos sucesso por meio de atos heroicos em impedir que o compilador reordene essas operações, não há garantia de que isso será suficiente. Considere uma forma válida e conforme para executar seu programa C ++ em uma máquina x86: DynamoRIO. Este é um sistema que avalia de forma dinâmica o código de máquina do programa. Uma coisa que ele pode fazer são otimizações online e é até capaz de executar especulativamente toda a gama de instruções aritméticas básicas fora do tempo. E esse comportamento não é exclusivo dos avaliadores dinâmicos, a CPU x86 real também especulará (um número muito menor de) instruções e as reordenará dinamicamente.
A compreensão essencial é que o fato de a aritmética não ser observável (mesmo no nível do tempo) é algo que permeia as camadas do computador. É verdade para o compilador, o tempo de execução e, muitas vezes, até mesmo para o hardware. Forçar que seja observável restringiria dramaticamente o compilador, mas também restringiria drasticamente o hardware.
Mas tudo isso não deve fazer você perder a esperança. Quando você deseja cronometrar a execução de operações matemáticas básicas, temos técnicas bem estudadas que funcionam de forma confiável. Normalmente, eles são usados ao fazer micro-benchmarking . Dei uma palestra sobre isso na CppCon2015: https://youtu.be/nXaxk27zwlk
As técnicas mostradas também são fornecidas por várias bibliotecas de micro-benchmarks, como a do Google: https://github.com/google/benchmark#preventing-optimization
A chave para essas técnicas é focar nos dados. Você torna a entrada para o cálculo opaca para o otimizador e o resultado do cálculo opaco para o otimizador. Depois de fazer isso, você pode cronometrar de forma confiável. Vejamos uma versão realista do exemplo na questão original, mas com a definição de foo
totalmente visível para a implementação. Também extraí uma versão (não portátil) da DoNotOptimize
biblioteca do Google Benchmark, que você pode encontrar aqui: https://github.com/google/benchmark/blob/master/include/benchmark/benchmark_api.h#L208
#include <chrono>
template <class T>
__attribute__((always_inline)) inline void DoNotOptimize(const T &value) {
asm volatile("" : "+m"(const_cast<T &>(value)));
}
// The compiler has full knowledge of the implementation.
static int foo(int x) { return x * 2; }
auto time_foo() {
using Clock = std::chrono::high_resolution_clock;
auto input = 42;
auto t1 = Clock::now(); // Statement 1
DoNotOptimize(input);
auto output = foo(input); // Statement 2
DoNotOptimize(output);
auto t2 = Clock::now(); // Statement 3
return t2 - t1;
}
Aqui, garantimos que os dados de entrada e os dados de saída sejam marcados como não otimizáveis em torno do cálculo foo
, e apenas em torno desses marcadores os tempos são calculados. Como você está usando dados para pinçar o cálculo, é garantido que ele permaneça entre os dois tempos e, ainda assim, o cálculo em si pode ser otimizado. O conjunto x86-64 resultante gerado por uma compilação recente do Clang / LLVM é:
% ./bin/clang++ -std=c++14 -c -S -o - so.cpp -O3
.text
.file "so.cpp"
.globl _Z8time_foov
.p2align 4, 0x90
.type _Z8time_foov,@function
_Z8time_foov: # @_Z8time_foov
.cfi_startproc
# BB#0: # %entry
pushq %rbx
.Ltmp0:
.cfi_def_cfa_offset 16
subq $16, %rsp
.Ltmp1:
.cfi_def_cfa_offset 32
.Ltmp2:
.cfi_offset %rbx, -16
movl $42, 8(%rsp)
callq _ZNSt6chrono3_V212system_clock3nowEv
movq %rax, %rbx
#APP
#NO_APP
movl 8(%rsp), %eax
addl %eax, %eax # This is "foo"!
movl %eax, 12(%rsp)
#APP
#NO_APP
callq _ZNSt6chrono3_V212system_clock3nowEv
subq %rbx, %rax
addq $16, %rsp
popq %rbx
retq
.Lfunc_end0:
.size _Z8time_foov, .Lfunc_end0-_Z8time_foov
.cfi_endproc
.ident "clang version 3.9.0 (trunk 273389) (llvm/trunk 273380)"
.section ".note.GNU-stack","",@progbits
Aqui você pode ver o compilador otimizando a chamada para foo(input)
uma única instrução addl %eax, %eax
, mas sem movê-la para fora do tempo ou eliminá-la inteiramente, apesar da entrada constante.
Espero que isso ajude, e o comitê de padrões C ++ está analisando a possibilidade de padronizar APIs semelhantes a esta DoNotOptimize
aqui.