24.2 CPU/GPU profiling és helyes időmérés

24.2.1 Cél, előismeret és induló kérdés

A 24.1. Tanulási görbe és hibadiagnózis tanulási görbéje felhasználja az epoch-időt, de nem mondja meg, hogyan mértük. Ha ugyanazt a hálót kétszer futtatjuk, a Python time modulja más-más értéket adhat, és GPU-n a különbség nem is feltétlenül a számítás ideje. Ebben az egységben a mérés fogalmát pontosítjuk: mit jelent a „futásidő”, miért kell a gyorsítóval szinkronizálni, és hogyan bontsuk a teljes képzési pipeline költségét adatbetöltésre, transzferre, számításra és memóriára.

Előismeret a 24.1. Tanulási görbe és hibadiagnózis hibadiagnózisa, a 12.2. Checkpoint és folytathatóság checkpoint-protokoll és a 20.2. Finomhangolás és tanulási ráta paraméterfrissítési lépése. A labor CPU-n fut; GPU-mérést nem állítunk elvégzettnek, csak jelölt tervet adunk.

Induló kérdés: egy batch feldolgozása ötször mérve 2,1; 2,0; 2,4; 2,0; 9,3 ms-ot mutat. Miért hibás azt állítani, hogy a lépés „2,0 ms”, és miért hibás a 9,3-at egyszerűen kidobni?

24.2.2 A mért időintervallum definíciója

Definíció 1 (mért időintervallum). Legyen CC egy monoton óra; C(t)C(t) a tt fizikai pillanathoz tartozó időbélyeg. Ha a mérés kezdetét aa, végét bb jelöli, a mért futásidő

Tmeas=C(b)−C(a).T_{\text{meas}} = C(b) - C(a).

Az intervallum akkor érvényes az MM munkára, ha MM minden releváns eseménye [a,b][a,b]-be esik. A Python time.perf_counter() monoton óra; a time.time() nem az, mert a rendszeróra visszafelé állítható, így C(b)−C(a)C(b)-C(a) akár negatív is lehet. A CPU process time ezzel szemben csak a processz CPU-idő-magját méri, és nem tartalmazza a DataLoader várakozását vagy a GPU-végrehajtást.

A definícióban a „releváns esemény” a kulcs. Amikor a mérés csupán a CPU-oldali műveletsor végét rögzíti, a gyorsítón esetleg még futó munkát nem tartalmazza.

24.2.3 Miért szükséges a gyorsítós szinkronizáció

A GPU munkát aszinkron módon indítjuk: a CPU kernelindítást küld a streambe, majd azonnal továbblép. A mérés helyességéről szóló állítást egy egyszerű, de pontos modellben bizonyítjuk.

Lemma 1 (záró szinkronizáció szükségessége). Legyen mm kernel egyetlen, FIFO streamen. Tegyük fel, hogy az ii-edik kernel indítási időpontja a CPU-órán ℓi\ell_i, tiszta eszközoldali futásideje ei>0e_i>0, és a befejezési időpont a stream-csúcsban

fi=max(fi−1,ℓi)+ei,f0≤ℓ1.f_i = \max(f_{i-1}, \ell_i) + e_i, \qquad f_0 \le \ell_1.

Ha a CPU-oldali mérési intervallum záró időpontjára b<fmb < f_m, akkor az MM kernelsorozat teljes eszközmunkájából legalább fm−bf_m-b idő a mérés lezárása után következik be; így b−ab-a nem érvényes mértéke MM futásidejének.

Bizonyítás. Az mm-edik kernel befejezési időpontja fmf_m. A b<fmb<f_m feltétel azt mondja, hogy bb-kor a stream még nem érte el az utolsó kernel végét. Mivel a stream FIFO, az utolsó kernel munkájából fm−b>0f_m-b>0 rész bb után esedékes. A mért intervallum tehát nem tartalmazza MM teljes eseményét, ami sérti az érvényesség definícióját. ▫\square

A szinkronizáció nem teszi a mérést „pontossá” minden értelemben: a GPU óráját nem a CPU perf_counter adja, a záró szinkron hívás maga is időt fogyaszt, és a CPU-indítás overheadje is beleszámít a mért intervallumba. A lemma csak a szükségességet rögzíti: szinkronizálás nélkül a mérés nem állíthatja a teljes munka idejét.

24.2.4 Kézi számpélda a lemmához

Tekintsünk három kernelt:

ii ℓi\ell_i (ms) eie_i (ms) fif_i (ms)
1 0 4 max(f0,0)+4=4\max(f_0,0)+4=4
2 1 3 max(4,1)+3=7\max(4,1)+3=7
3 2 5 max(7,2)+5=12\max(7,2)+5=12

