Einführung in Paralleles Programmieren 2.Teil

Zeitmessungen

Als Auflösung vom ersten Teil:
Ich habe jetzt eine bessere Methode der Zeitmessung gefunden. Im Programm kann man die aktuelle Zeit mit der Funktion gettimeofday() auf die Microsekunde genau ermitteln.
Ich habe also unter Benutzung dieser Funktion 3 kleine Unterprogramme entworfen:
stoppuhr_reset()   zum die Zeitmessung starten
stoppuhr_read();   zum aktuelle Zeitdifferenz abfragen
zeit_print(int usec,long flop);   um das Ergebnis anzuzeigen. Wenn man bei flop angibt wieviele Flisspunkt-Operationen der Algorithmus berechnet hat, dann wird auch gleich noch die Leistung in GFLOPS (Giga Fliessoperationen pro Sekunde) mit angezeigt.
Also direkt vor dem Kernelaufruf stoppuhr_reset(); und danach usec=stoppuhr_read(); und schliesslich zeit_print(usec) ergibt dann die Rechenzeit des Kernels.

Ich werde diese Unterprogramme dann in den folgenden Beispielen verwenden.

Zweidimensionales Beispiel

Oft haben wir Felder von Daten, also 2-dimensionale Matrizen oder 3-dimensionale Daten die z.B. Punkte im Raum darstellen.
Als einfaches zweites Beispiel wollen wir mal ein Bild von RGB-Farbe in Grauwerte umrechnen.
Das Bild soll N Punkte breit und M Punkte hoch sein. Jeder Punkt besteht aus 3 Zahlen R,G,B, die Werte von 0 (dunkel) bis 255 (hell leuchtend) haben können. Dann in einen Grauwert zwischen 0.0 (schwarz) und 1.0 (weiss) umrechnen.
Dazu gibt es diese Formel: H = 0.299*R + 0.587*G + 0.114*B
Damit bekommt man einen Wert zwischen 0 und 255, also noch durch 255 dividieren um Werte zwischen 0.0 und 1.0 zu bekommen.

Zuerst die CPU-Variante (mit Threads):

void grauwerte(int& R[N][M],int& G[N][M],int& B[N][M],float& H[N][M],
               int i0,int nt)
{
 for(int i=i0;i<N;i+=nt)
  for(int j=0;j<M;j++)
   {
    H[i][j] = (0.299*R[i][j] + 0.587*G[i][j] + 0.114*B[i][j])/255;
   }
}

Jetzt gibts aber schon ein kleines Problem: in c sind mehrdimensionale Felder nur schlecht benutzbar. Insbesondere müssen N und M unbedingt Konstanten sein. Für unterschiedliche Bildgrössen also nicht verwendbar.
Wir müssen die zweidimensionalen Felder also wieder durch gewöhnliche eindimensionale ersetzen.
Also statt feld[N][M] in der Deklaration schreiben wir feld[N*M], oder als Parameter int*feld (oder float*feld).
Dann um auf ein Element zuzugreifen statt feld[i][j] schreiben wir feld[j*N+i].
(Einige Leute machen das anders herum (z.B. CUBLAS) und verwenden feld[i*M+j])
Man könnte sich auch ein Makro schreiben um dann feld[IDX(i,j)] zu benutzen.

Hier also das angepasste Beispiel ohne Makros:

void grauwerte(const int*R,const int*G,const int*B,float*H,
               int i0,int nt)
{
 for(int i=i0;i<N;i+=nt)
  for(int j=0;j<M;j++)
   {
    H[j*N+i] = (0.299*R[j*N+i] + 0.587*G[j*N+i] + 0.114*B[j*N+i])/255;
   }
}

Und jetzt der OpenCL-Kernel:

__kernel
void calc(global int*R,global int*G,global int*B,global float*H,int N,int M)
{
 int i=get_global_id(0);
 int j=get_global_id(1);
 H[j*N+i] = (0.299*R[j*N+i] + 0.587*G[j*N+i] + 0.114*B[j*N+i])/255;
}
und Ausschnitt aus Hauptprogramm zum Kernel aufrufen:
  kernel_calc.setArg(0,d_R);
  kernel_calc.setArg(1,d_G);
  kernel_calc.setArg(2,d_B);
  kernel_calc.setArg(3,d_H);
  kernel_calc.setArg(4,N);
  kernel_calc.setArg(5,M);
  queue.enqueueNDRangeKernel(kernel_quadr, cl::NullRange,
                             cl::NDRange(N,M), cl::NDRange(32,32));
  queue.finish();
Wir definieren also die gesamte Anzahl Threads ("work items" in OpenCL-Sprache) als zweidimensionales Feld (N,M), und die Blockgrösse (also Anzahl Threads pro Block) ebenfalls zweidimensional (32,32).
(Block in OpenCL-Sprache: work group, zur Übersetzung zwischen CUDA- und OpenCL-Begriffen siehe Tabelle)
Wenn N und M durch 32 teilbar sind, dann funktioniert es so. Andernfalls müssen wir in cl::NDRange(N,M) aufgerundete Werte verwenden, so dass restlos teilbar:
#define rup(a,b) ((a+b-1)/(b)*(b)) //aufrunden, damit durch b teilbar

  queue.enqueueNDRangeKernel(kernel_quadr, cl::NullRange,
  cl::NDRange(rup(N,32),rup(M,32)), cl::NDRange(32,32));
Und im Kernel sollten wir ungültige Werte abfangen:
__kernel
void calc(global const int*R,global const int*G,global const int*B,
          global float*H,const int N,const int M)
{
 int i=get_global_id(0);
 int j=get_global_id(1);
 if(i<N && j<M)
   H[j*N+i] = (0.299*R[j*N+i] + 0.587*G[j*N+i] + 0.114*B[j*N+i])/255;
}

