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.
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.
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.pyAz 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 |
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.
É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.
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.
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.
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))D tömb, ha n=20_000 és a dtype
8 byte?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.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.10Ha 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.
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.n-re a memória hamar kimerül, a swap vagy az
OutOfMemory megöli a folyamatot.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.
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.
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.