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