DSP C6000 BAHTAT Mounir DSP C6000 Architecture & programmation C/ASM Rédigé par : Mounir BAHTAT Type de cours : TP guidé Catégorie du cours : Systèmes embarqués & Temps réel Pré-requis : Mise à jour le : 18-12-2012 Easy Learn www.easylearn.max.st 0 DSP C6000 BAHTAT Mounir Sommaire Partie 1 : Architecture & Programmation C embarqué Chapitre 1 : DSP et architecture Chapitre 2 : Notre premier programme en C embarqué Chapitre 3 : Techniques d’optimisation en C Partie 2 : Programmation ASM C66x Chapitre 1 : Notre premier code assembleur Chapitre 2 : Ecrire un code assembleur optimisé 1 DSP C6000 BAHTAT Mounir Introduction Une unité centrale de traitement (CPU : Central Processing Unit) est l’élément jouant le rôle d’un cerveau dans tout système. Cet élément ne permet pourtant que d’effectuer des opérations arithmétiques et logiques basiques, ainsi que des opérations d’E/S [Entrée/Sortie]. Depuis 1970, les CPU (plus précisément les microprocesseurs) ne cessent de s’optimiser. Le besoin en temps réel dans des applications liées aux traitements de signal, a engendré l’apparition d’un nouveau type de processeurs optimisés, qui portent l’acronyme de DSP (Digital Signal Processor). Les DSP et les systèmes embarqués temps réel Un système embarqué est un système électronique [matériel] et informatique [logiciel] autonome, qui doit souvent prendre en compte des contraintes temps réel. Les caractéristiques essentielles qui sont généralement exigées pour un système embarqué sont : - Le coût doit être le plus faible possible Consommation énergétique la plus faible possible, dû à l’utilisation des batteries [en général] Encombrement le plus réduit possible Performance taillée spécifiquement pour une certaine application [puissance de calcul, …] L’architecture d’un système embarqué est constituée principalement/généralement d’un élément de traitement (qui peut être GPP [General Purpose Processor] et/ou DSP [Digital Signal Processor] et/ou SoC [System On Chip] basé sur FPGA/ASIC), des mémoires et des interfaces de communication avec des périphériques [comme exemple : écran tactile, GPS, …]. La partie logicielle qui sera présente dans des mémoires Flash pour pouvoir être exécutée par un microprocesseur, est appelée : "firmware". Ci-après un exemple de système embarqué : Gumstix Overo COM [Computer On Module], avec Wifi et Bluetooth Partie 1 : Architecture & Programmation C embarqué Chapitre 1 : DSP et architecture Les DSP sont apparus vers 1978, pour répondre aux attentes temps réel des algorithmes de traitement de signal. Ils se sont caractérisé principalement des processeurs ordinaires par l’opération MAC (Multiplication & Accumulation) en un seul cycle d’horloge, alors que cette dernière opération est couteuse en termes de cycles sur d’autres types de microprocesseurs. Les DSP se dotent également d’une architecture Harvard modifié, qui contrairement aux architectures Von Neumann, permet l’accès simultané au programme et aux données, via des bus dédiés. L’utilisation de ce type de processeur dans un cadre de traitement de signal nécessitera des interfaces de conversion analogique/numérique (échantillonnage à une certaine fréquence), du fait que le DSP ne pourra traiter les données qu’aux cycles d’horloge, comme le montre la figure suivante : 2 DSP C6000 BAHTAT Mounir ADC=[Analog-Decimal Converter] ; DAC=[Decimal-Analog Converter] Texas Instruments (TI) occupe 70% du marché des DSP, laissant 30% aux autres concurrents (Motorola, Analog Devices, Lucent Technologies, …) On s’intéressera durant ce TP guidé, à un DSP propre au Texas Instruments, ces DSP se trouvent catégorisés en 3 : C2000 ; C5000 et C6000. Les caractéristiques/applications de chacune des familles sont citées ci-dessous : On s’intéressera particulièrement aux DSP performants C6000, qui se trouvent encore catégorisés en 2 : à virgule fixe et à virgule flottante. Les DSP à virgule flottante peuvent effectuer des opérations à virgule (nombres réels) en 1 cycle, alors que ça doit prendre plusieurs cycles sur un DSP à virgule fixe (spécifique aux nombres entiers) ; Ceci est dû à la présence des blocs matériels dans l’architecture du DSP qui sont spécialisés dans les opérations flottantes, alors que pour un DSP à virgule fixe une opération flottante est traduite en une combinaison de plusieurs opérations fixes [entières], engendrant une augmentation considérable des cycles lors de l’exécution. Les DSP de TI de la famille C6000 classés en performance sont listés ci-dessous : 3 DSP C6000 BAHTAT Mounir DSP à virgule fixe DSP à virgule flottante 4 DSP C6000 BAHTAT Mounir Le DSP qui sera utilisé dans ce TP est le tout dernier TMS320C6678 à virgule fixe ET flottante. Ce DSP qui se présente sous la forme d’un circuit intégré est fournie dans une carte de développement avec d’autres périphériques et mémoires afin faciliter son test et utilisation. La carte se présente ainsi : On y trouve principalement le DSP, des interfaces pour les protocoles de communication haut-débit (Ethernet, HyperLink, AMC), de la mémoire dynamique de 512 Mo, un émulateur XSD100v1 qui a pour rôle de faire communiquer un PC avec le DSP pour des objectifs de débogage : chargement du code logiciel vers les mémoires pour exécution par les cœurs du DSP, mise en pause/marche de chacun des cœurs, accès direct aux mémoires, … 18 interrupteurs DIP-Switch permettent la configuration statique de la carte (horloge, protocole PCIe, …). Un émulateur externe à haute vitesse peut être connecté via 60-pin afin d’effectuer des opérations de débogage rapide. Le connecteur AMC dans l’image ci-dessus transporte des lignes des protocoles de communication haut-débit tel que RapidIO ou PCIe (débits jusqu’à 5 Gbps [giga bit per second]). Finalement des boutons poussoirs pour "reset" sont également disponibles, l’expression "warm reset" exprime une réinitialisation sans mettre hors tension des composants. La configuration usine [par défaut] des DIP-Switch est la suivante : Cette configuration statique initiale choisit : - Un fonctionnement en "Little Endian" ; la différence entre "Little Endian" et "Big Endian" existe au niveau de la façon d’adressage d’un octet au sein d’un mot (de 32-bit) comme le montre la figure suivante : 5 DSP C6000 - BAHTAT Mounir "I2C Boot Master Mode" -> lire après la mise sous tension, du code programme à partir d’une mémoire EEPROM de 128Ko via le protocole série I2C Module PCIe désactivé L’architecture interne du DSP C6678 est la suivante : Le DSP contient 8 cœurs chacun pouvant se comporter comme un processeur indépendant, fonctionnant jusqu’à 1.25 GHz. Plusieurs niveaux de mémoires sont présents : - L1 local pour chacun des cœurs, divisé en 2, L1D (pour les données) et L1P (pour le programme) de taille 32Ko chacun ; c’est le niveau le plus proche au cœur, résultant en un accès le plus rapide possible, sans latences (débit de 16 octets par cycle) 6 DSP C6000 - BAHTAT Mounir L2 local pour chacun des cœurs de taille 512 Ko ; l’accès à cette mémoire est moins rapide qu’en L1 L1 ou L2 peuvent être aussi utilisé en mode CACHE, durant ce mode, on sauve l’accès aux données lointaines (à partir de la mémoire dynamique DDR3 externe par exemple) en les chargeant au L1 ou L2 d’avance. - MSM (Multicore Shared Memory) est une mémoire statique SRAM de 4 Mo commune à tous les cœurs Un contrôleur de la mémoire dynamique DDR3 (Double Data Rate), il s’occupe des différentes opérations de contrôle pour la mémoire externe DDR3 (rafraîchissement périodique, READ, WRITE, …) ; L’accès à ce type de mémoire est relativement lent par rapport aux mémoires statiques. Ci-après une brève description des autres modules de l’architecture : - - - Le périphérique « Debug and Trace » s’occupera des opérations de débogage entre DSP et PC La mémoire « Boot ROM » est une mémoire non volatile, de taille 128 Ko, servant pour sauvegarder le code programme, même après mise hors tension des périphériques du DSP Le bloc « Semaphore » protège l’accès concurrent aux périphériques par plusieurs maîtres (particulièrement par les 8 cœurs) Le PLL (Phase-Locked Loop) permettra la génération des signaux aux fréquences souhaitées L’EDMA (Enhanced Direct Memory Access) permet le transfert mémoire sans l’intervention du CPU, ceci apporte une importante majeure pour les applications nécessitant l’accès à une quantité importante de la mémoire Le bloc EMIF16 est un contrôleur d’une mémoire ROM externe Plusieurs protocoles de communication haut-débit sont disponibles également sur le DSP : PCIe (5 Gbps), RapidIO (5 Gbps), ETHERNET (1 Gbps), TSIP (32 Mbps), HyperLink (50 Gbps, entre 2 DSP C6678), I2C, UART, SPI Finalement, le « Multicore Navigator » a pour rôle d’optimiser le transfert des paquets issus des interfaces de communications haut-débit sur le bus TeraNet, ainsi que de réduire les latences des transferts mémoires entre cœurs Chacun des cœurs C66x CorePac est présenté sous le schéma suivant : 7 DSP C6000 BAHTAT Mounir On trouve par banc : - 32 registres de 32-bit nommés A0 -> A31 / B0 -> B31 4 unités .L / .S / .M / .D o .L : Unité arithmétique et logique, capable d’exécuter des instructions arithmétiques (additions, soustractions) et logiques (AND, OR, …), chacune des unités est capable de faire au maximum 2 additions/soustractions 32-bit flottantes/fixes par cycle ; Notez bien que toutes les instructions agissent seulement sur les 64 registres disponibles par cœur, et n’admettent pas des opérandes liées à la mémoire externe. o .S : Unité de branchement et de décalage, capable d’exécuter des opérations de branchement [saut du flux d’exécution d’un programme à une position bien déterminée] ou de décalage des registres. Cette unité est capable également d’exécuter des opérations d’additions/soustractions flottante ou fixe (au maximum 2 opérations par unité) o .M : Unité de multiplication, capable d’exécuter au maximum 4 multiplications 32-bit flottantes/fixes o .D : Unité de chargement et de stockage, capable de charger/stocker une donnée sur 64-bit Ainsi, le cœur est capable d’exécuter 8 instructions différentes en parallèle par cycle (une instruction par unité). Le cœur est également capable d’exécuter 8 multiplications 32-bit flottante/fixe par cycle [en utilisant les deux unités de multiplication], et 8 additions/soustractions 32-bit flottante/fixe par cycle [en exploitant les 4 unités .L1/.L2/.S1/.S2] Ce DSP multi-cœur [à une technologie de 40nm], vu sa puissance de calcul énorme, se dresse comme le meilleur processeur existant pouvant satisfaire une exigence en temps réel avec la moindre consommation en puissance, en effet suivant les Benchmarks certifiés du BDTI, ce DSP dépasse en performance l’ensemble des plateformes industrielles similaires : Le TMS320 C6678 est le premier DSP à 10 GHz. A une puissance de calcul de 320 GMACS en virgule fixe et 160 GFLOPS en virgule flottante à simple précision, 40 GFLOPS en virgule flottante à double précision. Avec une consommation moyenne de 10W seulement. Offrant alors une efficacité maximum de 4 GFLOPS/W [DP]. 8 DSP C6000 BAHTAT Mounir L’architecture d’un cœur de ce DSP est de type VLIW (Very Long Instruction Word), qui dispose de plusieurs unités fonctionnelles capables d’exécuter plusieurs instructions hétérogènes en 1 cycle d’horloge [ILP, Instruction Level Parallelism]. Dans notre cas il s’agit d’exécuter 8 instructions différentes en parallèle à la fois, ceci est possible grâce à un bus programme sur 256-bit [emportant 8 instructions de 32-bit chacune], comme le montre la figure des bus suivante : L’architecture du VLIW est plus simple qu’un CISC/RISC, du fait que le parallélisme des instructions n’est pas spécifié en matériel mais laissé à la partie logicielle [tâche du programmeur ASM ou du compilateur C]. L’inconvénient que peut présenter ce nouveau type d’architecture est la taille du code source, en effet, dans le cas où on dispose de moins que 8 instructions à exécuter en parallèle il faudra utiliser des instructions NOP pour marquer la nonutilisation de quelques unités fonctionnelles ; par conséquence, de la mémoire programme doit être réservée pour chaque instruction NOP utilisée. Si par exemple en moyenne on ne disposait que de 4 instructions exécutées par cycles, alors la moitié du code source serait des NOP ! Une nouvelle amélioration par rapport au VLIW standard a été implémentée par TI sur C66x portant le nom de "VelociTI", réduisant de manière considérable le phénomène de la taille du code source : La figure suivante illustre le chemin des données du banc B dans un cœur c66x [taille par défaut d’un bus est 64-bit] : 9 DSP C6000 BAHTAT Mounir On peut voir alors que les unités .L et .S peuvent avoir 2 opérandes de 64-bit chacun [src1/src2] et un port de sortie [dst] de 64-bit également. L’unité .M dispose d’un port de sortie de 128-bit [dst1/dst2]. 2 chemins entre les 2 bancs sont disponibles [1X et 2X], appelés "cross path". Le chemin 1X transporte 64-bit des données du banc B vers le banc A, alors que le chemin 2X transporte 64-bit du banc A vers le banc B. Finalement, on conclue que la communication entre les bancs est limitée à 64-bit des données dans les deux sens par cycle. Chapitre 2 : Notre premier programme en C embarqué L’outil de débogage qui va nous permettre d’écrire nos codes C ou ASM, leurs compilations ainsi que leurs chargements sur le DSP cible est : Code Composer Studio v5.1 de Texas Instruments 10 DSP C6000 BAHTAT Mounir Il s’agit d’un outil puissant et gratuit, capable de faire l’émulation (quand on dispose d’une carte de développement pour DSP) ou la simulation (quand on n’a pas de DSP, le simulateur donne pratiquement des résultats similaires au cas réel) L’outil est téléchargeable à partir du lien suivant : http://processors.wiki.ti.com/index.php/Download_CCS (version 5.1.0 de taille de 1200 Mo) Un nouveau projet peut se créer à partir du : File -> New -> CCS Project Spécifier un nom pour votre projet, le type du projet doit être exécutable (pas une librairie de fonctions) ; puisque notre DSP d’intérêt est C6678, il faut spécifier la famille C6000, de type générique C66xx. Valider afin de créer un nouveau projet vide. Il faut ensuite créer une configuration de la cible souhaitée (Target Configuration), qui va nous permettre de choisir la référence exacte de notre DSP. Pour cela, cliquer sur « New Target Configuration File » de la fenêtre « Target Configurations » (faites View -> Target Configurations si nous ne voyez pas la fenêtre en question) Une invite vous demande de spécifier le nom du fichier de configuration .ccxml, après validation, choisissez la cible souhaitée : 11 DSP C6000 BAHTAT Mounir Dans le cas de l’émulation, il faut sélectionner tout d’abord le type de la connexion à « Texas Instruments XDS100v1 USB Emulator », puis sélectionner TMS320C6678. Dans le cas de la simulation, on choisira « C6678 Device Cycle Approximate Simulator, Little Endian » Il faut lier après, la configuration crée, avec votre projet CCS. Pour cela cliquez-droit votre fichier de configuration dans la fenêtre « Target Configurations » et ensuite Link File To Project -> Nom de votre projet : Une fois fait, le fichier sera ajouté et sera visible parmi les fichiers de votre projet. Une autre information importante à fournir au compilateur c’est dans quel niveau mémoire (L1, L2, MSM, DDR3, ROM) il faut charger le programme qu’on écrit. Cette information est à spécifier dans un fichier .cmd qui a cette structure minimale : -stack Taille de la pile en octets -heap Taille d’allocations dynamiques en octets MEMORY { } Liste des mémoires disponibles sur le DSP cible SECTIONS { } Le programme est découpé en sections pouvant être mappées à plusieurs mémoires La liste des mémoires disponibles sur TMS320C6678 est la suivante : Plusieurs sections sont prédéfinies pour un programme écrit en langage C ; quelques sections importantes sont décrites ci-dessous : 12 DSP C6000 BAHTAT Mounir Un exemple de fichier de configuration .cmd est le suivant : -stack 0x5000 -heap 0x5000 MEMORY { L2SRAM : o = 0x00800000 , l = 0x00080000 } SECTIONS { .text > L2SRAM .data > L2SRAM .cinit > L2SRAM .const > L2SRAM .cio > L2SRAM .far > L2SRAM .near > L2SRAM .fardata > L2SRAM .sysmem > L2SRAM .stack > L2SRAM } Dans la partie MEMORY les mémoires utilisées de la cible sont listées, avec leurs adresses d’origine (o) et taille (l) Dans la partie SECTIONS on affecte chacune des sections définies vers l’identifiant d’une mémoire Les valeurs saisies dans le fichier sont en format hexadécimal. On sauvegarde le fichier sous l’extension .cmd et on le place dans le même répertoire de notre projet [qui doit se trouver dans le workspace] Enfin, on se propose d’exécuter le programme de test suivant : #include <stdio.h> void main(void) { printf("c66x test end\n"); } Faites Project -> Build Project, puis Run -> Debug 13 DSP C6000 BAHTAT Mounir Le résultat de la compilation, est la génération d’un fichier binaire .out (traduction binaire d’un code assembleur) qui sera chargé par la suite dans une mémoire. Une fenêtre vous demandera de cocher les cœurs qu’on veut utiliser. Après validation, le programme se chargera dans les mémoires spécifiées et vous devez être capable de voir tous les cœurs dans la fenêtre debug, prêts pour exécuter le programme : Après exécution sur un cœur, vous devez voir dans la console le résultat de l’instruction printf : [TMS320C66x_0] c66x test end Chapitre 3 : Techniques d’optimisation en C Durant ce chapitre, on passera à travers les techniques couramment utilisées en C sur un cœur C66x, ceci, en essayant d’implémenter et d’optimiser comme application, un algorithme de produit matriciel complexe. Le produit matriciel est un algorithme essentiel dans un nombre de blocs de traitement du signal ainsi que d’autres applications, dont on cite comme exemple : DFT (Discrete Fourrier Transform), BF (Beam Forming), inversion des matrices, résolution des systèmes linéaires, recherche de déterminant, LQR (commande automatique optimale), … On considère des matrices carrés de taille N, et que chacun des éléments des matrices est un nombre complexe (ayant une partie réelle et une partie imaginaire) Le produit matriciel des matrices A et B donnera une matrice carré C de taille N, vérifiant : 14 DSP C6000 BAHTAT Mounir On aura tout d’abord besoin d’un type des données pouvant stocker un nombre complexe (partie imaginaire & partie réelle). La solution la plus facile étant de construire notre propre type décrit par une structure ayant deux champs : réel et imaginaire [NB : le type float est un type supportant la virgule flottante, codé sur 4 octets] : typedef struct { float re; float im; } complex; Les matrices seront représentées alors par des tableaux bidimensionnels, ainsi : #define N 12 complex inpA[N][N]; complex inpB[N][N]; complex outp[N][N]; On choisit alors que l’élément A(i,j) soit équivalent à la notation A[i][j] (i pour la ligne, j pour la colonne) : Il est intéressant de savoir également comment les données sont organisées en mémoire, et savoir dans quelle adresse est stocké chacun des éléments de la matrice. On peut représenter la mémoire comme une liste d’adresses unidimensionnelle, de la façon suivante : Il est à noter que l’ordre des champs dans la structure affecte la façon avec laquelle les éléments sont placés dans la mémoire. De manière générale la notation bidimensionnelle [i][j] peut se traduire à la notation unidimensionnelle 15 DSP C6000 BAHTAT Mounir [i*N+j]. Notez bien que inpA (l’identifiant de notre tableau bidimensionnel) en C n’est qu’une adresse pointant vers le premier élément de notre tableau, la notation inpA+1 incrémente le pointeur par une quantité égale à la taille du type des données sur lequel inpA pointe (pour notre cas il s’agit du type complex, de taille 8 octets) On se propose ensuite d’écrire une fonction de multiplication matricielle ayant le prototype suivant : void produit_matriciel(complex inpA[N][N], complex inpB[N][N], complex outp[N][N], unsigned int n); On fournira à cette fonction, 3 pointeurs vers les 3 matrices concernées, ainsi que la taille utilisée. Deux opérations sont nécessaires afin de faire un produit matriciel : somme complexe & multiplication complexe : On écrira alors les 2 fonctions C suivantes : complex somme(complex argA, complex argB) { complex res; res.re=argA.re+argB.re; res.im=argA.im+argB.im; return res; } complex produit(complex argA, complex argB) { complex res; res.re=argA.re*argB.re-argA.im*argB.im; res.im=argA.re*argB.im+argB.re*argA.im; return res; } En appliquant la formule bien connue du produit matricielle, la fonction de multiplication complexe s’écrira : void produit_matriciel(complex inpA[N][N], complex inpB[N][N], complex outp[N][N], unsigned int n) { int i,j,k; for (i=0;i<n;i++) { for (j=0;j<n;j++) { complex sum={0.0 , 0.0}; for (k=0;k<n;k++) { sum=somme( sum , produit(inpA[i][k] , inpB[k][j]) ); } outp[i][j]=sum; } } } Le programme se trouve écrit en 3 boucles imbriquées, faisant fonction principale main de la manière suivante : itérations. L’appel à cette fonction sera dans la produit_matriciel(inpA,inpB,outp,N); Afin de tester la justesse de notre fonction de multiplication sur DSP, il faut initialiser les matrices A et B, on se propose de le faire de la manière suivante : void inputs_init(complex inpA[N][N],complex inpB[N][N]) { int i,j; 16 DSP C6000 BAHTAT Mounir for (i=0;i<N;i++) { for (j=0;j<N;j++) { inpA[i][j].re=1.0/(i+j+1); inpA[i][j].im=1.0/(i+j+2); inpB[i][j].re=2.0/(i+j+1); inpB[i][j].im=2.0/(i+j+2); } } } Enfin une fonction pour affichage d’une matrice : void affiche_matrice(complex matr[N][N]) { int i,j; for (i=0;i<N;i++) { for (j=0;j<N;j++) { printf("%f+i%f\n",matr[i][j].re,matr[i][j].im); } } } L’appel à ces fonctions dans main se fera ainsi : void main(void) { inputs_init(inpA,inpB); produit_matriciel(inpA,inpB,outp,N); affiche_matrice(outp); printf("c66x test end\n"); } Fixez N à 2, compilez et exécutez le programme, vous devez avoir le résultat suivant : [TMS320C66x_0] [TMS320C66x_0] [TMS320C66x_0] [TMS320C66x_0] [TMS320C66x_0] 1.777778+i2.666667 0.833333+i1.638889 0.833333+i1.638889 0.375000+i1.000000 c66x test end Lors de l’optimisation d’un algorithme complexe, il est intéressant de comparer la justesse et la précision des résultats avec un modèle dit de référence. On pourra alors tout au long de ce document faire la comparaison avec Matlab puisque la majorité des algorithmes dont on aura besoin existent dans ses librairies. Le script .m faisant la même chose que notre implémentation est le suivant : clc; N=12; inpA=zeros(N,N); inpB=zeros(N,N); for i=0:N-1 for j=0:N-1 inpA(i+1,j+1)=1.0/(i+j+1)+1i*1.0/(i+j+2); inpB(i+1,j+1)=2.0/(i+j+1)+1i*2.0/(i+j+2); end end outp=inpA*inpB 17 DSP C6000 BAHTAT Mounir Une différence qui existe entre C et Matlab est que les indices des tableaux en C commencent à partir de 0, alors qu’en Matlab ça commencent à partir de 1. Notre code C exécute itérations, chacune des itérations fait : 1 produit complexe + 1 addition complexe, ce qui est équivalent à : 4 multiplications réelles sur 32-bit (float) + 4 additions/soustractions réelles sur 32-bit. Le nombre des multiplications accumulations (MAC) que fais le programme est alors : MAC On rappelle que le cœur C66x est capable d’exécuter 8 multiplications 32-bit flottante/fixe par cycle [en utilisant les deux unités de multiplication], et 8 additions/soustractions 32-bit flottante/fixe par cycle [en exploitant les 4 unités .L1/.L2/.S1/.S2]. Autrement dit, un cœur est capable de faire 8 MAC par Cycle. Le nombre des cycles idéal afin d’exécuter un produit matriciel complexe sur un cœur C66x est alors = cycles = cycles Soit pour N=1000 le produit matriciel pourra être exécuté en 500 000 000 cycles par un seul cœur C66x, donc un temps de 500 ms (sachant que la fréquence de fonctionnement est 1 GHz) On se propose ensuite de mesurer le temps d’exécution de notre code et voir de combien on est loin de la performance idéal attendue, on définira alors l’efficacité de l’implémentation C par : Il est prévu que l’efficacité du code C va être différente par rapport à celle idéal, bien que le compilateur de Texas Instruments est très puissant, on ne pourra dans la plupart des cas, atteindre des efficacités maximum qu’en codant nos routines directement en ASM. Afin de mesurer le nombre des cycles d’une routine on utilisera le module TSC (Time Stamp Counter) de la librairie CSL (Chip Support Library) fournie par Texas Instruments. TI suggère deux façons d’accès à un périphérique du DSP à partir de la couche application : niveau registre ou niveau fonctionnelle : Le niveau registre, consiste à configurer et avoir l’accès direct aux registres mappés dans la mémoire afin de mettre en marche un certain périphérique (comme EDMA, TIMER, …). Tandis qu’au niveau fonctionnel, la complexité de la configuration registre par registre est masquée via l’utilisation des fonctions C fournies dans des librairies comme CSL. Le CSL existe en MCSDK (Multicore Software Development Kit) et plus particulièrement dans le PDK (Programmers Development Kit), téléchargeable à partir du lien suivant : 18 DSP C6000 BAHTAT Mounir http://software-dl.ti.com/sdoemb/sdoemb_public_sw/bios_mcsdk/latest/index_FDS.html (pour Windows, de taille 900 Mo) Après installation dans le chemin par défaut, vous pouvez voir la librairie CSL dans le chemin : C:\Program Files\Texas Instruments\pdk_C6678_1_0_0_9_beta2\packages\ti\csl Dans le fichier csl_tsc.h on trouve les prototypes de toutes les fonctions du module TSC, copier ce fichier au répertoire de votre projet. Vous trouverez la définition des fonctions du module dans un fichier assembleur à : C:\Program Files\Texas Instruments\pdk_C6678_1_0_0_9_beta2\packages\ti\csl\src\ip\tsc Copier le fichier .asm également dans le répertoire de votre projet. Afin de pouvoir inclure les modules de la librairie CSL à partir de notre code C, allez dans Project -> Properties Dans Include Options ajoutez le chemin suivant à partir de File System … : C:\Program Files\Texas Instruments\pdk_C6678_1_0_0_9_beta2\packages Dorénavant, ce chemin sera pris en compte lors de la recherche des fichiers .h inclus via la directive #include Les fonctions du module TSC qu’on va utiliser sont les suivants : void _CSL_tscEnable (void); CSL_Uint64 _CSL_tscRead (void); La 1ère fonction déclenche l’incrémentation d’un compteur sur 64-bit à chaque cycle, qui se trouve parmi les registres du cœur C66x, noté [ TSCL (32-bit) et TSCH (32-bit) ]. La 2ème fonction lit la valeur actuelle des paires de registre TSCL et TSCH, et retourne le résultat comme étant un entier non signé sur 64-bit. 19 DSP C6000 BAHTAT Mounir L’utilisation de ces fonctions afin de mesurer le nombre des cycles de notre fonction de multiplication se fera ainsi : #include "csl_tsc.h" void main(void) { unsigned long long db,fn,mes; _CSL_tscEnable(); inputs_init(inpA,inpB); db=_CSL_tscRead(); produit_matriciel(inpA,inpB,outp,N); fn=_CSL_tscRead(); mes=fn-db; affiche_matrice(outp); printf("nombre des cycles : %lld\n",mes); printf("c66x test end\n"); } Puisque le fichier .h se trouve dans le même répertoire de votre projet, on utilisera la syntaxe #include "." à la place de #include <.> On lit la valeur du compteur 2 fois, avant et après l’appel à la fonction désirée. La soustraction des deux valeurs lues doit permettre d’avoir le temps d’exécution en termes de cycles. Afin d’éviter des avertissements gênant lors de la compilation à propos de l’appel des fonctions du module TSC, modifier le fichier csl_tsc.h en ajoutant au prototype de chacune des 2 fonctions un tiret manquant à leurs noms, comme suit : extern void _CSL_tscEnable(void); Après compilation et exécution, vous devez avoir un résultat similaire au suivant : [TMS320C66x_0] [TMS320C66x_0] [TMS320C66x_0] [TMS320C66x_0] [TMS320C66x_0] [TMS320C66x_0] 1.777778+i2.666667 0.833333+i1.638889 0.833333+i1.638889 0.375000+i1.000000 nombre des cycles : 1768 c66x test end Notez bien : Ce n’est pas nécessaire de faire Run -> Debug pour chaque recompilation du projet On s’intéressera aussi à l’affichage de l’efficacité du code, le code C de la fonction main devient : void main(void) { unsigned long long db,fn,mes,parfait=N*N*N/2; _CSL_tscEnable(); inputs_init(inpA,inpB); db=_CSL_tscRead(); produit_matriciel(inpA,inpB,outp,N); fn=_CSL_tscRead(); mes=fn-db; affiche_matrice(outp); printf("nombre des cycles : %lld\n",mes); printf("efficacité : %lf %%\n",100.0*parfait/(double)mes); printf("c66x test end\n"); } Qui affiche une efficacité de 0.280962 % pour N=12 !! L’efficacité de notre code C est très faible. On se proposera ensuite d’optimiser notre implémentation C pour une meilleure traduction en code assembleur par le compilateur. 20 DSP C6000 BAHTAT Mounir On commencera par activer les options d’optimisations du compilateur via : Project -> Properties ; dans la rubrique Basic Options, choisir 3 pour Optimization Level : Aussi, dans la rubrique Optimizations, choisir 5 pour Optimize for Speed : Pour N=12 on obtient cette fois-ci une efficacité de 0.362223 %. Une légère augmentation est remarquée. En réalité il y une instruction assembleur DADDSP qui permet de calculer deux sommes 32-bit à la fois, ce qui nous fais rappeler à notre fonction somme qu’on a écrit pour faire la somme de 2 nombres complexes (nécessitant 2 additions 32-bit également). Heureusement, on la possibilité d’appeler cette instruction à partir du C via son interface nommée une instruction intrinsèque. Le prototype de cette fonction est le suivant : 21 DSP C6000 BAHTAT Mounir __float2_t _daddsp (__float2_t src1, __float2_t src2); Le type __float2_t est codé sur 64-bit, et compatible avec le type double. L’opération réalisée est schématisée sur la figure suivante : Une autre instruction intrinsèque faisant tout le produit complexe est également disponible : double _complex_mpysp (double src1, double src2); En argument on fournit deux nombres complexes sur 64-bit [src1/src2] (32-bit pour la partie réelle et 32-bit pour la partie imaginaire), en retour, on récupère le résultat du produit complexe. Pourtant, les parties réelles et imaginaires doivent être ordonnées de la manière suivante [l’imaginaire en premier, puis le réel dans la position qui suit] : Afin d’utiliser cette fonction il faut changer l’ordre qu’on a imposé dans la structure vers celui-là : typedef struct { float im; float re; } complex; Les pointeurs utilisés jusqu’à présent dans notre implémentation C, sont de type complex* (pointeur vers des données de type de la structure définie comme complex), qui n’est pas compatible avec un pointeur sur un double (quoi qu’ils peuvent décriver la même quantité complexe sur 64-bit chacun). Or, les instructions intrinsèques qu’on souhaite utiliser acceptent en paramètre une donnée de type double et ne peuvent pas, pas la suite, être compilés avec des paramètres de notre type (de la structure complex) ; une manière remédiant à ce problème est de changer le type des pointeurs dans le prototype de notre fonction de multiplication matricielle à : void produit_matriciel(double *inpA, double *inpB, double *outp, unsigned int n) En plus, les pointeurs ne sont plus bidimensionnels mais unidimensionnels, la formule de translation vers ces derniers est connue et citée précédemment, la fonction de multiplication matricielle deviendra : void produit_matriciel(double *inpA, double *inpB, double *outp, unsigned int n) { int i,j,k; 22 DSP C6000 BAHTAT Mounir for (i=0;i<n;i++) { for (j=0;j<n;j++) { double sum=0.0; for (k=0;k<n;k++) { sum=_daddsp( sum , _complex_mpysp (inpA[i*N+k] , inpB[k*N+j]) ); } outp[i*N+j]=sum; } } } Au niveau de l’appel à la fonction main, un cast (changement de type de pointeurs) est nécessaire : produit_matriciel((double*)inpA,(double*)inpB,(double*)outp,N); Exécutez pour N=12 et en commentant la fonction d’affichage, on obtient une efficacité de : 28.973843 % -> Une amélioration nettement importante est à remarquer grâce aux instructions intrinsèques. Un autre mécanisme important à prendre en compte est le cache. Trois mémoires pouvant être configurées comme cache sur un cœur C66x : L1P, L1D et L2. Le cache a pour rôle de sauvegarder en avance les données d’une mémoire lointaine/externe (lente d’accès) dans un niveau de mémoire plus proche au cœur (L1 par exemple) afin de minimiser les latences mémoires. Le principe de localité étant utilisé, qui dicte que si le cœur accède à une donnée, alors il est très probable, qu’il essayera d’accéder aux données voisines, le cache exploite ceci et charge les données voisines à un niveau de mémoire plus proche. L1P, étant activé en cache, s’occupera de charger le code du programme à exécuter. L1D et L2 peuvent être utilisé afin de « cacher » les données auxquels accède le cœur. Si les données sont stockées en DDR3, il est bénéfique d’activer le cache pour L2 et L1D. Alors que si on positionne nos données en L2, seule L1D pourra procurer un gain en cache. Puisque dans notre cas, on a utilisé le L2 afin de stocker toutes les matrices, on s’intéressera à la structure du cache L1D par la suite : La mémoire L1D de taille 32Ko peut être activé entièrement en cache, on pourra la représenter comme composée de plusieurs lignes, chacune de taille 64 octets : On suppose ensuite que le cœur essaye d’accéder à une mémoire relativement lente d’accès (L2, MSM ou DDR3), qu’on va schématiser aussi par : 23 DSP C6000 BAHTAT Mounir Comme représenté, on peut voir la mémoire que le cœur va accéder via le cache, comme subdivisé à des blocs de la taille du cache (dans ce cas de 32 Ko). Chacun des blocs est attribué un identifiant, dit « Tag ». Si on suppose que le cœur accède à la toute première adresse de la mémoire, le mécanisme du cache cherche tout d’abord si la donnée existe déjà dans le cache, si ça n’existe pas (ce cas est dit « Read Miss »), non seulement la donnée requise est transférée au cache, mais aussi toute la ligne voisine (de taille 64 octets) est transférée. Et afin de se rappeler de quel bloc on a fais ce transfert, chacune des lignes du cache est affectée à un numéro du Tag : Ce fonctionnement décrit est dit "Direct Mapped Cache" qui associe chaque adresse à une ligne unique du cache ; la structure réelle est légèrement différente et optimisée, qui sera décrite plus loin dans ce document. Le cache est totalement transparent lors du fonctionnement, c’est-à-dire, qu’on n’a pas à se préoccuper des opérations qu’il fait ou de lui demander de cacher une certaine mémoire, tout se fait automatiquement après son activation. Le module du CSL qui permet d’avoir accès pour activation du cache contient ces 3 fonctions principales : void CACHE_setL2Size (CACHE_L2Size newSize) 24 DSP C6000 BAHTAT Mounir void CACHE_setL1DSize (CACHE_L1Size newSize) void CACHE_setL1PSize (CACHE_L1Size newSize) Ils permettent de choisir une nouvelle taille pour le cache, les types CACHE_L2Size et CACHE_L1Size sont des énumérations définies dans csl_cache.h, ainsi : typedef enum { /** No Cache */ CACHE_L1_0KCACHE = 0, /** 4KB Cache */ CACHE_L1_4KCACHE = 1, /** 8KB Cache */ CACHE_L1_8KCACHE = 2, /** 16KB Cache */ CACHE_L1_16KCACHE = 3, /** 32KB Cache */ CACHE_L1_32KCACHE = 4, /** MAX Cache Size */ CACHE_L1_MAXIM1 = 5, /** MAX Cache Size */ CACHE_L1_MAXIM2 = 6, /** MAX Cache Size */ CACHE_L1_MAXIM3 = 7 } CACHE_L1Size; Finalement, on écrit notre fonction d’activation du cache comme suit : #include <ti/csl/csl_cache.h> #include <ti/csl/csl_cacheAux.h> void cache_init() { CACHE_setL2Size (CACHE_0KCACHE); CACHE_setL1DSize (CACHE_L1_32KCACHE); CACHE_setL1PSize (CACHE_L1_32KCACHE); } Et ne pas oublier son appel dans la fonction main tout au début : cache_init(); Après exécution, on ne remarque pas une amélioration car le cache est activé par défaut. Pourtant c’est toujours une étape essentielle afin de s’assurer de la taille de la mémoire procurée au cache par rapport à celle utilisée par notre programme (comme SRAM). Afin de voir l’effet du cache sur l’efficacité, on mettra une taille de 0K au L1D et L1P, on obtiendra une efficacité de : 6.661527 % (une diminution par rapport à la valeur avec cache active : 28.973843 %) Cette diminution d’efficacité sans cache est due aux latences mémoire supplémentaires introduites. On peut écrire : Nombre total mesuré des cycles = Nombre des cycles CPU + Latences [mémoires] Le nombre des cycles CPU étant le nombre des paquets d’instructions (jusqu’à 8 au maximum, vu qu’on a 8 unités dans le cœur) que le cœur C66x exécute. Les latences mémoires "Memory Stalls" étant le nombre des cycles pendant lesquels le processeur est mis en pause en attendant l’arrivée des données de la mémoire. D’autres terminologies sont utilisées afin de décrire le fonctionnement du cache, parmi lesquels : - L1D Access Count : Nombre d’accès à la mémoire via L1D L1D Miss Count : Nombre d’accès à une donnée n’existant pas dans le cache 25 DSP C6000 - BAHTAT Mounir L1D Hit Count : Nombre d’accès à une donnée existante dans le cache On se demande ensuite combien de fois le processeur est mis en pause à cause de l’accès à la mémoire pendant l’exécution de la fonction du produit matriciel ("L1D Miss") ? La taille des données lues (des 2 matrices A et B) est nombres complexes, soit octets Pour N=12, on aura 2304 octets à lire. D’après notre compréhension du fonctionnement du mécanisme du cache, il va y avoir 2304/64=36 L1D Read Miss, ceci est vrai car 2304 octets < 32 Ko du cache activé. L’outil Code Composer Studio nous permet également de mesurer ce type d’événements (L1D Read Miss, L1D Hit, …). Pour le faire, commencer par encadrer la routine à mesurer par des breakpoints (bouton droit sur une ligne du code puis, Breakpoint (Code Composer Studio) -> Breakpoint) : Les breakpoints vont nous permettre d’arrêter l’exécution d’un programme à un certain point du code, cela nous permettra d’initialiser la mesure des événements, juste avant d’exécuter la fonction désirée, et de voir le résultat de mesure, juste après l’exécution de notre fonction. Lancer l’exécution sur un certain cœur, le programme s’arrête au premier breakpoint, faites Run -> Clock -> Enable, puis Run -> Clock -> Setup … et choisir l’événement L1.miss.read : Lancer à nouveau le programme (Resume ou F8), le programme s’arrêtera au deuxième breakpoint, en affichant le résultat de mesure : 37 (pratiquement, le même résultat théorique) 26 DSP C6000 BAHTAT Mounir Pour réinitialiser le compteur : Run -> Clock -> Reset Pour supprimer les breakpoints : Run -> Remove All Breakpoints De même on mesure d’autres événements importants : - Nombre des cycles CPU (CPU.cycle) : 2709 cycles Latences mémoire (CPU.stall.mem.L1D) : 268 cycles Nombre total des cycles (cycle.Total) : 2984 cycles Note : Durant tout ce document les résultats reportés sont ceux du simulateur (en émulation, il est prévu qu’il y en ait une petite différence) A ce stade, on se demande pourquoi on n’est pas capable d’atteindre une efficacité aussi importante (à peine 30% actuellement). Afin de répondre à cette question, on essayera d’évaluer en un niveau plus bas les instructions pouvant être réalisé au niveau du cœur C66x, vu les ressources disponibles, afin d’implémenter notre produit matriciel. La toute première opération qu’on demande de faire dans notre produit matriciel est de charger deux nombres complexes de chacune des deux matrices, faire leur produit matriciel, et accumuler le résultat : Les unités .D1 et .D2 peuvent charger chacune 64-bit des données, donc chacune des unités chargera un nombre complexe par cycle. Une unité .M1 de multiplication peut faire en 1 cycle les 4 multiplications 32-bit requises dans une multiplication complexe sur les données chargées. Les unités .L1 et .S1 peuvent faire les 4 accumulations 32-bit restantes. La séquence possible des instructions dans le temps (par cycle) est schématisée ci-dessous : 27 DSP C6000 BAHTAT Mounir Au premier cycle, on utilise .D1 et .D2 afin de charger des données, qui seront disponibles au cycle suivant, on utilisera le .M1 afin de multiplier les données chargée, au cycle qui suit les résultats de multiplications seront prêts à être additionnées par .L1 en premier lieu afin de finaliser le calcul du produit complexe, et puis accumulés par .S1 dans le cycle suivant. Il s’agit de la séquence idéale des instructions qu’on peut avoir, dans ce cas, on est capable de faire 4 MAC par cycle seulement, encore loin de la capacité de calcul maximale d’un cœur C66x : 8 MAC par cycle (par rapport auquel on calcule l’efficacité de notre implémentation C). Ceci explique l’efficacité réduite de notre code (<30%), le maximum c’était d’ailleurs seulement 50%. Remarque : Là, on a supposé que chacune des instructions exécutées, renvoie le résultat au cycle suivant, en réalité ce n’est pas le cas (par exemple, une multiplication nécessite 4 cycles avant de retourner des données), mais on a le droit de le supposer à ce stade car en état plein du pipeline, on se retrouve quasiment avec un nouveau résultat par cycle. La raison principale de l’incapacité d’exploiter l’autre 50% de la capacité de calcul de notre cœur est dû au fait qu’on ne peut pas charger plus de données (à multiplier par .M2 par exemple). On remarque une pression maximale sur les unités de chargement, pourtant, la quantité chargée n’est pas suffisante afin d’exploiter la totalité de la puissance du processeur. Est-ce qu’on peut réduire le nombre d’accès à la mémoire afin de minimiser la pression sur les unités .D1 et .D2 et par la suite pouvoir exploiter le maximum du cœur ? On sait qu’au minimum il faut accéder à la mémoire fois, mais dans notre programme actuel, on le fait fois, ça veut dire que chaque donnée complexe des matrices A et B est chargée aux registres du cœur N fois. Une solution possible consisterait à exploiter au maximum une donnée avant d’en charger une autre, et donc ne pas être obligé de la recharger fréquemment. On remarque par exemple que la même 1ère donnée de la matrice B est utilisée avec chacune des premiers éléments des lignes de la matrice A, donc on était obligé de la charger N fois. Si on traite 2 lignes de la matrice A à la fois, de telle sorte que dans la 1ère itération de l’algorithme on charge 2 éléments de la matrice A et 1 seul élément de la matrice B et en faisant 2 multiplications et 2 accumulations comme illustré : 28 DSP C6000 BAHTAT Mounir On réduit alors le nombre de chargements des éléments de la matrice B par 2, vu qu’on utilise la même donnée de la matrice B deux fois. On peut faire mieux en traitant aussi en parallèle 2 colonnes de la matrice B, ceci aura similairement le gain de réduire le nombre de chargements des éléments de la matrice A par 2 : La séquence possible des instructions dans le temps (par cycle) est schématisée ci-dessous : On charge 4 nombres complexes pendant les 2 premiers cycles, 4 multiplications complexes sont réalisées par les unités .M après chargement, les accumulations sont assurées par les unités .L et .S On remarque que les itérations sont séparées par un cycle où on ne fait pas d’opérations, on ne peut pas effectivement charger des données durant ce cycle, sinon, les opérations de multiplications dans le cycle qui suit traiteront des données erronées. On a pu atteindre avec cette solution une performance idéale de 16 MAC/3 Cycles = 5.33 MAC/Cycle. Ceci est meilleur que le produit classique qui ne permettait que 4 MAC/Cycle comme valeur crête maximale. On peut faire encore mieux en traitant 4 lignes de la matrice A et 2 colonnes de la matrice B à la fois : La séquence possible des instructions dans le temps (par cycle) est schématisée ci-dessous : 29 DSP C6000 BAHTAT Mounir On charge 4 données de la matrice A et 2 données de la matrice B pendant les 3 premiers cycles, on peut commencer à utiliser les unités de multiplications à partir du 3ème cycle (où au moins 3 données sont chargées), dans ce cas il y’aurait 8 multiplications à faire pendant 4 cycles. Il est impératif que la dernière opération de multiplication .M1/2 dans une itération traite des données différentes à ceux chargées par .D1/2 durant le premier cycle d’itération, afin d’éviter le même problème rencontré précédemment et que les unités de multiplications traitent les données correctes. Grace à cette configuration on a pu réduire le nombre de chargements de la matrice A par 2 et le nombre de chargements de la matrice B par 4. Cela se voit clairement dans la séquence d’exécution proposée, que les unités .D1 et .D2 ne sont pas utilisées en plein régime, et pourtant suffisent pour alimenter 8 multiplications complexes par itération [l’itération est étalée dans ce cas sur 4 cycles]. Cette solution permet d’atteindre une performance idéale de 8 MAC/Cycle, et donc pourra exploiter toute la puissance fournie par le cœur C66x. L’inconvénient possible que peut présenter ce nouvel algorithme, est que N doit être multiple de 4 pour que ça fonctionne. On peut palier à cela par extension par zéros des tailles des matrices au multiple de 4 le plus proche. Il ne reste plus que d’implémenter cette nouvelle modification de l’algorithme en C, cela donnera le code suivant : void produit_matriciel(double *inpA, double *inpB, double *outp, int n) { int i,j,k; double a0b0,a0b1,a1b0,a1b1,a2b0,a2b1,a3b0,a3b1; double a0,a1,a2,a3,b0,b1; for (i=0;i<n;i+=4) { for (j=0;j<n;j+=2) { a0b0=a0b1=a1b0=a1b1=a2b0=a2b1=a3b0=a3b1=0.0; for (k=0;k<n;k++) { a0=inpA[i*N+k]; a1=inpA[(i+1)*N+k]; a2=inpA[(i+2)*N+k]; a3=inpA[(i+3)*N+k]; b0=inpB[k*N+j]; b1=inpB[k*N+j+1]; a0b0=_daddsp( a0b1=_daddsp( a1b1=_daddsp( a1b0=_daddsp( a2b0=_daddsp( a3b0=_daddsp( a2b1=_daddsp( a0b0, a0b1, a1b1, a1b0, a2b0, a3b0, a2b1, _complex_mpysp _complex_mpysp _complex_mpysp _complex_mpysp _complex_mpysp _complex_mpysp _complex_mpysp ( ( ( ( ( ( ( a0 a0 a1 a1 a2 a3 a2 , , , , , , , b0 b1 b1 b0 b0 b0 b1 ) ) ) ) ) ) ) ); ); ); ); ); ); ); 30 DSP C6000 BAHTAT Mounir a3b1=_daddsp( a3b1, _complex_mpysp ( a3 , b1 ) ); } outp[i*N+j]=a0b0; outp[i*N+j+1]=a0b1; outp[(i+1)*N+j+1]=a1b1; outp[(i+1)*N+j]=a1b0; outp[(i+2)*N+j]=a2b0; outp[(i+3)*N+j]=a3b0; outp[(i+2)*N+j+1]=a2b1; outp[(i+3)*N+j+1]=a3b1; } } } Cette fois-ci avec une efficacité de 44.216991 % Il s’agit de la majorité des optimisations qu’on peut faire en C, il s’avère difficile pour le compilateur de générer un code assembleur répondant à la séquence des instructions qu’on veut réaliser concrètement au niveau du cœur. L’alternative offrant le maximum de contrôle d’optimisation est de coder directement nos routines en assembleur, qui fera l’objet de la partie suivante. Finalement on cite quelques directives utilisées afin de procurer plus d’informations au compilateur : #pragma MUST_ITERATE (min, max, multiple de) Cette ligne est positionnée juste avant une boucle, afin de fournir au compilateur des informations qui portent sur le nombre des itérations (nombre minimale d’itérations, nombre maximale, est-ce que ce nombre est multiple d’une certaine valeur) Ajouté à notre fonction de multiplication ainsi : … double a0,a1,a2,a3,b0,b1; #pragma MUST_ITERATE (1,,) for (i=0;i<n;i+=4) { for (j=0;j<n;j+=2) { … On obtient une efficacité de 44.307692 % Note : Le pragma est une directive informant le compilateur qu’il faut générer l’exécutable d’une autre manière que celle par défaut Une 2ème instruction similaire est : _nassert (assertion) Fournir au compilateur des astuces d’optimisation sous la forme de vraies assertions. Dans notre produit matriciel ça peut être utilisée comme suit : … double a0b0,a0b1,a1b0,a1b1,a2b0,a2b1,a3b0,a3b1; double a0,a1,a2,a3,b0,b1; _nassert(n%4==0); _nassert(n>=4); #pragma MUST_ITERATE (1,,) for (i=0;i<n;i+=4) { for (j=0;j<n;j+=2) { 31 DSP C6000 BAHTAT Mounir … Qui donne une efficacité de 44.353183 % Un autre mécanisme est le dépilement des boucles (unrolling), qui permet de fusionner plusieurs itérations d’une boucle dans une seule, cela peut améliorer la performance mais augmente la taille du code générée : #pragma UNROLL (nombre de dépilements) Une dernière directive pragma importante est : #pragma DATA_SECTION (Variable , Section) Ceci positionne une variable dans une section déterminée spécifiée dans le fichier de commande .cmd. Ca peut être utilisé de cette manière : complex inpA[N][N]; complex inpB[N][N]; complex outp[N][N]; #pragma DATA_SECTION(inpA,".mysect") La section .mysect n’est pas une section prédéfinie, mais spécifié par le programmeur dans le fichier de commande ainsi : MEMORY { L2SRAM : o = 0x00800000 , l = 0x00080000 MSMCSRAM : o = 0x0C000000, l = 0x00400000 } SECTIONS { .text > L2SRAM .data > L2SRAM .cinit > L2SRAM .const > L2SRAM .cio > L2SRAM .far > L2SRAM .near > L2SRAM .fardata > L2SRAM .sysmem > L2SRAM .stack > L2SRAM .mysect > MSMCSRAM } 32 DSP C6000 BAHTAT Mounir Partie 2 : Programmation ASM C66x Chapitre 1 : Notre premier code assembleur Texas Instruments propose 3 manières pour écrire un programme pour c66x : langage c/c++ ; assembleur linéaire ; assembleur standard c66x : Comme montré ci-dessus, la programmation c/c++ masque la complexité bas niveau du cœur c66x, à savoir : [le choix des instructions assembleurs appropriées, choix des registres/répartition de l’exécution sur les 2 bancs (A et B), mapping/ordonnancement des instructions sur les différentes unités, pipeline], mais en contrepartie, une performance maximale n’est pas garantie. Aussi, le comportement du même code c/c++ peut varier suivant chaque mise à jour/version du compilateur [problème de portabilité]. Une nouvelle manière de programmation ASM est l’assembleur linéaire, qui donne la possibilité d’imposer le choix des instructions assembleur à être utilisé, ainsi que la manière d’allocation des registres. Par contre les autres détails sont gérés automatiquement par le compilateur [assembly optimizer]. Ce mode de programmation est plus facile que l’assembleur standard, et procure généralement de meilleurs performances par rapport au langage c/c++. L’assembleur standard c66x gère manuellement en bas niveau les opérations sur le cœur c66x et choisit le parallélisme convenable des instructions [ILP]. Ce mode de programmation peut offrir le maximum de performance possible. Aussi, le code écrit sera exécuté tel quel par le cœur, donc offre également le maximum de portabilité, puisque ce code ne sera pas modifié par le compilateur. On s’intéressera par la suite à ce mode de programmation. Le flux de génération du code c66x est schématisé ainsi : 33 DSP C6000 BAHTAT Mounir L’extension .sa est celle de l’assembleur linéaire, on écrira alors directement du code assembleur standard en .asm indépendamment de tout outil de post-optimisation. Le code binaire qui sera chargé dans la mémoire pour être exécuté a l’extension .out Il est noté que l’assembleur est plus difficile à construire qu’un code C, en compromis aux performances. Alors qu’en général on souhaite seulement optimiser des routines critiques principaux, et que d’autres routines n’ont pas intérêt à être optimiser tellement (routines d’initialisations, déclarations des données, allocations, …). On se propose par la suite de mixer l’utilisation du C et de l’ASM standard ; le langage C pour des routines/opérations n’ayant pas besoin d’être optimisées, et l’ASM pour les fonctions principaux et critiques en temps d’exécution. La structure minimale d’un code assembleur est la suivante (routine appelée _myasm) : .global _myasm _myasm: ; Instructions B .S2 B3 NOP 5 _myasm: est une étiquette indiquant le début de la définition de la routine ASM. La directive .global affecte une visibilité globale à l’entité "_myasm", de telle façon que ça soit possible de l’appeler à partir d’un code C. Une instruction assembleur a la syntaxe suivante : (nom d’instruction) (.unité) (opérandes). Ainsi l’instruction B (branchement) s’exécutera sur l’unité .S2 avec l’opérande dans le registre B3. Un branchement va changer le flux d’exécution d’un code vers une autre zone du mémoire programme, dans notre cas il s’agit de se brancher aux instructions qui se trouvent à l’adresse programme stockée dans B3. Cette valeur [adresse] contenue dans B3 est celle des instructions de la fonction mère qui doivent être exécutées juste après la fin de notre routine assembleur. Ainsi, il est impératif de se brancher à cette adresse à la fin de notre routine, afin de continuer le flux normal d’exécution de notre programme principale. Notez que l’adresse convenable est mise en B3 lors de l’appel à la fonction assembleur, il faut ainsi sauvegarder sa valeur pour pouvoir s’y brancher en fin d’instructions. 34 DSP C6000 BAHTAT Mounir On remarque également l’utilisation de NOP 5 [5 cycles sans opérations], juste après l’instruction de branchement. En effet, le branchement ne sera effectivement exécuté qu’à 6 cycles après son lancement, ainsi, l’attente de 5 cycles est exigée. Ceci est le cas pour toutes les instructions assembleur c66x, nécessitant une latence, appelée "Delay Slots" [DS], avant de pouvoir retourner un résultat/exécution. Par exemple pour le cas d’un branchement on trouve un "delay slots"=5, pour une multiplication flottante à simple précision la latence DS=3. Alors si une multiplication va durer 4 cycles pour retourner du résultat, pourquoi dit-on qu’on peut faire avec ce DSP 8 multiplications par cycle ? Une autre caractéristique des instructions assembleurs c66x est la latence sur les unités d’exécution "Functional Unit Latency" [FUL], qui indique le nombre des cycles que l’instruction assembleur doit occuper sur une unité d’exécution. Presque seules les instructions à double précision nécessitent FUL>1, par contre, les autres instructions ont quasiment tous FUL=1. Ceci dit, même si une multiplication flottante à simple précision nécessitera 4 cycles pour retourner le résultat, elle occupera l’unité .M pendant 1 seul cycle seulement, par la suite on pourra exécuter d’autres multiplications sur l’unité .M juste dans le cycle qui suit, même si la première multiplication n’a pas encore retourné un résultat, comme schématisé ci-dessous : On voit alors que ces instructions c66x peuvent fonctionner en "pipeline", on se retrouve finalement avec 1 résultat de multiplication par cycle. Finalement, on fait l’appel à notre routine assembleur à partir du C, en définissant tout d’abord le prototype "souhaité" pour la fonction assembleur, qui par exemple soit défini ainsi : void _myasm(void); L’appel se fera tout simplement par : _myasm(); On se propose ensuite de faire en assembleur standard c66x, une somme vectorielle de deux vecteurs A et B, de taille N, ayant des éléments flottants à simple précision. Le résultat de la somme est le vecteur noté S. La déclaration et l’allocation de la mémoire pour ces vecteurs serait en C ainsi : #define N 1000 float A[N]; float B[N]; float S[N]; On initialise les vecteurs d’entrée à des valeurs de test ainsi, dans la fonction main : int i; for (i=0;i<N;i++) { A[i]=(float)i; B[i]=(float)(i/2.0); } On définit ensuite le prototype souhaité pour notre routine assembleur qui fera la somme vectorielle : 35 DSP C6000 BAHTAT Mounir void _somme_asm(float *a, float *b,float *s,int n); 4 arguments sont choisis pour notre fonction ASM ; les 3 premiers sont des pointeurs vers les vecteurs A, B et S respectivement. Le dernier paramètre est la taille N de ces vecteurs. Notre routine doit alors lire les données des vecteurs A et B à partir des adresses [pointeurs] fournies en arguments, et doit stocker le résultat de la somme à partir de l’adresse du vecteur S, fournie également en argument. Les arguments fournis à un code assembleur sont automatiquement placés dans des registres bien spécifiques, ainsi : Le premier argument est placé dans A4, le 2ème en B4, le 3ème en A6, le 4ème en B6 et ainsi de suite. Notez bien que le registre B3 est pour l’adresse de retour vers la fonction mère, le registre A4 servira également comme valeur de retour de la fonction assembleur. Il est à noter également que les registres A10 vers A15 et B10 vers B15 doivent être sauvegardés si on les utilise en assembleur, car ils sont utilisés par l’interface C : 36 DSP C6000 BAHTAT Mounir Donc on commence par écrire le code minimal d’une routine assembleur : .global _somme_asm _somme_asm: B .S2 B3 NOP 5 Le code consistera en plusieurs itérations [N itérations], où à chacune, on charge 2 éléments flottants des vecteurs A et B, puis on calcule la somme et finalement on stocke le résultat vers le vecteur S. L’instruction assembleur qui permet de charger un élément de 32-bit [flottant] de la mémoire vers un registre des bancs est "LDW" [Load Word]. Les informations d’utilisations des instructions assembleurs existent dans le manuel de TI "TMS320C66x CPU and Instruction Set Reference Guide", téléchargeable à partir de la page officielle : http://www.ti.com/product/tms320c6678 Pour chaque instruction sur le manuel, on peut savoir : la syntaxe, les unités possibles d’exécution [.D1, .D2, …], description du fonctionnement, Delay Slots, Functional Unit Latency, des exemples d’utilisation. L’instruction LDW peut s’exécuter sur les unités : .D1 ou .D2. Ayant DS=4, la donnée ne sera effectivement chargée qu’après 5 cycles [4 NOP sont nécessaires]. Une syntaxe basique possible est la suivante : LDW .D *addr,dst Tous les opérandes des instructions ne peuvent être que des registres des bancs (A ou B), addr doit être remplacé par le registre contenant l’adresse duquel on veut charger la donnée, dst remplacé par le registre où sera chargée la donnée. Le code devient : .global _somme_asm _somme_asm: || LDW .D1 *A4,A31 LDW .D2 *B4,B31 B .S2 B3 NOP 5 Rappelez-vous que A4 et B4 contiennent les adresses des vecteurs A et B, qui ont été envoyées en argument. Le code chargera alors deux éléments vers les deux registres A31 et B31. A l’itération suivante on doit charger les 2 éléments qui suivent, donc l’incrémentation des pointeurs A4 et B4 est nécessaire. Sur la même instruction LDW, on peut faire également une post-incrémentation des pointeurs, vers les éléments suivants : .global _somme_asm _somme_asm: || LDW .D1 *A4++[1],A31 ; avec post-incrémentation de 1 [vers le mot suivant] LDW .D2 *B4++[1],B31 ; avec post-incrémentation de 1 [vers le mot suivant] NOP 4 B .S2 B3 NOP 5 Il est à noté que l’unité .D1 n’accède en principe qu’aux registres du banc A, et que l’unité .D2 ne pourra accéder qu’aux registres du banc B. Cependant, il se peut qu’une instruction ait des opérandes des deux bancs A et B grâce au chemin du "cross path" [1X et 2X], qui est limité à une donnée de 64-bit par direction par cycle. Les 2 instructions de chargement s’exécutent en parallèle, signalé par le symbole «||». En effet, on peut exécuter jusqu’à 8 instructions en parallèle via les 8 unités disponibles (.L1, .L2, .S1, .S2, .M1, .M2, .D1, .D2) 37 DSP C6000 BAHTAT Mounir Maintenant, l’instruction qui permettra de faire une somme de deux flottants à simple précision est FADDSP. Cette instruction peut s’exécuter sur les unités : .L1, .L2, .S1 ou .S2. Avec la syntaxe suivante : FADDSP (.unit) src1, src2, dst Ceci fera la somme de src1 et src2 et stockera le résultat dans le registre dst. Cette instruction a une latence de DS [delay slots] = 2. Juste quelques opérandes peuvent être utilisés en "cross path", signalés dans le manuel par "xop" vs. "op" pour ceux qui ne peuvent pas s’utiliser ainsi. Pour l’instruction FADDSP, seul le 2ème opérande [src2] peut s’utiliser en "cross path". Le code devient : .global _somme_asm _somme_asm: || LDW .D1 *A4++[1],A31 ; avec post-incrémentation de 1 [vers le mot suivant] LDW .D2 *B4++[1],B31 ; avec post-incrémentation de 1 [vers le mot suivant] NOP 4 FADDSP .L1X A31,B31,A30 NOP 2 B .S2 B3 NOP 5 Remarquer l’ajout d’un X à l’unité choisie .L1, indiquant l’utilisation du "cross path" pour l’opérande B31. Ensuite, il faut stocker le résultat [A30], vers l’adresse du vecteur S. Ceci se fera à l’aide de l’instruction STW [Store Word]. Cette instruction s’exécute sur les unités : .D1 ou .D2. Ayant DS=0. Une syntaxe basique possible est : STW (.unit) src,*dst. Ceci stockera le registre 32-bit src vers l’adresse contenue dans le registre dst : .global _somme_asm _somme_asm: || LDW .D1 *A4++[1],A31 ; avec post-incrémentation de 1 [vers le mot suivant] LDW .D2 *B4++[1],B31 ; avec post-incrémentation de 1 [vers le mot suivant] NOP 4 FADDSP .L1X A31,B31,A30 NOP 2 STW .D1 A30,*A6++[1] B .S2 B3 NOP 5 Finalement, il faut se brancher encore pour boucler l’exécution N fois. On va crée une étiquette nommée [boucle] marquant le début de la boucle ainsi : .global _somme_asm _somme_asm: boucle: || LDW .D1 *A4++[1],A31 ; avec post-incrémentation de 1 [vers le mot suivant] LDW .D2 *B4++[1],B31 ; avec post-incrémentation de 1 [vers le mot suivant] NOP 4 FADDSP .L1X A31,B31,A30 NOP 2 STW .D1 A30,*A6++[1] B .S2 boucle NOP 5 B .S2 B3 NOP 5 38 DSP C6000 BAHTAT Mounir On aura également besoin d’un registre comptant le nombre des itérations, et conditionnant le branchement au cas où N itérations s’est écoulé : .global _somme_asm _somme_asm: MV .S2 B6,B0 ; n boucle: || || LDW .D1 *A4++[1],A31 ; avec post-incrémentation de 1 [vers le mot suivant] LDW .D2 *B4++[1],B31 ; avec post-incrémentation de 1 [vers le mot suivant] SUB .S2 B0,1,B0 NOP 4 FADDSP .L1X A31,B31,A30 NOP 2 STW .D1 A30,*A6++[1] [B0] B .S2 boucle NOP 5 B .S2 B3 NOP 5 Tout d’abord, remarquer l’instruction : [B0] B .S2 boucle ; le registre B0 conditionne de cette manière l’exécution ou non de l’instruction de branchement. Si B0 est différent de 0 alors l’instruction s’exécute, sinon si B0 est nulle, l’instruction sera remplacée par un NOP. Toutes les instructions assembleurs c66x peuvent être conditionnées. Les registres de condition sont seulement les 6 suivants : A0, A1, A2, B0, B1, B2. L’instruction assembleur : MV .S2 B6,B0 ; copie le contenu du registre B6 [qui contiendra la valeur de N] vers le registre de condition B0 [DS=0]. Finalement dans le cœur de la boucle on décrémente à chaque itération la valeur de B0 en parallèle avec les opérations de chargement, via l’instruction SUB [soustraction entière, DS=0]. Ce code est capable de faire la somme vectorielle en un nombre des cycles CPU = 15N+7 Le code C final aura cette forme : #include <stdio.h> #include "csl_tsc.h" #define N 1000 float A[N]; float B[N]; float S[N]; void _somme_asm(float *a, float *b,float *s,int n); void main(void) { unsigned long long st,fn,total; _CSL_tscEnable(); int i; for (i=0;i<N;i++) { A[i]=(float)i; B[i]=(float)(i/2.0); } st=_CSL_tscRead (); _somme_asm(A,B,S,N); fn=_CSL_tscRead (); total=fn-st; printf("nombre des cycles=%lld\n",total); } Dans le chapitre suivant, on abordera des méthodes d’optimisation de notre code assembleur c66x, afin d’aboutir au temps d’exécution le plus court possible. 39 DSP C6000 BAHTAT Mounir Annexe du chapitre 1 : Instructions assembleurs par unité d’exécution Unité .D : Unité .L : 40 DSP C6000 Unité .S : Unité .M : BAHTAT Mounir 41 DSP C6000 BAHTAT Mounir Chapitre 2 : Ecrire un code assembleur optimisé Dans le code assembleur développé dans le premier chapitre, on trouve plusieurs opérations NOP dues aux latences des instructions c66x : .global _somme_asm _somme_asm: MV .S2 B6,B0 ; n boucle: || || LDW .D1 *A4++[1],A31 ; avec post-incrémentation de 1 [vers le mot suivant] LDW .D2 *B4++[1],B31 ; avec post-incrémentation de 1 [vers le mot suivant] SUB .S2 B0,1,B0 NOP 4 FADDSP .L1X A31,B31,A30 NOP 2 STW .D1 A30,*A6++[1] [B0] B .S2 boucle NOP 5 B .S2 B3 NOP 5 La première optimisation consiste à remplacer les NOP dans le cœur de la boucle par des opérations utiles. Ainsi, 5 instructions dues au branchement peuvent être éliminées comme suit : .global _somme_asm _somme_asm: MV .S2 B6,B0 ; n boucle: || || LDW .D1 *A4++[1],A31 ; avec post-incrémentation de 1 [vers le mot suivant] LDW .D2 *B4++[1],B31 ; avec post-incrémentation de 1 [vers le mot suivant] SUB .S2 B0,1,B0 NOP 2 [B0] B .S2 boucle NOP 1 FADDSP .L1X A31,B31,A30 NOP 2 STW .D1 A30,*A6++[1] B .S2 B3 NOP 5 On lance ci-dessus le branchement en avance, qui doit se brancher effectivement après 6 cycles ; ainsi, juste après l’exécution de l’instruction de stockage STW, on se branche de nouveau, sans latences vers l’étiquette "boucle". Ceci a pour effet de réduire le nombre des cycles CPU de 15N+7 vers 9N+7. L’unité .D est capable de charger/stocker une donnée sur 64-bit à la fois, mais jusqu’à présent on ne l’utilise que pour charger/stocker un élément de 32-bit [nombre flottant]. Il sera alors avantageux d’utiliser l’instruction LDDW à la place de LDW ; en effet, LDDW [Load Double Word] permet de charger 64-bit des données vers un pair des registres, on l’utilisera par la suite pour acquérir 2 nombres flottants du même vecteur à la fois. Une syntaxe basique possible est la suivante : LDDW .D *addr,dst_o:dst_e. La latence DS=4 également. Le dernier opérande de LDDW est un pair des registres, qui doit être l’un des couples suivants : 42 DSP C6000 BAHTAT Mounir Remarquer par exemple que A2:A1 n’est pas reconnu en tant qu’un pair de registres valide. On notera souvent un pair de registre par la notation : reg_o:reg_e ; l’entité reg_o [odd register] indique le registre impaire (A1,A3,…), tandis que reg_e [even register] indiquera le registre pair (A0,A2,…). Pour certaines instructions on utilise même des quadruplets de registres [reg_3:reg_2:reg_1:reg_0], qui doivent être l’un de ceux-ci-dessous : Pour LDDW, la post-incrémentation utilisant la syntaxe ++[1], incrémentera le pointeur d’un mot de 64-bit ; ce qui est différent du comportement lors de l’utilisation de LDW, qui dans ce cas n’incrémente le pointeur que d’une quantité de 32-bit. Il est à noter que pour LDDW, le mot 32-bit se trouvant à l’adresse addr sera stocké en dst_e, tandis que celui à addr+4 stocké en dst_o Après chargement de 2 nombres flottants de chacune des 2 vecteurs A et B en utilisant LDDW, il faut ensuite faire 2 additions flottantes. Ceci pourra se faire via une seule instruction : DADDSP [Double Single Precision Add] ; DS=2 ; La syntaxe est la suivante : DADDSP (.unit) src1_o:src1_e, src2_o:src2_e, dst_o:dst_e Cette instruction aura le même effet que les 2 instructions suivantes : FADDSP src1_o, src2_o, dst_o || FADDSP src1_e, src2_e, dst_e 43 DSP C6000 BAHTAT Mounir Finalement pour stocker 64-bit vers la mémoire, on dispose également de l’instruction STDW [Store Double Word] remplaçant STW. DS=0 ; ayant la syntaxe : STDW (.unit) src_o:src_e,*dst Il est à noter que pour STDW, src_e sera stocké à l’adresse dst ; tandis que src_o stocké à l’adresse dst+4 Le code devient : .global _somme_asm _somme_asm: SHRU .S2 B6,1,B0 ; n/2 boucle: || || LDDW .D1 *A4++[1],A31:A30 LDDW .D2 *B4++[1],B31:B30 SUB .S2 B0,1,B0 NOP 2 [B0] B .S2 boucle NOP 1 DADDSP .L1X A31:A30,B31:B30,A29:A28 NOP 2 STDW .D1 A29:A28,*A6++[1] ; fin de la boucle B .S2 B3 NOP 5 Le nombre des itérations est réduit à N/2 grâce à l’instruction SHRU (Logical Shift Right Unsigned), faisant le décalage à droite du 1er opérande [B6, la valeur de N] par une quantité du 2ème opérande [1 seule fois], stockant le résultat dans le dernier opérande [B0]. Les unités possibles pour SHRU sont .S1 ou .S2. DS=0. Un petit souci à régler est quand N est impair, il doit falloir ajouter une somme restante en dehors de la boucle de N/2 itérations. Le code devient finalement : .global _somme_asm _somme_asm: SHRU .S2 B6,1,B0 ; [n/2] = partie entière de (n/2) SUB .S2 B6,B0,B1 ; n-[n/2] SUB .S2 B1,B0,B1 ; n-2[n/2] = 0 si N est pair, ou 1 si N est impair boucle: || || LDDW .D1 *A4++[1],A31:A30 LDDW .D2 *B4++[1],B31:B30 SUB .S2 B0,1,B0 NOP 2 [B0] B .S2 boucle NOP 1 DADDSP .L1X A31:A30,B31:B30,A29:A28 NOP 2 STDW .D1 A29:A28,*A6++[1] ; fin de la boucle ; traitement du cas restant et fin de la routine 44 DSP C6000 || BAHTAT Mounir [B1] LDW .D1 *A4,A31 [B1] LDW .D2 *B4,B31 NOP 2 B .S2 B3 NOP 1 [B1] FADDSP .L1X A31,B31,A30 NOP 2 [B1] STW .D1 A30,*A6 Remarquer le conditionnement des instructions du dernier cas par le registre B1, qui contiendra 0 si N est pair, ou 1 si N est impair. Le nombre des cycles CPU est ainsi réduit de 9N+7 à 9N/2+12 Pipeline logiciel des boucles [SPLOOP : Software Pipelining Loop] On se propose ensuite d’optimiser le code précédent par un mécanisme matériel existant dans le cœur c66x, dit "SPLOOP". Le pipeline logiciel [Software Pipelining] est une technique d’ordonnancement exploitant le parallélisme des instructions (ILP) à travers les itérations d’une boucle. La figure suivante schématise le principe de fonctionnement : Sans attendre la fin d’une itération, on lance une nouvelle itération de façon concurrente. Ceci a pour effet de faire un pseudo-parallélisme de l’exécution des itérations, et par la suite réduire énormément le nombre des cycles résultant. On en distingue 3 composantes essentielles : Prolog (partie transitoire du départ), Kernel (l’état permanent de la boucle), Epilog (état d’épuisement de la boucle à la fin). Une itération est lancée après un nombre des cycles du lancement de l’itération précédente, ce nombre sera noté : ii [Intervalle d’Itération], comme montré sur la figure ci-dessous. Le nombre des cycles composant une seule itération est appelé : longueur dynamique, noté dynlen. Ainsi, le Prolog dure dynlen-ii cycles, qui est la même durée que celui de l’Epilog, tandis que le Kernel a une durée de ii cycles, se répétant tout au long de la boucle. Finalement, on remarque bien qu’il faut veiller à ce que l’intervalle d’itération [ii], soit le minimum possible afin d’augmenter la performance de l’implémentation d’une boucle. 45 DSP C6000 BAHTAT Mounir Le cœur c66x dispose d’un buffer spécialisé pour le mécanisme de SPLOOP, stockant le code d’une seule itération, et répétant son exécution suivant le fonctionnement expliqué ci-dessus. Ceci a plusieurs avantages par rapport aux boucles traditionnelles [à l’aide des branchements] : - - Code source réduit, puisque le Prolog et l’Epilog n’ont pas besoin d’être exprimés manuellement en assembleur Les instructions sont chargées 1 seule fois du mémoire programme vers le buffer [durant la 1ère itération seulement], donc cela a pour effet de réduire : l’utilisation de la bande passante vers la mémoire ainsi que la consommation de la puissance Permet des boucles très étroites et libère une unité .S pour le branchement. Tant que le branchement nécessite 6 cycles pour se brancher ! Le buffer de SPLOOP sur c66x est capable de stocker jusqu’à 14 paquets d’exécution. Ainsi, l’intervalle d’itération (ii) ne doit pas dépasser 14 L’accès au buffer SPLOOP se fait en assembleur principalement via les mots-clés SPLOOP et SPKERNEL, comme montré en exemple ci-dessous : On spécifie en premier lieu le nombre des itérations souhaitées en écrivant dans le registre ILC (Internal Loop Counter). L’écriture dans ce registre se fera par une instruction spéciale : MVC s’exécutant sur l’unité ".S2", 46 DSP C6000 BAHTAT Mounir demandant DS=3. Ensuite, l’instruction SPLOOP marquera le début de la boucle, son opérande est : l’intervalle d’itération [ii]. Dès son exécution, le buffer SPLOOP commencera à se remplir par les instructions qui suivent [I1, I2, …]. L’instruction SPKERNEL indique la fin de l’itération, et arrête le remplissage du buffer SPLOOP. La projection de ce mécanisme sur notre problème initial de la somme vectorielle, donnera l’ordonnancement possible suivant : Notez bien qu’il fallait changer la définition d’une itération de : || LDDW .D1 *A4++[1],A31:A30 LDDW .D2 *B4++[1],B31:B30 NOP 4 DADDSP .L1X A31:A30,B31:B30,A29:A28 NOP 2 STDW .D1 A29:A28,*A6++[1] Vers : || LDDW .D1 *A4++[1],A31:A30 LDDW .D2 *B4++[1],B31:B30 NOP 4 DADDSP .L1X A31:A30,B31:B30,A29:A28 NOP 3 STDW .D1 A29:A28,*A6++[1] Ajoutant un cycle, afin de ne pas avoir de conflits d’unités suivant l’ordonnancement décrit. Le code devient finalement : .global _somme_asm _somme_asm: SHRU .S2 B6,1,B0 ; [n/2] = partie entière de (n/2) SUB .S2 B6,B0,B1 ; n-[n/2] SUB .S2 B1,B0,B1 ; n-2[n/2] = 0 si N est pair, ou 1 si N est impair MVC .S2 B0,ILC NOP 3 SPLOOP 2 || LDDW .D1 *A4++[1],A31:A30 LDDW .D2 *B4++[1],B31:B30 NOP 4 DADDSP .L1X A31:A30,B31:B30,A29:A28 NOP 3 STDW .D1 A29:A28,*A6++[1] SPKERNEL 0,0 ; fin de la boucle 47 DSP C6000 || BAHTAT Mounir ; traitement du cas restant et fin de la routine [B1] LDW .D1 *A4, A31 [B1] LDW .D2 *B4, B31 NOP 4 [B1] FADDSP .L1X A31,B31,A29 NOP 3 [B1] STW .D1 A29,*A6 B .S2 B3 NOP 5 L’instruction SPKERNEL dispose de 2 opérandes, notés fstg et fcyc ; indiquant le retard à imposer juste après l’exécution de la boucle, avant de commencer l’exécution des instructions suivantes. Le nombre des cycles de retard est alors : . La notation : SPKERNEL 0,0 Donne la main d’exécution "durant l’Epilog" au code qui suit l’instruction SPKERNEL. Le code d’épuisement de la boucle [Epilog] "sera alors exécuté en parallèle" avec le code qui suit. Ceci dit, dans notre exemple, le traitement du dernier cas impair restant, sera exécuté en parallèle avec l’Epilog comme schématisé ci-dessous : Afin d’éviter tout conflit probable entre les instructions d’Epilog et le code externe, on "recommande" d’attendre la fin entière d’Epilog avant de donner la main d’exécution au code suivant. Ainsi, on spécifiera une latence de : "dynlen-ii" dans les paramètres du SPKERNEL. Avec cette nouvelle optimisation, on arrive à atteindre un nombre des cycles de l’ordre de N, à la place 9N/2. [Qui est un facteur d’accélération de x4.5] Afin de faciliter la recherche de la forme d’itération à mettre entre SPLOOP et SPKERNEL, ne provoquant pas de conflits d’unités, on recommande en premier lieu de mapper/ordonnancer les instructions sur une fenêtre du Kernel de taille [ii] : Attention aux conflits en écriture des unités Une dernière remarque importante, en analysant le code suivant : CMATMPY .M1 A3:A2, A7:A6:A5:A4, A11:A10:A9:A8 ; DS=3 NOP MPY .M1 A1,A2,A3 ; DS=1 48 DSP C6000 BAHTAT Mounir NOP L’instruction CMATMPY écrira dans 4 registres après une latence de 3 cycles, ainsi que MPY écrira dans 1 registre après une latence de 1 cycle. Le code précédent ne peut pas fonctionner correctement, car au 5ème cycle, l’unité .M1 essayera d’écrire 160 bits [5 registres], alors que le port de sortie de l’unité .M ne possède que 128-bit [d’après l’architecture mentionnée plus haut dans ce document] Par contre le code suivant : CMPY .M1 A0,A1,A3:A2 ; DS=3 NOP AVG2 .M1 A4,A5 ; DS=1 NOP Fonctionnera correctement, même si au 5ème cycle, l’unité .M1 écrira le résultat des 2 instructions ; puisque on ne stocke que 96-bit [3 registres] Ceci étant pour l’unité .M, par contre, pour les unités .L ou .S, deux écritures indépendantes provenant du même .L ou .S ne peuvent pas être réalisées sur le même cycle. Par la suite, le code suivant est incorrect, même si .L1 dispose d’un port de sortie de 64-bit (INTSPU essayera d’écrire dans A5, alors que DSPINTH essayera d’écrire dans A4) : INTSPU .L1 A1, A5 ; DS=3 NOP DSPINTH .L1 A3:A2, A4 ; DS=1 NOP 49