Einführung in Paralleles Programmieren 3.Teil

Optimierbares Beispiel

Die bisherigen Beispiele waren zu einfach um stark optimierbar zu sein.
Ein Paradebeispiel, das wesentlich schneller wird wenn optimiert, ist die Matrixmultiplikation. Dafür gibt es schon ein entsprechendes Tutorial.

Ich möchte hier aber ein einfacheres Beispiel verwenden.
Es sollen zwei Zahlenfelder miteinander multipliziert werden. Also jeder Eintrag im Feld A soll mit jedem Eintrag im Feld B multipliziert werden. Die Summe (ein Eintrag in A mit allen Einträgen in B multipliziert) soll dann in einem Feld C (mit selber Grösse wie A) gespeichert werden.

Zuerst die serielle Variante:

void hello_calc(const float *A,const float *B,float *C,int X,int Y)
{
 for(int x=0;x<X;x++)
  {
   float sum=0;
   for(int y=0;y<Y;y++)
     sum += A[x]*B[y];
   C[x] = sum;
  }
}

Und jetzt der OpenCL-Kernel:

__kernel void hello(__global const float *A,__global const float *B,
                    __global float *C,const int X,const int Y)
{
 int x=get_global_id(0);
 float sum=0;
 if(x<X)
  {
   for(int y=0;y<Y;y++)
     sum += A[x]*B[y];
   C[x] = sum;
  }
}

Die CUDA-Version wäre praktisch gleich. Ich konzentriere mich in diesem Teil aber auf die OpenCL-Version.
Beim Aufruf können wir uns auf die eindimensionale Variante beschränken:

 const int TPB=1024; //ThreadsPerBlock
 queue.enqueueNDRangeKernel(kernel_hello,cl::NullRange,cl::NDRange(rup(X,TPB)),cl::NDRange(TPB));

Im Hauptprogramm wieder die Stoppuhr-Funktionen von Teil2 eingebaut.
Vergleich von CPU- mit OpenCL-Version gibt je nach verwendeter Grafikkarte einen um etwa Faktor 8 bis 600 besseren Wert. (Faktor 8 auf der CPU würde man mit Threads und Optimierung auch noch erreichen.)

erster Optimierungsversuch

Da die Zugriffszeit auf Daten im globalen Speicher relativ gross ist, kann man durch kopieren von mehrfach gebrauchten Daten in den lokalen Speicher einige Ladezeit einsparen.
Also der "globale" Speicher ist Speicher auf der Grafikkarte der von allen Threads in allen Blocks benutzt werden kann.
"Lokaler" Speicher hingegen ist ein Speicher in jedem Block, der nur von den Threads im selben Block gemeinsam benutzt werden kann, dafür aber einiges schneller ist.

Wir können jetzt also die Daten vom Feld B in den lokalen Speicher laden. Danach werden diese für jeden Eintrag in A benutzt um zu multiplizieren und Summe zu bilden.
Der entsprechende Kernel sieht dann so aus:

#define YMAX 12000

__kernel void hello(__global const float *A,__global const float *B,
                    __global float *C,const int X,const int Y)
{
 int x0=get_local_id(0);
 int x=get_global_id(0);

 __local float Blocal[YMAX]; //lokaler Speicher pro Block (max.48KB)
 int ymax=(YMAX<Y)?YMAX:Y;
 for(int y=x0;y<ymax;y+=1024)
  {
   Blocal[y]=B[y]; //gemeinsam gesamtes B in lokalen Speicher kopieren
  }
 barrier(CLK_LOCAL_MEM_FENCE); //Warten bis alle Threads fertig mit
                               //speichern in lokalen Speicher.
 if(x<X)
  {
   float sum=0;
   for(int y=0;y<ymax;y++)
     sum += A[x]*Blocal[y]; //B nur noch aus lokalem Speicher lesen
   C[x] = sum;
  }
}
Die Grösse des benutzten lokalen Speichers muss beim Compilieren schon bekannt sein. Blocal[Y] würde also nicht funktionieren, da Y erst zur Laufzeit bekannt ist.
Ausserdem ist der lokale Speicher auf einen relativ kleinen Wert von 48KB beschränkt. Unser Feld B darf somit nicht grosser als 12000 sein.

