Supposons que je souhaite exécuter un certain nombre d'instructions dans un ordre déterminé. Je veux utiliser g ++ avec le niveau d'optimisation 2, afin que certaines instructions puissent être réorganisées. De quels outils dispose-t-on pour appliquer un certain ordre de déclarations?
Prenons l'exemple suivant.
using Clock = std::chrono::high_resolution_clock;
auto t1 = Clock::now(); // Statement 1
foo(); // Statement 2
auto t2 = Clock::now(); // Statement 3
auto elapsedTime = t2 - t1;
Dans cet exemple, il est important que les instructions 1 à 3 soient exécutées dans l'ordre donné. Cependant, le compilateur ne peut-il pas penser que l'instruction 2 est indépendante de 1 et 3 et exécuter le code comme suit?
using Clock=std::chrono::high_resolution_clock;
foo(); // Statement 2
auto t1 = Clock::now(); // Statement 1
auto t2 = Clock::now(); // Statement 3
auto elapsedTime = t2 - t1;
Je voudrais essayer de fournir une réponse un peu plus complète après que cela ait été discuté avec le comité de normalisation C++. En plus d'être membre du comité C++, je suis également développeur sur les compilateurs LLVM et Clang.
Fondamentalement, il n’ya 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’entier est totalement connue de l’implémentation. Il peut les simuler, il sait qu’ils ne peuvent pas être observés par les programmes appropriés et 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 échouerait finalement.
Premièrement, 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 la majorité écrasante des optimisations du compilateur. À l'intérieur du compilateur, nous n'avons essentiellement aucun bon mécanisme pour modéliser le fait que le minutage 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 entier non signé de 32 bits en un entier non signé de 64 bits prend-elle du temps? Cela prend zéro temps sur x86-64, mais sur d'autres architectures, cela prend un temps non nul. Il n'y a pas de réponse génériquement correcte ici.
Cependant, même si nous réussissons héroïquement à empêcher le compilateur de réorganiser ces opérations, rien ne garantit que cela suffira. Envisagez une méthode valide et conforme pour exécuter votre programme C++ sur une machine x86: DynamoRIO. C'est un système qui évalue dynamiquement le code machine du programme. Une des choses qu’il peut faire, ce sont les 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 minutage. Et ce comportement n’est pas propre aux évaluateurs dynamiques, le CPU x86 lui-même spéculera également des instructions (un nombre beaucoup plus réduit) et les réorganisera de manière dynamique.
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 fait de le rendre observable contraindrait considérablement le compilateur, mais le matériel également.
Mais tout cela ne devrait 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. Généralement, ceux-ci sont utilisés lors de la micro-analyse comparative . 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-références, telles que celles de Google: https://github.com/google/benchmark#preventing-optimisation
La clé de ces techniques est de se concentrer sur les données. Vous effectuez l'entrée de calcul opaque pour l'optimiseur et le résultat du calcul opaque pour l'optimiseur. Une fois que vous avez fait cela, vous pouvez 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
parfaitement visible pour 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 que les timings calculés sont uniquement autour de ces marqueurs. Du fait que vous utilisez des données pour pincer le calcul, il est garanti qu’il reste entre les deux minutages et que le calcul lui-même est autorisé à être optimisé. L'assemblage 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)
jusqu'à une seule instruction, addl %eax, %eax
, Mais sans le déplacer en dehors du minutage ni l'éliminer entièrement malgré l'entrée constante.
J'espère que cela vous aidera, et le comité de normalisation C++ étudie la possibilité de normaliser des API similaires à DoNotOptimize
ici.
Résumé:
Il semble n'y avoir aucun moyen garanti d'empêcher le réordonnancement, mais tant que l'optimisation du temps de liaison/du programme complet n'est pas activée, localiser la fonction appelée dans une unité de compilation séparée semble être un assez bon pari. (Du moins avec GCC, bien que la logique suggère que cela est également le cas avec d’autres compilateurs.) Cela se fait au détriment du code dont le code en-tête est par définition dans la même unité de compilation et ouvert à une réorganisation.
Réponse originale:
GCC réorganise les appels en optimisation -O2:
#include <chrono>
static int foo(int x) // 'static' or not here doesn't affect ordering.
{
return x*2;
}
int fred(int x)
{
auto t1 = std::chrono::high_resolution_clock::now();
int y = foo(x);
auto t2 = std::chrono::high_resolution_clock::now();
return y;
}
GCC 5.3.0:
g++ -S --std=c++11 -O0 fred.cpp
:
_ZL3fooi:
pushq %rbp
movq %rsp, %rbp
movl %ecx, 16(%rbp)
movl 16(%rbp), %eax
addl %eax, %eax
popq %rbp
ret
_Z4fredi:
pushq %rbp
movq %rsp, %rbp
subq $64, %rsp
movl %ecx, 16(%rbp)
call _ZNSt6chrono3_V212system_clock3nowEv
movq %rax, -16(%rbp)
movl 16(%rbp), %ecx
call _ZL3fooi
movl %eax, -4(%rbp)
call _ZNSt6chrono3_V212system_clock3nowEv
movq %rax, -32(%rbp)
movl -4(%rbp), %eax
addq $64, %rsp
popq %rbp
ret
Mais:
g++ -S --std=c++11 -O2 fred.cpp
:
_Z4fredi:
pushq %rbx
subq $32, %rsp
movl %ecx, %ebx
call _ZNSt6chrono3_V212system_clock3nowEv
call _ZNSt6chrono3_V212system_clock3nowEv
leal (%rbx,%rbx), %eax
addq $32, %rsp
popq %rbx
ret
Maintenant, avec foo () en tant que fonction externe:
#include <chrono>
int foo(int x);
int fred(int x)
{
auto t1 = std::chrono::high_resolution_clock::now();
int y = foo(x);
auto t2 = std::chrono::high_resolution_clock::now();
return y;
}
g++ -S --std=c++11 -O2 fred.cpp
:
_Z4fredi:
pushq %rbx
subq $32, %rsp
movl %ecx, %ebx
call _ZNSt6chrono3_V212system_clock3nowEv
movl %ebx, %ecx
call _Z3fooi
movl %eax, %ebx
call _ZNSt6chrono3_V212system_clock3nowEv
movl %ebx, %eax
addq $32, %rsp
popq %rbx
ret
MAIS, si cela est lié à -flto (optimisation du temps de liaison):
0000000100401710 <main>:
100401710: 53 Push %rbx
100401711: 48 83 ec 20 sub $0x20,%rsp
100401715: 89 cb mov %ecx,%ebx
100401717: e8 e4 ff ff ff callq 100401700 <__main>
10040171c: e8 bf f9 ff ff callq 1004010e0 <_ZNSt6chrono3_V212system_clock3nowEv>
100401721: e8 ba f9 ff ff callq 1004010e0 <_ZNSt6chrono3_V212system_clock3nowEv>
100401726: 8d 04 1b lea (%rbx,%rbx,1),%eax
100401729: 48 83 c4 20 add $0x20,%rsp
10040172d: 5b pop %rbx
10040172e: c3 retq
La réorganisation peut être effectuée par le compilateur ou par le processeur.
La plupart des compilateurs offrent une méthode spécifique à la plate-forme pour empêcher la réorganisation des instructions de lecture-écriture. Sur gcc, c’est
asm volatile("" ::: "memory");
Notez que cela n'empêche qu'indirectement les opérations de réordonnancement, tant qu'elles dépendent des lectures/écritures.
En pratique Je n'ai pas encore vu de système dans lequel l'appel système dans Clock::now()
a le même effet qu'une telle barrière. Vous pouvez inspecter l'assemblage résultant pour vous en assurer.
Cependant, il n'est pas rare que la fonction testée soit évaluée pendant la compilation. Pour appliquer une exécution "réaliste", vous devrez peut-être dériver une entrée pour foo()
à partir d'E/S ou une lecture volatile
.
Une autre option serait de désactiver l'inline pour foo()
- là encore, il s'agit d'un compilateur spécifique et généralement non portable, mais aurait le même effet.
Sur gcc, cela serait __attribute__ ((noinline))
@Ruslan soulève un problème fondamental: à quel point cette mesure est-elle réaliste?
Le temps d'exécution dépend de nombreux facteurs: le premier est le matériel sur lequel nous travaillons, le second est l'accès simultané à des ressources partagées telles que le cache, la mémoire, le disque et les cœurs de processeur.
Donc, ce que nous faisons habituellement pour obtenir comparable timings: assurez-vous qu’ils sont reproductibles avec une marge d’erreur faible. Cela les rend un peu artificiels.
L'exécution "cache à chaud" et "cache à froid" peut facilement différer d'un ordre de grandeur - mais en réalité, ce sera quelque chose entre les deux ("tiède"?)
Le langage C++ définit ce qui est observable de différentes manières.
Si foo()
ne fait rien d’observable, il peut être éliminé complètement. Si foo()
ne fait qu'un calcul qui stocke des valeurs dans un état "local" (que ce soit sur la pile ou dans un objet quelque part), et le compilateur peut prouver qu'aucun calculateur n'a été dérivé en toute sécurité le pointeur peut entrer dans le code Clock::now()
, alors le déplacement des appels Clock::now()
n'a aucune conséquence visible.
Si foo()
a interagi avec un fichier ou l'affichage, et que le compilateur ne peut pas prouver que Clock::now()
fonctionne pas avec le fichier ou l'affichage, la réorganisation ne peut pas être terminé, car l’interaction avec un fichier ou un affichage est un comportement observable.
Bien que vous puissiez utiliser des hacks spécifiques au compilateur pour forcer le code à ne pas être déplacé (comme dans l’assemblage inline), une autre approche consiste à essayer de surpasser votre compilateur.
Créez une bibliothèque chargée dynamiquement. Chargez-le avant le code en question.
Cette bibliothèque expose une chose:
namespace details {
void execute( void(*)(void*), void *);
}
et l'enveloppe comme ceci:
template<class F>
void execute( F f ) {
struct bundle_t {
F f;
} bundle = {std::forward<F>(f)};
auto tmp_f = [](void* ptr)->void {
auto* pb = static_cast<bundle_t*>(ptr);
(pb->f)();
};
details::execute( tmp_f, &bundle );
}
qui empile un lambda nullary et utilise la bibliothèque dynamique pour l'exécuter dans un contexte que le compilateur ne peut pas comprendre.
Dans la bibliothèque dynamique, nous faisons:
void details::execute( void(*f)(void*), void *p) {
f(p);
}
ce qui est assez simple.
Maintenant, pour réorganiser les appels sur execute
, il doit comprendre la bibliothèque dynamique, ce qu'il ne peut pas faire lors de la compilation de votre code de test.
Il peut toujours éliminer foo()
sans effets secondaires, mais vous en gagnez, vous en perdez.
Non ça ne peut pas. Selon le standard C++ [intro.execution]:
14 Chaque calcul de valeur et effet secondaire associé à une expression complète est séquencé avant chaque calcul de valeur et effet secondaire associé à la prochaine expression complète à évaluer.
Une expression complète est fondamentalement une déclaration terminée par un point-virgule. Comme vous pouvez le constater, la règle ci-dessus stipule que les instructions doivent être exécutées dans l'ordre. C’est dans des affirmations que le compilateur a plus de liberté (c’est-à-dire que dans certaines circonstances, il est permis d’évaluer les expressions qui constituent une instruction dans un ordre autre que celui de gauche à droite ou autre).
Notez que les conditions d'application de la règle as-if ne sont pas remplies ici. Il est déraisonnable de penser que tout compilateur serait capable de prouver que réorganiser les appels pour obtenir l'heure système n'aurait aucune incidence sur le comportement observable du programme. S'il existait une situation dans laquelle deux appels pour obtenir l'heure pouvaient être réorganisés sans changer le comportement observé, il serait extrêmement inefficace de produire un compilateur qui analyse un programme avec suffisamment de compréhension pour pouvoir en déduire avec certitude.
N °
Parfois, selon la règle "as-if", les instructions peuvent être réorganisées. Ce n'est pas parce qu'ils sont logiquement indépendants les uns des autres, mais parce que cette indépendance permet à un tel réordonnancement de se faire sans changer la sémantique du programme.
Le déplacement d'un appel système qui obtient l'heure actuelle ne répond évidemment pas à cette condition. Un compilateur qui le fait sciemment ou inconsciemment est non conforme et vraiment idiot.
En général, je ne m'attendrais pas à ce qu'une expression qui aboutisse à un appel système soit "mal interprétée" même par un compilateur à l'optimisation agressive. Il n'en sait pas assez sur le rôle de cet appel système.
noinline
fonction + boîte noire d'assemblage en ligne + dépendances complètes des données
Ceci est basé sur https://stackoverflow.com/a/38025837/895245 mais comme je ne voyais pas de justification claire pour laquelle la ::now()
ne peut pas être réorganisée ici, je serait plutôt paranoïaque et le mettre dans une fonction noinline avec l'asm.
De cette façon, je suis à peu près sûr que la réorganisation ne peut pas se produire, car le noinline
"lie" le ::now
Et la dépendance des données.
main.cpp
#include <chrono>
#include <iostream>
#include <string>
// noinline ensures that the ::now() cannot be split from the __asm__
template <class T>
__attribute__((noinline)) auto get_clock(T& value) {
// Make the compiler think we actually use / modify the value.
// It can't "see" what is going on inside the Assembly string.
__asm__ __volatile__("" : "+m"(value));
return std::chrono::high_resolution_clock::now();
}
template <class T>
static T foo(T niters) {
T result = 42;
for (T i = 0; i < niters; ++i) {
result = (result * result) - (3 * result) + 1;
}
return result;
}
int main(int argc, char **argv) {
unsigned long long input;
if (argc > 1) {
input = std::stoull(argv[1], NULL, 0);
} else {
input = 10000;
}
// Must come before because it could modify input
// which is passed as a reference.
auto t1 = get_clock(input);
auto output = foo(input);
// Must come after as it could use the output.
auto t2 = get_clock(output);
std::cout << "output " << output << std::endl;
std::cout << "time "
<< std::chrono::duration_cast<std::chrono::nanoseconds>(t2 - t1).count()
<< std::endl;
}
Compiler et exécuter:
g++ -ggdb3 -O3 -std=c++14 -Wall -Wextra -pedantic -o main.out main.cpp
./main.out 1000
./main.out 10000
./main.out 100000
Le seul inconvénient mineur de cette méthode est que nous ajoutons une instruction supplémentaire callq
à une méthode inline
. objdump -CD
Indique que main
contient:
11b5: e8 26 03 00 00 callq 14e0 <auto get_clock<unsigned long long>(unsigned long long&)>
11ba: 48 8b 34 24 mov (%rsp),%rsi
11be: 48 89 c5 mov %rax,%rbp
11c1: b8 2a 00 00 00 mov $0x2a,%eax
11c6: 48 85 f6 test %rsi,%rsi
11c9: 74 1a je 11e5 <main+0x65>
11cb: 31 d2 xor %edx,%edx
11cd: 0f 1f 00 nopl (%rax)
11d0: 48 8d 48 fd lea -0x3(%rax),%rcx
11d4: 48 83 c2 01 add $0x1,%rdx
11d8: 48 0f af c1 imul %rcx,%rax
11dc: 48 83 c0 01 add $0x1,%rax
11e0: 48 39 d6 cmp %rdx,%rsi
11e3: 75 eb jne 11d0 <main+0x50>
11e5: 48 89 df mov %rbx,%rdi
11e8: 48 89 44 24 08 mov %rax,0x8(%rsp)
11ed: e8 ee 02 00 00 callq 14e0 <auto get_clock<unsigned long long>(unsigned long long&)>
nous voyons donc que foo
était en ligne, mais que get_clock
ne l'était pas et l'entourait.
get_clock
Lui-même est toutefois extrêmement efficace, consistant en une instruction optimisée pour un seul appel feuille qui ne touche même pas la pile:
00000000000014e0 <auto get_clock<unsigned long long>(unsigned long long&)>:
14e0: e9 5b fb ff ff jmpq 1040 <std::chrono::_V2::system_clock::now()@plt>
Étant donné que la précision de l'horloge est elle-même limitée, il est peu probable que vous puissiez remarquer les effets de minutage d'un jmpq
supplémentaire. Notez qu'un call
est requis indépendamment du fait que ::now()
se trouve dans une bibliothèque partagée.
Appelez ::now()
depuis un assemblage en ligne avec dépendance des données
Ce serait la solution la plus efficace possible, surmontant même le supplément jmpq
mentionné ci-dessus.
C'est malheureusement extrêmement difficile à faire correctement, comme indiqué à: Appel de printf dans l'ASM étendu en ligne
Si votre mesure du temps peut être effectuée directement dans l'assemblage en ligne sans appel, cette technique peut alors être utilisée. C’est le cas par exemple pour instructions d’instrumentation magique gem5 , x86 RDTSC (pas sûr que ce soit représentatif) et éventuellement d’autres compteurs de performance.
Testé avec GCC 8.3.0, Ubuntu 19.04.