5.4 Vektorizálás és mért teljesítmény

5.4.1 Cél és konkrét helyzet

Egy munkatárs a rendeléskosár-elemzéshez logisztikus pontszámot számol:
[ L(x)=\frac{1}{1+e^{-x}}. ] Itt (x) normalizált, folytonos bemenet; a képlet most csak numerikus példa, nem predikciós modell. A bemenet float64 ndarray, shape (n,), elemei [-6, 6] intervallumban. Két méretet vizsgálunk: n = 2_400 a Northstar rendelési nagyságrendjéhez hasonló kis tömb, és n = 240_000 nagy tömb. A kimenet ugyanolyan shape és dtype. A mérési megfigyelési egység egy repeat: ilyenkor number = 50 futtatást mér a timeit, és ezt repeat = 7 alkalommal ismétli. Az elvárt ellenőrzés: L(0) = 0.5, a kimenet minden eleme 0 és 1 közé esik, a Python-ciklusos és NumPy-változat np.allclose-szal egyezik.

Előzetes becslés: nagy n-re a NumPy nagyságrenddel gyorsabb lehet; kis n-re az interpreter- és könyvtárhívási állandó költség miatt előfordulhat, hogy a Python-ciklus nyer. Ezt most méréssel döntjük el.

5.4.2 Fogalmak: timeit, bemelegítés, allokáció, ismétlés, memória

A timeit (időmérő) szerepe. A timeit.repeat(stmt, number=..., repeat=...) a stmt kifejezést number-szer futtatja, ezt repeat-ször megismétli, és minden ismétlés teljes idejét adja vissza. Egy futtatás becsült ideje total / number. Nem a time.time() köré írt egyszeri mérést használunk, mert az egyetlen futás zaját nem tudnánk megkülönböztetni a valódi időtől.

Bemelegítés. Az első hívások előtt a NumPy megosztott kódja betöltődik, a memórialapok bekötődnek, a CPU-frekvencia és az operációs rendszer ütemezése is átmeneti állapotban lehet. Ezért a mérendő függvényeket előbb legalább néhányszor kis bemeneten meghívjuk. Ez nem befolyásolja a mért időt, de elkerüli, hogy az első futás torzítsa a legjobb eredményt.

Allokáció. A np.empty, a művelet kimenete és a hosszú kifejezések ideiglenes tömbjei mind memóriát foglalnak. A mérés előtt el kell dönteni, hogy az allokáció a mért feladat része-e. A következő minimális példában a kimeneti tömb létrehozása mindkét változatban a mért futtatás része, mert egy valódi hívásnál is meg kell történnie. Az out= paraméterrel in-place változat akkor lenne releváns, ha a kimenet megadott puffert töltene fel.

Mérési ismétlés. Az ismétlésekből a legjobb (min) időt nézzük, mert a legjobb futás a legkevésbé szenved az operációs rendszer közbeavatkozásától; mellette a mediánt is rögzítjük, mert a legjobb önmagában elrejtheti a nagy szórást. Ugyanazon gépen, azonos bemenettel, ugyanabban a folyamatban mérünk; a gépet, verziót és beállítást a futási jegyzékbe írjuk (visszautalás: 4.4).

Műveletszám- és memóriabecslés. A Python-ciklus n darab math.exp hívást és n darab interpreter-lépést végez; a NumPy egyszeri hívással n elemen, C szintű kernellel dolgozik. Leegyszerűsített modell: [ t_{\text{py}}(n)\approx c_{\text{py}}+a n,\qquad t_{\text{np}}(n)\approx c_{\text{np}}+b n, ] ahol (c) az állandó hívási költség, (a) és (b) az elemenkénti idő, másodperc/elem dimenzióval. A NumPy-nak általában (b<a), de az állandója nagyobb: (c_{\text{np}}>c_{\text{py}}). A fordulópont (break-even) elméletileg: [ n^{*}\approx \frac{c_{\text{np}}-c_{\text{py}}}{a-b}. ] A memória tömbnél (M(n)=n\cdot s), ahol (s) a dtype bájtmérete: float64 esetén (s=8) byte. Tehát n=240_000 tömb 1_920_000 byte, azaz 1.92 MB. Ha Python listában tárolnánk float értékeket, CPython 64 bit alatt egy elem hozzávetőleg 8 byte listapointer + 24 byte float objektum lenne, tehát 32 byte/elem; a tömör ndarray ezért a kimeneti oldalon is memóriát takarít meg.

5.4.3 Teljes minimális munkapélda

A Python-változat előre allokál egy np.empty_like tömböt, és elemenként tölti ki; a NumPy-változat ugyanazt az eredményt teljes tömbművelettel adja. Így a kimeneti allokáció mindkét ágon azonos.

# bench_logistic.py
import math
import timeit
import numpy as np

