A kód olvashatósága és szépsége mellett fontos, hogy gyorsan is működjön. Természetesen a „gyors” egy relatív fogalom, ebből adódóan nem is mérhető. Éppen ezért amikor optimalizációról beszélünk, első körben érdemes számszerűsíteni, hogy az optimalizálni kívánt algoritmus, kódrészlet mennyire lassú. A számszerűsítés módjában és komplexitásában van bőven eltérés. Ha a kódrészletünk percek alatt fut le, akkor valószínűleg másodperc pontosság bőven jó lesz és akár egy stopperrel is mérhetünk, de ha milliszekundumos futási időről beszélünk, akkor bizony az egyszerű stoppernél kifinomultabb eszközökre lesz szükségünk. Egy kifinomultabb eszköz a reprodukálhatóság szempontjából is fontos.
Ha megvan az alap mérésünk, akkor van egy stabil viszonyítási alapunk, amihez hasonlítani tudjuk a változtatásainkat. Csak ezt követően érdemes belevágni egyáltalán az optimalizációba.
Optimalizáció kapcsán alapvetően két lehetőségünk van: vagy több erőforrás kihasználását építjük a programunkba, vagy átdolgozzuk a használt algoritmusokat. A több erőforrás kihasználása tipikusan az első gondolat, ami az ember eszébe jut, ha optimalizációról beszélünk. Ebbe a kategóriába tartoznak olyan technikák, mint:
- Több szálon futtatás
- SMID utasítások használata
- Memória elrendezés optimalizálás
- GPU bevonása
A több szálon futtatás elsőre jól hangzik , de vannak problémák, amikor nem éri meg ezt alkalmazni. Tegyük fel, hogy ha van egy számításunk, amiben c = a - b, a végeredmény pedig d = c + g és a műveletek elvégzése nem ugyanannyi ideig tart, akkor nem lehet hatékonyan párhuzamosítani, mert az egyik számítás függ a másik eredményétől. Ilyen esetekben a szálak bevezetése csak ront a sebességen, mivel egy szál elindításának is van időköltsége. Ez a hozzáadott időköltség pedig rontani fog az eredményen. Persze vannak olyan problémák, amikor megéri több szálon futtatni a számítást. Például akkor, amikor ugyanazt a számítást kell elvégezni sokszor, egymástól független módon.
Ezen számítások egy speciális részhalmaza azon problémák, amik képfeldolgozásra vagy neurális hálózat szimulációra irányulnak. Ezen problémák megoldásában sokkal jobb és gyorsabb, ha a számítás a dedikált grafikus egységen történik. Ennek a bevonása C# esetén nem triviális, mivel a videokártyák tipikusan egy speciális API-n keresztül programozhatóak. Ilyen API az OpenCL, ami minden operációs rendszeren és videokártyán működik, a Microsoft Direct Compute, ami a DirectX része és Windowson működik, illetve még ott van az Nvidia CUDA platformja, ami a saját videokártyáikon működik. Ezek az API-k tipikusan C és C++ támogatással rendelkeznek, ami végső soron azt jelenti, hogy a C# kódunknak majd egy ponton át kell hívnia egy natív C/C++ könyvtárba, ami elvégzi ezt a számítást.
Az SMID a "Single instruction, multiple data" kifejezés rövidítése és olyan CPU utasításkészletek gyűjtőneve, amik egy utasításban több adattagból álló művelet elvégzésére adnak lehetőséget. Ilyen utasítás lehet például két 3 dimenziós vektor összeadása. Ez a számítás SMID nélkül 3db összeadással valósítható meg, ami valószínűleg több időt fog igénybe venni, mint az egy speciális utasítás végrehajtása. Az SMID utasításkészletek megjelenése nem mai dolog. A PC-k esetén a Pentium I esetén mutatkozott be az MMX kiegészítés, amit kifejezetten multimédiára és 3D gyorsításra szántak. Az évek múlásával egyre több és több ilyen utasítás került bevezetésre a CPU-ba.
Ezen utasítások kihasználása azonban nem egy triviális feladat a fordító és a JIT számára. Természetesen folyamatosan fejlesztik őket, de automatikusan nem mindig tudja kitalálni a fordító, hogy melyik speciális utasítást is lenne célszerű használni. Éppen ezért ezek igazán jó kihasználása kézzel írott kódban lehetséges, amihez ismerni kell ezen utasítások adottságait és működését. Mivel itt közvetlenül a CPU számára adunk utasításokat, ezek kihasználása C# esetén a .NET Core 3 előtt nem volt igazán lehetséges. Azonban az újabb .NET verziók esetén C#-ból is ki tudjuk ezek előnyét használni, illetve vannak speciális vektor típusok is, amik segítségével hardveres gyorsítás segítségével végezhetünk számításokat különböző vektorokon.
A memória elrendezés optimalizálása szintén egy nagyon hasznos technika tud lenni. A processzorok sebessége az elmúlt 20 év során átlagosan minden második évben szinte duplázódott. Ugyanez azonban a memória sebességére nem mondható el. Ennek oka abban keresendő, hogy a processzor és a memória gyártás tekintetében nagyon eltérő. Tipikusan a memória 10x lassabb, mint a processzor. Ezt úgy próbálják a gyártók áthidalni, hogy egyre több cache memóriát integrálnak a processzorokba.
A cache memória más módszerekkel készül, mint a RAM memóriának alkalmazott DRAM. Cache esetén SRAM típusú memóriákat használnak, amelyek ugyan gyorsak, de GiB kapacitásban megbízhatóan nem gyárthatóak és drágák1. A memória optimalizáció lényegében azt takarja, hogy a programunk memória elrendezését úgy alakítjuk ki, hogy effektíven sok minden elférjen belőle a CPU cache-ben és ne kelljen a memóriához nyúlnia a processzornak. Ez a legtöbb esetben azt jelenti, hogy próbáljunk meg szekvenciális kollekciókat alkalmazni, ha lehetőségünk van rá, illetve csökkentsük a felesleges memória allokációk számát.
Ezen technikák nagyon hasznosak tudnak lenni, de a legjobb megoldás, ha a megoldandó problémához szükséges számítások számát tudjuk csökkenteni, például az algoritmusunk újragondolásával. Ez nem egy triviális feladat. Éppen ezért fontos, hogy mielőtt ezt vagy bármelyik másik optimalizációs megoldást választjuk, legyen a kód letesztelve helyesség szempontjából, mivel hiába gyors a kód, ha a működése nem helyes. Egy algoritmus újragondolása akkor lehetséges, ha a lehető legtöbbet tudunk a problémáról. Például ha sokszor kell valamit keresni, akkor lehet, hogy jobb egy HashSet-et alkalmazni, mint mondjuk egy tömbben vagy listában bináris vagy lineáris keresni. Mivel ez nem minden esetben egyszerű feladat, egy másik optimalizáció lehet például, hogy a követelményeket csökkentjük: egy keresés esetén egyszerre nem 1000db terméket jelenítünk meg a felhasználónak, hanem csak 50-et, vagy 25-öt.
Elképzelhető, hogy az optimalizáció során találunk egy javítható pontot, ami drámaian csökkenti a futási időt, de ne számítsunk arra, hogy egyetlen egy optimalizáció elég lesz az elérni kívánt gyorsuláshoz. Ezek általában több, kisebb-nagyobb megoldásból fognak összeállni. Ezért is fontos többek között, hogy legyen egy megbízható, reprodukálható mérésünk, amivel az eredményeket össze lehet hasonlítani. Érdemes azt is megjegyezni, hogy egy adott kód gyors futása és a memória felhasználása általában fordítottan arányosak. A kódunkat például gyorsíthatjuk az által, hogy a számítások eredményét gyorsítótárazzuk, ami a memória fogyasztást fogja növelni.
Problémás részek megtalálása
A problémás részek megtalálásának legjobb módja a mérés és a mérés. .NET esetén az egyes algoritmusaink mérésének legjobb eszköze a Benchmark.NET, ami tud memóriát és futási időt mérni byte és nanoszekundum pontossággal. Ezeknek a teszteknek az elkészítése azonban időt vesz igénybe és nem biztos, hogy mindent egyformán kell vele tesztelni. Egy meglévő alkalmazás esetén azonban a problémás részek megtalálása olyan is lehet, mintha tűt keresnénk a szénakazalban. Annyi könnyítésünk azonban van, hogy a tudásunk sokat segíthet és elképzelhető, hogy csak a kódot olvasva találunk olyan részt, aminek az átírása segít valamit, de közel sem biztos, hogy az észrevett hiba fogja okozni a várt gyorsulást.
Szerencsére vannak eszközeink a problémagócpontok megtalálására. A Visual Studio is rendelkezik egy ilyennel, a neve Performance Profiler és a Debug menü alatt található, illetve .NET esetén még kifejezetten jók a JetBrains dotTrace és dotMemory eszközei. Bármelyik eszközt is használjuk, azt érdemes tudni, hogy sebességet csak és kizárólag release konfigurációban mérünk.
Ennek az oka az, hogy a debug konfiguráció arra lett kitalálva, hogy könnyen hibakereshető legyen, ami általában azzal jár, hogy a kódot a fordító egyáltalán nem, vagy csak minimálisan optimalizálja, illetve egyes esetekben (pl. több szál futtatása) másképp is viselkedik a kódunk. Ha ilyen konfiguráción végzünk mérést és diagnosztikát, az a legjobb esetben csak pontatlan lesz, rosszabb esetben azonban fals eredményeket produkál és teljesen el is fedhet hibákat.
Optimalizáció olcsón
A kódunk sebességén és memória használatán drámaian tud változtatni az, hogy a keretrendszer legújabb változatát használjuk. A .NET esetén jó pár kiadás óta húzó téma a sebesség kérdése. Minden .NET kiadás újabb és újabb optimalizációkat hoz magával, mind a beépítetten kínált metódusok, osztályok algoritmusaiban, mind a kód generálására használt JIT-ben.
Éppen ezért érdemes a legújabb .NET változatot célozni a kódunkkal, ha lehetőségünk van rá, mert elképzelhető, hogy a kód módosítása nélkül gyorsulhat, vagy kevesebb memóriát fogyaszthat. Ezek az ugrások kevésbé látványosak, ha naprakészen tartjuk a célzott verziót. Azonban ha az alkalmazásunk még mindig .NET Framework-öt céloz, akkor az, hogy .NET 6-ra vagy 7-re áttesszük, drámai változást hozhat relatíve kis idő befektetésével.
-
A DRAM típusú memóriákhoz viszonyítva.↩