l'assembleur  [livre #4602]

par  pierre maurette




Optimisation & chronométrage

Soyons clairs, optimisation dans un ouvrage traitant de langage assembleur signifie optimisation en vitesse de traitement. Optimiser en fiabilité et en volume de données relève davantage du domaine de l'algorithmique. La réduction du volume du code constitue aujourd'hui un domaine particulier, appliqué souvent aux microcontrôleurs, dont nous ne nous occuperons pas spécifiquement.

Soit le problème : optimiser le calcul de Pi à 5 décimales. Il suffit de poser Pi = 3,14159. En excluant cette approche un peu triviale mais pas toujours dénuée de sens, il existe au moins trois voies d’optimisation :

Celles qui touchent l’algorithme, souvent un bien grand mot pour désigner le bon sens. Ne coder dans une boucle que ce qui est nécessaire, au besoin perdre un peu de temps avant la boucle pour en améliorer le fonctionnement.

Les améliorations par l’assembleur, c’est-à-dire la supposition qu’un code réécrit dans ce langage sera meilleur que celui produit par votre compilateur.

Les améliorations du code assembleur. C’est là que les choses se compliquent.

Pour la première catégorie, prenez l’habitude de vous demander en permanence où le code passe le plus de temps. Dans les parties non critiques, privilégiez un code sûr et lisible. Les performances des machines le permettent. Préférez de gros accès disques rares à des petits fréquents. Si vous connaissez bien la configuration de la machine cible, vous pourrez aller un peu plus loin en adaptant la taille des accès disques aux mémoires physiques et virtuelles. Cette opération est de l’ordre du bon sens, de l'expérience et même parfois de l'expérimentation. Attention, dans le cas d'une application réelle, de nombreux facteurs peuvent jouer sur un temps de traitement, en particulier les mises en cache effectuées par Windows de façon transparente pour l'utilisateur. Dans l'Explorateur, lancez une recherche sur la totalité d'une grosse partition, par exemple pour un fichier improbable.fic . Cette opération peut prendre d’une dizaine à quelques dizaines de secondes. Fermez l'Explorateur, faites un calcul avec la Calculatrice, ouvrez l'Explorateur à nouveau et lancez une recherche de même type, sur bonjour.abc par exemple, sur la même partition. Le résultat va être obtenu en moins d'une seconde très souvent. Il est clair que Windows utilise la mise en cache du dossier de la partition. Parler de vitesse de traitement dans ces conditions n'a plus vraiment de sens. Ajoutons que cette mise en cache bénéficiera à vos propres applications, même DOS, tournant sous Windows, ce qui pourra parfois poser problème : comment par exemple vérifier par relecture et comparaison qu'un fichier a été correctement sauvegardé sur disquette, si la relecture est remplacée d'autorité par une lecture du cache ?

Le travail d'optimisation est en quelque sorte une spécialité, qui devra se faire à l'aide de documents spécialisés. Vous trouverez sur le CD-Rom des liens vers les divers guides d'optimisation proposés par Intel et AMD. Il existe dans la livraison de MASM32 un fichier agner.hlp qui est une bonne référence dans le domaine.

Sur Internet, le groupe borland.public.delphi.language.basm travaille sur un concours FastCode, certainement intéressant. Si vous ne trouvez pas ce groupe sur votre serveur, Borland met à la disposition du public un serveur de news newsgroups.borland.com .

Pour ces raisons, il est clair qu'un guide d'optimisation n'a pas sa place dans un ouvrage généraliste. De plus, ce sport n'est pas le passe-temps favori de l'auteur. Donc, fidèles à la démarche consistant à mettre le lecteur en situation de progresser de lui-même, nous nous contenterons de:

  Une présentation de la manière de chronométrer des parties de code. Des outils logiciels seront proposés pour MASM et pour Delphi (et donc C++Builder et certainement tout assembleur intégré).

  Quelques réflexions sur le sujet, accompagnées de manipulations sous Delphi. Le code permettant l'évaluation des performances sera accepté tel quel.

  Quelques exemples, dans les pages qui suivent ainsi que sur le CD-Rom, dont une façon de mesurer la fréquence du processeur.

Nous allons débuter par quelques expérimentations, après avoir traité du code de chronométrage. Celui que nous avons intégré dans les exemples de l'application Opti du CD-Rom est suffisant pour notre propos. Même sans avoir installé Delphi, vous pouvez faire tourner opti.exe et étudier opti_main.pas dans un éditeur quelconque. Les données chiffrées sont relevées sur une machine équipée d'un Athlon XP 1700+, horloge à 1,466 GHz. Attention, les résultats sur une autre machine et même parfois à un autre moment peuvent être différents voire inversés. La procédure contenant l'exemple traité est donnée par le nom d'un bouton, sous la forme (Opti1).

 

11.1 Chronométrage du code

Il fut un temps où les tableaux de références du jeu d’instructions donnaient pour chacune d’elles un nombre de cycles d’horloge nécessaires à son exécution. Parfois, il s’agissait de deux valeurs, selon le résultat d’un test. Cela permettait de générer des délais très précis ; il était parfaitement possible de contrôler grossièrement une liaison série (RS 232 ou MIDI) sans circuit spécialisé. Ce temps est révolu. Si vous enchaînez 40  NOP pour perdre un peu de temps, vous serez bien déçu : le microprocesseur semble les omettre purement et simplement. Le temps requis par une instruction dépend aujourd’hui totalement du contexte. Il n’est même plus certain que ces performances soient reproductibles. Si, un jour, il lui est alloué des données bien alignées, avec rien qui dépasse, et le lendemain des données décalées, les résultats ne seront pas les mêmes. Ce sera, dans ce cas, à vous d’être attentifs à cette qualité de la donnée.

Il va donc nous falloir mesurer, en termes de cycles d’horloge ou de secondes, le temps passé par le programme dans une certaine partie de son code.

Nous voulons mesurer ce code sous Windows, dans sa version du jour en vente chez votre marchand de versions habituel, pour nous et aujourd'hui 98, 2000 ou XP. Une partie des difficultés vient de cet environnement. Nous allons être amenés à accéder à des ressources particulières, sans pour autant passer en programmation système.

11.1.1 L'instruction RDTSC et le registre de chronométrage TSC

En fait, le problème de l'évaluation des performances peut se résoudre de façon très fine, à l'aide d'un certain nombre d'outils ; mais ces outils ne sont pas tous accessibles au niveau application. La plupart d'entre eux font appel à des MSR (registres spécifiques au modèle) et ces registres et les instructions associées ne sont accessibles qu’en ring 0, donc à l'aide d'un pilote de périphérique.

