Einführung in Paralleles Programmieren

Startbeispiel

Zur Einführung ein sehr kleines Beispiel. Wir wollen mal einige Zahlen quadrieren.
Ausschnitt aus dem Hauptprogramm:
 int feld[32],zielfeld[32];
 for(i=0;i<32;i++)
  {
   feld[i]=i+1; //zu quadrierende Zahlen ins Feld einfuellen
  }
 quadrieren(feld,zielfeld,32); //alle 32 Zahlen quadrieren
 for(i=0;i<32;i++) //Ergebnisse anzeigen
  {
   printf(" %d",zielfeld[i]);
  }
Das Unterprogramm quadrieren() soll also alle 32 Zahlen quadrieren.
Hier einige Möglichkeiten wie man das machen kann:

Serielle Version:

void quadrieren(int *feld,int *ziel,int n)
{
 for(int i=0;i<n;i++)
  {
   ziel[i] = feld[i]*feld[i];
  }
}
Die Zahlen werden also der Reihe nach, eine nach der andern quadriert.

Version mit Threads auf der CPU:

#include <thread>

void quadr(int *feld,int *ziel,int i)
{
 ziel[i] = feld[i]*feld[i];
}

void quadrieren(int *feld,int *ziel,int n)
{
 std::thread tr[n-1];
 for(int i=0;i<n-1;i++)
  {
   //fuer jeden Thread das Unterprogramm quadr() starten:
   tr[i] = std::thread(quadr,feld,ziel,i);
  }
 quadr(feld,ziel,n-1); //Haupt-Thread startet quadr() direkt
 for(int i=0;i<n-1;i++)
  {
   tr[i].join(); //auf Beenden aller Threads warten
  }
}
Hier werden alle Zahlen gleichzeitig verarbeitet. 31 Threads werden mit std::thread() gestartet, der schon laufende Thread ruft dann quadr() noch direkt auf.
Mit join() wird dann gewartet bis alle fertig sind.

Wenn man eine CPU mit mindestens 32 Kernen hat, läuft das Programm somit etwa 32 mal schneller.
Bei weniger Kernen funktioniert das Programm trotzdem, es ist dann aber etwas langsamer, weil das Betriebssystem dann zwischen den verschiedenen Threads wechseln muss.
Man kann das Programm aber auch etwas anpassen, so dass nur soviele Threads verwendet werden wie Prozessorkerne vorhanden sind. Ein entsprechendes Beispiel etwas später.

Version auf GPU mit OpenCL:

Bei Verwendung einer Grafikkarte zum den Parallel-Teil rechnen sieht der entsprechende Programmteil bei Verwendung von OpenCL etwa so aus:
__kernel
void quadr(global int *feld,global int *ziel)
{
 int i = get_global_id(0);
 ziel[i] = feld[i]*feld[i];
}
Programme, die auf der Grafikkarte laufen, werden jeweils als "Kernel" bezeichnet. (Hat aber nichts mit einem Linux-Kernel zu tun)
Und um diesen Kernel dann aufzurufen der entsprechende Ausschnitt aus dem Hauptprogramm:
  kernel_quadr.setArg(0,d_feld);
  kernel_quadr.setArg(1,d_ziel);
  queue.enqueueNDRangeKernel(kernel_quadr,cl::NullRange,cl::NDRange(32),cl::NullRange);
  queue.finish(); //auf Beenden aller Threads warten
Wobei ich jetzt mal die ganze Vorarbeit um das Objekt "kernel_quadr" zu erzeugen weggelassen habe. (kommt dann später noch)

Version auf GPU mit CUDA:

Bei Verwendung von CUDA sieht der Kernel-Teil ähnlich aus:
__global__
void cuda_quadr(int *feld,int *ziel)
{
 int i = threadIdx.x;
 ziel[i] = feld[i]*feld[i];
}
Und der Aufruf im Hauptprogramm:
  int nBlocks=1, threadsPerBlock=32;
  cuda_quadr<<<nBlocks,threadsPerBlock>>>(d_feld,d_ziel);
Auch hier die ganze Vorarbeit noch weggelassen.

Kopieren der Daten zur Grafikkarte

Damit das Kernel-Programm auf der Grafikkarten arbeiten kann, muss es natürlich Zugriff auf die Daten haben. In unserem Fall muss es also vom feld[32] lesen und ins ziel[32] speichern können.

