Intrinsics
Wstęp
Funkcje intrinsics w C++ są inaczej nazywane funkcjami wbudowanymi. Są to krótkie funkcje, podobne do funkcji inline, które udostępniają specyficzne dla danej architektury operacje nie dostępne z poziomu języka C++ np. operacje wektorowe. Nie powodują one kosztów związanych z wywołaniem funkcji bo na ich podstawie kompilator tworzy kod w miejscu ich wywołania.
Przeważnie dana funkcja intrinsics odpowiada jednej instrukcji asemblerowej, ale są wyjątki.
Funkcje intrinsics zastępują stosowane wcześniej wstawki asemblerowe, zapewniając większe bezpieczeństwo i czytelność kodu, a przede wszystkim przenośność.
Wprowadzenie do funkcji intrinsics na stronach Intela.
Wygodny spis funkcji intrinsics stronie Intela.
Typy danych
__m64
dana 64 bitowa__m128
wektor 4 liczb typu float__m128d
wektor dwóch liczb typu double__m128i
wektor zawierający liczby całkowite: 16 liczb jedno bajtowych lub 8 słów lub 4 podwójne słowa itd
Zmienne tych typów mogą zostać umieszczone w jednym z rejestrów xmm0-xmm15 lub znajdować się w pamięci.
Przykład
#include <x86intrin.h>
#include <cstdio>
using namespace std;
int main(){
__m128d x = {1., 2.}, // __m128d jest tablicą d double'ów
y = {5., 7.};
__m128d result = _mm_add_pd(x, y); // ładujemy i dodajemy wektorowo 2 double
double z[2] = {2};
_mm_storeu_pd(z, result); // zapisujemy wynik w pamięci
printf("[%f, %f]\n", z[0], z[1]);
return 0;
}
Gdy dane są uprzednio w tablicy musimy je najpierw załadować do zmennej wektorowej
#include "emmintrin.h"
#include "xmmintrin.h"
#include <cstdio>
using namespace std;
int main(){
const int N = 16;
float x[N] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16};
float y[N] = {5, 7, 9, 11, 1, 2, 3, 4};
float z[N];
for(int i=0; i<16; i+=4){
__m128 vx = _mm_loadu_ps(x),
vy = _mm_loadu_ps(y);
__m128 vz = _mm_add_ps(vx, vy);
_mm_storeu_pd(z, vz); // zapisujemy wynik w pamięci
}
for(int i=0; i<16; i++)
printf("%f, ", z[i]);
return 0;
}
Inną metodą (osobiście nie polecaną przeze mnie) jest konwersja wskaźników do __m128 *
. W tym jednak wypadku ważne aby wskaźniki wskazywały na adresy wyrównane do granicy 16 bajtowej, bo zapis *vx
jest zamieniany na rozkaz movaps
.
#include "emmintrin.h"
#include "xmmintrin.h"
#include <cstdio>
using namespace std;
int main(){
float x[] = {1, 2, 3, 4, 5, 6, 7, 8};
float y[] = {5, 7, 9, 11};
float z[4];
__m128 *vx = (__m128 *) x,
*vy = (__m128 *) y,
*vz = (__m128 *) z;
// *vx = (__m128 *) (x+1); // odkomentowanie tej linijki spowoduje zatrzymanie programu z 'segmentation fault'
*vz = _mm_add_ps(*vx, *vy);
printf("[%f, %f, %f, %f]\n", z[0], z[1], z[2], z[3]);
return 0;
}
Wybór architektury procesora
Domyślnie gcc tworzy kod binarny dla ogólnego procesora zgodnego z ABI64 czyli posiadającego rozszerzenia SSE i SSE2. Użycie funkcji intrinsics z późniejszych rozszerzeń spoowduje błąd kompilacji.
Pierszą metodą jest dodanie opcji włączającej konkretne rozszerzenie zestawu instrukcji przez jedna z opcji: -msse3 -mssse3 -msse4 -msse4a -msse4.1 -msse4.2 -mavx -mavx2 -mavx512f
Drugą opcja jest zdefiniowanie architektury procesora opcją -march=cpu-type
. Umożliwi to używanie instrukcji z wszystkich rozszerzeń dostępnych w danej architekturze (także w kodzie generowanym przez kompilator). Dodatkowo kod zostanie zooptymalizowany pod tą architekturę procesora (o ile nie użyto opcji -mtune=cpu-type
). Współczesne architektury wszystkie udostępniają roszerzenia SSE, SSE2, SSE3, SSSE3, SSE4.1, SSE4.2, AVX, AVX2
.
cpu-type | procesory |
---|---|
haswell | 4-ta generacja Intela (2013-2022) |
skylake | 6-ta generacja Intela (2015-2019) i późniejsze |
native | procesor w danym komputerze |
Więcej informacji znajduje się w dokumentacji GCC.
Zadanie 1.
Przepisać poniższy program aby do wyznaczania maksimum używał funkcji intrinsics _mm_max_epu32
#include <cstdlib>
#include <cstdio>
#include <ctime>
using namespace std;
int main(){
const int prog = 100000;
const int rozmiar = 100;
unsigned int tab[rozmiar];
//srand(time(0));
srand(2021); // dla powtarzalności testów
for(int i=0; i<rozmiar; ++i){
tab[i] = rand() % prog;
}
unsigned int max = 0;
for(int i=0; i<rozmiar; ++i){
if(max < tab[i])
max = tab[i];
}
printf("%d\n", max);
return 0;
}
Zadanie 2.
Przepisać zadanie 3 (skalowanie bitmapy) z zestawu SSE używając funkcji intrinsics.
#include <stdio.h>
void scaleSSE(float * wynik, float * data, int n){
/// TODO !!!!
for(int i=0; i<n; ++i)
wynik[i] = data[i];
}
int main(void){
const int N=400,HL=1078;
float data[N][N],
wynik[N][N];
unsigned char header[HL];
int i,j;
FILE *strm;
strm=fopen("circle3.bmp","rb");
fread(header, 1, HL, strm); // wczytuje header
for(i=0;i<N;i++) // wczytuje dane bitmapy
for(j=0;j<N;j++) // konwertując piksele na float
data[i][j]=(float)fgetc(strm);
fclose(strm);
// właściwe skalowanie bitmapy wiersz po wierszu
for(i=0;i<N/2;i++)
scaleSSE(wynik[i],data[2*i],N);
// Modyfikujemy rozmiar bitmapy w nagłówku
header[4]=0; header[3]=160; header[2]=118; // rozmiar pliku
header[18]=200; header[19]=0; // szerokość bitmapy
header[22]=200; header[23]=0; // wysokość bitmapy
// Zapisuje wynik do pliku
strm=fopen("wynik3.bmp","wb");
fwrite(header, 1, HL, strm);
for(i=0;i<N/2;i++)
for(j=0;j<N/2;j++) // konwertuje wyniki na skalę szarości
fputc((unsigned char)wynik[i][j],strm);
fclose(strm);
}
Wskazówka:
- Przy kompilacji należy dodać opcje definiującą odpowiednią architekturę docelową procesora w zależności od użytych funkcji.
Zadanie 3.
Zaimplementować filtry uwzględniające otoczenie tak jak opisano tutaj.
Wskazówki:
- Dla każdego piksela liczymy średnią ważoną w jego otoczeniu 3x3 tzn. liczymy sumę pikseli z otoczenia przemnożonych przez odpowiednią wagę z macierzy weight, a następnie dzielimy przez sumę wag (chyba że suma wag jest zerem, to wtedy nie dzielimy).
- Wynik tych obliczeń może być poza zakresem [0,255], dlatego trzeba go odpowiednio “przyciąć”.
- Obliczenia ją osobno przeprowadzane dla każdej składowej koloru.
- Należy użyć funkcji wektorowych operujących na floatach.
Przykład użycia
#include <iostream>
#include <cmath>
using namespace std;
// width powinien dzielic sie przez 4
const int width = 300, height = 168;
void filtr(int imageWidth, int imageHeight, float image[height][width],
float weight[3][3], float result[height][width]){
// TODO !!!
}
int main(void)
{
const int headerLength = 122; // 64; //sizeof(BMPHEAD);
char header[headerLength];
float data[3][height][width]; // tablica dla każdej składowej koloru
float result[3][height][width];
float weight[3][3]= { {0, -2, 0}, { -2, 11, -2}, {0, -2, 0} };
int i,j,k;
FILE *file;
file=fopen("pigeon.bmp","rb");
if(!file) { std::cerr << " Error opening file !"; exit(1); }
fread(header, headerLength, 1, file);
for(i=0;i<height;i++)
for(j=0;j<width;j++)
for(k=0; k<3; ++k)
data[k][i][j]=fgetc(file);
fclose(file);
for(i=0;i<3;i++) // filtrujemy dla każdej składowej osobno
filtr(width, height, data[i], weight, result[i]);
file=fopen("result.bmp","wb");
fwrite(header, headerLength, 1, file);
for(i=0;i<height;i++)
for(j=0;j<width;j++)
for(k=0; k<3; ++k)
fputc((unsigned char)result[k][i][j],file);
fclose(file);
}
Obrazek wejściowy pigeon.bmp