In der Schlaufe beim kopieren in den lokalen Speicher arbeiten jetzt die 1024 Threads zusammen. In jedem Schlaufendurchgang werden also 1024 Werte kopiert.
(Der Wert 1024 sollte eigentlich noch durch get_local_size(0) ersetzt werden.)

Die Funktion barrier() wartet bis alle Threads im aktuellen Block soweit sind. Dabei ist es wichtig dass dieser Punkt von allen Threads erreicht wird. Also z.B. zuerst ein if(x<X) zu machen wäre falsch, und könnte ein unerwartetes Verhalten verursachen (vermutlich Absturz oder Programm hängt).

Um die Beschränkung auf 12000 zu umgehen, kann man auch mehrmals jeweils nur einen Teil vom Feld B in den lokalen Speicher laden. Man muss nur aufpassen, dass die Barrieren innerhalb der Schlaufe auch wirklich von jedem Thread erreicht werden.
Wir erweitern also die y-Schlaufe. Im äusseren Teil erhöhen wir jeweils um TPB, lesen dann den entsprechenden Teil vom Feld B ein, und benutzen in der inneren Schlaufe den lokalen Speicher.
Der Kernel sieht dann etwa so aus:

__kernel void hello(__global const float *A,__global const float *B,
		    __global float *C,const int X,const int Y)
{
 int x0=get_local_id(0);
 int x=get_global_id(0);
 const int TPB=get_local_size(0);
 __local float Blocal[1024]; //4KB lokaler Speicher
 float sum=0;
 int y;
 for(y=0;y<Y-TPB;y+=TPB)
  {
   Blocal[x0]=B[y+x0]; //aktuellen Teil von B in lokalen Speicher kopieren
   barrier(CLK_LOCAL_MEM_FENCE); //Warten bis alle Threads fertig
   for(int yb=0;yb<TPB;yb++)
      {
       sum += A[x]*Blocal[yb]; //B nur noch aus lokalem Speicher lesen
      }
   barrier(CLK_LOCAL_MEM_FENCE);
  }
 Blocal[x0]=B[y+x0]; //letzter Teil von B in lokalen Speicher kopieren
 barrier(CLK_LOCAL_MEM_FENCE);
 for(int yb=0;y+yb<Y;yb++)
  {
   sum += A[x]*Blocal[yb]; //B nur noch aus lokalem Speicher lesen
  }
 if(x<X) C[x] = sum;
}
Die Abfrage (x<X) habe ich erst ganz am Schluss gemacht. Da ja alle Threads jeweils die Barrieren erreichen müssen, spielt es auch keine Rolle wenn sie dazwischen noch irrelevante Werte addieren.
Anscheinend ist das Lesen von A[x], auch wenn x zu gross ist, kein Problem. Andernfalls müsste man in der inneren Schlaufe noch ein "if(x<X)" einfügen.
In der äusseren Schlaufe prüfen wir auf Y-TPB statt auf Y. Damit ist sichergestellt, dass die inneren Schlaufe immer genau TPB mal durchgeführt wird. Es ist somit keine weitere Abfrage y+yb<Y nötig. (würde nur unnötig Zeit kosten)
Diese Abfrage müssen wir dann nur noch nach Einlesen des letzten Teils von B machen. Falls das Lesen von Werten ausserhalb des definierten Bereichs von Feld B ein Problem darstellen sollte, müsste hier noch eine Abfrage "if(y+x0<Y)" eingefügt werden.

Auf zwei Computern getestet hat diese Optimierung auf dem einen ein Faktor 2 (GT 630 OEM) gebracht, auf dem andern (RTX 2080) praktisch keine Veränderung. (ist auf dieser neueren Grafikkarte der globale Speicher auch schon so schnell?)

2. Optimierungsversuch

Eine andere Optimierungsmöglichkeit ist die Verwendung von float4. Damit werden bei einem Zugriff auf den globalen Speicher immer gleich 4 float-Werte gelesen. Damit werden also weniger Speicherzugriffe benötigt um die gleiche Anzahl floats einzulesen (oder zu speichern).

In OpenCL ist der Vektortyp float4 in etwa so definiert:

struct float4 {float x,y,z,w;}
Bei einer Multiplikaton mit float wird jeder Eintrag in float4 damit multipliziert.
Bei Addition von zwei float4-Werten werden jeweils die einzelnen Einträge addiert (also a.x+b.x, a.y+b.y ...).
Es gibt noch andere Vektortypen, z.B. float2, float3, float8, float16 (gleiches auch mit z.B. int).

