Esempio di Sviluppo ed Ottimizzaizone di codice C su piattaforme

Transcript

Esempio di Sviluppo ed Ottimizzaizone di codice C su piattaforme
Insegnamenti di
Sistemi Elettronici Dedicati 1
(Corso di Laurea in Ingegneria Elettronica)
Sistemi Elettronici Digitali 1
(Corso di Laurea Specialistica in Ingegneria delle Telecomunicaizoni)
Esempio di Sviluppo ed Ottimizzaizone
di codice C su piattaforme DSP
Edizione 2006
Giovanni Parodi
Rodolfo Zunino
Introduzione
Questo documento descrive un esempio di impiego reale dei DSP, su cui viene implementato un
modello di rete neurale per applicazioni di intelligenza artificiale nel settore della predizione e del
controllo intelligente. L’applicazione descritta in questa sede era stata inizialmente sviluppata in
linguaggio C per l’impiego su processore general purpose, e solo in un secondo momento si è
proceduto all’adattamento per impiego su piattaforma DSP.
Pertanto l’esempio qui riportato
descrive un caso realistico e completo di porting di codice da piattaforme standard ad architetture
embedded DSP-based.
Il testo si articolerà nelle seguenti sezioni:
•
Descrizione del problema analizzato
•
Studio delle caratteristiche del codice da portare su piattaforma DSP
•
Descrizione dei risultati cui si perviene a seguito delle modifiche apportate al codice.
Descrizione del problema
L’applicazione di riferimento è situata nel settore industriale dei “display intelligenti”. Questi
dispositivi rappresentano l’avanguardia nei moderni apparati TV o monitor, e sono in grado di
predire con accuratezza quanto un utente non specializzato (il generico consumatore) apprezzerà la
qualità di presentazione di un’immagine in termini di nitidezza, livello di contrasto, accentuazione
dei colori, etc. Si tratta di un problema non banale perché coinvolge aspetti cognitivi legati alla
percezione umana dei colori e delle immagini, fenomeni tutti non ancora chiariti e quindi oggetto di
ricerca da parte dei principali costruttori di televisori.
Nell’esempio qui illustrato, per la stima della qualità si adotta una “rete neurale”.
Il software è stato sviluppato dapprima per un’implementazione su un PC general purpose,
dopodiché si è deciso di procedere al suo adattamento per DSP in vista di una applicazione low-cost
a bordo di un apparato televisivo. Lo schema di principio dell’applicazione descritta in questo
documento è quello proposto nella figura 1.
2
Innanzitutto si estrae dalle immagini acquisite un set di
informazioni, di solito dette ‘features’, che costituiscono una
rappresentazione numerica delle caratteristiche dell’immagine
che si sta valutando; la rete neurale provvede quindi ad elaborarli
ed a fornire in uscita un unico valore numerico, che rappresenta
proprio la stima della qualità percepita da un generico
osservatore.
L’interesse per questo genere di applicazioni risiede nella
possibilità di adattare in maniera automatica le operazioni di
filtraggio sulla immagine visualizzata in un display commerciale
al fine di massimizzare la soddisfazione dell’utente del sistema
video.
Figura 1
La rete neurale usata per determinare la stima della qualità è una Circular Back Propagation, la cui
struttura verrà sommariamente descritta nella seguente sottosezione al fine di agevolare la
comprensione delle parti seguenti di questa dispensa.
Introduzione alla rete neurale CBP
Prima di cominciare la discussione si sottolinea che in questo paragrafo non si andrà a spiegare
come funzioni una rete neurale, ma semplicemente si descriveranno quali siano le formule
matematiche che stanno alla base del suo funzionamento. Per maggiori dettagli si rimanda alle
opportune fonti reperibili in letteratura [1] o su Internet. In figura 2 è riportato lo schema di una
possibile realizzazione di una rete neurale di tipo circular back propagation (CBP).
3
σ
x1
σ
y1
σ
y no
σ
x ni
u2
u
Σ
2
Figura 2
Si può notare la presenza, a livello di input, di un ingresso particolare, che è semplicemente la
somma dei quadrati degli ingressi. La rete nel suo formato generale è costituita da ni ingressi
{x1… x ni }, da uno strato di neuroni ‘hidden’ (in giallo nella figura 2), costituito da nh elementi, e
dallo strato output (in blu), formato da no neuroni di uscita; nel caso particolare della stima di
qualità, si ha no=1, e le relative uscite si limitano ad un’unica grandezza scalare, y.
Il funzionamento della rete può essere illustrato mantenendo la distinzione tra i diversi strati.
Nello strato hidden il neurone j-esimo esegue sugli ingressi la seguente trasformazione:
ni
ni
i =1
i =1
r j = w j ,0 + ∑ w j ,i xi + w j ,( ni +1) ∑ xi
2
(1)
dove:
•
wj,0
è un valore indipendente dagli inputs, che indica il bias di tale neurone
•
wj,i
indica il peso della connessione tra l’ingresso i-esimo e il neurone j-esimo
•
w j ,( ni +1) indica il peso relativo alla connessione tra l’ingresso aggiuntivo e il neurone di
hidden j-esimo.
•
rj
rappresenta lo ‘stimolo’ che attiva il j-esimo neurone hidden.
L’uscita effettiva del neurone hidden j-esimo sarà data dal suo ‘livello di attivazione’, ossia una
elaborazione nonlineare del segnale di stimolo:
h j = σ (r j )
dove σ(x) è detta “funzione di attivazione”; nel caso che andremo a analizzare, σ(x) è la tangente
iperbolica, di cui nella figura seguente si riporta il grafico:
4
1.2
1
0.8
0.6
0.4
σ(x) 0.20
-0.2
-0.4
-0.6
-0.8
-1
-1.2
-10
-6
-2
2
6
10
x
Figura 3
Nello strato di output, il neurone di uscita elabora le uscite di attivazione dei neuroni hidden, ed
esegue su esse una analoga trasformazione:
nh
r = w0 + ∑ w j ⋅ h j
(2)
j =1
dove:
w0
indica il bias del neurone di output;
wj
indica il peso della connessione tra l’uscita del neurone hidden j-esimo ed il neurone di
output;
r
rappresenta lo stimolo del neurone di output.
Analogamente, l’uscita della rete sarà data dall’attivazione del k-esimo stimolo:
y k = σ (rk )
(3)
Nel seguito ci saranno utili le formule 1, 2 e 3, poiché si andrà a parlare di come se ne sia
implementato il calcolo su piattaforma DSP al fine di ottimizzare le prestazioni.
Primo passo: codice per processori general purpose
Come precedentemente detto, il codice del quale si tratta in questa sede era stato dapprima
realizzato e testato su piattaforma general purpose con compilatore Visual Studio 6.0. Tale scelta è
dovuta fondamentalmente alla necessità di realizzare innanzi tutto un codice perfettamente
funzionante e di dedicarsi solo in un secondo tempo alla sua ottimizzazione. Si vuol far notare che
sebbene quello qui proposto sia un esempio didattico, questo flusso di sviluppo di una applicazione
5
per processori DSP è standard anche in ambiti applicativi di alto livello, come testimoniato dalla
documentazione della Texas Instruments nella quale è rappresentato il seguente flow chart.
Figura 4
In pratica la direttiva che viene fornita da parte della Texas Instruments per pervenire alla
ottimizzazione di un programma è quella di scrivere codice in ANSI C standard, di fare uso di
tecniche di ottimizzazione avanzate su di esso (alcune delle quali non sono supportate da
compilatori ANSI C per piattaforma general purpose in quanto peculiari della specifica CPU) e
solamente come ultimo passo si considera l’eventualità di scrivere codice assembler in luogo del C.
I primi due passi di quanto proposto in figura 3 saranno descritti in maniera sufficientemente
accurata nell’ambito di questo documento.
Al fine di poter spiegare in maniera chiara le operazioni effettuate sul codice, nel seguito verranno
riportate le parti del codice C utilizzato su cui si è concentrata l’opera di ottimizzazione.
6
Definizione delle costanti
#define Niv
#define No
#define Nh
#define
CBP
#define
Ni
(short
(short
(short
(short
(short
int)
int)
int)
int)
int)
8
1
6
1
(Niv+CBP)
Queste costanti consentono di definire a compile time una serie di parametri che caratterizzano la
rete neurale CBP preposta alla valutazione della qualità delle immagini. Il loro significato è indicato
nella tabella seguente:
PArametro
Niv
Significato
Numero degli ingressi “veri”, ovvero sia gli xi, escludendo l’ingresso
caratteristico della CBP evidenziato in figura 2.
No
Numero di neuroni di output della rete neurale
Nh
Numero di neuroni nello strato di hidden
Se impostato a 1 significa che la rete è effettivamente una CBP, in
CBP
caso contrario, si realizza via software una rete neurale BP
corrispondente a quella indicata in figura 2 ma senza la parte
evidenziata mediante il cerchio.
Numero di ingressi della rete neurale, è la somma degli ingressi “veri”
Ni
(ovvero sia quelli indicati con il nome xi) e dell’eventuale ingresso
CBP (costituito dalla somma dei quadrati delle xi)
Si vuol far notare come teoricamente sarebbe possibile leggere questi parametri da un file di
configurazione (per quanto riguarda la versione che viene eseguita su piattaforma general purpose)
o impostarli mediante un segnale di ingresso alla scheda del DSP. Tuttavia si ricorda che il sistema
descritto in questo documento è una rete neurale della quale è già stata portata a termine la fase di
training, e pertanto il numero di neuroni da utilizzare è già stato fissato. Al contrario nel corso del
test ciò che viene cambiato è solo il set di dati di input sui quali la rete deve effettuare la valutazione
della qualità. Occorre inoltre evidenziare che oltre a questa motivazione, l’impiego di costanti quali
parametri di configurazione della rete neurale è assai utile poiché ciò garantisce una maggiore
efficacia di ottimizzazione per il compilatore C (infatti una costante ha un valore definito a compile
time e che non può essere modificato in alcun modo nel corso della esecuzione del codice). Questa
affermazione verrà esemplificata nelle sezioni successive del documento, ed ha valenza non solo
quando si opera su architetture embedded ma anche su processori general purpose. In quest’ultimo
7
caso la differenza può non apparire evidente principalmente per via della grande velocità dei PC
moderni e per la scarsa capacità di ottimizzazione del codice da parte dei compilatori quali Visual
Studio. Al contrario impiegando compilatori ottimizzati per una specifica architettura hardware
(compilatore C Intel) le differenze possono essere notate anche su PC.
Subroutine per il calcolo dell’output della rete neurale (equazione 3)
int eval_output ( short int l, int caa, short int *data, short int *target,
short int *pwhi, short int *pwoh, short int *psh, short int
*pso, short int *peo )
{
short int
i, j, k;
for (j=1; j<1+Nh; j++)
{
psh[j] = 0;
for (i=0; i<Ni+1; i++)
{
psh[j]= psh[j]+pwhi[i+(Ni+1)*(j-1)+1]*data[i+l*(Ni+1)]
/Eleva2(LENGHT-LEFT);
}
psh[j]= fattlt( psh[j], sigltq );
}
for (k=0; k<No; k++) {
pso[k] = 0;
for (j=0; j<Nh+1; j++)
{
pso[k]=pso[k]+pwoh[k*j+j]*psh[j]/Eleva2(LENGHT-LEFT);
}
pso[k]= fattlt( pso[k], sigltq );
}
return(pso);
}
Questa routine permette di valutare il valore di r nella formula (2).
Tra
i
parametri
della
funzione
Funzione EVAL_COSTO
Formula 2
eval_costoq e i simboli che compaiono
pso[]
w0
nella funzione 2 intercorrono le relazioni
pwh[]
wj
indicate nella tabella a fianco.
data[]
hj
8
Subroutine per il calcolo della funzione di attivazione
Questa funzione consente di calcolare il valore della funzione di attivazione della rete neurale
CBP in maniera efficiente. In effetti la funzione σ, il cui profilo è disegnato nella figura 3, per
essere valutata richiede un notevole dispendio di potenza di calcolo. Per evitare di dover incorrere
tutte le volte in tale handicap si è scelto di quantizzare la funzione tangente iperbolica e di
memorizzarne un set di campioni in una LUT (Look Up Table), ovvero sia in una tabella (il cui
contenuto è calcolato off-line) posta sulla memoria del DSP. Per determinare quale sia il valore di
σ(x) corrispondente a un certo ingresso si usa l’ingresso (x, corrispondente a rk ) quale indice
dell’array data contenente la LUT.
Si fa notare che vista la simmetria
rispetto allo 0 della figura 3, nella
short int fattlt( short int x, short int *data)
{
short int reslt, y;
y=(short int) fabs(x);
if((y) >= NCAMP)
reslt = data[NCAMP-1];
else
reslt = data[y];
if(x<0)
reslt=-reslt;
LUT si memorizza solamente il
contenuto del primo quadrante
delle coordinate cartesiane. Ciò
giustifica la presenza del costrutto
condizionale if(x<0).
return reslt ;
}
La sezione
if((y) >= NCAMP)
invece è giustificata dal fatto che oltre una certa ascissa si
considera la funzione coincidente con il suo asintoto matematico.
Subroutine per la normalizzazione del numero short int a floating point.
short int Eleva2(short int potenza)
{
short int risultato;
if (potenza<0) {
printf("Error: non sono ammesse potenze negative \n");
exit (-1);
}
risultato = 1 << potenza;
return risultato;
}
Questa routine consente di calcolare il valore di 2potenza. Tale valore è utile per utilizzare la
rappresentazione dei numeri nel formato fixed point Q15. La scelta di adottare una rappresentazione
fixed point dei numeri è dovuta alla sua maggiore velocità rispetto alle operazioni eseguite con
numeri floating point.
9
Le tre funzioni C descritte sino a questo punto sono perfettamente funzionanti ma studiate per
processori general purpose; i problemi che si incontrano nel suo utilizzo su DSP risiedono nel fatto
che non si tiene conto di alcune peculiarità del DSP stesso e nel fatto che non si valuta la pesantezza
di alcuni tipi di operazioni. Per tale motivo nel capitolo seguente si andrà a discutere quali siano i
passi da compiere per migliorare le prestazioni del software.
Secondo passo: ottimizzazioni di base
Il primo compito che ci si è prefissi è stato quello di correggere alcuni evidenti errori
nell’implementazione del codice proposta nel paragrafo precedente.
Concentrando dapprima l’attenzione sulla funzione eval_output occorre notare che l’istruzione:
for (j=0; j<Nh+1; j++)
{
pso[k]=pso[k]+pwoh[k*j+j]*psh[j]/Eleva2(LENGHT-LEFT);
}
può essere agevolmente tradotta in maniera più efficiente come segue:
for (j=0; j<Nh+1; j++)
{
pso[k]=pso[k]+pwoh[k*j+j]*psh[j];
}
pso[k]= pso[k]/Eleva2(LENGHT-LEFT)
In questo modo si passa da Nh+1 divisioni a una sola, con evidente miglioramento delle
prestazioni. Occorre tuttavia notare che questa modifica se effettuata senza prestare attenzione ai
dati in uso può portare a bachi difficili da individuare. Infatti mentre nella prima versione del codice
pso[k] ha una dinamica limitata (ogni volta gli si somma un numero diviso per 2LENGHT-LEFT), nel
secondo caso pso[k] può, tramite la somma di Nh+1 termini diventare troppo grande per essere
rappresentato adeguatamente dal tipo di dati scelto per pso[] e dare pertanto origine ad un overflow
del dato. La soluzione a questo genere di inconveniente consiste nel “promuovere” il tipo di dato di
pso[] (per esempio da short int a int) oppure nel valutare in maniera attenta i dati per poter essere
certi che in nessuna condizione a run time si verifichi un overflow.
A questo punto si può rivolgere l’attenzione sul ricorso alla chiamata ad una funzione esterna
come la Eleva2 per effettuare una divisione per una potenza di 2; si tratta infatti di una operazione
controproducente poiché la divisione per 2x può essere più efficacemente tradotta in uno shift a
destra di x posizioni. Inoltre la chiamata a una routine esterna comporta la rottura della pipeline e
quindi un degrado delle prestazioni. La soluzione a questo “difetto” verrà descritta nella sezione
successiva di queste dispense.
10
L’ultima ottimizzazione evidente che può essere effettuata anche su un sistema general purpose
senza far ricorso allo sfruttamento delle peculiarità di un DSP riguarda il calcolo del valore
dell’output del neurone di uscita. In effetti, il codice proposto in questa sede è stato studiato per
implementare una generica rete neurale CBP con un numero di neuroni nello stadio di out pari a No
(si veda il codice di eval_output( ) riportato a pagina 7). Tuttavia data la peculiarità del sistema
software implementato, in questa applicazione No vale 1 (il giudizio sulla qualità dell’immagine è
uno solo (Ottimo, buono, discreto etc)) e pertanto è possibile eliminare il ciclo for più esterno dalla
parte finale del codice eval_output che diventa:
int eval_output ( short int l, int caa, short int *data, short int *target,
short int *pwhi, short int *pwoh, int *psh, short int pso,
short int *peo )
{
short int
i, j, k;
for (j=1; j<1+Nh; j++)
{
psh[j] = 0;
for (i=0; i<Ni+1; i++)
{
psh[j]= psh[j]+pwhi[i+(Ni+1)*(j-1)+1]*data[i+l*(Ni+1)]
}
psh[j]/= Eleva2(LENGHT-LEFT);
psh[j]= fattlt( psh[j], sigltq );
}
pso = 0;
for (j=0; j<Nh+1; j++)
{
pso=pso+pwoh[j]*psh[j];
}
pso /= Eleva2(LENGHT-LEFT);
pso= fattlt( pso, sigltq );
return(pso);
}
Dall’osservazione del codice si può a questo punto notare come esso sia costituito da una serie di
prodotti scalari che tuttavia sono lasciati on line nel codice. La funzione definitiva è stata pertanto
realizzata come segue:
11
int eval_output ( short int l, int caa, short int *data, short int *target,
short int *pwhi, short int *pwoh,short int *psh, short int
pso, short int peo)
{
short int
i, j, k, temp1, temp2;
for (j=1; j<1+Nh; j++)
{
temp1 = dotproduct(&(pwhi[(Ni+1)*(j-1)+1]),&(data[l*(Ni+1)]),
(Ni+1));
psh[j]= fattlt( temp1, sigltq );
}
pso = 0;
pso = dotproduct(pwoh,psh, Nh+1);
pso = fattlt( pso, sigltq );
return pso;
}
Il passo successivo è stata ovviamente la verifica dell’efficacia delle ottimizzazioni compiute. I
risultati dapprima sono stati scadenti e si è potuto notare che nonostante uno speed up considerevole
conseguito grazie alla operazione di divisione non ripetuta tutte le volte, la chiamata alla funzione
dot_prod “rompe” la pipeline. In effetti i manuali della Texas Instruments specificano che il
compilatore non è in grado di sfruttare a dovere la pipeline quando all’interno di un loop si procede
alla chiamata a una subroutine esterna. Ciò è dovuto al fatto che in questa situazione il programma
abbandona il flusso normale del programma, salva sullo stack il set di informazioni di interesse e
passa a eseguire codice esterno a quello che era in esecuzione fino a quel momento.
La soluzione a questo genere di problema può essere di due tipi:
•
Espansione dell’intero codice inline senza far uso di una funzione separata ad hoc. Questo
approccio tuttavia ha l’aspetto negativo di rendere complessa l’interpretazione del codice e
la fase di debug/correzione di eventuali errori.
•
Utilizzo di una funzione “isolata” sfruttando però l’istruzione inline (siamo ancora nello
standard ANSI C).
L’uso dell’inline comporta un incremento dell’occupazione di memoria di un programma poiché
si traduce nell’inserimento del codice a essa corrispondente laddove avviene la chiamata alla
routine dotproduct.
Il codice per la funzione dotproduc diviene pertanto:
12
inline short int
dotproduct(
short int *restrict a, short int *restrict b,
int N)
{
int i=0;
int temp=0;
short int result;
short int temp_neg=0;
for(i=0;i<N;i++)
{
temp+=(a[i]*b[i]);
}
result=temp/Eleva2(LENGHT-LEFT);
return result;
}
Il significato della parola chiave restrict verrà descritto nel seguito.
Per quanto riguarda la funzione fattlt() si è leggermente modificata la maniera in cui viene
( )
calcolato il valore finale di y k = σ rk , al fine di evitare un ricorso esplicito all’if.
inline short int fattlt( short int x, short int *data)
{
short int reslt, y;
y=abs(x);
reslt = (y>=NCAMP)? (data[NCAMP-1]):(data[y]);
reslt = reslt-2*reslt*(x<0);
return reslt ;
}
E’ da notare che l’espressione (x<0) vale 1 se tale disuguaglianza è soddisfatta, 0 altrimenti.
Quindi la riga reslt = reslt-2*reslt*(x<0) è del tutto equivalente a
if(x<0)
reslt=-reslt;
Sebbene questo approccio ci costringa a compiere una moltiplicazione per 2 in più rispetto a
prima, esso apporta dei benefici al tempo di esecuzione dato che tale operazione si traduce in un
semplice shift.
Tornando alla implementazione della subroutine dotproduct() proposta all’inizio di questa pagina,
la parola chiave restrict consente di comunicare al compilatore che l’array che si sta indicando non
presenta “sovrapposizioni” con altri array. In queste condizioni il compilatore può procedere a
ottimizzazioni ad hoc che non sono effettuabili nel caso generale.
13
Si consideri per esempio il seguente codice:
void func(int *x, int *y, int *w, int *z)
{
int i=0;
for(i=0:i<10;i++)
x[i]=y[i]*w[i];
// M1
for(i=0:i<10;i++)
z[i]=y[i]*w[i];
// M2
}
Il DSP C6701 ha a disposizione due unità aritmetiche M preposte alla moltiplicazione; Quindi il
codice precedente potrebbe sembrare altamente parallelizzabile, dato che le due moltiplicazioni M1
e M2 potrebbero essere eseguite in parallelo. Tuttavia se ipotizziamo di chiamare la routine
precedente come segue func(a,b,c,a), si vede che non è possibile effettuare la parallelizzazione, in
quanto la x e la w del codice precedente andrebbero a coincidere e si dovrebbe prima terminare il
calcolo di M1 per poi passare a M2. Per questo motivo il compilatore deve generare un codice nel
quale M1 e M2 non sono calcolati in parallelo. Tuttavia se si è certi che x,y,w,z non si
“sovrappongano”, allora si può passare questa informazione aggiuntiva al compilatore scrivendo la
funzione come segue:
void func2(int *restrict x, int * restrict y, int * restrict w, int * restrict z)
{
int i=0;
for(i=0:i<10;i++)
x[i]=y[i]*w[i];
// M1
for(i=0:i<10;i++)
z[i]=y[i]*w[i];
// M2
}
Dopo aver inserito il restrict il compilatore è in grado di ottimizzare in maniera migliore il codice,
tuttavia occorre fare attenzione a non chiamare la funzione func2 come func2(a,b,c,a), in quanto il
risultato generato è imprevedibile.
Un esempio ancora più evidente dei problemi che possono nascere allorché non si faccia ricorso
alla parola chiave restrict si può vedere nei codici seguenti:
void twiddle1(int *xp, int *yp)
{
*xp += *yp;
*xp += *yp;
}
void twiddle2(int *xp, int *yp)
{
*xp += 2* *yp;
}
14
La prima versione del programma richiede due differenti accessi alla memoria per leggere il
valore di *yp, due operazioni di somma e due store su *xp; può tuttavia sembrare scontato che un
compilatore ottimizzato generi il codice indicato nella seconda colonna della tabella, in cui si
effettua un fetch del valore memorizzato in *yp, uno store in *xp, una somma e una moltiplicazione
per due facilmente traducibile in uno shift a sinistra. Se così avvenisse le prestazioni salirebbero in
maniera considerevole, ma nessun compilatore eseguirà questo genere d’ottimizzazione. Si
consideri, infatti, il caso in cui *xp e *yp puntino alla medesima cella di memoria: le subroutine
sopra riportate produrrebbero il risultato qui di seguito indicato:
•
twiddle1:
o *xp +=*xp Î *xp = *xp + *xp =2* *xp
o *xp +=*xp Î *xp = 2* *xp + 2* *xp = 4* *xp
•
twiddle2:
o *xp += 2* *xp Î *xp = 3* *xp
Questo esempio evidenzia cosa accade se non si garantisce il mancato aliasing dei puntatori in
memoria: il compilatore deve cercare di ottimizzare al massimo le prestazioni del codice facendo
tuttavia in modo da non inficiare in alcun modo la correttezza dei calcoli effettuati. Usando la
parola chiave restrict il compilatore sarebbe in grado di generare il codice di twiddle2 a partire da
quello di twiddle1, con il vincolo tuttavia da parte del compilatore di garantire di non usare
parametri della funzione che possano dar luogo a errori nell’esecuzione del programma.
Forniamo ora il confronto tra i risultati ottenuti nelle due versioni del codiceper il calcolo
dell’output della rete neurale CBP:
Passo
1
2
Dimensione codice
508
1572
Cicli di ck per iterazione
515
144
Si può notare come il tempo di esecuzione sia calato molto, a fronte di un sensibile aumento
dell’occupazione di memoria richiesta dalla funzione eval_output().
Qui di seguito si riporta il feedback fornito dal compilatore CCS (Code Composer Studio, è
l’ambiente di sviluppo per DSP della Texas Instruments) a proposito dello sfruttamento delle unità
funzionali del DSP.
;*----------------------------------------------------------------------------*
;*
SOFTWARE PIPELINE INFORMATION
;*
;*
Loop source line
: 208
;*
Loop opening brace source line
: 209
;*
Loop closing brace source line
: 212
;*
Known Minimum Trip Count
: 1
;*
Known Maximum Trip Count
: 6
;*
Known Max Trip Count Factor
: 1
15
;*
Loop Carried Dependency Bound(^) : 9
;*
Unpartitioned Resource Bound
: 7
;*
Partitioned Resource Bound(*)
: 7
;*
Resource Partition:
;*
A-side
B-side
;*
.L units
1
2
;*
.S units
1
6
;*
.D units
7*
7*
;*
.M units
6
4
;*
.X cross paths
1
2
;*
.T address paths
7*
6
;*
Long read paths
0
1
;*
Long write paths
0
0
;*
Logical ops (.LS)
0
1
(.L or .S unit)
;*
Addition ops (.LSD)
7
5
(.L or .S or .D unit)
;*
Bound(.L .S .LS)
1
5
;*
Bound(.L .S .D .LS .LSD)
6
7*
;*
;*
Searching for software pipeline schedule at ...
;*
ii = 9 Did not find schedule
;*
ii = 10 Schedule found with 4 iterations in parallel
;*
;*
Register Usage Table:
;*
+---------------------------------+
;*
|AAAAAAAAAAAAAAAA|BBBBBBBBBBBBBBBB|
;*
|0000000000111111|0000000000111111|
;*
|0123456789012345|0123456789012345|
;*
|----------------+----------------|
;*
0: |*** * *******
|************* * |
;*
1: |*** * *******
|************* * |
;*
2: |*** * *******
|**********
* |
;*
3: |***** *******
|*** ******
* |
;*
4: |*************
|**********
* |
;*
5: |*************
|**********
* |
;*
6: |*************
|*** ******
* |
;*
7: |************
|*** ******
* |
;*
8: |***** *******
|************ * |
;*
9: |***** *******
|************ * |
;*
+---------------------------------+
;*
;*
Done
;*
;*
Epilog not removed
;*
Collapsed epilog stages
: 0
;*
;*
Prolog not removed
;*
Collapsed prolog stages
: 0
;*
;*
Minimum required memory pad : 0 bytes
;*
;*
For further improvement on this loop, try option -mh60
;*
;*
Minimum safe trip count
: 4
;*----------------------------------------------------------------------------*
16
I punti di maggiore interesse nel testo sopra riportato riguardano il fatto che il programma
generato presenta quale collo di bottiglia non le unità .M, come sarebbe lecito attendersi, ma le
unità .L,.S e .D. In effetti si ricorda che
Unità funzionale
la “distribuzione” dei compiti tra le
.M
Moltiplicazioni
.L
Operazioni aritmetiche e di confronto
.S
Operazioni di shift dei dati
.D
Operazioni di fetch
varie unità funzionali è quella riportata
nella tabella a fianco, per cui, essendo il
nostro codice costituito da ripetute
chiamate al prodotto scalare, sarebbe
Tipologia di operazioni
prevedibile che il collo di bottiglia dell’intera applicazione fossero proprio le .M. Si tratta a questo
punto di spiegare perché non sia così e quali “provvedimenti” si possono prendere per migliorare le
prestazioni.
Tale studio sarà compiuto nel dettaglio nel prossimo capitolo.
Terzo passo: ottimizzazioni avanzate
Come evidenziato nei feedback proposti nella sezione precedente, le unità D preposte al fetch dei
dati costituiscono il collo di bottiglia dell’intero codice fin qui sviluppato. Ciò è testimoniato dalla
riga:
.D units
7*
7*
Infatti, essa indica che per ogni iterazione del loop più interno della funzione eval_costo(), le unità
.D sono utilizzate 14 volte, in maniera bilanciata tra i due datapath del C6701. Il feedback inoltre
tramite la presenza dell’asterisco rende subito evidente quali siano le unità funzionali costituenti il
collo di bottiglia della applicazione. Al fine di far diminuire il carico di lavoro delle unità preposte
al fetch dei dati, si è pensato di sfruttare una tecnica detta allineamento in memoria dei dati che
comporta solo una lieve perdita in memoria per via della necessità di fare il padding. Per capire la
tecnica di allineamento in memoria dei dati occorre ricordare che in un processore vengono
effettuati caricamenti di dati della dimensione di una word (nel caso del C6701 sono 32 bit) e che
gli indirizzi di memoria da cui prelevare i dati non sono configurabili al 100%. Da un punto di vista
tecnico si dice che un oggetto di S byte collocato all’indirizzo A ha un accesso allineato alla
memoria (più brevemente è allineato in memoria) se è verificata la condizione:
A mod S = 0
17
ovvero sia se A è un multiplo intero di S. In caso contrario si parla di accesso disallineato.
L’oggetto indirizzato potrà in generale essere costituito da un numero di byte variabile e le
occasioni in cui si verifica accesso disallineato per ciascuna evenienza sono evidenziate nella
tabella seguente.
Valore di S
valore di A che genera accesso disallineato
S = 1 (byte)
Mai, perché A mod 1=0 sempre
S = 2 (byte)
Valori dispari di A (1 mod2 = 3 mod 2 = (2n+1)mod 2 =1)
S = 4 (byte)
A non multiplo di 4
S = 8 (byte)
A non multiplo di 8
Un riferimento disallineato alla memoria comporta un numero più o meno di grande di altri
riferimenti disallineati alla memoria. Per capire ciò si propone uno schema in cui si illustra cosa
accade quando si accede a un dato della dimensione di una word (32 bit) non allineato in memoria.
La CPU (che può effettuare accessi solamente a indirizzi allineati alla dimensione della word) è
costretta a effettuare due accessi distinti per prelevare la parte alta e quella bassa del dato, con
conseguente raddoppio degli accessi alle unità D.
Tornando al nostro codice si può pertanto dire che l’allineamento in memoria dei dati (che sono di
tipo short int) consente al compilatore di effettuare il caricamento di due dati contigui ad ogni
operazione di fetch. Tuttavia occorre notare che i dati che costituiscono gli Nh array dei vettori dei
pesi associati ai neuroni di hidden della CBP erano stati memorizzati uno di seguito all’altro nello
stesso vettore. In pratica lo schema era quello proposto nella immagine seguente
18
Si è scelta questa organizzazione in quanto allocando tutti i pesi dei neuroni di hidden in unico
array, per modificare Nh era sufficiente ridefinire la dimensione di tale vettore, anziché cambiare il
numero di array definiti. Tuttavia questa soluzione dà alcuni problemi con l’allineamento in
memoria dei dati: infatti, essendo gli elementi che compongono l’array degli unsigned short a 16 bit
e essendo la parola del DSP a 32 bit, per riuscire a fare in maniera corretta l’allineamento in
memoria occorre che il numero di elementi presenti in ciascuno dei pwhi_j sia pari. Qualora ciò non
sia vero (ovvero sia nel caso in cui vi sia un numero
dispari di neuroni nello strato di hidden) è possibile
rimediare inserendo uno 0 di padding alla fine di
ognuno dei sotto array, come indicato nella figura a
destra in cui la zona annerita corrisponde a un
elemento inizializzato col valore 0.
L’utilizzo di uno 0 di padding, infatti, consente di non modificare il risultato della subroutine di
calcolo del prodotto scalare e di pervenire nel contempo a un miglioramento delle prestazioni del
codice. Una volta organizzati in questa maniera gli array si è stati in grado di sfruttare
l’allineamento in memoria dei dati che ha fatto ottenere un discreto speed up del programma.
La fase successiva del lavoro ha comportato la modifica della tecnica di gestione dei dati su cui
lavora la funzione eval_output().L’idea è stata allora quella di specializzare il codice in maniera
estrema sul nostro problema specifico, per cui la dot_prod() non prende più parametri in ingresso
ma opera direttamente su variabili allocate globalmente. Ciò fa sì che il compilatore sia in grado di
migliorare in maniera ancora più spinta le proprie ottimizzazioni. Per fare un esempio della
differente ottimizzazione utilizzabile allorché si usano variabili globali rispetto a quello locali è
sufficiente considerare che con le prime è possibile specificare al compilatore non solo che una
certa variabile è allineata in memoria, ma anche quale sia il banco di memoria sulla quale è stata
memorizzata. Quest’ultima informazione non è invece disponibile allorché si opera su variabili
locali. In particolare la memoria interna del DSP C6701 è organizzata in 4 differenti banchi a
ciascuno dei quali può essere effettuato un solo accesso per volta
(ovvero sia il fetch di soli 32 bit per volta). Per far capire quali
possano essere le conseguenze negative di questo tipo di limitazione, è
sufficiente considerare il codice esemplificativo riportato a destra.
19
for(int i=0; i<10; i++)
{
result = a[i]*b[i];
}
Supponendo che result sia un array di valori short int a 16 bit memorizzato nella memoria interna
del DSP, la massima velocità alla quale può essere eseguito questo codice è quella raggiungibile
effettuando due iterazioni del ciclo for in parallelo. Tuttavia ciò è possibile solamente se a[] e b[]
sono contenuti in due banchi di memoria distinti così che in un’operazione di fetch vengono
caricate a[i] e a[i+1] (sfruttando l’allineamento in memoria di a[]) mentre con un altro fetch
(eseguito contemporaneamente al primo grazie al fatto che si tratta di array posti in due banchi di
#pragma DATA_MEM_BANK(data_aligned,0)
short int data_aligned[DIM_PADDING_Ni*Npt];
#pragma DATA_MEM_BANK(pwhi_aligned,2)
short int pwhi_aligned[DIM_PADDING_Ni*Nh+1];
short int
wohint[(Nh+1)];
#pragma DATA_MEM_BANK(wohint,0)
short int
shq[Nh+1];
#pragma DATA_MEM_BANK(shq,2)
memoria distinti) si caricano b[i] e b[i+1]. Oltre a questa funzionalità, l’uso di
DATA_MEM_BANK garantisce anche la possibilità di disporre in maniera allineata i dati in
memoria; Tuttavia affinché ciò sia vero occorre che il valore di num sia un numero pari.
Infatti, tra le due seguenti dichiarazioni di variabili:
#pragma DATA_MEM_BANK (x,0)
#pragma DATA_MEM_BANK (y,1);
short x;
short y;
c’è la differenza che x è allineata in memoria mentre y non lo è.
Oltre a queste modifiche si è deciso anche di disabilitare il controllo degli interrupt, in
considerazione della velocità della routine sviluppata (può essere accettabile disattivare gli interrupt
ma solo per intervalli di tempo limitati). Inoltre si è anche sfruttata una direttiva di preprocessore
che consente di effettuare il loop unrolling del codice per il calcolo del prodotto scalare.
20
A questo punto possiamo passare alla descrizione della funzione eval_output( ).
#pragma FUNC_INTERRUPT_THRESHOLD (eval_output, -1);
int eval_output ( short int l, int caa,const short int *restrict data,short int pso)
{
short int
i, j, k;
int temp1,temp2;
_nassert((int) pwhi_aligned & 0x3 == 0);
_nassert((int) data_aligned & 0x3 == 0);
#pragma UNROLL(Nh)
for (j=1; j<Nh+1; j++)
{
temp1 = dotproduct_aligned(&(pwhi_aligned[DIM_PADDING_Ni*(j1)]),&(data_aligned[l*DIM_PADDING_Ni]), DIM_PADDING_Ni);
shq[j] = fattlt( temp1, sigltq);
}
_nassert((int) wohint & 0x3 == 0);
_nassert((int) shq & 0x3 == 0);
temp1 = dotproduct_aligned2(wohint, shq, Nh+1);
pso = fattlt(temp1, sigltq );
return pso;
}
Riguardo a questo listato si fa notare che:
•
#pragma FUNC_INTERRUPT_THRESHOLD: consente di disabilitare il monitoraggio
degli interrupt
•
_nassert((int) pwhi_aligned & 0x3 == 0); serve a comunicare al compilatore che la
variabile pwhi_aligned è allineata in memoria.
La subroutine fattlt( ) non presenta modifiche di rilievo a parte l’uso di una intrinsic _abs( ) in
luogo di fabs( ).
inline short int fattlt( short int x, short int *data)
{
short int reslt, y;
short int temp;
int bool;
y=_abs(x);
temp = (y>=NCAMP)? (data[NCAMP-1]):(data[y]);
reslt = temp-(x<0)*2*temp;
return reslt ;
}
21
La subroutine dotproduct_aligned infine è stata modificata come indicato nei paragrafi precedenti,
e si è pervenuti al codice indicato qui di seguito.
//***************************************************************************
//
Dot product per dati aligned
//***************************************************************************
inline short int dotproduct_aligned(const short int *restrict a,const short int
*restrict b,int N)
{
int i=0;
int temp1=0;
short int result;
_nassert(((int) a & 0x3) == 0);
_nassert(((int) b & 0x3) == 0);
#pragma UNROLL(DIM_PADDING_Ni)
for(i=0;i<N;i++)
{
temp1 +=(a[i]*b[i]);
}
//---------------------------------------------------------------------------//
result = temp1/Eleva2(LENGHT-LEFT);
result = temp1>>(LENGHT-LEFT);
result+=(temp1<0);
//---------------------------------------------------------------------------return result;
}
Conclusioni
Nella tabella seguente si riportano i risultati relativi alla occupazione di memoria e al tempo di
esecuzione del codice nei tre differenti passi di ottimizzazione compiuti.
Passo di ottimizzazione
1
2
3
Dimensione codice
508
1572
756
Ck per iterazione
515
144
100
Quindi l’ottimizzazione apportata nel terzo passo comporta un crollo del tempo di esecuzione ma
fa anche calare la dimensione del codice corrispondente alla subroutine.
22
In questa pagina si propone il feedback generato dal compilatore del CCS al termine delle nostre
ottimizzazioni: si può notare che il collo di bottiglia dell’intera applicazione è diventato a questo
punto il calcolo dei prodotti, come era facile attendersi dalla struttura del programma che si è
implementato. Inoltre si noti il collapsing almeno parziale di prolog e epilog e la diminuzione del
Loop Carried Dependency Bound.
;*----------------------------------------------------------------------------*
;*
SOFTWARE PIPELINE INFORMATION
;*
;*
Loop source line
: 247
;*
Loop opening brace source line
: 248
;*
Loop closing brace source line
: 251
;*
Known Minimum Trip Count
: 6
;*
Known Maximum Trip Count
: 6
;*
Known Max Trip Count Factor
: 6
;*
Loop Carried Dependency Bound(^) : 6
;*
Unpartitioned Resource Bound
: 6
;*
Partitioned Resource Bound(*)
: 6
;*
Resource Partition:
;*
A-side
B-side
;*
.L units
3
1
;*
.S units
3
3
;*
.D units
6*
3
;*
.M units
6*
5
;*
.X cross paths
2
3
;*
.T address paths
3
5
;*
Long read paths
0
1
;*
Long write paths
0
0
;*
Logical ops (.LS)
2
2
(.L or .S unit)
;*
Addition ops (.LSD)
4
5
(.L or .S or .D unit)
;*
Bound(.L .S .LS)
4
3
;*
Bound(.L .S .D .LS .LSD)
6*
5
;*
;*
Searching for software pipeline schedule at ...
;*
ii = 6 Did not find schedule
;*
ii = 7 Schedule found with 6 iterations in parallel
;*
;*
Register Usage Table:
;*
+---------------------------------+
;*
|AAAAAAAAAAAAAAAA|BBBBBBBBBBBBBBBB|
;*
|0000000000111111|0000000000111111|
;*
|0123456789012345|0123456789012345|
;*
|----------------+----------------|
;*
0: |*************** |*** ******** * |
;*
1: |************* * |************ * |
;*
2: |************* * |********** * * |
;*
3: |************* * |************ * |
;*
4: |************* * |********** * * |
;*
5: |*********** *
|************* * |
;*
6: |***********
* |************* * |
;*
+---------------------------------+
;*
Done
;*
;*
Epilog not entirely removed
;*
Collapsed epilog stages
: 4
;*
;*
Prolog not entirely removed
23
;*
Collapsed prolog stages
: 1
;*
;*
Minimum required memory pad : 80 bytes
;*
;*
Minimum safe trip count
: 1
;*----------------------------------------------------------------------------*
24