Je voudrais essayer de fournir une réponse un peu plus complète après que cela ait été discuté avec le comité des normes C ++. En plus d'être membre du comité C ++, je suis également développeur sur les compilateurs LLVM et Clang.
Fondamentalement, il n'y a aucun moyen d'utiliser une barrière ou une opération dans la séquence pour réaliser ces transformations. Le problème fondamental est que la sémantique opérationnelle de quelque chose comme une addition d'entiers est totalement connue de l'implémentation. Il peut les simuler, il sait qu'ils ne peuvent pas être observés par des programmes corrects et est toujours libre de les déplacer.
Nous pourrions essayer d'éviter cela, mais cela aurait des résultats extrêmement négatifs et finirait par échouer.
Tout d'abord, la seule façon d'éviter cela dans le compilateur est de lui dire que toutes ces opérations de base sont observables. Le problème est que cela empêcherait alors l'écrasante majorité des optimisations du compilateur. À l'intérieur du compilateur, nous n'avons pratiquement aucun bon mécanisme pour modéliser que le timing est observable mais rien d'autre. Nous n'avons même pas un bon modèle de ce que les opérations prennent du temps . Par exemple, la conversion d'un entier non signé de 32 bits en un entier non signé de 64 bits prend du temps? Cela ne prend aucun temps sur x86-64, mais sur d'autres architectures, cela prend un temps différent de zéro. Il n'y a pas de réponse générique correcte ici.
Mais même si nous réussissons grâce à certains actes héroïques à empêcher le compilateur de réorganiser ces opérations, rien ne garantit que cela suffira. Considérez un moyen valide et conforme d'exécuter votre programme C ++ sur une machine x86: DynamoRIO. Il s'agit d'un système qui évalue dynamiquement le code machine du programme. Une chose qu'il peut faire est des optimisations en ligne, et il est même capable d'exécuter de manière spéculative toute la gamme d'instructions arithmétiques de base en dehors du timing. Et ce comportement n'est pas unique aux évaluateurs dynamiques, le processeur x86 réel spéculera également (un nombre beaucoup plus petit) d'instructions et les réorganisera dynamiquement.
La réalisation essentielle est que le fait que l'arithmétique ne soit pas observable (même au niveau de la synchronisation) est quelque chose qui imprègne les couches de l'ordinateur. C'est vrai pour le compilateur, le runtime et souvent même le matériel. Le forcer à être observable contraindrait à la fois considérablement le compilateur, mais cela contraindrait également considérablement le matériel.
Mais tout cela ne doit pas vous faire perdre espoir. Lorsque vous souhaitez chronométrer l'exécution d'opérations mathématiques de base, nous avons des techniques bien étudiées qui fonctionnent de manière fiable. Ils sont généralement utilisés lors du micro-benchmarking . J'ai donné une conférence à ce sujet à CppCon2015: https://youtu.be/nXaxk27zwlk
Les techniques présentées ici sont également fournies par diverses bibliothèques de micro-benchmark telles que celles de Google: https://github.com/google/benchmark#preventing-optimization
La clé de ces techniques est de se concentrer sur les données. Vous rendez l'entrée du calcul opaque pour l'optimiseur et le résultat du calcul opaque pour l'optimiseur. Une fois que vous avez fait cela, vous pouvez le chronométrer de manière fiable. Regardons une version réaliste de l'exemple dans la question d'origine, mais avec la définition de foo
entièrement visible pour l'implémentation. J'ai également extrait une version (non portable) de DoNotOptimize
de la bibliothèque Google Benchmark que vous pouvez trouver ici: 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;
}
Ici, nous nous assurons que les données d'entrée et les données de sortie sont marquées comme non optimisables autour du calcul foo
, et seulement autour de ces marqueurs sont les timings calculés. Parce que vous utilisez des données pour pincer le calcul, il est garanti qu'il restera entre les deux synchronisations et pourtant le calcul lui-même peut être optimisé. L'assembly x86-64 résultant généré par une version récente de Clang / LLVM est:
% ./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
Ici, vous pouvez voir le compilateur optimiser l'appel à foo(input)
une seule instruction addl %eax, %eax
, mais sans le déplacer en dehors du timing ni l'éliminer complètement malgré l'entrée constante.
J'espère que cela vous aidera, et le comité des normes C ++ étudie la possibilité de standardiser des API similaires à DoNotOptimize
ici.