Wir benutzen in unserem Kernel also mal float4 für die Felder A und C:

__kernel void hello(__global const float4 *A,__global const float *B,
                    __global float4 *C,const int X,const int Y)
{
 int x=get_global_id(0);
 float4 sum={0,0,0,0};
 if(x<X/4)
  {
   for(int y=0;y<Y;y++)
    {
     sum += B[y]*A[x];
    }
   C[x] = sum;
  }
}
Je nach installierter OpenCL-Version kann es auch "float4 sum=0;" heissen.
Die Feldgrössen sollten durch 4 teilbar sein. Falls nicht, kann man beim Reservieren des Speichers auf dem Device entsprechend aufrunden. Dann im Kernel statt X/4 mit (X+3)/4 aufrunden.

Beim Aufruf sollte auch noch durch 4 dividiert werden (mit Aufrundung):

 queue.enqueueNDRangeKernel(kernel_hello,cl::NullRange,cl::NDRange(rup((X+3)/4,TPB)),cl::NDRange(TPB));
Bei mir gibt es damit folgende Verbesserungen: Faktor 2.7 (4218 GFLOPS) beim RTX-2080, 3.7 (83 GFLOPS) beim GT-630-OEM.
Im Vergleich dazu habe ich für eine Matrixmultiplikation mit optimierter Library (CUBLAS) beim RTX-2080 5727 GFLOPS gemessen, beim GT-630 noch 149 GFLOPS. Unsere Optimierung dürfte somit für die schnelle Grafikkarte schon fast optimal sein.

3. Optimierungsversuch

Wenn statt float4 noch float8 verwendet wird, wird es auf der schnellen Karte wieder langsamer, auf der andern gibt es nochmals eine Verbesserung um fast Faktor 2.

Wenn jetzt aber als Anzahl Threads pro Block statt 1024 nur 64 gesetzt wird, haben wir wieder eine Verbesserung. Ich habe keine richtige Erklärung dafür. Möglicherweise könnte es damit zusammenhängen, dass wenn weniger Threads pro Block verwendet werden, dann mehr Register pro Thread benutzbar sind.

Jedenfalls habe ich als optimale Zahl durch probieren folgende gefunden:
RTX-2080:   64
GT-630-OEM: 128

Bei mir gibt es damit folgende Verbesserungen: 5160 GFLOPS beim RTX-2080, 166 GFLOPS beim GT-630-OEM.
Damit liegt der Wert für den GT-630 leicht über dem Vergleichswert der Matrixmultiplikation mit CUBLAS-Library.

4. Optimierungsversuch

Eine naheliegende Idee ist es auch noch Feld B mit float8 anzusprechen.
Die Einträge in der float8 Struktur heissen s0 bis s7 (im Gegensatz zu float4 wo es x y z w heisst)
Hier der entsprechende Kernel mit der Einschränkung dass Y durch 8 teilbar sein muss:
__kernel void hello(__global const float8 *A,__global const float8 *B,
		    __global float8 *C,const int X,const int Y)
{
 int x=get_global_id(0);
 float8 sum={0,0,0,0,0,0,0,0};
 //float8 sum=0;
 if(x<(X+7)/8)
  {
   for(int y=0;y<Y/8;y++)
    {
     sum += B[y].s0*A[x];
     sum += B[y].s1*A[x];
     sum += B[y].s2*A[x];
     sum += B[y].s3*A[x];
     sum += B[y].s4*A[x];
     sum += B[y].s5*A[x];
     sum += B[y].s6*A[x];
     sum += B[y].s7*A[x];
    }
   C[x] = sum;
  }
}
Wenn Y nicht durch 8 teilbar ist, müssen wir am Ende der Schlaufe noch den Rest berücksichtigen. Also wenn der Rest 1 ist, dann wird B[Y/8].s0 noch gebraucht, bei Rest 2 auch noch B[Y/8].s1, usw.
Der entsprechende Programmteil sieht also etwa so aus:
   int Y8=Y/8, Yrest=Y%8;
   if(Yrest!=0)
    {
     sum += B[Y8].s0*A[x];
     if(Yrest>=2) sum += B[Y8].s1*A[x];
     if(Yrest>=3) sum += B[Y8].s2*A[x];
     if(Yrest>=4) sum += B[Y8].s3*A[x];
     if(Yrest>=5) sum += B[Y8].s4*A[x];
     if(Yrest>=6) sum += B[Y8].s5*A[x];
     if(Yrest==7) sum += B[Y8].s6*A[x];
    }
   C[x] = sum;
