„Intel Xeon Phi” változatai közötti eltérés

Innen: KIFÜ Wiki
(Változók láthatósága, megosztása)
(automatikus kódátvitel)
 
(16 közbenső módosítás, amit 2 másik szerkesztő végzett, nincs mutatva)
1. sor: 1. sor:
 
Az oldalon található információk segítséget nyújtanak a HPC felhasználóinknak, hogy alkalmazásaikat miként portolhatják valamint optimalizálhatják a hatékonyabb futást szem előtt tartva Intel Xeon Phi koprocesszor kártyákon.
 
Az oldalon található információk segítséget nyújtanak a HPC felhasználóinknak, hogy alkalmazásaikat miként portolhatják valamint optimalizálhatják a hatékonyabb futást szem előtt tartva Intel Xeon Phi koprocesszor kártyákon.
 +
 +
{{INFO|Egy 10 alkalmas, angol nyelvű Xeon Phi programozás tutoriál videóanyaggal és példaprogrammal elérhető a [[http://colfaxresearch.com/how-16-04/ Colfax oldalán]]}}
 +
  
 
==Hardver bemutatása==
 
==Hardver bemutatása==
57. sor: 60. sor:
 
====közvetlen natív használat====
 
====közvetlen natív használat====
 
A natív mód arra szolgál, hogy a MIC kártyára  lefordított alkalmazásunkat felmásoljuk a kártyára, majd belépünk SSH-n a kártyára és azon futtatjuk a bináris kódunkat. <br />
 
A natív mód arra szolgál, hogy a MIC kártyára  lefordított alkalmazásunkat felmásoljuk a kártyára, majd belépünk SSH-n a kártyára és azon futtatjuk a bináris kódunkat. <br />
Ez a legegyszerűbb használati mód, ugyanis nincs más dolgunk, csak a '''-mmic''' kapcsolót használni a fordításnál és a fordító egy máris a MIC architektúrára kész bináris készít nekünk. <br/>
+
Biztonsági okokból a közvetlen natív használat az NIIF HPC infrastruktúrán nem elérhető. <br/>
Workflow a következő lenne:
 
# Alkalmazás megírása. például [[Média:hello.cc|''hello.cc'']]
 
# Alkalmazás fordítása <br/> <pre>icpc -o hello-MIC -mmic hello.cc</pre>
 
# Másolás a MIC kártyára <br/> <pre>scp hello-MIC mic0:</pre>
 
# belépés a kártyára <br/> <pre>ssh mic0</pre>
 
# Alkalmazás futtatása <br/> <pre>./hello-MIC</pre>
 
  
 
===b) támogatott, de nem preferált használat===
 
===b) támogatott, de nem preferált használat===
73. sor: 70. sor:
 
# Alkalmazás megírása. például [[Média:hello.cc|''hello.cc'']]
 
# Alkalmazás megírása. például [[Média:hello.cc|''hello.cc'']]
 
# Alkalmazás fordítása <br/> <pre>icpc -o hello-MIC -mmic hello.cc</pre>
 
# Alkalmazás fordítása <br/> <pre>icpc -o hello-MIC -mmic hello.cc</pre>
# A tool futásához szükséges library-k megadása <br> <pre>export SINK_LD_LIBRARY_PATH=/opt/intel/composerxe/compiler/lib/mic</pre>
+
# A tool futásához szükséges library-k megadása <br> <pre>export SINK_LD_LIBRARY_PATH=/opt/intel/composerxe-<verzió>/compiler/lib/mic</pre>
 
# Alkalmazás futtatása <br/> <pre>micnativeloadex hello-MIC</pre>
 
# Alkalmazás futtatása <br/> <pre>micnativeloadex hello-MIC</pre>
  
 
:{| class="wikitable"
 
:{| class="wikitable"
 
|-
 
|-
| BUDAPEST2[cn10] phi (0)$ export SINK_LD_LIBRARY_PATH=/opt/intel/composerxe/compiler/lib/mic <br/>BUDAPEST2[cn10] phi (0)$ micnativeloadex hello-MIC <br/>Hello world! I have 244 logical cores.<br/>BUDAPEST2[cn10] phi (0)$
+
| BUDAPEST2[cn10] phi (0)$ export SINK_LD_LIBRARY_PATH=/opt/intel/composerxe-2011.4.191/compiler/lib/mic <br/>BUDAPEST2[cn10] phi (0)$ micnativeloadex hello-MIC <br/>Hello world! I have 244 logical cores.<br/>BUDAPEST2[cn10] phi (0)$
 
|}
 
|}
 +
Intel MKL használat esetén a SINK_LD_LIBRARY_PATH természetesen kiegészítendő az MKL könyvtárak elérésével.
 +
<pre>intelhome=/opt/intel/compilers_and_libraries_2016.1.150/linux
 +
export SINK_LD_LIBRARY_PATH=$intelhome/compiler/lib/mic:$intelhome/mkl/lib/mic</pre>
  
 
===c) támogatott módok===
 
===c) támogatott módok===
124. sor: 124. sor:
 
</pre>
 
</pre>
  
