J'ai remarqué pour la première fois en 2009 que GCC (du moins sur mes projets et sur mes machines) avait tendance à générer un code sensiblement plus rapide si j'optimisais pour taille (_-Os
_) au lieu de la vitesse (_-O2
_ ou _-O3
_), et je me demande depuis pourquoi.
J'ai réussi à créer un code (plutôt stupide) qui montre ce comportement surprenant et est suffisamment petit pour être posté ici.
_const int LOOP_BOUND = 200000000;
__attribute__((noinline))
static int add(const int& x, const int& y) {
return x + y;
}
__attribute__((noinline))
static int work(int xval, int yval) {
int sum(0);
for (int i=0; i<LOOP_BOUND; ++i) {
int x(xval+sum);
int y(yval+sum);
int z = add(x, y);
sum += z;
}
return sum;
}
int main(int , char* argv[]) {
int result = work(*argv[1], *argv[2]);
return result;
}
_
Si je le compile avec _-Os
_, il faut 0,38 s pour exécuter ce programme et 0,44 s s'il est compilé avec _-O2
_ ou _-O3
_. Ces temps sont obtenus de manière cohérente et pratiquement sans bruit (gcc 4.7.2, x86_64 GNU/Linux, Intel Core i5-3320M).
(Mise à jour: j'ai déplacé tout le code d'assemblage vers GitHub : ils ont rendu la publication gonflée et ont apparemment ajouté très peu de valeur aux questions, car les drapeaux _fno-align-*
_ ont la même effet.)
Voici l'assembly généré avec -Os
et -O2
.
Malheureusement, ma compréhension de l’Assemblée étant très limitée, je ne sais donc pas si ce que j’ai fait ensuite était correct: j’ai saisi l’Assemblée pour _-O2
_ et fusionné toutes ses différences dans l’Assemblée pour _-Os
_ sauf les lignes _.p2align
_, résultat ici . Ce code fonctionne toujours dans 0.38s et la seule différence est le _.p2align
_ stuff.
Si je devine bien, ce sont des rembourrages pour l'alignement de la pile. Selon Pourquoi le pad GCC fonctionne-t-il avec des NOP? c'est fait dans l'espoir que le code fonctionnera plus vite, mais apparemment, cette optimisation s'est retournée contre moi.
Est-ce le rembourrage qui est le coupable dans ce cas? Pourquoi et comment?
Le bruit qu’il fait rend pratiquement impossible la micro-optimisation du minutage.
Comment puis-je m'assurer que de tels alignements accidentels chanceux/chanceux n'interfèrent pas lorsque je fais des micro-optimisations (sans rapport avec l'alignement de pile) sur du code source C ou C++?
PDATE:
Après réponse de Pascal Cuoq , j'ai bricolé un peu avec les alignements. En passant _-O2 -fno-align-functions -fno-align-loops
_ à gcc, tous les _.p2align
_ sont supprimés de l'assembly et l'exécutable généré s'exécute en 0.38s. Selon le documentation gcc :
-Os active toutes les optimisations -O2 [mais] -Os désactive les indicateurs d'optimisation suivants:
_-falign-functions -falign-jumps -falign-loops <br/> -falign-labels -freorder-blocks -freorder-blocks-and-partition <br/> -fprefetch-loop-arrays <br/>
_
Cela ressemble donc à un problème de (mauvais) alignement.
Je suis toujours sceptique à propos de _-march=native
_ comme suggéré dans réponse de Marat Dukhan . Je ne suis pas convaincu qu'il ne s'agit pas seulement d'intervenir dans ce problème de (mauvais) alignement; cela n'a absolument aucun effet sur ma machine. (Néanmoins, j'ai voté sa réponse.)
PDATE 2:
Nous pouvons enlever _-Os
_ de la photo. Les temps suivants sont obtenus en compilant avec
_-O2 -fno-omit-frame-pointer
_ 0.37s
_-O2 -fno-align-functions -fno-align-loops
_ 0.37s
_-S -O2
_ puis déplacez manuellement l'assemblage de add()
après work()
0.37s
_-O2
_ 0.44s
Il me semble que la distance de add()
du site d’appel compte beaucoup. J'ai essayé perf
, mais les sorties de _perf stat
_ et _perf report
_ ont très peu de sens pour moi. Cependant, je n'ai pu obtenir qu'un seul résultat cohérent:
_-O2
_:
_ 602,312,864 stalled-cycles-frontend # 0.00% frontend cycles idle
3,318 cache-misses
0.432703993 seconds time elapsed
[...]
81.23% a.out a.out [.] work(int, int)
18.50% a.out a.out [.] add(int const&, int const&) [clone .isra.0]
[...]
¦ __attribute__((noinline))
¦ static int add(const int& x, const int& y) {
¦ return x + y;
100.00 ¦ lea (%rdi,%rsi,1),%eax
¦ }
¦ ? retq
[...]
¦ int z = add(x, y);
1.93 ¦ ? callq add(int const&, int const&) [clone .isra.0]
¦ sum += z;
79.79 ¦ add %eax,%ebx
_
Pour _fno-align-*
_:
_ 604,072,552 stalled-cycles-frontend # 0.00% frontend cycles idle
9,508 cache-misses
0.375681928 seconds time elapsed
[...]
82.58% a.out a.out [.] work(int, int)
16.83% a.out a.out [.] add(int const&, int const&) [clone .isra.0]
[...]
¦ __attribute__((noinline))
¦ static int add(const int& x, const int& y) {
¦ return x + y;
51.59 ¦ lea (%rdi,%rsi,1),%eax
¦ }
[...]
¦ __attribute__((noinline))
¦ static int work(int xval, int yval) {
¦ int sum(0);
¦ for (int i=0; i<LOOP_BOUND; ++i) {
¦ int x(xval+sum);
8.20 ¦ lea 0x0(%r13,%rbx,1),%edi
¦ int y(yval+sum);
¦ int z = add(x, y);
35.34 ¦ ? callq add(int const&, int const&) [clone .isra.0]
¦ sum += z;
39.48 ¦ add %eax,%ebx
¦ }
_
Pour _-fno-omit-frame-pointer
_:
_ 404,625,639 stalled-cycles-frontend # 0.00% frontend cycles idle
10,514 cache-misses
0.375445137 seconds time elapsed
[...]
75.35% a.out a.out [.] add(int const&, int const&) [clone .isra.0] ¦
24.46% a.out a.out [.] work(int, int)
[...]
¦ __attribute__((noinline))
¦ static int add(const int& x, const int& y) {
18.67 ¦ Push %rbp
¦ return x + y;
18.49 ¦ lea (%rdi,%rsi,1),%eax
¦ const int LOOP_BOUND = 200000000;
¦
¦ __attribute__((noinline))
¦ static int add(const int& x, const int& y) {
¦ mov %rsp,%rbp
¦ return x + y;
¦ }
12.71 ¦ pop %rbp
¦ ? retq
[...]
¦ int z = add(x, y);
¦ ? callq add(int const&, int const&) [clone .isra.0]
¦ sum += z;
29.83 ¦ add %eax,%ebx
_
Il semble que nous nous en tenions à l'appel de add()
dans le cas lent.
J'ai examiné tout que _perf -e
_ peut cracher sur ma machine; pas seulement les statistiques données ci-dessus.
Pour le même exécutable, le _stalled-cycles-frontend
_ montre une corrélation linéaire avec le temps d'exécution; Je n'ai rien remarqué d'autre qui puisse corréler aussi clairement. (Comparer _stalled-cycles-frontend
_ pour différents exécutables n'a pas de sens pour moi.)
J'ai inclus les données manquantes de la mémoire cache car elles apparaissaient dans le premier commentaire. J'ai examiné tous les échecs de mémoire cache qui peuvent être mesurés sur ma machine par perf
, pas seulement ceux indiqués ci-dessus. Les erreurs de cache sont très très bruyantes et ne montrent aucune corrélation avec les temps d'exécution.
Mon collègue m'a aidé à trouver une réponse plausible à ma question. Il a remarqué l'importance de la limite de 256 octets. Il n'est pas enregistré ici et m'a encouragé à poster la réponse moi-même (et à prendre toute la gloire).
Réponse courte:
Est-ce le rembourrage qui est le coupable dans ce cas? Pourquoi et comment
Tout se résume à l'alignement. Les alignements peuvent avoir un impact significatif sur les performances, c'est pourquoi nous avons les drapeaux -falign-*
dans le premier endroit.
J'ai soumis n rapport de bogue (fictif?) Aux développeurs de gcc . Il s’avère que le comportement par défaut est "nous alignons les boucles sur 8 octets par défaut, mais essayons de l’aligner sur 16 octets si nous n’avons pas besoin de remplir plus de 10 octets." Apparemment, ce paramètre par défaut n’est pas le meilleur choix dans ce cas particulier et sur ma machine. Clang 3.4 (trunk) avec -O3
effectue l'alignement approprié et le code généré ne présente pas ce comportement étrange.
Bien sûr, si un alignement inapproprié est effectué, cela aggrave les choses. Un alignement inutile/incorrect ne fait que manger des octets sans raison et peut potentiellement augmenter les erreurs de cache. , etc.
Le bruit qu’il fait rend pratiquement impossible la micro-optimisation du minutage.
Comment puis-je m'assurer que ces alignements chanceux/malchanceux accidentels n'interfèrent pas lorsque je fais des micro-optimisations (sans rapport avec l'alignement de pile) sur des codes sources C ou C++?
Simplement en disant à gcc de faire le bon alignement:
g++ -O2 -falign-functions=16 -falign-loops=16
Réponse longue:
Le code fonctionnera plus lentement si:
une limite XX
d'octets coupe add()
au milieu (XX
dépend de la machine).
si l'appel de add()
doit sauter par-dessus une limite d'octet XX
et que la cible n'est pas alignée.
si add()
n'est pas aligné.
si la boucle n'est pas alignée.
Les 2 premiers sont magnifiquement visibles sur les codes et résultats Marat Dukhan a gentiment posté . Dans ce cas, gcc-4.8.1 -Os
(s'exécute en 0,994 secondes):
00000000004004fd <_ZL3addRKiS0_.isra.0>:
4004fd: 8d 04 37 lea eax,[rdi+rsi*1]
400500: c3
une limite de 256 octets coupe add()
juste au milieu et ni add()
ni la boucle ne sont alignés. Surprise, surprise, c'est le cas le plus lent!
Dans le cas où gcc-4.7.3 -Os
(s’exécute en 0,822 seconde), la limite de 256 octets coupe uniquement dans une section froide (mais ni la boucle, ni add()
ne sont coupés):
00000000004004fa <_ZL3addRKiS0_.isra.0>:
4004fa: 8d 04 37 lea eax,[rdi+rsi*1]
4004fd: c3 ret
[...]
40051a: e8 db ff ff ff call 4004fa <_ZL3addRKiS0_.isra.0>
Rien n'est aligné et l'appel à add()
doit franchir la limite de 256 octets. Ce code est le deuxième plus lent.
Dans le cas où gcc-4.6.4 -Os
(s’exécute en 0,709 seconde), bien que rien ne soit aligné, l’appel de add()
ne doit pas nécessairement franchir la limite de 256 octets et la cible se trouve à 32 octets exactement:
4004f2: e8 db ff ff ff call 4004d2 <_ZL3addRKiS0_.isra.0>
4004f7: 01 c3 add ebx,eax
4004f9: ff cd dec ebp
4004fb: 75 ec jne 4004e9 <_ZL4workii+0x13>
C'est le plus rapide des trois. Pourquoi la limite de 256 octets est-elle spécifique sur sa machine, je lui laisse le soin de la comprendre. Je n'ai pas un tel processeur.
Maintenant, sur ma machine, je n'ai pas cet effet de frontière de 256 octets. Seules la fonction et l'alignement de la boucle entrent en jeu sur ma machine. Si je réussis g++ -O2 -falign-functions=16 -falign-loops=16
alors tout est rentré dans l'ordre: j'obtiens toujours le cas le plus rapide et le temps n'a plus d'importance pour le drapeau -fno-omit-frame-pointer
. Je peux passer g++ -O2 -falign-functions=32 -falign-loops=32
ou n'importe quel multiple de 16, le code n'est pas sensible à cela non plus.
J'ai remarqué pour la première fois en 2009 que gcc (du moins sur mes projets et sur mes machines) avait tendance à générer un code sensiblement plus rapide si j'optimisais la taille (-Os) au lieu de la vitesse (-O2 ou -O3) et je me demandais depuis pourquoi.
Une explication probable est que j'avais des points chauds sensibles à l'alignement, tout comme celui de cet exemple. En jouant avec les drapeaux (en passant -Os
au lieu de -O2
), ces points chauds ont été alignés de manière chanceuse par accident et le code est devenu plus rapide. Cela n’a rien à voir avec l’optimisation de la taille: c’est par hasard que les zones sensibles se sont mieux alignées. À partir de maintenant, je vérifierai les effets de alignement sur mes projets.
Oh, et encore une chose. Comment de tels points chauds peuvent-ils survenir, comme celui présenté dans l'exemple? Comment l'inline d'une telle fonction minuscule telle que add()
peut-elle échouer?
Considère ceci:
// add.cpp
int add(const int& x, const int& y) {
return x + y;
}
et dans un fichier séparé:
// main.cpp
int add(const int& x, const int& y);
const int LOOP_BOUND = 200000000;
__attribute__((noinline))
static int work(int xval, int yval) {
int sum(0);
for (int i=0; i<LOOP_BOUND; ++i) {
int x(xval+sum);
int y(yval+sum);
int z = add(x, y);
sum += z;
}
return sum;
}
int main(int , char* argv[]) {
int result = work(*argv[1], *argv[2]);
return result;
}
et compilé comme suit: g++ -O2 add.cpp main.cpp
.
gcc ne sera pas en ligne add()
!
C'est tout, il est si facile de créer involontairement des points chauds comme celui du PO. Bien sûr, c'est en partie de ma faute: gcc est un excellent compilateur. Si compilez ce qui précède comme: g++ -O2 -flto add.cpp main.cpp
, c'est-à-dire, si j'effectue une optimisation du temps de liaison, le code est exécuté en 0.19!
(L'insertion est désactivée artificiellement dans l'OP, par conséquent, le code dans l'OP était 2x plus lent).
Par défaut, les compilateurs optimisent le processeur "moyen". Étant donné que différents processeurs préfèrent des séquences d'instructions différentes, les optimisations du compilateur activées par -O2
peuvent être avantageuses pour le processeur moyen, mais réduisent les performances de votre processeur (il en va de même pour -Os
). Si vous essayez le même exemple sur différents processeurs, vous constaterez que certains bénéficient de -O2
tandis que d'autres sont plus favorables aux optimisations de -Os
.
Voici les résultats de time ./test 0 0
sur plusieurs processeurs (le temps utilisateur indiqué):
Processor (System-on-Chip) Compiler Time (-O2) Time (-Os) Fastest
AMD Opteron 8350 gcc-4.8.1 0.704s 0.896s -O2
AMD FX-6300 gcc-4.8.1 0.392s 0.340s -Os
AMD E2-1800 gcc-4.7.2 0.740s 0.832s -O2
Intel Xeon E5405 gcc-4.8.1 0.603s 0.804s -O2
Intel Xeon E5-2603 gcc-4.4.7 1.121s 1.122s -
Intel Core i3-3217U gcc-4.6.4 0.709s 0.709s -
Intel Core i3-3217U gcc-4.7.3 0.708s 0.822s -O2
Intel Core i3-3217U gcc-4.8.1 0.708s 0.944s -O2
Intel Core i7-4770K gcc-4.8.1 0.296s 0.288s -Os
Intel Atom 330 gcc-4.8.1 2.003s 2.007s -O2
ARM 1176JZF-S (Broadcom BCM2835) gcc-4.6.3 3.470s 3.480s -O2
ARM Cortex-A8 (TI OMAP DM3730) gcc-4.6.3 2.727s 2.727s -
ARM Cortex-A9 (TI OMAP 4460) gcc-4.6.3 1.648s 1.648s -
ARM Cortex-A9 (Samsung Exynos 4412) gcc-4.6.3 1.250s 1.250s -
ARM Cortex-A15 (Samsung Exynos 5250) gcc-4.7.2 0.700s 0.700s -
Qualcomm Snapdragon APQ8060A gcc-4.8 1.53s 1.52s -Os
Dans certains cas, vous pouvez atténuer les effets d'optimisations défavorables en demandant à gcc
de l'optimiser pour votre processeur particulier (à l'aide des options -mtune=native
ou -march=native
):
Processor Compiler Time (-O2 -mtune=native) Time (-Os -mtune=native)
AMD FX-6300 gcc-4.8.1 0.340s 0.340s
AMD E2-1800 gcc-4.7.2 0.740s 0.832s
Intel Xeon E5405 gcc-4.8.1 0.603s 0.803s
Intel Core i7-4770K gcc-4.8.1 0.296s 0.288s
Mise à jour: sur le Core i3 basé sur Ivy Bridge, trois versions de gcc
(4.6.4
, 4.7.3
et 4.8.1
) produisent des binaires avec des performances significativement différentes, mais le code d'assemblage n'a variations subtiles. Jusqu'ici, je n'ai aucune explication de ce fait.
Assemblage de gcc-4.6.4 -Os
(s'exécute en 0.709 secondes):
00000000004004d2 <_ZL3addRKiS0_.isra.0>:
4004d2: 8d 04 37 lea eax,[rdi+rsi*1]
4004d5: c3 ret
00000000004004d6 <_ZL4workii>:
4004d6: 41 55 Push r13
4004d8: 41 89 fd mov r13d,edi
4004db: 41 54 Push r12
4004dd: 41 89 f4 mov r12d,esi
4004e0: 55 Push rbp
4004e1: bd 00 c2 eb 0b mov ebp,0xbebc200
4004e6: 53 Push rbx
4004e7: 31 db xor ebx,ebx
4004e9: 41 8d 34 1c lea esi,[r12+rbx*1]
4004ed: 41 8d 7c 1d 00 lea edi,[r13+rbx*1+0x0]
4004f2: e8 db ff ff ff call 4004d2 <_ZL3addRKiS0_.isra.0>
4004f7: 01 c3 add ebx,eax
4004f9: ff cd dec ebp
4004fb: 75 ec jne 4004e9 <_ZL4workii+0x13>
4004fd: 89 d8 mov eax,ebx
4004ff: 5b pop rbx
400500: 5d pop rbp
400501: 41 5c pop r12
400503: 41 5d pop r13
400505: c3 ret
Assemblage de gcc-4.7.3 -Os
(s'exécute en 0.822 secondes):
00000000004004fa <_ZL3addRKiS0_.isra.0>:
4004fa: 8d 04 37 lea eax,[rdi+rsi*1]
4004fd: c3 ret
00000000004004fe <_ZL4workii>:
4004fe: 41 55 Push r13
400500: 41 89 f5 mov r13d,esi
400503: 41 54 Push r12
400505: 41 89 fc mov r12d,edi
400508: 55 Push rbp
400509: bd 00 c2 eb 0b mov ebp,0xbebc200
40050e: 53 Push rbx
40050f: 31 db xor ebx,ebx
400511: 41 8d 74 1d 00 lea esi,[r13+rbx*1+0x0]
400516: 41 8d 3c 1c lea edi,[r12+rbx*1]
40051a: e8 db ff ff ff call 4004fa <_ZL3addRKiS0_.isra.0>
40051f: 01 c3 add ebx,eax
400521: ff cd dec ebp
400523: 75 ec jne 400511 <_ZL4workii+0x13>
400525: 89 d8 mov eax,ebx
400527: 5b pop rbx
400528: 5d pop rbp
400529: 41 5c pop r12
40052b: 41 5d pop r13
40052d: c3 ret
Assemblage de gcc-4.8.1 -Os
(s'exécute en 0.994 secondes):
00000000004004fd <_ZL3addRKiS0_.isra.0>:
4004fd: 8d 04 37 lea eax,[rdi+rsi*1]
400500: c3 ret
0000000000400501 <_ZL4workii>:
400501: 41 55 Push r13
400503: 41 89 f5 mov r13d,esi
400506: 41 54 Push r12
400508: 41 89 fc mov r12d,edi
40050b: 55 Push rbp
40050c: bd 00 c2 eb 0b mov ebp,0xbebc200
400511: 53 Push rbx
400512: 31 db xor ebx,ebx
400514: 41 8d 74 1d 00 lea esi,[r13+rbx*1+0x0]
400519: 41 8d 3c 1c lea edi,[r12+rbx*1]
40051d: e8 db ff ff ff call 4004fd <_ZL3addRKiS0_.isra.0>
400522: 01 c3 add ebx,eax
400524: ff cd dec ebp
400526: 75 ec jne 400514 <_ZL4workii+0x13>
400528: 89 d8 mov eax,ebx
40052a: 5b pop rbx
40052b: 5d pop rbp
40052c: 41 5c pop r12
40052e: 41 5d pop r13
400530: c3 ret
J'ajoute ce post-accept pour souligner que les effets de l'alignement sur les performances globales des programmes - y compris les grands - ont été étudiés. Par exemple, cet article (et une version de celui-ci est également apparue dans le MCCA) montre que les modifications apportées à l'ordre des liens et à la taille de l'environnement du système d'exploitation suffisaient à elles seules à améliorer considérablement les performances. Ils attribuent cela à l'alignement des "boucles dynamiques".
Cet article, intitulé "Produire des données erronées sans rien faire de mal de manière évidente!" indique que les biais expérimentaux involontaires dus à des différences presque incontrôlables dans les environnements d'exécution de programmes vident probablement de nombreux résultats de référence.
Je pense que vous rencontrez un angle différent sur la même observation.
Pour le code critique en termes de performances, il s'agit d'un très bon argument pour les systèmes qui évaluent l'environnement au moment de l'installation ou de l'exécution et choisissent le meilleur emplacement local parmi les versions optimisées différemment des routines clés.
Je pense que vous pouvez obtenir le même résultat que ce que vous avez fait:
J'ai saisi l'Assembly pour -O2 et fusionné toutes ses différences dans l'Assembly pour -Os, à l'exception des lignes .p2align:
… En utilisant -O2 -falign-functions=1 -falign-jumps=1 -falign-loops=1 -falign-labels=1
. J'ai tout compilé avec ces options, qui étaient plus rapides que plain -O2
à chaque fois que je me donnais la peine de mesurer, depuis 15 ans.
Aussi, pour un contexte complètement différent (y compris un compilateur différent), j'ai remarqué que la situation est similaire : l'option supposée "optimiser la taille du code plutôt que la vitesse" optimise la taille et la vitesse du code.
Si je devine bien, ce sont des rembourrages pour l'alignement de la pile.
Non, cela n’a rien à voir avec la pile, les NOP générés par défaut et les options -falign - * = 1 empêchent d’aligner le code.
Selon Pourquoi le pavé GCC fonctionne-t-il avec des NOP? c'est fait dans l'espoir que le code sera exécuté plus rapidement, mais apparemment, cette optimisation s'est retournée contre moi.
Est-ce le rembourrage qui est le coupable dans ce cas? Pourquoi et comment
Il est très probable que le rembourrage soit le coupable. La raison pour laquelle le remplissage est jugé nécessaire et utile dans certains cas est que le code est généralement extrait sur des lignes de 16 octets (voir ressources d'optimisation d'Agner Fog pour les détails, qui varient en fonction du modèle de processeur). Aligner une fonction, une boucle ou une étiquette sur une limite de 16 octets signifie qu'il est statistiquement plus probable qu'un minimum de lignes soit nécessaire pour contenir la fonction ou la boucle. Évidemment, cela se retourne contre-sens car ces NOP réduisent la densité du code et donc l'efficacité du cache. Dans le cas des boucles et des libellés, il se peut même que les NOP doivent être exécutés une fois (lorsque l'exécution arrive normalement à la boucle/au libellé, par opposition à un saut).
Si votre programme est délimité par le cache CODE L1, l'optimisation de la taille commence soudainement à porter ses fruits.
La dernière fois que j'ai vérifié, le compilateur n'est pas assez intelligent pour comprendre cela dans tous les cas.
Dans votre cas, -O3 génère probablement suffisamment de code pour deux lignes de cache, mais -Os tient dans une ligne de cache.
Je ne suis nullement un expert dans ce domaine, mais il me semble me rappeler que les processeurs modernes sont assez sensibles lorsqu'il s'agit de prédiction de branche . Les algorithmes utilisés pour prédire les branches sont (ou du moins remontés à l'époque où j'écrivais du code assembleur) basés sur plusieurs propriétés du code, notamment la distance d'une cible et la direction.
Le scénario qui me vient à l’esprit est de petites boucles. Lorsque la branche effectuait un retour en arrière et que la distance n'était pas trop grande, la prédicition de la branche optimisait pour ce cas car toutes les petites boucles étaient effectuées de cette façon. Les mêmes règles peuvent entrer en jeu lorsque vous permutez l'emplacement de add
et work
dans le code généré ou lorsque la position des deux change légèrement.
Cela dit, je ne sais pas comment vérifier cela et je voulais simplement vous faire savoir que cela pourrait être quelque chose que vous souhaitez examiner.