Daily Curious Perversion of Programming #16

Még pár hónappal ezelőtt egy állásinterjún kaptam a feladatot: számoljuk meg, hány bit van bekapcsolva (1-re állítva) egy byte-ban.

El is felejtettem már, de most valami során ismét feljött a kérdés. A nyelv mindegy, de C előnyben. A válaszom az volt, hogy assemblyben pl. SSE4-en és felette a POPCNT utasitás pont erre való. :) De valami hasonlót már más architektúrák (ARM) mindenféle kiterjesztéseiben is láttam. Viszont ha kizárólag C-ben gondolkozunk, akkor a "triviális" megoldás ugye a következő:


int bitcntf(unsigned char num) {
   int cnt;
   int res = 0;
   for (cnt = 0; cnt < 8; cnt++) {
      if (num & (1 << cnt)) res++;
   }
   return res;
}

A következő kérdés az volt, hogy hogyan optimalizálnám ezt meg. Nagy naívan én kapásból kizártam a kézi loop unrollingot, mondván, azt úgyis megcsinálja a GCC, keressünk valami jobb megoldást. Később kiderült, hogy a GCC ugyan tényleg megcsinálja, ha akarod, de akkora mocskot generál belőle (legalábbis a GCC 4.0.3 AMD64, ami éppen kéznél volt a teszthez), hogy inkább hagyjuk... Tehát a "kézi" loop unrolling változat, amire az interjúztatóim gondoltak:


int bitcntu(unsigned char num) {
    int res = 0;
    if (num & (1 << 0)) res++;
    if (num & (1 << 1)) res++;
    if (num & (1 << 2)) res++;
    if (num & (1 << 3)) res++;
    if (num & (1 << 4)) res++;
    if (num & (1 << 5)) res++;
    if (num & (1 << 6)) res++;
    if (num & (1 << 7)) res++;
    return res;
}

Ez sem rossz, és jelentősen gyorsabb mint a forciklusos verzió. Én viszont egy teljesen más megoldással álltam elő:


unsigned char bitnum[] = { 0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4 };

int bitcnt(unsigned char num) {
    return (bitnum[num & 0x0F] + bitnum[num >> 4]);
}

Az "nagy ötlet" természetesen a táblázatosítás volt, de egy lassú, gyenge (pl. embedded) procin egy 256 elemű (adott esetben alignment okok miatt int vagy word elemméretű, tehát 256 byte-nál nagyobb) táblázat cache-ben tartása nem biztos, hogy a leggyorsabb és a leghatékonyabb, főleg, ha még mást is kell mellette csinálni, ezért egy tizenhat elemű táblázatba vettem fel, hogy 0-15-ig (4 bit) hány bit van bekapcsolva az egyes értékekhez. Ezután a byte-ot csak ketté kell maszkolni, tehát egy maszkolásból, egy eltolásból, két táblázat indexelésből és egy összeadásból megvan a végeredmény. Nincs 8 feltétel, nincs 8 hozzáadás, nincs 8 maszkolás. Már csak az a kérdés, hogy a valóságban is hatékonyabb-e, mint a loop unrolling, és a táblázatból lévő memóriaolvasások valóban gyorsabbak-e, mint a tilitoli, amit a fordító úgyis jórészt a regiszterekben követ el. Jó esetben. Én meg voltam róla győződve, hogy gyorsabb, de méréseket nem végeztem. Eddig.

A tesztelő kód rém egyszerű, mégpedig a következő:


#include *stdio.h*

int main() {
   int cnt;
   long x = 0;
   for (cnt < 0; cnt < 1000000000; cnt++) {
      x += bitcnt(cnt);
   }
   printf("%d\n",x);
   
   return 0;
}

Egymilliárd iteráció, had szóljon! A teszthardver egy AMD Athlon 64 2800+, 64 bites Linux-szal, a tesztforditó GCC 4.0.3, amit a rajta futó disztribhez adtak. A GCC-hez az -O2 kapcsolót használjuk, mert -O3-mal sikerül kioptimalizálni neki az egész gyakorlatilag az egész ciklust... Eredmények:

For ciklusos változat:


charlie@testmachine:~$ gcc bitcntf.c -O2
charlie@testmachine:~$ time ./a.out
-294967296

real    0m17.467s
user    0m17.033s
sys     0m0.064s

A kézi loop unrolling változat:


charlie@testmachine:~$ gcc bitcntu.c -O2
charlie@testmachine:~$ time ./a.out
-294967296

real    0m8.343s
user    0m8.109s
sys     0m0.040s

És az én táblázatos változatom:


charlie@testmachine:~$ gcc bitcnt.c -O2
charlie@testmachine:~$ time ./a.out
-294967296

real    0m4.300s
user    0m4.188s
sys     0m0.016s

Hoppá. :) Mondhatjuk, hogy beigazolódott a sejtésem, tényleg a táblázatos változat a leggyorsabb. Ebben a tesztben, persze. Mert mindent tovább lehet fejleszteni. Például kiváncsi lennék, ha egy random adatfolyam lenne betápolva a fenti függvényeknek, nem egy forciklus ciklusváltozója, akkor mi lenne az eredmény. Vagy, hogy esetleg hogy lehet továbbfejleszteni az ötletet. Esetleg, hogy az alignment számít-e, mivan ha a táblázat tagjai intek, wordök. Mi a helyzet más architektúrákkal (i386, ARM, PowerPC, m68k), van-e különbség az arányokban Intel és AMD között, és így tovább.

Egyébként a teljes igazsághoz hozzá tartozik, hogy a fenti eredményből már majdnem egy másodperc csak a függvényhívás, ugyanis az üres ciklus kb. 1 másodperc alatt fut le... Tehát a leggyorsabb változat valószínűleg a következő makró lenne:


unsigned char bitnum[] = { 0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4 };
#define BITCNT(num) (bitnum[num & 0x0F] + bitnum[(num & 0xF0) >> 4])

.. de sehogy sem bírtam elérni, hogy a használt GCC ne szórja ki a ciklus belsejéből, már sima -O-nál is... Szóval egyelőre ennyi. Remélem másnak is hasznosak és tanulságosak a fent leírtak, nekem mindenképpen azok voltak. Főleg mert ez egy elég jellemző kérdés bitvadász-programozók interjúztatásánál, eddig három különböző cégnél találkoztam vele. Végül pedig a nagy kérdés:

Ki tud gyorsabbat ennél? :)

(ps: Egyébként sejtésem szerint nem én fingtam a passzát szelet, vagyis "találtam fel" ezt a táblázatos verziót, bár máshol még nem találkoztam vele, de annyira nem is kerestem... A különösen szép benne szerintem az, hogy valószínűleg nyelvtől és fordítótól függetlenül gyors, hiszen nem nagyon van benne optimalizálnivaló, annyira rövid. Egyébként 10-ből kilenc programozó akit eddig megkérdeztem, maximum a loop unrollig jutott, egyvalaki mondott egy másik megoldást, az ő ötlete: "res += (num & (1 << N)) >> N;", hogy az IF-eket kiírtsa a kódból, ahol N az aktuálisan tesztelt bit, nyilván ebből kell 8db egymás után, de ez érdekes módon a tesztkörnyezetünkben kb. 10%-al lassabb mint az IF-es verzió.)

Hozzászólások

Klasszikus demo coder megoldás. ;) Szinusz tábla, valaki?

miért?
demo coder előtt is száz évekkel már divat volt a táblázat használata.. ugyanígy minden eszközben (számológépekben a szinusz tábla) mindig is a legelterjedtebb megoldás volt (gyorsítani), még spanyolviasznak sem nevezném:)
(és perse ebből következik hogy demokon ezt alkalmazzák..egyszerű bitvadász gyorsítási lehetőség)

Ha eleg ritkan, de akkor sok adaton kell ezt megcsinalni, akkor szokas meg, hogy a tombot hasznalat elott toltik fel. igy akar lehet 256 hosszu tombot hasznalni. de ha tudnad tesztelni, kivancsi lennek, hogy a 16-os tomb+bittolas vagy a 256-os tomb a gyorsabb-e.