A teljes eszközmunka befejezése f3=12f_3=12 ms. Ha a CPU a [0,3][0,3] ms intervallumot méri szinkronizálás nélkül, a mért érték 33 ms, miközben a harmadik kernel még 99 ms-ig fut. A szinkronizált mérés a harmadik kernel végét megvárva legalább 1212 ms-t mutat, plusz a szinkronizációs hívás overheadje. Ez a példa a gyakori tévedés ellenpéldája is: a CPU-oldali t1-t0 nem a kernel teljes munkája.

24.2.5 A mérési protokoll elemei

A fenti öt mérésben (2,1; 2,0; 2,4; 2,0; 9,3) a minimum kiválasztása félrevezető, mert a CPU-ütemező, a háttérfolyamat vagy a first-touch memóriaallokáció miatt a minimális érték nem a munkához tartozó stabil idő. A 9,3 ms sem dobható el indoklás nélkül: lehet a mérés valós eloszlásának felső széle. A robusztus közép a medián a teljes nyers listából, mellette a min–max és a kvartilisek. A „warmup” célja a lazy inicializáció eltávolítása: az első hívás gyakran tartalmazza a memóriaallokációt, a kernelmodul betöltését vagy a könyvtári algoritmusválasztást, amely a későbbi lépésekből hiányzik. Warmup nélkül az első mért lépés a többitől független, egyszeri költséget tartalmaz.

24.2.6 Labor: CPU-mérés a GlyphLab-pipeline-en

A következő kód a tanuló gépén futtatható a lab/ könyvtárban. A kézirat írásakor Python-kód nem futott: az itt megadott kimenet hiányában a hallgató saját measure_report.json-t állít elő, és az az empirikus bizonyíték.

# munkakönyvtár: lab/
# Futtatás: python measure_pipeline.py
import csv
import json
import statistics
import time
import numpy as np
import torch

SEED = 20261020
WARMUP = 3
REPEAT = 10
BATCH_SIZE = 32

def load_glyphlab_train():
    images = np.load("data/images.npy")      # (360,1,24,24) float32
    labels = np.load("data/labels.npy")      # (360,) int64
    idx = []
    with open("data/metadata.csv", newline="", encoding="utf-8") as f:
        for row in csv.DictReader(f):
            if row["split"] == "train":
                idx.append(int(row["index"]))
    X = torch.tensor(images[idx], dtype=torch.float32)
    y = torch.tensor(labels[idx], dtype=torch.long)
    return X, y

class SmallCNN(torch.nn.Module):
    def __init__(self):
        super().__init__()
        self.net = torch.nn.Sequential(
            torch.nn.Conv2d(1, 8, 3, padding=1),
            torch.nn.ReLU(),
            torch.nn.MaxPool2d(2),
            torch.nn.Conv2d(8, 16, 3, padding=1),
            torch.nn.ReLU(),
            torch.nn.MaxPool2d(2),
            torch.nn.Flatten(),
            torch.nn.Linear(16 * 6 * 6, 3),
        )

    def forward(self, x):
        return self.net(x)

def step(x, y, model, loss_fn, opt):
    opt.zero_grad(set_to_none=True)
    logits = model(x)
    loss = loss_fn(logits, y)
    loss.backward()
    opt.step()
    return loss.detach().item()

def main():
    torch.manual_seed(SEED)
    device = torch.device("cpu")
    X, y = load_glyphlab_train()
    ds = torch.utils.data.TensorDataset(X, y)
    loader = torch.utils.data.DataLoader(
        ds, batch_size=BATCH_SIZE, shuffle=True, num_workers=0
    )
    model = SmallCNN().to(device)
    loss_fn = torch.nn.CrossEntropyLoss()
    opt = torch.optim.SGD(model.parameters(), lr=0.01)

    for _ in range(WARMUP):
        xb, yb = next(iter(loader))
        step(xb.to(device), yb.to(device), model, loss_fn, opt)

    data_ms, step_ms, total_ms = [], [], []
    for _ in range(REPEAT):
        it = iter(loader)
        t0 = time.perf_counter()
        xb, yb = next(it)
        t1 = time.perf_counter()
        xb, yb = xb.to(device), yb.to(device)
        _ = step(xb, yb, model, loss_fn, opt)
        t2 = time.perf_counter()
        t3 = time.perf_counter()
        data_ms.append(1e3 * (t1 - t0))
        step_ms.append(1e3 * (t2 - t1))
        total_ms.append(1e3 * (t3 - t0))

    report = {
        "device": str(device),
        "batch_size": BATCH_SIZE,
        "warmup": WARMUP,
        "repeat": REPEAT,
        "data_ms": data_ms,
        "step_ms": step_ms,
        "total_ms": total_ms,
        "median": {
            "data": statistics.median(data_ms),
            "step": statistics.median(step_ms),
            "total": statistics.median(total_ms),
        },
    }
    with open("measure_report.json", "w", encoding="utf-8") as f:
        json.dump(report, f, indent=2)
    print(json.dumps(report["median"], indent=2))