Leider kann es aber nicht direkt auf den Hauptspeicher (RAM) des Computers zugreifen. Wir müssen deshalb die Daten erst in den Grafikkarten-Speicher kopieren und nach der Berechnung dann die Resultate wieder zurückkopieren.

Im Prinzip muss es nicht unbedingt eine Grafikkarte sein, es wird deshalb meistens von einem Device gesprochen.

Zum Device kopieren geht mit CUDA so:

 int *d_feld; //Device-Variable (Adresse ist Zeiger auf int)
 int size=n*sizeof(int);
 cudaMalloc((void**)&d_feld,size);
 cudaMemcpy(d_feld,feld,size,cudaMemcpyHostToDevice);
und mit OpenCL so:
 cl::Buffer dfeld; //Device-Variable (Adresse ist ein OpenCL-Objekt)
 int size=n*sizeof(int);
 dfeld = cl::Buffer(context,CL_MEM_READ_WRITE,size);
 queue=cl::CommandQueue(context,default_device);
 queue.enqueueWriteBuffer(dfeld,CL_TRUE,0,size,feld);
Dabei wird also zuerst ein Speicherbereich auf dem Device reserviert, bei CUDA mit cudaMalloc() und bei OpenCL mit cl::Buffer(..READ_WRITE..). Wobei dann die Adresse in einer entsprechenden Variablen gespeichert wird.
Die Daten werden dann mit dieser Adresse auf das Device gesendet, bei CUDA mit cudaMemcpy() und bei OpenCL mit queue.enqueueWriteBuffer().

Vom Device zurückkopieren geht mit OpenCL so:

 queue.enqueueReadBuffer(dziel,CL_TRUE,0,n*sizeof(int),ziel);
und mit CUDA so:
 cudaMemcpy(ziel,d_ziel,n*sizeof(int),cudaMemcpyDeviceToHost);

Übersetzen (compilieren) des Kernels

Als Besonderheit bei OpenCL wird der Kernel erst zur Laufzeit compiliert.
Dazu kann man das Kernel-Programm direkt als String ins Programm einbauen, oder man lädt es zur Laufzeit. Ich habe im Beispiel1 die zweite Variante gewählt.

Die Besonderheit von CUDA ist, dass der Kernel mit einer speziellen Syntax aufgerufen werden muss. Dazu muss man das Developer-Kit von Nvidia installieren und den darin enthaltenen Compiler "nvcc" verwenden.

Das gesamte Beispiel1 habe ich in 4 Teile aufgeteilt:
beispiel1.cc              Hauptprogramm zum beide Varianten aufrufen
beispiel1_cuda.cu     Programmteile fuer CUDA (mit nvcc übersetzen)
beispiel1_opencl.cc   Programmteil fuer OpenCL
beispiel1_kernel.cc    Kernel fuer OpenCL
Dann noch "beispiel1.h" zur Vordeklaration einiger Funktionen und "makefile" damit das Ganze bequem mit "make" compiliert werden kann.

Die interressantesten Teile des Programms sehen somit so aus:

// beispiel1.cc
...
 else if(variante==3)
  {printf("Auf Grafikkarte mit OpenCL gerechnet:\n");
   copy_to_device(feld,32);
   bool ok=opencl_kernel_compilieren("beispiel1_kernel.cc");
   if(!ok) {printf("Fehler3: opencl_kernel_compilieren() misslungen.\n"); exit(1);}
   quadrieren_opencl(feld,zielfeld,32);
   device_speicher_freigeben();
  }
 else //if(variante==4)
  {printf("Auf Grafikkarte mit CUDA gerechnet:\n");
   copy_to_device_cuda(feld,32);
   quadrieren_cuda(feld,zielfeld,32);
   device_speicher_freigeben_cuda();
  }
...
// beispiel1_opencl.cc
...
extern bool copy_to_device(const int *feld,int n)
{
 opencl_init();
 size1 = sizeof(int)*n;
 dfeld = cl::Buffer(context,CL_MEM_READ_WRITE,size1);
 dziel = cl::Buffer(context,CL_MEM_READ_WRITE,size1);
 queue=cl::CommandQueue(context,default_device);
 queue.enqueueWriteBuffer(dfeld,CL_TRUE,0,size1,feld);
 return true;
}

extern bool copy_from_device(int *ziel,int n)
{
 queue.enqueueReadBuffer(dziel,CL_TRUE,0,sizeof(int)*n,ziel);
 return true;
}

