Telechargé par elmarghichi.mouncef

DSP C6000

publicité
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
Téléchargement