web-dev-qa-db-fra.com

Pourquoi l'introduction d'instructions MOV inutiles accélèrerait-elle une boucle étroite dans l'assemblage x86_64?

Contexte:

Tout en optimisant du code Pascal avec le langage d'assemblage intégré, j'ai remarqué une instruction inutile MOV et je l'ai supprimée.

À ma grande surprise, la suppression de l’instruction inutile a fait que mon programme a ralenti.

J'ai trouvé que , ajouter des instructions arbitraires et inutiles MOV augmentait encore les performances .

L’effet est erratique et change en fonction de l’ordre d’exécution: les mêmes instructions d’ordure transposées vers le haut ou le bas d’une seule ligne produire un ralentissement .

Je comprends que le processeur effectue toutes sortes d’optimisations et de rationalisations, mais cela ressemble plus à de la magie noire.

Les données:

Une version de mon code compile conditionnellement trois opérations indésirables au milieu d'une boucle qui exécute 2**20==1048576 fois. (Le programme environnant calcule simplement SHA-256 hashes).

Les résultats sur ma machine plutôt ancienne (Intel 642 à 2,13 GHz) (Core (TM) 2):

avg time (ms) with -dJUNKOPS: 1822.84 ms
avg time (ms) without:        1836.44 ms

Les programmes ont été exécutés 25 fois en boucle, l'ordre d'exécution étant modifié de manière aléatoire à chaque fois.

Extrait:

{$asmmode intel}
procedure example_junkop_in_sha256;
  var s1, t2 : uint32;
  begin
    // Here are parts of the SHA-256 algorithm, in Pascal:
    // s0 {r10d} := ror(a, 2) xor ror(a, 13) xor ror(a, 22)
    // s1 {r11d} := ror(e, 6) xor ror(e, 11) xor ror(e, 25)
    // Here is how I translated them (side by side to show symmetry):
  asm
    MOV r8d, a                 ; MOV r9d, e
    ROR r8d, 2                 ; ROR r9d, 6
    MOV r10d, r8d              ; MOV r11d, r9d
    ROR r8d, 11    {13 total}  ; ROR r9d, 5     {11 total}
    XOR r10d, r8d              ; XOR r11d, r9d
    ROR r8d, 9     {22 total}  ; ROR r9d, 14    {25 total}
    XOR r10d, r8d              ; XOR r11d, r9d

    // Here is the extraneous operation that I removed, causing a speedup
    // s1 is the uint32 variable declared at the start of the Pascal code.
    //
    // I had cleaned up the code, so I no longer needed this variable, and 
    // could just leave the value sitting in the r11d register until I needed
    // it again later.
    //
    // Since copying to RAM seemed like a waste, I removed the instruction, 
    // only to discover that the code ran slower without it.
    {$IFDEF JUNKOPS}
    MOV s1,  r11d
    {$ENDIF}

    // The next part of the code just moves on to another part of SHA-256,
    // maj { r12d } := (a and b) xor (a and c) xor (b and c)
    mov r8d,  a
    mov r9d,  b
    mov r13d, r9d // Set aside a copy of b
    and r9d,  r8d

    mov r12d, c
    and r8d, r12d  { a and c }
    xor r9d, r8d

    and r12d, r13d { c and b }
    xor r12d, r9d

    // Copying the calculated value to the same s1 variable is another speedup.
    // As far as I can tell, it doesn't actually matter what register is copied,
    // but moving this line up or down makes a huge difference.
    {$IFDEF JUNKOPS}
    MOV s1,  r9d // after mov r12d, c
    {$ENDIF}

    // And here is where the two calculated values above are actually used:
    // T2 {r12d} := S0 {r10d} + Maj {r12d};
    ADD r12d, r10d
    MOV T2, r12d

  end
end;

Essayez vous-même:

Le code est en ligne sur GitHub si vous voulez l'essayer vous-même.

Mes questions:

  • Pourquoi copier inutilement le contenu d'un registre sur RAM augmenterait-il jamais les performances?
  • Pourquoi la même instruction inutile permettrait-elle d’accélérer certaines lignes et de ralentir d’autres?
  • Ce comportement peut-il être exploité de manière prévisible par un compilateur?