Ces compteurs sont lus par l'instruction  RDPMC  (ReaD Performance Monitoring Counter) ou plus généralement lus et écrits par  RDMSR et WRMSR . Ils permettent de contrôler des événements choisis parmi une très longue liste, qui comprend le simple comptage de coups d’horloge, mais également par exemple celui des saturations intervenant lors de calculs MMX. Des masquages peuvent être appliqués à ces comptages. Avec ces compteurs et instructions, il sera possible de contrôler le temps passé par notre tâche à effectuer une action. La liste d’événements permet de contrôler finement le comportement des caches et pipelines. Elle est à la base de programmes d’optimisation, également connus sous le nom de profilers .

Tout ce qui concerne les registres MSR nous est difficilement accessible, étant en ring 0. Nous nous tournerons vers  RDTSC  (ReaD Time Stamp Counter). Cette instruction renvoie dans EDX:EAX la valeur d'un compteur 64 bits TSC. Le compteur TSC compte imperturbablement les cycles d’horloge, avec démarrage à 0 au Reset. Il s’incrémente donc de 1 000 millions à chaque seconde sur une machine cadencée à 1 GHz. Plus précisément, il est garanti pour compter de façon régulière, à un rythme permettant le contrôle des performances, cela afin de préserver l’avenir et faire en sorte que les futures fréquences ne puissent faire saturer ce compteur. Nous verrons un peu plus loin que ce futur est du domaine de la science-fiction. Pour l’heure, c’est bien à la fréquence de l’horloge qu’il s’incrémente et c'est en fonction de cette hypothèse que nous nous situons pour la suite du chapitre.

Revenons sur cette notion de niveaux de privilèges. Toutes les instructions concernant les MSR ne sont accessibles qu'en ring 0, sauf deux : RDTSC et RDPMC , pour lesquelles cette caractéristique dépend d'un bit du CR4. Mais il existe une différence fondamentale entre les deux instructions.

RDTSC est accessible à tous les niveaux de privilèges, virtual-8086 compris, pour peu que le bit 2 (TSD) de CR4 ne l’interdise pas. RDTSC ne sera qu'exceptionnellement interdite aux programmes d'application.

Alors que RDPMC n'est normalement accessible qu'en ring 0, à moins que le bit 8 (PCE) ne l'autorise aux autres niveaux, RDPMC va être temporairement autorisée en ring 1, 2 ou 3, pour effectuer par exemple un travail au profit du niveau ring 0.

Si RDTSC , accès en lecture à TSC, est universellement utilisable, l'écriture dans le registre TSC est absolument réservée au niveau ring 0. Ainsi, plusieurs tâches peuvent simultanément utiliser le couple RDTSC/TSC, puisqu'elles ne peuvent que relever l'heure au passage. C'est donc cette instruction qui sera utilisée pour l'évaluation des performances du code utilisateur.

RDTSC est une simple lecture du registre TSC ; donc, il faudra effectuer deux lectures et une soustraction pour obtenir un résultat. Le résultat Chrono est un nombre de cycles d'horloge, très souvent suffisant pour des comparaisons en vue d'une amélioration. Pour passer à la mesure du temps, il suffit de faire : Durée (en secondes) = Chrono/Fréquence (en hertz). Nous y reviendrons en fin de chapitre.

procedure Topti_mainForm.BtnTSC1Click(Sender: TObject);
var
  Tempo, Chrono: int64;
begin
  asm
    rdtsc
    mov dword ptr Tempo + 0, eax
    mov dword ptr Tempo + 4, edx
  end; //asm
  MemoSortie.Lines.Add('Code à monitorer');
  //if(Label1.Caption <> 'Code à monitorer') then
  //Label1.Caption := 'Code à monitorer';
  asm
    rdtsc
    mov dword ptr Chrono + 0, eax
    mov dword ptr Chrono + 4, edx
  end; //asm
  Chrono := Chrono - Tempo;
  MemoSortie.Lines.Add(IntToStr(Chrono));
end;

Ce code fonctionne, sous 98, 2000 et XP. C’est une chance, puisque l’instruction mov eax, cr4 provoque une violation d’accès sous XP et 2000. Sous 98, elle semble fonctionner, mais elle renvoie n’importe quoi. Il est donc à fortiori impossible de procéder à une lecture, masquage pour forçage du bit 2, puis écriture. Ce qui est tout à fait normal, CR4 n'étant pas accessible en ring 3.

Seuls les registres EAX et EDX sont modifiés, et rappelons que l'instruction asm sous Delphi permet de modifier librement EAX, ECX et EDX.

Pour des manipulations de ce type, il est important de tester le programme à la fois sous Delphi, au travers du débogueur, et directement lancé sous Windows. Le comportement peut être différent, légèrement ou de façon plus significative.

Bien entendu, mettre un point d'arrêt au sein du code à tester va faire perdre toute signification à la valeur de Chrono . Un débogueur ou surtout un profileur adéquat pourrait s'affranchir de ce problème.

En mettant en commentaire la ligne : MemoSortie.Lines.Add('Code à monitorer'); , nous obtenons 11 cycles. Avec le code complet, le résultat est de l'ordre d’environ 380 000 cycles, ou d’environ 310 000 hors de l'EDI de Delphi. Si nous cliquons plusieurs fois sur le bouton, les chiffres augmentent doucement, pour se stabiliser au bout d'une quinzaine de coups au-dessus de 800 000 signes. Tout cela, sous 2000, parce que sous 98, c'est encore un peu différent. Le code chronométré ne fait rien d'autre que remplir un mémo. En observant la sortie, il est clair que le moment où la durée se stabilise correspond au moment où le mémo est complètement rempli et où chaque nouvelle écriture provoque un scrolling général. Pour vérifier, posons un TLabel Label1 et modifions le code pour Label1.Caption := 'Code à monitorer'; . Les durées deviennent toutes plus ou moins égales, aux alentours de 13 000 cycles, sauf la première autour de 350 000, ce qui correspond au seul moment où le libellé change, de Label1 en Code à monitorer . Pour confirmer, changeons la propriété Caption de Label1 –(dans l'inspecteur d'objets de l'EDI, avant le lancement du programme) en Code à monitorer , puis en Truc à monitorer . Nous en concluons que le composant TLabel vérifie d'abord si la nouvelle propriété Caption est différente de l'ancienne avant d'éventuellement redessiner. Et donc que :

if(Label1.Caption <> 'Code à monitorer') then
     Label1.Caption := 'Code à monitorer';

