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?
Definíció 1 (mért időintervallum). Legyen egy monoton óra; a fizikai pillanathoz tartozó időbélyeg. Ha a mérés kezdetét , végét jelöli, a mért futásidő
Az intervallum akkor érvényes az
munkára, ha
minden releváns eseménye
-be
esik. A Python time.perf_counter() monoton óra; a
time.time() nem az, mert a rendszeróra visszafelé
állítható, így
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.
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 kernel egyetlen, FIFO streamen. Tegyük fel, hogy az -edik kernel indítási időpontja a CPU-órán , tiszta eszközoldali futásideje , és a befejezési időpont a stream-csúcsban
Ha a CPU-oldali mérési intervallum záró időpontjára , akkor az kernelsorozat teljes eszközmunkájából legalább idő a mérés lezárása után következik be; így nem érvényes mértéke futásidejének.
Bizonyítás. Az -edik kernel befejezési időpontja . A feltétel azt mondja, hogy -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 rész után esedékes. A mért intervallum tehát nem tartalmazza teljes eseményét, ami sérti az érvényesség definícióját.
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.
Tekintsünk három kernelt:
| (ms) | (ms) | (ms) | |
|---|---|---|---|
| 1 | 0 | 4 | |
| 2 | 1 | 3 | |
| 3 | 2 | 5 |
A teljes eszközmunka befejezése
ms. Ha a CPU a
ms intervallumot méri szinkronizálás nélkül, a mért érték
ms, miközben a harmadik kernel még
ms-ig fut. A szinkronizált mérés a harmadik kernel végét megvárva
legalább
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.
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.
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
.
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.
GPU nélkül is megadható a protokoll, de nem szabad mért eredményt állítani. A terv:
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?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.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.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.
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.
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.
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: ms, ms. Számítsa ki az értékeket, a teljes eszközidőt, és mutassa meg, hogy a CPU-oldali 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) / 10Lokalizá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.
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
s, majd az óraállítás 30 s-ot visszavisz,
s, akkor
s.
F2 megoldása. . A rekurzióval:
A teljes eszközidő ms. A ms intervallum 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 outKontroll 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) / 10Ellenő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.
Ö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.
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.
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.