if __name__ == "__main__":
    main()

A labor értelmezése. A data_ms a batch előállításának költsége, a step_ms a transzfer, forward, backward és optimizer-lépés költsége; a total_ms a teljes mért ciklus. Az összefüggés total≈data+step+mérési overhead\text{total} \approx \text{data} + \text{step} + \text{mérési overhead}. A mérést a hallgató ellenőrzi: minden lista hossza REPEAT, minden elem nemnegatív; a medián a nyers listából számol. Ha a data_ms mediánja a step_ms-hez képest nagy, a betöltés a szűk keresztmetszet — de ez a következtetés csak erre a könyvtárra, splitre és gépre érvényes.

A num_workers=0 azt jelenti, hogy a betöltés a fő folyamatban történik; a munkások indításának overheadje ebben a változatban nem jelenik meg. A szkript a tanuló tényleges futtatását igényli; a kéziratban nem adunk meg mért számokat, mert nem létezik ilyen futás.

24.2.7 Jelölt GPU-mérési terv

GPU nélkül is megadható a protokoll, de nem szabad mért eredményt állítani. A terv:

  1. Kérdés: a GlyphLab SmallCNN egy 32-es batch lépése mennyi idő GPU-n, mennyi a HtoD-transzfer, és van-e átfedés a másolás és a számítás között?
  2. Környezet: GPU-modell, driver, CUDA-verzió, PyTorch-verzió, seed, float32; a [D02] értelmében a seed nem garantál platformok közötti bitazonosságot.
  3. Warmup: legalább 10 lépés, hogy a CUDA-kontextus, az első allokációk és az algoritmuskeresés ne a mért intervallumba essen.
  4. Mérés: minden mért intervallum előtt és után torch.cuda.synchronize(); a záró hívás a t1 rögzítése előtt történjen, különben a Lemma 1 hibáját követjük el. A másolást külön x.pin_memory()-vel és non_blocking=True móddal is mérjük, de a lemért intervallum végét akkor is szinkronizáljuk.
  5. Metrika: 30 ismétlés mediánja és IQR; a minimumot nem mint „tiszta időt” jelentjük.
  6. Memória: a mérés előtt torch.cuda.reset_peak_memory_stats(), a végén a max_memory_allocated rögzítése; a csúcs a gradiens és aktiváció együttes foglalása.
  7. Ellenőrző eszköz: a torch.utils.benchmark és a torch.profiler megfelelő szakaszának elolvasása után használható kettős mérésre; ezek a források jelen kéziratban kiinduló URL-ek, nem ellenőrzött API-szemantika.

A transzfer kiemelése: a tensor.to("cuda") nem feltétlenül fejeződik be azonnal; szinkron nélkül a CPU-oldali mérés a másolásnak is csak az indítását látja. A 20.2. Finomhangolás és tanulási ráta paraméterfrissítési lépése ugyanezen okból nem mérhető GPU-n a CPU perf_counter puszta különbségével.

24.2.8 Memória

A mért idő mellé a memóriát is naplózni kell, mert a gyorsulás sokszor allokációval vásárolt. GPU-n az allocated a ténylegesen lefoglalt, aktív tensor-memória, a reserved a caching allocator által megtartott keret; a csúcs a kettő maximuma a mérés során. CPU-n a torch.profiler memory-szekciója vagy a rendszerfigyelő ad hasonló bontást, de a Python-allocátor és a NumPy-allocátor külön réteg. A mérési protokoll része, hogy a memória-csúcs ugyanarra a warmup nélküli, első futásra vonatkozik-e, vagy a warmup után mérjük; a kettő nem azonos állítás.

24.2.9 Gyakori tévedés és ellenpélda

A kernelindítás CPU-oldali ideje nem azonos a GPU teljes munkájával. A

t0 = time.perf_counter()
y = model(x.cuda())
t1 = time.perf_counter()

kód a CPU-oldali indítások idejét méri: a t1-t0 értéke lehet 0,3 ms, miközben a kernel 2,1 ms-ig fut a GPU-n. A Lemma 1 numerikus példája mutatja, hogy a záró szinkronizáció hiánya alulbecsléshez vezet; a helyes sorrend torch.cuda.synchronize() a kezdő és a záró időpont előtt is. A ReLU nullponti konvenciójához hasonlóan ez sem „rossz implementáció”, hanem az aszinkron végrehajtási modell következménye: a CPU oldali visszatérés nem a befejezés eseménye.

24.2.10 Feladatok

F1 (fogalmi, nehézség 1). Miért nem használható a time.time() a futásidő mérésére, és mit ad a perf_counter helyette? Adj konkrét ellenpéldát, amelyben a time.time() különbsége negatív.

F2 (levezetési, nehézség 2). A Lemma 1 modelljében legyen négy kernel: ℓ=(0,2,3,10)\ell=(0,2,3,10) ms, e=(5,4,8,2)e=(5,4,8,2) ms. Számítsa ki az fif_i értékeket, a teljes eszközidőt, és mutassa meg, hogy a CPU-oldali [0,9][0, 9] ms szinkronizálatlan mérés miért nem érvényes. Hány ms a kihagyott munkarész?

F3 (implementációs, nehézség 2). Írjon measure_fn(fn, warmup, repeat, sync) függvényt, amely tetszőleges fn hívásának futásidejét méri perf_counter-rel, warmup melegítő hívást végez, repeat ismétlést gyűjt, és sync igaz esetén minden mért intervallum előtt és után hívja a megadott szinkronizáló függvényt. A függvény adja vissza a nyers másodperc-listát. Ellenőrizze a labor környezetben repeat=5 mellett egy lambda: torch.ones(100, 100).sum().item() híváson.

F4 (hibakeresési, nehézség 2). Az alábbi kód egy GPU-mérési segédfüggvény:

def bad_measure(model, x):
    torch.cuda.synchronize()
    t0 = time.perf_counter()
    for _ in range(10):
        y = model(x)
    t1 = time.perf_counter()
    torch.cuda.synchronize()
    return (t1 - t0) / 10

Lokalizálja a hibát, magyarázza el a következményt, és adja meg a javított változatot. Milyen kimenettel ellenőrizné, hogy a javítás helyes?

F5 (kísérlettervezési/transzfer, nehézség 3). Tervezzen GPU-mérési protokollt a GlyphLab SmallCNN egy teljes lépésére, amely a HtoD-transzfert, a forward–backward–step számítást és a teljes iterációt külön méri. Adja meg a kérdést, hipotézist, warmup- és ismétlésszámot, a szinkronizációs pontokat, a jelentendő metrikákat és a protokoll érvényességi korlátját. GPU hiányában a tervet dokumentáltan, mért adat nélkül adja be.

24.2.11 Megoldások és indoklás

F1 megoldása. A time.time() a rendszer falióráját olvassa, amelyet NTP-korrekció vagy kézi állítás visszafelé mozgathat; ha a mérés kezdete után az óra hátraugrik, a különbség negatív, ami sérti a futásidő nemnegativitását. A perf_counter monoton óra, ezért a két időbélyeg különbsége mindig a mért intervallum hossza. Ellenpélda: ha C(a)=1000,000C(a)=1000{,}000 s, majd az óraállítás 30 s-ot visszavisz, C(b)=999,990C(b)=999{,}990 s, akkor C(b)−C(a)=−10C(b)-C(a)=-10 s.

F2 megoldása. f0≤ℓ1f_0 \le \ell_1. A rekurzióval:

f1=max(f0,0)+5=5,f2=max(5,2)+4=9,f3=max(9,3)+8=17,f4=max(17,10)+2=19.f_1=\max(f_0,0)+5=5,\quad f_2=\max(5,2)+4=9,\quad f_3=\max(9,3)+8=17,\quad f_4=\max(17,10)+2=19.

A teljes eszközidő f4=19f_4=19 ms. A [0,9][0,9] ms intervallum f4−b=10f_4-b=10 ms munkát hagy ki; a mért 9 ms sem a befejezés, sem a teljes munka ideje. A helyes szinkronizált mérés minimálisan 19 ms-t vár, plusz a szinkron overhead.

F3 megoldása.

from typing import Callable, List
import time

def measure_fn(fn: Callable[[], None], warmup: int, repeat: int,
               sync: Callable[[], None] | None = None) -> List[float]:
    for _ in range(warmup):
        fn()
    out: List[float] = []
    for _ in range(repeat):
        if sync:
            sync()
        t0 = time.perf_counter()
        fn()
        if sync:
            sync()
        t1 = time.perf_counter()
        out.append(t1 - t0)
    return out