Ekkor minden memória területet 2x kell másolni, egyszer fel a kártyára, majd másodszor vissza a hoszt gépre, pedig elegendő lenne csak az eredmény vektort visszamásolni. Ezen optimalizálással egy későbbi fejezetben foglalkozunk. <p />
+
Ekkor minden memória területet 2x kell másolni, egyszer fel a kártyára, majd másodszor vissza a hoszt gépre, pedig elegendő lenne csak az eredmény vektort visszamásolni. Ezen optimalizálással egy későbbi fejezetben foglalkozunk. <p>
  
Futás során kaphatunk részletes riportot is, hogy pontosan milyen adatmozgatások történnek az alkalmazás futása során. Ehhez nincs szükség másra csak az '''OFFLOAD_REPORT''' környezeti változó megfelelő beállítása. A beállított értékek 1, 2 vagy 3 lehetnek, attól függően mennyire részletes riportot szeretnénk kapni. <br />
+
Futás során kaphatunk részletes riportot is, hogy pontosan milyen adatmozgatások történnek az alkalmazás futása során. Ehhez nincs szükség másra csak az '''OFFLOAD_REPORT''' környezeti változó megfelelő beállítása. A beállított értékek Automatic Offload esetén 1, 2, míg Compiler Assisted Offload esetén 1, 2 és 3 lehetnek, attól függően mennyire részletes riportot szeretnénk kapni. <br />
 
beállítás:
 
beállítás:
 
<pre>
 
<pre>
179. sor: 179. sor:
 
Abban az esetben, ha 1 számítási node nem elég, MPI segítségével össze tudunk kötni több nodet, heterogén rendszert alkotva, és azokon alkalmazható a fenti Offload + OpenMP modell. <br />
 
Abban az esetben, ha 1 számítási node nem elég, MPI segítségével össze tudunk kötni több nodet, heterogén rendszert alkotva, és azokon alkalmazható a fenti Offload + OpenMP modell. <br />
 
[[Fájl:Mpi+offload+openmp.png|MPI elosztás valamint Offload modell és OpenMP]] <br />
 
[[Fájl:Mpi+offload+openmp.png|MPI elosztás valamint Offload modell és OpenMP]] <br />
 +
 +
 +
==Phi használat slurm-mel==
 +
Slurm esetén a mic opciót kell megadni a gres-knél. Amennyiben egynél több kártyára van szükség, akkor azt a ''mic:2''-vel lehetséges megadni. Ekkor két kártyát foglal le az ütemező a futtatandó feladatnak. <br />
 +
Egy lehetséges feladat, amit srun-nal lehet a slurmben beküldeni. debrecen3 esetén a ''prod-phi'' particiót kell használni
 +
 +
<pre>
 +
$ cat slurm_job_openmp_phi
 +
 +
#!/bin/bash
 +
#SBATCH -A <PROJECT NEVE>
 +
#SBATCH --job-name=<JOB NEVE>
 +
#SBATCH --gres mic:2
 +
#SBATCH --time=1:00:00
 +
#SBATCH --partition=prod
 +
#SBATCH -o slurm-%A.out
 +
export OMP_NUM_THREADS=2
 +
./runme_openmp_2phi
 +
</pre>
 +
Ekkor a feladat beküldése a következő paranccsal lehetséges.
 +
<pre>
 +
srun ./slurm_job_openmp_phi
 +
</pre>
  
 
==Alkalmazások optimalizálása a kártyára==
 
==Alkalmazások optimalizálása a kártyára==
379. sor: 402. sor:
  
 
====Párhuzamos szálak ütemezése====
 
====Párhuzamos szálak ütemezése====
 +
OpenMp a szálak ütemezésére három féle módod kínál fel.
 +
Módok:
 +
* '''static''' (alapértelmezett): egyenletesen arányban egymás után elosztja a processzeket az indított szálakon, ezért a futási idő tetszőlegesen elhúzódhat, ha egyes processzek lassabban futnak le.
 +
* '''dynamic''': minden esetben az első szabad szálra kerül a következő processz ütemezése, folyamatos kihasználtságot elérve, de "költséges" a sok-sok ütemezés
 +
* '''guided''': Az előző két mód ötvözése. Nagyobb procesz számot ütemez az éppen szabad szálakra, melyek számát folyamatosan csökkenti a futás előre haladtával, hogy körülbelül egyszerre fejeződjön be minden szál futása, ezzel csökkentve a dynamic esetén magad ütemezési időráfordítást.
 +
 +
Mindhárom esetben megadható az egyszerre ütemezett processzek száma, amit a ''chunk'' értéke határoz meg. Ha megvan adva a chunk értéke, akkor az minden esetben annak megfelelő processzámot ütemez a szálakra.
 +
Használata:
 
<pre>
 
<pre>
 +
#pragma omp parallel [schedule (<mode>,<chunk>) {...}
 
</pre>
 
</pre>
  
 
====Szálak szinkronizációja====
 
====Szálak szinkronizációja====
 +
Szála szinkronizálását többféle módon lehet megvalósítani, de ezen megoldások sajno jelentősen lelassítják a program futását.
 +
