Test kilku PRNG - cykle na bajt z optymalizacją i bez

0

Testuję sobie różne generatory. Po tym jak kilka osób pomogło mi ogarnąć quick-bench.com mam implementacje i testy:

https://quick-bench.com/q/E8kPDt4GGHv1_MoJwz7ZTsLtHGM

Niedługo zamierzam opublikować swój własny PRNG i profesor, z którym pracuję powiedział mi, że takie pomiary to jest "academic junk" (także to: https://prng.di.unimi.it). Cykle na bajt są może i dobre do publikacji naukowej, jak twierdzi, ale my chcemy zaproponować rozwiązania dla biznesu high-tech. Mówi, że wielu inżynierów porównuje algorytmy pod względem MIPS (miliony instrukcji na sekundę), choć to też nie jest idealna miara (większość chciałaby zobaczyć wyniki na konkretnym sprzęcie i środowisku, na którym sami pracują).

Mówi, że, jeśli już coś mamy porównać na ogólnym poziomie, to oszacować liczbę cykli, licząc ręcznie operacje. Zrobiłem takie wyliczenia:

class xoroshiro256plus {

    uint64_t s[4] = { 5, 11, 13, 99 };

    static uint64_t rotl(const uint64_t x, int k)
    {
        return (x << k) | (x >> (64 - k));
    }

public:
    uint64_t next() noexcept
    {
      const uint64_t result = s[0] + s[3]; // 4 cycles

	const uint64_t t = s[1] << 17; // 3 cycles

	s[2] ^= s[0]; // 4 cycles
	s[3] ^= s[1]; // 4 cycles
	s[1] ^= s[2]; // 4 cycles
	s[0] ^= s[3]; // 4 cycles

	s[2] ^= t; // 4 cycles

	s[3] = rotl(s[3], 45); // 6 cycles

	return result;
    }
};

//Xoroshiro256+ ma 3 cykle.

class SFC64 {

    uint64_t s[4] = { 5, 11, 13, 21 };

    static uint64_t rotl(const uint64_t x, int k)
    {
        return (x << k) | (x >> (64 - k));
    }

public:
    uint64_t next() noexcept
    {
        uint64_t tmp = s[0] + s[1] + s[3]++; // 8 cycles

        s[0] = s[1] ^ (s[1] >> 11); // 5 cycles
        s[1] = s[2] + (s[2] << 3); // 5 cycles
        s[2] = rotl(s[2], 24) + tmp; // 8 cycles
  
        return tmp; 

    }
};

//SFC64 ma 26 cykli.

class PCGDXSM {

    __uint128_t LCG = 3311;

public:
    uint64_t next() noexcept
    {
        LCG = LCG * 0xda942042e4dd58b5 + 1; // I assumed 128-bit multiplication is 2 * 4 cycles = 8 cycles, so we have here 13 cycles
        uint64_t hi = LCG >> 64; // 5 cycles
        uint64_t lo = LCG; // 2 cycles
        lo |= 1; // 3 cycles
        hi ^= hi >> 32; // 5 cycles
        hi *= 0xda942042e4dd58b5ULL; // 6 cycles
        hi ^= hi >> 48; // 5 cycles
        hi *= lo; // 7 cycles
        return hi;
    }
};

//PCG-DXSM ma 46 cykli.

class MWC256 {

    uint64_t x = 3;
    uint64_t y = 33;
    uint64_t z = 333;
    uint64_t c = 1;

public:
    uint64_t next() noexcept
    {
        const uint64_t result = z; // 2 cycles
	    const __uint128_t t = 0xfff62cf2ccc0cdaf * (__uint128_t)x + c; // I assumed 128-bit multiplication is 2 * 4 cycles = 8 cycles, so we have here 12 cycles
	    x = y; // 2 cycles
	    y = z; // 2 cycles
	    z = t; // 2 cycles
	    c = t >> 64; // 5 cycles
	    return result;
    }
};

//MWC-256 ma 25 cykli (możliwe, że zbyt optymistycznie potraktowałem mnożenie liczb 128-bitowych).

class splitmix64 {

    uint64_t x = 5;

public:
    uint64_t next() noexcept
    {
    uint64_t z = (x += 0x9e3779b97f4a7c15); // 4 cycles
	z = (z ^ (z >> 30)) * 0xbf58476d1ce4e5b9; // 9 cycles
	z = (z ^ (z >> 27)) * 0x94d049bb133111eb; // 9 cycles
	return z ^ (z >> 31); // 4 cycles
    }
};


Splitmix64 ma 26 cykli.

class LCG {

    uint64_t x = 5;

public:
    uint64_t next() noexcept
    {
    x *= 0xf9b25d65 + 1; // 7 cycles
	return x >> 32; // 2 cyles
    }
};

//LCG ma 18 cykli (zwraca tylko 32 bity, więc wygenerowanie 64 bitów zajmie mu 2*6 cykli).




Generalnie te szacunki są zbliżone do wyników bez optymalizacji (optim = None). Czy pomiary prędkości tych generatorów bez optymalizacji powinny być zbliżone do ręcznego, zgrubnego oszacowania cykli? Z tego co widzę mniej więcej są, o ile nie walnąłem się gdzieś w zliczaniu operacji. W przypadku MWC256 nie jestem pewien przykładowo, czy x = y; liczyć jako jeden cykl. Zakładam, że mamy do dyspozycji architekturę 64-bitową.

Czy te moje ręczne szacunki mają sens?

2
Tomasz_D napisał(a):

Mówi, że, jeśli już coś mamy porównać na ogólnym poziomie, to oszacować liczbę cykli, licząc ręcznie operacje. Zrobiłem takie wyliczenia:

No to życzę powodzenia. Współczesne processory mają tyle optymalizacji (wykonywanie instrukcji równolegle, w róznej kolejności itp itd). Że próba policznia tego ręcznie skazana jest na porażkę. Nawet projektant tego procesora, będzie miał problem by to zrobić ręcznie.

Kosument przetestuje to tak: bierze jakiś program który używa liczb losowych i testujesz jego szybkość podmieniając różne generatory liczb losowych. Wtedy widać faktyczny zysk.

Jednak najważnjesze są włąściowości generatora liczb losowych, czyli bardzo długi okres powtórzeń i naprawdę dobre wąściowści statystyczne (jednorodność rozkładu i słabe korelacje). Jest jakiś tool napisany w pythonie, który testuje generatory liczb losowych pod tym kontem.


czy ty dałeś linka to testów perfomance, gdzie są wyłączone optymalizacje kompilatora?
Z włączonymi optymalizacjami wyniki są inne.

0
MarekR22 napisał(a):
Tomasz_D napisał(a):

Mówi, że, jeśli już coś mamy porównać na ogólnym poziomie, to oszacować liczbę cykli, licząc ręcznie operacje. Zrobiłem takie wyliczenia:

No to życzę powodzenia. Współczesne processory mają tyle optymalizacji (wykonywanie instrukcji równolegle, w róznej kolejności itp itd). Że próba policznia tego ręcznie skazana jest na porażkę. Nawet projektant tego procesora, będzie miał problem by to zrobić ręcznie.

Wiem, to widać już na różnych flagach i bez optymalizacji. Mój generator akurat z jakichś powodów z optymalizacją OFast wypada gorzej niż xoroshiro, a bez żadnej optymalizacji lepiej (czy właśnie licząc cykle ręcznie). Xoroshiro jest lepiej optymalizowany przez kompilator, z jakichś powodów.

Kosument przetestuje to tak: bierze jakiś program który używa liczb losowych i testujesz jego szybkość podmieniając różne generatory liczb losowych. Wtedy widać faktyczny zysk.

To jest najlepszy i docelowy test - przetestowanie generatora przez użytkownika na sprzęcie i ramach jego aplikacji. Ale, żeby iść do kogoś i twierdzić, że mamy coś, co wypada lepiej, trzeba zrobić jakiekolwiek testy lub szacunki. Testy jak zrobił prof. Vigna:

https://prng.di.unimi.it

Mają tę wadę, że dotyczą konkretnego, jednego sprzętu i środowiska. Z tego co rozumiem mojego profesora, on chce przynajmniej jakoś zobiektywizować kryteria. Na podstawie pomiarów ludzi z Numpy, którzy wdrażali swój domyślny generator widać już jak duże różnice mogą wystąpić na różnych platformach, a nawet w różnych systemach:

https://github.com/numpy/numpy/pull/13163#issuecomment-496030958

Więc właśnie ze względu na liczne optymalizacje, zrobienie jakiegoś jednego testu na jednej platformie jest bardzo stronnicze. Z kolei testowanie na przeróżnych platformach i w różnych środowiskach czasochłonne i kosztowne.

Jednak najważnjesze są włąściowości generatora liczb losowych, czyli bardzo długi okres powtórzeń i naprawdę dobre wąściowści statystyczne (jednorodność rozkładu i słabe korelacje). Jest jakiś tool napisany w pythonie, który testuje generatory liczb losowych pod tym kontem.

Oczywiście, generator zdaje wszystkie testy, jakie istnieją i są lub były znane: NIST test, Dieharder, TestU01, PractRand. Ma też wszelkie wymagane i udowodnione matematyczne właściwości. Testowanie jakości statystycznej jest paradoksalnie proste, bo te testy są praktycznie automatyczne. To było coś od tego zacząłem. W tej chwili szacujemy tylko gdzie, jeśli chodzi o wydajność, pośród generatorów o absolutnie niezachwianej jakości statystycznej (a takich jest nie tak wiele, tak naprawdę) jesteśmy.

czy ty dałeś linka to testów perfomance, gdzie są wyłączone optymalizacje kompilatora?
Z włączonymi optymalizacjami wyniki są inne.

Tak, właśnie dlatego, bo liczyłem na to, że bez optymalizacji wyniki będą mniej więcej zbliżone do tego co można sobie ręcznie podliczyć.

1
Tomasz_D napisał(a):
MarekR22 napisał(a):
Tomasz_D napisał(a):

Mówi, że, jeśli już coś mamy porównać na ogólnym poziomie, to oszacować liczbę cykli, licząc ręcznie operacje. Zrobiłem takie wyliczenia:

No to życzę powodzenia. Współczesne processory mają tyle optymalizacji (wykonywanie instrukcji równolegle, w róznej kolejności itp itd). Że próba policznia tego ręcznie skazana jest na porażkę. Nawet projektant tego procesora, będzie miał problem by to zrobić ręcznie.

Wiem, to widać już na różnych flagach i bez optymalizacji. Mój generator akurat z jakichś powodów z optymalizacją OFast wypada gorzej niż xoroshiro, a bez żadnej optymalizacji lepiej (czy właśnie licząc cykle ręcznie). Xoroshiro jest lepiej optymalizowany przez kompilator, z jakichś powodów.

Źle zrozumiałeś. Ja nie psizę o optymalizacjach kompilatora, ale optymalizacjach CPU - hardware. Ergo posiadanie kodu postaci instrukcji procesora nie czyni zadaniem łatwym.
Procesor potrafi wykonwywać instrukcje w innej kolejności niż jest to zapisane, potrafi przetwarzać dane spekulatywnie, przewidywać branche, wykonywać rzeczy równolegle (bez użycia wielowątkowości).
Efekt jest taki, że licznie ile cykli zegara zajmuje dana instrukcja w kodzie maszynowym, nie jest dobrą metryką jak szybko działa ten fragment kodu.

Na godbolt jest jakieś narzędzie, do szacowania tych efektów, ale nie pamiętam jak się nazywa i jak się je włącza. Matt Godbolt (autor narzedzia) zajmuje się miedzy innymi High Frequeny Trading, więc dlatego Compiler Explorer ma takie zdolności.

0

W przypadku mojego generatora dostaję coś takiego:

movq x(%rip), %rax
movq %rax, %rcx
shrq %rcx
addq k(%rip), %rax
movq %rax, k(%rip)
movq %rax, %rdx
orq $1, %rdx
imulq %rcx, %rdx
movq weyl(%rip), %rcx
addq s(%rip), %rcx
movq %rcx, weyl(%rip)
xorq %rdx, %rcx
movq %rcx, x(%rip)
shrq $48, %rax
xorq %rcx, %rax
retq

W przypadku np. xoroshiro128++:

static inline uint64_t rotl(const uint64_t x, int k) {
	return (x << k) | (x >> (64 - k));
}


static uint64_t s[2];

uint64_t next(void) {
	const uint64_t s0 = s[0];
	uint64_t s1 = s[1];
	const uint64_t result = rotl(s0 + s1, 17) + s0;

	s1 ^= s0;
	s[0] = rotl(s0, 49) ^ s1 ^ (s1 << 21); // a, b
	s[1] = rotl(s1, 28); // c

	return result;
}

movq _ZL1s.0(%rip), %rcx
movq _ZL1s.1(%rip), %rdx
leaq (%rdx,%rcx), %rax
rolq $17, %rax
addq %rcx, %rax
xorq %rcx, %rdx
rolq $49, %rcx
movq %rdx, %rsi
shlq $21, %rsi
xorq %rcx, %rsi
xorq %rdx, %rsi
movq %rsi, _ZL1s.0(%rip)
rolq $28, %rdx
movq %rdx, _ZL1s.1(%rip)
retq

Jeśli liczyć movq jako 2, rolq jako 1 i tak dalej, to xoroshiro jest chyba szybszy. Podobnie wychodzi to w https://quick-bench.com.

0

@enedil: Swoją drogą, być może to tłumaczy dlaczego w https://quick-bench.com xoroshiro256++ jest szybszy niż mój generator 64-bitowy, pomimo, że po podliczeniu operacji z Godbolt wychodzi mi, że nie jest. Mój generator ma 33 cykle:

movq x(%rip), %rcx --> 3
movq k(%rip), %rax --> 3
addq %rcx, %rax --> 1
movq %rax, k(%rip) --> 3
shrq %rcx --> 1
movq %rax, %rdx --> 3
orq $1, %rdx --> 1
imulq %rcx, %rdx --> 5
movq s(%rip), %rcx --> 3
addq weyl(%rip), %rcx --> 1
movq %rcx, weyl(%rip) --> 3
xorq %rdx, %rcx --> 1
movq %rcx, x(%rip) --> 3
shrq $48, %rax --> 1
xorq %rcx, %rax --> 1

Xoroshiro256++ (https://prng.di.unimi.it/xoshiro256plusplus.c):

movq _ZL1s.0(%rip), %rcx --> 3
movq _ZL1s.3(%rip), %rdx --> 3
leaq (%rdx,%rcx), %rax --> 2
rolq $23, %rax --> 1
addq %rcx, %rax --> 1
movq _ZL1s.1(%rip), %rsi --> 3
movq %rsi, %rdi --> 3
shlq $17, %rdi --> 1
movq _ZL1s.2(%rip), %r8 --> 3
xorq %rcx, %r8 --> 1
xorq %rsi, %rdx --> 1
xorq %r8, %rsi --> 1
movq %rsi, _ZL1s.1(%rip) --> 3
xorq %rdx, %rcx --> 1
movq %rcx, _ZL1s.0(%rip) --> 3
xorq %rdi, %r8 --> 1
movq %r8, _ZL1s.2(%rip) --> 3
rolq $45, %rdx --> 1
movq %rdx, _ZL1s.3(%rip) --> 3

Wychodzi 38 cykli. Z kolei mój generator 128-bitowy jest szybszy w https://quick-bench.com, a po podliczeniu operacji ma 33,5 cykli (w przeliczeniu na wygenerowanie 64-bitowej liczby). Czyli teoretycznie nadal powinien być wolniejszy.

Innym paradoksem jest, że, gdy podliczę operacje dla xoroshiro128++: https://godbolt.org/z/9WGG7PPzP, zwłaszcza w Clang, to wychodzi jedynie 25 cykli. Tymczasem prof. Vignie wyszło, że xoroshiro128++ jest wolniejszy niż xoroshiro256++: https://prng.di.unimi.it. A licząc tak naiwnie operacje powinien być znacznie szybszy.

PS Liczbę cykli biorę z https://www.agner.org/optimize/instruction_tables.pdf, dla AMD K8.

0

przecież ci o tym pisałem

MarekR22 napisał(a):

Źle zrozumiałeś. Ja nie pszię o optymalizacjach kompilatora, ale optymalizacjach CPU - hardware. Ergo posiadanie kodu postaci instrukcji procesora nie czyni zadaniem łatwym.
Procesor potrafi wykonywać instrukcje w innej kolejności niż jest to zapisane, potrafi przetwarzać dane spekulatywnie, przewidywać branche, wykonywać rzeczy równolegle (bez użycia wielowątkowości).
Efekt jest taki, że licznie ile cykli zegara zajmuje dana instrukcja w kodzie maszynowym, nie jest dobrą metryką jak szybko działa ten fragment kodu.

Na godbolt jest jakieś narzędzie, do szacowania tych efektów, ale nie pamiętam jak się nazywa i jak się je włącza. Matt Godbolt (autor narzedzia) zajmuje się miedzy innymi High Frequeny Trading, więc dlatego Compiler Explorer ma takie zdolności.

Dobra znalazłem demo od Matta na to narzędzie:

0

Zapominajac o tym jak to liczyc i jak bardzo nie ma to sensu, to wydaje sie bardzo wyrazne przyspieszenie jest bardzo proste. Mimo ze blizej mi do ARM-owego NEON-a niz x86, to wydaje mi sie ze w tym assemblerze nie ma SIMD-a. Wystarczy dodac i bedzie duzo szybsze. Jesli nie chcesz pisac w assemblerze to jesli pomozesz troche kompilatorowi to sam sobie powinien poradzic. Po zrobieinu tablicy, zainicjalizowaniu roznymi seedami i pozniejszym liczeniu w petli, madry kompilator ma szanse ta petle zamienic na instrukcje SIMD-owe. Oczywiscie wtedy moga sie zmienic wlasciwosci statystyczne, ale przy naiwnym zalozeniu ze PRNG jest idealny to jedynym warunkiem zeby to dzialalo rzeczywiscie losowo jest zagwarantowanie ze seedy nie powtarzaja sie i sa niezalezne.

0

@MarekR22: najdziwniejsze jest w tym narzędziu to, że daje inne latency dla tego samego kodu z komentarzami i bez:

#include <cstdint>

static inline uint64_t rotl(const uint64_t x, int k) {
	return (x << k) | (x >> (64 - k));
}

static uint64_t s[2] = {3,5};

uint64_t next(void) {
	const uint64_t s0 = s[0];
	uint64_t s1 = s[1];
	const uint64_t result = rotl(s0 + s1, 17) + s0;

	s1 ^= s0;
	s[0] = rotl(s0, 49) ^ s1 ^ (s1 << 21); // a, b
	s[1] = rotl(s1, 28); // c

	return result;
}

Z komentarzami:

[1] [2] [3] [4] [5] [6] Instructions:
1 4 0.50 * movq _ZL1s.0(%rip), %rcx
1 4 0.50 * movq _ZL1s.1(%rip), %rdx
1 1 0.25 leaq (%rdx,%rcx), %rax
1 1 0.25 rolq $17, %rax
1 1 0.25 addq %rcx, %rax
1 1 0.25 xorq %rcx, %rdx
1 1 0.25 rolq $49, %rcx
1 1 0.25 movq %rdx, %rsi
1 1 0.25 shlq $21, %rsi
1 1 0.25 xorq %rcx, %rsi
1 1 0.25 xorq %rdx, %rsi
1 1 0.50 * movq %rsi, _ZL1s.0(%rip)
1 1 0.25 rolq $28, %rdx
1 1 0.50 * movq %rdx, _ZL1s.1(%rip)
2 1 0.50 U retq

Bez komentarzy:

[1] [2] [3] [4] [5] [6] Instructions:
1 5 0.50 * movq _ZL1s.0(%rip), %rcx
1 5 0.50 * movq _ZL1s.1(%rip), %rdx
1 1 0.50 leaq (%rdx,%rcx), %rax
1 1 0.50 rolq $17, %rax
1 1 0.25 addq %rcx, %rax
1 1 0.25 xorq %rcx, %rdx
1 1 0.50 rolq $49, %rcx
1 1 0.25 movq %rdx, %rsi
1 1 0.50 shlq $21, %rsi
1 1 0.25 xorq %rcx, %rsi
1 1 0.25 xorq %rdx, %rsi
1 1 1.00 * movq %rsi, _ZL1s.0(%rip)
1 1 0.50 rolq $28, %rdx
1 1 1.00 * movq %rdx, _ZL1s.1(%rip)
3 7 1.00 U retq

0
Tomasz_D napisał(a):

@MarekR22: najdziwniejsze jest w tym narzędziu to, że daje inne latency dla tego samego kodu z komentarzami i bez:

Zapewne używałeś narzędzia nieprawidłowo.
Zgaduję, że nie pdałeś typu procesora (tak jek to robi Matt na demie), przypuszczam, że w takim wypadku tool używa procesora na jakim został właśnie uruchomiony.
Ergo jak ten twój kod wylądował na innym serwerze z innym CPU to dał inne wyniki i stąd złudzenie, że usunięcie komentarzy coś zmieniło.

Mam wrażanie, że masz za dużo pewności siebie. Testowanie perfomance i dociekanie co jest powodem danych wyników jest bardzo trudne.
Po piersze łatwo zrobić błedy, których nie widać na pierwszy rzut oka.
Po drugie trzbea znać zaawansowane narzędzia
Po trzecie trzeba mieć dość zaawanasowaną widzę na temat hardware by być w stanie zrozumieć jak wszystko razem działa.

Sam w tych obszarach nie czuję sie pewnie, ja ci tylko mogę wskazą cźródła i kieruni, ale wiele więcej niej jestem w stanie pomóc.

1 użytkowników online, w tym zalogowanych: 0, gości: 1