Inhalt des Dokuments
Überblick über das System
Der IBM Cell/B.E. Cluster der Mathematik besteht aus 8 IBM BladeCenter QS22 Knoten mit je einem Allzweckprozessor (PPE bzw. PPU, 64 Bit Power Prozessor) sowie acht Ko-Prozessoren (SPE bzw. SPU, Single Instruction Multiple Data), die besonders für Multimedia, Vektorrechnen und weitere breitbandige Berechnungsanwendungen geeignet sind.
Jeder QS22 Knoten bietet 217 GFLOPS in Double Precision bzw. 460 GFLOPS in Single Precision. Damit stehen insgesamt 1.7 bzw. 3.7 TFLOPS in Double bzw. Single Precision zur Verfügung. Die Knoten sind für schnelle Kommunikation mittels Infiniband DDR verbunden und verfügen jeweils über 16 GB RAM.
Entwicklung
Zur Entwicklung auf der IBM Cell/B.E. Architektur stehen die C/C++ und Fortran Compiler aus dem SDK 3.1 zur Verfügung. IBM stellt dazu u.A. ein ausführliches Programming Tutorial zur Verfügung.
Im IBM Cell Broadband Engine resource center finden sich eine Vielzahl von Bibliotheken für die Entwicklung auf der Cell/B.E. Architektur. Darunter befinden sich u.A.
- Basic Linear Algebra Subprograms Programmer's Guide
- Fast Fourier Transform Library Programmer's Guide
- 3D FFT Library API Reference
- Linear Algebra PACKage Programmer's Guide
- Mathematical Acceleration Subsystem (MASS) Library
- Monte Carlo Programmer's Guide and API Reference
- SIMD Math Library API Reference
Erste Schritte - Cell Superscalar (CellSs)
Der Cell Superscalar (CellSS) Kompiler bietet eine sehr deklarative Methode, um Quellcode automatisch zu parallelisieren. Dies illustriert das folgende Beispiel, in dem zwei Matrizen miteinander multipliziert werden sollen:
matrix_multiplication.c
// ...
#define BSIZE 64
#pragma css task input(A, B) inout(C)
void matmul (float A[BSIZE][BSIZE], float B[BSIZE][BSIZE], float C[BSIZE][BSIZE])
{
int i, j, k;
for (i = 0; i < BSIZE; i++)
{
for (j = 0; j < BSIZE; j++)
{
for (k=0; k < BSIZE; k++)
C[i][j] += A[i][k]*B[k][j];
}
}
}
void compute(int DIM, float *A[DIM][DIM], float *B[DIM][DIM], float *C[DIM][DIM])
{
int i, j, k;
#pragma css start
for (i = 0; i < DIM; i++)
for (j = 0; j < DIM; j++)
for (k = 0; k < DIM; k++)
matmul (A[i][k], B[k][j], C[i][j]);
#pragma css finish
}
|
Die Compute-Methode bekommt zwei Matrizen A und B, in denen an jeder Stelle [i][j] eine 64x64 Untermatrix gespeichert ist. Die compute-Methode bekommt nun die Aufgabe, A und B zu multiplizieren, indem die entsprechenden Untermatrizen multipliziert werden. Da matmul mit dem #pragma css task versehen ist, werden alle Aufrufe in eine Task-Queue gelegt und dann parallel von den SPUs verarbeitet.
Unter Verwendung von Cell Superscalar, muss der Programmierer nur ein Programm schreiben, indem parallele Code-Blöcke entsprechend annotiert sind. Ein Aufruf von "cellss-cc matrix_multiplication.c" generiert den notwendigen Code für PPU und SPU und erzeugt ein ausführbares Binary.
Eine ausführliche Anleitung zu Cell Superscalar findet sich auf den Seiten des Barcelona Supercomputing Centers.
Erste Schritte - Direkte Cell Programmierung
Zunächst muss die CELL_TOP Umgebungsvariable wie folgt gesetzt werden.
export CELL_TOP=/opt/cell/sdk/
|
Das einfachste Programm auf der PPU (Standard CPU) sieht wie folgt aus:
hello_ppu.c
#include <stdio.h>
int main(int argc, char **argv) {
printf("Hello World!\n");
return 0;
}
|
Zum Kompilieren wird das folgende Makefile verwendet:
Makefile
PROGRAM_ppu = hello_ppu
include $(CELL_TOP)/buildutils/make.footer
|
Das Pendant zum Hello World auf der SPU (co-Prozessor) sieht wie folgt aus:
spu/hello_spu.c
#include <stdio.h>
typedef unsigned long long ull;
// speid = eindeutiger SPU Task Identifier; vom OS zugewiesen
// argp = Adresse im Hauptspeicher für Parameter, im PPU Teil zugewiesen
// envp = Adresse im Hauptspeicher für Laufzeitumgebungsinformationen; im PPU Teil zugewiesen
int main(ull speid, ull argp, ull envp) {
printf("Hello World from a SPU!\n");
return 0;
}
|
Zum Kompilieren wird das folgende Makefile verwendet:
spu/Makefile
PROGRAM_spu = hello_spu
LIBRARY_embed = hello_spu.a
include $(CELL_TOP)/buildutils/make.footer
|
Um das SPU Programm einzubinden, sind die folgenden vier Schritte im PPU Programm notwendig:
- SPE Context erzeugen
- Ausführbares SPE Programm in SPE Context laden
- SPE Context starten
- SPE Context zerstören
Dazu können die folgenden Funktionen genutzt werden:
Funktionen zum Starten von SPE Programmen
spe_context_ptr_t spe_context_create(unsigned int flags, spe_gang_context_ptr_t gang );
int spe_program_load(spe_context_ptr_t spe, spe_program_handle_t *program );
int spe_context_run(spe_context_ptr_t spe, unsigned int * entry,
unsigend int runflags, void * argp, void * envp, spe_stop_info_t * stopinfo);
int spe_context_destroy(spe_context_ptr_t spe);
|
Die genauen Funktionen sowie die Parameter und Rückgabewerte sind in den Man-Pages ausführlich erläutert.
Das hello_ppu.c Programm wird nun wie folgt modifiziert:
hello_ppu.c
#include <stdio.h>
#include <stdlib.h>
#include <libspe2.h>
// handle for the spe program
extern spe_program_handle_t hello_spu;
int main(int argc, char **argv) {
printf("Hello World from PPU!\n");
unsigned int entry = SPE_DEFAULT_ENTRY;
spe_context_ptr_t context = spe_context_create(0, NULL);
spe_program_load(context, &hello_spu);
spe_context_run(context, &entry, 0, NULL, NULL, NULL);
spe_context_destroy(context);
return 0;
}
|
Desweiteren wird das Makefile Skript angepasst:
Makefile
DIRS := spu
PROGRAM_ppu = hello_ppu
IMPORTS = ./spu/hello_spu.a -lspe2
include $(CELL_TOP)/buildutils/make.footer
|
Nach Kompilieren des Programms mittles make, kann es mittels ./hello_ppu ausgeführt werden und gibt den folgenden Text auf die Konsole aus:
Hello World from PPU!
Hello World from a SPU!
|
Da der spe_context_run Aufruf synchron ist, wird zur Ansteuerung mehrerer SPUs die pthreads Bibliothek verwendet:
hello_ppu.c
#include <stdio.h>
#include <stdlib.h>
#include <libspe2.h>
#include <pthread.h>
#define NUM_SPE 8
extern spe_program_handle_t hello_spu;
void *ppu_pthread_function(void *arg) {
spe_context_ptr_t context = * (spe_context_ptr_t *)arg;
unsigned int entry = SPE_DEFAULT_ENTRY;
spe_context_run(context, &entry, 0, NULL, NULL, NULL);
pthread_exit(NULL);
}
int main(int argc, char **argv) {
printf("Hello World from PPU!\n");
spe_context_ptr_t context[NUM_SPE];
pthread_t pthread[NUM_SPE];
int i;
for (i=0; i<NUM_SPE; ++i) {
context[i] = spe_context_create(0, NULL);
spe_program_load(context[i], &hello_spu);
pthread_create(&pthread[i], NULL, &ppu_pthread_function, &context[i]);
}
for (i=0; i<NUM_SPE; ++i) {
pthread_join(pthread[i], NULL);
spe_context_destroy(context[i]);
}
return 0;
}
|
Das Makefile skript wird um die pthread Bibliothek erweitert:
Makefile
DIRS := spu
PROGRAM_ppu = hello_ppu
IMPORTS = ./spu/hello_spu.a -lspe2 -lpthread
include $(CELL_TOP)/buildutils/make.footer
|
Das Programm erzeugt nun die folgende Ausgabe:
Hello World from PPU!
Hello World from a SPU!
Hello World from a SPU!
Hello World from a SPU!
Hello World from a SPU!
Hello World from a SPU!
Hello World from a SPU!
Hello World from a SPU!
Hello World from a SPU!
|
Nun sollen die PPE den SPEs mitteilen, was sie ausgeben sollen. Da die SPEs nicht auf den Hauptspeicher zugreifen können, müssen die Daten zunächst in den lokalen Speicher der SPEs transferiert werden. Für die Übertragung ist es wichtig, dass Quelle und Ziel sich an Speicheradressen befinden, die Vielfache von 128 sind. Dazu dient das "__attribute__((aligned (128)))" Statement.
Wir übergeben dem Thread nun nicht nur den spe_context sondern außerdem die Startadresse, ab der Daten in die lokalen Speicher der SPEs kopiert werden sollen. Diese Adresse wird der main-Methode der SPEs übergeben.
hello_ppu.c
#include <stdio.h>
#include <stdlib.h>
#include <libspe2.h>
#include <pthread.h>
#define NUM_SPE 8
// Struktur zum Uebergeben mehrerer Daten an den Thread
typedef struct {
spe_context_ptr_t spe_context;
void *argp;
void *envp;
} thread_args_t;
extern spe_program_handle_t hello_spu;
char parameter_data[NUM_SPE][128] __attribute__((aligned (128)));
void *ppu_pthread_function(void *voidarg) {
thread_args_t *arg = (thread_args_t*) voidarg;
unsigned int entry = SPE_DEFAULT_ENTRY;
spe_context_run(arg->spe_context, &entry, 0, arg->argp, arg->envp, NULL);
pthread_exit(NULL);
}
int main(int argc, char **argv) {
printf("Hello World from PPU!\n");
int i;
for (i=0; i<NUM_SPE; ++i) {
sprintf(parameter_data[i], "Hello SPU %d", i);
}
spe_context_ptr_t context[NUM_SPE];
pthread_t pthread[NUM_SPE];
thread_args_t thread_args[NUM_SPE];
for (i=0; i<NUM_SPE; ++i) {
context[i] = spe_context_create(0, NULL);
spe_program_load(context[i], &hello_spu);
thread_args[i].spe_context = context[i];
thread_args[i].argp = &(parameter_data[i]);
thread_args[i].envp = NULL;
pthread_create(&pthread[i], NULL, &ppu_pthread_function, &(thread_args[i]));
}
for (i=0; i<NUM_SPE; ++i) {
pthread_join(pthread[i], NULL);
spe_context_destroy(context[i]);
}
return 0;
}
|
Die SPEs veranlassen das Einlesen von 128 Bytes ab der übergebenen Adresse, warten auf die Beendigung des Einlesevorgangs und setzen ihre Verarbeitung mit dem Ausgeben der gelesenen Strings fort.
spu/hello_spu.c
#include <stdio.h>
#include <spu_mfcio.h>
typedef unsigned long long ull;
unsigned char parameter_area[128] __attribute__ ((aligned (128)));
int main(ull speid, ull argp, ull envp) {
// Parameter von mfc_get:
// - Adresse im Local Store (Ziel der Daten)
// - Adresse im Hauptspeicher (Quelle der Daten)
// - Anzahl zu uebertragende Bytes
// - Tag zwischen 0 und 31 zum Gruppieren mehrerer DMA Kommands in tag groups
// - Transfer class identifier (hier 0)
// - Replacement class identifier (hier 0)
mfc_get(parameter_area, (unsigned int)argp, 128, 31, 0, 0);
// angeben, dass wir auf Gruppe 31 warten
mfc_write_tag_mask(1<<31);
// warten auf Beendigung des Speichertransfers
mfc_read_tag_status_all();
printf("SPU says: %s\n", parameter_area);
return 0;
}
|
Das folgende etwas größere Beispiel demonstriert die verteilte Berechnung eines Skalarproduktes. Dabei wird die spu_madd(A, B, C) Methode eingesetzt, die in einem Taktzyklus komponentenweise A*B + C berechnet, wobei A, B und C jeweils vier floats entsprechen. Desweiteren werden Mailboxen eingesetzt, mit denen einzelne Integers verschickt werden können.
dotproduct_ppu.c
#include <stdio.h>
#include <libspe2.h>
#include <pthread.h>
#define NUM_SPE 8
typedef struct {
float sum;
char dummy[128 - sizeof(float)];
} RESULT_STRUCT;
typedef struct {
float *X;
float *Y;
RESULT_STRUCT *RESULT;
char dummy[128 - 2 * sizeof(float*) - sizeof(RESULT_STRUCT*)]; // fill rest of 128 bytes
} VECTOR_STARTS;
typedef struct {
spe_context_ptr_t spe_context;
void *argp;
void *envp;
} thread_args_t;
spe_program_handle_t dotproduct_spu;
void *ppu_pthread_function(void *voidarg) {
thread_args_t *arg = (thread_args_t*) voidarg;
unsigned int entry = SPE_DEFAULT_ENTRY;
spe_context_run(arg->spe_context, &entry, 0, arg->argp, arg->envp, NULL);
pthread_exit(NULL);
}
int main(int argc, char **argv) {
// length needs to be a multiple of 32*8 as each block contains
// 32 floats (=128 bits) and all 8 SPEs need to receive the same amount
// of work
unsigned int length = 32*8*16; // -> each SPE needs to process 16 fragments
float X[32*8*16] __attribute__ ((aligned (128)));
float Y[32*8*16] __attribute__ ((aligned (128)));
RESULT_STRUCT results[NUM_SPE] __attribute__ ((aligned(128)));
VECTOR_STARTS vector_starts[NUM_SPE] __attribute__ ((aligned(128)));
unsigned int i;
// fill vectors with data
for (i=0; i<length; ++i) {
X[i] = 1.0f;
Y[i] = 2.0f;
}
// fill data structures that tell SPEs where to find the vectors
// and where to send the results
for (i=0; i<NUM_SPE; ++i) {
vector_starts[i].X=X;
vector_starts[i].Y=Y;
vector_starts[i].RESULT = &results[i];
}
// start threads in SPEs
spe_context_ptr_t context[NUM_SPE];
pthread_t pthread[NUM_SPE];
thread_args_t thread_args[NUM_SPE];
for (i=0; i<NUM_SPE; ++i) {
context[i] = spe_context_create(0, NULL);
spe_program_load(context[i], &dotproduct_spu);
thread_args[i].spe_context = context[i];
thread_args[i].argp = &(vector_starts[i]);
thread_args[i].envp = NULL;
pthread_create(&pthread[i], NULL, &ppu_pthread_function, &(thread_args[i]));
spe_in_mbox_write(context[i], &i, 1, SPE_MBOX_ANY_NONBLOCKING);
spe_in_mbox_write(context[i], &length, 1, SPE_MBOX_ANY_NONBLOCKING);
}
for (i=0; i<NUM_SPE; ++i) {
pthread_join(pthread[i], NULL);
spe_context_destroy(context[i]);
}
// if all threads have terminated, they have also written the results
// add the partial results
float sum = 0;
for (i=0; i<NUM_SPE; ++i) {
sum += results[i].sum;
}
printf("Sum %f\n", sum);
return 0;
}
|
Makefile
DIRS := spu
PROGRAM_ppu = dotproduct_ppu
IMPORTS = ./spu/dotproduct_spu.a -lspe2 -lpthread
include $(CELL_TOP)/buildutils/make.footer
|
spu/dotproduct_spu.c
#include <stdio.h>
#include <spu_mfcio.h>
#define NUM_SPE 8
typedef struct {
float sum;
char dummy[128 - sizeof(float)];
} RESULT_STRUCT;
typedef struct {
float *X;
float *Y;
RESULT_STRUCT *RESULT;
char dummy[128 - 2 * sizeof(float*) - sizeof(RESULT_STRUCT*)]; // fill rest of 128 bytes
} VECTOR_STARTS;
float dot_product(float *a, float *b, int N) {
float tmp[4] = {0,0,0,0};
// two doubles fit into one register -> two multiplications can be done in parallel
vector float *va = (vector float *)a;
vector float *vb = (vector float *)b;
vector float *vtmp = (vector float *)tmp;
int i;
for (i=0; i<N/4; ++i) {
// tmp = a * b + tmp
*vtmp = spu_madd(va[i], vb[i], *vtmp);
}
float sum = tmp[0] + tmp[1] + tmp[2] + tmp[3];
return sum;
}
int main(unsigned long long speid, unsigned long long argp, unsigned long long envp) {
printf("Thread started\n");
// pointers where the vectors start for which we calculate the dot product
VECTOR_STARTS vector_starts __attribute__((aligned(128)));
// buffer for vector transfer
float X[32] __attribute__((aligned(128))); // 32 floats fit into 128 bytes
float Y[32] __attribute__((aligned(128)));
RESULT_STRUCT result __attribute__((aligned(128)));
// receive spu_id, unfortunately only integers can be passed by this method
unsigned int spu_id = spu_read_in_mbox();
// receive length of vector in ppd, measured in floats
unsigned int length = spu_read_in_mbox();
// how many fragments do we need to process?
unsigned int nr_fragments = length / 32 / NUM_SPE;
printf("SPU %i has to calculate %i fragments of %i\n", spu_id, nr_fragments, length/32);
int tag = 1, tag_mask = 1<<tag;
// get vestor_starts
mfc_get(&vector_starts, (unsigned int)argp, 128, tag, 0, 0);
mfc_write_tag_mask(tag_mask);
mfc_read_tag_status_all();
unsigned int offset = spu_id * 128;
unsigned int fragment;
result.sum = 0;
for ( fragment=0; fragment < nr_fragments; ++fragment ) {
// transfer one fragment from vector X and Y
// (128 bytes = 32 floats)
mfc_get(X, (unsigned long int) vector_starts.X + offset, 128, tag, 0, 0);
mfc_get(Y, (unsigned long int) vector_starts.Y + offset, 128, tag, 0, 0);
mfc_write_tag_mask(tag_mask);
mfc_read_tag_status_all();
// calculate dot product of this fragment
result.sum += dot_product(X, Y, 32);
// calculate offset for next iteration
offset += NUM_SPE * 128; // skip 128 bytes for each SPU
}
// finally send result back
mfc_put(&result, (unsigned long int)vector_starts.RESULT, 128, tag, 0, 0);
mfc_read_tag_status_all();
printf("End of SPU %i thread, calculated sum %f\n", spu_id, result.sum);
return 0;
}
|
spu/Makefile
PROGRAM_spu := dotproduct_spu
LIBRARY_embed := dotproduct_spu.a
include $(CELL_TOP)/buildutils/make.footer
|
Die Beispiele basieren auf www.lemma.ufpr.br/wiki/index.php/Cell_BE_Tutorial und www.hlrs.de/fileadmin/_assets/organization/sos/par/people/jenz/uebung5.pdf
Die angegebenen Beispiele vermitteln nur einen ersten Eindruck über die Programmierung mit der Cell B.E. Architektur. Für weitere Details sei an dieser Stelle nocheinmal auf das original Programming Tutorial hingewiesen.