Nella sezione sulla pipeline, si è lasciato intendere che ciascun processore eseguisse, ad ogni ciclo, una sola volta il proprio programma. In realtà, la GPU estrae un gran numero di elementi dal buffer di origine, istanzia uno shader per ciascuno di essi, ed esegue tutti questi shader contemporaneamente.
Questo è possibile perchè ciascun processore ha molte (diverse centinaia) unità di elaborazione identiche, che hanno propria area di memoria temporanea e propri registri interni, ma che condividono l'unità di controllo. Queste unità sono dette (Shader) Processing Unit.
L'unità di controllo interpreta una sola volta il programma e invia uguali segnali di controllo a ciascuna Processing Unit. Gli elementi del buffer di origine vengono prelevati a blocchi e sono consegnati a blocchi al buffer di uscita, tutti contemporaneamente.
Le Processing Units eseguono simultaneamente le stesse operazioni su dati diversi. Un'architettura di questo tipo è detta SIMD (Single Instruction Multiple Data).
Questa architettura è molto efficiente, perchè l'esecuzione di un numero molto elevato di elaborazioni tutte uguali è molto comune nella pipeline grafica. Si consideri per esempio la colorazione dei singoli frammenti, che può richiedere molte operazioni simili per ogni singolo pixel della viewport (decine o centinaia di migliaia).
Tuttavia, per poter avere il maggior numero possibile di unità di elaborazione, l'architettura sacrifica l'unità di controllo, che invece è comune a tutte le Shader Processing Unit. Questo provoca alcuni svantaggi, dal punto di vista del programmatore degli shader:
I primi svantaggi non non sono gravi e costituiscono una limitazione trascurabile per il programmatore degli shader. Ciononostante, di recente una funzione per ottenere una qualche sincronizzazione è stata aggiunta ai Tessellation Control Shader, perciò è possibile che la comunicazione tra shader sia aggiunta in futuro.
L'ultimo svantaggio invece costituisce un problema molto significativo. Sembrerebbe imporre che le istruzioni di controllo all'interno degli shader debbano essere rigorosamente vietate.
Come esempio, sia dato questo codice, dove d1 e d2 sono dati che provengono dall'esterno, diversi per ciascuna istanza dello shader:
float a = 1.0; if (d1 == d2) a = f(); // f è una generica funzione lunga e complicata
In una ipotetica CPU, potrebbe essere compilato a livello assembly in qualcosa del tipo:
TEMPF a ; dichiara la variabile temporanea float a MOV a,1.0 ; la inizializza CMP d1,d2 ; confronta JNE @avanti ; se non sono uguali, salta avanti (Jump if Not Equal) CALL f ; altrimenti, esegue la funzione MOV a,reg1 ; e salva il risultato in a @avanti:
Questo non può essere usato in una GPU, perché l'unità di controllo decodifica e invia le stesse (identiche) istruzioni a tutte le Processing Unit. Invece, per le unità in cui d1 è diverso da d2, la chiamata a f non deve essere inviata e soprattutto il suo risultato deve essere ignorato e non salvato in a.
Processing Unit 1 (d1 == d2) Processing Unit 2 (d1 != d2) TEMPF a TEMPF a MOV a,1.0 MOV a,1.0 CMP d1,d2 CMP d1,d2 JNE @avanti ; non deve saltare JNE @avanti ; deve saltare CALL f CALL f MOV a,reg1 MOV a,reg1 @avanti: @avanti:
Per risolvere questo problema, e consentire l'uso di istruzioni di controllo, sono state previste alcune soluzioni. La più antica, implementata anche nell'assembly ARB, prevede l'uso di singole istruzioni condizionate.
Sono presenti singole istruzione macchina (che non alterano il flusso di controllo) che possono essere condizionate al risultato dell'istruzione precedente. Questa semplice operazione può essere eseguita anche senza intervento dell'unità di controllo, perchè si limita ad avere un effetto diverso a seconda di quel risultato. Il tempo di esecuzione e la sequenza di istruzioni successive non è alterata.
Ad esempio, può essere presente una istruzione macchina che sposta il contenuto di un registro in un altro solo se l'uguaglianza valutata in precedenza è risultata vera (MOVE: MOVe if Equal).
Se più operazioni devono essere eseguite sotto condizione, questa soluzione impone che siano eseguite comunque su variabili temporanee, poi sia valutata la condizione, e infine i risultati individuati siano salvati all'interno delle variabili di destinazione solo se la condizione finale è risultata vera.
Con questo metodo le istruzioni devono essere riordinate, ma sono le stesse per entrambe le processing unit e non ci sono problemi.
Processing Unit 1 (d1 == d2) Processing Unit 2 (d1 != d2) TEMPF a TEMPF a MOV a,1.0 MOV a,1.0 TEMPF temp1 TEMPF temp1 CALL f CALL f MOV temp1,reg1 MOV temp1,reg1 CMP d1,d2 CMP d1,d2 MOVE a,temp1 ; questa esegue, sono uguali MOVE a,temp1 ; questa non esegue, sono diversi
Una seconda soluzione prevede che le Shader Processing Unit per cui la condizione è risultata falsa cominciano ad ignorare le istruzioni di controllo (sono "mascherate"). Al termine del blocco condizionato, l'unità di controllo invia un comando particolare (nell'esempio, UNMASK) e anche le Processing Unit in attesa riprendono a seguire le istruzioni.
Questa soluzione è più complessa da realizzare dal punto di vista dell'architettura della GPU, perché necessita la presenza di un sistema di mascheratura delle Shader Processing Unit. Però i consumi risultano ridotti, in quanto le unità in attesa consumano meno.
Processing Unit 1 (d1 == d2) Processing Unit 2 (d1 != d2) TEMPF a TEMPF a MOV a,1.0 MOV a,1.0 CMP d1,d2 CMP d1,d2 MASKNE ; MASK if Not Equal MASKNE ; solo l'unità 2 è mascherata, qui CALL f <mascherata, ignora le istruzioni> MOV a,reg1 <mascherata, ignora le istruzioni> UNMASK ; l'unità 1 è già non mascherata UNMASK ; mascheratura tolta, continua l'esecuzione
Di solito, questo metodo introduce alcuni limiti per i condizionamenti nidificati (if dentro un altro if). Nell'esempio precedente, ad esempio, non esiste nessun modo per togliere la mascheratura solo a una parte delle unità mascherate e si dovrà ricorrere al riordinamento del codice o a qualche altro metodo se l'if interno non termina insieme all'if esterno.
Come si può osservare, le istruzioni di controllo non provocano nessun vantaggio in termini di velocità di esecuzione dello shader. A meno della possibilità piuttosto remota che tutte le (centinaia di) processing unit seguano lo stesso percorso nel codice condizionato, infatti, ogni Processing Unit esegue per il tempo massimo possibile, cioè quello che servirebbe nel caso in cui tutto il codice condizionato debba essere eseguito.
Per esempio, se f e g sono funzioni:
float a; if (d1 == d2) a = f(); else a = g();
In una CPU è eseguita o f o g, perciò il tempo medio di esecuzione di quel codice è circa la media (pesata) di quello di f e di g, ammesso che il tempo per valutare la condizione sia trascurabile. In una GPU, invece, il tempo di esecuzione è (quasi) sempre la somma dei tempi di esecuzione di f e g, indipendentemente da quella delle due che è stata selezionata.
In conclusione, è bene evitare il più possibile l'uso delle istruzioni di controllo di flusso all'interno degli shader, specialmente quelle nidificate.
Copyright © 2011 Riccardo Monica
Eccetto ove espressamente indicato altrimenti, il contenuto di questa pagina è disponibile secondo la licenza Creative Commons Attribuzione - Condividi allo stesso modo 3.0.