* mutexxel: nagyon lassú, állandóan várnak egymásra a párhuzamos szálak
 +
<pre>
 +
#pragma omp parallel for
 +
for (int i = 0; i < n; i++) {
 +
#pragma omp critical
 +
  { // kritikus szekció, csak egy szál hajthatja végre
 +
    total = total + i;
 +
  }
 +
}
 +
</pre>
 +
 +
* atomi művelet definiálása: sajnos még ez is lassú, de itt már nem olyan "erős" a szinkronizáció, ezért rendkívül sok limitációja van az itt használható atomi műveleteknek.
 
<pre>
 
<pre>
  * with mutexes (it is very slow, waiting always) #pragma omp parallel for   for (int i = 0; i < n; i++) { #pragma omp critical     { // Only one thread at a time can execute this section       total = total + i;     }   }   * or with atomic operation (it also [very] slow, lot of limitations) #pragma omp parallel for for (int i = 0; i < n; i++)   { // Lightweight synchronization #pragma omp atomic     total += i;   }
+
#pragma omp parallel for
 +
for (int i = 0; i < n; i++) {
 +
#pragma omp atomic
 +
   total += i;
 +
}
 
</pre>
 
</pre>
  
 
=====Párhuzamos redukció=====
 
=====Párhuzamos redukció=====
 +
Az itt bemutatott eljárás gyorsabb mint a fenti szinkronizációs megoldás, de sajnos nem alkalmazható vektorokra! A redukciót a ''reduction(operation: scalar)'' formában kell megadni.
 +
<pre>
 +
int sum = 0;
 +
#pragma omp parallel for reduction(+: sum)
 +
for (int i = 0; i < n; i++) {
 +
  sum = sum + i;
 +
}
 +
</pre>
 +
 +
 +
Tulajdonképpen a fenti problémakörre a következő minta ad megoldást. Ekkor egy külön változóban akkumuláltatjuk a szál általi összeget és ezen értékeke összegyűjtését végezzük csak atomi műveletként.
 
<pre>
 
<pre>
    (it is fast)  BUT not for vector int total = 0; #pragma omp parallel for reduction(+: total)              //usage: reduction(operation: scalar) for (int i = 0; i < n; i++) {   total = total + i; }
+
int sum = 0;
 +
#pragma omp parallel
 +
{
 +
  int sum_thr = 0;
 +
#pragma omp for
 +
  for (int i=0; i<n; i++)
 +
    sum_thr += i;
 +
 
 +
#pragma omp atomic
 +
  sum += sum_thr;
 +
}
 
</pre>
 
</pre>
  
 
====Ciklusok párhuzamosítása====
 
====Ciklusok párhuzamosítása====
 +
Ciklus párhuzamosítására a következő mintát szokás használni.
 
<pre>
 
<pre>
#pragma omp parallel {   // Code placed here will be executed by all threads.   ... #pragma omp for [schedule (<mode>,<chunk>)   for (int i = 0; i < n; i++) {     // ... iterations will be distributed across available threads...   }   // ... code placed here will be executed by all threads }     modes: static, dynamic, guided     chunk: integer value
+
#pragma omp parallel
 +
{
 +
  // Az itt definiált forrást minden szál esetén végre fog hajtani
 +
  ...
 +
#pragma omp for [schedule (<mode>,<chunk>)
 +
  for (int i = 0; i < n; i++) {
 +
    // Ez a kódrész lesz párhuzamosítva
 +
    ...
 +
  }
 +
  // ... és ez a köd is minden szál esetén végrehajtódik
 +
  ...
 +
}
 
</pre>
 
</pre>
  
438. sor: 520. sor:
 
}
 
}
 
</pre>
 
</pre>
 +
[[Kategória: HPC]]

A lap jelenlegi, 2017. május 9., 08:15-kori változata

Az oldalon található információk segítséget nyújtanak a HPC felhasználóinknak, hogy alkalmazásaikat miként portolhatják valamint optimalizálhatják a hatékonyabb futást szem előtt tartva Intel Xeon Phi koprocesszor kártyákon.



Hardver bemutatása

Intel® Xeon Phi™ Coprocessor 7120P

CPU threads/Core max threads CPU Freq Memória L1 cache L2 cache Interfész Ptot Számítási kapacitás
61 4 Hardver 244 1238 GHz 16GB GDDR5 32KB/core 512KB/core PCIe 300W 1,2 TFlop/s dupla pontosság
OpenMP-nél csak 240 ~100 cycles

~174 GB/s

~3 cycles ~10 cycles ~7 GB/s

~1 microsecond

2,4 TFlop/s egyszeres pontosság