n'apporte que très peu, voire rien.

Conclusion partielle : quel que soit le langage, chronométrer des parties de code peut apporter de nombreux enseignements. Pour par exemple s'apercevoir que l'affichage est souvent très coûteux et qu'il est souvent possible de le reporter à la fin d'un traitement lourd.

La disponibilité de RDTSC et du compteur TSC lui-même dépend du modèle de processeur. En toute rigueur, il serait donc logique d'interroger à l'aide de CPUID , après avoir vérifié également que cette instruction est implémentée. CPUID doit être appelée avec EAX = 1 , le bit 4 de EDX à 1 indique alors que RDTSC est supportée. Voir CPUID pour le code. Mais généralement, RDTSC sera utilisée par le développeur durant une période limitée et sur sa propre machine dont il connaît bien le processeur, et il n'aura pas à se préoccuper de ces détails.

 

11.1.2 Limites de RDTSC

Nous avons utilisé le type int64 , une facilité de Delphi qui existe aujourd'hui dans tous les langages sérieux. Nous utilisons donc les 64 bits du compteur et leur capacité de comptage qui est, comme nous allons le voir, énorme.

Un compteur 64 bits compte de  0 à FFFFFFFFFFFFFFFFh . En décimal, cela fait 18 446 744 073 709 551 616 valeurs possibles. C'est de l'ordre de 18 milliards de milliards. Appelons MaxInt64 cette valeur, comme nous désignons par MaxInt32 la valeur FFFFFFFFh , soit 4 294 967 295 si interprété comme un entier non signé. Il en est de même pour MaxInt8 , soit 255, et MaxInt16 ou 65 535.

Rappelons le fonctionnement d'un compteur EDX:EAX : le registre EAX est incrémenté unité par unité. Quand il atteint MaxInt32 , il passe à 00000000h , et en même temps, EDX est incrémenté d'une unité. Si le compteur a été initialisé à 0, quand EDX passe de MaxInt32 à 00000000h , le compteur EDX:EAX passe de MaxInt64 à 0000000000000000h , la capacité du compteur vient d'être dépassée. C'est tout simplement le fonctionnement normal d'un compteur 64 bits, et nous aurions pu écrire la même chose du compteur AX séparé mentalement en AL et AH, ou EAX en AX et les 16 bits forts de EAX, non nommés. L'analogie avec le compteur kilométrique d'une automobile est parlante.

Pourquoi ces remarques ? Parce que, s'il n'est absolument pas insurmontable de faire du calcul 64 bits en assembleur IA32, et c'est même un excellent exercice, ce n'est pas nécessairement agréable quand il s'agit simplement d'évaluer du code. Il faudra trouver ou écrire les routines d'affichage. Nous aimerions donc savoir dans quelles circonstances nous pouvons nous contenter de EAX pour notre chronométrage.

La manipulation consiste à relever une valeur Val0 de EAX, attendre un peu puis relever la valeur Val1 de EAX. Si EAX n'est pas passé par 0 (EDX n'a pas changé), pas de problème, le nombre de cycles écoulé sera bien obtenu en effectuant la soustraction Val1 - Val0 . Si EAX est passé plusieurs fois par 0, pas de problème non plus, il faudra faire intervenir EDX dans le calcul, et nous tombons dans du calcul 64 bits. Maintenant, dernier cas de figure, imaginons que nous sachions que nous n'avons pu attendre plus que MaxInt32 cycles. Cela signifie soit que EAX n'est pas passé par 0 et que Val1 est plus grand que Val0 ou alors que EAX est passé une fois et une seule par 0, et que Val1 est plus petit que Val0 . À ce niveau, vous devrez au besoin relire les informations relatives aux arithmétiques signées et non signées, ainsi que l'instruction  SUB . Toujours est-il que, faire sub Val1, Val0 (en réalité, ce sera sub eax, Val0 ) donnera toujours en non signé le nombre de cycles écoulés. Même en position de débutant, il serait bon que ce détail soit maintenant plutôt compris qu'admis. Pour ne pas avoir à manipuler d'interminables suites de bits, faisons un test sur un octet. La valeur Val0 relevée est par exemple de 240, soit 11110000b . Nous relevons ensuite Val1 = 252 , soit 11111100b . Bien entendu, le SUB donne 00001100b , soit 12. Maintenant Val1 est relevée à 25, soit 00011001b . Le compteur a tourné, mais peu importe, posons la soustraction ; le résultat est 00101001b , soit 41 si nous oublions l'emprunt sur le dernier bit, soit 16 pour aller de 240 à 256 = 0, plus 25 pour aller de 0 à 25. C'est correct.

Le calcul devient donc très simple, mais rien de facile comme un flag ne nous permet de vérifier notre hypothèse, à savoir que le compteur EAX n'a pas tourné de plus que MaxInt32 cycles d'horloge. Seul le bon sens et un peu de calcul mental nous aideront. MaxInt32 vaut quand même un peu moins que 4 milliards et 300 millions de cycles, ce qui indépendamment de la fréquence de l'horloge et avec un peu d'habitude nous permet de supputer un très grand nombre d'instructions.

