A II. ellenőrző pont II. ellenőrző pont: adatból
ellenőrzött feature-tábla után rendelkezésünkre áll egy
rendelésszintű, időhelyes features.parquet. A modellépítés
előtt a feature-pipeline-t szeretnénk gyorsítani, de nem találgatni
akarunk, hanem méréssel megmutatni, hol érdemes beavatkozni. A bemenet
egy Parquet-fájl, a megfigyelési egység a rendelés, a kimenet egy
determinisztikus, azonos sémájú feature-tábla. Az előzetes becslés: kis
méreten a fájlolvasás, nagy méreten a numerikus transzformáció
dominálhat. Az elfogadási feltétel: a három skálán megismételt mérésből
nevesíteni tudjuk az elsődleges szűk keresztmetszetet, és a
transzformált tábla kontrolljai változatlanok maradnak. A kódot itt nem
futtattuk; minden megadott időtartam szemléltető, a
tanuló saját gépi jegyzőkönyve az érvényes bizonyíték.
A falon mért idő (wall time) az az óra, amit a felhasználó is lát. A processz ideje (CPU time) két részből áll: a felhasználói utasítások idejéből és a kernelbeli, rendszerhívásokra fordított időből . Mértékegység mindkettőnél másodperc:
ahol
a várakozás, például lemezolvasás, hálózat vagy más processzre
várakozás. Pythonban a time.perf_counter() a falidőhöz, a
time.process_time() a processz CPU-idejéhez való.
import time
t_wall = time.perf_counter()
t_cpu = time.process_time()
result = pipeline(path)
wall_s = time.perf_counter() - t_wall
cpu_s = time.process_time() - t_cpu
print(f"wall={wall_s:.3f}s cpu={cpu_s:.3f}s wait={wall_s-cpu_s:.3f}s")Ha wall ≈ cpu, a folyamat számolás- vagy
memóriakorlátos. Ha wall ≫ cpu, akkor I/O-ra vagy alvásra
vár, és a CPU-gyorsítás nem segít. Fordított jel a párhuzamos
könyvtáraknál jelenik meg: ha a BLAS több magot használ, akkor
cpu > wall lehet, például 1,0 s falidő mellett 3,6 s
felhasználói CPU-idő. Kis kézi kontroll: egy képzeletbeli futás
wall=0,85 s, cpu=0,35 s; a hiányzó
0,50 s nem számítás, hanem várakozás.
A cProfile determinisztikus profilozó: minden
függvényhívást megszámol, és két fontos időt ad. A tottime
a függvény saját testében töltött idő, a cumtime a hívott
függvényekkel együtt számított idő. Ha egy sor tottime-ja
alacsony, de cumtime-ja magas, akkor az idő a hívási
mélységben tűnik el.
A memóriaoldalt a standard könyvtár tracemalloc modulja
méri: a get_traced_memory() visszaadja a pillanatnyi és a
csúcskiosztást bájtban. A memory_profiler csomag soronkénti
felbontást ad, de jelentős lassítással, ezért a lab alaptelepítéséhez új
függőségként csak akkor vegyük fel, ha a futtatott profil ezt tényleg
szükségessé teszi. Egy float64 oszlop mérete
bájt; ha a pipeline egy átmeneti DataFrame-et is készít, a csúcs ennek
körülbelül a duplája.
A mérési ismétlés itt a repeat, nem az adatsorok száma.
Legyen
az
-edik
megismételt futás ideje,
.
A használt összegzők:
A medián és az interkvartilis terjedelem (IQR, harmadik kvartilis mínusz első kvartilis) egy kilógó, hideg rendszerbeli futásra kevésbé érzékeny, mint az átlag. Ha két változatot hasonlítunk össze, a sebességhányados . A log-hányados közelítő relatív szórása első rendben:
Ez nem konfidenciaintervallum, csak a mérés zajának nagyságrendjét mutatja. Számpélda: ha , és , akkor a fenti hiba . Egy hányados körülbelül relatív ingadozással értelmezhető, ami nagyobb, mint amit egyetlen futásból látnánk.
A baseline a jelenlegi, helyes implementáció rögzített változata. Nem
trükkösen lassú kód, hanem az az állapot, amelyhez a gyorsulást
relatívan mérjük. A jegyzőkönyvhöz hozzátartozik a Python-verzió, a
NumPy/pandas verzió, a gép, az operációs rendszer, és ha konténerben
futunk, az image-digest is Konténer és virtuális
környezet külön szerepe nyomán. A features.parquet
SHA-256 lenyomata az inputot azonosítja, ahogy a cache kulcs is azt
használja Cache, backfill és orchestrator. Ha a
transzformáció módosul, az invariantok (shape, dtype, kontrollösszeg)
biztosítják, hogy nem mértünk gyorsabb, de eltérő eredményt.
A példa a tiszta függvényes határválasztást követi Modulhatár és tiszta függvény: a
features.py-ból ismert transzformációt futtatjuk, most
kizárólag a numerikus oszlopokra, hogy a profil olvasható maradjon.
# src/northstar/profile_features.py -- futtatható vázlat
from __future__ import annotations
import io
import cProfile
import pstats
import statistics
import timeit
import tracemalloc
from pathlib import Path
import numpy as np
import pandas as pd
NUMERIC_FEATURES = [
"distance_km",
"priority_flag",
"basket_minor",
"quantity_sum",
"account_age_days",
]
def load_features(path: str | Path) -> pd.DataFrame:
df = pd.read_parquet(path)
return df.loc[:, NUMERIC_FEATURES].copy()
def transform_features(df: pd.DataFrame) -> pd.DataFrame:
out = df.copy()
out["log_basket"] = np.log1p(out["basket_minor"].to_numpy())
for col in ["distance_km", "log_basket", "account_age_days"]:
arr = out[col].to_numpy(dtype=np.float64)
mu = arr.mean()
sd = arr.std(ddof=0)
out[f"{col}_z"] = (arr - mu) / sd if sd > 0 else 0.0
return out
def pipeline(path: str | Path) -> pd.DataFrame:
return transform_features(load_features(path))
def timed_pipeline(path: str | Path, repeat: int = 7, number: int = 3) -> list[float]:
pipeline(path) # bemelegítés és operációs cache feltöltése
raw = timeit.repeat(lambda: pipeline(path), number=number, repeat=repeat)
return [t / number for t in raw] # egy hívásra eső átlagos idő
def allocation_stats(path: str | Path) -> tuple[pd.DataFrame, int, int]:
tracemalloc.start()
out = pipeline(path)
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
return out, peak
def profile_summary(path: str | Path, repeat: int = 7, number: int = 3) -> dict:
times = timed_pipeline(path, repeat, number)
out, peak_bytes = allocation_stats(path)
pr = cProfile.Profile()
pr.enable()
pipeline(path)
pr.disable()
stream = io.StringIO()
pstats.Stats(pr, stream=stream).sort_stats("tottime").print_stats(8)
return {
"median_wall_s": statistics.median(times),
"iqr_wall_s": statistics.quantiles(times, n=4)[2]
- statistics.quantiles(times, n=4)[0],
"times_s": times,
"peak_bytes": peak_bytes,
"profile": stream.getvalue(),
"shape": out.shape,
}A három skálához az alapsorokat ismétléssel nagyítjuk, csak a mérési
bemenethez:
pd.concat([base] * k, ignore_index=True).head(target_n);
ezt a features.parquet mellé írt ideiglenes fájlokra
futtatjuk. A parancs Linux/macOS alatt, lab/
munkakönyvtárból:
# platform: Linux/macOS; munkakönyvtár: lab/
python -m northstar.profile_features --input artifacts/features.parquet \
--sizes 2400 9600 24000 --repeat 7Szemléltető, nem e könyv gépi mérése:
| n (rendelés) | wall medián | CPU user () | peak allokált |
|---|---|---|---|
| 2 400 | 0,44 s | 0,19 s | 14 MB |
| 9 600 | 0,61 s | 0,52 s | 56 MB |
| 24 000 | 1,10 s | 1,00 s | 140 MB |
A hozzá tartozó, szintén szemléltető cProfile-kivágat
n=2400 esetén a read_parquet/PyArrow-sort
helyezné az első helyre, n=24000 esetén pedig a
np.log1p mögötti NumPy-ufunc és a
DataFrame.copy allokációja kerülne felülre. Ezt a valódi
mérési jegyzőkönyvben a gépi profile mező rögzíti.
Az invariáns ellenőrzéshez nem kell a futás vége:
basket_minor=0 esetén
,
így a log_basket minimuma nemnegatív. Egy kézi kétpontos
etalonnal is ellenőrizhető: ha egy distance_km oszlop
értékei [0, 2], akkor
,
,
a standardizált értékek [-1, 1], összegük nulla, szórásuk
egy. Az output shape=(n, 9), mert az öt bemeneti oszlophoz
egy log- és három standardizált oszlop jön.
A szemléltető táblázatból a döntés: n=2400-nál a falidő
és a CPU-idő különbsége nagy, tehát a lemezolvasás a gyanúsított;
n=24000-nél wall − cpu kicsi, tehát a
CPU-oldali transzformáció már dominál. A peak allokáció a sorokkal közel
lineárisan nő, ami a copy() és a köztes tömbök szerepére
utal. Egy ilyen bizonyíték után lehet célzottan cache-elni az I/O-t,
vagy a transzformáció másolási rétegét csökkenteni; nem mindent
egyszerre.
Teljesítménymérésnél a bemelegítés és az ismétlések variabilitásának közlése nélkül az egyetlen futási idő nem megbízható összehasonlítás.
Értelmezés. Adott két változat hétszeri mérése:
A falidői [0,48; 0,47; 0,51; 0,49; 0,47; 0,50; 0,46] s,
CPU-user mind 0,18 s; B falidői
[0,61; 0,41; 0,43; 0,42; 0,44; 0,41; 0,40] s, CPU-user mind
0,38 s. Számold ki az A és B falidő-mediánját, IQR-ját, és
mondd meg, melyik hányadosra állítható stabilabb következtetés.
Implementálás. Írj
measure_repeated(fn, *, warmup, number, repeat) függvényt,
amely warmup alkalmat bemelegítésként futtat, majd
timeit.repeat-tel mér, végül a becsült per-hívás időkből
median/IQR/CV hármast ad. Miért a mediánt jelented, nem a
minimumot?
Hibadiagnózis. Egy kolléga
timeit.timeit("transform_features(df)", globals=..., number=1)
hívással méri a transzformációt az éles adaton, és közli, hogy a kód
harmadával lassult az előző napihoz képest. Legalább három olyan okot
nevezz meg, ami miatt ez nem értelmezhető változatlanságként.
Megváltozott feltétel. A
features.parquet hálózati meghajtóra kerül. Egy futás
wall=9,2 s, cpu=1,1 s; helyi másolatra
wall=1,6 s, cpu=1,1 s. Ezután a pipeline-ba
bekerül a segment, region,
acquisition_channel one-hot kódolása is, és
n=24000 mellett wall=2,9 s,
cpu=2,5 s, peak 380 MB. Melyik lépés milyen jellegű szűk
keresztmetszet, és mi a következő mérési kérdés a párhuzamosítás
előtt?
A rendezett 7, 0,42; az IQR
.
B rendezett 7, 0,42; IQR
.
B rendezett 7, 0,42; az IQR
.
B rendezett sor [0,40; 0,41; 0,41; 0,42; 0,43; 0,44; 0,61],
medián 0,42, IQR
.
Az első B mérés 0,61 kiugró volt, de a medián és az IQR
felülírta. A CPU-oldalon A átlagosan várakozik, tehát A I/O-közeli, B
viszont cpu > wall, tehát B a számítási oldalon
terheltebb. A B falidő-mediánja megbízhatóbb, mert a CPU-idő szerint a
mérés alatt komolyabb, kétséges várakozás nincs; A-nál a hiányzó idő
OS-allapotfüggő marad.
Egy lehetséges megoldás:
def measure_repeated(fn, *, warmup=3, number=5, repeat=9):
for _ in range(warmup):
fn()
times = [
t / number
for t in timeit.repeat(fn, number=number, repeat=repeat)
]
sorted_times = sorted(times)
q1, q3 = statistics.quantiles(sorted_times, n=4)[0], statistics.quantiles(
sorted_times, n=4
)[2]
mean = statistics.fmean(times)
sd = statistics.pstdev(times)
cv = sd / mean if mean > 0 else float("inf")
return {"median": statistics.median(times), "iqr": q3 - q1, "cv": cv}A minimum bármelyik OS-szálütemezési szerencse lehet; a medián
robustabb. A number és a repeat külön szerepet
játszik: az előbbi a híváson belüli zajt átlagolja, az utóbbi a futások
közötti varianciát méri. Az elfogadási küszöb: IQR kisebb legyen a
medián 10%-ánál; ha nem, növeljük a repeat értékét.
Lehetséges okok: nincs bemelegítés, ezért az első hívás importál,
a JIT-kód még nem meleg, vagy hideg az I/O-gyorsítótár; a
number=1 miatt a rendszer CPU-frekvencia-skálázása egyetlen
futásban benne marad; nincs rögzített baseline-verzió és bemenethash,
így nem tudjuk, ugyanazt a fájlt és kódot mérte-e; terhelt kollégagépen
más processzek vették el a CPU-t. Az ilyen mérésből legfeljebb egy
hibajel lehet, nem teljesítményváltozás.
A hálózati futásnál wall − cpu = 8,1 s, tehát
I/O-korlát; a helyi másolatnál ugyanez 0,5 s, tehát a fájl
elhelyezése volt a probléma. Az one-hot után wall=2,9 s és
cpu=2,5 s: a CPU-oldal vált dominánssá, de a
cpu/wall arány kisebb egynél, tehát a szűk keresztmetszet
egy magos számítás és allokáció. A 380 MB csúcs miatt a 380 MB-os köztes
adat másolása valószínű. A következő mérési kérdés: a
pd.get_dummies helyett astype("category") +
OneHotEncoder vagy ritka mátrix hány másodperc és hány MB?
Ezt a frissített baseline ellen mért sebességhányadossal kell
eldönteni.
Egyetlen időmérésből nem indokolt általános gyorsulási sorrendet
állítani. Ellenpélda: A első futása 0,60 s, B első futása
0,52 s; ha itt megállunk, azt mondanánk, hogy B gyorsabb. A
hétszeri mérésben azonban A
[0,60; 0,41; 0,43; 0,42; 0,44; 0,41; 0,40], B
[0,52; 0,54; 0,53; 0,55; 0,52; 0,54; 0,53], a mediánok
0,42 s és 0,53 s: a megismételt mérés szerint
A a gyorsabb, és az első A-futás csak hidegindulás volt. Ugyanez az oka
annak, hogy a Vektorizálás és mért teljesítmény
alfejezetben is repeat és bemelegítés szerepelt.
Önellenőrzés: mielőtt gyorsulást jelentünk, kérdezzük meg, hány
ismétlésből jött a szám; ha egy, a jegyzőkönyv csak egy pontot
bizonyít.
A szűk keresztmetszet mérése három réteget választ szét: a falidő és
a CPU-idő különbsége az I/O-várakozást jelzi, a cProfile
tottime/cumtime profilja a forró függvényeket
nevesíti, a tracemalloc pedig a csúcskiosztást méri.
Egyetlen futás nem bizonyíték; timeit.repeat, bemelegítés,
majd medián/IQR/CV szükséges, és a sebességhányados relatív hibája a
CV-kből becsülhető. A mérés előtt rögzített baseline, input-hash és
invariant biztosítja, hogy ugyanazt az eredményt gyorsítottuk. A
következő alkalmazás Thread, process és könyvtári
szálak alfejezetben ezt használja fel: a cpu/wall
hányadosból döntjük el, hogy thread, process vagy könyvtári szálak
adhatnak-e tényleges nyereséget.