Vyzkoušel jsem si kódy z repozitáře pana autora a dovolím si rozebrat nebo doplnit některé informace z článku.
(Určitě se podívejte na odstavec 4)
Testoval jsem nativní implementaci v C a numba7 primárně na velikosti 4096 a 25500 iteracích.
Stroje (všechny nastaveny 1 vlákno na jádro)1) Pokud chceme měřit skutečnou rychlost výpočtu, tak dle mého názoru, měřit celkový běh programu nedává moc smysl a mnohem přínosnější je měřit jen čas provedení výpočetní funkce, protože zápis na standardní výstup je celkem pomalý. A to například pomocí gettimeofday() z knihovny sys/time.h a pro python time.time() z knihovny time. Například na stroji 3. trvá nativní výpočet 3.514 sekund a tisk na stdout 2.771 sekund, takže IO prodlužuje dobu běhu programu o cca 78%. Je sice pravda že tam to zdržení je a bude, ale dle mého názoru není zajímavé dokud nebudeme optimalizovat IO.
2) Ofast, Obad, aneb velmi rychlá střelba do nohy. -Ofast v nativní implementaci nepřináší téměř žádný další výkon oproti -O3. Navíc při dalším rozšiřování programu se dřív nebo později projeví chybným nebo nezvyklým chováním programu.
Myslím že lepší volbou jsou například -mtune=native a -march=native
3) Sice Amdahlův zákon nás omezuje, ale naštěstí je tu Gustafsonův zákon. Jednoduše řečeno, Amdahlův říká něco jako že pokud v programu je nějaká sekvenční část, tak více jader nepomůže. Gustafsonův zákon na to jde obráceně a říká něco ve smyslu, že pokud máš příliš mnoho jader, tak zvětši řešený problém a zachováš si tím zrychlení. Tyto zákony se objevují taky pod pojmy slabé a silné škálování.
4) Výrok: "výpočet ve více vláknech v C lze realizovat mnohem složitěji" Není pravdivý. Již nějakou dobu je tu s námi knihovna OpenMP, která ve světě C/C++ působí jako zázrak, protože pomocí jednoduchého makra
#pragma omp parallel for paralelizujeme naší smyčku. Pak stačí jen kompilovat kód s flagem -fopenmp a mít v systému balíček openmp. Nejjednodušší použití vypadá například takto
#pragma omp parallel for for (int y = 0; y < height; y++) {
což vypadá mnohem více zadarmo než
@jit((int64, int64, int64, UniTuple(UniTuple(int64, 3), 256)), nopython=True, parallel=True) def calc_mandelbrot(width, height, maxiter, palette):Samozřejmě musíme stejně jako v případě numby vnořit proměnnou cy dovnitř prvního cyklu
5) Zde je menší porovnání
C ~ 1 jádro,
OpenMP ~ všechny jádra,
Numba ~ všechny jádra
stroj | 2048 4096 __________|_____________________ 1. C | 61.73 246.7 1. OpenMP | 16.80 67.21 1. Numba | 32.55 130.9 | 2. C | 67.52 269.8 2. OpenMP | 6.951 27.68 2. Numba | 19.28 76.72 | 3. C | 54.24 217.6 3. OpenMP | 0.883 3.514 3. Numba | 2.800 11.10
Na závěr bych chtěl dodat, že mě velice mile překvapilo kam až to python výkonově dotáhl.
Díky za doplnění.
Trošku jsem popravdě nepochopil bod 1 - to je přece tématem hned několika kapitol, aby to čtenáře upozornilo, že výsledky benchmarků jsou jedna věc a co měříme věc druhá. V další části článku se už I/O neprojevuje, ale JIT ano (naschvál, to nemůžu vyhodit, protože to vede ke špatným závěrům)
2) Ofast tam je naschvál (zmíněno v článu), aby se ukázalo, že můžeme i "šidit" a obejít IEEE 754. Resp. naopak, aby to ukázalo, že všechny ostatní benchmarky produkují na pixel přesné výsledky a má smysl je porovnávat, ale tento konkrétní prostě cheatuje.
Za 4 moc díky! Měl jsem to do článku dát, to je pravda.
24. 11. 2023, 08:36 editováno autorem komentáře
Ten bod 1 jsem myslel tak, že proč se snažit eliminovat vliv IO na dobu výpočtu, tím že ho zmenšíme, když ho nemusíme měřit vůbec. (myšleno v případě kdy nás zajímá pouze výkon výpočtů)
Navíc i print může být ovlivněn například tím jak rychle někdo zpracovává data v z roury, do níž náš print cpe data. (Jsou lepší způsoby jak dostat data na disk než pomocí formátovaného výstupu)
To asi záleží jestli potřebujete paralelismus rychle a jednoduše, nebo jste ochotný si to udělat sám efektivněji.
Navíc omp neumí jen hloupé rozbalování smyček ale i má nástroje na synchronizaci a něco jako vlastní obal vláken a mutexů. A kdo ví co dalšího.
Zde jednoduchá ukázka vláken
void mujprint(int k){ usleep(k * 1000); printf("%d\n", k); } int main (int argc, char** argv){ #pragma omp parallel //paralelní sekce { #pragma omp master //pouze vlákno 0 { mujprint(5); #pragma omp task //spawn tasku mujprint(4); #pragma omp task mujprint(3); #pragma omp task mujprint(2); #pragma omp taskwait //počká na všechny tasky mujprint(1); } } return 0; }
Výstup toho to programu je při kompilaci s -fopenmp
5 2 3 4 1
a s vypnutým OpenMP
5 4 3 2 1
Další výhodou použití OpenMP je možnost si zvoli při kompilaci jestli poběží paralelně, nebo ne. Bez přepínače -fopenmp se omp makra ignorují.
Podle mě pokud kdokoliv sáhne po C++, tak chce právě tu efektivitu.
Expresivita openmp je podle mě mizerná a upřímně já jako uživatel aplikace chci i možnost zvolit si kolik třeba některé operace v té appce použijou vláken. Prostě nechci aby nějaký průchod polem co má 1000 prvků hned vytížil všechny thready, které se pak budou hádat o cache lines sousedních elementů...
Také by se dalo říci, že kdokoliv chce pravou nefalšovanou efektivitu, tak sáhne po assembleru.
Expresivita openmp je podle mě dostačující a já jako uživatel jsem snad nikdy neviděl apku co by uměla podrobnější nastavení než prostě počet použitých vláken.
OpenMP není o tom aby vše bylo absolutně perfektní a dokonalé. Je tu pro ty co jsou ochotni za jednoduchost zaplatit určitým snížením efektivity.
Když něco programuji co není hotové a do 2 minut to mám rychlejší třeba 10x, tak mě ty 2 minuty tolik nebolí když pak musím něco předělat.
Řešení na váš požadavek jsem teda z hlavy nedal a musel jsem to vyhledat a zde by mohlo být řešení, je tedy trochu expresivnější než bych sám chtěl, ale vypadá to, že funguje.
#include <stdio.h> #include <omp.h> int main() { #pragma omp teams num_teams(1) thread_limit(2) { #pragma omp parallel for for(int i = 0; i < 8; ++i){ int thread_id = omp_get_thread_num(); printf("Hello from FOR_1 from thread %d in team %d on idx %d\n", thread_id, omp_get_team_num(), i); } } return 0; }
Hello from FOR_1 from thread 1 in team 0 on idx 4 Hello from FOR_1 from thread 1 in team 0 on idx 5 Hello from FOR_1 from thread 1 in team 0 on idx 6 Hello from FOR_1 from thread 1 in team 0 on idx 7 Hello from FOR_1 from thread 0 in team 0 on idx 0 Hello from FOR_1 from thread 0 in team 0 on idx 1 Hello from FOR_1 from thread 0 in team 0 on idx 2 Hello from FOR_1 from thread 0 in team 0 on idx 3
Dokonce můžeme například zpracovat 2 smyčky zároveň, každou pomocí 2 vláken
#include <stdio.h> #include <omp.h> int main() { #pragma omp teams num_teams(2) thread_limit(2) { if (omp_get_team_num() == 0) #pragma omp parallel for for(int i = 0; i < 4; ++i){ int thread_id = omp_get_thread_num(); printf("Hello from FOR_1 from thread %d in team %d on idx %d\n", thread_id, omp_get_team_num(), i); } if (omp_get_team_num() == 1) #pragma omp parallel for for(int i = 0; i < 4; ++i){ int thread_id = omp_get_thread_num(); printf("Hello from FOR_2 from thread %d in team %d on idx %d\n", thread_id, omp_get_team_num(), i); } } return 0; }
Hello from FOR_1 from thread 1 in team 0 on idx 2 Hello from FOR_1 from thread 1 in team 0 on idx 3 Hello from FOR_1 from thread 0 in team 0 on idx 0 Hello from FOR_1 from thread 0 in team 0 on idx 1 Hello from FOR_2 from thread 0 in team 1 on idx 0 Hello from FOR_2 from thread 0 in team 1 on idx 1 Hello from FOR_2 from thread 1 in team 1 on idx 2 Hello from FOR_2 from thread 1 in team 1 on idx 3
V případě vybíjení cache lines,
by mohla fungovat klauzule schedule(static, "počet_prvků"), která nastaví, že každé vlákno zpracuje určitý počet prvků. Mám dojem že ve bez použití klauzule schedule, se prostě práce dělí počtem vláken (rozestup je maximální). Pokud v prvním kódu přidáme za "parallel for" klauzuli shedule(static,1), tak je výstup takovýto
Hello from FOR_1 from thread 0 in team 0 on idx 0 Hello from FOR_1 from thread 0 in team 0 on idx 2 Hello from FOR_1 from thread 0 in team 0 on idx 4 Hello from FOR_1 from thread 0 in team 0 on idx 6 Hello from FOR_1 from thread 1 in team 0 on idx 1 Hello from FOR_1 from thread 1 in team 0 on idx 3 Hello from FOR_1 from thread 1 in team 0 on idx 5 Hello from FOR_1 from thread 1 in team 0 on idx 7
Pro více-dimenzionální případ bych zvážil ještě použití klauzule collapse
Hlavně to neberte tak že se tu snažím tlačit OpenMP jako všelék. Všelék to rozhodně není. OpenMP není ani lepší než klasická vlákna, ani horší, je prostě jiné.
Myslím, že je dobré aby lidé věděli že existuje a na co je a není vhodné.
Btw blízké době budu studovat jeho použití pro programování na GPU, tak se kdyžtak podělím.
Souběžné čtení a zápis už je na programátorovi stejně jako kdyby to udělal ručně přes vlákna.
Některé operace ovšem jdou udělat velice jednoduše.
Například chcete udělat histogram (spočítá se četnost výskytů prvků v datech). V té chvíli je velký problém s přístupem do pole histogramu.
Při použití vlastních vláken mě napadají 3 přístupy: mutex(y), atomické operace, každé vlákno vlastní histogram (nejrychlejší).
Ale v tomto vám taky může OpenMP usnadnit práci.
Zde od nejpomalejšího po nejrychlejší
void histo_1(uint8_t * data, uint32_t * histogram){ #pragma omp parallel for for(uint64_t i = 0; i < SIZE; ++i){ #pragma omp critical histogram[data[i]]++; } } void histo_3(uint8_t * data, uint32_t * histogram){ #pragma omp parallel for for(uint64_t i = 0; i < SIZE; ++i){ #pragma omp atomic histogram[data[i]]++; } } void histo_4(uint8_t * data, uint32_t * histogram){ #pragma omp parallel for reduction(+:histogram[:256]) for(uint64_t i = 0; i < SIZE; ++i){ histogram[data[i]]++; } }
Je dost možné že Numba umí něco podobného.
Můžeš prosím nějak doplnit tu info o Amdahlově a Gustafsonově zákonu. IMHO Amdahlův zákon nastavuje tvrdé limity, přes které to nejde. Prostě pokud 10% problému bude nutno řešit "sériově", nikdy se nedá dostat na speedup větší než 10x, i při nekonečném počtu jader. Gustafson mi s tímto nějak nepomůže - může pouze vylepšit to, do jaké míry se k tomu 10x speedupu přiblížím.