extern bool opencl_kernel_compilieren(const char *kernel_name)
{
 if(size1==0)
  {printf("Fehler1: kein Speicher auf dem Device reserviert.\n"); return false;}
 char *quellcode;
 int len=filelaenge(kernel_name);
 if(len==0)
  {printf("Fehler2: \"%s\" nicht gefunden\n",kernel_name); return false;}
 quellcode=new char[len]; if(quellcode==NULL) return false;
 datei_einlesen(kernel_name,quellcode);
 std::string code=quellcode;
 delete[] quellcode;
 cl::Program::Sources sources;
 sources.push_back({code.c_str(),code.length()});
 cl::Program program(context,sources);
 if(program.build({default_device})!=CL_SUCCESS) return false;
 kernel_quadr=cl::Kernel(program,"quadr");
 return true;
}

extern void quadrieren_opencl(const int *feld,int *ziel,int n)
{
 if(size1==0) copy_to_device(feld,n);
 kernel_quadr.setArg(0,dfeld);
 kernel_quadr.setArg(1,dziel);
 queue.enqueueNDRangeKernel(kernel_quadr,cl::NullRange,cl::NDRange(n),cl::NullRange);
 queue.finish(); //auf Beenden aller Threads warten
 copy_from_device(ziel,n);
}

extern void device_speicher_freigeben()
{
 //nicht noetig?
}
...
// beispiel1_cuda.cu
...
int *d_feld=NULL, *d_ziel=NULL;

bool copy_to_device_cuda(const int *feld,int n)
{
 int size=n*sizeof(int);
 cudaMalloc((void**)&d_feld, size);
 cudaMalloc((void**)&d_ziel, size); if(d_ziel==NULL) return false;
 cudaMemcpy(d_feld,feld,size,cudaMemcpyHostToDevice);
 return true;
}

bool copy_from_device_cuda(int *ziel,int n)
{
 cudaMemcpy(ziel,d_ziel,n*sizeof(int),cudaMemcpyDeviceToHost);
 return true;
}

extern void quadrieren_cuda(const int *feld,int *ziel,int n)
{
 if(d_ziel==NULL)
  {
   if(!copy_to_device_cuda(feld,n))
     {printf("Fehler: copy_to_device_cuda() misslungen\n"); return;}
  }
 int nBlocks=1, threadsPerBlock=32;
 cuda_quadr<<<nBlocks,threadsPerBlock>>>(d_feld,d_ziel);
 cudaDeviceSynchronize(); //auf Beenden aller Threads warten
 copy_from_device_cuda(ziel,n);
}

extern void device_speicher_freigeben_cuda()
{
 cudaFree(d_ziel); d_ziel=NULL;
 cudaFree(d_feld); d_feld=NULL;
}
...

Compilieren des gesamten Programms

Das erste Beispielprogramm kann hier als tgz-Archiv heruntergeladen werden:
beispiel1.tar.gz

Unter Linux (Ubuntu) braucht man folgende Pakete:
build-essential
opencl-headers
opencl-c-headers ?
opencl-cpp-headers ?
ocl-icd-*
ocl-icd-opencl-dev
ocl-icd-libopencl1
beignet (für Intel-Grafikkarten)
beignet-devel (für Intel-Grafikkarten)
clinfo
nvidia-opencl-dev
nvidia-cuda-dev
nvidia-cuda-doc
nvidia-cuda-gdb
nvidia-cuda-toolkit
libcudart10.1
nvidia-390 ?
nvidia-utils-390 ?

Versionsnummern können ändern. Bei älteren Grafikkarten sind eventuell auch ältere Pakete erforderlich.
Das "CUDA Toolkit" und der zur Grafikkarte passende Nvidia-Treiber muss man direkt von der Nvidia-Homepage runterladen.
Eventuell werden dabei einige der obigen Pakete schon installiert?

Wenn alle Pakete installiert sind, kann das Programm so compiliert und laufen gelassen werden:

 tar zxvf beispiel1.tar.gz
 cd beispiel1/
 make
 ./beispiel1 2
 ./beispiel1 3
 ./beispiel1 4
Beim Aufruf kann man mit den Nummern entscheiden welche Variante ausprobiert werden soll.
1=Seriell rechnen, 2=CPU-Threads, 3=OpenCL, 4=CUDA

