A jitter és az inlining

Az előző cikkben az egyik mintakódban láthattuk, hogy a jitter milyen szépen inline-olta a Console.WriteLine() metódust. Kíváncsi lettem hát, hogy mi a stratégiája. Azt korábbról tudtam, hogy az egyszerű property getter/settereket binline-olja, de vajon mitől függ, hogy mivel teszi meg, és mivel nem?

Itt van például a következő kód:

static int Calc(int a, int b)
{
    int result = 0;

    if (a < b)
    {
        result += a * b;
    }
    else
    {
        result += a + b;
    }

    return result;
}

static void Main(string[] args)
{
    int result = Calc(3, 8); 
    Console.WriteLine(result);
}

Vajon a Calc() metódushívás be lesz inline-olva? Nézzük mit alkotott a jitter:

// Verem állapot elmentése
00000000  push        ebp 
00000001  mov         ebp,esp 

// Valamiért az esi fontos a hívónak,
// nekünk most ez nem számít
00000003  push        esi 

// Ez csak a debugger behúzása
00000004  call        65A67D40

// Hopp, és itt az inline-olt Calc().
// De nem csak inline-olt, hanem szélsőségesen optimalizált!
// Nézzük:
// Egy változó nullázása, alighanem
// result = 0 
00000009  xor         esi,esi 

// Ha esi regiszter a result változó, akkor itt ahhoz ad hozzá 0x18-at, azaz 24-et,
// result += 24
0000000b  add         esi,18h 

// Ez itt már a Console.WriteLine, ugyancsak inline-olva
0000000e  call        65457130 
00000013  mov         ecx,eax 
00000015  mov         edx,esi 
00000017  mov         eax,dword ptr [ecx] 
00000019  mov         eax,dword ptr [eax+38h] 
0000001c  call        dword ptr [eax+14h] 

// Vissza a hívóhoz
0000001f  pop         esi 
00000020  pop         ebp 
00000021  ret 

Mit látunk? Nem hogy inline lett a Calc() metódus, hanem jelentős optimalizáláson ment keresztül. A jitter észrevette, hogy a konstans paraméterek miatt a Calc() metódusban található feltételes elágazás mindig egyirányú, így ezt kitörölte. Azt is észrevette, hogy a konstans paraméterekkel elvégzendő művelet előre kiszámolható, így azt meg is tette. Ez figyelemre méltó teljesítmény! Ezek után kicsit furcsa, hogy miért nullázgat változót a 17-es sorban, majd végzi el az összeadást a kód a 21-ben, amikor egyből beemelhetné a 24-et az esi regiszterbe.

Hogy elvegyük az extrém optimalizálás lehetőségét a jittertől, vigyünk bele egy kis véletlent az átadott paraméterekbe:

static void Main(string[] args)
{
    int i = new Random().Next();
    int result = Calc(3 + i, 8 + i); 
}

A generált kód egy kicsit hosszabb volt a Random kezelése miatt, azokat a kódokat kitöröltem:

// Verem állapot elmentése
00000000  push        ebp 
00000001  mov         ebp,esp 

// Valamiért az esi még mindig fontos a hívónak,
// nekünk most ez nem számít
00000003  push        esi 

// Debugger:
00000004  call        65C97D40 

// Itt történt a random példányosítása, konstruktor
// hívása, a Next() virtuális metórus slot-jának
// keresése.
// Az alábbi a Next() hívása:
0000002a  call        dword ptr [eax+14h] 

// A Next() az eredményt az EAX-ba adta vissza, tehát
// az EAX most egy random szám, ez került a C# kódban az
// i változóba.
// Az első paramétere a Calc()-nak az i + 3. Erre a
// kézenfekvő megoldás az lett volna, ha a jitter 
// előbb ecx-be tölti az eax-ot, majd egy add utasítással
// elvégzi az összeadást. Ehelyett az eredetileg nem arra való
// LEA (load effective address) utasítást használja, mivel az
// a mindenféle támogatott címzési trükk miatt tud aritmetikai
// műveleteket végezni, és azonnal az eredményt egy regiszterben
// tárolni. Így meg lehetett spórolni egy utasítást:
0000002d  lea         ecx,[eax+3] 

// A Calc() második paramétere i + 8. Azt nem tudom, hogy itt
// miért nincs használva a LEA-s trükk.
00000030  add         eax,8 
00000033  mov         edx,eax 

// Hopp, nincs inline!!! Calc() hívása:
00000035  call        dword ptr ds:[00143818h] 

