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
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.