Just in time překlad programů psaných v Pythonu nástrojem Numba

23. 5. 2023
Doba čtení: 34 minut

Sdílet

 Autor: Numba
Ve druhém pokračování miniseriálu o nástroji Numba si ukážeme především interní procesy, které Numba provádí při JITování kódu. Setkáme se tedy i s projektem LLVM, který je velmi populární, a to v mnoha oblastech.

Obsah

1. Just in time překlad programů psaných v Pythonu nástrojem Numba

2. Instalace nástroje Numba

3. Získání systémových informací i aktuální konfigurace Numby

4. Anotace skriptů zpracovávaných nástrojem Numba

5. Ukázka anotace jednoduché funkce sčítající dvě celočíselné hodnoty

6. Co z anotovaného kódu vyčteme?

7. Anotace stejné funkce, ovšem zavolané s jinými typy argumentů

8. Funkce, která je postupně volána s různými typy argumentů

9. Just in time překlad volaných funkcí do mezikódu LLVM

10. Rozdíly mezi funkcí volanou s celočíselnými parametry a s parametry typu double

11. Zobrazení optimalizovaného mezikódu

12. Překlad do assembleru cílové architektury

13. Využití SIMD instrukcí systémem Numba

14. Anotovaný zdrojový kód a optimalizovaný mezikód

15. Vygenerovaný kód v assembleru využívající SIMD instrukce

16. Režim fast math

17. Paralelizace výsledného kódu

18. Kdy se paralelizace vyplatí a kdy nikoli?

19. Repositář s demonstračními příklady

20. Odkazy na Internetu

1. Just in time překlad programů psaných v Pythonu nástrojem Numba

Ve druhém pokračování miniseriálu o nástroji Numba (viz též poněkud postarší článek [1]) si ukážeme především interní procesy, které Numba provádí při JITování kódu. Setkáme se tedy s projektem LLVM, který je velmi populární, a to v mnoha oblastech (stačí jen připomenout zajímavý projekt Emscripten atd.).

Nástroj Numba podporuje překlad vybraných částí kódu aplikace psané v Pythonu do nativního kódu cílové platformy (x86–64 apod.), přičemž cílovou platformou může být i GPU (přes CUDA). Jedná se tedy o takzvaný JIT neboli o just-in-time překladač, který má tu výhodu, že dokáže odvodit datové typy proměnných a argumentů funkcí na základě skutečného chování aplikace, tedy na základě typů předávaných parametrů a kontextu. To samozřejmě neznamená, že by JIT již při prvním volání funkce přesně věděl, jak má funkci přeložit.

Ve skutečnosti se dozví pouze informace o jediné konkrétní větvi, kterou může přeložit. V případě, že bude ta samá funkce později volána s odlišnými typy parametrů, popř. se její chování změní jiným způsobem (Python je velmi dynamický jazyk), provede se just-in-time překlad znovu, takže zde zaplatíme za vyšší výpočetní výkon poněkud většími paměťovými nároky a pomalejším během prvních volání funkce. Na druhou stranu mnoho náročných výpočtů používá Numpy a Numba s Numpy dokáže spolupracovat velmi dobře.

Z pohledu běžného vývojáře je největší předností tohoto způsobu překladu fakt, že není zapotřebí samotný zdrojový kód měnit (až na uvedení anotace před funkci). Nepříjemný je přesun času překladu do runtime, což sice nevadí u aplikací, které běží delší dobu, ovšem u jednorázových skriptů může být použití JITu spíše kontraproduktivní.

Samotný překlad je prováděn na několika úrovních, přičemž Numba na nižších úrovních využívá možností nabízených LLVM. Jedná se o relativně složitou problematiku, které se budeme věnovat v samostatném článku.

Poznámka: s JITy jsme se již na stránkách Roota setkali, především v souvislosti s LuaJITem a JVM. Další odkazy naleznete na konci článku.

2. Instalace nástroje Numba

V současnosti existuje hned několik možností, jak projekt Numba nainstalovat. Pravděpodobně nejjednodušší možnost představuje použití nástroje conda (viz též platformu Anaconda. Instalace bude v tomto případě probíhat takto:

$ conda install numba

popř. pro přechod na vyšší verzi:

$ conda update numba

Pokud namísto nástroje conda použijete nástroj pip (jenž je taktéž podporován), je nejprve vhodné provést upgrade pipu na novější verzi, protože instalace Numby se starým pipem není podporována:

$ sudo python3 -m pip install --upgrade pip
 
Collecting pip
  Downloading https://files.pythonhosted.org/packages/a4/6d/6463d49a933f547439d6b5b98b46af8742cc03ae83543e4d7688c2420f8b/pip-21.3.1-py3-none-any.whl (1.7MB)
    100% |████████████████████████████████| 1.7MB 764kB/s
Installing collected packages: pip
  Found existing installation: pip 9.0.3
    Uninstalling pip-9.0.3:
      Successfully uninstalled pip-9.0.3
Successfully installed pip-21.3.1

Pro jistotu zkontrolujeme, zda se používá skutečně poslední nainstalovaná verze nástroje pip:

$ pip3 --version
 
pip 21.3.1 from /usr/local/lib/python3.8/site-packages/pip

Nyní provedeme instalaci balíčku llvmlite, který obsahuje část projektu LLVM, který je nástrojem Numba interně používán:

$ pip3 install --user llvmlite
 
Collecting llvmlite
  Downloading llvmlite-0.36.0-cp36-cp36m-manylinux2010_x86_64.whl (25.3 MB)
     |████████████████████████████████| 25.3 MB 3.2 MB/s
Installing collected packages: llvmlite
Successfully installed llvmlite-0.36.0

A následně již můžeme nainstalovat samotnou Numbu:

$ pip3 install --user numba
 
Collecting numba
  Downloading numba-0.53.1-cp36-cp36m-manylinux2014_x86_64.whl (3.4 MB)
     |████████████████████████████████| 3.4 MB 1.4 MB/s
Requirement already satisfied: setuptools in /usr/lib/python3.8/site-packages (from numba) (37.0.0)
Requirement already satisfied: numpy>=1.15 in ./.local/lib/python3.8/site-packages (from numba) (1.19.4)
Requirement already satisfied: llvmlite<0.37,>=0.36.0rc1 in ./.local/lib/python3.8/site-packages (from numba) (0.36.0)
Installing collected packages: numba
Successfully installed numba-0.53.1
Poznámka: pochopitelně je možné předchozí dva kroky spojit, ovšem pro řešení problémů s instalací je asi vhodnější provádět celou instalaci krok po kroku.

Dalším krokem ihned po instalaci bude zjištění, zda se Numba nainstalovala korektně. Prvním pokusem bude pokus o spuštění příkazu numba, tj. především test, jestli tento příkaz leží na PATH:

$ numba
 
numba: error: the following arguments are required: filename

V případě, že se tento příkaz nepodaří spustit, většinou to znamená, že do PATH není zahrnuta cesta ~/.local/bin, což lze snadno napravit (.bashrc atd.).

Zobrazení nápovědy:

$ numba --help
 
usage: numba [-h] [--annotate] [--dump-llvm] [--dump-optimized]
             [--dump-assembly] [--dump-cfg] [--dump-ast]
             [--annotate-html ANNOTATE_HTML] [-s] [--sys-json SYS_JSON]
             [filename]
 
positional arguments:
  filename              Python source filename
 
optional arguments:
  -h, --help            show this help message and exit
  --annotate            Annotate source
  --dump-llvm           Print generated llvm assembly
  --dump-optimized      Dump the optimized llvm assembly
  --dump-assembly       Dump the LLVM generated assembly
  --dump-cfg            [Deprecated] Dump the control flow graph
  --dump-ast            [Deprecated] Dump the AST
  --annotate-html ANNOTATE_HTML
                        Output source annotation as html
  -s, --sysinfo         Output system information for bug reporting
  --sys-json SYS_JSON   Saves the system info dict as a json file

3. Získání systémových informací i aktuální konfigurace Numby

Numba dokáže, pokud je korektně provedena konfigurace, spouštět některé výpočty na GPU, což je výhodné například v případě výpočtů prováděných s n-dimenzionálními poli balíčku Numpy, jejichž výslednou hodnotu potřebujeme získat až na konci celého výpočtu. Ovšem jak použití GPU (CUDA), tak i dalších „vychytávek“ nabízených Numpy, vyžaduje korektní konfiguraci. Aktuální konfiguraci je přitom možné získat snadno následujícím příkazem:

$ numba -s

Nejprve se vypíšou základní informace o systému i o použitém mikroprocesoru:

System info:
--------------------------------------------------------------------------------
__Time Stamp__
Report started (local time)                   : 2023-05-20 12:01:57.835226
UTC start time                                : 2023-05-20 10:01:57.835229
Running time (s)                              : 0.829354
 
__Hardware Information__
Machine                                       : x86_64
CPU Name                                      : skylake
CPU Count                                     : 8
Number of accessible CPUs                     : 8
List of accessible CPUs cores                 : 0 1 2 3 4 5 6 7
CFS Restrictions (CPUs worth of runtime)      : None
 
CPU Features                                  : 64bit adx aes avx avx2 bmi bmi2
                                                clflushopt cmov cx16 cx8 f16c fma
                                                fsgsbase fxsr invpcid lzcnt mmx
                                                movbe pclmul popcnt prfchw rdrnd
                                                rdseed rtm sahf sgx sse sse2 sse3
                                                sse4.1 sse4.2 ssse3 xsave xsavec
                                                xsaveopt xsaves
 
Memory Total (MB)                             : 15466
Memory Available (MB)                         : 13824
 
__OS Information__
Platform Name                                 : Linux-4.18.19-100.fc27.x86_64-x86_64-with-fedora-27-Twenty_Seven
Platform Release                              : 4.18.19-100.fc27.x86_64
OS Name                                       : Linux
OS Version                                    : #1 SMP Wed Nov 14 22:04:34 UTC 2018
OS Specific Version                           : ?
Libc Version                                  : glibc 2.3.4
 
__Python Information__
Python Compiler                               : GCC 7.3.1 20180303 (Red Hat 7.3.1-5)
Python Implementation                         : CPython
Python Version                                : 3.8.6
Python Locale                                 : en_US.UTF-8

Dále se vypíšou důležité informace o LLVM a CUDA:

__LLVM Information__
LLVM Version                                  : 10.0.1
 
__CUDA Information__
CUDA Device Initialized                       : False
CUDA Driver Version                           : ?
CUDA Detect Output:
None
CUDA Libraries Test Output:
None
 
__ROC information__
ROC Available                                 : False
ROC Toolchains                                : None
HSA Agents Count                              : 0
HSA Agents:
None
HSA Discrete GPUs Count                       : 0
HSA Discrete GPUs                             : None
 

Ovšem Numba dokáže používat i další knihovny, například Short Vector Math Library Operations neboli SVML, knihovnu pro spouštění výpočtů ve více vláknech (TBB) atd.:

__SVML Information__
SVML State, config.USING_SVML                 : False
SVML Library Loaded                           : False
llvmlite Using SVML Patched LLVM              : True
SVML Operational                              : False
 
__Threading Layer Information__
TBB Threading Layer Available                 : False
+--> Disabled due to Unknown import problem.
OpenMP Threading Layer Available              : True
+-->Vendor: GNU
Workqueue Threading Layer Available           : True
+-->Workqueue imported successfully.
 
__Numba Environment Variable Information__
None found.
 
__Conda Information__
Conda not available.

Dále se vypíše seznam nainstalovaných balíčků Pythonu (zde jsou schválně použity starší balíčky):

__Installed Packages__
Package                  Version
------------------------ -----------------
absl-py                  0.11.0
alabaster                0.7.9
aniso8601                3.0.0
...
...
...
xmltodict                0.11.0
zc.thread                1.0.0
zipp                     3.1.0

A na konci výpisu se zobrazí různá varování, například v mém případě informace o tom, že není možné použít CUDA:

__Warning log__
Warning (cuda): CUDA driver library cannot be found or no CUDA enabled devices are present.
Exception class: <class 'numba.cuda.cudadrv.error.CudaSupportError'>
Warning (roc): Error initialising ROC: No ROC toolchains found.
Warning (roc): No HSA Agents found, encountered exception when searching: Error at driver init:
NUMBA_HSA_DRIVER /opt/rocm/lib/libhsa-runtime64.so is not a valid file path.  Note it must be a filepath of the .so/.dll/.dylib or the driver:
Warning: Conda not available.

4. Anotace skriptů zpracovávaných nástrojem Numba

Nástroj Numba dokáže skripty naprogramované v Pythonu překládat do strojového (tedy nativního) kódu cílové platformy. Ovšem nejedná se (a v případě Pythonu se ani nemůže jednat) o operaci provedenou v jediném kroku. Celý překlad je totiž rozdělen na mnoho fází. První operací, kterou Numba provádí, je analýza AST (abstraktního syntaktického stromu) se snahou o „porozumění“ kódu zapsaného vývojářem. Druhou operací je odvození datových typů argumentů zpracovávaných funkcí a tím pádem i typů lokálních proměnných těchto funkcí. Numba navíc dokáže původní zdrojový kód doplnit o komentáře, které naznačí, jak vstupnímu zdrojovému kódu „rozumí“ po tomto kroku (tedy analýze) a jaké další informace (typy, živost proměnných) byly z kódu odvozeny. Do původního zdrojového kódu jsou formou poznámek doplněny další informace, jak ostatně uvidíme dále.

5. Ukázka anotace jednoduché funkce sčítající dvě celočíselné hodnoty

Vyzkoušejme si nyní, jak vlastně bude vypadat v předchozí kapitole zmíněná anotace zdrojového kódu zpracovávaného nástrojem Numba, na následujícím jednoduchém demonstračním příkladu, který obsahuje jednu funkci s dekorátorem @jit – tím Numbě naznačujeme, že chceme JITovat právě tuto funkci (význam parametrů dekorátoru si vysvětlíme dále, na anotaci však nemají vliv). Funkce je volána s parametry typu int:

from numba import jit
 
 
@jit(nopython=True,nogil=True)
def sum(a, b):
     return a+b
 
 
x = sum(1, 2)
print(x)
Poznámka: zdrojový kód tohoto demonstračního příkladu naleznete na adrese https://github.com/tisnik/most-popular-python-libs/blob/master/numba/sum1.py.

Vytvoření anotovaného kódu s jeho následujícím výpisem zajišťuje tento příkaz:

$ numba --annotate sum1.py

Podívejme se nyní na výsledek vypsaný nástrojem Numba:

-----------------------------------ANNOTATION-----------------------------------
# File: sum1.py
# --- LINE 4 ---
 
@jit(nopython=True,nogil=True)
 
# --- LINE 5 ---
 
def sum(a, b):
 
     # --- LINE 6 ---
     # label 0
     #   a = arg(0, name=a)  :: int64
     #   b = arg(1, name=b)  :: int64
     #   $0.3 = a + b  :: int64
     #   del b
     #   del a
     #   $0.4 = cast(value=$0.3)  :: int64
     #   del $0.3
     #   return $0.4
 
     return a+b
 
 
================================================================================

6. Co z anotovaného kódu vyčteme?

Výsledek uvedený v páté kapitole ukazuje, jaké užitečné informace nástroj Numba v rámci prvních dvou kroků JIT překladu získal:

  • Typy argumentů předávaných do funkce sum – oba argumenty jsou v našem konkrétním případě typu int64 (což ovšem znamená zúžení původního typu, protože Pythonovská celá čísla mají neomezený rozsah!)
  • Typ výsledku operace (operací) prováděných ve funkci. Konkrétně je typ hodnoty získané operací a+b taktéž roven int64 (mezivýsledek je představován pseudoproměnnou $0.3).
  • Původní argumenty mohou být po provedení operace součtu z paměti odstraněny, což Numba korektně detekuje a naznačí příkazem del.
  • Poté je zapsáno explicitní přetypování, které bude později při optimalizacích opět odstraněno.
  • I mezivýsledek může být z paměti odstraněn, což naznačuje poslední příkaz del.
Poznámka: jedná se tedy o překvapivě velké množství informací, které se nástroji Numba budou zcela jistě hodit v navazujících krocích JIT překladu. Navíc Numba, možná poněkud přímočaře, předpokládá, že se celočíselné hodnoty vejdou do rozsahu datového typu int64.

7. Anotace stejné funkce, ovšem zavolané s jinými typy argumentů

Funkci sum uvedenou v páté kapitole je pochopitelně možné v Pythonu volat s prakticky jakýmikoli typy parametrů, protože operace + je definována například pro pravdivostní hodnoty (i když výsledek může někoho překvapit), pro celočíselné hodnoty, hodnoty s plovoucí řádovou čárkou, řetězce, n-tice, seznamy, množiny atd. Co se tedy stane v případě, kdy stejnou funkci nyní zavoláme s parametry typu float? Můžeme si to velmi snadno ověřit:

from numba import jit
 
 
@jit(nopython=True,nogil=True)
def sum(a, b):
     return a+b
 
 
x = sum(1.1, 2.2)
print(x)
Poznámka: zdrojový kód tohoto demonstračního příkladu naleznete na adrese https://github.com/tisnik/most-popular-python-libs/blob/master/numba/sum2.py.

Nyní bude výsledek analýzy AST, odvození typů a živosti proměnných vypadat poněkud odlišně:

$ numba --annotate sum2.py
 
-----------------------------------ANNOTATION-----------------------------------
# File: sum2.py
# --- LINE 4 ---
 
@jit(nopython=True,nogil=True)
 
# --- LINE 5 ---
 
def sum(a, b):
 
     # --- LINE 6 ---
     # label 0
     #   a = arg(0, name=a)  :: float64
     #   b = arg(1, name=b)  :: float64
     #   $0.3 = a + b  :: float64
     #   del b
     #   del a
     #   $0.4 = cast(value=$0.3)  :: float64
     #   del $0.3
     #   return $0.4
 
     return a+b
 
 
================================================================================
Poznámka: povšimněte si, že v místech, kde byl původně zapsán typ int64 se nyní nachází jméno typu float64, což odpovídá typu double z jazyka C:
$ numba --annotate sum1.py > sum1_annotated
 
$ numba --annotate sum2.py > sum2_annotated
 
$ diff -y sum1_annotated sum2_annotated 
-----------------------------------ANNOTATION----------------   -----------------------------------ANNOTATION----------------
# File: sum1.py                                               | # File: sum2.py
# --- LINE 4 ---                                                # --- LINE 4 ---
 
@jit(nopython=True,nogil=True)                                  @jit(nopython=True,nogil=True)
 
# --- LINE 5 ---                                                # --- LINE 5 ---
 
def sum(a, b):                                                  def sum(a, b):
 
     # --- LINE 6 ---                                                # --- LINE 6 ---
     # label 0                                                       # label 0
     #   a = arg(0, name=a)  :: int64                         |      #   a = arg(0, name=a)  :: float64
     #   b = arg(1, name=b)  :: int64                         |      #   b = arg(1, name=b)  :: float64
     #   $0.3 = a + b  :: int64                               |      #   $0.3 = a + b  :: float64
     #   del b                                                       #   del b
     #   del a                                                       #   del a
     #   $0.4 = cast(value=$0.3)  :: int64                    |      #   $0.4 = cast(value=$0.3)  :: float64
     #   del $0.3                                                    #   del $0.3
     #   return $0.4                                                 #   return $0.4
 
     return a+b                                                      return a+b
 
 
=============================================================   =============================================================
3                                                             | 3.3000000000000003
Poznámka: poslední řádek obsahuje vypočtené hodnoty.

Pro zajímavost si ještě v rychlosti ukažme stejnou funkci, ovšem nyní spojující dvě n-tice:

from numba import jit
 
 
@jit(nopython=True,nogil=True)
def sum(a, b):
     return a+b
 
 
x = sum((1, 2), (3, 4))
 
print(x)

Anotovaný zdrojový kód:

$ numba --annotate sum3.py
 
-----------------------------------ANNOTATION-----------------------------------
# File: sum3.py
# --- LINE 4 ---
 
@jit(nopython=True,nogil=True)
 
# --- LINE 5 ---
 
def sum(a, b):
 
     # --- LINE 6 ---
     # label 0
     #   a = arg(0, name=a)  :: UniTuple(int64 x 2)
     #   b = arg(1, name=b)  :: UniTuple(int64 x 2)
     #   $0.3 = a + b  :: UniTuple(int64 x 4)
     #   del b
     #   del a
     #   $0.4 = cast(value=$0.3)  :: UniTuple(int64 x 4)
     #   del $0.3
     #   return $0.4
 
     return a+b
 
 
================================================================================

8. Funkce, která je postupně volána s různými typy argumentů

Viděli jsme, že Numpy dokáže správně detekovat a odvodit parametry volané funkce v případě, že je daná funkce v programovém kódu volána pouze jedenkrát. Ovšem v praxi nás Python nijak neomezuje v tom, jak a kolikrát bude nějaká funkce volána, což znamená, že stejná funkce může být volána vícekrát, pokaždé s různými typy (a někdy i počtem) parametrů. To pro Numbu ve skutečnosti znamená jen nepatrný problém – danou funkci bude muset přeložit vícekrát, pokaždé pro jiné typy parametrů (a tím pádem i typy lokálních proměnných atd.). A řešení tohoto problému by se mělo projevit i na anotaci, takže si tuto domněnku otestujme na tomto příkladu:

from numba import jit
 
 
@jit(nopython=True,nogil=True)
def sum(a, b):
     return a+b
 
 
x = sum(1, 2)
y = sum(1.1, 2.2)
z = sum((1, 2), (3, 4))
 
print(x)
print(y)
print(z)
Poznámka: zdrojový kód tohoto demonstračního příkladu naleznete na adrese https://github.com/tisnik/most-popular-python-libs/blob/master/numba/sum3.py.

Skript spustíme a přitom si necháme vypsat anotace JITovaných funkcí:

$ numba --annotate sum3.py

Z vypsaných výsledků je patrné, že se tatáž funkce skutečně ve výpisu objeví ve třech různých variantách:

-----------------------------------ANNOTATION-----------------------------------
# File: sum3.py
# --- LINE 4 ---
 
@jit(nopython=True,nogil=True)
 
# --- LINE 5 ---
 
def sum(a, b):
 
     # --- LINE 6 ---
     # label 0
     #   a = arg(0, name=a)  :: int64
     #   b = arg(1, name=b)  :: int64
     #   $0.3 = a + b  :: int64
     #   del b
     #   del a
     #   $0.4 = cast(value=$0.3)  :: int64
     #   del $0.3
     #   return $0.4
 
     return a+b
 
 
================================================================================
-----------------------------------ANNOTATION-----------------------------------
# File: sum3.py
# --- LINE 4 ---
 
@jit(nopython=True,nogil=True)
 
# --- LINE 5 ---
 
def sum(a, b):
 
     # --- LINE 6 ---
     # label 0
     #   a = arg(0, name=a)  :: float64
     #   b = arg(1, name=b)  :: float64
     #   $0.3 = a + b  :: float64
     #   del b
     #   del a
     #   $0.4 = cast(value=$0.3)  :: float64
     #   del $0.3
     #   return $0.4
 
     return a+b
 
 
================================================================================
-----------------------------------ANNOTATION-----------------------------------
# File: sum3.py
# --- LINE 4 ---
 
@jit(nopython=True,nogil=True)
 
# --- LINE 5 ---
 
def sum(a, b):
 
     # --- LINE 6 ---
     # label 0
     #   a = arg(0, name=a)  :: UniTuple(int64 x 2)
     #   b = arg(1, name=b)  :: UniTuple(int64 x 2)
     #   $0.3 = a + b  :: UniTuple(int64 x 4)
     #   del b
     #   del a
     #   $0.4 = cast(value=$0.3)  :: UniTuple(int64 x 4)
     #   del $0.3
     #   return $0.4
 
     return a+b
 
 
================================================================================
Poznámka: asi je možné předpokládat, že poslední varianta nebude přeložena do krátkého strojového kódu, kdežto první dvě varianty ano.

9. Just in time překlad volaných funkcí do mezikódu LLVM

V okamžiku, kdy má nástroj Numba k dispozici podrobné informace o typech parametrů funkcí a odvodí jak typy, tak i živost lokálních proměnných, může dojít k další fázi překladu – k vygenerování mezikódu pro LLVM. Tento mezikód si můžeme nechat snadno zobrazit příkazem:

$ numba --dump-llvm sum1.py

Vygenerovaný mezikód obsahuje mnoho pseudoinstrukcí. V následujícím výpisu se setkáme zejména s pseudoinstrukcemi store, alloca, ret, br, load a add:

--------------------LLVM DUMP <function descriptor 'sum$1'>---------------------
; ModuleID = "sum$1"
target triple = "x86_64-unknown-linux-gnu"
target datalayout = "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:128-n8:16:32:64-S128"
 
@"_ZN08NumbaEnv8__main__7sum$241Exx" = common global i8* null
define i32 @"_ZN8__main__7sum$241Exx"(i64* noalias nocapture %"retptr", {i8*, i32, i8*}** noalias nocapture %"excinfo", i64 %"arg.a", i64 %"arg.b")
{
entry:
  %"a" = alloca i64
  store i64 0, i64* %"a"
  %"b" = alloca i64
  store i64 0, i64* %"b"
  %"$0.3" = alloca i64
  store i64 0, i64* %"$0.3"
  %"$0.4" = alloca i64
  store i64 0, i64* %"$0.4"
  br label %"B0"
B0:
  %".7" = load i64, i64* %"a"
  store i64 %"arg.a", i64* %"a"
  %".10" = load i64, i64* %"b"
  store i64 %"arg.b", i64* %"b"
  %".12" = load i64, i64* %"a"
  %".13" = load i64, i64* %"b"
  %".14" = add nsw i64 %".12", %".13"
  %".16" = load i64, i64* %"$0.3"
  store i64 %".14", i64* %"$0.3"
  %".18" = load i64, i64* %"b"
  store i64 0, i64* %"b"
  %".20" = load i64, i64* %"a"
  store i64 0, i64* %"a"
  %".22" = load i64, i64* %"$0.3"
  %".24" = load i64, i64* %"$0.4"
  store i64 %".22", i64* %"$0.4"
  %".26" = load i64, i64* %"$0.3"
  store i64 0, i64* %"$0.3"
  %".28" = load i64, i64* %"$0.4"
  store i64 %".28", i64* %"retptr"
  ret i32 0
}
 
================================================================================
3
Poznámka: jedná se tedy o formu assembleru, ovšem určeného pro virtuální stroj a nikoli pro nějakou konkrétní cílovou platformu.

Pro zajímavost se můžeme podívat, jak se do mezikódu LLVM přeloží druhá funkce, kterou voláme s parametry typu double:

$ numba --dump-llvm sum2.py

Výsledek:

--------------------LLVM DUMP <function descriptor 'sum$1'>---------------------
; ModuleID = "sum$1"
target triple = "x86_64-unknown-linux-gnu"
target datalayout = "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:128-n8:16:32:64-S128"
 
@"_ZN08NumbaEnv8__main__7sum$241Edd" = common global i8* null
define i32 @"_ZN8__main__7sum$241Edd"(double* noalias nocapture %"retptr", {i8*, i32, i8*}** noalias nocapture %"excinfo", double %"arg.a", double %"arg.b")
{
entry:
  %"a" = alloca double
  store double 0.0, double* %"a"
  %"b" = alloca double
  store double 0.0, double* %"b"
  %"$0.3" = alloca double
  store double 0.0, double* %"$0.3"
  %"$0.4" = alloca double
  store double 0.0, double* %"$0.4"
  br label %"B0"
B0:
  %".7" = load double, double* %"a"
  store double %"arg.a", double* %"a"
  %".10" = load double, double* %"b"
  store double %"arg.b", double* %"b"
  %".12" = load double, double* %"a"
  %".13" = load double, double* %"b"
  %".14" = fadd double %".12", %".13"
  %".16" = load double, double* %"$0.3"
  store double %".14", double* %"$0.3"
  %".18" = load double, double* %"b"
  store double 0.0, double* %"b"
  %".20" = load double, double* %"a"
  store double 0.0, double* %"a"
  %".22" = load double, double* %"$0.3"
  %".24" = load double, double* %"$0.4"
  store double %".22", double* %"$0.4"
  %".26" = load double, double* %"$0.3"
  store double 0.0, double* %"$0.3"
  %".28" = load double, double* %"$0.4"
  store double %".28", double* %"retptr"
  ret i32 0
}
 
================================================================================
3.3000000000000003

10. Rozdíly mezi funkcí volanou s celočíselnými parametry a s parametry typu double

Opět se podívejme na rozdíly mezi oběma výsledky. V této fázi by neměly být velké, protože se liší „jen“ datové typy, s nimiž se pracuje:

$ numba --dump-llvm sum1.py > sum1_llvm
 
$ numba --dump-llvm sum2.py > sum2_llvm

Rozdíly spočívají v náhradě i64 na double:

$ diff -y sum1_llvm sum2_llvm 
 
--------------------LLVM DUMP <function descriptor 'sum$1'>--   --------------------LLVM DUMP <function descriptor 'sum$1'>--
; ModuleID = "sum$1"                                            ; ModuleID = "sum$1"
target triple = "x86_64-unknown-linux-gnu"                      target triple = "x86_64-unknown-linux-gnu"
target datalayout = "e-m:e-p270:32:32-p271:32:32-p272:64:64-i   target datalayout = "e-m:e-p270:32:32-p271:32:32-p272:64:64-i
 
@"_ZN08NumbaEnv8__main__7sum$241Exx" = common global i8* null | @"_ZN08NumbaEnv8__main__7sum$241Edd" = common global i8* null
define i32 @"_ZN8__main__7sum$241Exx"(i64* noalias nocapture  | define i32 @"_ZN8__main__7sum$241Edd"(double* noalias nocaptu
{                                                               {
entry:                                                          entry:
  %"a" = alloca i64                                           |   %"a" = alloca double
  store i64 0, i64* %"a"                                      |   store double 0.0, double* %"a"
  %"b" = alloca i64                                           |   %"b" = alloca double
  store i64 0, i64* %"b"                                      |   store double 0.0, double* %"b"
  %"$0.3" = alloca i64                                        |   %"$0.3" = alloca double
  store i64 0, i64* %"$0.3"                                   |   store double 0.0, double* %"$0.3"
  %"$0.4" = alloca i64                                        |   %"$0.4" = alloca double
  store i64 0, i64* %"$0.4"                                   |   store double 0.0, double* %"$0.4"
  br label %"B0"                                                  br label %"B0"
B0:                                                             B0:
  %".7" = load i64, i64* %"a"                                 |   %".7" = load double, double* %"a"
  store i64 %"arg.a", i64* %"a"                               |   store double %"arg.a", double* %"a"
  %".10" = load i64, i64* %"b"                                |   %".10" = load double, double* %"b"
  store i64 %"arg.b", i64* %"b"                               |   store double %"arg.b", double* %"b"
  %".12" = load i64, i64* %"a"                                |   %".12" = load double, double* %"a"
  %".13" = load i64, i64* %"b"                                |   %".13" = load double, double* %"b"
  %".14" = add nsw i64 %".12", %".13"                         |   %".14" = fadd double %".12", %".13"
  %".16" = load i64, i64* %"$0.3"                             |   %".16" = load double, double* %"$0.3"
  store i64 %".14", i64* %"$0.3"                              |   store double %".14", double* %"$0.3"
  %".18" = load i64, i64* %"b"                                |   %".18" = load double, double* %"b"
  store i64 0, i64* %"b"                                      |   store double 0.0, double* %"b"
  %".20" = load i64, i64* %"a"                                |   %".20" = load double, double* %"a"
  store i64 0, i64* %"a"                                      |   store double 0.0, double* %"a"
  %".22" = load i64, i64* %"$0.3"                             |   %".22" = load double, double* %"$0.3"
  %".24" = load i64, i64* %"$0.4"                             |   %".24" = load double, double* %"$0.4"
  store i64 %".22", i64* %"$0.4"                              |   store double %".22", double* %"$0.4"
  %".26" = load i64, i64* %"$0.3"                             |   %".26" = load double, double* %"$0.3"
  store i64 0, i64* %"$0.3"                                   |   store double 0.0, double* %"$0.3"
  %".28" = load i64, i64* %"$0.4"                             |   %".28" = load double, double* %"$0.4"
  store i64 %".28", i64* %"retptr"                            |   store double %".28", double* %"retptr"
  ret i32 0                                                       ret i32 0
}                                                               }
 
=============================================================   =============================================================
3                                                             | 3.3000000000000003

11. Zobrazení optimalizovaného mezikódu

Dalším krokem, který nástroj Numba (nyní již nepřímo) zajišťuje, je optimalizace mezikódu LLVM. I optimalizovaný mezikód si pochopitelně můžeme nechat zobrazit, a to s využitím přepínače –dump-optimized:

$ numbra --show-optimized sum1.py

Výsledek je poněkud dlouhý, protože obsahuje i realizaci různých mezních stavů, ovšem pro nás je nejpodstatnější následující část, která opravdu obsahuje velmi optimalizovaný kód:

; Function Attrs: nofree norecurse nounwind writeonly
define i32 @"_ZN8__main__7sum$241Exx"(i64* noalias nocapture %retptr, { i8*, i32, i8* }** noalias nocapture readnone %excinfo, i64 %arg.a, i64 %arg.b) local_unnamed_addr #0 {
entry:
  %.14 = add nsw i64 %arg.b, %arg.a
  store i64 %.14, i64* %retptr, align 8
  ret i32 0
}
Poznámka: zde je patrné, že návratový kód funkce ve skutečnosti neobsahuje výslednou hodnotu.

Podobně si můžeme nechat zobrazit optimalizovaný mezikód i pro druhou variantu funkce sum:

$ numbra --show-optimized sum2.py
; Function Attrs: nofree norecurse nounwind writeonly
define i32 @"_ZN8__main__7sum$241Edd"(double* noalias nocapture %retptr, { i8*, i32, i8* }** noalias nocapture readnone %excinfo, double %arg.a, double %arg.b) local_unnamed_addr #0 {
entry:
  %.14 = fadd double %arg.a, %arg.b
  store double %.14, double* %retptr, align 8
  ret i32 0
}

12. Překlad do assembleru cílové architektury

LLVM v navazujícím kroku provádí překlad z optimalizovaného mezikódu do strojového kódu cílové architektury, což dnes bude pro jednoduchost prozatím x86–64, ovšem v dalším článku si ukážeme i další nabízené možnosti. A i způsob provedení tohoto kroku můžeme velmi snadno zkontrolovat, protože nástroj Numba nabízí přepínač –dump-assembly, jenž vypíše sekvenci instrukcí v assembleru, které odpovídají výslednému strojovému kódu.

Vyzkoušíme si to nejprve na funkci sum ve variantě, kdy je volána s dvojicí celých čísel:

$ numba --dump-assembly sum1.py

Překlad do assembleru cílové architektury bude vypadat následovně:

_ZN8__main__7sum$241Exx:
        addq    %rcx, %rdx
        movq    %rdx, (%rdi)
        xorl    %eax, %eax
        retq
Poznámka: opět si povšimněte, že návratový kód neobsahuje výsledek volané funkce.

A jak bude vypadat způsob překladu stejné funkce, ovšem volané s parametry typu double?

$ numba --dump-assembly sum2.py

Nyní vidíme využití registrů přidaných v rámci z rozšíření instrukční sady SSE popř. SSE2 a instrukce vaddsd a vmovsd jsou definovány v AVX:

_ZN8__main__7sum$241Edd:
        vaddsd  %xmm1, %xmm0, %xmm0
        vmovsd  %xmm0, (%rdi)
        xorl    %eax, %eax
        retq

13. Využití SIMD instrukcí systémem Numba

Jednou z velkých předností nástroje Numba oproti dalším konkurenčním překladačům Pythonu je jeho schopnost detekovat ty části programového kódu, které lze realizovat s využitím SIMD instrukcí (což je mnohdy důležitější, než snaha o souběžný běh ve více vláknech). Tato „vektorizace“ se nejvíce projeví u kódu založeného na manipulaci s n-dimenzionálními poli z balíčku Numpy, přičemž výsledek může být lepší, než původní strojový kód v Numpy a navíc je možné v kódu používat Pythonovské smyčky, které nemají žádný velký negativní vliv na výsledný čas výpočtu. Ostatně se podívejme na jednoduchý demonstrační příklad, který počítá součet prvků v poli, a to s využitím explicitně zapsané programové smyčky:

from numba import jit
 
import numpy as np
 
 
@jit(nopython=True)
def sum_array(array):
    result = 0
    for i in range(array.shape[0]):
        result += array[i]
    return result
 
 
array = np.arange(1, 1001)
print(sum_array(array))
Poznámka: zdrojový kód tohoto demonstračního příkladu naleznete na adrese https://github.com/tisnik/most-popular-python-libs/blob/master/numba/sum_array.py.

14. Anotovaný zdrojový kód a optimalizovaný mezikód

Podívejme se nyní na to, jak bude vypadat anotovaný zdrojový kód s funkcí sum_array zmíněnou v předchozí kapitole:

-----------------------------------ANNOTATION-----------------------------------
# File: sum_array.py
# --- LINE 6 ---
 
@jit(nopython=True)
 
# --- LINE 7 ---
 
def sum_array(array):
 
    # --- LINE 8 ---
    # label 0
    #   array = arg(0, name=array)  :: array(int64, 1d, C)
    #   result = const(int, 0)  :: Literal[int](0)
 
    result = 0
 
    # --- LINE 9 ---
    #   jump 6
    # label 6
    #   jump 8
    # label 8
    #   $8.1 = global(range: )  :: Function(<class 'range'>)
    #   $8.3 = getattr(value=array, attr=shape)  :: UniTuple(int64 x 1)
    #   $const8.4 = const(int, 0)  :: Literal[int](0)
    #   $8.5 = static_getitem(value=$8.3, index=0, index_var=$const8.4, fn=<built-in function getitem>)  :: int64
    #   del $const8.4
    #   del $8.3
    #   $8.6 = call $8.1($8.5, func=$8.1, args=[Var($8.5, sum_array.py:9)], kws=(), vararg=None)  :: (int64,) -> range_state_int64
    #   del $8.5
    #   del $8.1
    #   $8.7 = getiter(value=$8.6)  :: range_iter_int64
    #   del $8.6
    #   $phi22.1 = $8.7  :: range_iter_int64
    #   del $8.7
    #   jump 22
    # label 22
    #   result.2 = phi(incoming_values=[Var(result, sum_array.py:8), Var(result.1, sum_array.py:10)], incoming_blocks=[8, 24])  :: int64
    #   del result.1
    #   $22.2 = iternext(value=$phi22.1)  :: pair<int64, bool>
    #   $22.3 = pair_first(value=$22.2)  :: int64
    #   $22.4 = pair_second(value=$22.2)  :: bool
    #   del $22.2
    #   $phi24.1 = $22.3  :: int64
    #   $phi40.1 = $22.3  :: int64
    #   del $phi40.1
    #   del $22.3
    #   $phi40.2 = $phi22.1  :: range_iter_int64
    #   del $phi40.2
    #   branch $22.4, 24, 40
    # label 24
    #   del $22.4
    #   i = $phi24.1  :: int64
    #   del $phi24.1
 
    for i in range(array.shape[0]):
 
        # --- LINE 10 ---
        #   $24.5 = getitem(value=array, index=i, fn=<built-in function getitem>)  :: int64
        #   del i
        #   $24.6 = inplace_binop(fn=<built-in function iadd>, immutable_fn=<built-in function add>, lhs=result.2, rhs=$24.5, static_lhs=Undefined, static_rhs=Undefined)  :: int64
        #   del result.2
        #   del $24.5
        #   result.1 = $24.6  :: int64
        #   del $24.6
        #   jump 22
        # label 40
 
        result += array[i]
 
    # --- LINE 11 ---
    #   del result
    #   del array
    #   del $phi24.1
    #   del $phi22.1
    #   del $22.4
    #   jump 42
    # label 42
    #   $42.2 = cast(value=result.2)  :: int64
    #   del result.2
    #   return $42.2
 
    return result
 
 
================================================================================
Poznámka: jak je patrné, nejedná se v žádném případě o triviální operace, jak by se mohlo z pohledu na původní Pythonovský kód zdát.

Zajímavé je taktéž zjistit, jak zhruba vypadá optimalizovaný mezikód LLVM. Zde je patrné, že byly detekovány operace, které je možné provádět s celými vektory:

...
...
...
vector.body:                                      ; preds = %vector.body, %vector.ph.new
  %index = phi i64 [ 0, %vector.ph.new ], [ %index.next.3, %vector.body ]
  %vec.phi = phi <4 x i64> [ zeroinitializer, %vector.ph.new ], [ %48, %vector.body ]
  %vec.phi9 = phi <4 x i64> [ zeroinitializer, %vector.ph.new ], [ %49, %vector.body ]
  %vec.phi10 = phi <4 x i64> [ zeroinitializer, %vector.ph.new ], [ %50, %vector.body ]
  %vec.phi11 = phi <4 x i64> [ zeroinitializer, %vector.ph.new ], [ %51, %vector.body ]
  %niter = phi i64 [ %unroll_iter, %vector.ph.new ], [ %niter.nsub.3, %vector.body ]
  %sunkaddr = mul i64 %index, 8
  %4 = bitcast i64* %arg.array.4 to i8*
  %sunkaddr101 = getelementptr i8, i8* %4, i64 %sunkaddr
  %5 = bitcast i8* %sunkaddr101 to <4 x i64>*
  ...
  ...
  ...
  %rdx.shuf = shufflevector <4 x i64> %bin.rdx22, <4 x i64> undef, <4 x i32> <i32 2, i32 3, i32 undef, i32 undef>
  %bin.rdx23 = add <4 x i64> %bin.rdx22, %rdx.shuf
  %rdx.shuf24 = shufflevector <4 x i64> %bin.rdx23, <4 x i64> undef, <4 x i32> <i32 1, i32 undef, i32 undef, i32 undef>
  %bin.rdx25 = add <4 x i64> %bin.rdx23, %rdx.shuf24
  %59 = extractelement <4 x i64> %bin.rdx25, i32 0

15. Vygenerovaný kód v assembleru využívající SIMD instrukce

Nejzajímavější bude pochopitelně až výsledný nativní kód, který se spustí namísto interpretace původního pythonního skriptu. A v tomto nativním kódu nalezneme (kromě mnoha dalších nyní nevýznamných informací) i sekvenci instrukcí tvořících programovou smyčku, v níž se používají „vektorové“ registry ymm(x) a navíc jsou operace ve smyčce rozbaleny. Nejprve si ukažme přípravnou fázi smyčky:

_ZN8__main__13sum_array$241E5ArrayIxLi1E1C7mutable7alignedE:
        movq    16(%rsp), %rax
        testq   %rax, %rax
        jle     .LBB0_1
        movq    8(%rsp), %r9
        cmpq    $16, %rax
        jae     .LBB0_4
        xorl    %r8d, %r8d
        xorl    %ecx, %ecx
        jmp     .LBB0_13
.LBB0_1:
        xorl    %ecx, %ecx
        jmp     .LBB0_15
.LBB0_4:
        movq    %rax, %r8
        andq    $-16, %r8
        leaq    -16(%r8), %rdx
        movq    %rdx, %rsi
        shrq    $4, %rsi
        incq    %rsi
        movl    %esi, %ecx
        andl    $3, %ecx
        cmpq    $48, %rdx
        jae     .LBB0_6
        vpxor   %xmm0, %xmm0, %xmm0
        xorl    %edx, %edx
        vpxor   %xmm1, %xmm1, %xmm1
        vpxor   %xmm2, %xmm2, %xmm2
        vpxor   %xmm3, %xmm3, %xmm3
        jmp     .LBB0_8
.LBB0_6:
        subq    %rcx, %rsi
        vpxor   %xmm0, %xmm0, %xmm0
        xorl    %edx, %edx
        vpxor   %xmm1, %xmm1, %xmm1
        vpxor   %xmm2, %xmm2, %xmm2
        vpxor   %xmm3, %xmm3, %xmm3

Následují vlastní instrukce pro částečně rozbalenou smyčku:

        .p2align        4, 0x90
.LBB0_7:
        vpaddq  (%r9,%rdx,8), %ymm0, %ymm0
        vpaddq  32(%r9,%rdx,8), %ymm1, %ymm1
        vpaddq  64(%r9,%rdx,8), %ymm2, %ymm2
        vpaddq  96(%r9,%rdx,8), %ymm3, %ymm3
        vpaddq  128(%r9,%rdx,8), %ymm0, %ymm0
        vpaddq  160(%r9,%rdx,8), %ymm1, %ymm1
        vpaddq  192(%r9,%rdx,8), %ymm2, %ymm2
        vpaddq  224(%r9,%rdx,8), %ymm3, %ymm3
        vpaddq  256(%r9,%rdx,8), %ymm0, %ymm0
        vpaddq  288(%r9,%rdx,8), %ymm1, %ymm1
        vpaddq  320(%r9,%rdx,8), %ymm2, %ymm2
        vpaddq  352(%r9,%rdx,8), %ymm3, %ymm3
        vpaddq  384(%r9,%rdx,8), %ymm0, %ymm0
        vpaddq  416(%r9,%rdx,8), %ymm1, %ymm1
        vpaddq  448(%r9,%rdx,8), %ymm2, %ymm2
        vpaddq  480(%r9,%rdx,8), %ymm3, %ymm3

A takto vypadá konec smyčky (podmínka+podmíněný skok) a úklidové operace:

        addq    $64, %rdx
        addq    $-4, %rsi
        jne     .LBB0_7
.LBB0_8:
        testq   %rcx, %rcx
        je      .LBB0_11
        leaq    (%r9,%rdx,8), %rdx
        addq    $96, %rdx
        negq    %rcx
        .p2align        4, 0x90
.LBB0_10:
        vpaddq  -96(%rdx), %ymm0, %ymm0
        vpaddq  -64(%rdx), %ymm1, %ymm1
        vpaddq  -32(%rdx), %ymm2, %ymm2
        vpaddq  (%rdx), %ymm3, %ymm3
        subq    $-128, %rdx
        incq    %rcx
        jne     .LBB0_10
.LBB0_11:
        vpaddq  %ymm3, %ymm1, %ymm1
        vpaddq  %ymm2, %ymm0, %ymm0
        vpaddq  %ymm1, %ymm0, %ymm0
        vextracti128    $1, %ymm0, %xmm1
        vpaddq  %xmm1, %xmm0, %xmm0
        vpshufd $78, %xmm0, %xmm1
        vpaddq  %xmm1, %xmm0, %xmm0
        vmovq   %xmm0, %rcx
        cmpq    %rax, %r8
        je      .LBB0_15
        andl    $15, %eax
.LBB0_13:
        leaq    (%r9,%r8,8), %rdx
        incq    %rax
        .p2align        4, 0x90
.LBB0_14:
        addq    (%rdx), %rcx
        addq    $8, %rdx
        decq    %rax
        cmpq    $1, %rax
        jg      .LBB0_14
.LBB0_15:
        movq    %rcx, (%rdi)
        xorl    %eax, %eax
        vzeroupper
        retq
.Lfunc_end0:
Poznámka: registry ymm(x) mají šířku 256 bitů a umožňují tedy pracovat s vektory obsahujícími osm hodnot typu int64. To znamená, že každá operace může v ideálním případě probíhat s osmi prvky původního pole (a nutno říci, že výpočet sumy není zcela ideální operací – lepší by byl například součet dvou polí prvek po prvku atd.)

16. Režim fast math

Výpočty s hodnotami s pohyblivou řádovou čárkou lze realizovat v režimu fast-math, který nezaručí, že všechny mezivýsledky budou správně zaokrouhleny a znormalizovány. Na druhou stranu je tento režim v praxi rychlejší, protože umožňuje matematickému koprocesoru (což je mimochodem termín, který dnes již vlastně postrádá smysl) vynechat některé mezioperace.

Rozdíl mezi „správnou“ aritmetikou a režimem fast-math je patrný z následujících dvou skriptů.

První skript režim fast-math nepoužívá:

from numba import jit
 
import numpy as np
 
 
@jit
def sum_array(array):
    result = 0.
    for x in array:
        result += np.sqrt(x)
    return result
 
 
array = np.arange(1, 100000001)
x = 0
 
for _ in range(100):
    x += sum_array(array)
 
print(sum_array(array))

Druhý testovací skript naopak režim fast-math zapíná:

from numba import jit
 
import numpy as np
 
 
@jit(nopython=True, fastmath=True)
def sum_array(array):
    result = 0.
    for x in array:
        result += np.sqrt(x)
    return result
 
 
array = np.arange(1, 100000001)
x = 0
 
for _ in range(100):
    x += sum_array(array)
 
print(sum_array(array))

Jak se budou lišit vypočtené výsledky?

$ numba sum_sqrts2.py 
666666671666.567
 
$ numba sum_sqrts3.py 
666666671666.449

Rozdíl je tedy nepatrný.

Nyní se podívejme na časy výpočtů:

$ time numba sum_sqrts2.py 
666666671666.567
 
real    0m19,524s
user    0m19,875s
sys     0m1,689s
 
$ time numba sum_sqrts3.py 
666666671666.449
 
real    0m14,829s
user    0m15,168s
sys     0m1,708s

Rozdíl 15 sekund vs. 19 již je dosti znatelný.

17. Paralelizace výsledného kódu

Další optimalizace, kterou Numba dokáže zajistit, je paralelizace části kódu, typicky programové smyčky nebo častého volání nějaké funkce. Pro tento účel se používá parametr parallel=True předaný dekorátoru @jit a navíc je vhodné při generování indexů atd. použít namísto vestavěného generátoru range jeho paralelní variantu prange. Jak ale takový paralelní výpočet probíhá? Kód, resp. jednotlivé iterace nebo celé volání funkce, je spouštěn ve vláknech, přičemž pro správu vláken se používají různé knihovny, o nichž se zmíním příště.

18. Kdy se paralelizace vyplatí a kdy nikoli?

„Běh ve více vláknech“ je sice mnohdy chápán jako svatý grál moderního IT, ovšem ve skutečnosti tomu tak být nemusí (osobně si myslím, že je výhodnější věnovat čas vektorizaci kódu, než bojovat s vícevláknovým programováním). Ostatně si to můžeme ověřit na příkladu, v němž se pracuje s poli, která mají počet prvků v prvním případě nastaven na 1000 a v případě druhém na 100000. Každý příklad testuje rychlost vykonávání kódu ve třech funkcích, jejichž těla jsou totožná a liší se pouze jejich dekorátory @gil:

import time
 
from numba import jit
import numpy as np
 
 
def regular_sum(a, b):
     return a+b
 
 
@jit(nopython=True)
def sequential_sum(a, b):
     return a+b
 
 
@jit(nopython=True,nogil=True,parallel=True)
def parallel_sum(a, b):
     return a+b
 
 
N = 1000
 
x = np.arange(0, N)
y = np.zeros(N)
 
print("Let's start")
 
z = regular_sum(x, y)
z = sequential_sum(x, y)
z = parallel_sum(x, y)
 
MAX = 100000
 
print("Compiled")
 
t1 = time.time()
for _ in range(MAX):
    z = regular_sum(x, y)
t2 = time.time()
print(t2-t1)
 
t1 = time.time()
for _ in range(MAX):
    z = sequential_sum(x, y)
t2 = time.time()
print(t2-t1)
 
t1 = time.time()
for _ in range(MAX):
    z = parallel_sum(x, y)
t2 = time.time()
print(t2-t1)
import time
 
from numba import jit
import numpy as np
 
 
def regular_sum(a, b):
     return a+b
 
 
@jit(nopython=True)
def sequential_sum(a, b):
     return a+b
 
 
@jit(nopython=True,nogil=True,parallel=True)
def parallel_sum(a, b):
     return a+b
 
 
N = 100000
 
x = np.arange(0, N)
y = np.zeros(N)
 
print("Let's start")
 
z = regular_sum(x, y)
z = sequential_sum(x, y)
z = parallel_sum(x, y)
 
MAX = 100000
 
print("Compiled")
 
t1 = time.time()
for _ in range(MAX):
    z = regular_sum(x, y)
t2 = time.time()
print(t2-t1)
 
t1 = time.time()
for _ in range(MAX):
    z = sequential_sum(x, y)
t2 = time.time()
print(t2-t1)
 
t1 = time.time()
for _ in range(MAX):
    z = parallel_sum(x, y)
t2 = time.time()
print(t2-t1)

Podívejme se nyní na změřené výsledky.

První příklad (krátká pole):

bitcoin školení listopad 24

$ numba sum5.py 
 
Let's start
Compiled
0.28554391860961914
0.1572709083557129
0.5220246315002441
Poznámka: zde je vícevláknový výpočet zcela jasně nejhorší!

Druhý příklad (delší pole):

$ numba sum6.py 
 
Let's start
Compiled
14.95469880104065
6.64401388168335
2.430103063583374
Poznámka: zde naopak dosáhneme velmi dobrého času při použití více vláken.

19. Repositář s demonstračními příklady

Všechny skripty, které jsme si v dnešním i minulém článku ukázali, naleznete na adrese https://github.com/tisnik/most-popular-python-libs. Následují odkazy na jednotlivé příklady (pro jejich spuštění je nutné mít nainstalovánu knihovnu Numba a její závislosti):

# Příklad Stručný popis Adresa
1 mandelbrot-v1 benchmark, v němž se nepoužívají anotace projektu Numba https://github.com/tisnik/most-popular-python-libs/blob/master/numba/mandelbrot-v1/
2 mandelbrot-v2 použití anotace @jit ve funkci, v níž se provádí mnoho výpočtů https://github.com/tisnik/most-popular-python-libs/blob/master/numba/mandelbrot-v2/
3 mandelbrot-v3 volání zjednodušených variant funkce print https://github.com/tisnik/most-popular-python-libs/blob/master/numba/mandelbrot-v3/
4 mandelbrot-v4 použití anotace @jit s parametrem nopython https://github.com/tisnik/most-popular-python-libs/blob/master/numba/mandelbrot-v4/
     
5 sum1.py funkce pro výpočet součtu dvou prvků, které je volána s argumenty typu int64 https://github.com/tisnik/most-popular-python-libs/blob/master/numba/sum1.py
6 sum2.py funkce pro výpočet součtu dvou prvků, které je volána s argumenty typu double/float64 https://github.com/tisnik/most-popular-python-libs/blob/master/numba/sum2.py
7 sum3.py funkce pro výpočet součtu dvou prvků volaná s argumenty různých typů https://github.com/tisnik/most-popular-python-libs/blob/master/numba/sum3.py
8 sum4.py součet dvou polí z balíčku Numpy https://github.com/tisnik/most-popular-python-libs/blob/master/numba/sum4.py
9 sum5.py porovnání různých variant součtu dvou polí https://github.com/tisnik/most-popular-python-libs/blob/master/numba/sum5.py
10 sum6.py dtto, ale pro větší počet volání funkce pro provedení součtu https://github.com/tisnik/most-popular-python-libs/blob/master/numba/sum6.py
     
11 sum_array.py explicitní zápis součtu všech prvků pole https://github.com/tisnik/most-popular-python-libs/blob/master/numba/sum_array.py
12 range_loop.py využití funkce range ve smyčce for https://github.com/tisnik/most-popular-python-libs/blob/master/numba/range_loop.py
13 prange_loop.py paralelní varianta funkce range ve smyčce for https://github.com/tisnik/most-popular-python-libs/blob/master/numba/prange_loop.py
14 sum_sqrts1.py výpočet prováděný ve smyčce s akumulací výsledku https://github.com/tisnik/most-popular-python-libs/blob/master/numba/sum_sqrts1.py
15 sum_sqrts2.py dtto, ovšem provedení v režimu nopython https://github.com/tisnik/most-popular-python-libs/blob/master/numba/sum_sqrts2.py
16 sum_sqrts3.py zapnutí režimu fast array při výpočtech https://github.com/tisnik/most-popular-python-libs/blob/master/numba/sum_sqrts3.py

20. Odkazy na Internetu

  1. Numba
    http://numba.pydata.org/
  2. numba 0.57.0
    https://pypi.org/project/numba/
  3. Pushing Python toward C speeds with SIMD
    https://laurenar.net/posts/python-simd/
  4. Retrieve generated LLVM from Numba
    https://stackoverflow.com/qu­estions/25213137/retrieve-generated-llvm-from-numba
  5. Numba documentation
    http://numba.pydata.org/numba-doc/latest/index.html
  6. Numba na GitHubu
    https://github.com/numba/numba
  7. First Steps with numba
    https://numba.pydata.org/numba-doc/0.12.2/tutorial_firststeps.html
  8. Numba and types
    https://numba.pydata.org/numba-doc/0.12.2/tutorial_types.html
  9. Just-in-time compilation
    https://en.wikipedia.org/wiki/Just-in-time_compilation
  10. Cython (home page)
    http://cython.org/
  11. Cython (wiki)
    https://github.com/cython/cython/wiki
  12. Cython (Wikipedia)
    https://en.wikipedia.org/wiki/Cython
  13. Cython (GitHub)
    https://github.com/cython/cython
  14. Python Implementations: Compilers
    https://wiki.python.org/mo­in/PythonImplementations#Com­pilers
  15. EmbeddingCython
    https://github.com/cython/cyt­hon/wiki/EmbeddingCython
  16. The Basics of Cython
    http://docs.cython.org/en/la­test/src/tutorial/cython_tu­torial.html
  17. Overcoming Python's GIL with Cython
    https://lbolla.info/python-threads-cython-gil
  18. GlobalInterpreterLock
    https://wiki.python.org/mo­in/GlobalInterpreterLock
  19. The Magic of RPython
    https://refi64.com/posts/the-magic-of-rpython.html
  20. RPython: Frequently Asked Questions
    http://rpython.readthedoc­s.io/en/latest/faq.html
  21. RPython’s documentation
    http://rpython.readthedoc­s.io/en/latest/index.html
  22. RPython (Wikipedia)
    https://en.wikipedia.org/wi­ki/PyPy#RPython
  23. Getting Started with RPython
    http://rpython.readthedoc­s.io/en/latest/getting-started.html
  24. PyPy (home page)
    https://pypy.org/
  25. PyPy (dokumentace)
    http://doc.pypy.org/en/latest/
  26. Localized Type Inference of Atomic Types in Python (2005)
    http://citeseer.ist.psu.e­du/viewdoc/summary?doi=10­.1.1.90.3231
  27. Tutorial: Writing an Interpreter with PyPy, Part 1
    https://morepypy.blogspot­.com/2011/04/tutorial-writing-interpreter-with-pypy.html
  28. List of numerical analysis software
    https://en.wikipedia.org/wi­ki/List_of_numerical_analy­sis_software
  29. Pixie: lehký skriptovací jazyk s „kouzelnými“ schopnostmi
    https://www.root.cz/clanky/pixie-lehky-skriptovaci-jazyk-s-kouzelnymi-schopnostmi/
  30. Programovací jazyk Pixie: funkce ze základní knihovny a použití FFI
    https://www.root.cz/clanky/pro­gramovaci-jazyk-pixie-funkce-ze-zakladni-knihovny-a-pouziti-ffi/
  31. The future can be written in RPython now (článek z roku 2010)
    http://blog.christianpero­ne.com/2010/05/the-future-can-be-written-in-rpython-now/
  32. PyPy is the Future of Python (článek z roku 2010)
    https://alexgaynor.net/2010/ma­y/15/pypy-future-python/
  33. Portal:Python programming
    https://en.wikipedia.org/wi­ki/Portal:Python_programming
  34. RPython Frontend and C Wrapper Generator
    http://www.codeforge.com/ar­ticle/383293
  35. PyPy’s Approach to Virtual Machine Construction
    https://bitbucket.org/pypy/ex­tradoc/raw/tip/talk/dls2006/py­py-vm-construction.pdf
  36. Tutorial: Writing an Interpreter with PyPy, Part 1
    https://morepypy.blogspot­.com/2011/04/tutorial-writing-interpreter-with-pypy.html
  37. A simple interpreter from scratch in Python (part 1)
    http://www.jayconrod.com/posts/37/a-simple-interpreter-from-scratch-in-python-part-1
  38. Brainfuck Interpreter in Python
    https://helloacm.com/brainfuck-interpreter-in-python/
  39. Interpretry, překladače, JIT překladače a transpřekladače programovacího jazyka Lua
    https://www.root.cz/clanky/interpretry-prekladace-jit-prekladace-a-transprekladace-programovaciho-jazyka-lua/
  40. LuaJIT – Just in Time překladač pro programovací jazyk Lua
    https://www.root.cz/clanky/luajit-just-in-time-prekladac-pro-programovaci-jazyk-lua/
  41. LuaJIT – Just in Time překladač pro programovací jazyk Lua (2)
    https://www.root.cz/clanky/luajit-just-in-time-prekladac-pro-programovaci-jazyk-lua-2/
  42. LuaJIT – Just in Time překladač pro programovací jazyk Lua (3)
    https://www.root.cz/clanky/luajit-just-in-time-prekladac-pro-programovaci-jazyk-lua-3/
  43. LuaJIT – Just in Time překladač pro programovací jazyk Lua (4)
    https://www.root.cz/clanky/luajit-just-in-time-prekladac-pro-programovaci-jazyk-lua-4/
  44. LuaJIT – Just in Time překladač pro programovací jazyk Lua (5 – tabulky a pole)
    https://www.root.cz/clanky/luajit-just-in-time-prekladac-pro-programovaci-jazyk-lua-5-tabulky-a-pole/
  45. LuaJIT – Just in Time překladač pro programovací jazyk Lua (6 – překlad programových smyček do mezijazyka LuaJITu)
    https://www.root.cz/clanky/luajit-just-in-time-prekladac-pro-programovaci-jazyk-lua-6-preklad-programovych-smycek-do-mezijazyka-luajitu/
  46. LuaJIT – Just in Time překladač pro programovací jazyk Lua (7 – dokončení popisu mezijazyka LuaJITu)
    https://www.root.cz/clanky/luajit-just-in-time-prekladac-pro-programovaci-jazyk-lua-7-dokonceni-popisu-mezijazyka-luajitu/
  47. LuaJIT – Just in Time překladač pro programovací jazyk Lua (8 – základní vlastnosti trasovacího JITu)
    https://www.root.cz/clanky/luajit-just-in-time-prekladac-pro-programovaci-jazyk-lua-8-zakladni-vlastnosti-trasovaciho-jitu/
  48. LuaJIT – Just in Time překladač pro programovací jazyk Lua (9 – další vlastnosti trasovacího JITu)
    https://www.root.cz/clanky/luajit-just-in-time-prekladac-pro-programovaci-jazyk-lua-9-dalsi-vlastnosti-trasovaciho-jitu/
  49. LuaJIT – Just in Time překladač pro programovací jazyk Lua (10 – JIT překlad do nativního kódu)
    https://www.root.cz/clanky/luajit-just-in-time-prekladac-pro-programovaci-jazyk-lua-10-jit-preklad-do-nativniho-kodu/
  50. LuaJIT – Just in Time překladač pro programovací jazyk Lua (11 – JIT překlad do nativního kódu procesorů s architekturami x86 a ARM)
    https://www.root.cz/clanky/luajit-just-in-time-prekladac-pro-programovaci-jazyk-lua-11-jit-preklad-do-nativniho-kodu-procesoru-s-architekturami-x86-a-arm/
  51. LuaJIT – Just in Time překladač pro programovací jazyk Lua (12 – překlad operací s reálnými čísly)
    https://www.root.cz/clanky/luajit-just-in-time-prekladac-pro-programovaci-jazyk-lua-12-preklad-operaci-s-realnymi-cisly/
  52. Podpora SIMD (vektorových) instrukcí na RISCových procesorech
    https://www.root.cz/clanky/podpora-simd-vektorovych-instrukci-na-riscovych-procesorech/
  53. Užitečné rozšíření GCC – podpora SIMD (vektorových) instrukcí: nedostatky technologie
    https://www.root.cz/clanky/uzitecne-rozsireni-gcc-podpora-simd-vektorovych-instrukci-nedostatky-technologie/
  54. Podpora SIMD operací v GCC s využitím intrinsic pro nízkoúrovňové optimalizace
    https://www.root.cz/clanky/podpora-simd-operaci-v-gcc-s-vyuzitim-intrinsic-pro-nizkourovnove-optimalizace/
  55. Podpora SIMD operací v GCC s využitím intrinsic: technologie SSE
    https://www.root.cz/clanky/podpora-simd-operaci-v-gcc-s-vyuzitim-intrinsic-technologie-sse/
  56. Rozšíření instrukční sady „Advanced Vector Extensions“ na platformě x86–64
    https://www.root.cz/clanky/rozsireni-instrukcni-sady-advanced-vector-extensions-na-platforme-x86–64/
  57. Rozšíření instrukční sady F16C, FMA a AVX-512 na platformě x86–64
    https://www.root.cz/clanky/rozsireni-instrukcni-sady-f16c-fma-a-avx-512-na-platforme-x86–64/
  58. Použití instrukcí SSE a AVX pro zrychlení bitových operací
    https://www.root.cz/clanky/pouziti-instrukci-sse-a-avx-pro-zrychleni-bitovych-operaci/
  59. Rozšíření instrukční sady AVX-512 na platformě x86–64 (dokončení)
    https://www.root.cz/clanky/rozsireni-instrukcni-sady-avx-512-na-platforme-x86–64-dokonceni/
  60. Nuitka
    https://github.com/Nuitka/Nuitka

Autor článku

Vystudoval VUT FIT a v současné době pracuje na projektech vytvářených v jazycích Python a Go.