Voyons malgré tout les correspondances en temps pour quelques fréquences. Sur une machine arbitrairement à 1 GHz : il suffit de diviser MaxInt32 par 1 000 000 000, ce qui donne un peu moins de 4,3 s. Pour obtenir, toute chose égale par ailleurs, la durée au bout de laquelle EDX:EAX va reboucler, il suffit de multiplier par MaxInt32 . Pour utiliser d'autres valeurs d'horloge, une règle de trois suffit. Quelques lignes d'Excel permettent d'obtenir les valeurs suivantes (l'année est comptée à 365 jours tout rond) :

Capacités de chronométrage de RDTSC pour quelques fréquences d'horloge

Fréquence d'horloge

Durée maxi sur 32 bits

Durée maxi sur 64 bits

100 MHz

42,95 secondes

5 849 années

400 MHz

10,74 secondes

1 462 années

1 GHz

4,29 secondes

585 années

1,466 GHz (Athlon XP 1700+)

2,93 secondes

399 années

3 GHz

1,43 seconde

195 années

30 GHz (avenir à quelques années)

0,14 seconde

19 années

Nous sommes ainsi en possession de tous les éléments pour choisir selon les circonstances une stratégie d'évaluation de la vitesse de traitement dans diverses circonstances. Mais avant cela, il nous faut nous pencher sur quelques problèmes posés par l'instruction  RDTSC .

 

11.1.3 Stratégies de chronométrage

Les instructions  RDTSC et CPUID sont des instructions sans opérande, c’est-à-dire qu'elles correspondent chacune à un seul code machine : 0F31h pour RDTSC , 0FA2h pour CPUID . Il est donc très facile de les implémenter, avec ou sans l'aide de macros, pour des compilateurs ou assembleurs ne les supportant pas. Il faut simplement faire attention à l'implantation des données en mémoire (little endian). Par exemple :

    db 0Fh
    db 0a2h   //cpuid
 
    dw 0a20fh //cpuid
 
    db 0Fh
    db 31h    //rdtsc
 
    dw 310Fh  //rdtsc

 

Généralement, nous testerons de petits bouts de code isolés, repérés comme goulets d'étranglement. L'utilisation de RDTSC en 32 bits suffira souvent. Il suffit pour évaluer la nécessité d'utiliser les 64 bits d'avoir en tête les quelque 4 milliards de cycles autorisés par les 32 bits de poids faible.

Pour tester des procédures que vous ne souhaitez ou ne pouvez pas découper, les 64 bits seront alors nécessaires. De toute façon, nous verrons que ce n'est pas réellement plus complexe, surtout si votre environnement intègre un entier sur 64 bits et les fonctions de calcul et d'affichage associées.

Ensuite, le plus souvent pour tester le programme et non plus quelques instructions, vous pouvez utiliser GetTickCount() , qui renvoie un DWORD représentant le nombre de millisecondes écoulées depuis le démarrage du système.

Et, the last but not the least , le chronomètre ou la montre-bracelet, ou encore les fonctions de date et heure du langage. Ce n'est pas une plaisanterie, pour comparer et valider deux évolutions du même projet dans leur version définitive, prêtes à l'installation, c'est même la seule solution. De plus, nous sommes certains que la mesure n'intervient pas sur le résultat, ce qui n'est pas le cas des autres méthodes. Enfin, quelle serait la pertinence d'une amélioration sur un produit final que le chronomètre serait trop imprécis pour mettre en évidence ? Attention, nous parlons bien de produit final, gagner 10 µs sur une routine isolée peut être très important.

Revenons à l'utilisation de RDTSC . La documentation Intel explique que cette instruction n'est pas sérialisante (serializing). Ce qui signifie qu'elle n'attendra pas nécessairement que l'instruction précédente soit terminée pour s'exécuter. De même, l'instruction suivante pourra démarrer avant la fin de RDTSC .

Intel conseille de faire précéder et/ou suivre par une instruction sérialisante. Or, le choix en ring 3 est limité à CPUID . Cette instruction modifiant certains registres, dont EAX et EDX, il n'est pas possible de la placer juste à la suite de RDTSC . Mais celle placée juste avant rend reproductible le comportement du code qui suit.

Pour utilisation sous MASM, nous proposons les macros CHRONO_INI , CHRONO_DEB , CHRONO_FIN , CHRONO_AFF .

CHRONO_INI MACRO param1:=<0> , param2:=<5>
      mov DWORD PTR Incompressible, param1
      mov DWORD PTR Compteur, param2
      ENDM
 
CHRONO_DEB MACRO
chronodeb:
    pushad
    xor eax, eax
    cpuid
    rdtsc
    mov DWORD PTR Chrono + 0, eax
    mov DWORD PTR Chrono + 4, edx
    popad
    ENDM
 
CHRONO_FIN MACRO
    pushad
    xor eax, eax
    cpuid
    rdtsc
    sub eax, DWORD PTR Chrono + 0
    sbb edx, DWORD PTR Chrono + 4
    sub eax, DWORD PTR Incompressible
    sbb edx, 0
    mov DWORD PTR Chrono + 0, eax
    mov DWORD PTR Chrono + 4, edx
    CHRONO_AFF
    popad
    dec Compteur
    jnz chronodeb
    ENDM
 
CHRONO_AFF MACRO
    ;INVOKE udw2str, DWORD PTR Chrono + 4, ADDR message
    ;INVOKE StdOut,ADDR message
    ;INVOKE StdOut,ADDR a_la_ligne
    INVOKE udw2str, DWORD PTR Chrono + 0, ADDR message
    INVOKE StdOut,ADDR message
    INVOKE StdOut,ADDR a_la_ligne
    ENDM

 

Ces macros sont largement modifiables. Telles quelles, elles sont utilisables en code 32 bits, selon le schéma :

    CHRONO_INI 71, 4
    CHRONO_DEB
    
    ;Code à tester
    mov ecx, 10000
@@: mov eax, ecx
    dec ecx
    jnz @B
    ;Fin code à tester
    
    CHRONO_FIN

Le premier paramètre passé à CHRONO_INI sert à compenser les cycles utilisés pour le code de chronométrage. Le but est d'afficher 0 en l'absence de code à tester.

Le second est le nombre de tests à effectuer. En effet, les résultats sont variables d'un test à l'autre. Le premier test d'une série est en particulier fortement grevé par la préparation des accès mémoire quand le code testé accède à des données : mise en cache, peut-être positionnement des paramètres du gestionnaire de mémoire. Faire une moyenne n'est pas une bonne idée, c'est selon l'utilisation que vous choisirez le nombre significatif pour vous.

Les résultats sont : 20015, 20028, 20015, 19996. Si nous mettons en commentaire dans les macros les couples XOR EAX, EAX/CPUID , en tout cas dans CHRONO_DEB , ces résultats deviennent 29969, 29966, 29950, 29950. Ce phénomène, comme les autres essais de ce chapitre, n’est à considérer que pour un Athlon XP 1700+.

CHRONO_AFF affiche séparément les variations en EDX, souvent 0, et EAX. Pour voir un résultat où la variation de EDX n'est pas nulle, testez le code :

    mov ecx, -1
@@: mov eax, ecx
    dec ecx
    jnz @B

Pour une utilisation habituelle, nous avons mis en commentaire les trois premières lignes.

Pour Delphi, le code équivalent est dans le projet Opti, dans le traitement du bouton TSC.

 

11.2 Expérimentations

Nous allons, pendant quelques pages, voir deux séries de manipulations simplement investigatrices, la première série sous Delphi, la seconde sous MASM.

11.2.1 Sous Delphi

Testons le code suivant, auquel nous serions arrivés à l'issue d'une analyse brute d'un problème (Opti1) :

var
  i, j: integer;
  f0: double;
begin
  f0 := 0;
  for i := 1 to 1000 do
    for j := 1 to 10 do
      f0 := f0 + (10 * (sin(PI/(10*j)) / cos(PI/(10*j))));
end;

Ce code est lisible, inutile et il fonctionne. Il demande environ 2 740 000 cycles pour effectuer le calcul. En cliquant plusieurs fois sur le bouton, nous avons déjà une idée de la dispersion sur le résultat et sur le fait que de temps en temps ce résultat dévie plus franchement. Ce qui est bien entendu dû à la gestion des tâches par Windows.

Nous pouvons penser que ce sont les calculs trigonométriques qui consomment le plus de temps. Il est également clair que ces expressions sont calculées 10 000 fois, alors qu'elles ne prennent que 10 valeurs différentes et qu'il doit être possible de ne faire ce calcul que 10 fois (Opti2) :

var
  i, j: integer;
  f0, f1: double;
begin
  f0 := 0;
  for j := 1 to 10 do
    begin
    f1 := 10 * (sin(PI/(10*j)) / cos(PI/(10*j)));
    for i := 1 to 1000 do
      f0 := f0 + f1;
    end;
end; 

Le résultat est le même, à une nuance près, due aux arrondis. Mais cette simple modification fait passer le nombre de cycles à environ 214 000 ! Il n'est pas question de parler ici d'optimisation, mais simplement de ne pas programmer n'importe comment. Cette modification de bons sens de la structure du programme existe en assembleur pur : c'est l'algorithme. Cet exemple montre clairement que tester le fonctionnement sûr d'un code, en milieu scolaire par exemple, ne suffit absolument pas à le valider.

La dispersion est meilleure, et les écarts occasionnels moins fréquents. Logique, la boucle est 12 fois plus courte, les risques d'intervention de Windows sont d'autant plus réduits.

Le lecteur aura remarqué des parenthèses qui semblent inutiles. C'est une marotte de l'auteur. Une troisième version intègre les lignes (Opti3) :

for j := 1 to 10 do
  begin
  f1 := 10 * sin(PI/10/j) / cos(PI/10/j);
  for i := 1 to 1000 do
    f0 := f0 + f1;
  end;

Cette version fournit, et c'est heureux, le même résultat, sans modification significative de la vitesse. Il est intéressant de placer des points d'arrêt et d'étudier le code machine généré à chaque boucle externe pour le calcul de f1. La mise entre parenthèses de (10 * j) autorise le compilateur, ou lui suggère, la multiplication entière de 10 par la valeur de  i , avant d'effectuer la suite des calculs dans la FPU :

0045034A 8BC6             mov eax,esi
0045034C 03C0             add eax,eax
0045034E 8D0480           lea eax,[eax+eax*4]
00450351 8945E4           mov [ebp-$1c],eax

ESI contient la valeur de boucle  j . La deuxième ligne est une façon rapide de multiplier une valeur entière par 2. La troisième est une méthode astucieuse de multiplier une valeur entière par 5. Ces deux lignes aboutissent donc à multiplier EAX par 10 et sont un exemple d'optimisation du code assembleur, ici faite par le compilateur. Il existe sur Internet un certain nombre de sites consacrés à l'optimisation, et il est aisé d'y trouver des listes, par exemple au sujet des multiplications d'un entier par une constante entière. Nous n'avons pas parlé de modèle de processeur et ne pouvons donc en aucun cas affirmer que ce code est optimal. Tout au plus est-il clair que le compilateur a fait un certain travail dans cette direction.

Nous pourrions ici être tentés de profiter de ce code assembleur pour l'utiliser comme point de départ d'une optimisation. Mais regardons encore notre code Pascal, celui de la version (Opti2). Il est clair que la boucle interne for i := 1 to 1000 do correspond simplement à multiplier f1 par 1 000. Avec un papier et un crayon, si nécessaire, et un bagage mathématique de niveau mat(ernelle) sup, nous obtenons (Opti4) :

f0 := 0;
f1 := PI / 10;
for i := 1 to 10 do f0 := f0 + tan(f1/i);
f0 := f0 * 10000;

Le résultat est impressionnant : si la valeur numérique est très légèrement différente, l'opération est effectuée en moins de… 2 300 cycles. La deuxième ligne n'a d'autre utilité que d'effectuer le transfert de la valeur PI/10 une fois seulement au lieu de 10. Peut-être serait-il intéressant de tenter du calcul formel sur la somme des 10 tangentes. Mais à ce moment-là, puisque le résultat est constant, pourquoi ne pas coder tout simplement (Opti5) ?

f0 = 9329.79719511485;

Plus sérieusement, maintenant que nous sommes convaincus que l'optimisation commence par le bon sens et un bon codage en langage de haut niveau, voyons ce que nous pouvons améliorer dans le code précédent. Pour cela, commençons par dériver (Opti6) de (Opti4) en modifiant la boucle :

f0 := 0;
f1 := PI / 10;
for i := 1 to 10000 do f0 := f0 + tan(f1/i);
f0 := f0 * 10000;

Ceci simplement pour être plus en rapport avec la réalité : il n'est utile d'optimiser que des routines passant un certain temps à mouliner. Nous en sommes ainsi à environ 1.660.000 cycles.

Dans un premier temps, après par exemple avoir tracé le code dans la fenêtre CPU, clonons ce désassemblage (Opti7), dont voici le listing complet :

var
  dimil:  single;
  f0, f1: double;
  chrono, chrono2 : Longword;
  i: integer;
begin
  dimil := 10000;
  asm
  push ebx
 
  rdtsc
  mov chrono, eax
 
  xor eax,eax
  mov dword ptr f0 + 0,eax
  mov dword ptr f0 + 4,eax
  mov dword ptr f1 + 0,$769cf0e0
  mov dword ptr f1 + 4,$3fd41b2f
 
  //mov chrono2, eax
 
  mov ebx,$00000001
  
  @1:
  mov   i,ebx
  fild  i
  fdivr f1
  add   esp,-$0c
  fstp  tbyte ptr [esp]
  wait
  call Tan
 
  fadd f0
  fstp f0
  wait
  inc  ebx
  cmp  ebx,10001
  jnz  @1
 
  fld  f0
  fmul dimil
  fstp f0
  wait
  
  rdtsc
  sub eax, chrono
  mov chrono, eax
 
  pop ebx
  end; //asm
 
  MemoSortie.Lines.Add(IntToStr(chrono));
  MemoSortie.Lines.Add(FloatToStr(f0));
end;

Surprise, le résultat est correct, mais demande... environ 1 730 000 cycles. En fait de début d'optimisation, on peut rêver mieux. À partir de là, il faut chercher à comprendre. Après avoir vérifié dans la fenêtre CPU que le code machine est le même dans les deux cas, il est logique de tenter de voir à quel moment le temps est perdu. C'est ce qui est fait par installation de postes de chronométrage intermédiaires (Opti6') et (Opti7'), à voir sur le CD-Rom. Et là, seconde surprise, le simple fait d’ajouter une paire d'instructions de chronométrage nous ramène à environ 1 660 000 cycles ! À partir de là, il est logique de tenter d'isoler l'instruction, si elle existe, qui fait la différence entre (Opti7) et (Opti7'). Nous en arrivons rapidement à la ligne mov chrono2, eax , mise en commentaire dans le listing précédent. L'explication est que chrono2 est une variable locale, et en l'absence d'utilisation dans le code, elle est tout simplement zappée par le lieur. Et la présence ou l'absence, dans la pile, de cet entier influe sur l'alignement de  i , qui à chaque boucle est utilisé par la FPU ( fild i ). Il n'y a pas de solution simple sous Delphi pour maîtriser totalement l'alignement des variables, en particulier des variables locales. Nous pourrions déclarer  i plus grand que 32 bits, et utiliser dword ptr i + n , mais ce ne serait pas reproductible : un simple bricolage. Nous reviendrons sur ce sujet plus loin dans ce chapitre. Restons-en là pour le moment, le plus important était la méthode d'investigation.