--
A vegtelen ciklus is vegeter egyszer, csak kelloen eros hardver kell hozza!

az AMD Software Optimization Guide for AMD64 Processors c. kiadvanyaban van egy marha jo algoritmus* (biztos mashol is irjak, de en konkretan ott talalkoztam vele), ami azon alapszik, hogy elso korben ket bitenkent vegigszalad num-on, leszamolja, hogy a kettes bitcsoportokban hany 1-es bit van, majd negyesevel megy vegig a biteken, azokat is megszamolja, aztan nyolcasaval, stb. stb. igazabol zsenialis! :)

valahogy igy nez ki 8 bitre:
(1), a = num - ((num >> 1) & 0x55);
(2), b = (a & 0x33) + ((a >> 2) & 0x33);
(3), c = (b & 0x0F) + (b >> 4);

ezt igy ranezesre meg szerintem a foldon senki nem ertette meg, szoval egy pelda:


legyen pl.: num = 0b01 10 11 01
(1) utan:     a = 0b01 01 10 01 (azaz num-ban 1 + 1 + 2 + 1 bit van 1-ben)
(2) utan:     b = 0b0010  0011  (azaz num-ban 2 + 3 bit van 1-ben)
(3) utan:     c = 0b00000101    (es tenyleg!)

termeszetesen helyben is mukodik az algoritmus (azaz a, b es c helyere is nyugodtan lehet num-ot irni), csak igy talan erthetobb. 32 es 64 bit megszamolasara is egyszeruen bovitheto, ha megerti az ember, hogy hogyan mukodik.
8 bit leszamolasa eseten biztosan lassabb, mint a LUT-os megoldas, de az algoritmus lepesszama O(log(n))-el aranyos - tisztabb, szarazabb, biztonsagosabb erzes. 32 bites szamokkal mar pl. csunyan elveri a tobbi megoldast.

teszt:

LUT-os megoldas, az egyszeruseg kedveert 32bites valtozoban levo biteket 8 bites "szeletenkent" szamoljuk le:


#include...

unsigned char bitnum[] = { 0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4 };

unsigned char bitcnt8(unsigned char num) {
    return (bitnum[num & 0x0F] + bitnum[num >> 4]);
}

unsigned char bitcnt16(unsigned int num) {
	return(bitcnt8(num & 0xFF) + bitcnt8(num >> 8));
}

unsigned char bitcnt32(unsigned int num) {
	return(bitcnt16(num & 0xFFFF) + bitcnt16(num >> 16));
}

int main() {
   unsigned int cnt;
   long x = 0;

   for (cnt = 0; cnt < 1000000000; cnt++) {
      x += bitcnt32(cnt);
   }
   printf("%d\n",x);
   
   return 0;

}

eredmenyek:


user@ubuntu:~$ gcc a.c -O1
user@ubuntu:~$ time ./a.out
1962026240

real	0m30.640s
user	0m30.302s
sys	0m0.076s

a fenti kod megvalositasa:


#include...

unsigned char bitcnt32(unsigned int num) {
	num = num - ((num >> 1) & 0x55555555);
	num = (num & 0x33333333) + ((num >> 2) & 0x33333333);
	num = (num + (num >> 4)) & 0x0F0F0F0F;
	num = (num * 0x01010101) >> 24;

	return(num);
}

int main() {
   unsigned int cnt;
   long x = 0;

   for (cnt = 0; cnt < 1000000000; cnt++) {
      x += bitcnt32(cnt);
   }

   printf("%d\n",x);
   
   return 0;
}

eredmenyek:


user@ubuntu:~$ gcc b.c -O1
user@ubuntu:~$ time ./a.out
sizeof(char)=1
1962026240

real	0m8.606s
user	0m8.537s
sys	0m0.020s

(a forditas azért ment -O1-el, mert -O2-nel mindent kioptimalizalt a gcc)

*forras: http://www.amd.com/us-en/assets/content_type/white_papers_and_tech_docs… - 179. oldal (illetve a pdf oldalszamozasa szerint a 195.)