def logistic_py(x):
    """Python-ciklusos, allokált kimeneti tömbbel."""
    out = np.empty_like(x, dtype=np.float64)
    for i, v in enumerate(x):
        out[i] = 1.0 / (1.0 + math.exp(-v))
    return out

def logistic_np(x):
    """Vektorizált NumPy-változat."""
    return 1.0 / (1.0 + np.exp(-x))

# 1. Kis kézi ellenőrzés: shape, dtype, L(0)=0.5, egyezés
x_small = np.linspace(-6.0, 6.0, 11)
y_py = logistic_py(x_small)
y_np = logistic_np(x_small)
assert y_py.shape == y_np.shape == (11,)
assert y_py.dtype == y_np.dtype == np.float64
assert np.allclose(y_py, y_np, rtol=1e-15, atol=1e-15)
assert y_np[5] == 0.5          # x=0 elem pontosan a közepén
assert np.all((y_np > 0.0) & (y_np < 1.0))
print("Kis ellenőrzés rendben:", y_np)

# 2. Mérési segédfüggvény
def bench_one(n, number=50, repeat=7):
    x = np.linspace(-6.0, 6.0, n)
    # Bemelegítés mindkét változatnak, a mérésen kívül
    for _ in range(3):
        logistic_py(x[:min(n, 11)])
        logistic_np(x[:min(n, 11)])
    py_times = timeit.repeat(lambda: logistic_py(x),
                             number=number, repeat=repeat)
    np_times = timeit.repeat(lambda: logistic_np(x),
                             number=number, repeat=repeat)
    py_best = min(py_times) / number * 1000.0
    np_best = min(np_times) / number * 1000.0
    py_med = float(np.median(py_times)) / number * 1000.0
    np_med = float(np.median(np_times)) / number * 1000.0
    return {
        "n": n,
        "x_MB": x.nbytes / 1e6,
        "python_best_ms": py_best,
        "numpy_best_ms": np_best,
        "python_median_ms": py_med,
        "numpy_median_ms": np_med,
        "speedup_best": py_best / np_best if np_best > 0 else float("nan"),
    }

if __name__ == "__main__":
    for n in (2_400, 24_000, 240_000):
        row = bench_one(n)
        print(row)

Futtatás a projektgyökérben, feltéve, hogy a fájlt oda mentette:

python bench_logistic.py

Az itt látható kód futtatható, de a könyvgenerátor nem futtatta; a számszerű sort a tanuló helyi futtatása adja. Az alábbi táblázat ezért szándékosan üres mérési oszlopokkal mutatja a rögzítendő szerkezetet:

n python_best_ms numpy_best_ms speedup_best
2 400 helyi mérés helyi mérés helyi mérés
24 000 helyi mérés helyi mérés helyi mérés
240 000 helyi mérés helyi mérés helyi mérés

5.4.4 Eredmény értelmezése

A kis ellenőrző példa invariánsa: az ötödik indexen (x=0), ezért y_np[5] pontosan 0.5, mert a 0.5 kettes számrendszerben pontosan ábrázolható. A Python- és NumPy-eredmény np.allclose-szal rtol=1e-15, atol=1e-15 mellett egyezik; a float64 kerekítési eltérése ennél kisebb (5.3). A kimenet shape=(11,), dtype=float64, minden elem (0,1)-ben van.

A nagy mérési táblában egy tipikus gépen speedup_best kis n-re 1 alatt lehet, mert a logistic_np állandó hívási költsége, a kimeneti tömb allokációja és a C-könyvtár hívása dominál. n=240_000-nél viszont a Python-ciklus 240_000 math.exp függvényhívást és Python-szintű lépést fizet, ezért a NumPy-best idő általában több nagyságrenddel kisebb. A pontos arány gépenként és verzióként eltérő; a mérés akkor hiteles, ha a jegyzékben szerepel az operációs rendszer, CPU, RAM, Python- és NumPy-verzió, valamint a number és repeat.

A memória n=240_000 esetén: bemeneti x 1.92 MB, a NumPy-kimenet 1.92 MB, a Python-változat kimeneti tömbje is 1.92 MB. A hosszú NumPy-kifejezés ideiglenes tömböket is létrehozhat: -x, exp(-x), 1.0 + ..., 1.0 / ... mind n elemű float64; a csúcs a tartós két tömbnél nagyobb, de a Python-listás megoldás 32 byte/elem közbülső tárolásánál még mindig kedvezőbb.

Benchmark mint megismételhető kísérlet. A vektorizálás nem garantál gyorsulást minden méretnél: kis bemenetnél a hívási többlet, nagy bemenetnél a memória-sávszélesség és a köztes tömbök költsége dominálhat. A Python-ciklus és NumPy-művelet összehasonlításakor ugyanazt a számítást kell mérni, és az eredményegyezést előbb külön teszttel igazolni.