Nous avons difficilement réussi à obtenir un résultat avec l'assembleur à peu près aussi bon que le code produit par le compilateur. Il serait donc souhaitable qu'avant de conclure cet exercice d'introduction, nous puissions gagner quelques cycles. Le code amélioré, si ce n'est optimisé, est en (Opti8). Si vous avez installé Delphi, il serait peut-être préférable d'appliquer les modifications une par une à partir de (Opti7). Voici quatre points qui peuvent être envisagés :

La première chose qui doit attirer votre attention est la présence d'un appel de la fonction VCL Tan à chaque boucle. Il sera assurément intéressant de remplacer cet appel par l'usage direct de l'instruction  FPTAN . Vous pouvez étudier la fonction  Tan en plaçant un point d'arrêt sur call Tan et en la traçant par   F7  . Ce point doit apporter l'essentiel du gain de vitesse.

Le cas des WAIT est simple à partir de la génération Pentium : à part traitement particulier des exceptions, ils semblent inutiles. En revanche, les enlever n'apporte presque rien.

Nous pouvons nous passer de EBX en tant que compteur de boucle. C'est la variable  i qui sera incrémentée et utilisée pour les calculs et les comparaisons. Il ne faut rien en attendre de grandiose, et même vérifier que l'effet n’est pas négatif.