Az Intel® 1. generációs Xeon Phi™ koprocesszorában található 61 processzor ugyanazt a 16GB memóriát éri el, de semmilyen módon se fér hozzá közvetlenül a hoszt gép fizikai memóriájához.
Budapest2 és Debrecen3-Phi gépeinkben számítási nodonként 2-2 Phi™ koprocesszor található.
A kártyákon speciális célú Linux kernel fut melyre a belépés limitáltan csak a rendszergazdák számára lehetséges SSH-n keresztül a hoszt gépről. Természetesen a kártyára belépve olyan érzésünk lehet, mintha egy sok processzoros gépen dolgoznánk, ami rendelkezik rengeteg memóriával. Ez így is van, de a hatékonyabb kihasználás érdekében az SSH-n keresztüli elérés nem lehetséges, csak a Slurm segítségével valamint a programozási nyelvek által támogatott csatornákon keresztül. Ennek részletezése lentebb megtalálható.

Használati módok

A koprocesszor használatai módjait két csoportba lehet sorolni. A natív és az offload használatot.
Ezen használati módokról a lenti fejezetekben több részletet is megtudhat. Az itt megfogalmazott "támogatás" csak a mi infrastruktúránkra érvényes!
A bemutatott példák c++ nyelven vannak, de természetesen c és fortran esetén is megtalálható az ekvivalens módszer.

a) nem támogatott mód

közvetlen natív használat

A natív mód arra szolgál, hogy a MIC kártyára lefordított alkalmazásunkat felmásoljuk a kártyára, majd belépünk SSH-n a kártyára és azon futtatjuk a bináris kódunkat.
Biztonsági okokból a közvetlen natív használat az NIIF HPC infrastruktúrán nem elérhető.

b) támogatott, de nem preferált használat

automatikus kódátvitel

Ebben az esetben nem kell átmásolnunk a bináris alkalmazásunkat a kártyára sőt még csak be se kell lépnünk, mert ezt a két lépést egy beépített tool végzi el.
A tool eléréséhez szükséges parancs neve: micnativeloadex

Ebben az esetben a workflow a következőképpen módosul:

  1. Alkalmazás megírása. például hello.cc
  2. Alkalmazás fordítása
    icpc -o hello-MIC -mmic hello.cc
  3. A tool futásához szükséges library-k megadása
    export SINK_LD_LIBRARY_PATH=/opt/intel/composerxe-<verzió>/compiler/lib/mic
  4. Alkalmazás futtatása
    micnativeloadex hello-MIC
BUDAPEST2[cn10] phi (0)$ export SINK_LD_LIBRARY_PATH=/opt/intel/composerxe-2011.4.191/compiler/lib/mic
BUDAPEST2[cn10] phi (0)$ micnativeloadex hello-MIC
Hello world! I have 244 logical cores.
BUDAPEST2[cn10] phi (0)$

Intel MKL használat esetén a SINK_LD_LIBRARY_PATH természetesen kiegészítendő az MKL könyvtárak elérésével.

intelhome=/opt/intel/compilers_and_libraries_2016.1.150/linux
export SINK_LD_LIBRARY_PATH=$intelhome/compiler/lib/mic:$intelhome/mkl/lib/mic

c) támogatott módok

Offload

Az Offload modell esetén szükséges a forráskód módosítása, mégpedig úgy, hogy azon kódrészletet, amit a koprocesszoron szeretnénk futtatni, ki kell egészíteni egy fordítási direktívával. Az alkalmazás fordításakor elegendő csak a CPU-ra való binárist készíteni mivel a modell végzi a megfelelő forrás részlet mozgatását és futtatását.
Offload modell
Példánkban jól látható, hogy a második kiíratás már a koprocesszoron fut.

#include <stdio.h>
int main(int argc, char * argv[]) {
  printf("Hello world from host!\n");
#pragma offload target(mic)
  {
    printf("Hello world from coprocessor!\n"); fflush(stdout);
  }
  printf("Bye\n");
}

Ebben a példában az operációs rendszer választja ki, hogy melyik Phi kártyán fusson le a kódrészlet. Amennyiben mi szeretnénk explicite megmondani, hogy melyik kártyán fusson, akkor a fordítási direktívában a mic kulcsszó után kell tenni egy ":"-t és a kártya sorszámát [mi esetünkben 0 vagy 1]. Ekkor így nézne ki a direktíva: #pragma offload target(mic:1)

Amennyiben az offload szekcióban függvény hívás is szerepel, akkor szükségeltetik az függvény futásához szükséges változók adatterületének átmásolása a Phi kártyára, majd a lefutás után visszamásolni a hoszt gépre. Ezen másolást az offload_attribute push és pop paraméterei segítségével határozhatjuk meg.
A következő mátrix-vektor szorzás példánál az A mátrix globális változó, míg a b és c vektorok a függvény paraméterei.

//A változók felmásolásra kerülnek a Xeon Phi-re
#pragma offload_attribute(push, target(mic))
  double* A;
  void MatrixVector(double *b, double *c, double m, double n) {
    ...
    c[] = A[..]*b[]
  }
// felmásolt objektumok visszamásolása
#pragma offload_attribute(pop)

int main() {
  ...
#pragma offload target(mic) in(A[0:n*m])
// így is meg lehetne adni: #pragma offload target(mic:0) in(A : length(n*m))	        
  {
    MatrixVector(b, c, m, n);
  }
  ...
}
Ekkor minden memória területet 2x kell másolni, egyszer fel a kártyára, majd másodszor vissza a hoszt gépre, pedig elegendő lenne csak az eredmény vektort visszamásolni. Ezen optimalizálással egy későbbi fejezetben foglalkozunk.

