====== einfache Vektoraddition ====== Der Sinn des Programms ist es, zu zeigen, wie OpenCL grundsätzlich funktioniert, was beachtet werden muss. Weiter unten wird dann alles erklärt. ===== Quellcode ===== #include #include #ifdef __APPLE__ #include #else #include #endif #define MAX_SOURCE_SIZE (0x100000) int main(void) { // Create the two input vectors int i; const int LIST_SIZE = 1024; int *A = (int*)malloc(sizeof(int)*LIST_SIZE); int *B = (int*)malloc(sizeof(int)*LIST_SIZE); for(i = 0; i < LIST_SIZE; i++) { A[i] = i; B[i] = LIST_SIZE - i; } // Load the kernel source code into the array source_str FILE *fp; char *source_str; size_t source_size; fp = fopen("vector_add_kernel.cl", "r"); if (!fp) { fprintf(stderr, "Failed to load kernel.\n"); exit(1); } source_str = (char*)malloc(MAX_SOURCE_SIZE); source_size = fread( source_str, 1, MAX_SOURCE_SIZE, fp); fclose( fp ); // Get platform and device information cl_platform_id platform_id = NULL; cl_device_id device_id = NULL; cl_uint ret_num_devices; cl_uint ret_num_platforms; cl_int ret = clGetPlatformIDs(1, &platform_id, &ret_num_platforms); ret = clGetDeviceIDs( platform_id, CL_DEVICE_TYPE_DEFAULT, 1, &device_id, &ret_num_devices); // Create an OpenCL context cl_context context = clCreateContext( NULL, 1, &device_id, NULL, NULL, &ret); // Create a command queue cl_command_queue command_queue = clCreateCommandQueue(context, device_id, 0, &ret); // Create memory buffers on the device for each vector cl_mem a_mem_obj = clCreateBuffer(context, CL_MEM_READ_ONLY, LIST_SIZE * sizeof(int), NULL, &ret); cl_mem b_mem_obj = clCreateBuffer(context, CL_MEM_READ_ONLY, LIST_SIZE * sizeof(int), NULL, &ret); cl_mem c_mem_obj = clCreateBuffer(context, CL_MEM_WRITE_ONLY, LIST_SIZE * sizeof(int), NULL, &ret); // Copy the lists A and B to their respective memory buffers ret = clEnqueueWriteBuffer(command_queue, a_mem_obj, CL_TRUE, 0, LIST_SIZE * sizeof(int), A, 0, NULL, NULL); ret = clEnqueueWriteBuffer(command_queue, b_mem_obj, CL_TRUE, 0, LIST_SIZE * sizeof(int), B, 0, NULL, NULL); // Create a program from the kernel source cl_program program = clCreateProgramWithSource(context, 1, (const char **)&source_str, (const size_t *)&source_size, &ret); // Build the program ret = clBuildProgram(program, 1, &device_id, NULL, NULL, NULL); // Create the OpenCL kernel cl_kernel kernel = clCreateKernel(program, "vector_add", &ret); // Set the arguments of the kernel ret = clSetKernelArg(kernel, 0, sizeof(cl_mem), (void *)&a_mem_obj); ret = clSetKernelArg(kernel, 1, sizeof(cl_mem), (void *)&b_mem_obj); ret = clSetKernelArg(kernel, 2, sizeof(cl_mem), (void *)&c_mem_obj); // Execute the OpenCL kernel on the list size_t global_item_size = LIST_SIZE; // Process the entire lists size_t local_item_size = 64; // Divide work items into groups of 64 ret = clEnqueueNDRangeKernel(command_queue, kernel, 1, NULL, &global_item_size, &local_item_size, 0, NULL, NULL); // Read the memory buffer C on the device to the local variable C int *C = (int*)malloc(sizeof(int)*LIST_SIZE); ret = clEnqueueReadBuffer(command_queue, c_mem_obj, CL_TRUE, 0, LIST_SIZE * sizeof(int), C, 0, NULL, NULL); // Display the result to the screen for(i = 0; i < LIST_SIZE; i++) printf("%d + %d = %d\n", A[i], B[i], C[i]); // Clean up ret = clFlush(command_queue); ret = clFinish(command_queue); ret = clReleaseKernel(kernel); ret = clReleaseProgram(program); ret = clReleaseMemObject(a_mem_obj); ret = clReleaseMemObject(b_mem_obj); ret = clReleaseMemObject(c_mem_obj); ret = clReleaseCommandQueue(command_queue); ret = clReleaseContext(context); free(A); free(B); free(C); return 0; } __kernel void vector_add(__global const float *a, __global const float *b, __global float *result) { int gid = get_global_id(0); result[gid] = a[gid] + b[gid]; } gcc host.c -lOpenCL ===== Erläuterung zum Quellcode ===== Das C-Programm initialisiert die OpenCL-Umgebung, liest den Kernel ein, holt sich das Device, erstellt einen Kontext und eine CommandQueue, die Daten auf die Grafikkarte (das OpenCL-Gerät) kopiert, dort werden die Daten im Kernel verarbeitet, und nach Beendigung des Kernels im Hostprogramm zurückkopiert. ==== Devices und Plattformen ==== cl_platform_id platform_id = NULL; cl_device_id device_id = NULL; cl_uint ret_num_devices; cl_uint ret_num_platforms; cl_int ret = clGetPlatformIDs(1, &platform_id, &ret_num_platforms); ret = clGetDeviceIDs( platform_id, CL_DEVICE_TYPE_DEFAULT, 1, &device_id, &ret_num_devices); Zunächst werden Variablen für Plattform ID und Device ID angelegt, sowie Variablen um die Anzahl der Devices und Plattformen zu halten. In OpenCL ist es möglich mehrere Plattformen in einem Computer zu haben, bspw. kann eine AMD Grafikkarte und eine Intel CPU Verwendung finden. AMD und Intel liefern unterschiedliche OpenCL-Umgebungen, und man kann innerhalb des Programms zwischen den vorhandenen Plattformen wählen, oder sich irgendeine nehmen. ''clGetPlatformIDs'' lässt sich vom OpenCL-Framework eine Plattform ID geben, und ''clGetDeviceIDs'' lässt sich eine Device ID geben. Genaue Beschreibungen der Funktionen finden Sie in unserer Referenz oder unter [1]. Es gibt die Möglichkeit ''clGetDeviceIDs'' mitzuteilen, nur bestimmte Geräte haben zu wollen. Zum Einen muss man sich auf eine Plattform festlegen, zum anderen kann man angeben, ob CPUs, GPUs, andere Beschleuniger gewählt werden sollen oder das egal ist (DEFAULT => CPU oder GPU). ==== Kontext und Commandqueue ==== // Create an OpenCL context cl_context context = clCreateContext( NULL, 1, &device_id, NULL, NULL, &ret); // Create a command queue cl_command_queue command_queue = clCreateCommandQueue(context, device_id, 0, &ret); Als nächstes erzeugen wir uns aus der Plattform ID und der DeviceID einen Kontext und eine Commandqueue. Der Kontext lässt uns auf das Device zugreifen, die Commandqueue ist eine Befehlswarteschlange. Im Laufe des Programms werden wir Befehle an das OpenCL-Device schicken (und damit Operationen in die Warteschlange einreihen, die vom Gerät ausgeführt werden sollen). ==== Speicherobjekte ==== // Create memory buffers on the device for each vector cl_mem a_mem_obj = clCreateBuffer(context, CL_MEM_READ_ONLY, LIST_SIZE * sizeof(int), NULL, &ret); cl_mem b_mem_obj = clCreateBuffer(context, CL_MEM_READ_ONLY, LIST_SIZE * sizeof(int), NULL, &ret); cl_mem c_mem_obj = clCreateBuffer(context, CL_MEM_WRITE_ONLY, LIST_SIZE * sizeof(int), NULL, &ret); Nun werden Speicherobjekte angelegt - das kann man ähnlich auffassen, wie Variablen (bzw. Felder / Zeiger) in einem normalen C-Programm. Wir legen die Speicherobjekte an, was veranlasst, dass die OpenCL-Umgebung den Speicher auf dem Device reserviert, oder abbricht, wenn er nicht zur Verfügung steht. Die Hostvariable ''a_mem_obj'' des Typs ''cl_mem'' ist ein Identifikator, mit dem wir dem OpenCL-Framework mitteilen können, welches Speicherobjekt angesprochen werden soll. Nach der Benutzung von Speicherobjekten ist es erforderlich diese auch wieder zu löschen (vgl. malloc und free). Wenn man einem Compute Kernel Felder als Parameter übergeben möchte, ist es erforderlich Speicherobjekte anzulegen. // Copy the lists A and B to their respective memory buffers ret = clEnqueueWriteBuffer(command_queue, a_mem_obj, CL_TRUE, 0, LIST_SIZE * sizeof(int), A, 0, NULL, NULL); ret = clEnqueueWriteBuffer(command_queue, b_mem_obj, CL_TRUE, 0, LIST_SIZE * sizeof(int), B, 0, NULL, NULL); ''clEnqueueWriteBuffer'' fügt eine Schreiboperation auf ein Speicherobjekt in die Befehlswarteschlange ein. Dh. in der Befehlswarteschlange steht nach der Ausführung der Funktion eine Schreiboperation, die später vom CL-Device ausgeführt wird. Dabei muss angegeben werden, welches Speicherobjekt beschrieben werden soll, wie viele Bytes geschrieben werden sollen und wo die Daten herkommen, sowie die Command-Queue Variable. ''clEnqueueWriteBuffer'' führt den Schreibbefehl nicht sofort aus, sondern fügt ihn nur in die Befehlwarteschlange ein. ==== Kernelerzeugung ==== // Create a program from the kernel source cl_program program = clCreateProgramWithSource(context, 1, (const char **)&source_str, (const size_t *)&source_size, &ret); // Build the program ret = clBuildProgram(program, 1, &device_id, NULL, NULL, NULL); // Create the OpenCL kernel cl_kernel kernel = clCreateKernel(program, "vector_add", &ret); Wir haben weiter oben einen String aus einer Datei ausgelesen. Dieser String muss von einem OpenCL-Compiler übersetzt werden, das geschrieht mit ''clCreateProgramWithSource'' und ''clBuildProgram''. In diesem Code (weiter unten mehr dazu) gibt es eine Funktion, die mit kernel gekennzeichnet ist. Diese Funktion ist der eigentliche Kernel, der vom Hostprogramm aus gerufen werden kann. Dieser Kernel wird mittels ''clCreateKernel'' erzeugt. ==== Parameter des Kernels ==== // Set the arguments of the kernel ret = clSetKernelArg(kernel, 0, sizeof(cl_mem), (void *)&a_mem_obj); ret = clSetKernelArg(kernel, 1, sizeof(cl_mem), (void *)&b_mem_obj); ret = clSetKernelArg(kernel, 2, sizeof(cl_mem), (void *)&c_mem_obj); Die Kernelfunktion besitzt drei Parameter. Diese Parameter müssen im Hostprogramm festgelegt werden. Das geschieht mittels ''clSetKernelArg''. Dabei wird das Kernel-Objekt angegeben, die Parameternummer, und die Größe des eigentlichen Parameters, sowie der Parameter selbst. Wichtig dabei ist, dass, wenn ein Speicherobjekt als Parameter verwendet wird, ''sizeof(cl_mem)'' als Größe angegeben wird. Soll bspw. ein Int-Wert übergeben werden, kann dort sizeof(int) stehen. ==== Ausführung ==== // Execute the OpenCL kernel on the list size_t global_item_size = LIST_SIZE; // Process the entire lists size_t local_item_size = 64; // Divide work items into groups of 64 ret = clEnqueueNDRangeKernel(command_queue, kernel, 1, NULL, &global_item_size, &local_item_size, 0, NULL, NULL); ''clEnqueueNDRangeKernel'' reiht die Ausführung des Kernels in die Befehlswarteschlange des CL-Devices ein. Für die Ausführung eines Kernel müssen die Ausmaße der Berechnung bekannt sein (Parallelität). Wir haben hier eine LIST_SIZE große Datenstruktur, die parallel bearbeitet werden kann. Deshalb verwenden wir LIST_SIZE viele Working Groups (Ausführungseinheiten). local_item_size gibt dann noch an, wie groß die Working Groups sein sollen. Es ist auch möglich mehrere Dimensionen von Ausführungseinheiten anzulegen, das erleichtert bspw. die Programmierung für die Bearbeitung eines Bildes. ==== Daten zurücklesen ==== // Read the memory buffer C on the device to the local variable C int *C = (int*)malloc(sizeof(int)*LIST_SIZE); ret = clEnqueueReadBuffer(command_queue, c_mem_obj, CL_TRUE, 0, LIST_SIZE * sizeof(int), C, 0, NULL, NULL); ''clEnqueueReadBuffer'' funktioniert analog zu ''clEnqueueWriteBuffer''. Hier wird eine Leseoperation in die Befehlswarteschlange eingefügt, die den Wert des Speicherobjekts c_mem_obj ausliest und an die Speicheradresse C des Host-Speichers schreibt. ==== Aufräumen ==== // Clean up ret = clFlush(command_queue); ret = clFinish(command_queue); ret = clReleaseKernel(kernel); ret = clReleaseProgram(program); ret = clReleaseMemObject(a_mem_obj); ret = clReleaseMemObject(b_mem_obj); ret = clReleaseMemObject(c_mem_obj); ret = clReleaseCommandQueue(command_queue); ret = clReleaseContext(context); free(A); free(B); free(C); return 0; Hier wird aufgeräumt. ClFlush und clFinish haben zum Ergebnis, dass die Befehlswarteschlange abgearbeitet wird (also zuende abgearbeitet wird, clFlush wartet nicht darauf, clFinish wartet auf die Abarbeitung der CommandQueue. clReleaseX leert den Speicher, den ein Objekt eingenommen hat, bspw. wird bei clReleaseMemObject der Speicher auf dem Gerät freigegeben, der vom Speicherobjekt eingenommen wurde. Nicht vergessen den Kontext und die CommandQueue wieder freizugeben. ===== Kernel ===== Der Kernel ist der eigentlich auf dem CL-Device ausgeführte Code. Eine Kerneldatei endet auf .cl und sieht erstmal aus wie normaler C-Code. Folgender Kernel: __kernel void vector_add (__global const float *a, __global const float *b, __global float *result ) { int gid = get_global_id(0); result[gid] = a[gid] + b[gid]; } Zunächst steht der Funktionskopf, wie bei einer normalen C-Funktion. Das beginnende kernel hat zur Folge, dass die Funktion als Kernel vom Hauptprogramm aus aufrufbar ist, sie dient quasi als Einsprungpunkt für die Berechhnung auf dem CL-Device. Es ist möglich weitere Funktionen zu haben, die auf dem CL-Device ausgeführt werden können, die diese Markierung mittels kernel nicht besitzen. Es folgt ein Rückgabetyp und Funktionsname, sowie die Parameterliste. Es ist auffällig, dass die Parameter einerseits ''global'' andererseits (die ersten beiden) als const definiert sind. ''const'' verhindert, dass die Variable verändert werden kann, so können Fehler beim Programmieren vermieden werden, wenn der Programmierer doch versucht die Variable zu ändern. ''global'' bedeutet, dass die Variablen im (langsameren) globalen Speicher liegen. Es gibt noch ''local'', dann liegen die Parameter im lokalen Speicher, der schneller, aber kleiner ist. Außerdem steht der nur den lokalen Recheneinheiten zur Verfügung. Der Kernel besteht nur aus zwei Anweisungen. Zunächst wird die globale ID des Working-Items (Ausführungseinheit) geholt. In Abhängigkeit dieses Wertes kann bestimmt werden, welche Stelle des Vektors von dem Working-Item bearbeitet werden soll. Kernelfunktionen werden also aus der Sicht irgendeines einzigen Working-Item geschrieben, und lässt sich anhand der globalen ID problemlos von Cl auf alle Berechnungseinheiten übertragen. ===== Literatur ===== [1] [[https://www.khronos.org/registry/cl/sdk/1.2/docs/man/xhtml/]]