Um die Zeiten zu messen habe ich die oben erwähnten Unterprogramme so eingebaut:

 stoppuhr_reset();
 calc_opencl(Rot,Gruen,Blau,Hwerte,N,M);
 int usec=stoppuhr_read();
 copy_from_device(Hwerte,N*M);
 int usec2=stoppuhr_read();
 printf("Rechenzeit OpenCL: ");
 zeit_print(usec,FLOP);
 printf("   zurueckkopiert: ");
 zeit_print(usec2);

Optimierungen

Die CPU-Variante lässt sich noch optimieren, indem man innere und äussere Schlaufe vertauscht. Damit erreicht man, dass hintereinander liegende Speicherbereiche gelesen werden. Das macht das ganze Programm einiges schneller.
Die Zeile mit der Grauwertberechnung kann auch noch etwas optimiert werden:
 for(int i=0,k=j*N;i<N;i++,k++)
   H[k] = 0.299/255*R[k] + 0.587/255*G[k] + 0.114/255*B[k];
Diese einfachen Optimierungen haben bei mir etwa ein Faktor 10 gebracht!
Bei OpenCL- und CUDA-Variante entspricht das Vertauschen von innererer und äusserer Schlaufe einem Vertauschen von .x und .y bzw. (0) und (1). Dieses Vertauschen bringt in diesem Beispiel aber keine Verbesserung (im Gegenteil, wird langsamer).
(in andern Beispielen kann es aber durchaus eine Verbesserung geben.)
Die Optimierung der Berechnung ergibt noch eine leichte Verbesserung.

Download und Testlauf

Hier das Beispiel2 zum downloaden:
beispiel2.tar.gz

Ausgabe vom Testlauf auf meinem Laptop:

2073600 Zufallszahlen erstellt.
 103 113 6 105 ... 41 6 251 10
Seriell auf CPU gerechnet:
Rechenzeit CPU: 37.965 ms  (0.273 GFLOPS)
Mit 4 CPU-Threads gerechnet:
Rechenzeit CPU (optimiert): 3.525 ms  (2.9 GFLOPS)
Auf Grafikkarte mit OpenCL gerechnet:
Using platform: NVIDIA CUDA
Using device: GeForce GTX 850M
Rechenzeit OpenCL: 1.277 ms  (8.1 GFLOPS)
   zurueckkopiert: 6.585 ms
Auf Grafikkarte mit CUDA gerechnet:
 Rechenzeit  CUDA: 2.357 ms  (4.4 GFLOPS)
   zurueckkopiert: 7.624 ms
 0.623502 0.516267 0.164071 0.300651 ... 0.292592 0.101984 0.748243 0.066392
Ergebnisse erfolgreich ueberprueft.

Und Testlauf auf meinem Gaming-PC:

2073600 Zufallszahlen erstellt.
 103 113 6 105 ... 41 6 251 10
auf CPU (12 Threads) gerechnet:
Rechenzeit CPU: 3.966 ms  (2.6 GFLOPS)
auf CPU (12 Threads) leicht optimiert:
Rechenzeit CPU (optimiert): 1.679 ms  (6.2 GFLOPS)
Auf Grafikkarte mit OpenCL gerechnet:
Using platform: NVIDIA CUDA
Using device: GeForce RTX 2080
Rechenzeit OpenCL: 0.183 ms  (56.7 GFLOPS)
   zurueckkopiert: 1.626 ms
Auf Grafikkarte mit CUDA gerechnet:
 Rechenzeit  CUDA: 0.233 ms  (44.5 GFLOPS)
   zurueckkopiert: 1.236 ms
 0.623502 0.516267 0.164071 0.300651 ... 0.292592 0.101984 0.748243 0.066392
Ergebnisse erfolgreich ueberprueft.

weitere Optimierungen

Das aktuelle Beispiel ist wahrscheinlich zu einfach für weitere Optimierungen.
Um sicher zu stellen dass die Faktoren wirklich als float und nicht als double verwendet werden, kann man noch dies machen:
 H[k] = (float)(0.299/255)*R[k] + (float)(0.587/255)*G[k] + (float)(0.114/255)*B[k];
Im vorliegenden Fall gibt das keine Veränderung, könnte aber beim Verwenden von float4 eine Rolle spielen.

Im nächsten Teil werde ich dann ein Beispiel verwenden, bei dem mehr gerechnet werden muss, so dass Optimierungen sinnvoll werden.


Fortsetzungen:

Teil 3
zurück zum Teil1

Vergleich von OpenCL und CUDA:

OpenCL CUDA Bedeutung
? Grid gedachtes Gitter auf dem sich Blöcke befinden
Work Group Block eine Ansammlung von mehreren Threads
Work Item Thread eine Instanz von parallel laufendem Programmteil
__kernel __global__ Beginn des Kernel-Codes
__global __device__ globaler Speicher auf dem Device
get_local_id(0)threadIdx.x Nummer des aktuellen Threads innerhalb eines Blocks
get_group_id(0)blockIdx.x Nummer des aktuellen Blocks
get_global_id(0)blockDim.x*blockIdx.x+threadIdx.xglobale Nummer des aktuellen Threads
get_local_size(0) blockDim.x Anzahl Threads pro Block
get_num_groups(0) gridDim.x Anzahl Blöcke
(0) (1) (2).x .y .z Dimensions-Zugriff
__local __shared__ Lokaler Speicher in einem Block
barrier(CLK_LOCAL_MEM_FENCE) __syncthreads() Wartepunkt bis alle Threads im Block fertig

Teilweise hier gefunden: Porting CUDA to OpenCL


Letzte Änderungen: 18.6.2019 / Rolf                                         Validator