Futás során kaphatunk részletes riportot is, hogy pontosan milyen adatmozgatások történnek az alkalmazás futása során. Ehhez nincs szükség másra csak az OFFLOAD_REPORT környezeti változó megfelelő beállítása. A beállított értékek Automatic Offload esetén 1, 2, míg Compiler Assisted Offload esetén 1, 2 és 3 lehetnek, attól függően mennyire részletes riportot szeretnénk kapni.
beállítás:

export OFFLOAD_REPORT=3

példa kimenet a hello alkalmazásra:

Hello world from host!
[Offload] [MIC 0] [File]                    hello_offload.cc
[Offload] [MIC 0] [Line]                    5
[Offload] [MIC 0] [Tag]                     Tag 0
Hello world from coprocessor!
[Offload] [HOST]  [Tag 0] [CPU Time]        0.554653(seconds)
[Offload] [MIC 0] [Tag 0] [CPU->MIC Data]   0 (bytes)
[Offload] [MIC 0] [Tag 0] [MIC Time]        0.000140(seconds)
[Offload] [MIC 0] [Tag 0] [MIC->CPU Data]   0 (bytes)
Bye

Offload + OpenMP

Amennyiben nem csak 1 processzort szeretnénk használni a Phi kártyánkon és az alkalmazásunkban használt számítás megengedi a párhuzamosítást, akkor az OpenMP segítségével párhuzamosíthatjuk a megfelelő számítást.
Offload modell és OpenMP
Példánk maradjon ugyanúgy a mátrix-vektor szorzás. Amennyiben a teljes kártya adta kapacitást ki szeretnénk használni, akkor az OMP threadek számát 240-re kell beállítani. Azért nem 244-re, mivel 1 CPU-t dedikáltan csak a kommunikációra használ a rendszer és 1 CPU 4 hardver threaddel rendelkezik. Ekkor a példánk így változik:

#include <omp.h>

#pragma offload_attribute(push, target(mic))
void multiply(int n, int m, double* A, double* b, double* c){
  const int nCPUs = omp_get_max_threads();   //240
  omp_set_num_threads(nCPUs);
#pragma omp parallel for schedule(dynamic, 1)
  for ( int i = 0 ; i < m ; i++)
     for ( int j = 0 ; j < n ; j++)
       c[i] += A[i*n+j] * b[j];
}
#pragma offload_attribute(pop)

