Chciałbym spróbować udzielić nieco bardziej kompleksowej odpowiedzi po omówieniu tego z komitetem normalizacyjnym C ++. Poza tym, że jestem członkiem komitetu C ++, jestem także programistą kompilatorów LLVM i Clang.
Zasadniczo nie ma sposobu, aby użyć bariery lub jakiejś operacji w sekwencji, aby osiągnąć te przekształcenia. Podstawowym problemem jest to, że semantyka operacyjna czegoś takiego jak dodawanie liczb całkowitych jest całkowicie znana implementacji. Potrafi je symulować, wie, że nie można ich obserwować za pomocą odpowiednich programów i zawsze może je dowolnie przenosić.
Moglibyśmy próbować temu zapobiec, ale miałoby to wyjątkowo negatywne skutki i ostatecznie by się nie powiodło.
Po pierwsze, jedynym sposobem, aby temu zapobiec w kompilatorze, jest poinformowanie go, że wszystkie te podstawowe operacje są obserwowalne. Problem polega na tym, że wykluczałoby to przytłaczającą większość optymalizacji kompilatora. Wewnątrz kompilatora zasadniczo nie mamy dobrych mechanizmów do modelowania, że synchronizacja jest obserwowalna, ale nic więcej. Nie mamy nawet dobrego modelu tego, jakie operacje wymagają czasu . Na przykład, czy konwersja 32-bitowej liczby całkowitej bez znaku na 64-bitową liczbę całkowitą bez znaku zajmuje trochę czasu? Na x86-64 zajmuje to zero czasu, ale na innych architekturach zajmuje to niezerowy czas. Nie ma tutaj ogólnie poprawnej odpowiedzi.
Ale nawet jeśli odniesiemy sukces dzięki pewnym heroicznym próbom powstrzymania kompilatora przed zmianą kolejności tych operacji, nie ma gwarancji, że to wystarczy. Rozważ prawidłowy i zgodny sposób wykonania programu w C ++ na maszynie x86: DynamoRIO. Jest to system, który dynamicznie ocenia kod maszynowy programu. Jedyną rzeczą, jaką może zrobić, jest optymalizacja online, a nawet jest w stanie spekulacyjnie wykonać cały zakres podstawowych instrukcji arytmetycznych poza czasem. I to zachowanie nie jest unikalne dla dynamicznych oceniających, rzeczywisty procesor x86 również spekuluje (znacznie mniejsza liczba) instrukcji i zmienia ich kolejność dynamicznie.
Najważniejsze jest uświadomienie sobie, że fakt, że arytmetyka nie jest obserwowalna (nawet na poziomie czasowym) jest czymś, co przenika warstwy komputera. Dotyczy to kompilatora, środowiska uruchomieniowego, a często nawet sprzętu. Wymuszenie na nim widoczności spowodowałoby zarówno drastyczne ograniczenie kompilatora, jak i drastyczne ograniczenie sprzętu.
Ale to wszystko nie powinno powodować utraty nadziei. Jeśli chcesz zaplanować wykonanie podstawowych operacji matematycznych, dobrze poznaliśmy techniki, które działają niezawodnie. Zwykle są one używane podczas wykonywania mikro-benchmarkingu . Mówiłem o tym na CppCon2015: https://youtu.be/nXaxk27zwlk
Pokazane tam techniki są również dostarczane przez różne biblioteki mikro-benchmarków, takie jak Google: https://github.com/google/benchmark#preventing-optimization
Kluczem do tych technik jest skupienie się na danych. Wprowadzasz dane wejściowe do obliczeń jako nieprzezroczyste dla optymalizatora, a wynik obliczeń nieprzezroczysty dla optymalizatora. Gdy to zrobisz, możesz niezawodnie mierzyć czas. Spójrzmy na realistyczną wersję przykładu w oryginalnym pytaniu, ale z definicją w foo
pełni widoczną dla realizacji. Wyodrębniłem również (nieprzenośną) wersję programu DoNotOptimize
z biblioteki Google Benchmark, którą można znaleźć tutaj: 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;
}
Tutaj zapewniamy, że dane wejściowe i dane wyjściowe są oznaczone jako niemożliwe do optymalizacji wokół obliczeń foo
i tylko wokół tych znaczników są obliczane czasy. Ponieważ używasz danych do ścisłego wykonywania obliczeń, gwarantuje się, że pozostanie między dwoma taktami, a jednak same obliczenia mogą być zoptymalizowane. Wynikowy zestaw x86-64 wygenerowany przez najnowszą kompilację Clang / LLVM to:
% ./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
Tutaj możesz zobaczyć kompilator optymalizujący wywołanie do foo(input)
pojedynczej instrukcji addl %eax, %eax
, ale bez przenoszenia go poza taktowanie lub całkowite wyeliminowanie go pomimo ciągłego wprowadzania.
Mam nadzieję, że to pomoże, a komitet normalizacyjny C ++ rozważa możliwość ujednolicenia interfejsów API podobnych do DoNotOptimize
tutaj.