Benutzung von switch(Yrest) würde auch gehen.

Wieder die Anzahl Threads pro Block variiert:
RTX-2080:   128
GT-630-OEM: 128...512

Bei mir gibt es damit folgende Verbesserungen: 5594 GFLOPS beim RTX-2080, 214 GFLOPS beim GT-630-OEM.
Also schon sehr nahe am CUBLAS-Vergleichswert, oder sogar darüber.

5. Optimierungsversuch

Man kann noch versuchen die Optimierungen 1 und 4 zu kombinieren.

Hier der entsprechende Kernel:

__kernel void hello(__global const float8 *A,__global const float8 *B,
		    __global float8 *C,const int X,const int Y)
{
 int x0=get_local_id(0);
 const int TPB=get_local_size(0); //Threads Per Block
 int x=get_group_id(0)*TPB+x0; //gleiches Resultat wie get_global_id(0)
 __local float8 Blocal[1024]; //lokaler Speicher (max.48KB)
 float8 sum={0,0,0,0,0,0,0,0};
 const int X8=(X+7)/8;
 const int Y8=Y/8;
 int y;
 for(y=0;y<Y8-TPB;y+=TPB)
  {
   Blocal[x0]=B[y+x0];
   barrier(CLK_LOCAL_MEM_FENCE);
   if(x<X8)
    for(int yb=0;yb<TPB;yb++)
    {
     sum += Blocal[yb].s0*A[x];
     sum += Blocal[yb].s1*A[x];
     sum += Blocal[yb].s2*A[x];
     sum += Blocal[yb].s3*A[x];
     sum += Blocal[yb].s4*A[x];
     sum += Blocal[yb].s5*A[x];
     sum += Blocal[yb].s6*A[x];
     sum += Blocal[yb].s7*A[x];
    }
   barrier(CLK_LOCAL_MEM_FENCE);
  }
 if(y+x0<(Y+7)/8) Blocal[x0]=B[y+x0];
 barrier(CLK_LOCAL_MEM_FENCE);
 if(x<X8)
 {
  for(int yb=0;y+yb<Y8;yb++)
   {
    sum += Blocal[yb].s0*A[x];
    sum += Blocal[yb].s1*A[x];
    sum += Blocal[yb].s2*A[x];
    sum += Blocal[yb].s3*A[x];
    sum += Blocal[yb].s4*A[x];
    sum += Blocal[yb].s5*A[x];
    sum += Blocal[yb].s6*A[x];
    sum += Blocal[yb].s7*A[x];
   }
  switch(Y%8)
   {
    case 7: sum += B[Y8].s6*A[x];
    case 6: sum += B[Y8].s5*A[x];
    case 5: sum += B[Y8].s4*A[x];
    case 4: sum += B[Y8].s3*A[x];
    case 3: sum += B[Y8].s2*A[x];
    case 2: sum += B[Y8].s1*A[x];
    case 1: sum += B[Y8].s0*A[x];
   }
  C[x] = sum;
 }
}
Ich habe mal die (wahrscheinlich unnötigen) Abfragen um sicher zu stellen, dass nicht von einem ungültigen Bereich gelesen wird, mit eingefügt. Also "if(x<X8)" vor innerer Schlaufe und "if(y+x0<(Y+7)/8)" beim Kopieren des letzten Teils von B in lokalen Speicher.
Erstaunlicherweise ist es damit überhaupt nicht langsamer geworden.

zufällige Optimierungen

Wenn man willkürlich TPB (Threads Pro Block) verändert, dann gibt es manchmal erstaunliche Verbesserungen. Ich weiss aber nicht warum.
Jedenfalls bei Optimierung 2 auf meiner schnellen Grafikkarte wird es mit TPB=64 etwa 25% schneller. Damit erreiche ich den bisher besten gemessenen Wert (5800 GFLOPS).

Beispiel3 download

Das vollständige Beispiel kann hier heruntergeladen werden:
beispiel3_opencl.tar.gz

