Algoritmusok vizsgálata hierarchikus memóriájú rendszerekben Horváth Gábor 2014. december 25. 1. Bevezetés Klasszikusan az algoritmusok futási idejét asszimptotikusan szokás vizsgálni. Kellően nagy méretű bemenetek esetén az asszimptotikusan gyorsabb algoritmus lesz a jó választás. Kisebb méretű bemeneteknél viszont nem ritka, hogy az asszimptotikusan rosszabb futási idejű algoritmus teljesít jobban. Sok esetben a programozó előre tudja, hogy egy adott algoritmusnak várhatóan mekkora méretű bemenetei lesznek. Jó példa erre a felhasználó és a program közti interakciókat érintő algoritmusok egy része, hiszen egy reális korlátot lehet mondani akár a képernyőn megjelenítendő grafikus vezérlők számára, mert bizonyos méret felett a felhasználó nem is tudná feldolgozni azt az információmennyiséget amit a képernyőn lát. Ilyen esetekben az algoritmusok összehasonlítására egy lehetséges módszer bizonyos lépések várható számának az összehasonlítása, például várhatóan hány értékadást hajt végre az algoritmus. Ez a modell viszont azt feltételezi, hogy az értékadások ugyanannyi időt vesznek igénybe függetlenül attól, hogy mikor történnek és milyen értéket írnak felül. A mai architektúrák azonban a teljesítmény növelése érdekében bonyolult gyorsítótárazási megoldásokat használnak. Ahogy az az alábbi táblázatból [3] is látható, csupán azzal, hogy az adat amit épp el akarunk érni, nem csak a memóriában található meg, hanem éppen az első szintű gyorsítótárban is, 200-szor gyorsabban érhető el. Ha csupán egy-egy változó elérése lenne jelentősen gyorsabb, akkor nem befolyásolná jelentősen az algoritmus futási idejét a gyorsítótár, azonban különböző mechanizmusok segítségével a processzor megpróbálja kikövetkeztetni, hogy milyen mintában olvassuk az adatokat a memóriából, és ezáltal megpróbálja előre betölteni a gyorsítótárba azokat az adatokat amikre szükségünk lehet. Ebből kifolyólag amennyiben az algoritmusunk egyszerű mintát követve olvassa az ada- L1 cache reference Branch mispredict L2 cache reference Mutex lock/unlock Main memory reference Read 1 MB sequentially from memory Disk seek Read 1 MB sequentially from disk 0.5 ns 5 ns 7 ns 25 ns 100 ns 250,000 ns 10,000,000 ns 20,000,000 ns 1
tokat (lehetőleg sorfolytonosan akár előre akár hátrafelé), a hivatkozott változók túlnyomó többsége a gyorsítótárban lesz addigra, mire hivatkozni vagy írni akarjuk őket. Az egyes műveletek idejét érintő változásokra az algoritmus futási ideje még érzékenyebb lehet. Például, ha egy vonal megjelenítését végző rutin kétszeresére gyorsul, egy táblázatot kirajzoló rutin akár négyszeres gyorsulást is mutathat. A cache-ek elérésénél még gyorsabb a registerek elérése, azonban a registerekkel azért nincs értelme foglalkozni az algoritmusok teljesítményének a vizsgálatakor, mert a fordítók ma már lineáris időben meg tudják állapítani azok közel optimális felhasználását [4]. A program változóinak a helyét, illetve azoknak az elérési mintázatát viszont nem tudják ma sem átrendezni a fordítók, ezért sebességkritikus alkalmazások esetén a programozónak kell erre gondolnia. 2. Mérések metodológiája A méréseimhez a programokat C++ nyelven [2] írom meg, és a gcc fordító 4.9.1- es változatával fordítom le. A kódokat a C++11-es szabvány szerint fordítom és az O3 kapcsolóval optimalizálom. A mérésekhez felhasznált laptopom egy Dell Inspiron N5110, Core i7-2630qm [5] processzorral és 8GB DDR3-mas RAM-mal. Az előbb említett CPU 4 magos, 8 szálat támogat hardveresen, 6 MB 3. szintű közös gyorsítótárral (továbbiakban cache), valamit processzormagonként 256KB 2. szintű és 32 KB 1. szintű cache-el rendelkezik. Nyelvnek azért a C++-t választottam, mivel ebben a programozónak nagy befolyása van a program memória és utasítás kiosztásra. A forrásfájlokban egymás mellett megtalálható függvényekből a generált binárisban a függvényekből generált kód is egymás mellett lesz. A mérések során a mérendő kódrészletet egy-egy függvénybe ágyazom ahol a függvény futási idejét mérem. A függvény, aminek az idejét mérem, nem hozhatja létre a saját adatszerkezeteit, hiszen akkor ez is a mérés része lenne. Ehelyett azokat a függvény futtatása előtt hozom létre. Ebben az esetben azonban ha ugyanazt a függvényt többször futtatom és mérem az eredményeket, akkor a CPU cacheben maradhattak az előző futásból adatok, ez torzítja a mérés pontosságát. Ebből kifolyólag minden futás előtt meghívok egy kódrészletet ami a CPU cache-t kiüríti. Hogy életszerű legyen a mérés, ezért a programokat mindig úgy fordítom le, hogy a fordító optimalizációs lehetőségei be vannak kapcsolva. Ez azonban egy veszélyt is magában rejt, mégpedig azt, hogy a cache ürítését végző kódot a fordító kioptimalizálja, az nem fut le, hiszen nincs megfigyelhető mellékhatása a kódnak a program működésére. unsigned flush_cpu_cache_fn () { // Reset the cache with 20 mb data read constexpr const int size = 20*1024*1024; static volatile char * data = new char [ size ]{}; unsigned ret = 0; for ( unsigned i = 0; i < size ; ++i) ret += data [ i]; } return ret ; template <... > void benchmark (...) { //... static unsigned (* volatile flush_cpu_cache )() = flush_cpu_cache_fn ; //... 2
} A cache felülírásához egy 20MB méretű adatot olvasok végig. Ez a mai CPUk cache méretét nézve elég kell, hogy legyen, ahhoz, hogy annak jelentős része felülíródjon. Azért, hogy az olvasást ne optimalizálhassa ki a fordító, egy volatile mutatón keresztül olvasom az adatot, ami a C++ szabványban annyit jelent, hogy a fordítónak arra kell számítania hogy külső program is módosíthatja az adott memóriaterületet. Így hiába van a fordítónak statikus ismerete a tömb elemeiről, nem transzformálhatja ki az olvasást más műveletté. Hasonlóan annak céljából, hogy a fordító a függvény meghívását ne eliminálja, az egy volatile függvénymutatón keresztül kerül meghívásra. Mivel a fordítónak feltételeznie kell, hogy egy külső program bármikor átállíthatja a mutatót egy olyan függvényre aminek megfigyelhető mellékhatása lenne, ezért nem eliminálhatja a hívást. A mérésnél az összes futás után a futás sorszámához tartozó indexel egy tömbbe eltárolom a futás idejét amit nanomásodpercben mérek. A mért függvénynek tetszőleges számú paramétere lehet, amit a mérő függvény továbbít neki perfect forwarding-ot használva. Ez C++-ban a lehető leghatékonyabb paramétertovábbítás, így a mérés idejében minimális torzítást jelent a paraméterátadás ideje. A mérések megismétlésének a száma az bemeneti paramétere a mérő függvénynek. template < typename Func, typename... Args > BenchmarkResult benchmark ( unsigned sample_num, Func f, Args &&... args ) { //... } for ( unsigned i = 0; i < sample_num ; ++i) { } flush_cpu_cache (); auto start = chrono :: steady_clock :: now (); f( forward <Args >( args )...) ; times [ i] = chrono :: duration_cast < chrono :: nanoseconds >( chrono :: steady_clock :: now () - start ). count (); //... A mérések lefutása után a futási időket tartalmazó tömbben lévő adatokból a függvény kiszámolja a futási idők átlagát (tapasztalati várható értéket), valamint a korrigált tapasztalati szórását. A szórás alapján eldöntöm, hogy az adott mért értéket elfogadom-e, vagy a mintaszámot valamint a mért függvény bemenetének a méretét állítva megismétlem-e a mérést. std :: cout << benchmark (100, row_major ) << std :: endl ; A mérést végző függvény használata kifejezetten egyszerű és kényelmes, minden fejezetben az ismertetett kódokhoz tartozó mérést hasonló módszerekkel végeztem el. A fenti példában egy row major paraméter nélküli függvény futási ideje lesz mérve 100 alkalommal, majd az eredményt kiírja a szabványos kimenetre. 3. Memória bejárási minták Az első példában egy egészeket tartalmazó mátrixban számolom meg a páratlan számokat. Egy mátrix bejárására számtalan lehetőség van, de a leggyakrabban sorfolytonos illetve oszlopfolytonos bejárást szokás alkalmazni, mivel ezeket a legegy- 3
szerűbb implementálni és gyakran nincs kikötés az elemek meglátogatásának a sorrendjére. Ezzel a két bejárással valósítottam meg a számlálást, a mátrixot pedig egyetlen sorfolytonos vektorként tároltam el. std :: vector <int > matrix ( m_size * m_size ); int traverse_matrix ( Traversal t) { int ret = 0; if ( t == RowMajor ) { for ( int i = 0; i < m_size ; ++i) for ( int j = 0; j < m_size ; ++j) ret += matrix [ m_size * i + j] % 2; } else { for ( int i = 0; i < m_size ; ++i) for ( int j = 0; j < m_size ; ++j) ret += matrix [ m_size * j + i] % 2; } return ret ; } A fenti kódon futtatott mérési eredményem a 1 grafikonon látható. Futási idő (ns) 1 0.8 0.6 0.4 0.2 0 10 8 Sorfolytonos Oszlopfolytonos 10 2 10 3 10 4 Input méret (sor/oszlop szám) 1. ábra. Páratlan elemek számlálása mátrixban A grafikonból jól látszik, hogy a sorfolytonos bejárás közel kétszer olyan gyors mint az oszlopfolytonos. Nem történt változtatás az algoritmusban, csupán a memóriában az adatok elérési sorrendje változott. Abban az esetben, ha a mátrixot nem csak olvasnánk, hanem írnánk is, akkor még drasztikusabb különbségek jelennének meg, a méréseim során akár közel háromszoros különbséget is tapasztaltam. Klasszikus módszerekkel számolva a két algoritmus futási ideje az egyszerű cache független modellben azonos lenne. Sorfolytonos esetben a memória elérése is sorfolytonosan történik, ezért a CPU prefetchere (előtöltője) szépen sorban betölti a cache-be azokat a memóriacímeket amikre előreláthatólag szükség lesz. Mivel a prefetcher működése nagyon egyszerű, ezért a bonyolultabb mintázatokat (például konstans mennyiséggel való lépegetést a memóriában) nem képes felismerni. Ez a jelenség nem csak mátrixoknál lép fel. Tegyük fel, hogy rendelkezünk egy tömbbel, ami rekordokat tartalmaz. Abban az esetben ha a rekordok egyik mezőjén akarunk módosítást végezni, akkor a tömbön való végigiterálás közben a mátrix oszlopfolytonos bejárásához hasonló mintázatban olvassuk illetve módosítjuk 4
a memóriát. Ez a programozási minta objektum orientált programozás esetén gyakran előfordul. A leghatékonyabb szimulációs eljárásoknál valamint a játékiparban is gyakori optimalizációnak számít, amikor a rekordok tömbjéből tömbök rekordját csinálnak. Ebben az esetben ha az egyik attribútumnak megfelelő tömbön akarunk valamilyen transzformációt elvégezni, akkor a mátrix sorfolytonos bejárásához hasonló mintában fogjuk olvasni illetve írni a memóriát. // Array of structs struct Point { int x, y, z; }; Point points [200000]; // Struct of arrays struct Points { int x [200000]; int y [200000]; int z [200000]; }; Points pts ; Az előbbi kódrészlet szemlélteti a különbséget a rekordok tömbje és a tömbök rekordja között. Ez az optimalizációs lehetőség népszerű, viszont az emberi gondolkodáshoz a rekordok tömbje közelebb áll. Ezért aktív kutatási terület, hogy hogyan lehet ipari kódbázisok esetén az egyik reprezentációról a másikra transzformálni a kódot automatizált eszközökkel emberi beavatkozás nélkül. Sajnos az iparban használt nyelvek komplexitása miatt ez nem egy egyszerű feladat. 4. Folytonos és csúcs alapú adatszerkezetek A C++-ban két gyakran használat adatszerkezet a list és a vector. Az előbbi az egy két irányban láncolt lista, az utóbbi pedig egy olyan tömb, ami dinamikusan tud nőni és számon tartja a saját méretét. Mind a listában mind a vektorban a lineáris keresés asszimptotikusan ugyan úgy kellene, hogy viselkedjen, hiszen O(n) műveletet végzünk várhatóan, ahol n az adatszerkezet hossza. A következő mérésben feltöltöttem egy-egy vektort és listát 1..n egész számokkal, majd véletlenszerűen összekevertem ezeket a számokat. Ezután egy véletlenszerűen kiválasztott számot kerestem lineáris keresést használva. A 2 grafikon mutatja a mérési eredményeket. Látható az adatokból, hogy igen jelentős a vektorban és a listában való keresés ideje között az eltérés, pedig asszimptotikusan megegyezik a két műveletigény. Az a jelenség oka, hogy még a vektorban a keresés sorfolytonos memóriaolvasást jelent, addig a láncolt listában az elemek a memóriában véletlenszerűen helyezkednek el, így nem valószínű, hogy a következőnek hivatkozott elem a cache-ben található. A lista jelentősen lassabb, mivel a bejárás minden lépése várhatóan memória olvasással fog járni, még a vektor esetében a következő elem a cache-ben megtalálható lesz hála a prefetch mechanizmusnak. A mért adatok alapján ráadásul az az ember benyomása, hogy a listán a keresés sokkal rosszabb a lineárisnál. Tipikusan hasonló jelenség zajlik le, ha a lista helyett egy tetszőleges másik láncolt adatszerkezetet tekintünk. Ezért teljesítménykritikus alkalmazások esetén sokszor kerülendő az olyan szótárak, halmazok használata, amik fák segítségével 5
3 10 8 Vektor Lista Futási idő (ns) 2 1 0 10 4 10 5 10 6 10 7 10 8 10 9 Input méret (hossz) 2. ábra. Véletlen elem keresése vannak implementálva. A C++ szabványnak is az egyik legnagyobb kritikája teljesítmény szempontjából az, hogy a szabványos könyvtárban lévő unordered map, ami egy hasító tábla implementáció, láncolva tárolja a vödrökben az elemeket. Sok alkalmazás esetén gyorsabb egy nyílt címzést alkalmazó hasító tábla. Hasonló okokból gyakran hatékonyabbak azok az algoritmusok, amik kevés adat mozgatást végeznek, tehát helyben végzik a módosításokat. Ilyen például a quick sort, ami helyben rendez egy tömböt. Azáltal, hogy az adatok nem kerülnek mozgatásra, kevesebbszer invalidálódik a cache. A Fortran gyors nyelv hírében áll, viszont a gyorsaságának pont az az egyik oka, hogy tipikusan a Fortran programokban sorfolytonos memóriaterülettel rendelkező adatszerkezeteket szoktak használni. 5. Adatszerkezetek az algoritmusok ellen A mai modern processzorok a cache-selésen túl számos trükköt alkalmaznak a sebesség növelésére. Ilyen például az out of order execution, ami azt jelenti, hogy a processzor a pipeline-ban lévő utasítások sorrendjét megváltoztathatja, ha ez nem okoz változást a program szemantikájában. Az átrendezés azért lehet előnyös, mert a processzor külön áramköröket tartalmazhat a különböző utasítások elvégzésére, és az utasítások feldolgozásának a sorrendjétől függhet, hogy párhuzamosan hány ilyen különböző feladatokat ellátó áramkör tud dolgozni egyszerre. Egy másik gyakori módszer a vektor utasítások használata. A vektor utasítások olyan utasítások, amik egy adott műveletet több értéken végeznek el egyszerre. Két szám összeadása helyett például képes lehet a processzor egy vektorutasítással négy számpárt összeadni, aminek az eredménye négy új szám lesz. Az ilyen utasítások végrehajtásának az ideje megegyezik a hagyományos utasításokéval, csupán ugyanannyi idő alatt, a munka többszörösét képes elvégezni. Ezen felül olyan speciális utasításkészletek is megjelentek, mint például az FMA, amivel képesek vagyunk két számot összeszorozni, majd egy harmadikat hozzáadni a szorzás eredményéhez, mindezt ugyanannyi idő alatt, mint amennyit a szorzás vett 6
volna igénybe. Elsőre elég ezoterikusnak hangozhat ez az utasítás, ugyanakkor elég gyakran előfordul ez a művelet, például a skaláris szorzásban is. Részben a fenti technológiák miatt, ma már nem ritka, hogy egy kellően optimalizált kód esetében a teljesítményt már nem a processzor sebessége korlátozza, hanem az, hogy milyen gyorsan tudjuk az algoritmus számára az adatokat betölteni a memóriából. Ebben az esetben algoritmikusan már nincs értelme javítani a programunkon, a memória elérési mintázatot kell optimalizálni. Ebből adódóan egy kellően gyors algoritmus esetén már csak az adatok memóriában való elhelyezkedésétől függ a program teljesítménye. Éppen ezért, egyre nagyobb figyelmet kapnak az adatszerkezetek, hiszen az nagyban befolyásolják az adatok elhelyezkedését. Chandler Carruth Google alkalmazott egy nagyon élvezetes előadásban foglalja ezt össze [7]. 6. Utasítás gyorsítótár A ma használt számítógépekben a memóriában nem csupán az adatok találhatóak, amiket a programok feldolgoznak, hanem a programok utasításai maguk is. Ezt könnyű elfelejteni. A processzor működése közben nem csupán az adatokat, hanem az utasításokat is a memóriából tölti be. Éppen ezért, hasonlóan mint az adatoknál, az utasításoknál is gyorsítótárazási mechanizmusok működnek. Ezek a mechanizmusok sokszor kifinomultabbak, mint az adatok esetén. Például egy függvény meghívásakor a processzor már ismeri azt is, hogy a függvényből való visszatérés után hol fog folytatódni a végrehajtás, így a megfelelő utasításokat be tudja tölteni a gyorsítótárba előre. Alacsony szintű nyelvek esetén, mint amilyen a C és a C++, a generált kódban azok a függvények lesznek egymáshoz közel, amik a forráskódban is közel voltak. Ezáltal az egymást gyakran hívó függvényeket egymáshoz közel helyezve növelhetjük az alkalmazás teljesítményét. Egy egyszerű mérést végeztem az utasítások gyorsítótárazásával kapcsolatban. A feladat az volt, hogy számoljuk meg, hány prím szám van egy adott felső korlátig, kétszer. Az első esetben leírtam egymás alá kétszer ugyanazt a kódot. A második esetben viszont egy függvényt hívtam meg kétszer. Az eredmények a 3 grafikonon láthatóak. Az időt most logaritmikus skálán ábrázoltam. A függvényhívásos megoldás egyértelműen jobb teljesítményt nyújtott, mint a kódduplikálással járó megoldás. Ebben az esetben nem jött ki drasztikus különbség, és ahogy a bement nőtt, úgy ez a különbség arányaiban csökkent. A különbség apróságának az egyik oka az, hogy a tesztprogram maga kicsi, ezért az összes utasítás befért az L2 cache-be. Nagyobb programok esetén sokkal drasztikusabb különbségek mérhetőek. Gyakran előfordul ipari alkalmazásokban, hogy polimorfikus gyűjteményeket használnak a programozók. Ez azt jelenti, hogy egy olyan gyűjteményt hoznak létre, ami egy adott típusra mutató mutatókat vagy referenciákat tartalmaz, de ezek a mutatók vagy referenciák valójában az adott típus altípusainak a példányaira mutatnak. Ilyenkor sokszor virtuális függvényhívás segítségével kerül meghívásra a dinamikus típushoz tartozó megfelelő művelet. Abban az esetben, ha a gyűjteményben a típusok váltakozva szerepelnek, minden alkalommal, amikor az egyik elemen meghívunk egy virtuális tagfüggvényt, az jó eséllyel olyan függvényt fog meghívni, ami jelenleg nincs bent az utasítás gyorsítótárban. Éppen ezért egy lehetséges optimalizáció az, ha a 7
10 6 Duplikált kód Két függvényhívás Futási idő (ns) 10 5 10 4 10 3 10 1 10 2 10 3 10 4 Input méret (felső korlát) 3. ábra. Prímek számolása kétszer gyűjteményben lévő elemeket a típusuk szerint rendezzük. Ebben az esetben az azonos típusú elemek egymás mellé kerülnek, így maximálisan kihasználják a processzor által végzett gyorsítótárazást. A [6] cikkben olvasható, hogy a Microsoft fejlesztői képesek voltak a Windows CE operációs rendszer hálózati teljesítményét több mint háromszorosára növelni azáltal, hogy az kódjukat úgy írták újra, hogy figyelembe vették az utasítás gyorsítótárazást is. 7. Problémák párhuzamos és elosztott rendszerekben Párhuzamos és elosztott rendszerekben még nagyobb jelentősége van az adatok lokalitásának. Elsősorban több processzormaggal rendelkező rendszerekről fogok beszélni, mivel manapság ezek a legelterjedtebb konfigurációk. A processzor L1 gyorsítótára fel van osztva cache line-nak nevezett egységekre. Minden alkalommal, amikor a memóriából valami beolvasásra kerül, vagy kiíródik oda, az egy teljes cache line lesz. Vegyük elő újra azt a feladatot, hogy számoljuk meg egy mátrixban hány páratlan elem található. Ebben az esetben párhuzamosítsuk a megoldást. Az itt bemutatott példa és a mérések mind Herb Suttertől származnak [11]. A kód (4) egy thread poolt fog használni. Mindegyik szál meg fogja számolni a mátrix hozzá tartozó részében, hogy hány páratlan szám található. Van egy tömb, aminek minden eleme az egyik szálhoz van rendelve. Elindítjuk az összes dolgozó szálat, majd megvárjuk, hogy mind befejezze a munkát. A munkájuk végeztével pedig összeadjuk a tömbben található számokat, így megkapva a végeredményt. A mérés eredménye a 5 grafikonon látható. A teljesítmény több magon sok esetben romlott, és 24 processzormag használatával sem érhető el még a kétszeres teljesítmény sem. Pedig a probléma maga jól párhuzamosítható. Mi az oka? Az, hogy a processzorok egyszerre egy cache line méretével megegyező szeletet olvasnak ki a memóriából, vagy írnak be. Ebből kifolyólag, bár minden szál külön memóriaterüle- 8
int result [P]; for ( int p = 0; p < P; ++p ) pool. run ( [&,p] { result [ p] = 0; int chunksize = DIM / P + 1; int mystart = p * chunksize ; int myend = min ( mystart + chunksize, DIM ); for ( int i = mystart ; i < myend ; ++i ) for ( int j = 0; j < DIM ; ++j ) if( matrix [ i* DIM + j] % 2!= 0 ) ++ result [p]; } ); pool. join (); odds = 0; for ( int p = 0; p < P; ++p ) odds += result [ p]; 4. ábra. Párhuzamos számlálás mátrixon: első változat tre írja a saját eredményét, a processzorok erről nem tudnak. Csak azt látják, hogy mindegyik szál ugyanazt a cache line-t használja, ezért annak érdekében, hogy a memóriában lévő érték konzisztens maradjon minden szál számára, minden módosítás után szinkronizálják az értéket a processzormagok cache-ei között. Ennek az az eredménye, hogy az a cache line, amiben a tömb található, folyamatosan másolgatva lesz a memória és az egyes processzorok gyorsítótára között, holott erre nem lenne szükség. A processzormagok pedig a legtöbb idejüket azzal töltik, hogy várják, hogy a memóriából megkapják a friss értékeket. Ezt a jelenséget hívják hamis megosztásnak (False Sharing). Az előző kód könnyen kijavítható (6) azáltal, hogy minden szál külön számára elkülönített memóriaterületen tartja számon a saját eredményeit, majd a végén lesznek ezek összesítve egy közös memóriaterületen. A javításnak hála immár lineárisan fog skálázódni a teljesítmény a felhasznált processzormagok függvényében (7). Párhuzamos rendszerek fejlesztésekor tehát különösen nagy figyelmet kell fordítanunk az adatok lokalitására, ugyanis ez lehet a kulcsa a skálázhatóságnak. Sajnos sok esetben a fejlesztőknek máig szükségük van alacsony szintű ismeretekre a azokról az eszközökről, amikre fejlesztenek. 9
5. ábra. False Sharing int result [P]; for ( int p = 0; p < P; ++p ) pool. run ( [&,p] { int count = 0; int chunksize = DIM / P + 1; int mystart = p * chunksize ; int myend = min ( mystart + chunksize, DIM ); for ( int i = mystart ; i < myend ; ++i ) for ( int j = 0; j < DIM ; ++j ) if( matrix [ i* DIM + j] % 2!= 0 ) ++ count ; result [ p] = count ; } ); 6. ábra. Párhuzamos számlálás mátrixon: javított változat 10
7. ábra. False Sharing megoldva 11
8. Összefoglalás Az esszében bemutattam a modern informatika számos vívmányát, amivel egyre jobb teljesítményt érnek el az alkalmazások, és amit felhasználva egyre hatékonyabb kódot tudnak generálni a fordító programok. Összegyűjtöttem néhány optimalizációs technikát és vezérelvet, amik támpontot adhatnak, mikor teljesítmény kritikus alkalmazást fejlesztünk. Általánosságban véve ma már nem igaz az, hogy egy kódrészlet assembly kódját vizsgálva meg lehet állapítani, hogy mennyire gyors. Az új utasításkészletek miatt, számos esetben a hosszabb assembly kód lesz a gyorsabb. Az informatika egy dinamikusan változó és fejlődő terület, ezt türközi az is, ha valaki más fordítóval, más operációs rendszeren vagy másik processzor architektúrán ismétli meg az általam végzett méréseket, akár lényegesen eltérő eredményt is kaphat. Ebből kifolyólag nem dolgoztak ki az asszimptotikus analízishez hasonló módszertant az algoritmusok hatékonyságának a vizsgálatára a modern architektúrákon. Mivel a változás nem lassul, ezért hasonló módszertanra megjelenésére a közeljövőben sem számítok. A legtöbb szakember egyedül azt a tanácsot tudja adni, hogy minél több mérést végezzünk az adott programon, és ezt minél több környezetben ismételjük meg. Gyakran kaphatunk az első intuíciónknak ellent mondó eredményt. Az alacsony szintű optimalizáció célja nem csupán a hatékonyság lehet. A mai processzorok úgy takarítanak meg energiát, hogy amikor nincs sok munkájuk, képesek korlátozni az órajelüket, vagy akár magokat teljesen lekapcsolni. Éppen ezért az áramfogyasztás visszaszorítására is az optimalizáció jelenleg az egyetlen eszközünk. Ez főleg nagy szerverparkok és mobilalkalmazások esetén lényeges. Az előbbi esetben a költségek jelentős részét már nem a fejlesztők bére hanem a szerverfarm áramellátása jelenti, a másik esetben pedig a felhasználói élmény drasztikus romlásához vezet, ha egy alkalmazás hamar lemeríti a készüléket. Emellett a hatékony alkalmazásokat, a megspórolt energiából adódóan környezetbarát alkalmazásoknak is tekinthetjük. Hivatkozások [1] Lattner, C.: LLVM and Clang: Next Generation Compiler Technology, The BSD Conference, 2008. [2] Stroustrup, B.: The C++ Programming Language Addison-Wesley Publishing Company, fourth edition, 2013. [3] Norvig, P.: Latency numbers every programmer should know http://norvig.com/21-days.html#answers [4] Poletto, M., Sarkar, V.: Linear Scan Register Allocation [5] Intel i7-2630qm specification: http://ark.intel.com/products/52219/ Intel-Core-i7-2630QM-Processor-6M-Cache-up-to-2_90-GHz [6] Importance of the Instruction Cache http://1-800-magic.blogspot.hu/2007/12/ memory-is-not-free-more-on-vista.html 12
[7] Carruth, C.: Efficiency with Algorithms, Performance with Data Structures, https://www.youtube.com/watch?v=fhnmrkzxhws [8] Is Parallel Programming Hard, And, If So, What Can You Do About It?, https://www.kernel.org/pub/linux/kernel/people/paulmck/ perfbook/perfbook.html [9] Drepper, U.: What every programmer should know about memory, http://www.akkadia.org/drepper/cpumemory.pdf [10] Meyers, S.: CPU Caches and Why You Care, http://www.aristeia.com/talknotes/pdxcodecamp2010.pdf [11] Sutter, H.: Eliminate False sharing http://www.drdobbs.com/parallel/eliminate-false-sharing/217500206 13