Présentation de l'Assembleur du PowerPC
Un article de GuruMed.
Le PowerPC et son assembleur
par Krabob
Voici un article visant à vous expliquer les particularités du langage machine des processeurs de la famille PowerPC, vu depuis le monde des amigas. On ne va pas rentrer dans les détails, mais on relévera ses particularités . Programmer cet assembleur est extrémement interessant, car il permet de se faire une image exacte du code qui va être exécuté, et on peut en théorie se permettre certaines bidouilles. Mais si du temps du 68000 l'assembleur était nécessaire pour obtenir les meilleures performances, ce n'est plus le cas avec le PPC, pour plusieurs raisons:
- L'ASM 68000 se justifiait pour manipuler les composants de l'amiga classique sans abstraction matérielle. Avec les amigas actuel on a interêt à utiliser uniquement le CPU et les fonctions systéme comme unique interface.
- L'ASM PowerPC a été étudié pour que les compilateurs puissent trouver le code le plus optimal de maniére simple: Pour une opération, il existe moins de possibilités différentes de l'écrire en ASM PPC qu'en ASM 68k..
- Les Compilateurs ont beaucoup évolués: trouver une écriture assembleur PPC plus rapide que celle généré par un compilateur pour un même algorithme est difficile.(IBM à fait des expériences: entre un programmeur ASM expérimenté et un compiler, le compiler gagne toujours.)
- Les optimisations que vous trouverez en ASM PPC sont souvent directement adaptable en C/C++.
- Il existe 4 générations d'exécutables Amiga PowerPC actuellement ( warpos, powerup, aos4, et morphos) utilisant deux différentes A.B.I: Les "Application Binary Interface", qui définissent entre autre le rôle des registres.PowerUP,AmigaOS4 et MorphOS utilise l'ABI System V. Un source assembleur PPC ne peut s'adresser qu'a une ABI, donc programmer assembleur c'est perdre la portabilité d'un systéme à l'autre.
Malgré tout ces points, l'assembleur peut être parfois utile. Si vous lisez l'anglais vous en aprendrez toujours plus sur les sites officiels.
Quelques spécificités du hardware
Le powerPC a été inventé par Motorola et IBM au début des années 1990, et ces 2 sociétés ont fabriqué leurs versions de chaques générations de PPC. Il s'agissait au départ de faire un processeur RISC grand public. La technologie RISC (Reduced Instruction Set) s'oppose au plus classique CISC (complex instruction set). L'idée est d'avoir moins d'instructions, ce qui permet d'accélérer le traitement. Une autre génération de PPC professionnel est dénommé "POWER"(Pipeline Orientation With Enhanced RISC) en opposition aux "PowerPC" actuels que nous utilisons. Ces deux architectures ont divergés (d'aucuns disent que le PPC n'est plus RISC.) et utilisent un jeu d'instruction sensiblement différent: Dans les catalogues d'instructions que vous trouverez, certaines ne sont valables que pour les "POWER".
En interne, les PowerPC peuvent ou non gérer des entiers 64 bits mais toujours des flottants 64 bits. L'ABI utilisé ensuite par le systéme devra en prendre compte. Pour nos Amigas les registres entiers sont en 32bits, (jusqu'à nouvel ordre.) mais les flottants sont utilisables en 32 (float) ou 64 bits (float double). Il existe des instructions pour les entiers 64bits mais l'ABI décide de leurs disponibilités. Encore plus fort: L'ABI peut aussi décider de l'ordre BigEndian/Little Endian !!! Nos amiga, comme les 68000 et les macs, sont en Big Endian. Il est possible d'imaginer un systéme PPC complet implémenté nativement en Little Endian !!
le plein de registre
Ce qui frappe le néophyte sur l'ASM PPC c'est le nombre de registres internes:
32 registres entier 32bits (GPR, General Purpose register) noté de r0 à r31 dans les sources.
32 registres flottants accessible en temps que 32 ou 64bit (float double IEEE) (FPR) noté de f0 à f31 dans les sources.
Petits rappel: les registres sont en théorie utilisé comme variables locales d'une fonction. En 680X0, il existe des registres entier d'adressage (pointeurs) et des registres entiers de caclul. Ici, les registres entiers peuvent chacun avoir l'un ou l'autre rôle. Certains registres ont un rôle strict défini par l'ABI et ne peuvent pas être utilisé pour autre chose: Ainsi, un des 32 registres entier est le pointeur de pile, et un autre sera un pointeur vers la TOC, La 'Table Of Content', les variables globales. l'ABI défini enfin quels registres doivent être gardé intact (persistant) ou pas (volatile) lors d'un retour de fonction. Donc vous aurez compris qu'une connaissance parfaite de votre ABI favorite est nécessaire à l'écriture du moindre code.
Un processeur qui Mise sur ses caches
Les instruction PowerPC assemblés font en général 4 octets, alors que sur les 68000 et Intel on a plutot 2 octets. Cela signifie qu'une fois assemblée, un code PPC peut être plus long. Nous verrons que pour d'autres raisons il est parfois plus court. Mais ce n'est pas fini:
4*32 + 8*32 = 384
Les registres du PPC pésent 384 octets. Chaque fois qu'on veut stocker un état des registres, c'est 384 octets qui doivent être écrit puis lu. Si une tâche utilise le VPU (vector proc. unit, l'altivec.) on atteint le 1 Kilo de registres internes, quand un 68000 n'avait que 64 octets !
Cela refléte la politique PowerPC de se baser sur ses gros caches internes: Les problémes de performances du Bus liés à tout ces accés est annulés par l'efficacité du cache: par exemple ,la pile d'une tâche ne va réaliser vraiment ses écritures/lectures qu'en dernier recours. (Voir article sur les optimisations avec le cache.)
La syntaxe, le systéme de mnémonique
Dans les faits,Le powerPC est un des processeurs qui posséde le plus d'instructions assembleurs bien qu'il soit RISC. Il existe une syntaxe de base définis par IBM: '#' pour les commentaires, les mnémoniques des instructions sont clairement définie, comme les 'pseudo op': les directives de compilations, ( commençant par '.' comme .align) qui sont définies aussi. Ensuite les applications d'assemblages (pasm, Powerasm sur amiga) peuvent rajouter d'autres mnémoniques et d'autres pseudo-op en interne !
Nous allons vous apprendre à lire l'assembleur PowerPC.
Si vous lâchez un assembleuriste 68000 ou Intel sur de l'asm PPC il ne va rien comprendre. En 68000 , vous aviez une instruction "move" unique pour réaliser une copie de valeur, entre registres ou mémoires. Le format de données était explicite, donné par .b (1 octet) .w (2 oct.) et .l (4 oct.) La source et la destination du move pouvait être un registre ou une mémoire, et il y avait plusieurs mode d'indexations possibles. En PowerPC vous trouverez un jeu d'instruction pour remplacer move: Notez que pour toute les mnémonique ASM PPC, le registre affécté ou lu est toujours à gauche:
- li r4,456 # étend la valeur 456 défini sur 16bit dans le registre r4 en 32bit.(load immediate)
- mr r4,r5 # copie de registre 32bit source r5 vers un registre destination r4 . (Move Register)
- lbz r5,decalage (r6) # lit valeur 8 bit a l'adresse pointé par r6+decalage dans r5 et les 24 bit de poid fort sont mis à zero. (8 bit non signé)
- lba r5,decalage (r6) # pareil mais les 24 bit fort sont étendu par le signe.(8 bit signé)
- lhz r5,decalage (r6) # lit une valeur 16bits vers 32 bits, en temps que non-signé.(load half and zero)
- lha r5,decalage (r6) # lit une valeur 16bits vers 32 bits, en temps que signé.(load half algebraic)
- lwz r5,decalage (r6) # lit valeur 32 bit a l'adresse pointé par r6+decalage (load word and zero)
(note importante: dans le cas de lwz sur une ABI 32bit, le 'z' s'adresse au 32 bit non implémenté, et 'lwa' n'a pas de sens.)
- lwzx r5,r6,r7 # comme lwz, mais l'adresse lue est donné par r6+r7
- lwzu r5,decalage (r6) # comme lwz, mais aprés r6 = r6 + decalage
- lwzux r5,r6,r7 # comme lwzx, mais ensuite r5 = r6 + r7.
- lfs f0,decalage (r6) # lecture d'un float 4 octets dans le registre flottant f0.
- lfd f0,decalage (r6) # lecture d'un float double 8 octets.
- stb r5,decalage (r6) # écrit l'octet de poid faible de r5 à l'adresse r6+decalage.(store byte)
- stw r5,decalage (r6) # écrit les 32 bits de poid faible à l'adresse r6+decalage.(store word)
- stfsux ... # devinez !
Pourquoi ces noms si complexes et incompréhensibles ?
Explication: les noms des mnémoniques PPC sont les initiales de la phrase décrivant leurs actions:
Toutes les instructions commençant par 'l' (load) chargent depuis la mémoire vers un registre. (à part li, load immédiate) Toute celles commençant par 'st' (store) écrivent la mémoire. Suit ensuite le format déplacé: en PowerPC le vocabulaire n'est pas le même qu'en 68000: les 'mots' PowerPC (word) font 4 octets (2 en 68k.) mais ce n'est qu'une question de convention et de vocabulaire. On a:
- b (byte) 1 octet.
- h (half) entier 2 octets
- w (word) entier 4 octets
- fs flottant 4 octet IEEE
- fd flottant double 8 octet IEEE
Ensuite viennent les lettres d'option de la copie:
- 'z' aprés un 'l' signifie que les bits de poids fort du registre sont mis à 0 (cas d'un entier non signé, sauf lwz cas particulier.)
- 'a' remplace z pour étendre le signe pour les nombres signé.
- 'i' pour immédiate, valeur donné dans l'instruction.
- 'x' pour 'indexed' indique que qu'on additionne 2 registres pour trouver l'adresse à lire.
- 'u' pour 'update' indique que le pointeur utilisé va être incrémenté aprés la copie.( (a0)+ en 680x0 )
- '.' Ne marche pas pour les load et les stores mais pour les opérations de calcul et logique: indique optionellement si l'instruction doit mettre à jour
le registre d'état cr0 dont les bits indiquent les dépassements et l'égalité à 0, pour un prochain test.(voir point 13.)
Le systéme d'abstraction de mnémonique par macro
Attention: les mnémoniques PPC sont déjà des abstractions par rapport au code assemblé ! Il existe un jeu d'instruction PPC officiel ( lwz, mr en font partie ) mais parmis elles certaines sont déjà des macros:
mr r4,r5 # est une macro équivalent à: or r4,r5,r5
Ne cherchez pas d'équivalent au "tst.l" du 68000, rajoutez un '.' a l'instruction précédente, ou au pire faite:
or. r5,r5,r5
De même beaucoup d'instructions de décalage officielles sont des macros de "rlwinm". Pour l'assembleur pasm, certaines de ces macros sont déclarés en interne, d'autre font appel à un fichier include spécial. N'importe qui peut créer ses propres macros, mais je ne le conseille pas. certaines macro ont l'air interessantes, mais elles correspondent parfois à plusieurs instructions, donc on perd la lisibilité des cycles effectivement exécuté pour un code.
Un gros piége: la notation des registres
Comme je l'ai déjà indiqué, les registres entier sont noté de r0 à r31, il faut écrire:
mr r5,r6 # copie r6 sur r5
Mais voilà: ces 'rx' sont des macros ! l'assembleur PPC peut se contenter de cet équivalent:
mr 5,6 # ou même: or 5,6,6
C'est également vrai pour les registres flottant noté de f0 à f31. Je vous conseille fortement d'utiliser uniquement les notations rx et fx à chaque fois qu'il s'agit de registres, et de n'utiliser des valeurs entiéres que pour décrire des valeurs entiére, des valeurs de décalages ou de positions de bit, sinon votre code sera illisible.
le FPU: l'Unité gérant les nombres réel flottants
Comme le FPU n'est jamais en option je vous encourage à les utiliser autant que possible, ce qui laisse d'autant plus de registres libres pour les entiers. Attention à cette particularité importante du PPC:
Tout les accés mémoire FPU, en float ou en double, en lecture ou en écriture, doivent se faire sur des adresses multiples de 4 ou une exception est généré !
cela signifie que si on a cette structure C aligné sur 4 octets:
struct maStructAvecUnFloat{
short mas_deuxoctets;
float mas_unfloat;
};
si on réalise une lecture sur mas_unfloat, il y a exception processeur, et sur la plupart des sytémes cela se traduit par une émulation de l'instruction de lecture ou d'écriture. (ralentissement significatif) note: sur warpos l'outil ShowHALInfo permet de tracer le nombre d'exception FPU émulé. Donc faites bien attention a aligner vos flottants.
Autre particularité du FPU: il existe une instruction assembleur de conversion d'un Flottant vers un entier, mais il n'existe pas d'instruction de conversion d'un entier vers un Flottant, il faut taper l'algorithme "de normalisation" pour créer le type Flottant IEEE. Cela peut être trés frustrant. Le fait est que j'ai programmé un moteur 3D en ASM PPC sans m'être posé la question ! En effet, dans la grosse majorité des algoritmes, on a des flottants en entrée, et la conversion vers les entiers se fait une fois pour toute en cours de route. (dans le cas d'un pipeline 3D on a des flottants décrivant la géométrie en entrée, et le passage en entier se fait seulement au niveau de l'écriture des pixels.)
Les mêmes registres flottants peuvent être utilisés en tant que 'float' 4 octet ou 'float double' 8 octets. Les fonctions de lecture/écriture mémoire (lfs, stfs ou lfd,stfd ) décide de leur types. Certains 'cast' entre float et double écrit en c/C++ sont donc implicite une fois compilé. A noter que toutes les instructions de calcul flottant existent en version double et simple (on ajoute 's' dans ce cas.), sauf pour la copie de registre à registre valable pour les 2: 'fmr'.
Quelques mnémoniques flottantes intéressantes:
fmadd frt,fra,frc,frb # 2 cycles fmadds frt,fra,frc,frb # 1 cycle : précision simple. fmadd. frt,fra,frc,frb fmadds. frt,fra,frc,frb
...réalise une multiplication et une addition en 1 ou 2 cycles, équivalente à:
{
float frt,fra,frc,frb;
frt = ( fra*frc ) + frb ;
}
Le calcul ci-dessus peut être compilé en une instruction PPC ! (code plus court.) Ce n'est pas tout:
fmsub frt,fra,frc,frb # correspond à: frt = ( fra*frc ) - frb ;
fnmadd frt,fra,frc,frb # correspond à: frt = -(( fra*frc ) + frb) ;
fnmsub frt,fra,frc,frb # correspond à: frt = -(( fra*frc ) - frb) ;
Les derniers GCC et VBCC générent de telles instructions. (à vérifier!) Dernier point intéressant pour le C/C++: chaque fois que vous écrivez une valeur flottante dans votre code:
float fa = 0.4f; // 4 octet en var globale double da = 0.4 ; // 8 octet en var globale.
Le compilateur va rajouter une var.globale contenant '0.4f' et compiler l'affectation comme:
lfs f0,varglobale1 (rtoc) lfd f1,varglobale2 (rtoc)
Car il n'existe pas d'affectation immédiate flottante en PPC. Ce serait un moindre mal, mais les compilateurs actuels vont ajouter des données globales pour chaque écriture d'une valeur dans vos source C,même si la cette valeur apparait plusieurs fois. Pour ces raisons, écrivez toujours '0.2f' et pas '0.2' quand vous le pourrez. La solution ultime en c: créer son .o de valeurs flottantes globales récurentes, et affectez ces var globale à des var locale le moment venu, au lieu d'écrire des valeurs directe dans vos équations. Cela accélérera vos calculs FPU.(chez intel c'est pareil.)
Avis aux petits malins qui pensent optimiser en pensant que ceci donne la valeur 0.0f à f0:
fsub f0,f0,f0
cela ne marchera pas si f0 est NaN.(Not a Number). On fait les malins et voilà ce que ça donne.
La plupart des instructions pour le même prix !!!
La grande majorité des instructions PowerPC prennent 1 cycle. C'est le cas des additions,soustractions,opérations logiques, mais aussi des multiplications et décalages. Beaucoup d'optimisations autrefois valables pour la famille 680x0 ne se justifient plus:
a *=256; // est aussi rapide que: a <<=8;
Du coté du FPU, les additions et multiplications prennent également 1 cycle. La grosse exception reste les divisions: (note: les cycles varient en fonction de la génération de processeur.)
divw r4,r5,r6 # division entiére 32 bit : 21 cycles fdiv f3,f4,f5 # division en flottant 8 octet (double): 31 cycles fdivs f3,f4,f5 # division en flottant 4 octet (float): 17 cycles
Il faut donc éviter de faire des divisions dans les boucles. Si vous utilisez les flottants et que vous divisez par une valeur constante dans une boucle, n'écrivez pas ceci:
{
float fa = fb / 5.0f ;
...
}
mais ceci, la valeur 0.2f sera stocké et aucune division réalisée:
float f_1div5 = 1.0f / 5.0f ;
{
float fa= fb * f_1div5 ;
...
}
Mais c'est peut être automatique pour votre compilateur.
Le PowerPC peut paralléliser beaucoup de calculs
Le PowerPC peut également paralléliser un grand nombre de calcul dans les mêmes cycles si des instructions consécutives utilisent des registres différents et des instructions permettant cette parallélisation.( notion de pipeline dans le processeur, qui parallélise les instructions). Le FPU peut aussi travailler en paralléle avec les entiers, dans les mêmes cycles.
Par exemple:
divw r13,r13,r14 fmadd f0,f1,f2,f3 add r5,r5,r6 add r7,r7,r8 add r9,r9,r10 add r11,r11,r12 ...
Les 4 additions peuvent être réalisés dans 1 cycle, et l'éxécution de la division entiére est parralléle. (mais continue aprés les add.). Il existe de meilleurs exemples de parallélisation. Notez que les compilateurs sont sencé en prendre compte.
Les mnémoniques de décalage de Bits
Le PowerPC est trés puissant en décalage de bit. A noter qu'en PowerPC les bits sont numéroté dans l'ordre inverse de la convention 68000: le bit de poid le plus fort est le zero, le bit de poid le plus faible est noté 31.( les sens gauche-droite de nature schématique, restent les mêmes. D'autre part il semble que les bit noté 0 à 31 en 32bit deviennent les bit 32 à 63 dans une ABI PPC 64bit !!!)
L'instruction "Rotate Left Word Immediate Then AND with Mask" demande 5 paramétres ! rlwinm r_destination, r_source, Nombre de bit de décalage à gauche (0-31), 1er bit du masque inclu (0-31), dernier bit du masque inclu (0-31) Cela permet de prendre n'importe quelle suite de bit dans un registre, et de la copier au bit prés dans un autre, en mettant les autres bits à 0. La plupart des instructions de décalage de bit sont des macro de rlwinm. Il n'y a pas besoin de version pour un décalage vers la droite: il suffit de spécifier (32-nombre de bit de décalage à droite) au nombre de bit de décalage à gauche. Malin non ?
Pour le même prix, on peut aussi utiliser rlwimi (Rotate Left Word Immediate Then Mask Insert) , qui prend les mêmes paramétres, mais insére la suite de bit dans le registre destination, laissant les autres bits inchangés.
Le compilateur sait utiliser au mieu ces instructions:
int a = (b>>6) & 0x00ffff00 ;
peut être compilé en une instruction asm PPC, qui plus est parallélisable. La encore on voit qu'un code PPC est parfois plus court. Les décalages algébriques (signé) à droite comme sraw et srawi ne sont pas des mnémoniques de ces instructions.
les mnémoniques logiques et arithmétiques
Les mnémoniques logiques et arithmétiques ont 3 paramétres:
add r_destination,r_a,r_b # réalise r_destination = r_a + r_b and r_destination,r_a,r_b # opération logique 'et'. ...
Toutes ces mnémoniques prennent 2 registres pour faire leurs calculs, et mettent le résultat dans un troisiéme. Cela économise des instructions d'affectations, et là aussi rend le code plus court. Voilà.
Des instructions de manipulation du cache puissantes
Ces intructions ne sont jamais et ne peuvent pas être utilisées par le compilateur, pourtant leurs maîtrises permet d'accélérer de façon significative certaines routines ! Mais attention il faut comprendre le mécanisme du cache et surtout le fait que les lignes de cache sont lues et écrites par bloc de 32 octets, lesquelles sont alignés sur des adresses multiples de 32. Si on fait un accé en lecture ou écriture, le cacheline 'touché' est d'abord chargé dans le cache, et l'opération effectué dans le cache. Le cache sera validé en écriture RAM plus tard, à un moment indéterminé.
dcbt r4,r5
"Data Cache Block Touch" permet de préparer le chargement d'un cacheline, une zone mémoire de 32 octets qui sera ensuite lu plus vite par les instructions suivantes. Cela permet de paralléliser le chargement des données par le bus. L'adresse affecté est donné ici par r4+r5.
dcbz r4,r5
"Data Cache Block to Zero".
Encore plus fort, le fin du fin: lorsque vous effacez une zone mémoire, habituellement vous mettez des registres à zero puis vous écrivez ces registres. Chaque cacheline va donc charger une mémoire en cache ou elle va se faire écraser aussitôt par des zeros. dcbz permet de déclarer les 32 octets d' un cacheline comme chargé et mis à 0 sans chargement par le bus ce qui accélére considérablement cette écriture. Dans les faits et aprés des tests de benchmark sérieux, toute écritures visant à écraser 32 octets à la suite peut être accéléré par un facteur 4 avec DCBZ. L'adresse affecté est donné ici par r4+r5.
Il existe tout un jeu d'instruction de manipulation du cache interne.(dcbst qui force une écriture dans l'instant, dcbi qui invalide une ligne de cache,...)
Notez que chacune des instructions "dcb (x)" peut potentiellement fonctionner ou pas, selon le contexte: Certaines zones mémoires (la mémoire video par exemple ) ne sont pas 'cachable'. Un dcbz peut donc échouer selon la zone concerné.
Précision Importante !
Les instructions "dcb (x)" sont fait pour manipuler des lignes de caches de 32 octets,avec lesquelles les PowerPC travaillent depuis les premiéres générations jusqu'au G4. Sur le G5, les lignes de caches sont à 128 octets. D'aprés des docs de apple, sur G5, dcbz charge implicitement 128 octets et écrit 32 d'entre eux à zero. Il a donc le même comportement qu'une écriture classique et ne génére pas de bugg. De plus, une nouvelle instruction G5, dcbzl permet d'effacer les 128 octets d'un CL sans lecture implicite. Selon une autre documentation IBM, le dcbz prend l'un ou l'autre comportement Selon le systéme. Il est donc conseillé d'utiliser dcbz seulement aprés avoir testé que votre CPU est un G4 ou inférieur.
Les sauts gratuits et les 8 registres d'états
L'instruction 'b label' permet de faire un saut simple jusqu'à un label. Si le code exécuté et le code de destination du saut se trouvent déjà dans le cache instruction, le saut lui-même prend zéro cycle. Il est instantané.
Encore plus fort: Un test, quelque soit l'assembleur, se déroule en 2 temps: on met à jour un registre d'état, par exemple en comparant 2 nombres, puis on réalise un branchement (saut) ou non, vers un code particulier d'aprés cet état (logique du 'if' en C et basic). En assembleur PowerPC, si la mise à jour du registre d'état à été faite 3 cycles avant le branchement de test, et si tout les codes concernés sont en cache instruction, ce branchement prend également zéro cycle. Il est instantané. Si vous avez compris, en PowerPC, un test peut potentiellement prendre zero cycle !!!
Dans les 680X0 on avait 1 registre d'état qui stockait les dépassements, l'égalité à 0, le 'plus petit que' et le 'plus grand que', ce qui permet ensuite de faire des branchement (saut de test).
Sur PPC , on a huit registres d'état noté cr0,cr1,cr2,...,cr7 (en fait tous font partie d'un registre entier spécial accessible.) qui peuvent stocker 8 images de ces états. L'option de test '.' affecte toujours 'cr0':
sub. r5,r6,r7
Des instructions de comparaison comme cmpwi peuvent affecter l'un ou l'autre: cmpwi cr1,r5,421 # compare valeur de r5 avec '421'. Les instructions de branchement conditionnel peuvent utiliser l'un ou l'autre:
beq cr1,label
...Ou pas: utilisation implicite de cr0:
beq label
Il est possible de faire des copies d'états entre ces registres:
mcrf cr1,cr0
Cependant,le code généré par les compilateurs n'utilisent que cr0, cette capacité à retenir plusieurs registres d'états n'est accessible que depuis l'assembleur. (même chose pour la famille intel.)
Vic "Krabob" Ferry - 29/11/2004
