inline

Czirkos Zoltán · 2021.08.24.

Az inlining optimalizációs technika és használata. Hogyan működik, miért gyorsítja a programunkat, és hogyan kell használni?

Néha nagyon rövid függvényeket írunk – olyan kicsiket, hogy úgy érezzük, fölöslegesek, lassítják a programunkat. Főleg ha a függvényhívással töltött idő (paraméterek átadása, ugrás, visszaugrás stb.) összemérhető a függvény feladatának végrehajtási idejével. Úgy tűnik emiatt, hogy nem lesz hatékony a program. Például ebben a kódban:

struct Teglalap {
    int a, b;
};

double kerulet(Teglalap t) {
    return 2 * (t.a + t.b);
}

double terulet(Teglalap t) {
    return t.a * t.b;
}

Egy területszámítás vagy egy kerületszámítás, azaz egy-két szorzás vagy összeadás miatt függvényt hívunk? Nincs kárunkra ez az absztrakció? Megéri ez így számunkra? Az inlining-nak nevezett optimalizációs technika miatt bőven. Nem kell attól tartanunk, hogy ez lassítja a programot. Egy függvény inline-olása azt jelenti, hogy a függvény törzsét a fordító a lefordított programban beilleszti a hívás helyére, megspórolva ezzel a függvényhívás költségét.

A C programkódokba írt inline kulcsszó is ezzel kapcsolatos. De egy függvény neve elé írt inline kulcsszó nem azt jelenti, hogy az a függvény inline-olva lesz. Akkor hogy is van ez?

1. Az inlining működése

Tekintsük az alábbi kis programocskát:

#include <stdio.h>

int szorzat(int x, int y) {
    return x * y;
}

int main() {
    int a, b;
    scanf("%d %d", &a, &b);
    int s = szorzat(a, b);
    printf("%d", s);
}

Vizsgáljuk meg a lefordított program Assembly kódját! Erre kiválóan alkalmas a Compiler Explorer oldal, ahol egy online felületen tehetjük meg ezt. De parancssorból is könnyen megy, gcc -S hatására fájlba írja a fordító az Assembly kódot.

A két függvényünk így fest – kivágva csak a releváns részeket:

szorzat(int, int):
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], edi
        mov     DWORD PTR [rbp-8], esi
        mov     eax, DWORD PTR [rbp-4]
        imul    eax, DWORD PTR [rbp-8]  ; (2)
        pop     rbp
        ret

main:
        ...
        call    scanf
        mov     edx, DWORD PTR [rbp-12]
        mov     eax, DWORD PTR [rbp-8]
        mov     esi, edx
        mov     edi, eax
        call    szorzat(int, int)       ; (1)
        ...
        call    printf
        ...

A legfontosabb két sort a számok jelölik. Az (1)-essel jelzett helyről ugrunk a főprogramból a szorzat() függvény belsejébe, itt történik meg a függvényhívás (call). Az előtte lévő néhány sor valósítja meg a paraméterátadást. A (2)-essel jelzett hely pedig maga a szorzás: imul, mint multiplication, szorozza össze a megadott számokat. Az összes többi utasítás a függvényhívás adminisztrációja; a ret, azaz return utasítás tér vissza a függvényből.

Lényegében ez az, amit el szeretnénk kerülni. Látszik, hogy mennyi többlet teendő volt ahhoz képest, hogy az érdemi munka a függvényben egyetlen egy gépi utasítást jelent, az imul-t.

Nézzük most meg ugyanezt a kódot gcc -O2 paraméterrel fordítva, tehát optimalizálva! A releváns részek:

szorzat(int, int):
        mov     eax, edi
        imul    eax, esi                    ; (2)
        ret

main:
        ...
        call    scanf
        mov     esi, DWORD PTR [rsp+12]
        mov     edi, OFFSET FLAT:.LC0
        xor     eax, eax
        imul    esi, DWORD PTR [rsp+8]      ; (1)
        call    printf
        ...

Először is, látszik hogy a kód optimalizált. A (2)-essel jelzett helyen továbbra is ott a szorzást végző imul utasítás, de a szorzat függvény sokkal rövidebb lett, a fordító okosabban oldotta meg a paraméterátadást.A főprogramból viszont teljesen eltűnt a call szorzat függvényhívás! Helyette a szorzó utasítás bekerült a főprogramba az (1)-es helyre. A függvény inline-olódott, körülbelül nyolc utasítást megspóroltunk így.

