Pomiary prędkości kilku PRNG w c++

0

Mam 3 generatory PRNG, których czasy chcę zmierzyć. Xoroshiro128+:

#include <stdint.h>
#include <iostream>
#include <chrono>

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

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

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

	s1 ^= s0;
	s[0] = rotl(s0, 24) ^ s1 ^ (s1 << 16); // a, b
	s[1] = rotl(s1, 37); // c

	return result;
}

int main()
{
    double a=0;

    uint64_t result;

    for(int i=0; i<100; i++)
    {
    auto begin = std::chrono::high_resolution_clock::now();
    for(int i=0; i<320000000; i++)
    //while (true)
    {
    result = next();
    //std::cout << result << "\n";
    //char *c = reinterpret_cast<char*>(&result);
    //std::cout.write(reinterpret_cast<char*>(&result), sizeof result);
    }
    auto end = std::chrono::high_resolution_clock::now();
    auto elapsed = std::chrono::duration_cast<std::chrono::nanoseconds>(end - begin);
    printf("Time measured: %.3f seconds.\n", elapsed.count() * 1e-9);
    a = a + (elapsed.count() * 1e-9);
    }
__asm__ volatile("" : "+g" (result) : :);
std::cout << a/100;
}

Kod wzięty stąd: https://prng.di.unimi.it/xoroshiro128plus.c. Generator Lehmera 64-bitowy truncated (mój własny kod):

#include <iostream>
#include <cmath>
#include <stdio.h>
#include <chrono>

uint64_t LCG(uint64_t LCG_state)
{
    LCG_state = (LCG_state * 2862933555777941757 + 1422359891750319841);
    return LCG_state;
}

int main()
{
auto begin = std::chrono::high_resolution_clock::now();

    double a = 0;
    uint64_t LCG_state = 333;
    uint32_t result;

    for(int i=0; i<100; i++)
    {
    auto begin = std::chrono::high_resolution_clock::now();
    for(int i=0; i<640000000; i++)
    {
        LCG_state = LCG(LCG_state);
        result = LCG_state >> 32;
    }
    auto end = std::chrono::high_resolution_clock::now();
    auto elapsed = std::chrono::duration_cast<std::chrono::nanoseconds>(end - begin);
    printf("Time measured: %.3f seconds.\n", elapsed.count() * 1e-9);
    a = a + (elapsed.count() * 1e-9);
    }
__asm__ volatile("" : "+g" (result) : :);
std::cout << a/100;
}

Oraz generator PCG:

#include <iostream>
#include <cmath>
#include <stdio.h>
#include <stdint.h>
#include <chrono>

typedef struct { uint64_t state;  uint64_t inc; } pcg32_random_t;

uint32_t pcg32_random_r(pcg32_random_t* rng)
{
    uint64_t oldstate = rng->state;
    // Advance internal state
    rng->state = oldstate * 6364136223846793005ULL + (rng->inc|1);
    // Calculate output function (XSH RR), uses old state for max ILP
    uint32_t xorshifted = ((oldstate >> 18u) ^ oldstate) >> 27u;
    uint32_t rot = oldstate >> 59u;
    return (xorshifted >> rot) | (xorshifted << ((-rot) & 31));
}


int main()
{
        double a = 0;
        uint32_t result;
        pcg32_random_t rnd_ctx = {123, 111};

        for(int i=0; i<100; i++)
        {
        auto begin = std::chrono::high_resolution_clock::now();
        for(int i=0; i<640000000; i++)
        {
        result = pcg32_random_r(&rnd_ctx);
        }
        auto end = std::chrono::high_resolution_clock::now();
        auto elapsed = std::chrono::duration_cast<std::chrono::nanoseconds>(end - begin);
        printf("Time measured: %.3f seconds.\n", elapsed.count() * 1e-9);
        a = a + (elapsed.count() * 1e-9);
        }
__asm__ volatile("" : "+g" (result) : :);
std::cout << a/100;
}

Kod stąd: https://www.pcg-random.org/download.html.

Robię pomiary czasu i otrzymałem następujące wyniki dla tych generatorów:

  • LCG ok. 1 sekunda,
  • PCG ok. 1 sekunda,
  • xoroshiro128+ około 1,5 sekundy.

Zauważcie, że xoroshiro128+ ma mniej powtórzeń, bo zwraca liczby 64-bitowe, a ja chcę zmierzyć sekundy na bajt. Problem polega na tym, że xoroshiro128+ powinien być znacznie szybszy niż pozostałe dwa generatory. Mierzę kod w trybie release. Ale wciąż coś zdaje się istotnie zaburza mi wyniki.

Co Wam wychodzi i, czy jest jeszcze coś, co powinienem zrobić, żeby te wyniki były bardziej obiektywne?

4

Stawiam, że rozbijasz się o model pamięci (cache? Bez głębszej analizy trudno mi powiedzieć).
Tutaj Twój kod, wyniki podobne do Twoich:
https://quick-bench.com/q/ZLKINsL8St2wwL2Kii_ijSO1z4U

Tutaj podobnie, ale stan xoroshiro128 wciągnięty do wewnątrz (ale static, więc nadal globalny):
https://quick-bench.com/q/im0xsY-Sh9_pFMf9iFVduBDXQus
wyniki analogiczne.

A tutaj Twój kod przerobiony na stan lokalny:
https://quick-bench.com/q/Btm-f5-lKQMGIDnKTe9SZw9lLTU (wyniki wszystkich trzech zbliżone).
Jest szybszy niż dwa inne, ale czy "znacznie" to sprawa dyskusyjna.

EDIT: no i nie dyskutowałbym o obiektywności za bardzo, natomiast jeśli chcesz porównywać algorytmy bazujące na jakimś stanie, to niech on będzie przechowywany w sposób porównywalny - globalnie, lokalnie - jeden pies jak, ale porównywalnie.

0

Stawiam, że rozbijasz się o model pamięci (cache? Bez głębszej analizy trudno mi powiedzieć).

Nie wiem. Uruchamiam kod w Code Blocks w trybie release i nad niczym innym nie mam kontroli.

EDIT: no i nie dyskutowałbym o obiektywności za bardzo, natomiast jeśli chcesz porównywać algorytmy bazujące na jakimś stanie, to niech on będzie przechowywany w sposób porównywalny - globalnie, lokalnie - jeden pies jak, ale porównywalnie.

To wiem, ale nie wiedziałem jak w xoroshiro128+ zdefiniować static uint64_t s[2] = {9,5}; lokalnie. Wpisanie tego w funkcji zwraca błędy.

Co do wyników, to mam dwa testy porównawcze:

https://prng.di.unimi.it

https://github.com/lemire/testingRNG

PCG (PCG32) według wyników Lemire osiąga 1.51 cycles per byte a xoroshiro128plus: 0.83 cycles per byte. Z kolei według Vigny PCG RXS M XS 64 (LCG) osiąga 0.52 cykla na bajt, ale to jest wersja zwracająca pełne 64 bity, więc żeby mieć porównanej do wersji, którą my testujemy (ucinamy połowę bitów), należałoby pomnożyć wynik przez 2. Z kolei xoroshiro128plus według Vigny osiąga 0,29 cykla na bajt. Więc jest nie prawie 2 razy szybszy (jak u Lemire), ale 3 razy szybszy.

Powoli zaczynam się zastanawiać, czy podobne pomiary w ogóle mają sens, skoro nie można ich odtworzyć w realnych warunkach. Chyba pójdę na forum Vigny i go zapytam co robimy źle: https://groups.google.com/g/prng.

1

Nie wiem. Uruchamiam kod w Code Blocks w trybie release i nad niczym innym nie mam kontroli.

Wybacz, ale pomiary prędkości w taki sposób nie mają sensu, zwłaszcza kiedy autor publikacji pisze wyraźnie, że:

Code has been compiled using gcc's -fno-move-loop-invariants and -fno-unroll-loops options. These options are essential to get a sensible result: without them, the compiler can move outside the testing loop constant loads (e.g., multiplicative constants) and may perform different loop unrolling depending on the generator. For this reason, I cannot provide timings with clang: at the time of this writing there is no way to avoid constants loads outside of the loop. If you find timings that are significantly better than those shown here on comparable hardware, they are likely to be unreliable and just due to compiler artifacts (e.g., vectorization).

Przy czym nieodwijanie pętli może lub nie być pożądane w realnej sytuacji; autor publikacji przyjął tu po prostu założenie x [EDIT: chyba, że chodzi mu nieodwijanie pętli, w której leci pomiar. No można tak zrobić ale są lepsze techniki do tego niż globalna flaga kompilacji, bo to rzeźbą siekierą trąci]

Powtórzę to co też już padło na privie: ustawienia kompilacji (często z dokładnością do konkretnej podrodziny procesora) są kluczowe przy pomiarach prędkości i podejście „mam tryb release bez większej kontroli” naprawdę nie ma żadnego sensu. Zero. Null.

EDIT: jak chcesz porównywać to raczej nie obejdzie się bez przejścia przez całość publikacji włącznie z całym build envem.
To nie o to chodzi że nie chcę Ci pomóc ale do tego typu solidnej analizy porównawczej raczej będzie potrzebne więcej czasu niż wrzutka „szybka fucha” ;)

0

Przy czym nieodwijanie pętli może lub nie być pożądane w realnej sytuacji; autor publikacji przyjął tu po prostu założenie x.

A czy quick-bench.com temu nie zapobiega? Rozumiem, że nie, skoro wyniki wydają się podejrzane. A jako narzędzie do benmarkingu chyba powinno. Zresztą pierwsze słyszę o niedowijaniu pętli i nie za bardzo rozumiem na czym to polega. Wiem, że pętla mogła być w ogóle pomijana, dlatego wstawiłem asm volatile("" : "+g" (result) : :);.

EDIT: jak chcesz porównywać to raczej nie obejdzie się bez przejścia przez całość publikacji włącznie z całym build envem.
To nie o to chodzi że nie chcę Ci pomóc ale do tego typu solidnej analizy porównawczej raczej będzie potrzebne więcej czasu niż wrzutka „szybka fucha” ;)

Myślałem, że uzyskam sensowne wyniki, tyle, że obarczone np. jakimś systematycznym błędem (ewentualnie zaburzone pracą czegoś w tle). Np. wolniejsze niż naprawdę o czynnik 10. A to miałoby dla mnie wciąż wartość informacyjną, bo zależy mi wyłącznie na analizie porównawczej, bezwzględne wartości tak naprawdę mnie nie interesują. Ale widzę, że to nie takie proste, bo wyniki nie są obarczone systematycznym błędem, tylko się rozjeżdżają, każdy w swoją stronę. Całość tej publikacji jest dla mnie nie tylko nie do przejścia na tym etapie, ale nawet nie do zaczęcia.

Swoją drogą w internetowych poradnikach nt. jak zmierzyć execution time wszystko wydaje się takie proste. Żaden nie wspomina o trybie release, a już tym bardziej o jakimś niedowijaniu pętli. Po prostu użyj chrono i zmierz czas!

0

A czy quick-bench.com temu nie zapobiega? Rozumiem, że nie, skoro wyniki wydają się podejrzane.

Z glowy - nie wiem, raczej głównej pętli nie odwinie, bo sensu by to nie miało. Sprawdź wygenerowany asembler dla pewności.

A jako narzędzie do benmarkingu chyba powinno

Powinno to dać się konfigurować pod konkretne wymogi. Tam chyba jest instrukcja jak to odpalić lokalnie. Generalnie powinno nie odwijać głównej pętli gdzie leci pomiar, ale inne może lub nie wedle woli (kompilatora i/lub programisty).

Zresztą pierwsze słyszę o niedowijaniu pętli i nie za bardzo rozumiem na czym to polega.

Może tu jest Twój główny problem.

Żaden nie wspomina o trybie release, a już tym bardziej o jakimś niedowijaniu pętli.

Przepraszam, zabrzmię protekcjonalnie i złośliwie: dostałeś zadanie na studiach i nie wiesz jak je ugryźć czy o co właściwie chodzi?
Do czego Ci te porównania i co w zasadzie chcesz z tymi wynikami zrobić?

Generalnie polecam się zapoznać m.in. z:
https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html

https://www.linuxjournal.com/article/7269

… a jest tego dużo więcej.

0

Przepraszam, zabrzmię protekcjonalnie i złośliwie: dostałeś zadanie na studiach i nie wiesz jak je ugryźć czy o co właściwie chodzi?

Gorzej. W skrócie, od lat zajmuję się pewnym problemem matematycznym i po latach uzyskałem pewne ciekawe wyniki. Na ich podstawie stworzyłem generator liczb losowych, którym zainteresowałem autora podobnego generatora (pomimo, że nie mam wykształcenia matematycznego, nie jestem pracownikiem naukowym, nie jestem nawet programistą, ale świetnie znam problem Collatza i nieźle orientuję się w generatorach PRNG):

https://www.researchgate.net/publication/332583614_Pseudo-random_number_generators_based_on_the_Collatz_conjecture

Razem tworzymy patent wspomnianej technologii (liczymy, że znajdzie również zastosowania w kryptografii) oraz w planach jest publikacja naukowa. Dotychczas szacowałem złożoność tego algorytmu w odniesieniu do prostego generatora LCG, z uwagi na pewne podobieństwa. Ale po napisaniu kodu w c++ (bo Python się do takich rzeczy nie nadaje) dostałem dosyć niespodziewane wyniki. Aczkolwiek niespodziewane są także w przypadku innych generatorów, więc teraz przynajmniej wydaje się jasne, że to nie musi być faktyczny problem z moim generatorem.

Podstawowa (z naciskiem na podstawowa) znajomość Pythona pozwalała mi przetestować większość problemów i hipotez związanych z zagadnieniem. Programowanie pamiętam ze studiów (astronomia, fizyka, inżynieria środowiska), ale to były podstawy, a kursów wcale nie zdałem dobrze. Tak więc programista ze mnie słaby i o programowaniu wiem niewiele.

A dlaczego nie robi tego profesor computer science, z którym współpracuję? Nie ma czasu (zresztą nie jestem pewien, czy to i dla niego nie byłby problem, specjalizuje się w raczej w kompresji danych), a nasze prace przebiegają etapami. Tymczasem ja już teraz chciałem zdobyć jakiś wstępny proof of concept, bo szybkość takiego generatora jest istotną sprawą. Ostatecznie jestem pewien, że zrobimy te szacunki właściwie, ewentualnie, jeśli będzie to koniecznie pozyskamy kolejnego naukowca do publikacji. Niemniej sporo wysiłku wkładamy w to już teraz i źle by było, gdyby się okazało, że to nad czym pracujemy, ma parametry gorsze, niż się nam wydawało.

Stąd chciałem na szybko chociaż zobaczyć jak to wypada w c++, który nadaje się do tego rodzaju porównań, ale oczywiście nie miałem złudzeń, że nie będą to wyniki rozstrzygające, czy nadające się do prezentacji w publikacji naukowej. Sądziłem jednak, że biorąc grubą poprawkę, czegoś się z takich porównań dowiem.

0

Jeszcze ad vocem samych pomiarów. Chyba okazuje się, że te wyniki:

A tutaj Twój kod przerobiony na stan lokalny:
https://quick-bench.com/q/Btm-f5-lKQMGIDnKTe9SZw9lLTU (wyniki wszystkich trzech zbliżone).
Jest szybszy niż dwa inne, ale czy "znacznie" to sprawa dyskusyjna.

Są w miarę ok. Quick-bench nie zmierzył bowiem liczby powtórzeń, którą ja wpisałem 2 razy większą dla LCG i PCG w moim kodzie, tylko zmierzył performance tych kodów. Przekładając to na sekundy na bity, należy pomnożyć wyniki PCG i PCG przez 2, bo one zwracają tylko 32 bity, a xoroshiro128+ zwraca 64 bity. Wówczas PCG ma czas 8,15 a xoroshiro128+ ma czas 3,53. PCG jest 2,3 razy wolniejszy. Natomiast według wyników Lemire:

pcg32: 1.51 cycles per byte
xoroshiro128plus: 0.83 cycles per byte

https://github.com/lemire/testingRNG

jest 1,82 razy wolniejszy. Z kolei, gdy spojrzymy na wyniki Vigny:

https://prng.di.unimi.it

i porównamy to do tego, co otrzymał testując PCG RXS M XS 64 (LCG), w którym nie ucina się połowy bitów stanu na wyjściu, za to mixer bitów jest nieco wolniejszy (ale można z tego zrobić też generator z wyjściem 32-bitowym), to PCG jest 3,59 razy wolniejszy (szacuję czas PCG RXS M XS 64 na dwa razy dłuższy niż u Vigny, bo tak by się stało w przeliczeniu na sekundy na bity, gdybyśmy ucinali połowę bitów na wyjściu).

Oznacza to, że jesteśmy gdzieś po środku wyników Lemire oraz Vginy i na oko ma to sens (z podkreśleniem na oko, co już ustaliliśmy wcześniej).

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