A C99 restrict kulcsszó
Czirkos Zoltán · 2022.06.21.
A C99 restrict kulcsszóról és az optimalizálásról néhány gondolat.
Találós kérdés. Adott az alábbi függvény. Mi a visszatérési értéke?
int foo(int *a, int *b) {
*a = 1;
*b = 2;
return *a;
}
Megoldás
A válasz: nem tudhatjuk. Előfordulhat az, hogy mindkét pointer ugyanarra a változóra mutat. Ha ez így van, a visszatérési érték 2, amúgy 1:
int i;
printf("%d", foo(&i, &i));
Ha a programjainkban csak értékként definiált változók lennének, nem lehetne föltenni ezt a találós kérdést. Mert akkor
minden egyes változó egy egyedi helyet jelölne a memóriában, és így mindegyik független lenne. Azonban abban a pillanatban, hogy
pointereket vagy referenciákat kezdünk használni, előfordulhat az, hogy két különállónak látszó név ugyanazt a memóriaterületet,
ugyanazt a változót hivatkozza. A probléma neve az angol nyelvű szakirodalomban: aliasing, és már találkoztunk vele
C++-ból a másoló értékadó operátorok (copy assignment operator) megírása kapcsán. Ott is figyelnünk kellett az önértékadásra,
amit arról ismertünk fel, hogy a this
pointer és a paraméterként kapott referencia ugyanazt az objektumot
hivatkozzák:
Vector& Vector::operator=(Vector const &rhs) {
if (this != &rhs) {
/* ... */
}
return *this;
}
Emiatt fenti találós kérdés függvényéről sem lehet teljes biztonsággal állítani, hogy 1-gyel térne vissza. A függvény
lefordításakor a fordító nem ismeri az a
és b
pointerek értékét, ezért fel kell készülnie arra, hogy
esetleg a két pointer megegyezik egymással.
Ez akkor fontos, ha sebességre optimalizálva próbáljuk lefordítani a kódot. Az aliasing lehetősége miatt rengeteg
optimalizációs, gyorsítási lehetőség veszik el. Például az alábbi foo1()
függvényben a *n
indirekciót
nem lehet kiemelni a ciklus elé (a jobb oldalon látható foo2()
változat), mivel akár az is lehet, hogy
az n
pointer a vec[]
tömb valamelyik elemére mutat, azaz *n
a vec[]
tömb valamelyik elemével egyezik meg. Pedig ez fontos lenne, mert iterációnként egy memóriaműveletet megspórolhatnánk:
void foo1(int vec[], int *n) {
for (int i = 0; i != *n; ++i)
vec[i] += 1;
}
void foo2(int vec[], int *n) {
int t = *n;
for (int i = 0; i != t; ++i)
vec[i] += 1;
}
A következő programrész pedig nem gyorsítható úgy, hogy az egyes bájtokat nem egyesével, hanem négyesével vagy nyolcasával másoljuk (az erre való gépi utasítással), hiszen a két tömb átfedheti egymást:
void my_memcpy(char *dst, char const *src, size_t n) {
for (size_t i = 0; i != n; ++i)
dst[i] = src[i];
}
Pedig ezek mind olyan optimalizációk, amelyre a mai fordítók képesek lennének.
C++-ban még rosszabb a helyzet. Ott egy
objektum tagváltozói mindig indirekt elérésűek, a this
pointeren keresztül. Az alábbi tagfüggvényben a
size
nem lokális változó, ezért lehet, hogy a data
pointeren keresztül hivatkozható. Emiatt minden
egyes iterációban újra ki kell olvasni az értékét a memóriából:
class Vector {
public:
void clear() {
for (int i = 0; i < size; i++)
data[i] = 0;
}
private:
int size;
int *data;
};
Akármennyire is tűnik ez értelmetlennek nekünk, a fordító nem érti a kódot,
és nem élhet azzal a feltételezéssel, hogy data!=&size
teljesül!
TBAA: Type-based alias analysis
A szabvány és a fordítók írói megpróbáltak kicsit javítani ezen a helyzeten. Vannak olyan körülmények, amelyek fennállása
esetén tudni lehet, hogy két pointer biztosan nem ugyanarra az objektumra mutat. Például egy int*
-ról és egy
double*
-ról „ránézésre” is tudjuk, hogy nem kell feltételezni az egyezésüket. De a „ránézésre” nem elég, a
szabályrendszert formalizálni kell, mert a fordítók csak a beléjük programozott szabályokat tudják követni.
Az előző példa azonban adhat egy ötletet. A nyelv típusait fel lehet használni arra, hogy felismerjük a különálló pointereket! Ebből kiindulva, a szabályok a következők:
- Minden memóriaterület csak egy bizonyos típusú objektumot tárolhat, amíg annak az objektumnak az élettartama tart.
- Két pointer akkor nevezünk kompatibilisnek, ha:
- Csak előjelben vagy minősítőkben (qualifier, pl.
const
) térnek el. - Az egyikük
void *
, mert az bármilyen típussal kompatibilis (generikus pointer). - Az egyikük
char *
vagyunsigned char *
, mert ezt használjuk bájtonkénti memóriacímzésre.
- Csak előjelben vagy minősítőkben (qualifier, pl.
- Két pointerről csak akkor kell feltételezni, hogy ugyanarra az objektumra mutatnak, ha kompatibilis típusúak.
A szabályok alapján a fordítók már sok optimalizálást el tudnak végezni. Például az alábbi foo1()
függvényt
jogosan foo2()
-ként értelmezhetik, mivel tudni lehet, hogy az int *n
pointer nem mutat a float
vec[]
tömb semelyik elemére. Így nem kell a *n
kifejezést minden iterációban kiértékelni:
/* vec[i] ugyanaz az int, mint *n? */
void foo1(float vec[], int *n) {
for (int i = 0; i != *n; ++i)
vec[i] += 1;
}
/* biztosan nem ugyanazok! */
void foo2(float vec[], int *n) {
int t = *n;
for (int i = 0; i != t; ++i)
vec[i] += 1;
}
Azonban ez int vec[]
, int *n
esetén még mindig nem lenne segítség.
A kompatibilis pointerekhez: restrict
kulcsszó
Ha két kompatibilis pointerünk van, akkor is jelezhetjük a fordító számára, hogy biztosan nem ugyanarra az objektumra
mutatnak. Ezt a restrict
minősítővel tehetjük meg. A kulcsszót a pointert jelző *
után kell írni,
mivel a pointert minősíti, nem a mutatott objektumot:
int foo(int * restrict a, int * restrict b) {
*a = 1;
*b = 2;
return *a;
}
Ha egy blokkban a restrict
kulcsszóval megjelölünk egy pointert, azzal azt jelezzük a fordítónak, hogy abban a
blokkban az általa mutatott objektumot csak az a pointer, vagy az abból létrehozott másolatok hivatkozzák. Így a fenti kódban
például optimalizálhatja a visszatérési értéket, fixen 1-re. Ha megnézzük a generált assembly kódot, ez látszik is:
restrict
nélkül generált assembly kód:
movl $1, (%rdi) ; *a = 1; movl $2, (%rsi) ; *b = 2; movl (%rdi), %eax ; (vissza) = *a; ret ; visszatérés a fv-ből
restrict
-tel generált assembly kód:
movl $1, (%rdi) ; *a = 1; movl $1, %eax ; (vissza) = 1; movl $2, (%rsi) ; *b = 2; ret ; visszatérés a fv-ből
Tömbök esetén nagyon sokat segíthet a restrict
kulcsszó, mert lehetővé teheti a tömbelemek párhuzamos
feldolgozását. A mai processzorok rengeteg ún. SIMD (single instruction, multiple data) utasítással rendelkeznek, amelyek sok
adatot dolgoznak fel egyszerre, így az előny például a lenti függvényben jelentős lehet. (Itt a
és b
pointerek! A tömb szintaktikájú formális paraméternél így kell jelölni a restrict
-et.)
/* vector a += b */
void vec_plus(int n, float a[restrict], float const b[restrict]) {
for (int i = 0; i != n; ++i)
a[i] += b[i];
}
Nagyon vigyázni kell arra, hogy a restrict
használatával ígéretet teszünk a fordítónak a pointerek által
mutatott objektumokat illetően. Ha mégis olyan értékeket adunk a pointereknek, amelyek megegyeznek, nem garantált, hogy a
futtatott program helyes eredményt ad! Például:
$ cat restrict.c #include <stdio.h> int foo(int * restrict a, int * restrict b) { *a = 1; *b = 2; return *a; } int main() { int i; printf("%d\n", foo(&i, &i)); } $ clang -O0 restrict.c -o restrict; ./restrict 2 $ clang -O2 restrict.c -o restrict; ./restrict 1
Látszik, hogy a -O2
optimalizálás mellett a fordító kihasználta az ígéretünket, és ezért más lett az amúgy
helytelen (!) programkód futási eredménye, mint optimalizálás nélkül.
A C89-ben két függvénnyel lehetett adott méretű memóriaterületet másolni, ezek a
memcpy()
és amemmove()
. Mindkettő paraméterei: cél pointer, forrás pointer, memóriaterület mérete. Mi a két függvény között a különbség? A C99-től kezdve a kettő közül vajon melyik paramétereirestrict
minősítésű pointerek?
Megoldás
A memcpy()
-nál elvárás volt a C89-ben is, hogy olyan tömböket
adjunk paraméterként, amelyek nem fedik egymást. Így az új verzióban ennek
restrict
minősítésűek a paraméterei. A memmove()
-nál
nincs ilyen elvárás, az képes egymást átfedő memóriaterületeket is másolni.