215
tangentstorm

La cause la plus probable de l’amélioration de la vitesse est la suivante:

  • l'insertion d'un MOV décale les instructions suivantes vers différentes adresses de mémoire
  • une de ces instructions déplacées était une branche conditionnelle importante
  • cette branche était mal prédite en raison d'un repliement du spectre dans la table de prédiction de branche
  • le déplacement de la branche a éliminé le pseudonyme et a permis de prédire correctement la branche

Votre Core2 ne conserve pas d’historique distinct pour chaque saut conditionnel. Au lieu de cela, il conserve un historique partagé de tous les sauts conditionnels. Un inconvénient de prévision de branche globale est que l'historique est dilué par des informations non pertinentes si les différents sauts conditionnels ne sont pas corrélés.

Ce petit tutoriel de prédiction de branche montre le fonctionnement des tampons de prédiction de branche. La mémoire cache est indexée par la partie inférieure de l'adresse de l'instruction de branchement. Cela fonctionne bien à moins que deux branches importantes non corrélées partagent les mêmes bits inférieurs. Dans ce cas, vous vous retrouvez avec un aliasing qui provoque de nombreuses branches imprévisibles (ce qui bloque le pipeline d'instructions et ralentit votre programme).

Si vous voulez comprendre comment les erreurs de prédiction de branche affectent les performances, jetez un oeil à cette excellente réponse: https://stackoverflow.com/a/11227902/100164

Les compilateurs ne disposent généralement pas d'assez d'informations pour savoir quelles branches seront associées à un alias et si ces alias seront significatifs. Toutefois, ces informations peuvent être déterminées au moment de l'exécution à l'aide d'outils tels que Cachegrind et VTune .

140
Raymond Hettinger

Vous voudrez peut-être lire http://research.google.com/pubs/pub37077.html

TL; DR: l'insertion aléatoire d'instructions nop dans des programmes peut facilement augmenter les performances de 5% ou plus, et non, les compilateurs ne peuvent pas l'exploiter facilement. Il s'agit généralement d'une combinaison de prédicteur de branche et de comportement de cache, mais il peut tout aussi bien s'agir, par exemple. un décrochage de la station de réservation (même s’il n’existe aucune chaîne de dépendance brisée ou une surabonnement évident de ressources).

79
Jonas Maebe

Je crois que dans les processeurs modernes, les instructions d'assemblage, tout en étant la dernière couche visible d'un programmeur pour fournir des instructions d'exécution à un processeur, sont en réalité plusieurs couches à partir de l'exécution réelle par le processeur.

Les processeurs modernes sont RISC / CISC hybrides qui traduisent les instructions CISC x86 en instructions internes qui sont plus RISC dans le comportement. En outre, il existe des analyseurs d’exécution, des prédicteurs de branche, la "fusion de micro-opérations" d’Intel qui tentent de regrouper les instructions en lots plus importants de travaux simultanés (un peu comme le VLIW / Itanium titanic). Il y a même des limites de cache qui pourraient rendre le code plus rapide à l'exécution, même s'il est plus volumineux (peut-être que le contrôleur de cache le positionnera plus intelligemment ou le conservera plus longtemps).

CISC a toujours eu une couche de traduction d'assemblage vers un microcode, mais le fait est qu'avec les processeurs modernes, les choses sont beaucoup plus compliquées. Avec tout le potentiel supplémentaire des transistors dans les usines de fabrication de semi-conducteurs modernes, les CPU peuvent probablement appliquer plusieurs approches d'optimisation en parallèle, puis sélectionner celle qui offre la meilleure accélération. Les instructions supplémentaires peuvent forcer le processeur à utiliser un chemin d’optimisation meilleur que les autres.

L'effet des instructions supplémentaires dépend probablement du modèle, de la génération et du fabricant de la CPU, et n'est probablement pas prévisible. Optimiser le langage Assembly de cette manière nécessiterait une exécution sur de nombreuses générations d'architecture de processeur, en utilisant éventuellement des chemins d'exécution spécifiques au processeur, et ne serait souhaitable que pour des sections de code vraiment importantes, bien que si vous exécutez Assembly, vous le savez probablement déjà.

14
cowarldlydragon