Me gustaría intentar proporcionar una respuesta algo más completa después de que esto se discutiera con el comité de estándares de C ++. Además de ser miembro del comité de C ++, también soy desarrollador de los compiladores LLVM y Clang.
Fundamentalmente, no hay forma de usar una barrera o alguna operación en la secuencia para lograr estas transformaciones. El problema fundamental es que la semántica operativa de algo como una suma de números enteros es totalmente conocida en la implementación. Puede simularlos, sabe que no pueden ser observados por los programas correctos y siempre es libre de moverlos.
Podríamos intentar prevenir esto, pero tendría resultados extremadamente negativos y finalmente fracasaría.
Primero, la única forma de evitar esto en el compilador es decirle que todas estas operaciones básicas son observables. El problema es que esto evitaría la inmensa mayoría de las optimizaciones del compilador. Dentro del compilador, esencialmente no tenemos buenos mecanismos para modelar que el tiempo es observable, pero nada más. Ni siquiera tenemos un buen modelo de qué operaciones llevan tiempo . Como ejemplo, ¿lleva tiempo convertir un entero sin signo de 32 bits en un entero sin signo de 64 bits? No lleva tiempo en x86-64, pero en otras arquitecturas toma un tiempo distinto de cero. Aquí no hay una respuesta genéricamente correcta.
Pero incluso si logramos evitar con algunos actos heroicos que el compilador reordene estas operaciones, no hay garantía de que esto sea suficiente. Considere una forma válida y conforme de ejecutar su programa C ++ en una máquina x86: DynamoRIO. Este es un sistema que evalúa dinámicamente el código máquina del programa. Una cosa que puede hacer son optimizaciones en línea, e incluso es capaz de ejecutar especulativamente toda la gama de instrucciones aritméticas básicas fuera del tiempo. Y este comportamiento no es exclusivo de los evaluadores dinámicos, la CPU x86 real también especulará (un número mucho menor de) instrucciones y las reordenará dinámicamente.
La comprensión esencial es que el hecho de que la aritmética no sea observable (incluso a nivel de tiempo) es algo que impregna las capas de la computadora. Es cierto para el compilador, el tiempo de ejecución y, a menudo, incluso el hardware. Obligarlo a ser observable restringiría drásticamente el compilador, pero también restringiría drásticamente el hardware.
Pero todo esto no debería hacer que pierda la esperanza. Cuando desee cronometrar la ejecución de operaciones matemáticas básicas, tenemos técnicas bien estudiadas que funcionan de manera confiable. Por lo general, se utilizan al realizar microevaluaciones comparativas . Di una charla sobre esto en CppCon2015: https://youtu.be/nXaxk27zwlk
Las técnicas que se muestran allí también las proporcionan varias bibliotecas de microevaluaciones como las de Google: https://github.com/google/benchmark#preventing-optimization
La clave de estas técnicas es centrarse en los datos. Usted hace que la entrada del cálculo sea opaca para el optimizador y el resultado del cálculo opaco para el optimizador. Una vez que haya hecho eso, puede cronometrarlo de manera confiable. Veamos una versión realista del ejemplo de la pregunta original, pero con la definición de foo
completamente visible para la implementación. También extraje una versión (no portátil) de DoNotOptimize
la biblioteca de Google Benchmark que puede encontrar aquí: 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;
}
Aquí nos aseguramos de que los datos de entrada y los datos de salida estén marcados como no optimizables alrededor del cálculo foo
, y solo alrededor de esos marcadores se calculan los tiempos. Debido a que está utilizando datos para ajustar el cálculo, se garantiza que se mantendrá entre los dos tiempos y, sin embargo, se permite optimizar el cálculo en sí. El ensamblado x86-64 resultante generado por una compilación reciente de Clang / LLVM es:
% ./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
Aquí puede ver al compilador optimizando la llamada para foo(input)
reducirla a una sola instrucción addl %eax, %eax
, pero sin moverla fuera del tiempo o eliminarla por completo a pesar de la entrada constante.
Espero que esto ayude, y el comité de estándares de C ++ está estudiando la posibilidad de estandarizar API similares a DoNotOptimize
aquí.