Ultimo aggiornamento: 2022-11-26
Scopo di questo esercizio è sviluppare una versione SIMD di una funzione che calcola l’iterata della mappa del gatto di Arnold, una vecchia conoscenza che abbiamo già incontrato in altre esercitazioni. Riportiamo nel seguito la descrizione del problema.
La mappa del gatto trasforma una immagine \(P\) di dimensione \(N \times N\) in una nuova immagine \(P'\) delle stesse dimensioni. Per ogni \(0 \leq x < N,\ 0 \leq y < N\), il pixel di coordinate \((x,y)\) in \(P\) viene collocato nella posizione \((x',y')\) di \(P'\) dove:
\[ x' = (2x + y) \bmod N, \qquad y' = (x + y) \bmod N \]
(“mod” è l’operatore modulo, corrispondente all’operatore %
del linguaggio C). Si può assumere che le coordinate \((0, 0)\) indichino il pixel in alto a sinistra e le coordinate \((N-1, N-1)\) quello in basso a destra, in modo da poter indicizzare l’immagine come se fosse una matrice in linguaggio C. La Figura 1 mostra graficamente la trasformazione.
La mappa del gatto ha proprietà sorprendenti. Applicata ad una immagine ne produce una versione molto distorta. Applicata nuovamente a quest’ultima immagine, ne produce una ancora più distorta, e così via. Tuttavia, dopo un certo numero di iterazioni (il cui valore dipende da \(N\), ma che in ogni caso è sempre minore o uguale a \(3N\)) ricompare l’immagine di partenza! (si veda la Figura 2).
Il tempo minimo di ricorrenza per l’immagine cat1368.pgm di dimensione \(1368 \times 1368\) fornita come esempio è \(36\): iterando \(k\) volte della mappa del gatto si otterrà l’immagine originale se e solo se \(k\) è multiplo di 36. Non è nota alcuna formula analitica che leghi il tempo minimo di ricorrenza alla dimensione \(N\) dell’immagine.
Viene fornito un programma sequenziale che calcola la \(k\)-esima iterata della mappa del gatto usando la CPU. Il programma viene invocato specificando sulla riga di comando il numero di iterazioni \(k\). Il programma legge una immagine in formato PGM da standard input, e produce una nuova immagine su standard output ottenuta applicando \(k\) volte la mappa del gatto. Occorre ricordarsi di redirezionare lo standard output su un file, come indicato nelle istruzioni nel sorgente. La struttura della funzione che calcola la k-esima iterata della mappa del gatto è molto semplice:
for (y=0; y<N; y++) {
for (x=0; x<N; x++) {
/* calcola le coordinate (xnew, ynew) del punto (x, y)
dopo k applicazioni della mappa del gatto */
next[xnew + ynew*N] = cur[x+y*N];
}
}
Per sfruttare il parallelismo SIMD possiamo ragionare come segue: anziché calcolare le nuove coordinate di un punto alla volta, calcoliamo le coordinate di quattro punti adiacenti \((x, y)\), \((x+1,y)\), \((x+2,y)\), \((x+3,y)\) usando i vector datatype del compilatore. Per fare questo, definiamo le seguenti variabili di tipo v4i
(vettori SIMD di 4 interi):
vx
, vy
: coordinate di quattro punti adiacenti, prima dell’applicazione della mappa del gatto;
vxnew
, vynew
: nuova coordinate dei punti di cui sopra dopo l’applicazione della mappa del gatto.
Ricordiamo che il tipo v4i
si definisce con gcc
come
Posto \(vx = \{x, x+1, x+2, x+3\}\), \(vy = \{y, y, y, y\}\), possiamo applicare ad essi le stesse operazioni aritmetiche applicate agli scalari x e y per ottenere le nuove coordinate vxnew, vynew. Fatto questo, al posto della singola istruzione:
per spostare materialmente i pixel nella nuova posizione occorre eseguire quattro istruzioni scalari:
next[vxnew[0] + vynew[0]*N] = cur[vx[0] + vy[0]*N];
next[vxnew[1] + vynew[1]*N] = cur[vx[1] + vy[1]*N];
next[vxnew[2] + vynew[2]*N] = cur[vx[2] + vy[2]*N];
next[vxnew[3] + vynew[3]*N] = cur[vx[3] + vy[3]*n];
Si assuma che la dimensione \(N\) dell’immagine sia sempre multipla di 4.
Compilare con:
gcc -std=c99 -Wall -Wpedantic -O2 -march=native simd-cat-map.c -o simd-cat-map
Eseguire con:
./simd-cat-map k < input_file > output_file
Esempio:
./simd-cat-map 100 < cat368.pgm > cat1368-100.pgm
Le prestazioni della versione SIMD dell’algoritmo della mappa del gatto dovrebbero risultare solo marginalmente migliori della versione scalare (potrebbero addirittura essere peggiori). Analizzando il codice assembly prodotto dal compilatore, si scopre che il calcolo del modulo nelle due espressioni
viene realizzato usando operazioni scalari. Consultando la lista dei SIMD intrinsics sul sito di Intel si scopre che non esiste una istruzione SIMD che realizzi la divisione intera. Per migliorare le prestazioni del programma occorre quindi ingegnarsi per calcolare i moduli senza fare uso della divisione. Ragionando in termini scalari, osserviamo che se \(0 \leq xold < N\) e \(0 \leq yold < N\), allora si ha necessariamente che \(0 \leq 2 \times xold + yold < 3N\) e \(0 \leq xold+yold < 2N\).
Pertanto, sempre in termini scalari, possiamo realizzare il calcolo di xnew
e ynew
come segue:
xnew = (2*xold + yold);
if (xnew >= N) { xnew = xnew - N; }
if (xnew >= N) { xnew = xnew - N; }
ynew = (xold + yold);
if (ynew >= N) { ynew = ynew - N; }
Il codice precedente è meno leggibile della versione che usa l’operatore modulo, ma ha il vantaggio di poter essere vettorizzato ricorrendo al meccanismo di “selection and masking” visto a lezione. Ad esempio, l’istruzione
può essere riscritta come
che può essere ulteriormente semplificata come:
Si ottiene in questo modo un programma più complesso della versione scalare, ma più veloce in quanto si riesce a sfruttare al meglio le istruzioni SIMD.
Per compilare:
gcc -std=c99 -Wall -Wpedantic -march=native -O2 simd-cat-map.c -o simd-cat-map
Per eseguire:
./simd-cat-map [niter] < in.pgm > out.pgm
Esempio:
./simd-cat-map 1024 < cat1368.pgm > cat1368-1024.pgm