============================================================== Vorbereitung: Verwendung von Hardware-optimierten Micro-Kernel [TOC:2] ============================================================== Um die für SIMD (Single instruction multiple data) Operationen optimierten Assembler Micro-Kernel benutzen zu können, müssen noch ein paar Vorbereitungen getroffen werden: - Diese Micro-Kernel sind für jeweils feste Werte von `MR` und `NR` implementiert. Wir müssen sicher stellen, dass bei abweichenden Werten die Referenz-Implementierung verwendet wird. - Beim Index Type muss man sich bei der Assembler Implementierung auf einen konkreten Datentyp festlegen. Dies wird hier eine _signed integer_ mit 64 Bit (`std::int64_t` aus `cstdint`) sein - Die optimierten Micro-Kernel setzen verschiedene Alignments voraus. Bei einem SSE Micro-Kernel müssen die Paneele bei einer durch 16 teilbaren Adresse beginnen, bei einem AVX Micro-Kernel muss die Adresse durch 32 teilbar sein. SFINAE Substitution failure is not an error =========================================== Die Blockgrößen sind durch den `hpc::ulmblas::BlockSize` Trait bereits zur Compile-Zeit bekannt. Wir werden zeigen, wie man auch schon zur Compile-Zeit festlegen kann, ob beim Micro-Kernel die Referenz-Implementierung oder eine optimierte Variante verwendet wird. Wir brechen das eigentliche Problem zunächst auf ein Reihe einfacher Beispiele herunter: - Durch einen Trait soll festgelegt werden können, ob ein Datentyp eine gewisse Eigenschaft hat oder nicht. Um was für eine konkrte Eigenschaft es sich handelt spielt keine Rolle. Entscheidend ist, dass wir eine Schwarz-Weiss Welt festlegen können, bei der einzelne Datentypen diese Eigenschaft haben oder nicht. Beispielsweise durch :import: session12/traits/foo.cc ---- SHELL (path=session12/traits) ------------------------------------------- g++ -Wall -o foo foo.cc ./foo ------------------------------------------------------------------------------ Was man hier vielleicht nicht sofort sieht ist, dass bereits zur Compile-Zeit bekannt sein muss, welchen Wert `IsFoo::value` besitzt. Denn diese Variable ist als `static const` deklariert. Man sieht dies auch sofort am erzeugten Assembler Code: ---- SHELL (path=session12/traits) ------------------------------------------- g++ -O1 -S -Wall foo.cc cat foo.s ------------------------------------------------------------------------------ - Man kann sich auch auf andere Art überzeugen, dass der Wert `IsFoo::value` schon vor dem Ausführen des Programmes bekannt ist. Wir betrachten voller Staunen zunächst folgendes Programm: :import: session12/traits/morefoo.cc Wir staunen noch mehr, dass dieses Übersetzt. Die Ausgabe ist nur dann möglich, wenn der Compiler abhängig vom Argument entscheiden kann, welche Funktion aufgerufen werden kann. Notwendig ist dafür natürlich, dass das Argument eine Konstante ist, die beim Übersetzen bereits bekannt ist: ---- SHELL (path=session12/traits) ------------------------------------------- g++ -Wall -o morefoo morefoo.cc ./morefoo ------------------------------------------------------------------------------ Aber wie ist dies möglich? Zunächst betrachten wir die Traits-Klasse `RestrictTo` Neu ist hier lediglich, dass Klassen auch bezüglich einer Konstantääen (hier vom Typ `bool`) parametrisiert werden können: ---- CODE (type=cpp) --------------------------------------------------------- template struct RestrictTo { }; template struct RestrictTo { typedef T Type; }; ------------------------------------------------------------------------------ Nur die bezüglich der Konstante `true` spezialisierte Klasse definiert einen typedef `Type`. Dieser ist zudem auf den Template-Parameter `T` gesetzt. Deshalb kann beispielsweise ---- CODE (type=cpp) --------------------------------------------------------- typename RestrictTo::value, void>::Type ------------------------------------------------------------------------------ nur dann mit `void` substituiert werden, wenn `T` in unserem Fall vom Typ `double` oder `int` ist. In den meisten Fehler ist es ein Fehler, wenn die Substitution fehlschlägt. Beispielsweise, wenn dies notwenig ist, um den Typ einer Variable festzulegen: ---- CODE (type=cpp) --------------------------------------------------------- template void dummy(const T &var) { typename RestrictTo::value, void>::Type foo; /* ... */ } ------------------------------------------------------------------------------ Bei der Definition der Funktion `print` ist dies anders: ---- CODE (type=cpp) --------------------------------------------------------- template typename RestrictTo::value, void>::Type print(const T &) { std::printf("Is foo\n"); } ------------------------------------------------------------------------------ Nur der Type des Rückgabewertes hängt von der Substitution ab. Kann diese nicht erfolgen, dann wird die Funktion nicht instanziiert, also quasi ignoriert. In diesem Fall gilt _Substitution failure is not an error_. Durch unsere Aufteilung in Schwarz-und-Weiss können wir so zwei Varianten von `print` bereitstellen, die Abhängig von der Typ-Eigenschaft verwendet werden. Seit C++11 ist dieser `RestrictTo` Trait durch `std::enable_if` verfügbar (mit `type` statt `Type`): :import: session12/traits/morefoo_cpp11.cc ---- SHELL (path=session12/traits) ------------------------------------------- g++ -Wall -std=c++11 -o morefoo_cpp11 morefoo_cpp11.cc ./morefoo_cpp11 ------------------------------------------------------------------------------ - Wie Hilft uns das beim eventuelle Verwenden eines Assembler Micro-Kernel? Nehmen wir an, die spezielle Signatur des Kernel lautet ---- CODE (type=cpp) --------------------------------------------------------- void ugemm(std::int64_t kc, double alpha, const double *A, const double *B, double beta, double *C, std::int64_t incRowC_, std::int64_t incColC_) { /* some inline assembler code here */ } ------------------------------------------------------------------------------ Weiter soll dieser Kernel nur dann verfügbar sein, wenn `BlockSize::MR==4` und `BlockSize::NR==8` gilt. Ein erster Gedanke verführt zu ---- CODE (type=cpp) --------------------------------------------------------- std::enable_if::MR==4 && BlockSize::NR==4, void>::type ugemm(std::int64_t kc, double alpha, const double *A, const double *B, double beta, double *C, std::int64_t incRowC_, std::int64_t incColC_) { /* some inline assembler code here */ } ------------------------------------------------------------------------------ In diesem Fall hängt die Funktion von keinem Template-Parameter ab (deshalb benötigt man auch kein `typename` beim `enable_if` Trait) und muss deshlab in jedem Fall instanziiert werden. Das SFINAE gilt also nicht. Ein nächster Gedanke mag zu folgendem Code führen: ---- CODE (type=cpp) --------------------------------------------------------- template std::enable_if::MR==4 && BlockSize::NR==4, void>::type ugemm(Index kc_, double alpha, const double *A, const double *B, double beta, double *C, Index incRowC_, Index incColC_) { std::int64_t kc = kc_; /* some inline assembler code here */ } ------------------------------------------------------------------------------ Die Funktion wurde künstlich parametrisiert bezüglich dem Index Typen. Dieser wird (in der Hoffnung, dass dies möglich ist) in ein `std::int64_t` konvertiert. Doch auch dies ist kein Fall von SFINAE, denn die Instanziierung des Rückgabewertes hängt nicht von diesem Template-Parameter ab. Sie schlägt entweder immer fehl oder gelingt stets. Wir können aber unsere Bedingung, dass `Index` in `std::int64_t` konvertierbar ist, in die Bedingung einbauen. Seit C++11 gibt es dafür den `std::is_convertible` Trait, den man sich zuvor selber bauen und an jede Plattform anpassen musste: `std::is_convertible::value` ist `true`, genau dann, wenn `S` in `T` konvertierbar ist. Dies führt zu ---- CODE (type=cpp) --------------------------------------------------------- template typename std::enable_if::value && BlockSize::MR==4 && BlockSize::NR==4, void>::type ugemm(Index kc_, double alpha, const double *A, const double *B, double beta, double *C, Index incRowC_, Index incColC_) { std::int64_t kc = kc_; /* some inline assembler code here */ } ------------------------------------------------------------------------------ Aufgabe ------- Obige Foo-Beispiele ausprobieren. Allokation mit speziellem Alignment =================================== In C11 wurde die Funktion ---- CODE (type=cpp) ----------------------------------------------------------- void *aligned_alloc(size_t alignment, size_t size); -------------------------------------------------------------------------------- eingeführt, die `size` Bytes allokiert, wobei der allokierte Speicherbereich bei einer durch `alignment` teilbaren Adresse beginnt. Leider ist diese Funktion voraussichtlich erst ab C++17 für C++ verfügbar. Für eine Allokation mit speziellem Alignment greifen wir deshalb zunächst auf die in `xmmintrin.h` dekalrierte und den (meisten) Intel Platformen verfügbaren Funktion `_mm_malloc` zurück: ---- CODE (type=cpp) ----------------------------------------------------------- void *_mm_malloc(size_t size, size_t align); -------------------------------------------------------------------------------- Um den Speicher freizugeben, muss dann ---- CODE (type=cpp) ----------------------------------------------------------- void _mm_free(void *mem); -------------------------------------------------------------------------------- verwendet werden. In `hpc/ulmblas/malloc.h` implementieren wir einen Wrapper, um dynamischen Speicher zu allokieren. Falls ein Macro `MEM_ALIGN` definiert wurde soll die Funktion `hpc::ulmblas::malloc` einen Block mit entsprechendem Alignment allokieren, der mit `hpc::ulmblas::free` wieder freigegeben werden muss: ---- CODE (type=cpp) ----------------------------------------------------------- #ifndef HPC_ULMBLAS_MALLOC_H #define HPC_ULMBLAS_MALLOC_H 1 #include #ifdef MEM_ALIGN # include #endif namespace hpc { namespace ulmblas { template T * malloc(std::size_t n) { #ifdef MEM_ALIGN return reinterpret_cast(_mm_malloc(n*sizeof(T), MEM_ALIGN)); # else return new T[n]; # endif } template void free(T *block) { #ifdef MEM_ALIGN _mm_free(reinterpret_cast(block)); # else delete [] block; # endif } } } // namespace ulmblas, hpc #endif // HPC_ULMBLAS_MALLOC_H -------------------------------------------------------------------------------- Aufgaben -------- - Den Header für `hpc::ulmblas::malloc` und `hpc::ulmblas::free` in das Projekt einpflegen. - Welches Alignment benötigt wird soll in `hpc/ulmblas/config` festgelegt werden. Fügt dazu die Zeilen ---- CODE (type=cc) ---------------------------------------------------------- #ifdef SSE #define MEM_ALIGN 16 #elif defined(AVX) || defined(FMA) #define MEM_ALIGN 32 #endif ------------------------------------------------------------------------------ ein. - Im Frame-Algorithmus soll nun `hpc::ulmblas::malloc` und `hpc::ulmblas::free` statt `new` und `delete` verwendet werden. Ausserdem werden die Assembler Kernel etwas mehr Speicher bei den Puffern benötigen: - Der Block von `A` muss die Größe `MC*KC+MR` besitzen. - Der Block von `B` muss die Größe `KC*NC+NR` besitzen. Auf die zusätzlichen `MR` bzw. `NR` Elemente wird in den Assembler-Kernel zwar zugegriffen, die Werte werden aber nicht verwendet. Das Packen muss also nicht verändert werden. Dass zusätzlicher Speicher beim Allokieren angefordert wird ist notwendig, um Page-Faults zu vermeiden. :navigate: up -> doc:index back -> doc:session12/page06 next -> doc:session12/page08