int main(){
  double * A = (double*) malloc(sizeof(double)*n*m);
  ...
#pragma offload target(mic) in(A:length(n*m))
     multiply(n, m, A, b, c);

Az alkalmazás fordításánál a fordítónak meg kell adni, hogy használhja az openmp library-ket is. Ezt a -qopenmp kapcsolóval tudjuk elérni.

icpc -o matrix-CPU -qopenmp matrix.cc

MPI + Offload + OpenMP

Abban az esetben, ha 1 számítási node nem elég, MPI segítségével össze tudunk kötni több nodet, heterogén rendszert alkotva, és azokon alkalmazható a fenti Offload + OpenMP modell.
MPI elosztás valamint Offload modell és OpenMP


Phi használat slurm-mel

Slurm esetén a mic opciót kell megadni a gres-knél. Amennyiben egynél több kártyára van szükség, akkor azt a mic:2-vel lehetséges megadni. Ekkor két kártyát foglal le az ütemező a futtatandó feladatnak.
Egy lehetséges feladat, amit srun-nal lehet a slurmben beküldeni. debrecen3 esetén a prod-phi particiót kell használni

$ cat slurm_job_openmp_phi

#!/bin/bash
#SBATCH -A <PROJECT NEVE>
#SBATCH --job-name=<JOB NEVE>
#SBATCH --gres mic:2
#SBATCH --time=1:00:00
#SBATCH --partition=prod
#SBATCH -o slurm-%A.out
export OMP_NUM_THREADS=2
./runme_openmp_2phi

Ekkor a feladat beküldése a következő paranccsal lehetséges.

srun ./slurm_job_openmp_phi

Alkalmazások optimalizálása a kártyára

Ebben a fejezetben optimalizálási lehetőségeket mutatunk be, melyek segítségül szolgálhatnak az egyes alkalmazások hatékonyabb futása érdekében ezzel jobban kihasználni a rendelkezésre álló erőforrások adta számítási kapacitás.

Környezeti változók

Lehetséges csak a Phi kártyákon használt környezeti változók beállítása, amit az alkalmazásunk használni is tud.
Példánkban az OpenMP esetén használható szálak számát állíthatjuk be:

export MIC_ENV_PREFIX=XEONPHI
export OMP_NUM_THREADS=12
export XEONPHI_OMP_NUM_THREADS=240

A beállított prefix tetszőleges karaktersorozat lehet, a lényeg, hogy ezt a beállított prefixet használjuk a környezeti változók esetén is!

Hosszredukció

Többnyire a fordítók, ha nem mondjuk meg explicite, a dupla pontosságú változatot választják a lehetséges változó- és függvény típusok közül. Amennyiben a számításunk nem igényli a dupla pontosságot, akkor használjuk a forrásban mindenhol az adott típus vagy függvény egyszeres pontosságú változatát, mellyel jelentős teljesítmény növekedést tudunk elérni. Továbbá az egyes műveletek futási ideje nem egyforma, így a műveletet helyettesíthetjük más művelettel, ami gyorsabb, akkor ismét javíthatunk a hatékonyságon.
Íme pár példa, amit alkalmazni lehet:

  • "/" művelet helyett "*" -t használjunk, mert azt gyorsabban végre tudja hajtani a processzor
  • pow => 1.0 / sqrt() , tetszőleges hatványozás, pl.: 3/2-ik hatvány
  • hardver áltatl támogatott műveletek:
    • exp => exp2
    • log => log2
  • dupla pontosság -> egyszeres pontosság lebegőpontos számok és műveletek esetén
    • 1.0 => 1.0f
    • pow => powf

Vektorizáció

A vektorizáció azon fordítási időben eldönthető optimalizációs faktor, amely nagy mértékben függ attól a hardver felépítésétől, amire éppen a fordítás történni fog. Legfontosabb befolyásoló tényezők a processzor cache méretei illetve a vektor műveletekért felelős aritmetikai egységének jellemzői mivel ezeket használja fel a fordító az optimalizálási folyamata során.
Az automatikus vektorizáció minimum -O2 szinttől érvényes, de mivel általában ez a szint az alapértelmezett, ezért minden fordításnál alkalmazásra kerül.
A fordító programnak a következő kapcsolókat lehet megadni, melyek segítenek a minél jobb vektorizációban:

  • -qopt-report=<szám> ; az optimalizációs jelentés részletességét lehet megadni 0-tól 5-ig, ahol az 5-s szint a legrészletesebbet jelenti
  • -qopt-report-phase:vec ; a vektorizációról szeretnék jelentést kapni
  • -opt-report-file=<fájlnév> ; megadható a riportfájl neve
icpc autovec.cc -mmic -qopt-report=5 -opt-report-file=vectorization.oMIC.optrpt -qopt-report-phase:vec

SIMD

Az adatok hatékonyabb feldolgozására az egyik megvalósítás a SIMD (Single Instruction Multiple Data), amikor is a különálló adatokat úgy kezeli, mintha az vektorok volnának és azon végzi el a vektor-műveletet. Ezzel egy a hardver által támogatott műveletet végzünk egy lépésben egy nagyobb adatmennyiségen, ezzel párhuzamosítva a feldolgozási folyamatot. Ezzel a módszerrel nagyon jó adat szintű párhuzamosítást lehet elérni.

SIMD

Főbb támogatott műveletek:

  • aritmetikai műveletek, bináris operátorok
  • összehasonlítások
  • konverziók és típus kasztolások
  • bit-műveletek: NOT, AND, OR, XOR, XAND

Használata: simd fordítási direktívával lehetséges
Például:

  • egyszerű vektorizáció
#pragma simd
for (int i = 0; i < n; i++)
  A[i] += B[i];
  • adat párhuzamosítás OpenMP segítségével
#pragma omp parallel for simd
for (int i = 0; i < n; i++)
  DoSomeWork(A[i]);

AoS -> SoA

A kiindulás probléma az, hogy a struktúrákat tartalmazó vektort nem lehet jól vektorizálni, mivel a cache-ben tárolt adatok nagy többsége feleslegesen van bent, mivel azon adatokat csak egy későbbi ciklus futás során fogja az alkalmazás feldolgozni. Tehát ezen adatok jelenleg feleslegesen foglalják a cache területet, ahelyett, hogy hasznos adatot tárolnánk ott.

struct MyStructure {
  float x, y, z;
}

void My_Function(cons int nItem, MyStructure* const Items, const float delta) {
  for (int i = 0; i < nItem; i++) {
    ...
    for (int j = 0; j < nItem; j++) {
      const float dx = Items[j].x - Items[i].x;
      const float dy = Items[j].y - Items[i].y;
      const float dz = Items[j].z - Items[i].z;
      ...  
    }
  }
  ... 
}

A vektorban a struktúrák tárolása helyett minden elemnek egy dedikált vektort allokálunk és így könnyen tudja a fordító vektorizálni azt. Természetesen segítségként a vektor feldolgozását "tömbösítettük" a tileSize méret megadásával illetve a unroll fordítási direktíva segítségével.

struct MyStructureSet {
  float *x, *y, *z;
}

void My_Function(cons int nItem, MyStructure* const Items, const float delta) {
  const int tileSize = 16;
#pragma omp parallel for schedule(guided)
  for (int ii = 0; ii < nItem; ii+= tileSize) {
    ...
#pragma unroll(tileSize)
    for (int j = 0; j < nItem; j++) {
#pragma unroll(tileSize)
      for (int i = ii; i < ii + tileSize; i++) {
        const float dx = Items.x[j] - Items.x[i];
        const float dy = Items.y[j] - Items.y[i];
        const float dz = Items.z[j] - Items.z[i];
        ...
      }
    }
  }
  ... 
}

Hatékony hossz választás

Olyan esetben jelent előnyt, ha például az n*n-es mátrix esetén n nem osztható 16-val, mert akkor a vektorizáció nem teljesen hajtható végre.

// n%16 != 0
float* A = _mm_malloc(sizeof(float)*n*n, 64);

Ilyen esetben mindig marad egy kis vektor részlet, amire elemenként kell elvégezni a műveletet.
A megoldást az jelenti, hogy a memória allokálást kiterjesztjük és nagyobb memória területet foglalunk a változónak, így már alkalmazható lesz a vektorizáció, mivel a maradvány területen is el tudjuk végezni a műveletet egy lépésben.

int lda=n;
if (n % 16 != 0) lda += (16 - n%16); // most lda%16==0
float* A = _mm_malloc(sizeof(float)*n*lda, 64);

// használat
for (int i = 0; i < n; i++)
  for (int j = 0; j < n; j++)
    A[i*lda + j] = ...

"Héj ciklusok" kiküszöbölése

Ciklusok esetén lehetséges, hogy a vektorizációt nem teljes mértékben ismeri fel a fordító program. Ekkor segíthetünk a fordítónak egy fordítási direktívával (#pragma vector aligned), ha teljes mértékben biztosak vagyunk, hogy a ciklusunk vektorizálható!

#pragma vector aligned
  for (int j = 0; j < n; j++)
    A[i*lda + j] -= ...

Ettől jelentős futás idejű javulást érhetünk el, mivel ezzel a #pragma-val kikapcsoltuk a futás idejű ellenőrzést, de előfordulhatnak olyan esetek, főleg amikor nem jól mértük fel a ciklusunkat és nem sikerült a vektorizálás, hogy Segmentation fault hibára fogunk futni.

Regularizáció

Van egy ciklusunk, ami egy vektor elemeit módosítja. A processzor az adatokat "csokorba szedve" emeli be a belső kis méretű cache-be. Egyszerre természetesen több vektor elem is bekerül a a cache-be ezzel gyorsítva a feldolgozást. Ezen vektor iterációk valahogyan ráilleszkedik a vektorra.

Vektor regularizáció

Abban az esetben, amikor ez az illesztés nem teljesen tökéletes (lsd. piros elemek), keletkeznem héj ciklusok, melyeket a regularizációval is eliminálni lehet.
A feladat eredetileg valahogyan így nézhet ki:

for (int i = 0; i<n; i++)
  A[i]=...

Ez a következő átalakítással már alkalmas vektorizációra és nem lesz "peel loop" probléma.

for (int b=0; b<n; b++) {
  const int jStart = b- b%16; // mindig osztható 16-val
  ...
#pragma simd
  for (int j = jStart; j<n; j++)
    A[j]=...

Párhuzamosítás

Több féle mód létezik a párhuzamosításra. Korábban említettük a SIMD technikát, ami adatpárhuzamosításra való 1 processzoron belül, de létezik még processzorok közti párhuzamosítás, amit az OpenMP segítségével tudunk hatékonyan megvalósítani. Amennyiben gépek közti párhuzamosításra, üzenek küldésre van szükség, akkor az MPI-t kell segítségül hívni. Általános megoldás nem létezik minden feladatra, mert MPI is alkalmas lehet gépen belüli párhuzamosításra, nem csak az OpenMP. Amit mérlegelni kell, hogy mi az amire az alkalmazásnak szükséges van illetve mekkora kommunikációs overhead keletkezik, ha lecseréljük az OpenMP-t MPI-ra. Általában az egymástól független számításokat OpenMP-vel, míg a kommunikációt igénylő feladatokra MPI-t szoktak használni.

Szálak párhuzamosítása

Az OpenMP-nek környezeti változók segítségével megadható a párhuzamosan futtatható szálak száma. Ezt a következő paranccsal tudjuk megtenni, ahol 5-re állítjuk.

export OMP_NUM_THREADS=5

Forrásban a párhuzamos szálakat a #pragma omp parallel segítségével adhatjuk meg.

#pragma omp parallel for              	//külső for szálak párhuzamosítása
for (int i = 0; i < n; i++)
#pragma simd				// vektorizáció a belső cikluson
  for (int j = 0; j < m; j++)
    My_Function(A[i][j]);

A fordítási direktívák össze is vonhatóak, ilyenkor 1 ciklus is elég.

#pragma omp parallel for simd
for (int i = 0; i < n; i++)
    My_Function(A[i]);
Változók láthatósága, megosztása

Változók hatókörét a programozási nyelven kívül pragma-k segítségével is beállíthatjuk, hogy megosztott legyen a változó vagy a szálra nézve saját példány. Amennyiben nem definiáljuk felül, akkor minden változó megosztott. A felül definiálást a private és a shared paraméterekkel lehet megadni az OpenMP-nek.

int A, B, C;

#pragma omp parallel private(A) shared(B)
{...}

Az itteni példában az B és C megosztott változó a szálak között, míg A-t minden szálnak lemásolja a futás kezdetén.

Párhuzamos szálak ütemezése

OpenMp a szálak ütemezésére három féle módod kínál fel. Módok:

  • static (alapértelmezett): egyenletesen arányban egymás után elosztja a processzeket az indított szálakon, ezért a futási idő tetszőlegesen elhúzódhat, ha egyes processzek lassabban futnak le.
  • dynamic: minden esetben az első szabad szálra kerül a következő processz ütemezése, folyamatos kihasználtságot elérve, de "költséges" a sok-sok ütemezés
  • guided: Az előző két mód ötvözése. Nagyobb procesz számot ütemez az éppen szabad szálakra, melyek számát folyamatosan csökkenti a futás előre haladtával, hogy körülbelül egyszerre fejeződjön be minden szál futása, ezzel csökkentve a dynamic esetén magad ütemezési időráfordítást.

Mindhárom esetben megadható az egyszerre ütemezett processzek száma, amit a chunk értéke határoz meg. Ha megvan adva a chunk értéke, akkor az minden esetben annak megfelelő processzámot ütemez a szálakra. Használata:

#pragma omp parallel [schedule (<mode>,<chunk>) {...}

Szálak szinkronizációja

Szála szinkronizálását többféle módon lehet megvalósítani, de ezen megoldások sajno jelentősen lelassítják a program futását.

  • mutexxel: nagyon lassú, állandóan várnak egymásra a párhuzamos szálak
#pragma omp parallel for
for (int i = 0; i < n; i++) {
#pragma omp critical
  { // kritikus szekció, csak egy szál hajthatja végre
    total = total + i;
  }
}
  • atomi művelet definiálása: sajnos még ez is lassú, de itt már nem olyan "erős" a szinkronizáció, ezért rendkívül sok limitációja van az itt használható atomi műveleteknek.
#pragma omp parallel for
for (int i = 0; i < n; i++) {
#pragma omp atomic
   total += i;
}	
Párhuzamos redukció

Az itt bemutatott eljárás gyorsabb mint a fenti szinkronizációs megoldás, de sajnos nem alkalmazható vektorokra! A redukciót a reduction(operation: scalar) formában kell megadni.

int sum = 0;
#pragma omp parallel for reduction(+: sum)
for (int i = 0; i < n; i++) {
  sum = sum + i;
}


Tulajdonképpen a fenti problémakörre a következő minta ad megoldást. Ekkor egy külön változóban akkumuláltatjuk a szál általi összeget és ezen értékeke összegyűjtését végezzük csak atomi műveletként.

int sum = 0;
#pragma omp parallel
{
  int sum_thr = 0;
#pragma omp for
  for (int i=0; i<n; i++)
    sum_thr += i;

#pragma omp atomic
  sum += sum_thr;
}

Ciklusok párhuzamosítása

Ciklus párhuzamosítására a következő mintát szokás használni.

#pragma omp parallel
{
  // Az itt definiált forrást minden szál esetén végre fog hajtani
  ...
#pragma omp for [schedule (<mode>,<chunk>)
  for (int i = 0; i < n; i++) {
    // Ez a kódrész lesz párhuzamosítva
    ...
  }
  // ... és ez a köd is minden szál esetén végrehajtódik
  ...
}

Ciklus kifejtés

A technika már egy példa erejéig a vektorizációnál is látható, de ott nem került részletezésre. A megoldás lényege, hogy a kellően nagy ciklust szétdaraboljuk kisebb darabokra, amit már külön feldolgozhatóak, ezzel párhuzamosítva a folyamatokat. Az egy darab kiindulási ciklusból két darab egymásba ágyazott ciklust hozunk létre, ami ugyanazon a feladattéren dolgozik.

Eredeti állapot:

for (int i = 0; i < n; i++) {...}

Átírt változat:

int STRIP=1024;
for (int ii = 0; ii < n; ii += STRIP)
 for (int i = ii; i < ii+STRIP; i++) {...}

// ha még van maradék elem, akkor azokon is végrehajtjuk a számításokat
if (n%STRIP != 0) {
  for (i=n-(n%STRIP)+1; i<n; i++) {...}
}

A darabok mérete (STRIP értéke) az adott rendszer és feladat függvényében változhat, tehát ez egyfajta "tuning" paraméter.

Automatikus ciklusösszevonás

Az OpenMP támogatást nyújt több egymásba ágyazott ciklus "összevonására". Ehhez csak a collaps fordítási direktívát és az összevonandó ciklusok számát kell megadni.
FONTOS: ekkor az automatikus vektorizáció kikapcsolásra kerül!

#pragma omp parallel for collaps(2)
for (int i=0; i<n; i++)
  for( int j=0; j<m; j++){...}

A fneti példában a 2db for ciklis kerül párhuzamosításra úgy, hogy a fordítás során 1db ciklus generálódik belőle, ami a következő paraméterezéssel ekvivalens:

#pragma omp parallel for
for (int c=0; c < n*m; c++) {
  int i = c/n;
  int j = c%n;
  ...
}