Enfin, vous pouvez voir qu'à chaque boucle la valeur de  f0 est transférée vers puis depuis un registre de la FPU. La laisser dans ce registre pendant la durée de la boucle devrait être relativement efficace.

Ce qui nous donne finalement :

  //mov chrono2, eax
  mov  dword ptr i + 0, $00000001
  fld f0
  @1:
  fld  f1
  fild i
  fdivp
  fptan
  fstp st(0)
  faddp
  inc  i
  cmp  i,10001
  jnz  @1
  fmul dimil
  fstp f0

Pour un résultat d’environ 1 370 000 cycles, soit 17 % de gain.

Ce code peut être utilisé au cours de l'apprentissage de la FPU. Dans cette optique, nous proposons, comme pour le suivant, un schéma d'évolution des registres de la pile de cette unité.

Évolution de la pile FPU
figure 11.01 Évolution de la pile FPU [the .swf]

La boucle, donc la partie qui doit réellement être améliorée, est représentée par une flèche et une différence de teinte.

En veillant à ce que toutes les instructions de base de (Opti6) soient exécutées, car sinon le test n'aurait pas de sens, cherchons encore à gagner quelques cycles. La démarche est de bien isoler mentalement la boucle et de chercher à utiliser au maximum les instructions FPU travaillant sur des registres FPU. Par exemple, le fld f1 devrait pouvoir être mis hors boucle (en fait, pour un gain quasi nul), i  va disparaître au profit d'un registre ST, etc.

Une remarque de méthodologie : si vous envisagez deux modifications A et B, testez-les l'une après l'autre. Imaginez que A apporte un gain de 7, et B une perte de 2. Si vous les testez simultanément dans la même révision du code, vous trouverez un gain de 5, que vous jugerez satisfaisant. Alors que vous auriez pu ne retenir que A, pour un gain de7. Ceci suppose que A et B sont indépendantes, ce qui est rarement le cas, mais l'idée est là.

Voici le code proposé, dans lequel un entier tempo est utilisé. I  n'apparaît plus, c'est un registre de la FPU qui joue ce rôle, comme représenté dans le schéma d'évolution de la pile. f1  n'est pas utilisé, c'est un registre FPU initialisé à PI/10 qui joue ce rôle.

  fldpi
  mov tempo, 10
  fild tempo
  fdivp
 
  mov  tempo, 10001
  fild tempo
  fld1
  fld1
  fldz
 
  @1:
  fld  st(4) // ou fld f1
  fdiv st(0), st(2)
  fptan
  fstp st(0)
  faddp
  fincstp
  fadd st(0), st(1)
  fcomi st, st(2)
  fdecstp
  jnz  @1
 
  fmul dimil
  fstp f0
  finit

Nous sommes parvenus à environ 1 300 000 cycles. Le point important est que toute la boucle se fait dans la FPU. Un conseil pour les lecteurs peu à l'aise en programmation FPU : suivez ce code en vous aidant du schéma d'évolution de la pile FPU, de la liste des instructions FPU de la documentation Intel ou de cet ouvrage et éventuellement d'un débogueur muni d'une fenêtre FPU. Rappelons que ce n'est pas le cas de Delphi 6 Personnel, mais que vous pouvez vous procurer une version d'évaluation Pro ou Entreprise fonctionnant pendant la durée de l'apprentissage.

Évolution de la pile FPU
figure 11.02 Évolution de la pile FPU [the .swf]