// Eredmény az eax-ben jött vissza, ez már csak az
// inline-olt Console.WriteLine(), meg a visszatérés
0000003b  mov         esi,eax 
0000003d  call        65687130 
00000042  mov         ecx,eax 
00000044  mov         edx,esi 
00000046  mov         eax,dword ptr [ecx] 
00000048  mov         eax,dword ptr [eax+38h] 
0000004b  call        dword ptr [eax+14h] 
0000004e  pop         esi 
0000004f  pop         ebp 
00000050  ret 

Az a meglepő eredmény, hogy valamiért most nem került inline-olásra ugyan az a metódus. Szerencsére sikerült megtalálnom ezt a remek blog bejegyzést, ami megmagyarázza miről van szó.

A jitter egy mérőszámot próbál kalkulálni arról, hogy megéri-e az inline-olás vagy nem. Az első esetben az inline-olandó kódot olyan picire tudta optimalizálni, hogy ott már az inline-olás megérte. A második esetben nem sikerült kidobálni az if-eket, és a jóval nagyobb eredeti kód már nem éri el a küszöböt. A blog azt is megemlíti, hogy a jitter nagyobb hajlandóságot mutat az inline-ra, ha a hívás egy ciklusban van, ekkor ugyanis a metódushívás költsége a többszörös hívás miatt jobban érvényesülhet. Próbáljuk hát ki:

int result = 0; 

for (int i = 0; i < 10; i++)
{
    result += Calc(3 + i, 8 + i);
}
// Verem állapot és fontos regiszterek elmentése
00000000  push        ebp 
00000001  mov         ebp,esp 
00000003  push        edi 
00000004  push        esi 
00000005  push        ebx 

// Debugger behúzása
00000006  call        65A97D40 

// Main() lokális változója: result = 0
0000000b  xor         ebx,ebx 

// for ciklus, inicializálás:
// i = 0
0000000d  xor         esi,esi 

// Calc() paraméterei: i + 3, i + 8
// mivel tényleg megtörtént az inline,
// ezek már a Calc() argumentumait hordozzák.
// a -> eax, b -> edx
0000000f  lea         eax,[esi+3] 
00000012  lea         edx,[esi+8] 

// Calc() lokális változója: result = 0
00000015  xor         edi,edi 

// if (a < b), ha nem ugrás 22-re
00000017  cmp         eax,edx    
00000019  jge         00000022 

// result += a * b
0000001b  imul        edx,eax 
0000001e  add         edi,edx 
00000020  jmp         00000026
 
// else ág, result += a + b
00000022  add         eax,edx 
00000024  mov         edi,eax 

// Main.result += Calc.result
00000026  add         ebx,edi

// for ciklus:
// i++ 
00000028  inc         esi 

// i < 10?
00000029  cmp         esi,0Ah 

// ha nincs vége a ciklusnak, újra
0000002c  jl          0000000F 

// Ez már csak a Console.WriteLine()
0000002e  call        65487130 
00000033  mov         ecx,eax 
00000035  mov         edx,ebx 
00000037  mov         eax,dword ptr [ecx] 
00000039  mov         eax,dword ptr [eax+38h] 
0000003c  call        dword ptr [eax+14h] 
0000003f  pop         ebx 
00000040  pop         esi 
00000041  pop         edi 
00000042  pop         ebp 
00000043  ret 

A ciklus tehát valóban arra sarkalta a jittert, hogy a lineáris lefutással szemben itt már végezze el az inline-t. Az is látszik, hogy a nagyobb kódon a jitter azért nem boldogul olyan jól az optimalizálással. A Calc() metódus Result változójára, amit a gépikódban az EDI regiszter hordoz, nem lenne szükség, és ezt emberként nem olyan nehéz észrevenni.

Konklúzió

A C# nyelv nem rendelkezik a C++ inline kulcsszavával. Ez néhány esetben hátrány lehet, legtöbbször azonban nem az. A mai processzoroknál nagyon nehéz kiszámítani, hogy mitől lesz gyorsabb egy kód, és nem biztos, hogy egy függvényhívás költsége olyan jelentős, mint ahogy azt gondolnánk.

Láthatólag a jitter-be próbáltak egyfajta heurisztikát építeni, amely eldönti, mikor lehet érdemes inline-olni. Biztos találni sok helyzetet, amikor a jitter nem a legjobb döntést hozza. Nincs is ideje túl sokat analizálni, különben az analizáláson több idő megy el, mint amit az optimális kóddal nyerni tud. Átlagos helyzetekben azonban valószínűleg megállja a helyét.

  1. Leave a comment

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: