33.1 Szűk keresztmetszet és mérési terv

33.1.1 Cél és konkrét helyzet

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.

33.1.2 Wall time, CPU time és a hiányzó idő

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 TuserT_{\mathrm{user}} és a kernelbeli, rendszerhívásokra fordított időből TsysT_{\mathrm{sys}}. Mértékegység mindkettőnél másodperc:

Twall≈Twait+Tuser+Tsys,T_{\mathrm{wall}} \approx T_{\mathrm{wait}} + T_{\mathrm{user}} + T_{\mathrm{sys}},

ahol TwaitT_{\mathrm{wait}} 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.

33.1.3 Profil és allokáció

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 n⋅8n \cdot 8 bájt; ha a pipeline egy átmeneti DataFrame-et is készít, a csúcs ennek körülbelül a duplája.

33.1.4 Mintanagyság és a hányados bizonytalansága

A mérési ismétlés itt a repeat, nem az adatsorok száma. Legyen TiT_i az ii-edik megismételt futás ideje, i=1,…,ri = 1,\dots,r. A használt összegzők:

T‾=1r∑i=1rTi,s=1r−1∑i=1r(Ti−T‾)2,CV=sT‾.\bar{T} = \frac{1}{r}\sum_{i=1}^r T_i, \qquad s = \sqrt{\frac{1}{r-1}\sum_{i=1}^r (T_i-\bar{T})^2}, \qquad \mathrm{CV}=\frac{s}{\bar{T}}.

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 R=T‾A/T‾BR=\bar{T}_A / \bar{T}_B. A log-hányados közelítő relatív szórása első rendben:

SElogR≈CVA2rA+CVB2rB.\mathrm{SE}_{\log R} \approx \sqrt{\frac{\mathrm{CV}_A^2}{r_A} + \frac{\mathrm{CV}_B^2}{r_B}}.

Ez nem konfidenciaintervallum, csak a mérés zajának nagyságrendjét mutatja. Számpélda: ha CVA=0,04\mathrm{CV}_A=0{,}04, CVB=0,06\mathrm{CV}_B=0{,}06 és rA=rB=7r_A=r_B=7, akkor a fenti hiba 0,042/7+0,062/7≈0,027\sqrt{0{,}04^2/7 + 0{,}06^2/7}\approx 0{,}027. Egy R=1,5R=1{,}5 hányados körülbelül ±8%\pm 8\% relatív ingadozással értelmezhető, ami nagyobb, mint amit egyetlen futásból látnánk.

33.1.5 Baseline

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.

33.1.6 Teljes minimális példa

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 7

Szemléltető, nem e könyv gépi mérése:

n (rendelés) wall medián CPU user (TuserT_{\mathrm{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.

33.1.7 Eredmény értelmezése

Az invariáns ellenőrzéshez nem kell a futás vége: basket_minor=0 esetén log(1+0)=0\log(1+0)=0, í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 μ=1\mu=1, s=1s=1, 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.

33.1.8 Feladatok

  1. É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.

  2. 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?

  3. 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.

  4. 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?

33.1.9 Megoldások és indoklás

  1. A rendezett 7, 0,42; az IQR 0,44−0,41=0,030{,}44-0{,}41=0{,}03. B rendezett 7, 0,42; IQR 0,43−0,41=0,030{,}43-0{,}41=0{,}03. B rendezett 7, 0,42; az IQR 0,44−0,41=0,030{,}44-0{,}41=0{,}03. B rendezett sor [0,40; 0,41; 0,41; 0,42; 0,43; 0,44; 0,61], medián 0,42, IQR 0,44−0,41=0,030{,}44-0{,}41=0{,}03. 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.

  2. 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.

  1. 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.

  2. 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.

33.1.10 Gyakori tévedés és ellenpélda

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.

33.1.11 Rövid összefoglaló

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.