Voilà, au cours de cette introduction, dans le cadre d'une amélioration des performances d'un programme écrit en langage évolué, nous avons vu que :

  Il est facile de chronométrer des séquences de code avec une précision suffisante, bien que ce chronométrage soit parfois capricieux.

  Le fonctionnement de la machine moderne semble avoir quitté le domaine des sciences exactes. Bien entendu, il n'en est rien, le fonctionnement d'un processeur particulier est parfaitement déterministe. Il est simplement difficile, sous Windows, de fixer parfaitement l'environnement logiciel des tests. Ce comportement peu évoquer l' effet papillon (voir Google) des physiciens et météorologistes.

  Il est, avant tout, important de bien concevoir son code en langage évolué ou son algorithme en pur assembleur.

  L'utilisation de code machine sans optimisation spécifique à un processeur, en remplacement du code fourni par le compilateur, demande un certain travail, dépendant de son niveau de maîtrise en assembleur.

  Le gain le plus important peut être amené par les points les plus évidents, comme le remplacement d'appels à des sous-programmes par du code en ligne.

  Dans le cas de Delphi et des instructions FPU, le gain à espérer n'est ni immense, ni négligeable. Un peu plus que 20 % dans notre exemple. C'est peu, mais d'un autre côté, comparez à un instant t le prix du processeur IA32 haut de gamme et celui du modèle 20 % plus lent.

Dans l'exemple que nous venons de voir, le compilateur utilisait la FPU, Delphi ne compilant que pour au minimum un 386. Dans le cas de technologies comme MMX ou SSE, il est possible qu'il ne les utilise pas et que le gain de l'assembleur soit plus important. Pensez également, avant de vous lancer dans le code assembleur, à faire le tour des options du compilateur, parfois très nombreuses.

 

Sous MASM

Reprenons notre code MASM là où nous l'avions laissé, c’est-à-dire à l'issue de la présentation des macros de chronométrage, pour effectuer quelques tests supplémentaires.

    REPT 20
    NOP
    ENDM

Cette séquence génère 20 fois l'instruction NOP , à l'assemblage. En faisant varier ce nombre de NOP , nous constaterons que le nombre de cycles mesuré lui est bien inférieur. Nous pouvons également voir l'influence de l'instruction de sérialisation  CPUID dans CHRONO_DEB .

Nous vous laissons expérimenter à partir du code que vous trouverez sur le CD-Rom. Il suffit de commenter ou décommenter telle ou telle partie du code et de faire toutes les expériences qui vous sembleront intéressantes. Par exemple, nous avons réservé deux tableaux T1 et T2 de 1 000 WORD et pour chaque paire d'éléments correspondants avons effectué la moyenne ((T1[n] + T1[n])+1)/2 .

      ALIGN 8
      ;ess1 DB 1, 2, 3
      T1 WORD 1000 DUP (?)
      ;ess2 DB 1, 2
      T2 WORD 1000 DUP (12345)
.......
    mov ecx, 1001
@@:
    dec ecx 
     
    mov ax, WORD PTR [T1 + ecx * 2]
    add ax, WORD PTR [T2 + ecx * 2]
    inc ax
    shr ax, 1
    
    
    mov WORD PTR [T2 + ecx * 2], ax
    cmp ecx, 0
    jnz @B

Les résultats sont à rapprocher de ceux de la même opération effectuée à l'aide de fonctions MMX, décrite dans la partie suivante. Les résultats varient énormément.

 

11.3 Alignement des données

Rappelons que, jusqu’à l’architecture x64 et les bus de 128 bits de large compris, la mémoire est accessible octet par octet. Toutefois, nous avions vu avec le 8086 que l’accès, par exemple, à un mot de 16 bits mal placé nécessitait d’accéder physiquement à deux adresses contiguës, en toute transparence pour le programmeur, mais avec dégradation des performances. C’est en légère contradiction avec ce qui a été affirmé en tête de chapitre à propos de l’immuabilité des temps de traitement des instructions. Disons que nous pensions à des microprocesseurs 8 bits, comme le 6502. C’est d’ailleurs bien à lui que nous pensions à propos de la liaison série.

Une adresse est alignée sur un certain nombre quand elle est multiple de cette adresse. Le nombre en question correspond à des octets. Nous trouvons donc couramment des alignements sur le WORD (adresses paires), sur le DWORD (adresses multiples de 4), etc. Les valeurs comme 5 ou 7 octets n’ont aucun intérêt. Les valeurs intéressantes, en bits, sont 16, 32, 64, éventuellement 80 et 128. Deux valeurs particulières : la largeur du bus physique, 64 bits sur les dernières versions, et la taille des registres, 32 bits aujourd’hui.

Nous avons déjà vu que l’alignement du code était peu important, puisqu’il entrait en file indienne dans une queue de prefetch. Il sera éventuellement préférable d’aligner, sur 2 ou 4, les adresses cibles de sauts ou de CALL . Cela s’effectue à l’aide de NOP .

Il faudra veiller à ce que la pile soit au maximum alignée sur sa largeur, 16 ou 32 bits, c’est-à-dire ne pas empiler de valeur désalignée qui ne serait pas dépilée rapidement. Donc, dans la mesure du possible, se limiter aux valeurs 8 bits et 16 bits en mode 16 bits, 8 bits et 32 bits en mode 32 bits. Rappelons que les données sur 8 bits sont empilées et dépilées complétées à 16 bits ou 32 bits, selon le mode.

Pour les données, les choses sont beaucoup plus critiques. L’alignement des tableaux de données sur la taille propre de la donnée est un minimum et pour un tableau un maximum.

MASM dispose d’un certain nombre de directives, dont surtout ALIGN  n , ou n  désigne le nombre d’octets de l’alignement. Tout va bien. Testons-en l'effet par le code :

ALIGN 8
ess1 DB 1, 2, 3
T1 WORD 1000 DUP (?)
ess2 DB 1,2
T2 WORD 1000 DUP (12345)
...
    mov ecx, 1001
@@:
    dec ecx
    movq  mm0, QWORD PTR [T1 + ecx * 2]
    pavgw mm0, QWORD PTR [T2 + ecx * 2]
 
    movq  QWORD PTR [T1+ ecx * 2], mm0
    jnz @B

Les résultats en désalignant volontairement les tableaux sont 56152, 19002, 19002, 19003. Si les tableaux sont alignés sur des adresses multiples de 8, ils deviennent : 47651, 18517, 18516, 18509. La différence est faible. Pourtant, le code passe la quasi-totalité du temps à effectuer des transferts mémoire<->registres.

Une difficulté survient à l'utilisation des assembleurs intégrés. Le jeu d’instructions SSE (et SE2) est parfaitement reconnu par BASM, et l'utiliser dans un but d'optimisation est tentant.

Or, il est très pénalisant que les données SSE ne soient pas alignées. Ce sont des données compactées de 128 bits, rappelons-le. En fait, si ces données ne sont pas alignées sur 128 bits, il est inutile d’utiliser la technologie : le temps gagné en traitement est perdu dans les transferts.