Es ist so voreingestellt, dass es beim Compilieren Informationen (für Fehlersuche) anzeigen sollte und nebenbei noch ein tmp.ptx (Assemblerähnlicher Code) speichert.
Mehrmals den gleichen Kernel compiliert gibt die Informationen aber nur beim ersten mal (Bug im OpenCL?).
Das Hauptprogramm heisst übrigens "hello.cc", die Routinen für den OpenCL-Teil sind in "myopencl.cc" und der Kernel heisst "hello_kernel.cc".
Die unterschiedlichen Optimierungsversuche sind in "kernelversionen/" zu finden.
Beim Aufruf kann man mit Option -o die Optimierungsvariante auswählen und mit Option -t noch ein anderes TPB setzen. (Option -? gibt eine kleine Hilfe.)

Fehlersuche (debuggen des OpenCL-Kernels)

Wie man sinnvolle Fehlermeldungen bekommt wenn man im Kernel-Teil was falsch gemacht hat, war anhand der im Internet gefundenen Beispiele überhaupt nicht offensichtlich.

Die Suche nach Informationen über OpenCL war etwas schwierig. Einmal sind die Funktionsnamen von OpenCL bei c und c++ unterschiedlich und deshalb schwierig die richtigen zu finden. Dann die Informationsseiten der KhronosGroup sind teilweise extrem unübersichtlich.

Deshalb hier eine kleine Zusammenstellung der wichtigsten Funktionen:
Funktion Parameter-Beispiele Beschreibung
int err;
err=program.build({device},optionen)
"-cl-nv-verbose"
"-cl-nv-maxrregcount=32"
das "verbose" ist nötig wenn man eine Rückmeldung haben will
std::string default_device.getInfo<CL_DEVICE_NAME>() gibt Name der Grafikkarte als String zurück
std::string
program.getBuildInfo<CL_PROGRAM_BUILD_LOG>(device)
default_device Fehlermeldungen oder Informationen bei erfolgreichem Compilieren
size_t bin_sz;
program.getInfo(CL_PROGRAM_BINARY_SIZES,&bin_sz)
&bin_sz gibt die Grösse des PTX-Codes zurück
char *bin=new char[bin_sz];
program.getInfo(CL_PROGRAM_BINARIES,bin)
gibt den PTX-Code als Text zurück
queue.enqueueNDRangeKernel(kernel,offset,globrange,locrange) cl::NDRange(xmax), cl::NDRange(TPB) offset wird nie gebraucht und sollte immer cl::NullRange sein

Einige Funktionen geben einen Fehlercode zurück, 0 wenn erfolgreich, oder eine negative Zahl wenn Fehler. Die Definitionen der Fehlercodes sind in /usr/include/CL/cl.h zu finden. Die Funktion getErrorString(int) in "myopencl.cc" zeigt in etwa diese Definitionen an.

Mit einem "#define __CL_ENABLE_EXCEPTIONS" vor dem "#include <CL/cl.hpp>" hat man die Möglichkeit try-catch-Konstrukte zu verwenden:

  try {
  ...
  } catch(cl::Error &error) {
  ...
  }
Wenn im try-Block ein Fehler passiert, so bekommt man den Fehlercode im catch-Block mit error.err(), und noch wo der Fehler passiert ist mit error.what().
Wenn beim Kernelcompilieren der catch-Block erreicht wird, kann man sich so ein Fehlerlisting anzeigen lassen:
  } catch(cl::Error &error) {
  std::cout << error.what() << "(" << error.err() << ") "
            << getErrorString(error.err()) << std::endl;
  std::string name     = default_device.getInfo<CL_DEVICE_NAME>();
  std::string buildlog = program.getBuildInfo<CL_PROGRAM_BUILD_LOG>(default_device);
  std::cerr << "Build log for " << name << ":" << std::endl << buildlog << std::endl;
  return false;
 }

Bei erfolgreichem Compilieren bekommt man mit program.getBuildInfo() in etwa folgenden Ausdruck:

Build log for GeForce GT 625:

ptxas info    : 0 bytes gmem
ptxas info    : Compiling entry function 'hello' for 'sm_21'
ptxas info    : Function properties for hello
ptxas         .     0 bytes stack frame, 0 bytes spill stores, 0 bytes spill loads
ptxas info    : Used 31 registers, 32800 bytes smem, 64 bytes cmem[0]

Nachdem das Compilieren erfolgreich war, kann man beim laufen lassen immer noch Fehler bekommen. Eine typische Fehlermeldung sieht dann so aus:

Fehler10: Kernelaufruf misslungen:
  clFinish(-36) CL_INVALID_COMMAND_QUEUE
device_speicher_freigeben()
terminate called after throwing an instance of 'cl::Error'
  what():  clFinish
Abgebrochen (Speicherabzug geschrieben)
Ob und wie man da eine aussagekräftige Fehlermeldung bekommen könnte, weiss ich noch nicht. (Im vorliegenden Fall waren es falsche Indizes beim schreiben oder lesen von lokalem Speicher.)

Erzeugen einer PTX-Datei

Nach erfolgreichem Compilieren kann man mit Hilfe der Funktion program.getInfo() einen Assemblerähnlichen Code für den Kernel bekommen.
Der entsprechende Programmteil hier:
 size_t bin_sz;
 program.getInfo(CL_PROGRAM_BINARY_SIZES,&bin_sz);
 char *bin=new char[bin_sz];
 program.getInfo(CL_PROGRAM_BINARIES,&bin);
 FILE *fp=fopen("tmp.ptx","w");
 fprintf(fp,"%s\n",bin);
 fclose(fp);
 delete[] bin;

Makefile

Das Programm lässt sich mit "make" compilieren. Zum Aufrämen "make clean" oder "make clean_all" verwenden.
Wie schon erwähnt, kann man mit der Option -o einen optimierten Kernel auswählen. Der wird dann direkt aus dem Ordner kernelversionen/ geladen.

Wenn schon defaultmässig eine optimierte Variante verwendet werden soll, kann man diese von kernelversionen/ ins Hauptverzeichnis kopieren und als "hello_kernel.cc" speichern.
Damit das Hauptprogramm weiss welche Optimierung verwendet werden soll, ist jeweils ein entsprechendes #define eingebaut:

#ifdef NUR_OPTIONEN
OPTIMIERUNG 5
#else
__kernel ...
...
}
#endif
Das Hauptprogramm liest dann dieses #define mit entsprechendem #include (mit NUR_OPTIONEN gesetzt).

Auszug aus dem makefile:

KERNEL = hello_kernel.cc
KERNEL_OPT = kernelversionen/hello_kernel-opt5.cc

all: hello
#all: hello tmp.o

hello: hello.cc hello.h myopencl.cc myopencl.h

check: $(KERNEL_OPT)
        gcc -c -I/usr/local/cuda/include/ -DCHECK $(KERNEL_OPT) -o tmp.o
tmp.o: $(KERNEL)
        gcc -c -I/usr/local/cuda/include/ -DCHECK $(KERNEL) -o tmp.o
Mit "make check" kann man einen Kernel schon mal mit dem normalen gcc auf Syntaxfehler kontrollieren.
Um direkt vom Editor aus (emacs) zu testen und direkt zu allfälligen Fehlern zu springen, kann man noch bei all: das tmp.o mit angeben.

Damit der gcc bei den OpenCL-spezifischen Sachen nicht reklamiert, sollte noch die Datei "kernelcheck.h" im Kernel eingefügt werden, aber nur wenn CHECK gesetzt ist:

#ifdef CHECK
#include "kernelcheck.h"
#endif
Falls "vector_types.h" nicht gefunden wird, dann im makefile bei -I den entsprechenden Pfad angeben. (find /usr/ -name "vector_types.h"   sollte den Pfad liefern)

vollständiges Hello-World mit OpenCL

Wenn man im Internet nach "hello world opencl" sucht, kommt man bisher nur auf sehr einfache Beispiele, meiner Meinung nach zu einfache.
Einem Prinzip von Einstein folgend "man soll es so einfach wie möglich machen, aber nicht einfacher" habe ich also mal versucht ein richtiges Hello-World für OpenCL zu entwerfen.

Ich habe dazu das oben erklärte Beispiel verwendet, von dem ich glaube dass es so einfach wie möglich, aber nicht einfacher ist.


Downloads:

opencl_helloworld.tar.gz   vereinfachte Variante, ohne Optionsauswertung, nur ein optimierter Kernel (neben nicht optimiertem)

beispiel3_opencl.tar.gz   Hauptprogramm mit Optionen zum alle Optimierungsvarianten auszuprobieren


Fortsetzungen:

Teil 4 (noch nicht vorhanden)
zurück zu: Teil1   Teil2

Vergleichstabelle in Teil2
Letzte Änderungen: 20.6.2019 / Rolf                                         Validator