Zaciatkom roka som si rozchodil nejake zakladne benchmarky a porovnal skore CMRXu a FreeRTOS. CMRX ma zatial vykon niekde na urovni 30 - 60% toho, co vie FreeRTOS. V niektorych testoch ale celkom prekvapivo uz prekonava Zephyr. To je celkom haluz, lebo su to prave tie testy, ktorych skore zavisi na casti kernelu ktora je uplne najtupejsia a vobec nebola optimalizovana (o tom nizsie).
Chcel som vysledky z benchmarkov vykreslit do grafu, aby som videl, ako sa menia v case vzhladom k tomu ako upravujem kernel. Nieco ako ked Phoronix zbehne sadu testov na roznych Linuxovych kerneloch a je vidno, ako sa vykon meni v case. A hej, trocha som sa na ten benchmarking namotal a optimalizovat ma bavi.
Bol s tym ale problem. Niektore testy davaju vysledky v statisicoch, ine v desiatkach alebo stovkach milionov. Ak to vykreslim do jedneho grafu, tie prve budu nerozoznatelne od nuly:
Taky graf moc uzitocny nie je. Mohol by som nahodit logaritmicku skalu na ose Y, to by ale zdeformovalo krivky. Tak som rozmyslal nad inou reprezentaciou dat.
Benchmarky, ktore spustam su benchmarky typu Dhrystone a pod. Procesor robi dookola nejaku monotonnu zataz a zaznamenava sa pocet iteracii, ktore sa stihnu spravit za fixny cas.
Tieto benchmarky su podstatne primitivnejsie nez Dhrystone a su zamerane na to, aby sa zistilo aky je zakladny overhead sluzieb kernelu. Napriklad ako dlho trva systemove volanie, ktore nic nerobi. Alebo ako dlho trva zamknut a odomknut mutex. Skore v benchmarku je teda pocet iteracii za fixny cas.
Mozem teda skore podelit casom a ziskam tak pocet iteracii za sekundu. To ale problem neriesi, pretoze dynamika dat ostane rovnaka. Ja ale viem aj frekvenciu procesora, teda aj pocet taktov za sekundu. Ak pocet taktov podelim poctom iteracii, dostanem "IPC" jedneho opakovania testu - pocet cyklov procesora, ktora jedna iteracia testu trvala. A tu je dynamika podstatne mensia, resp. sa necitatelne stanu tie najnezaujimavejsie data:
V tomto formate uz je mozne mat v jednom grafe data zo vsetkych merani. Zaujimavejsie ale je, ze tu vidno ako dlho trvaju jednotlive operacie.
Zacnime bordovou ciarou uplne na spodku. Ta ukazuje 9.06 taktu na iteraciu a test sa vola "Calibration". Tento test nerobi nic, iba inkrementuje skore. Jeho povodny ucel je zakladne porovnanie toho, ze test je naintegrovany spravne a vsetko funguje +- rovnako napriec operacnymi systemami. Tu sa ale da zistit ze minimalny test, ktory robi toto:
- nacita z pamate premennu s flagom ci test este bezi
- skontroluje flag, ci sa nema ukoncit
- inkrementuje pocitadlo skore
- skoci na zaciatok cyklu
Trva 9 taktov. Vysledok nie je cele cislo, pretoze skore v testoch ovplyvnuje rezia kernelu (o tom tie testy su). Pokial kernel nie je tick-less, tak sa pravidelne spusta obsluha hodin - v tomto pripade 1000x za sekundu, aby skontrolovala ci nie je potrebne prepnut thread, alebo obsluzit nejaky casovac. To pridalo tych 6 stotin k dlzke cyklu navyse.
Zaujimavejsi je napriklad taky prazdny syscall (fialova ciara "System Call Overhead"), ktory je v pripade toho testu volanie get_tid() - read-only syscall, ktory vrati cislo aktualne beziaceho threadu.
Z grafu vyplyva, ze taky syscall trva cca 122 taktov CPU. Systemove volanie v tomto pripade zahrna prechod z userspacu do kernelu cez instrukciu SVC. To automaticky ulozi a opatovne nacita registre. To
podla ARMu trva nejakych 12 + 12 taktov. Zvysnych 100 taktov pripada na samotnu obsluhu systemoveho volania. Kedze procesor je ARM, je celkom bezpecne predpokladat, ze 1 takt ~ 1 instrukcia.
Dalej, jeden cyklus synchronizacie (zlta ciara "Synchronization"), co je zamknutie a odomknutie mutexu trva 480 taktov. Jeden taky cyklus spravi dve systemove volania. O tych vieme, ze dokopy stoja
minimalne 244 taktov. Takze na samotnu logiku zamykania mutexov ostava 236 taktov (z toho 6 zozerie zakladna rezia testu). Cast z nej je v systemovom volani a cast z nich sa spotrebuje v userspace.
Odoslanie signalu trva 180 taktov (ruzova ciara "Signal Processing"). Odoslanie signalu je tiez syscall. Z toho vyplyva ze cca 120 zo 180 taktov je zakladny overhead systemoveho volania. Odoslanie signalu teda trva dalsich 60 taktov.
To je zaujimava informacia vo vztahu k testu, ktory zistuje vykon preemptivneho planovania threadov (oranzova ciara "Preemptive Scheduling"). Tento test robi v kazdej iteracii odoslanie dvoch signalov. Jeden inemu threadu, ktorym ho zobudza. Druhy posiela sam sebe, ktorym sa zastavuje. Jedno taketo prepnutie threadu trva ~1896 taktov. Z toho 2x 180 = 360 taktov pripada na vrub signalom.
Zvysnych 1530 taktov je overhead kerneloveho planovaca. To je celkom dost ale nie je to prekvapenie. Planovac je jedna z casti kernelu, ktora nebola vobec optimalizovana. Pri kazdom prepnuti threadu sa prehladava
cela tabulka threadov, aby sa nasiel dalsi vhodny thread na beh. To je extremne zly a pomaly dizajn.
Trocha zahada je, preco preemptivne prepinanie threadov je skoro 2x pomalsie ako kooperativne. V oboch pripadoch sa vola planovac a v oboch pripadoch robi +- to iste. Meni sa krovie okolo, ale nie dost na to, aby vysvetlilo rozdiel 1000 taktov CPU. Bude zaujimave zistit z akych akcii sa tie testy skladaju. Mozno objavim, ze v jednom z pripadov sa planovac vola viackrat. To bude vela vykonu skoro zadarmo. A fixnuty bug ako bonus :)
Tymto sposobom sa da kernel rozobrat na jednotlive stavebne bloky a zistit, ktora cast je pomala, ktora je rychla a kde sa najviac oplati optimalizovat tak, aby to malo celosystemovy dopad.
Zaroven sa ukazuje, aky velky dopad na vykon ma v tomto meritku usetrenie par instrukcii. Ak sa napriklad systemove volanie zrychli o 10 instrukcii, je to takmer 10% zrychlenie. Zaroven to ale znamena, ze sa usetri 20 instrukcii pri spracovani mutexu - 5% zrychlenie.
Na tie data sa ale da pozriet aj inac. V predoslej verzii trval jeden cyklus zaknutia a odomknutia mutexu nieco malo cez 1000 cyklov. Teraz trva 480 cyklov, To je vyse 2x zrychlenie. Jedna z ciest ako sa toho dosiahlo bolo to, ze som sa pozrel na to, co sa tam vlastne robi.
CMRXove mutexy su v skutocnosti futexy (podobne ako v Linuxe). To znamena ze vacsina ich processingu bezi v userspace a kernel sa vola len ked sa neda inac. Povodna nizkourovnova implementacia mutexov podporovala rekurzivne mutexy, ale verejne API tuto funkciu nespristupnovalo. Ta robota sa ale vzdy vykonavala a to stoji takty CPU.
Zaroven mutexy robili kontrolu, ci nahodou mutex neodomyka niekto iny nez ho zamkol. To je uzitocna funkcia do debug buildu, ale plytva to vykonom v produkcnom prostredi. Zaroven na to, aby sa taka kontrola dala spravit, bolo potrebne urobit jedno dalsie systemove volanie. To je minimalne 122 taktov. Bezne sa odomknutie mutexu niekym inym nez kto ho zamkol povazuje za undefined behavior a neriesi sa to, lebo je to hruba programatorska chyba.
Odstranil som teda logiku na podporu rekurzivneho zamykania a odomykania a kontrolu na to kto mutex zamkol. Zaroven som odstranil aj jedno systemove volanie. Vysledkom bolo skoro 2x zrychlenie kodu.
To ukazuje, ze nie vzdy ma zmysel optimalizovat kroky, ktore v kode su. Obcas sa staci pozriet na to, ake kroky tam su. Niektore z nich su mozno nadbytocne.
Tak si tak pocitam cykly a snazim sa dobehnut, alebo predbehnut FreeRTOS. Aby som mohol tvrdit, ze mikrokernely zdaleka nie su tak pomale, ako si ludia myslia.
Shitty life is like radiation. You can sustain it for long time if daily doses are small.