Kontroll a laborban:

times = measure_fn(lambda: torch.ones(100, 100).sum().item(), 2, 5)
assert len(times) == 5
assert all(t >= 0 for t in times)

A nemnegativitás a monoton órából következik; a szinkron hívás maga is a mért intervallumon kívül, de a t0 előtt és a t1 után van, tehát a mért érték a fn-re vonatkozik.

F4 megoldása. A hiba: a záró torch.cuda.synchronize() a t1 rögzítése után található. A t1 ezért a CPU-oldali indítások végét rögzíti, nem a GPU-befejezést; a Lemma 1 miatt a mért átlag alulbecsli a teljes munkát. Következmény: a jelentett „batch-idő” csak az indítások CPU-ideje, ami GPU-n tipikusan nagyságrenddel kisebb a kernelidejénél. Javítás: a szinkront a t1 előtt végezzük, és a záró időpontot utána olvassuk:

def good_measure(model, x):
    torch.cuda.synchronize()
    t0 = time.perf_counter()
    for _ in range(10):
        y = model(x)
    torch.cuda.synchronize()
    t1 = time.perf_counter()
    return (t1 - t0) / 10

Ellenőrzés: hasonlítsuk a torch.profiler vagy a GPU-oldali kernelidő-összeghez; a jó mérés nagyobb vagy egyenlő, mint a hibás, és a különbség a kernelidő nagyságrendjébe esik. A hibás kód kontrolljaként ugyanazt a modellt CPU-n is lemérhetjük, ahol a szinkronizálás hatása elhanyagolható, és a két idő közel azonos.

F5 megoldásterve. Kérdés: a GlyphLab pipeline egy batch lépésében a HtoD-transzfer vagy a kernel a szűk keresztmetszet? Hipotézis: a 24×24-es, egycsatornás képeknél a számítás dominál, de a másolás szétválaszthatatlanul a lépés elején elhelyezkedő költség. Protokoll: rögzített seed, 32-es batch, pin_memory=True DataLoader; warmup 10, repeat 30; minden intervallum előtt és után torch.cuda.synchronize(); a transfer idő mérése egy dedikált x.to(device, non_blocking=True) hívással, a számítási idő a forward–backward–step mérésével; metrika a medián és IQR ms-ban, valamint a csúcs max_memory_allocated. A terv nem állít benchmarkot: a 24×24-es adat és a CPU-generálás miatt az eredmény nem általánosítható természetes képekre; a num_workers és az adatbetöltő overheadje is része a teljes lépésnek, ezért a pipeline-mérést a batch-mikroméréstől elkülönítve kell jelenteni.

24.2.12 Önellenőrzés és korábbi fogalom visszahívása

Önellenőrzés: minden valós mért idő nemnegatív-e; a mért szakaszok összege és a teljes intervallum közötti különbség azonosítható-e; van-e warmup és rögzített seed a [D02] értelmében. A 12.2. Checkpoint és folytathatóság folytathatósági protokolljához hasonlóan a mérési eljárást is rögzíteni kell, különben a checkpointból folytatott modell lépésideje nem hasonlítható az előző futtatáshoz. A 20.2. Finomhangolás és tanulási ráta finomhangolási lépés mérésekor a befagyasztott paraméterek hiánya nem jelenti azt, hogy a lépés gyorsabb: a BatchNorm running statisztikái továbbra is frissülnek, és a DataLoader, valamint az optimizer overheadje a lépésidő része.

24.2.13 Rövid összefoglaló

A futásidő-mérés nem egyetlen függvényhívás különbsége. A mért intervallumot monoton órával definiáljuk, és érvényességéhez a vizsgált munka teljes eseményét az intervallumba kell foglalni. GPU-n a kernelindítás aszinkron: szinkronizálás nélkül a CPU-oldali idő csak az indítások költségét látja, ezért a záró szinkron a mérési protokoll kötelező része. A warmup a lazy inicializációt, a medián a terhelési kiugrásokat kezeli. A labor CPU-n méri a GlyphLab pipeline-t adatbetöltésre és lépésidőre bontva; a GPU-terv jelölt, nem elvégzett mérés. A memória-csúcs és a nyers adat ugyancsak a protokoll része.

24.2.14 Következő alkalmazás

A 24.3. Mixed precision és nagyobb batch egységben a mixed precision és a nagyobb batch használata következik: az itteni helyes időmérés nélkül nem lehet eldönteni, hogy az AMP-val mért gyorsulás valós, vagy csupán a szinkronizálatlan CPU-oldali indítások különbsége.

24.2.15 Felhasznált források