Végezzünk el még egy kísérletet! Tegyünk egy static kulcsszót a szorzat() függvény elé. Mint tudjuk, ez azt jelenti, hogy több forrásfájlból álló program esetén más forrásfájlból nem is szeretnénk elérni ezt a függvényt. Csak ebből az egy szorzat.c fájlból lesz meghívható, a többiben nem is látszik:

static int szorzat(int x, int y) {
    return x * y;
}

Ebben az esetben a teljes lefordított program így néz ki:

.LC0:
        .string "%d %d"
.LC1:
        .string "%d"
main:
        sub     rsp, 24
        mov     edi, OFFSET FLAT:.LC0
        xor     eax, eax
        lea     rdx, [rsp+12]
        lea     rsi, [rsp+8]
        call    scanf
        mov     esi, DWORD PTR [rsp+12]
        mov     edi, OFFSET FLAT:.LC1
        xor     eax, eax
        imul    esi, DWORD PTR [rsp+8]  ; (1)
        call    printf
        xor     eax, eax
        add     rsp, 24
        ret

Ebben az (1)-essel jelölt helyen látjuk a szorzást. A szorzat() függvény pedig teljesen eltűnt (!) a programból. Önálló függvényként már nem is létezik, csak a főprogramba beépítve látjuk azt a műveletet, ami a forráskódban még külön függvényként létezett. A fordító teljesen kihagyhatja a függvényt a lefordított programból! Ahol használtuk, oda beépítette a törzsét. Máshonnan pedig nem hívhatjuk, tehát biztos lehet benne, hogy a lefordított programba fölösleges betenni.

Vegyük észre, hogy ehhez az optimalizációhoz nem volt szükség az inline kulcsszóra! A fordító magától döntött úgy, hogy a függvénytörzset beépíti a főprogramba. Ahhoz viszont, hogy ez megtörténhessen, ismernie kellett a törzset. Látnia kellett, mi van a függvény belsejében. Ha a függvény egy másik forrásfájlban lenne definiálva, és itt csak a fejlécét ismerné, akkor erre nem lett volna lehetősége. Egyszerűen azért, mert nem tudja, mi van benne:

szorzas.h
#ifndef SZORZAS_H_INCLUDED
#define SZORZAS_H_INCLUDED

int szorzat(int x, int y);

#endif

2. Az inline kulcsszó használata

Mégis, mire kell az inline kulcsszó, ha a fordító anélkül is alkalmazza a fent vázolt optimalizációt?

Ennek megértéséhez azt kell elfogadni, hogy az inline szó nem az optimalizációt engedélyezi vagy javasolja a fordító számára. Hanem a létezése a C programok fordítási modelljének egyfajta mellékterméke: ahhoz kell, hogy a függvény törzsét is a fejlécfájlba tehessük. Nem csinál mást, nem való másra, csak tárolási osztályt (linkage) ad meg a függvény számára.

Térjünk vissza az előző, szorzós függvényhez. Tegyük fel, hogy ezt a függvényt szeretnénk inline-olni. Ezért kénytelenek vagyunk a függvény törzsét is a szorzas.h fejlécfájlba tenni, mert különben a használat helyén nem fogja látni azt a fordító. Tehát a projekt fájljai így néznek ki:

szorzas.h
#ifndef SZORZAS_H_INCLUDED
#define SZORZAS_H_INCLUDED

int szorzat(int x, int y) {
    return x * y;
}

#endif
szorzas.c
#include "szorzas.h"


/* szorzat() függvény
 * itt most nincs */


/* de további
 * függvények lehetnek */
main.c
#include <stdio.h>
#include "szorzas.h"

int main() {
    int a, b;
    scanf("%d %d", &a, &b);
    int s = szorzat(a, b);
    printf("%d", s);
}