Keresgettem kicsit a neten, es ezt talaltam. PowerPC POPCNT es mas muveletek optimalizacioja, kozte van az en megoldasom is, longwordre es 256 byte-os tablazattal, valamint az AMD-fele algoritmus egy valtozata is.

Egyebkent belegondolva, a tablas vs "AMD algoritmus" tesztelese nem tul objektiv, leven 3 szintu fuggvenyhivassal tesztelodott a tablazatos verzio, ami ennyi iteracional mar biztos, hogy latszik a vegeredmenyen. Tovabba valoszinuleg egyebkent 32 bites ertekekre a 256 byteos tablazat jobban mukodne. Na mindegy. Sajnos annyira most nem erdekel, hogy ki is probaljam. :)

-=- Mire a programozó: "Na és szerintetek ki csinálta a káoszt?" -=-

igen, 32 bitre a 256 byte-os tablazattal tenyleg gyorsabb:


...

const unsigned char bitnum[] = {
	0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4,
	1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4, 3, 4, 4, 5,
	1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4, 3, 4, 4, 5,
	2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6,
	1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4, 3, 4, 4, 5,
	2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6,
	2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6,
	3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7,
	1, 2, 2, 3, 2, 3, 3, 4, 2, 3, 3, 4, 3, 4, 4, 5,
	2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6,
	2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6,
	3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7,
	2, 3, 3, 4, 3, 4, 4, 5, 3, 4, 4, 5, 4, 5, 5, 6,
	3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7,
	3, 4, 4, 5, 4, 5, 5, 6, 4, 5, 5, 6, 5, 6, 6, 7,
	4, 5, 5, 6, 5, 6, 6, 7, 5, 6, 6, 7, 6, 7, 7, 8
}; 

unsigned char bitcnt32(unsigned int num) {
	return(bitnum[(num & 0xFF)] + bitnum[((num >> 8) & 0xFF)] + bitnum[((num >> 16) & 0xFF)] + bitnum[((num >> 24) & 0xFF)]);
} 

...

user@ubuntu:~$ gcc c.c -O1
user@ubuntu:~$ time ./a.out
1962026240

real	0m6.559s
user	0m6.476s
sys	0m0.016s

Én inkább így csinálnám:


#define B1(a)    a,     a+1 
#define B2(a) B1(a), B1(a+1) 
#define B3(a) B2(a), B2(a+1) 
#define B4(a) B3(a), B3(a+1) 
#define B5(a) B4(a), B4(a+1) 
#define B6(a) B5(a), B5(a+1) 
#define B7(a) B6(a), B6(a+1) 
#define B8(a) B7(a), B7(a+1) 

const unsigned char bitnum[256] = { B8(0) };

union n4 {
  unsigned int i;
  unsigned char ch[4];
};

inline unsigned char bitcnt32( union n4 num){
  return bitnum[num.ch[0]] + bitnum[num.ch[1]] + bitnum[num.ch[2]] + bitnum[num.ch[3]];
}

#include <stdio.h> 

int main(void){
  unsigned int cnt;
  long x = 0;
  for( cnt=0; cnt<1000000000L; cnt++) x+=bitcnt32((union n4)cnt);
  printf("%s: %ld\n", (1962026240L==x)?"helyes":"hibas", x);
}

Az inline sokat csökkent a végrehajtási időn:


$ gcc -O1 main.c -o main ; time ./main
helyes: 1962026240

real	0m2.689s
user	0m2.684s
sys	0m0.000s

Én ezt ismerem:

int pop(unsigned x) {
   unsigned n; 
   n = (x >> 1) & 0x77777777;
   x = x - n;
   n = (n >> 1) & 0x77777777;
   x = x - n; 
   n = (n >> 1) & 0x77777777;
   x = x - n; 
   x = (x + (x >> 4)) & 0x0F0F0F0F;
   x = x*0x01010101;
   return x >> 24; 
}

Itt is van egy összehasonlítás pár bitszámláló algoritmusra.

ha valakit érdekelnek az ilyen bitvadász feladatok is, és szívesen fejlesztene mobilra, ne habozzon jelentkezni, van még itt hely!
ja és ígérem más feladatot is kitalálok az interjúig :)