Python vs. Fortran vs. Fortran pro Python

Naším cílem je uchopit v Pythonu výpočetně intenzivní úlohu a dosáhnout co nejvyšší rychlosti jejího běhu. Nejprve vytvoříme skript přeložitelný a paralelizovatelný pomocí balíčku Numba, poté zpracujeme obdobný program ve Fortranu (zde navíc s volitelnou schopností počítat na GPU) a ten zpřístupníme pro Python pomocí nástroje f2py. Zdrojové soubory úlohy v Pythonu a Fortranu jsou ke stažení na konci stránky.

Budeme na obdélníku v komplexní rovině testovat, které jeho body leží v Mandelbrotově množině. Test pro daný bod provede vnitřní funkce testMandelbrot(x,y) užitím cyklu s neparalelizovatelnými iteracemi. Paralelizovatelná bude vnější funkce loopMandelbrot(nxmax,nymax), která bude systematicky vybírat body z obdélníka daného rozlišení a ty předkládat pro otestování paralelně běžícím vláknům programu. Návratovou hodnotou vnější funkce bude počet bodů cnt, které náš test z Mandelbrotovy množiny nevyloučil, a poměr cnt/(nxmax*nymax) udávající relativní velikost Mandelbrotovy množiny ve zvoleném obdélníku. Úloha je compute-bound (hodně výpočtů), nikoliv memory-bound (žádná pole), tedy s dobrou nadějí na paralelizační úspěch; její vlastností je ovšem i nerovnoměrné množství práce ve vnitřní funkci pro jednotlivé body.

Python a Numba

Běžnou cestou ke zrychlování pythonských funkcí je jejich překlad užitím balíčku Numba. Výpočetně intenzivní funkce bývá vhodné upravit v duchu strukturovaného programování tak, aby obsahovaly cykly a podmínky operující na skalárních proměnných a prvcích NumPy polí běžných datových typů. Takové funkce se označí direktivou @njit (just-in-time compilation in nopython mode) a po spuštění skriptu se je Numba pokusí jednorázově přeložit do strojového kódu. Je-li ve funkci paralelizovatelný indexovaný cyklus, použije se direktiva ve tvaru @njit(parallel=True) a místo sekvenčního objektu (funkce) range paralelizovatelný prange. Systém zvolí počet paralelních vláken ve shodě s počtem jader procesoru nebo lze počet vláken stanovit přiřazením do proměnné prostředí NUMBA_NUM_THREADS. Body k otestování přiřazuje Numba jednotlivým vláknům staticky, tedy každému vláknu přiřadí stejný počet bodů, což při nerovnoměrné práci na jednotlivých bodech vede k nerovnoměrné délce běhu vláken. Tomu lze do jisté míry čelit vytvořením většího počtu vláken, čímž se zredukuje množství práce připadající na vlákno a tím i prodleva při čekání na vlákna, která obdržela práce nejvíc. (Moderní Numba, novější než Numba v Ubuntu 22.04, nabídne řešení srovnatelné s dynamickým přidělováním práce v OpenMP: set_parallel_chunksize.) Numba umožňuje i přesun výpočtů na rychlé grafické procesory (GPU), činí tak ovšem programátorsky náročným způsobem. Fortran nabídne snadnější cestu, kterou bohužel pomocí f2py zpřístupnit pro Python neumíme.

Překlad Numbou vedl k dvouřádovému zrychlení skriptu, paralelizace na 8jádrovém hyper-threadovém (HT) procesoru běh dále zrychlila 5krát při 16 vláknech a 9krát při 80 vláknech. Skript vykazuje podobné chování v Linuxu i Windows. (Případné varování o ignorování vysokého počtu vláken se zdá být irelevantní.)

Spuštění:

(lnx) NUMBA_NUM_THREADS=16 python mandel.py
(win) set NUMBA_NUM_THREADS=16 && python mandel.py

Fortran

Od Fortranu jako překládaného jazyka lze čekat rychlost srovnatelnou s rychlostí skriptů přeložených Numbou. Překladače mívají nastavitelnou schopnost optimalizace, pro odladěné programy bývá žádoucí jejím nastavením zvýšit rychlost jejich běhu. Může se lišit rychlost běhu programů přeložených různými překladači. S populárním překladačem gfortran z GNU Compiler Collection (GCC) se pro sekvenční běh s optimalizací překládá příkazem gfortran -O file.f90 a pro vláknově paralelizovaný běh příkazem gfortran -O -fopenmp file.f90. Tak se interpretují tzv. OpenMP direktivy (značené !$OMP ve zdrojovém kódu), udávající způsob paralelizace. Použili jsme zde klauzuli SCHEDULE pro dynamické přidělování práce vláknům, což umožňuje vyrovnat zátěž a dobu běhu jednotlivých vláken. Počet vláken se nastaví přiřazením do proměnné prostředí OMP_NUM_THREADS a optimální je ponechat jej ve shodě s počtem jader procesoru (tj. 8 nebo spíše 16 pro 8jádrový HT procesor).

K dispozici jsou i obdobné tzv. OpenACC direktivy (uvozené !$ACC), umožňující přesouvat výpočty na GPU. gfortran tyto direktivy zpracovat umí, lépe vybaveným je však v tomto směru Nvidia překladač nvfortran. Na druhou stranu, nvfortran už delší dobu nepodporuje Windows (lze však provozovat v rámci Windows Subsystem for Linux). nvfortran má také potíže s vazbou na Python pomocí f2py.

Optimalizovaný překlad gfortranem i nvfortranem má dobu jednovláknového běhu srovnatelnou s Numbou. (Bez optimalizace stoupla doba běhu téměř 2krát, s vyššími stupni optimalizace klesla o několik procent.) OpenMP paralelizace s dynamickým přidělováním práce na 8jádrovém HT procesoru běh zrychlila 13krát s oběma překladači. V 8bytové double precision (DP) reálné aritmetiky dokázala OpenACC paralelizace na GPU Nvidia GeForce běh zrychlit oproti OpenMP paralelizované verzi 2násobně (na GPU a CPU v cenové relaci po 10000 Kč) až 4násobně (na GPU za 25000 Kč) pomocí nvfortranu; s gfortranem GPU běžely o desítky procent pomaleji. Šampionem v době běhu našeho testu je tedy nvfortran na GPU, a to jak v DP, tak tím spíše v 4bytové single precision (SP), kde mají testované Nvidia GeForce GPU teoretický výkon 32-64x vyšší než v DP. GPU řešení bohužel nedokážeme přenést do Pythonu. Vše zmíněné jsme zprovoznili v Linuxu, ve Windows jen gfortran a OpenMP.

Překlad a spuštění:

sekvenční překlad a spuštění: (lnx) gfortran -O3 mandel.f90; ./a.out
                              (lnx) nvfortran -O3 mandel.f90; ./a.out
                              (win) gfortran -O3 mandel.f90 && a
OpenMP:  (lnx) gfortran -O3 -fopenmp mandel.f90; OMP_NUM_THREADS=16 ./a.out
         (lnx) nvfortran -O3 -mp mandel.f90; OMP_NUM_THREADS=16 ./a.out
         (win) gfortran -O3 -fopenmp mandel.f90 && set OMP_NUM_THREADS=16 && a
OpenACC: (lnx GPU) gfortran -O3 -fopenacc -foffload=nvptx-none mandel.f90; ./a.out
         (lnx GPU) nvfortran -O3 -acc mandel.f90; ./a.out
         (lnx CPU) nvfortran -O3 -acc=multicore mandel.f90; ACC_NUM_CORES=16 ./a.out

Python a f2py

Součást f2py balíčku NumPy umožňuje uchopit fortranský zdrojový kód a vytvořit pythonský modul s přeloženými fortranskými procedurami. f2py klade řadu omezení na překládané fortranské zdroje a spolupracuje jen s některými překladači (např. GCC, Intel), přesto s ním lze dosáhnout pěkných výsledků. Použít lze v Linuxu i Windows; bohužel jen v Linuxu se nám daří úspěšně připojovat další knihovny, což je nezbytné např. pro OpenMP. Nedokázali jsme pomocí f2py zpracovat Fortran s OpenACC, ostatně ani překládat pomocí nvfortranu.

Překlad a spuštění, včetně optimalizace a OpenMP:

(lnx OpenMP) f2py -c -m mandel --opt=-O3 --f90flags=-fopenmp -lgomp mandel.f90
             OMP_NUM_THREADS=16 python mandel-f90.py
(win) f2py -c -m mandel --opt=-O3 mandel.f90 && python mandel-f90.py

Ve Windows může být třeba program nejprve přeložit, gfortran -c -O3 mandel.f90, a po překladu přesunout vytvořený dll soubor do adresáře ke skriptu.

Výsledky

Hardware
Software
Ubuntu 22.04, Windows 10
Python 3.10.6, Numba 0.55.1, NumPy/f2py 1.21.5, gfortran 11.3.0, nvfortran 22.11
Testování 20000*20000=4e8 bodů
odhad počtu operací (počet bodů*mez iterací*odhad počtu operací*pokrytí)
4e8*100*10*0.25 = 100 GFLO (giga-floating-point-operations)
teoretický výkon CPU (počet jader*frekvence)
8*5e9 = 40 GFLOPS (giga-floating-point-operations-per-second)

prostředí

počet vláken

doba běhu

zrychlení

Python Numba

1

31.7 s

1.0x

…

16

5.7 s

5.5x

…

80

3.6 s

8.8x

gfortran -O3

1

30.4 s

1.0x

…

16

2.3 s

13.2x

Python f2py

16

2.3 s

13.2x

Testování 50000*50000=25e8 bodů
odhad počtu operací (počet bodů*mez iterací*odhad počtu operací*pokrytí)
25e8*100*10*0.25 = 625 GFLO
teoretický výkon CPU (počet jader*frekvence)
8*5e9 = 40 GFLOPS
teoretický výkon GPU (počet multiprocesorů*počet jednotek v multiprocesoru*frekvence)
2070: (DP) 36*2*1.6e9 = 120 GFLOPS, (SP) 36* 64*1.6e9 = 3.7 TFLOPS
3070: (DP) 46*2*1.8e9 = 170 GFLOPS, (SP) 46*128*1.8e9 = 10.6 TFLOPS
4070: (DP) 60*2*2.6e9 = 310 GFLOPS, (SP) 60*128*2.6e9 = 20.0 TFLOPS
Na Wikipedii jsou uváděny 2krát vyšší hodnoty kvůli započtení kombinované MAD instrukce.

prostředí

vláken, přesnost

doba běhu

zrychlení

Python Numba

16

33.1 s

0.4x

…

80

18.7 s

0.8x

gfortran CPU

16

14.5 s

1.0x

… RTX 2070

DP, SP

7.6 s, 0.6 s

1.9x, 24x

… RTX 3070

DP, SP

6.0 s, 0.5 s

2.4x, 29x

… RTX 4070

DP, SP

3.3 s, 0.2 s

4.4x, 72x

nvfortran CPU

16

14.5 s

1.0x

… RTX 2070

DP, SP

6.3 s, 0.3 s

2.3x, 48x

… RTX 3070

DP, SP

4.9 s, 0.2 s

3.0x, 72x

… RTX 4070

DP, SP

3.2 s, 0.1 s

4.5x, 145x

Python f2py

16

14.5 s

1.0x

Shrnutí

Připravili a spustili jsme pythonský skript zatěžující procesor (nikoliv paměťový systém) s využitím překladu balíčkem Numba. Běh se podařilo zparalelizovat, jistou újmu na době běhu způsobilo staticky přidělované nerovnoměrné množství práce v jednotlivých iteracích. Navrhli jsme pomoc v podobě nasazení vyššího počtu vláken než jader. Počítali jsme v Linuxu i Windows, omezili jsme se jen na CPU.

Shodný fortranský program jsme paralelizovali pro CPU v Linuxu i Windows pomocí direktiv OpenMP, přičemž odhad teoretického výkonu CPU, celkové práce programu a doby běhu byly v pozoruhodném souladu. V Linuxu jsme dokázali pomocí OpenACC paralelizovat i pro GPU Nvidia GeForce, poskytující v DP až 4násobné zrychlení proti plně zatíženému 8jádrovému CPU a v SP řádové zrychlení proti DP. Uvedená zrychlení jsou opět přiměřeně srovnatelná s teoretickým výkonem GPU.

Pokusili jsme se fortranská řešení importovat do Pythonu pomocí f2py. Na CPU jsme uspěli v Linuxu s verzí paralelizovanou pomocí OpenMP a ve Windows jen se sekvenčním překladem. Dosáhli jsme tím v Pythonu plné fortranské rychlosti – v Linuxu paralelního, ve Windows sekvenčního běhu. S f2py importem pro GPU jsme neuspěli.

V Pythonu jsme tedy dosáhli nejrychlejšího běhu v Linuxu s paralelizovaným fortranským modulem zpřístupněným pomocí f2py a ve Windows pomocí paralelizujícího balíčku Numba. Problémy f2py s připojením knihoven a paralelizací ve Windows patrně nejsou neřešitelné, ostatně obejít je lze kdykoliv pomocí Windows Subsystem for Linux.

Zdrojové soubory pro Python a Fortran: zip.

Otázkou k zodpovězení zůstává, proč se při vysokých počtech testovaných bodů výsledky běhů mírně liší. Rozdíly jsou mezi Pythonem, Fortranem na CPU a Fortranem na GPU; neliší se výsledky v témže prostředí mezi překladači ani vlivem paralelizace.

27. 5. 2023