Malheureusement, Delphi exploite pour ses allocations mémoire un gestionnaire qui ne connaît pas cet alignement sur 128 bits. Il existe un gestionnaire de remplacement, MultiMM, développé par Robert Lee, et qui est disponible sur le site Borland (voir le CD-Rom). Nous l'avons utilisé pour tester un code semblable au précédent. D'abord, dans le fichier opti.dpr , nous modifions la clause uses  :

uses
  //MultiMM,
  Forms,
  opti_main in 'opti_main.pas' {opti_mainForm};

Le gestionnaire MultiMM aligne les tableaux créés sur le tas, donc déclarés au niveau application, sur la taille de leurs éléments. Nous allons déclarer :

  T1: packed array[0..1001] of int64;
  T2: packed array[0..1001] of int64;

Nous réservons 1 001 éléments afin de pouvoir désaligner un tableau virtuel en commençant la lecture à un certain offset, ce qui fait les instructions add esi, 3 et add edi, 4 . Pour aligner sur 16 octets (128 bits), il faudrait créer une structure de cette taille, par exemple quatre DWORD :

  Test = packed record
    X, Y, Z, T : Longword;
    end;

Ensuite, il suffirait de créer un tableau à partir de ces structures. Nous écrivons ensuite le code à tester :

    mov ecx, 1001
    lea esi, &T1
    //add esi, 3
    lea edi, &T2
    //add edi, 4
@debut:
    dec ecx
 
    movq  mm0, QWORD PTR [esi + ecx * 4]
    pavgw mm0, QWORD PTR [edi + ecx * 4]
 
    movq  QWORD PTR [esi + ecx * 4], mm0
 
    loopnz @debut

Nous pouvons maintenant en jouant sur les commentaires constater l'effet du gestionnaire et du désalignement volontaire. Sans gestionnaire, nous relevons 17700 et 26000, selon que la mémoire est ou non désalignée. Ces résultats sont variables. Avec le gestionnaire MultiMM, ces résultats deviennent 17400 et 23000. En réalité, les résultats sous Delphi 6 et Delphi 7 sont très variables, plutôt meilleurs sous Delphi 6. L'effet de MultiMM est difficile à mettre en évidence, mais il faut bien voir que les données étant alignées sur le DWORD, elles seront une fois sur deux alignées sur le QWORD. L'effet du désalignement sauvage est par contre bien visible.

Voilà, il ne reste plus qu’à expérimenter. Et ce n’est pas le plus désagréable.

 

11.4 Mesure de la fréquence du processeur

Puisque nous en sommes à RDTSC , nous allons coder un marronnier de l’assembleur, la mesure directe de la fréquence du processeur. Il ne s’agit pas d’une optimisation de vitesse, mais plutôt de précision.

Nous savons donc mesurer le nombre de cycles d’horloge entre deux points de programme. Pour obtenir la fréquence, il nous faut avoir accès au temps écoulé entre ces deux mêmes points. Pour cela, nous utiliserons les fonctions que Windows met à notre disposition pour justement évaluer les performances d’un code.

Fonction QueryPerformanceCounter
figure 11.03 Fonction QueryPerformanceCounter

 

Fonction QueryPerformanceFrequency
figure 11.04 Fonction QueryPerformanceFrequency

Le code est le suivant :

function get_cpu_speed:dword;assembler;
var
  hProcess, hThread, pProcess, pThread: Longint;
  PerfDeb, PerfFin: Int64;
  Clics, Perf: Longword;
asm
  push ebx
 
  lea eax, PerfCounterFreq
  push eax
  Call QueryPerformanceFrequency
{
  // Récupération handle et priorité du processus
  call GetCurrentProcess
  mov hProcess, eax
  push eax
  call GetPriorityClass
  mov pProcess, eax
 
  // Récupération handle et priorité du thread
  call GetCurrentThread
  mov hThread, eax
  push eax
  call GetThreadPriority
  mov pThread, eax
 
  // Paramétrage de la priorité du processus
  push REALTIME_PRIORITY_CLASS
  push hProcess
  call SetPriorityClass
 
  // Paramétrage de la priorité du thread
  push THREAD_PRIORITY_TIME_CRITICAL
  push hThread
  call SetThreadPriority
 }
 
  lea  edx, PerfDeb
  push edx
  call QueryPerformanceCounter
  rdtsc
  mov Clics, eax
 
  mov ecx, eax
  add ecx, 2000000000
  @boucle:
  rdtsc
  sub eax, ecx
  cmp eax, 100
  jl @boucle
 
  lea  edx, PerfFin
  push edx
  call QueryPerformanceCounter
  rdtsc
  sub eax, Clics
  mov Clics, eax
 
  mov eax, dword ptr PerfFin
  sub eax, dword ptr PerfDeb
  mov Perf, eax
 
  xor edx, edx
  mov eax, Clics
  mov ecx, PerfCounterFreq
  mul ecx
  mov ecx, Perf
  div ecx
 
  mov ebx, eax
 
{
  // Restauration de la priorité du thread
  push pThread
  push hThread
  call SetThreadPriority
 
  // Restauration de la priorité du processus
  push pProcess
  push hProcess
  call SetPriorityClass
}
  mov eax, ebx
  pop ebx
end;

Le résultat est donné sous la forme d’un grand entier. Ce sont des hertz et non des gigahertz. C’est une des raisons du bon comportement du programme (voir résultats).

Tel quel, une grande partie du programme est en commentaire. Cette partie correspond à la modification de la priorité du thread et à sa restitution en fin de travail. Il ne semble pas que cette précaution soit déterminante ; voici en effet un échantillon de résultats :

Avec priorité maximale :

1466407608, 1466407914, 1466407914, 1466407719, 1466407771
1466407725, 1466407601, 1466407519, 1466407920, 1466407619

Sans précaution de priorité :

1466407633, 1466407930, 1466407899, 1466407909, 1466408133
1466407813, 1466407692, 1466407855, 1466407921, 1466407823

Les trois lignes de code au-dessus du code de priorité initialisent une variable globale PerfCounterFreq . Cette initialisation devrait intervenir pendant l’événement OnCreate  ; ce serait plus logique.

Le cœur du code est un peu sioux, le but étant de s’adapter à la fréquence du processeur en même temps qu’elle est mesurée, pour ne travailler que sur 32 bits, mais au mieux de ces 32 bits, et ce, dans le but d’atteindre une bonne précision dans un temps raisonnable.

Ce code n’a pas été expérimenté sur d’autres machines. Donc prudence.

Envoyer un message à l'auteur

se connecter pour pouvoir écrire

Ce site est créé et maintenu (laborieusement) par Pierre Maurette
hébergé par 1 & 1