Geschwindigkeits-Tests

Um auszumessen wie schnell nun die verschiedenen Varianten sind, brauchen wir viel mehr Daten. Also etwa feld[1000000] und zielfeld[1000000].
Das sollte man aber im Programm nicht direkt so eingeben, sondern besser mit "malloc" oder mit "new" entsprechende Speicherbereiche anfordern.
Dann das feld mit Zufallszahlen füllen und zielfeld erst mal mit Nullen füllen.
Das kann man etwa so machen:
#define N 1000000
 int *feld,*zielfeld;
 feld = new int[N];
 zielfeld = new int[N];
 for(i=0;i<N;i++)
  {
   feld[i] = random()&0xFFFF;
   zielfeld[i]=0;
  }
Am Ende des Programms sollten wir die reservierten Speicherbereiche wieder freigeben:
 delete[] feld;
 delete[] zielfeld;
Bei der Variante mit CPU-Theads wäre es sehr ungeschickt 1000000 Threads zu starten. Wir sollten also nur so viele Threads verwenden wie Prozessorkerne vorhanden sind.
Also wenn z.B. 8 Threads verwendbar sind, dann lassen wir
den ersten Thread die Daten feld[0], feld[8], feld[16] ... rechnen,
der nächste rechnet dann feld[1], feld[9], ..,
und der letzte dann noch feld[7], feld[15] ...

Der entsprechende Programmteil sieht dann so aus:

void quadr(int *feld,int *ziel,int i0,int nt,int n)
{
 for(int i=i0;i<n;i+=nt)
   ziel[i] = feld[i]*feld[i];
}

void quadrieren2(int *feld,int *ziel,int n)
{
 //Anzahl verfuegbare Threads:
 int nt = std::thread::hardware_concurrency();
 std::thread tr[nt-1];
 for(int i=0;i<nt-1;i++)
   tr[i]=std::thread(quadr,feld,ziel,i,nt,n);
 quadr(feld,ziel,nt-1,nt,n);
 for(int i=0;i<nt-1;i++)
   tr[i].join();
}

Die Variante mit OpenCL können wir so lassen.
Wenn der Compiler genügend intelligent ist, sorgt er dafür dass die Arbeit auf die verfügbare Anzahl Threads aufgeteilt wird.
Aus Programmierersicht sieht es so aus, wie wenn 1000000 Threads parallel gerechnet würden.

Die Variante mit CUDA müssen wir noch etwas anpassen.
Ein Block kann nur eine bestimmte Anzahl Threads wirklich parallel ausführen. Ich glaube auf den bisher getesteten Grafikkarten sind das 4 bis 32. Man kann zwar bis zu 1024 angeben, aber die werden dann nicht wirklich parallel gerechnet.
Allerdings sind eine grosse Anzahl Blöcke pro Grid vorhanden. Bis zu 65535, wobei soviele auch nicht wirklich physikalisch vorhanden sind, womit dann einige Blöcke doch wieder nacheinander abgearbeitet werden.
Die Anzahl Blöcke die wir brauchen, so dass theoretisch alles parallel gerechnet wird, können wir leicht ausrechnen.
Wenn wir Anzahl Threads pro Block auf 32 setzen, dann sind das: N/32 auf ganze Zahl aufgerundet.
Eventuell bekommen dann einige (wenige) Threads eine id, die zu gross für unser Zahlenfeld ist. Das muss dann im Kernel abgefangen werden.
Die entsprechenden Programmteile in beispiel1_cuda.cu:

__global__
void cuda_quadr(const int *feld,int *ziel,int n)
{
 int i = blockDim.x*blockIdx.x + threadIdx.x;
 if(i<n)
  {
   ziel[i] = feld[i]*feld[i];
  }
}
...
extern void quadrieren_cuda(const int *feld,int *ziel,int n)
{
 if(d_ziel==NULL)
  {
   if(!copy_to_device_cuda(feld,n))
     {printf("Fehler1: copy_to_device_cuda() misslungen\n"); return;}
  }
 int threadsPerBlock=32; //bis 1024 erlaubt
 int nBlocks=divup(n,threadsPerBlock); //bis 65535 erlaubt
 //(divup(a,b) ist aufgerundete Division a/b)
 cuda_quadr<<<nBlocks,threadsPerBlock>>>(d_feld,d_ziel,n);
 cudaDeviceSynchronize(); //auf Beenden aller Threads warten
 errcheck("Fehler2: Kernelaufruf misslungen");
 copy_from_device_cuda(ziel,n);
}
Dieses komische "blockDim.x" ist ein in CUDA fest definiertes Register, das genau unserem threadsPerBlock entspricht. (Bei 2- und 3-dimensionalen Aufgaben wird dann auch noch blockDim.y und blockDim.z benötigt.)
Und "blockIdx.x" ist die Blocknummer. In unserm Beispiel ist also blockDim.x=32 und blockIdx.x eine Zahl zwischen 0 und nBlocks.
Das threadIdx.x entspricht nur der Identifikationsnummer innerhalb eines einzelnen Blocks. Somit muss beim Berechnen von i noch Blockgrösse und Blocknummer berücksichtigt werden. (wie in obigem Code gezeigt)
Die Meldung "Fehler2" wird nur ausgegeben wenn ein Fehler aufgetreten ist. Allerding auch wenn schon vor dem Kernelaufruf ein CUDA-Fehler aufgetreten ist. Also immer auf ersten Fehler schauen.

Wenn wir auf mehr als 65535 Anzahl Blöcke kommen, müssen wir entweder die Anzahl Threads pro Block erhöhen, oder wir machen es ähnlich wie bei den CPU-Threads.
Die gesamte Anzahl der Threads können wir dabei wieder durch fest definierte Register erhalten.
Der Kernel sieht dann so aus:

__global__
void cuda_quadr(const int *feld,int *ziel,int n)
{
 int nt = blockDim.x*gridDim.x; //Anzahl Threads
 int i0 = blockDim.x*blockIdx.x + threadIdx.x;
 for(int i=i0; i<n; i+=nt)
  {
   ziel[i] = feld[i]*feld[i];
  }
}
Und beim Aufruf können wir eine beliebige Anzahl Blöcke angeben, also zum Beispiel:
 int threadsPerBlock=32;
 int nBlocks=1024; //bis 65535 erlaubt
 cuda_quadr<<<nBlocks,threadsPerBlock>>>(d_feld,d_ziel,n);

Für Zeitmessungen habe ich noch eine Option -q ins Hauptprogramm eingebaut um die Überprüfung der Ergebnisse wegzulassen.

Das so erweiterte erste Beispiel kann hier heruntergeladen werden:
beispiel1b.tar.gz
Übersetzen wie gehabt.
Zum laufen lassen mit Zeitmessung so aufrufen:
time ./beispiel1 4
Und mit Zeitmessung ohne Resultate zu prüfen so aufrufen:
time ./beispiel1 -q 4

Zu schnell für Zeitmessung!?

Offenbar sind alle Varianten zu schnell um eine aussagekräftige Zeitmessung zu machen.
Wir sollten uns also was anderes einfallen lassen ....

Idee1:

Für die CUDA-Variante gibt es den Befehl "nvprof", mit dem angezeigt wird, wie schnell die einzelnen CUDA-Aufrufe sind.
So anzuwenden:
nvprof ./beispiel1 -q 4
Allerdings können wir damit nicht mit den andern Varianten vergleichen.

Idee2:

Wenn wir einfach N noch weiter erhoehen, auf z.B. 100'000'000, bekommen wir eventuell Probleme einen so grossen Speicherbereich auf dem Device zu reservieren.
Ausserdem besteht das Problem dass Initialisierung von grossen Datenfeldern, dann auf Device kopieren und nach der Berechnung wieder zurück, einen grossen Teil der gemessenen Zeit ausmacht.

Idee3:

Den Kernelaufruf viele male, z.B. 1000 mal zu machen, könnte eine Möglichkeit sein.

Alle diese Ideen waren nicht ideal. Ich stelle dann im Teil2 eine bessere Methode vor.

Fortsetzungen:

Teil2   Teil3


Anmerkungen:

Grid:
ein Gitter auf dem die Blöcke angeordnet sind.
Vermutlich ist das nicht wirklich physikalisch vorhanden, sondern wird nur als Programmiermodell verwendet.
Wenn ich es richtig verstanden habe wird pro Kernelaufruf immer genau 1 Grid verwendet.
Pro Grid gibt es maximal 65535 Blöcke (auf neueren Grafikkarten auch mehr).


Letzte Änderungen: 7.6.2019 / Rolf                                         Validator