J'aimerais essayer de fournir une réponse un peu plus complète après que cette question ait été discutée 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 quelconque 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 connu à l'implémentation. Elle peut les simuler, elle sait qu'elles ne peuvent pas être observées par des programmes corrects, et elle est toujours libre de les déplacer.
Nous pourrions essayer d'empêcher cela, mais cela aurait des résultats extrêmement négatifs et finirait par échouer.
Tout d'abord, la seule façon d'empêcher cela dans le compilateur est de lui dire que toutes ces opérations de base sont observables. Le problème est que cela exclurait alors l'écrasante majorité des optimisations du compilateur. À l'intérieur du compilateur, nous n'avons essentiellement pas de bons mécanismes pour modéliser que les timing est observable, mais rien d'autre. Nous n'avons même pas un bon modèle de quelles opérations prennent du temps . Par exemple, la conversion d'un nombre entier non signé de 32 bits en un nombre entier non signé de 64 bits prend-elle du temps ? Le temps est nul sur x86-64, mais sur d'autres architectures, il est non nul. Il n'y a pas de réponse génériquement correcte ici.
Mais même si nous parvenons, par des moyens héroïques, à empêcher le compilateur de réordonner ces opérations, il n'est pas certain que cela soit suffisant. Considérons une façon 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. Il peut notamment effectuer des optimisations en ligne, et il est même capable d'exécuter de manière spéculative toute la gamme des instructions arithmétiques de base en dehors du timing. Et ce comportement n'est pas propre aux évaluateurs dynamiques, le CPU x86 actuel spéculera également (un nombre beaucoup plus petit d') instructions et les réordonnera dynamiquement.
La prise de conscience essentielle est que le fait que l'arithmétique ne soit pas observable (même au niveau du timing) est quelque chose qui imprègne toutes 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 considérablement le compilateur, mais aussi 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 disposons de techniques bien étudiées qui fonctionnent de manière fiable. En général, ces techniques sont utilisées pour faire micro-benchmarking . J'ai donné une conférence à ce sujet à la CppCon2015 : https://youtu.be/nXaxk27zwlk
Les techniques présentées ici sont également fournies par diverses bibliothèques de micro-benchmarks telles que celle de Google : https://github.com/google/benchmark#preventing-optimization
La clé de ces techniques est de se concentrer sur les données. Il faut rendre 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. Examinons une version réaliste de l'exemple de la question originale, mais avec la définition de foo
entièrement visible à la mise en œuvre. 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 c'est seulement autour de ces marqueurs que les temps sont calculés. Puisque vous utilisez des données pour pincer le calcul, il est garanti de rester entre les deux timings et pourtant le calcul lui-même est autorisé à être optimisé. L'assemblage x86-64 résultant généré par une construction 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 que le compilateur optimise l'appel à foo(input)
en une seule instruction, addl %eax, %eax
mais sans le déplacer en dehors du timing ou l'éliminer complètement malgré l'apport constant.
J'espère que cela vous aidera. Le comité de normalisation C++ étudie la possibilité de normaliser des API similaires à DoNotOptimize
ici.
35 votes
Si le compilateur pense qu'ils sont indépendants alors qu'ils ne le sont pas, le compilateur est défaillant et vous devriez utiliser un meilleur compilateur.
18 votes
open-std.org/jtc1/sc22/wg21/docs/papers/2016/p0342r0.html
1 votes
Pourrait
__sync_synchronize()
être d'une quelconque aide ?0 votes
Essayez de mettre foo dans une unité de compilation différente de la fonction ci-dessus. Cela peut empêcher le compilateur de l'analyser et donc le forcer à conserver le même ordonnancement.
3 votes
@HowardHinnant : La puissance sémantique du C standard serait énormément améliorée si une telle directive était définie, et si les règles d'aliasing étaient ajustées pour exempter les lectures effectuées après une barrière de données qui a été écrite avant elle.
1 votes
@LokiAstari La réponse de Jeremy a déjà montré cet effet, mais a aussi montré que (de manière prévisible) LTO le met aussi en échec.
5 votes
@DavidSchwartz Dans ce cas, il s'agit de mesurer le temps.
foo
prend pour s'exécuter, ce que le compilateur est autorisé à ignorer lors du réordonnancement, tout comme il est autorisé à ignorer l'observation d'un thread différent.0 votes
Si je comprends bien vos réponses, le réordonnancement des instructions peut être évité en plaçant les instructions des fonctions dans des unités de compilation différentes. Cependant, si LTO est activé, cela ne fonctionne pas non plus. Par ailleurs, l'existence de la proposition pointée du doigt par Howard Hinnant
0 votes
Indique qu'il n'y a pas de moyen parfait d'éviter ce problème. Je vais donc accepter la réponse de Jeremy. Merci à tous pour votre aide :)
0 votes
@CodesInChaos Il n'est pas nécessaire qu'il y ait une telle chose comme "le temps que foo prend pour s'exécuter". Le compilateur est libre de réarranger le code de telle sorte que le travail de foo soit effectué à l'endroit et au moment qu'il juge les plus appropriés, en intercalant d'autres travaux qui pourraient avoir besoin d'être effectués comme il le souhaite. Vous aurez besoin de connaissances spécifiques au compilateur pour vous assurer que le "temps d'exécution de foo" est une chose qui peut être mesurée.
0 votes
@S2108887 a lu la question et accepté la réponse en ce fil pour en savoir plus
1 votes
Les pointeurs de fonction volatiles sont vos amis. De plus, si vous faites du profilage, utilisez un outil comme Valgrind.
0 votes
foo()
pourrait prendret1
en tant que paramètre, puis retourner0.0
qui est ajouté àt2
.0 votes
Je suppose que les barrières de mémoire sont ce que vous recherchez ? fr.cppreference.com/w/cpp/atomic/memory_order stackoverflow.com/questions/7346163/barrières de mémoire efficaces