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?
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:
#ifndef SZORZAS_H_INCLUDED
#define SZORZAS_H_INCLUDED
int szorzat(int x, int y);
#endif
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:
#ifndef SZORZAS_H_INCLUDED
#define SZORZAS_H_INCLUDED
int szorzat(int x, int y) {
return x * y;
}
#endif
#include "szorzas.h"
/* szorzat() függvény
* itt most nincs */
/* de további
* függvények lehetnek */
#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:
előfordítva
int szorzat(int x, int y) {
return x * y;
}
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:
#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.
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:
#ifndef SZORZAS_H_INCLUDED
#define SZORZAS_H_INCLUDED
inline int szorzat(int x, int y) {
return x * y;
}
#endif
#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.
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
inlinekulcsszó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 inlinekulcsszavakkal.