Mi történik, ha megpróbáljuk így lefordítani ezt a projektet? A helyzet az, hogy nem fog működni. A fordítás során ugyanis a szorzat.c-ből keletkező szorzat.o-ba is, és a main.c-ből fordított main.o-ba is bekerül a szorzat() függvény. Az előfordított szorzas.c és main.c ugyanis a lent látható formát öltik. Bemásolódott mindkettőbe a szorzas() függvény törzse, mert az #include-dal mintha copypaste-eltünk volna a fejlécfájlt mindkettőbe:

szorzas.c
előfordítva
int szorzat(int x, int y) {
    return x * y;
}
main.c
előfordítva
int szorzat(int x, int y) {
    return x * y;
}

int main() {
    int a, b;
    scanf("%d %d", &a, &b);
    int s = szorzat(a, b);
    printf("%d", s);
}

A linker számára tehát úgy fog tűnni, hogy a függvény kétszer van definiálva, és leáll:

$ gcc szorzas.o main.o -o prg
main.o: In function `szorzat':
main.c:(.text+0x0): multiple definition of `szorzat'
szorzas.o:szorzas.c:(.text+0x0): first defined here
collect2: error: ld returned 1 exit status

Megvizsgálva a keletkezett object fájlokat, látjuk, hogy igaza is van; a szorzat() függvényből tényleg kettő lett az #include-ok miatt. Jogosan kiabál tehát a linker:

$ nm szorzas.o
0000000000000000 T szorzat

$ nm main.o
0000000000000013 T main
0000000000000000 T szorzat

Hova is jutottunk így? Ha szeretnénk, hogy a fordító inline-oljon, akkor a fejlécfájlba kell tennünk a függvénytörzset is. Ha viszont a fejlécfájlba tesszük, akkor viszont nem fordítható le a programunk, mert látszólag többször van definiálva a függvény.

Ennek az ellentmondásnak a feloldására való az inline kulcsszó. Ha ezt a fejlécfájlban a kifejtett függvény elé írjuk, akkor a fordító tudomására hozzuk, hogy emiatt látja többször a függvény törzsét:

szorzas.h
#ifndef SZORZAS_H_INCLUDED
#define SZORZAS_H_INCLUDED

inline int szorzat(int x, int y) {
    return x * y;
}

#endif

Tehát ezzel nem azt kérjük a fordítótól, hogy inline-olja a függvényt, hanem csak azt jelezzük neki, hogy ennek a függvénynek a definíciójával (törzsével) valószínű többször is fog találkozni, de ez nem hiba. Szándékos részünkről, hogy az optimalizáció megtörténhessen.

3. A függvények példányosítása

C-ben a programozó felel azért, hogy megjelölje valamelyik fordítási egységet, tehát valamelyik .c fájlt, amelybe a fordító bele fogja tenni a függvény szokásos, nem inline módon lefordított változatát. Ne feledjük: a függvény inline-olása optimalizációs kérdés, a fordító bármikor dönthet úgy, hogy nem teszi meg! Arról nem is beszélve, hogy inline függvényre is hivatkozhatunk pointerrel, márpedig akkor szokásos, önálló függvényként is szerepelnie kell valahol a memóriában.

A megjelölés az extern inline kulcsszavakkal történik:

szorzas.h
#ifndef SZORZAS_H_INCLUDED
#define SZORZAS_H_INCLUDED

inline int szorzat(int x, int y) {
    return x * y;
}

#endif
szorzas.c
#include "szorzas.h"

extern inline int szorzat(int x, int y);

A tárgykód fájlok ilyen esetben így néznek ki:

$ nm szorzas.o
0000000000000000 T szorzat

$ nm main.o
0000000000000000 T main
                 U szorzat

A szorzas.o-ban tehát a szokásos módon szerepel a lefordított függvény. A többi helyen pedig, ahol nem inline-olódott, U, azaz undefined szimbólumként jelenik meg a függvény; ezt a hivatkozást majd feloldja a linker.

4. Recept

A recept tehát:

  • Ha inline-olni szeretnénk a függvényt, akkor tegyük át azt törzzsel együtt a fejlécfájlba.
  • A fejlécfájlban tegyük elé az inline kulcsszót.
  • Valamelyik forrásfájlban – tipikusan ott, ahol eredetileg a függvény volt – kérjük meg a fordítót a függvény szokásos lefordítására az extern inline kulcsszavakkal.