Javasolt mérési protokoll: bemelegítés; több ismétlés monoton órával vagy timeit-tal; medián és szóródás jelentése; bemeneti méret, hardver, Python/NumPy/BLAS-verzió és szálkeret rögzítése. Egyetlen mért időből ne fogalmazz meg általános teljesítményállítást.

5.4.5 Feladatok

  1. Értelmezés. Egy gépen a 2 400 elemű bemenetre a Python-ciklus 0.42 ms, a NumPy 0.51 ms; 240 000 elemre a Python 43 ms, a NumPy 1.3 ms. (A számok fiktívek, az érveléshez használja őket.) Magyarázza meg az időmodell alapján, miért lehetséges kis méretnél NumPy-veszteség, és miért nem mondhatjuk, hogy „a NumPy mindig gyorsabb”. Adja meg a fordulópont képletét a szakasz jelöléseivel.

  2. Implementáció és mérés. Futtassa a bench_logistic.py fájlt a gépén. Készítse el a fenti háromsoros táblázatot a legjobb és medián idővel is. A táblázat alá írja be a gép adatait: operációs rendszer, CPU, RAM, Python-verzió, NumPy-verzió, number=..., repeat=.... Ellenőrizze, hogy a repeat-ekből számított relatív tartomány (max-min)/median minden sorban 10% alatt marad-e; ha nem, növelje a repeat értéket.

  3. Hibadiagnózis. Egy kolléga így mérte a NumPy-változatot:

x_list = list(np.linspace(-6.0, 6.0, 100_000))

def convert_and_logistic(x_list):
    x = np.array(x_list, dtype=np.float64)
    return 1.0 / (1.0 + np.exp(-x))

times = timeit.repeat(lambda: convert_and_logistic(x_list),
                      number=100, repeat=5)

Azt állítja, hogy a NumPy 2.1-szer lassabb, mint a Python-ciklus. Mi a mérési hiba? Írjon olyan javított kódot, amely az előkészítést és a kernelműveletet külön méri. Adja meg, mit kell bemelegíteni a két mérésben.

  1. Megváltozott feltétel: páronkénti távolság. A distance_km 20 000 elemű float64 tömb. A kolléga az átlagos páronkénti abszolút távolságot így számolja:
D = np.abs(np.subtract.outer(d, d))
avg = D.sum() / (n * (n - 1))
  1. Hány bájt a D tömb, ha n=20_000 és a dtype 8 byte?
  2. Mi a memóriaprobléma a teljes mátrix materializálásával?
  3. Írjon mean_pairwise_abs(d, block=1000) blokkos változatot, amely egyszerre legfeljebb block sort tart meg, és n=200-on np.isclose-szal rtol=1e-9, atol=1e-12 mellett ellenőrizze, hogy az eredmény egyezik a teljes outer-változattal.

5.4.6 Megoldások és indoklás

1. megoldás. A lineáris modellben (a) a Python elemenkénti költsége, (b) a NumPy elemenkénti költsége, és (a > b). Kis n-nél a (t) értékét az állandó tagok határozzák meg: (c_{\text{np}}) tartalmazza a tömb kernel hívását, a dtype-ellenőrzéseket és a kimeneti allokációt, ezért nagyobb lehet, mint (c_{\text{py}}). Nagy n-nél a lineáris tag dominál, ezért a kisebb (b) nyer. A fordulópont (n^{*}\approx (c_{\text{np}}-c_{\text{py}})/(a-b)). A „NumPy mindig gyorsabb” azért hamis, mert a gyorsaság a méret, a művelet típusa és a gép függvénye; más műveletre, például np.sort-ra, a fordulópont jóval kisebb lehet.

2. megoldás. A helyi táblázat kitöltése a tanuló mérésétől függ; a hitelesítéshez a következő invariant szükséges: a script meghívása után az assert a kis példán ne szálljon el, a speedup_best kis n-re tipikusan 1 körül, nagy n-re tipikusan 1 fölött van. A relatív tartomány ellenőrzéséhez például:

times = timeit.repeat(lambda: logistic_np(x), number=50, repeat=7)
rel = (max(times) - min(times)) / float(np.median(times))
print(rel)  # cél: < 0.10

Ha 10% fölé megy, növelje a repeat értéket 15-re, vagy zárja be a mérés alatt futó egyéb nagy folyamatokat. A jegyzék a gép adataival együtt teszi megismételhetővé a mérést.

3. megoldás. A hiba az, hogy minden mért futtatás tartalmazza a 100 000 elemű Python-lista np.array-vá alakítását. Ez adatelőkészítés, nem a vektorizált képlet; a Python-ciklusos összehasonlító ág nem végez hasonló konverziót. Helyes szétválasztás:

# előkészítés, a mérésen kívül
x_arr = np.array(x_list, dtype=np.float64)
# bemelegítés mindkét útra
logistic_py(x_arr)
logistic_np(x_arr)
# kernelművelet mérése
kernel_times = timeit.repeat(lambda: logistic_np(x_arr),
                             number=100, repeat=5)
# előkészítés külön mérése
prep_times = timeit.repeat(lambda: np.array(x_list, dtype=np.float64),
                           number=100, repeat=5)

A bemelegítésnek mind a logistic_py, mind a logistic_np első hívását el kell fednie; az x_arr-t a mérésen kívül hozzuk létre. Az ellenőrzés marad np.allclose(y_py, y_np, rtol=1e-15, atol=1e-15). Helyes méréssel a vektorizált képlet a nagy tömbön tipikusan felülmúlja a ciklust, de a tényleges arány gépenként mérhető.

4. megoldás.

  1. D.nbytes = 20_000 * 20_000 * 8 = 3_200_000_000 byte = 3.2 GB. Mivel a kifejezés először np.subtract.outer-rel létrehoz egy 3.2 GB-os tömböt, majd np.abs egy újabb 3.2 GB-osat állít elő, a csúcs legalább 6.4 GB lehet, mielőtt az első felszabadul.
  2. A teljes mátrix a kívánt átlaghoz feleslegesen materializál; nagy n-re a memória hamar kimerül, a swap vagy az OutOfMemory megöli a folyamatot.
  3. Blokkos változat:
def mean_pairwise_abs(d, block=1000):
    n = d.size
    total = 0.0
    count = n * (n - 1)
    for i in range(0, n, block):
        block_vals = d[i:i + block]
        total += np.abs(block_vals[:, None] - d[None, :]).sum()
    return total / count

# Ellenőrzés kis n-en: teljes outer vs blokkos
d_small = np.linspace(1.0, 100.0, 200)
D = np.abs(np.subtract.outer(d_small, d_small))
full_avg = D.sum() / (200 * 199)
block_avg = mean_pairwise_abs(d_small, block=50)
assert np.isclose(full_avg, block_avg, rtol=1e-9, atol=1e-12)

A blokk-köztes tömb formája (block, n), tehát 1000 * 20_000 * 8 = 160_000_000 byte = 160 MB; az np.abs rövid időre még egy hasonló tömböt tarthat, így a csúcs 320 MB körül van, szemben a 3.2 GB-os teljes mátrixszal. Az invariáns a np.isclose a teljes és blokkos eredmény között n=200 esetén; a főátló nullákat ad, ezért a count = n * (n - 1) nevező a rendezett párokat, nem az átlót osztja.

5.4.7 Gyakori tévedés ellenpéldával

A leggyakoribb félreértés, hogy a vektorizált kód mindig gyorsabb és memóriatakarékosabb. Az ellenpélda a 4. feladat: a np.subtract.outer(d, d) a 20 000 elemű bemenetből 3.2 GB-os köztes mátrixot csinál, ami sokkal nagyobb, mint a bemenet, és a gép memóriáját kimerítheti. A másik félreértés, hogy az első futás ideje tipikus mérés; bemelegítés nélkül az idegen kód betöltése és a CPU-átmenetek torzítanak. A harmadik félreértés, hogy a nem futott benchmark eredményét ellenőrzöttnek nevezzük: a hitelesítéshez gép, méret, number, repeat és tényleges futási jegyzék kell. Önellenőrzéshez kérdezze meg: ugyanazt a kimenetet ellenőrzi-e a két ág? A mérésből kihagytam-e a bemeneti konverziót? A memóriabecslésben figyelembe vettem-e a köztes tömböket? Visszautalás: 5.3 toleranciája adja a numerikus egyezés feltételét, 4.4 futási jegyzéke pedig a mérés dokumentálását.

5.4.8 Rövid összefoglaló

A vektorizált NumPy-kód nem automatikusan gyors: a munkát C szintű kernellel, tömör float64 tömbökön végzi, ezért nagy méreten nyer, de kis méreten az állandó hívási és allokációs költség dominálhat. A Python-ciklus elemenként interpreter- és math.exp hívást fizet. Méréshez timeit.repeat kell explicit bemelegítéssel, előre eldöntött allokációs határral, number és repeat beállítással; a legjobb és medián időt, gépadatokat és verziókat rögzítsünk. A memória n-szeres itemsize; az outer-jellegű műveletek hatalmas köztes tömböket hozhatnak létre, ezért blokkos vagy összegző megoldást érdemes választani. A np.allclose a numerikus egyezést, a kézi L(0)=0.5 az irányt ellenőrzi. A következő fejezetben a pandas ugyanezen tömbök fölé épít típusos adattáblát; a mérési fegyelmet ott is tartjuk.