Hatékony CPU kód írásához hasznos architekturális háttér Valasek Gábor
Mentegetőzések Hatékony CPU kód írásához hasznos architekturális háttér, hogy miért is lassú a kódom Valasek Gábor
Műveletvégzés 4 + 9 13
Műveletvégzés 4 + 9 13
Műveletvégzés 4 + 9 13
Műveletvégzés Intel szintaxis: mnemonic src, dst 4 + 9 13
Regiszterek http://www.cs.virginia.edu/~evans/cs216/guides/x86.html
Utasítások Három fő kategória: adatmozgatás: mov, push, pop stb. aritmetikai/logikai műveletek: add, sub, imul, mul, div, and, xor stb. control flow: jmp, je, jne, call, ret stb.
Műveletvégzés 4 + 9 13
Műveletvégzés 4 + 9 13
Műveletvégzés 4 + 9 13
Tartalom 1. 2. 3. 4. Pipeline-ing, szuperskalár architektúra A programunk a memóriában Cache-ek: I cache és D cache Néhány probléma (stalling, branch prediction, load-hit-store)
Tartalom 1. 2. 3. 4. Pipeline-ing, szuperskalár architektúra A programunk a memóriában Cache-ek: I cache és D cache Néhány probléma (stalling, branch prediction, load-hit-store)
Pipeline architektúrák
Pipeline architektúrák Két fontos statisztikájuk van: Latency: egy utasítás végrehajtásához szükséges teljes idő. Bandwidth vagy throughput: egységnyi idő alatt hány utasítást tud feldolgozni a rendszer. Ez a leglassabb komponenstől függ.
Szuperskalár architektúrák
Szuperskalár architektúrák
Szuperskalár architektúrák A pipeline egyes fázisait megvalósító egységeknek több, redundáns példánya is van Ezáltal egyszerre több utasítás is végrehajtható párhuzamosan Például az Intel processzorok pipeline-os és szuperskalár architektúrák Érdeklődőknek további anyagok: http://www.lighterra.com/papers/modernmicroprocessors/
Very Long Instruction Word
Hatékony kód régen és most Amíg az órajelek relatíve alacsonyak voltak, megérte minimalizálni a számítások számát Ez ma már megfordult: a mantra az, hogy inkább végezz több munkát a ALU-kkal és csak akkor nyúlj a memóriához, ha feltétlenül szükséges Ennek oka az elérési idők módosulása (forrás: https://www.theregister.co.uk/2016/04/21/storage_approaches_memory_spee d_with_xpoint_and_storageclass_memory/ )
Elérési idők
Utasítások költsége
Irodalom Egy nagyon hasznos bejegyzés erről a témáról: http://ithare.com/infographics-operation-costs-in-cpu-clock-cycles/
Kitérő Miért olyan drága az egésszel számokkal való osztás? Mindig ugyanolyan drága? Mi a helyzet a lebegőpontos számokkal? Milyen algoritmusokat használnak a szokásos művetek megvalósítására CPU-ban? Miket használnak a kevésbé szokásos műveletekre (sin, cos stb.)? Sok válasz megtalálható az ilyen jellegű kérdésekre Jean Michel Muller könyveiben: https://www.amazon.com/handbook-floating-point-arithmetic-jean-michel-muller/dp/0817647 04X https://www.amazon.com/elementary-functions-implementation-jean-michel-muller/dp/14899 79816/ref=sr_1_2?s=books&ie=UTF8&qid=1537995137&sr=1-2
Tartalom 1. 2. 3. 4. Pipeline-ing, szuperskalár architektúra A programunk a memóriában Cache-ek: I cache és D cache Néhány probléma (stalling, branch prediction, load-hit-store)
Program a memóriában A végrehajtható fájl lehet egy.exe (Windows) vagy.elf (executable and linking format - Unix) A lefordított és összelinkelt végrehajtható fájl tartalmazza a program executable image-ét Ez egy részleges képe annak, ahogyan a program memóriában ki fog nézni a dinamikus memóriafoglalásokat értelemszerűen nem tartalmazza Az image 4 szegmensből áll
Execution image Code/text segment Data segment BSS segment Read-only data segment
Execution image Végrehajtható gépikód A kódban inicializált globális illetve statikus változók Inicializálatlan globális és statikus változók Csak olvasható adatok Az értékük a specifikáció szerint adott (=0), de csak a program belépési pontjának meghívása előtt nullázódik ki. Egyes esetekben (ún. manifest constant-oknál) a kódba fordul bele a konstans értéke. Ilyen pl. az int konstans.
Execution image A BSS megnevezés történelmi hagyaték: a block started by symbol rövidítése A manifest konstansok trükkösek: mivel a compiler az értéküket belehelyettesíti a kódba, ezért az ő tárolásuk valójában a kódszegmensben történik
Endian Az egy bájtnál nagyobb foglalású változók esetén jön képbe Kétféle verziója van: Little endian: a kisebb helyiértéket reprezentáló bájtok a memória elejéhez vannak közelebb Big endian: a legnagyobb helyiérték bájtja van a kisebb memóriacímen Fontos, hogy min fejlesztünk és mire fejlesztünk: Intel processzorok: little endian-ok Wii, Xbox 360, PlayStation 3 (vagyis PowerPC hajtotta konzolok): big endian-ok
Program végrehajtása A belépési pont elindításával kezdődik (pl. main() ) A futtatás megkezdésekor az OS lefoglal egy memóriaterületet az alkalmazásnak, amit program stack-nek hívnak Minden egyes függvényhíváskor erre a stack-re push-olnak egy összefüggő (=folytonos) memóriafoglalást, amit stack frame-nek hívnak
Stack frame Háromféle adatot tárolunk itt: A függvényünket hívó függvény memóriacímét, hogy a visszatérésünk után folytatódhasson a program futtatása A CPU regisztereinek értékeit a hívás pillanatában. Visszatéréskor ezeket az értékeket visszaírjuk a regiszterekbe. A függvény visszatérési értéke viszont általában egy speciális regiszterbe kerül, amit értelemszerűen nem állítunk vissza A függvény lokális változói is itt kerülnek foglalásra. (Meg néha regiszterekben)
Példa void c() { U32 localc1;... } F32 b() { F32 localb1; I32 localb2;... c(); return localb1; } void a() { U32 alocalsa1[5];... F32 locala2 = b();... }
Változók helye a memóriában A globális és a statikus változók a futtatható fájlban vannak A lokális változók a stack-re kerülnek A dinamikus változók azonban a heap-re A probléma ezzel az, hogy az OS-től függ a foglalás Ezért váratlanul sokat állhat a programunk, amíg a new vissza nem tér (...legalábbis 4 new a 6-ból (<C++17)/8-ból(>=C++17)..)
Objektumok elhelyezése a memóriában struct Foo { U32 munsignedvalue; F32 mfloatvalue; I32 msignedvalue; };
Objektumok elhelyezése a memóriában struct InefficientPacking { U32 mu1; // 32 bits F32 mf2; // 32 bits U8 mb3; // 8 bits I32 mi4; // 32 bits bool mb5; // 8 bits char mp6; // 32 bits };
Objektumok elhelyezése a memóriában struct InefficientPacking { U32 mu1; // 32 bits F32 mf2; // 32 bits U8 mb3; // 8 bits I32 mi4; // 32 bits bool mb5; // 8 bits char mp6; // 32 bits };
Objektumok elhelyezése a memóriában Alignment: az objektum címe a memóriában az alignment méretének egy egész számú többszöröse kell, hogy legyen.
Objektumok elhelyezése a memóriában Alignment: az objektum címe a memóriában az alignment méretének egy egész számú többszöröse kell, hogy legyen.
Alignment Az alignment mérete adattípustól függ például 32 bites típusok általában 4 bájtos rácsra illesztendők feltehetjük rule of thumb-ként, hogy a típus mérete határozza meg, hogy milyen rácsra kell illeszteni az objektum kezdetét A objektumok/struktúrák adattagjaira ezek külön-külön is érvényesek +array context padding: azaz ha a struktúra példányaiból van egy tömbünk, akkor az i-edik elem utolsó adattagja után a memóriában annyival jön az i+1-edik elem első adattagja, hogy a méretének megfelelő alignment-en legyen
Tartalom 1. 2. 3. 4. Pipeline-ing, szuperskalár architektúra A programunk a memóriában Cache-ek: I cache és D cache Néhány probléma (stalling, branch prediction, load-hit-store)
Cache A CPU által írható és olvasható memóriadarab, aminek kialakításánál a cél a minél kisebb késleltetés Ezt kétféleképpen érik el: A lehető leggyorsabb memóriatechnológiát használják hozzájuk A cache-ek fizikailag is közelebb vannak a CPU-hoz A cache lényegében a globális memóriában lévő változóknak egy (CPU szempontjából) lokális másolata Méghozzá azoké, amiket gyakran lekérdez a program Így ha épp cache-ben van, akkor nem kell elzarándokolni a RAM-ig (ez a cache hit) Ha nem volt a cache-ben (cache miss), elmegyünk a RAM-ig, visszahozzuk az adatot de egyúttal beírjuk a cache-be is (hátha legközelebb is kell)
Cache line Amikor egy új memóriádarabot érünk el cache miss miatt, nem csak az általunk kért adatok jönnek vissza, hanem egy cache line-nyi darab i7-es archiektúrákon a L1, L2, L3 cache line-ok 64 byte-osak Így ha a programunk következő utasítása az előzőleg lekért memória utáni memóriát olvasná, akkor garantált () cache hit-ünk van A cache-ek asszociatív memóriák: tudják, hogy melyik RAM-beli valódi memóriaterület van bennük (ehhez használják a translation lookaside buffer-t, azaz a TLB-t)
Cache
Cache - írási policy Amikor a programunk egy változó értékét módosítja, akkor azt vissza kell juttatni a memóriába is Ez a CPU architektúra write policy-jétől függ, hogy miképp történik Az írások bekerülnek a cache-be, aztán A write-through cache-ek az írásokat rögtön továbbítják a memóriába is A write-back policy-k pedig csak bizonyos esetekben (pl. cache miss) írnak vissza
Többszintű cache Probléma, hogy kisméretű cache A nagyméretű viszont Nagyon gyors De sok cache miss-t generál Fizikailag nem tud olyan közel kerülni a CPU-hoz De cserébe több cache hit lesz Ezért csinálnak többszintű cache-eket, egyre nagyobb méretben
Cache-ek többmagos környezetben
Cache-ek többmagos környezetben Többmagos környezetben előjön a cache konzisztencia problémája Vagyis hogy minden mag lokális cache-e a fizikai memóriában található valódi értéket tükrözze Két elterjedt protokoll erre a MESI (modified, exclusive, shared, invalid) és a MOESI (modified, owned, exclusive, shared, invalid): https://en.wikipedia.org/wiki/moesi_protocol
A kétféle cache Instruction cache: a programunk kódját cache-eli. Elágazásokkal ezt tudjuk tönkrevágni, ezért van szükség branch prediction-re stb. Data cache: a programunk adatait cache-eli A fenti kettő független egymástól
D-cache-re optimalizálás Az adatainkat a memóriában folytonosan tároljuk, a lehető legkisebb méretben és szekvenciálisan dolgozzuk fel őket
I-cache-re optimalizálás A teljesítmény-kritikus ciklusok a lehető legrövidebbek legyenek kódméret szempontjából Ne hívjunk belőlük függvényeket Ha mégis kell és kicsi a függvény, akkor inline-oljuk Ha nem kicsi és csak igazi függvényhívással érhető el, ekkor érjük el, hogy a memóriában a függvény gépi kódja a ciklushoz a lehető legközelebb legyen
I-cache-re optimalizálás Az utóbbi nagyon nehéz, mert a linker és a compiler dönti el, hogy fizikailag hová kerül az executable image-ben a függvény törzse Viszont van néhány rule-of-thumb amit általában be szoktak tartani: Egy függvény kódja a memóriában szinte mindig folytonos, azaz a linker nem szúr bele más kódot a függvénybe (kivéve ha az a más kód egy inline-olt függvény hívása) A függvények a fordítási egységük forráskódjában (.cpp) található sorrendjüknek megfelelően kerülnek a memóriába Vagyis egyetlen fordítási egység függvényei a memóriában folytonosan helyezkednek el (általában)
Tartalom 1. 2. 3. 4. Pipeline-ing, szuperskalár architektúra A programunk a memóriában Cache-ek: I cache és D cache Néhány probléma (stalling, branch prediction, load-hit-store)
Stalling Előfordulhat, hogy egy utasítás végrehajtása csak akkor kezdődhet meg, hogy ha egy előtte lévő utasítás teljesen végigment a pipeline-on Ekkor a függő utasítás beakasztja a pipeline-t - ez a stalling
Stalling - adatfüggőség miatt
Stalling A fenti példában az add utasításnak be kellett várni a mul befejeződését Az ilyenek elkerülése érdekében a compilerek megpróbálják automatikusan átrendezni az utasításaink sorrendjét, hogy a függés miatt várakozó utasítás helyett független utasítások futhassanak
Branch prediction Amikor a végrehajtás elágazáshoz ér, akkor a pipeline-nak döntenie kell, hogy a then vagy az else ág kódját kezdi végrehajtani Ha rosszul tippelt, akkor a pipeline-t flush-olni kell (az eddigi műveleteket érvényteleníteni) és újratölteni a helyes elágazási irány kódjával A legegyszerűbb (statikus) stratégia a CPU részéről hogy a backward branch-et tekinti valószínűbbnek (azaz az elágazás azon ágát, aminek a gépi kód memóriacíme kisebb, mint az aktuális cím - ilyen például az az eset, amikor a ciklusunk még folytatja a futását) Következmény: if-nél, többfelé ágazó if-nél, switch-case-nél először a leggyakoribb eseteket rakjátok
Stalling + branching megoldások + O(e) => meltdown
Load-Hit-Store A probléma lényege, hogy egy változóba történő írás után közvetlenül próbáljuk újra olvasni a változó értékét A klasszikus példa: az oris-en stallol: stfs fr3,0(r3) ;Store the float lwz r9,0(r3) ;Read it back into an integer register oris r9,r9,0x8000 ;Force to negative
Load-Hit-Store int CauseLHS( int ptra ) { Nem lehetett ptra értékét regiszterben/cache-ben tartani, mert nem tudja a compiler, hogy nem módosította-e a ptra területét egy másik pointer a függvényben, ami ugyanoda mutat! int a,b; } int ptrb = ptra; // B and A point to the same direction ptra = 5; // Write data to address prta b = ptrb; // Read that data back again (won't be available for 40/80 cycles) a = b + 10;// Stall! The data b isn't available yet
Load-Hit-Store int slow( int a, int b) { a = 5; b = 7; return a + b;// Stall! The compiler doesn't know whether // a==b, so it has to reload both // before the add }
Irodalom és javasolt néznivalók Jason Gregory: Game Engine Architecture (az ábrák forrása is ez) Agner: Optimization manuals (ingyenes) Kitekintésnek C++ HPC workshop: https://www.youtube.com/watch?v=7xwvlfzrksk&list=pl1tk5lgm7zvqh6rk uropdmdomhb6lzcwl
Polinomok kiértékelése Forrás: http://lolengine.net/blog/2011/9/17/playing-with-the-cpu-pipeline A probléma: hogyan lehet kiértékelni egy polinomot hatékonyan? Most: a sin(x) hatványsoros közelítését, elvágva az x^15-edikenes tagnál
Polinomok kiértékelése static static static static static static static static double double double double double double double double a0 a1 a2 a3 a4 a5 a6 a7 = = = = = = = = +1.0; -1.666666666666580809419428987894207e-1; +8.333333333262716094425037738346873e-3; -1.984126982005911439283646346964929e-4; +2.755731607338689220657382272783309e-6; -2.505185130214293595900283001271652e-8; +1.604729591825977403374012010065495e-10; -7.364589573262279913270651228486670e-13; double sin1(double x) { return a0 x + a1 x x + a2 x x + a3 x x + a4 x x + a5 x x + a6 x x + a7 x x } 64 szorzás + 7 összeadás x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x x;
Polinomok kiértékelése double sin2(double { double ret, y = x, x2 = x ret = a0 y; ret += a1 y; ret += a2 y; ret += a3 y; ret += a4 y; ret += a5 y; ret += a6 y; ret += a7 y; return ret; } x) y y y y y y y x; = = = = = = = x2; x2; x2; x2; x2; x2; x2; 16 szorzás + 7 összeadás
Polinomok kiértékelése double sin3(double x) // Horner { double x2 = x x; return x (a0 + x2 (a1 + x2 (a2 + x2 (a3 + x2 (a4 + x2 (a5 + x2 (a6 + x2 a7))))))); } 9 szorzás + 7 összeadás
Mérések Intel Core i7-2620m CPU at 2.70GHz. The functions were compiled using -O3 -ffast-math: function nanoseconds per call sin 22.518 sin1 16.406 sin2 16.658 sin3 25.276
Mérések Intel Core i7-2620m CPU at 2.70GHz. The functions were compiled using -O3 -ffast-math: function nanoseconds per call sin 22.518 sin1 16.406 sin2 16.658 sin3 25.276
Polinomok kiértékelése double sin3(double x) // Horner { double x2 = x x; return x (a0 + x2 (a1 + x2 (a2 + x2 (a3 + x2 (a4 + x2 (a5 + x2 (a6 + x2 a7))))))); }
Polinomok kiértékelése double sin2(double { double ret, y = x, x2 = x ret = a0 y; ret += a1 y; ret += a2 y; ret += a3 y; ret += a4 y; ret += a5 y; ret += a6 y; ret += a7 y; return ret; } x) y y y y y y y x; = = = = = = = x2; x2; x2; x2; x2; x2; x2;
Polinomok kiértékelése Kézzel optimalizálni: function sin sin1 sin2 sin3 sin4 sin5 sin6 sin7 nanoseconds per call 22.518 16.406 16.658 25.276 18.666 18.582 16.366 17.470 Persze nem árt a compiler flag-ekkel is kicsit foglalkozni (csak -O3): function nanoseconds per call sin 22.497 sin1 30.250 sin2 19.865 sin3 25.279 sin4 18.587 De akár használhatsz irodalmat is: Estrin séma sin5 18.958 sin6 16.362 sin7 15.891