28.3 Generalizáció és gyorsító választás

28.3.1 Cél és mérési helyzet

A Tanítási ciklus és adatbetöltés tanítási ciklusában a kérdés az volt, hogyan számítunk ki egy gradienslépést. Most a teljes adat- és számítási út költségét mérjük, mielőtt gyorsítóra váltanánk. A döntési helyzet: a Northstar prediktív feladatnál 2400 rendelés és 8 rendeléskori feature áll rendelkezésre, a cél a 7 napot meghaladó kézbesítés valószínűségének becslése. A 2400×82400 \times 8-as fp32 mátrix nagyjából 2400⋅8⋅4=768002400 \cdot 8 \cdot 4 = 76\,800 byte, tehát nem a méret vagy a memória a szűk keresztmetszet, hanem a betöltés, a Python-keret overheadje és a fejlesztési idő.

Az előzetes becslésem ezért az, hogy ehhez a Northstar-mérethez a CPU is elegendő, és a GPU csak akkor éri meg, ha a tényleges mérés szerint a számítás vagy az adatmozgatás a domináns költség. Ahhoz, hogy a batch-, memória- és transferhatásokat értelmezni tudjuk, egy nagyobb, de helyben legyártható szintetikus mátrixon mérünk; a mért számok a saját gépedre vonatkoznak, nem a könyv előregyártott eredményére.

28.3.2 A költség összetevői: batch, transfer, VRAM

A batching (kötegelés) a DataLoader egyik alapbeállítása: egyszerre BB mintát ad át a modellnek. A minibatch gradiens

gB=1B∑i=1B∇θL(xi,yi;θ), g_B = \frac{1}{B}\sum_{i=1}^{B} \nabla_\theta L(x_i, y_i; \theta),

ahol θ\theta a modell paramétere, LL a veszteség, (xi,yi)(x_i, y_i) pedig a BB méretű batch ii-edik mintája. Ha a gradiensek független, azonos eloszlású mintánkénti becslések, akkor a variancia az 1/B1/B szabályt követi:

Var(gB)=Var(g1)B. \mathrm{Var}(g_B)=\frac{\mathrm{Var}(g_1)}{B}.

Nagyobb batch tehát kisebb gradienstorzítást, de több memóriát és kevesebb paraméterfrissítést jelent epochonként; az optimális BB mérési kérdés.

A transfer a CPU- és a GPU-memória közötti másolás. A PyTorch tensor.to(device) hívása a háttérben PCIe/átviteli sávszélességet használ. CPU-n nincs külön devicetransfer, de a CSV/Parquet betöltés, a pandas átalakítás és a tensorré alakítás ugyanúgy a teljes út része. GPU-mérésnél a transfert külön kell mérni: torch.cuda.synchronize() után time.perf_counter(), mert a GPU-műveletek aszinkronok lehetnek.

A VRAM a GPU saját memóriája. Három összetevője van: a paraméterek, a gradiensek és az optimizer állapota. Adamnél minden fp32 paraméterhez tartozik gradiens, első momentum és második momentum, tehát a paraméterbyte-ok négyszerese kell. Az aktivációk ezen felül batchfüggő memóriát foglalnak. A torch.cuda.memory_allocated() csak GPU-n hívható; CPU-s becsléshez kézzel számolunk, ahogy a következő szakasz mutatja.

A mixed precision (vegyes pontosság) áttekintő szinten annyit jelent, hogy a számítási részek fp16/bf16-ban futnak, a súlyok viszont fp32 master másolatban maradnak. Ezzel csökken a memória- és sávszélességigény, de az fp16 kis tartománya miatt a gradiens alulcsordulhat. A torch.amp.GradScaler ezért a veszteséget felskálázza a backward előtt, majd a súlyfrissítésnél visszaoszt. CPU-n ez általában nem hoz automatikus előnyt; a használat előtt méréssel kell igazolni az előnyt.

Az early stopping (korai leállás) a validációs veszteséget nézi: ha patience epochon át nincs min_delta-nál nagyobb javulás, a tanítást leállítjuk, és a legjobb mentett súlyt töltjük vissza. A validációs halmaz itt modellválasztási eszköz, a teszthalmazt csak a leállás után értékelhetjük; ellenkező esetben a teszt vezérli a modellt.

A seed limits (magkorlátok) azt rögzítik, hogy a pszeudovéletlen sorozat ugyanazon gépen, verzióval és csomagkészlettel ismételhető legyen. A seed nem garantál platformok közötti bitazonosságot: a GPU-k atomi redukciói, a könyvtárverziók és a párhuzamos futtatás eltérést okozhatnak. Ezért különböztetjük meg a numerikus, statisztikai és környezeti reprodukálhatóságot.

A TensorBoard áttekintésben a SummaryWriter naplózza a veszteség-, idő- és memóriagörbéket; a tensorboard --logdir runs parancs böngészőben jeleníti meg őket. A görbe munkanapló, nem helyettesíti a mért hardver-, méret- és ismétlésadatokat tartalmazó jegyzőkönyvet.

28.3.3 Műveletszám, memória és variancia

A bemutatott modell: 3232 bemenet, 4848 rejtett neuron, 11 kimenet. A paraméterszám

P=32⋅48+48+48⋅1+1=1633. P = 32 \cdot 48 + 48 + 48 \cdot 1 + 1 = 1633.

Egy lineáris réteg forward műveletszáma 2⋅B⋅din⋅dout2 \cdot B \cdot d_{\mathrm{in}} \cdot d_{\mathrm{out}} FLOP, mert egy szorzás-összeadás pár két lebegőpontos művelet. A két rétegre:

Cfwd=2B(32⋅48+48⋅1)=3168B C_{\mathrm{fwd}} = 2B(32 \cdot 48 + 48 \cdot 1) = 3168B

FLOP. A backward durván a forward kétszerese, így egy batch teljes költsége körülbelül 3⋅3168B3 \cdot 3168B. Például B=512B=512 esetén egy batch körülbelül 4,94{,}9 millió FLOP, a 8192/512=168192/512 = 16 batchből álló epoch pedig körülbelül 7878 millió FLOP. Ez a műveletmennyiség egy modern CPU-n is néhány tizedmásodperces nagyságrendű lehet; a mérés dönti el, hogy a Python-overhead vagy a számítás dominál-e.

A memóriabecslés ugyanilyen egyszerű. A 16331633 paraméter fp32-ben 1633⋅4≈6,51633 \cdot 4 \approx 6{,}5 KiB; Adamnél a paraméter, gradiens, momentum és variancia együtt körülbelül 2626 KiB. A B=512B=512 batch aktivációi: bemenet 512⋅32⋅4=65536512 \cdot 32 \cdot 4 = 65\,536 byte, rejtett kimenet 512⋅48⋅4=98304512 \cdot 48 \cdot 4 = 98\,304 byte, logit 512⋅1⋅4=2048512 \cdot 1 \cdot 4 = 2048 byte; a backward hasonló nagyságrendű, összesen fél megabyte alatt maradunk. Ezért ez a modell VRAM szempontból soha nem indokol GPU-t; a GPU-kérdést a műveletmennyiség, a transfer és a betöltés dönti el.

A gradiensvariancia-összefüggés visszautal a Power, mintanagyság és többszörös próbák becslési varianciájára: a batchméret növelése ugyanazt a zajcsökkentést adja, mint a mintanagyság növelése, de csak független minták esetén érvényes ilyen tisztán. A DataLoader shuffle után hozzávetőleg függetlennek tekinthető, de a modell frissítése közben az eloszlás már nem azonos; ezért a képlet az egy paraméterpont körüli lokális közelítés, nem globális ígéret.

28.3.4 Minimális munkapélda: CPU-költségmérés

A következő script a fenti modell teljes epochonkénti költségét méri CPU-n, különböző batchméretekkel. Az outputot a tanuló gépén kell létrehozni; az itt látható JSON-struktúra a várt séma, nem ellenőrzött mérési jegyzék. A kód a projektgyökérben futtatható.

import json
import time
from pathlib import Path

import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset

SEED = 20261020
torch.manual_seed(SEED)

def make_data(n=8192, d=32):
    X = torch.randn(n, d)
    w = torch.randn(d, 1)
    y = ((X @ w + 0.2 * torch.randn(n, 1)) > 0).float()
    return X, y

def build_model(d):
    return nn.Sequential(
        nn.Linear(d, 48),
        nn.ReLU(),
        nn.Linear(48, 1),
    )

X, y = make_data()
dataset = TensorDataset(X, y)
results = []

for batch_size in [32, 64, 128, 256, 512]:
    # minden batchmérethez azonos kezdősúly
    torch.manual_seed(SEED)
    model = build_model(X.shape[1])
    optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)
    loss_fn = nn.BCEWithLogitsLoss()
    loader = DataLoader(dataset, batch_size=batch_size, shuffle=True)

    # egy batch melegítés, hogy az első allokáció ne torzítson
    for xb, yb in loader:
        optimizer.zero_grad()
        loss_fn(model(xb), yb).backward()
        optimizer.step()
        break

    n = 0
    t0 = time.perf_counter()
    for xb, yb in loader:
        optimizer.zero_grad()
        out = model(xb)
        loss = loss_fn(out, yb)
        loss.backward()
        optimizer.step()
        n += xb.shape[0]
    dt = time.perf_counter() - t0

    results.append({
        "batch_size": batch_size,
        "samples": n,
        "epoch_s": round(dt, 4),
        "samples_per_s": round(n / dt, 2) if dt > 0 else None,
    })

path = Path("s111_cpu_results.json")
path.write_text(json.dumps(results, indent=2) + "\n")
print(json.dumps(results, indent=2))

Futtatás Linux/macOS terminálból a projektgyökérből:

python s111_cpu_measure.py

Windows PowerShellből ugyanez a python s111_cpu_measure.py parancs. A TensorBoard-napló opcionálisan a mérés köré építhető:

from torch.utils.tensorboard import SummaryWriter

writer = SummaryWriter("runs/s111_cpu_measure")
for row in results:
    writer.add_scalar("time/epoch_s", row["epoch_s"], row["batch_size"])
    writer.add_scalar("throughput/samples_per_s", row["samples_per_s"], row["batch_size"])
writer.close()

A tensorboard --logdir runs parancs a projektgyökérből indítva nyitja meg a görbéket; a pontos elérhető verzió a telepített csomagtól függ, ezért a saját környezetedben ellenőrizd.

28.3.5 Eredmény értelmezése és GPU-döntési lap

Az eredményeket a saját gépeden mérd, legalább három ismétléssel, és a mediánt jegyezd fel. Minden futásra két invariantet ellenőrizz: samples értéke mindig 81928192, és samples_per_s értéke samples / epoch_s kerekítve. A tipikus CPU-mintázat az, hogy a kisebb batch nagyobb Python- és kernelindítási overheaddel jár, ezért a nagyobb batch növeli az átviteli sebességet addig, amíg a cache vagy a háttérterhelés újabb korlátot nem szab. A pontos szám géptől függ; csak a saját futtatásodat nevezd mértnek.

Mutató Jelentés Ellenőrzés
batch_size minibatch méret input
samples feldolgozott sorok mindig 8192
epoch_s epoch idő tanulói mérés
samples_per_s átviteli sebesség samples / epoch_s

A GPU-döntési lap akkor használható, ha CUDA elérhető; enélkül a terv elméleti marad, és tilos gyorsulást közölni. A lap lépései:

  1. Rögzítsd a CPU-baseline-t: adatbetöltés, tensor-átalakítás, train, eval, ismétlésszám.
  2. Ellenőrizd a GPU-környezetet: torch.cuda.is_available(), torch.version.cuda, GPU neve.
  3. Mérd a transfert külön: x.to("cuda"); torch.cuda.synchronize(); dt.
  4. Futtasd ugyanazt a modellt GPU-n, azonos batch és ismétlés mellett.
  5. Hasonlítsd a teljes utat, nem csak a mátrixszorzást; a betöltés és a transfer is beleszámít.
  6. Adj memóriafelső korlátot: Adam fp32 esetén 4P⋅44P \cdot 4 byte paraméter, plusz a batchfüggő aktivációk.

A mixed precision használatakor a veszteségfelskálázás kötelező GPU-n:

use_cuda = torch.cuda.is_available()
device = torch.device("cuda" if use_cuda else "cpu")
model.to(device)
scaler = torch.amp.GradScaler("cuda", enabled=use_cuda) if use_cuda else None

optimizer.zero_grad()
if scaler is not None:
    with torch.amp.autocast("cuda", dtype=torch.float16, enabled=True):
        out = model(xb.to(device))
        loss = loss_fn(out, yb.to(device))
    scaler.scale(loss).backward()
    scaler.step(optimizer)
    scaler.update()
else:
    loss = loss_fn(model(xb), yb)
    loss.backward()
    optimizer.step()

A torch.amp API a PyTorch 2.x felület; a régi torch.cuda.amp elavult, ezért a saját verziód dokumentációját ellenőrizd. Az early stopping vázlata ugyanebben a környezetben:

best_loss = float("inf")
patience = 5
min_delta = 1e-4
no_improve = 0

for epoch in range(max_epochs):
    train_loss = train_one_epoch(model, train_loader, device)
    val_loss = evaluate(model, val_loader, device)
    writer.add_scalar("loss/train", train_loss, epoch)
    writer.add_scalar("loss/val", val_loss, epoch)

    if val_loss < best_loss - min_delta:
        best_loss = val_loss
        no_improve = 0
        torch.save(model.state_dict(), "best_model.pt")
    else:
        no_improve += 1
        if no_improve >= patience:
            break

A train_one_epoch és evaluate a Tanítási ciklus és adatbetöltés alfejezetből ismert train/eval módot és torch.no_grad()-ot használja; a max_epochs tetszőleges felső korlát, a patience a validációs javulás türelmi ideje. A végső teszthalmazt csak a best_model.pt betöltése után szabad egyszer kiértékelni.

CPU–GPU gyorsításnál a teljes mérési szakaszt és adatmozgatást is vegyük figyelembe; aszinkron GPU-futtatás időzítésénél szinkronizálás szükséges.

28.3.6 Feladatok

28.3.6.1 1. feladat (értelmezés)

Számítsd ki a bemutatott modellre: PP paraméterszámot, egy B=128B=128 batch lineáris forward FLOP-számát, a forward+backward közelítő FLOP-számát, továbbá a B=128B=128 és B=512B=512 minibatch gradiens varianciájának arányát független, azonos eloszlású gradiensek feltételével.

28.3.6.2 2. feladat (implementálás)

Módosítsd a s111_cpu_measure.py scriptet úgy, hogy minden batchmérethez három ismétlésben futtassa a mérést, a kimenetbe írja be a három epoch_s értéket és a mediánt, és mentse el s111_cpu_results.json néven. Futtasd a saját gépeden, és ellenőrizd, hogy minden samples mező 81928192.

28.3.6.3 3. feladat (hibadiagnózis)

Valaki a mixed precision kódot így írta, és a tanítási veszteség néhány lépés után nan lett:

with torch.amp.autocast("cuda", dtype=torch.float16):
    out = model(xb.to(device))
    loss = loss_fn(out, yb.to(device))
loss.backward()
optimizer.step()

Magyarázd meg, mi a hiba, és add meg a javítást. Adj egy második lehetséges okot is arra, ha CPU-mérésben a B=512B=512 epoch lassabb, mint a B=256B=256.

28.3.6.4 4. feladat (megváltozott feltétel)

Tegyük fel, hogy n=50000000n=50\,000\,000 sor, d=256d=256 feature és B=512B=512 batch adott. A modell legyen 256→128→1256 \to 128 \to 1. Készíts GPU-s becslési tervet: számítsd ki az input tenzor byte-méretét, a modell paraméterszámát, az Adam fp32 memóriafelső korlátját, a B=512B=512 batch aktivációinak közelítő méretét, és egy átviteli időbecslést 88 GB/s effektív sávszélességgel. Mit kell ténylegesen megmérned, mielőtt gyorsulást állítanál?

28.3.7 Megoldások és indoklás

28.3.7.1 1. megoldás

P=32⋅48+48+48⋅1+1=1633P=32\cdot48+48+48\cdot1+1=1633. A B=128B=128 forward lineáris költsége 2⋅128⋅(32⋅48+48)=2⋅128⋅1584=4055042\cdot128\cdot(32\cdot48+48)=2\cdot128\cdot1584=405\,504 FLOP. A forward+backward közelítőleg ennek háromszorosa: 12165121\,216\,512 FLOP. A varianciaarány:

Var(g512)Var(g128)=Var(g1)/512Var(g1)/128=128512=14. \frac{\mathrm{Var}(g_{512})}{\mathrm{Var}(g_{128})} = \frac{\mathrm{Var}(g_1)/512}{\mathrm{Var}(g_1)/128} = \frac{128}{512} = \frac14.

A szórásarány ennek négyzetgyöke, 1/21/2; a nagyobb batch tehát felére csökkenti a gradienszaj szórását ebben az idealizált közelítésben.

28.3.7.2 2. megoldás

A lényeg a mérési hurok megkettőzése: minden batchmérethez futtass egy belső ciklust [0, 1, 2] ismétléssel, gyűjtsd listába a dt értékeket, és a mediánt a statistics.median függvénnyel számold. Az újraindított torch.manual_seed(SEED) maradjon a külső ciklus elején, hogy azonos kezdősúlyokkal induljon minden batchméret. A kimenet egy lehetséges bővített rekordja:

{
  "batch_size": 128,
  "samples": 8192,
  "epoch_s_runs": [0.0411, 0.0398, 0.0403],
  "epoch_s_median": 0.0403,
  "samples_per_s": 203323.1
}

Itt az epoch_s_runs a három mért idő, a samples_per_s a mediánból számított 8192/0.04038192 / 0.0403. Az samples invariant minden sorban 81928192; ha nem, akkor a DataLoader vagy a batchméretezés hibás. A fenti konkrét szám csak szemléltető, nem mért; a saját gép és a háttérterhelés eltérő értéket adhat.

28.3.7.3 3. megoldás

A nan oka a GradScaler hiánya: fp16 autocast alatt a kis gradiensek alulcsordulhatnak, a backward így nem véges értéket ad. A javítás:

scaler.scale(loss).backward()
scaler.step(optimizer)
scaler.update()

Ha a scaler nem elérhető, maradjon fp32 CPU-n, vagy kapcsold ki az autocastot. A második lehetséges ok, hogy a B=512B=512 lassabb CPU-n: a nagyobb batch kinőhet a cache-ből, a gép háttérfolyamatot futtat, vagy egyetlen mérésből vontál le következtetést. A helyes válasz mérési terv: legalább öt ismétlés, melegítés, a medián használata, és a háttérterhelés rögzítése a jegyzőkönyvben.

28.3.7.4 4. megoldás

Az input tenzor mérete 50000000⋅256⋅4=51,250\,000\,000 \cdot 256 \cdot 4 = 51{,}2 GB, tehát nem fér be egyetlen batchbe; a betöltés így streaminget igényel. A modell paraméterszáma P=256⋅128+128+128⋅1+1=33025P = 256\cdot128+128+128\cdot1+1 = 33\,025. Az Adam fp32 felső korlát 4P⋅4=5284004P \cdot 4 = 528\,400 byte ≈516\approx 516 KiB. A B=512B=512 batch aktivációi: bemenet 512⋅256⋅4=524288512\cdot256\cdot4=524\,288 byte, rejtett kimenet 512⋅128⋅4=262144512\cdot128\cdot4=262\,144 byte, logit 20482048 byte, backward hasonló; a teljes aktiváció 22 MB alatt marad. A 88 GB/s effektív sávszélességgel számolt átvitel 51,2GB/8GB/s=6,451{,}2\ \mathrm{GB} / 8\ \mathrm{GB/s} = 6{,}4 s. Ez elméleti becslés; a valós sávszélesség hardverfüggő. Mielőtt gyorsulást állítanál, mérd meg a teljes utat legalább háromszor: betöltés, CPU→GPU transfer, train, eval; rögzítsd a GPU nevét, verzióját és a PCIe-környezetet. A 6,46{,}4 s-on felül a betöltés gyakran dominánsabb, ezért a GPU-váltás csak akkor indokolt, ha a teljes mért futásidő és a projektcél is igazolja.

28.3.8 Gyakori tévedés, visszautalás és önellenőrzés

A leggyakoribb tévedés, hogy a GPU-t automatikusan gyorsabbnak tekintjük. A Northstar 2400×82400 \times 8-as mátrixa 76,876{,}8 KB; a bemutatott rétegekkel a teljes epoch körülbelül 7878 millió FLOP alatt marad. Ez a műveletmennyiség a kernelindítás, a driver ütemezés és az adatmozgatás tartományába esik: a GPU-s overhead nagyobb lehet, mint a CPU-s teljes számítás, ezért a „modern GPU” látszólagos előnye itt veszteséggé válhat. A döntés sohasem eszköz, hanem a teljes adat- és számítási út mért költsége alapján születik.

A gradiensvariancia-összefüggés visszautal a Power, mintanagyság és többszörös próbák mintanagysági becslésére: a batch növelése statisztikai zajt csökkent, de nem tesz csodát a ritka célváltozóval. A LLM mint sztochasztikus komponens latency-keretére emlékeztet, hogy az LLM-értékelésben is a teljes késleltetés és költség döntött, nem a modell mérete önmagában. Az Anomália és ritka esemény felügyelet nélkül pedig arra figyelmeztet, hogy a ritka esemény nem gyógyítható gyorsabb mátrixszorzással; a rangsor és az emberi validálás akkor is kell, ha a GPU hirtelen elérhetővé válik.

Önellenőrzés: fel tudod-e sorolni a teljes út lépéseit és mértékegységeit; ki tudod-e számolni az Adam-memória felső korlátját; meg tudod-e mondani, mit garantál a seed és mit nem; el tudod-e különíteni a betöltés, transfer, train és eval idejét a saját mérésedben? Ha bármelyik nem egyértelmű, futtasd újra a feladatok 2. és 4. mérését, mielőtt gyorsító mellett döntenél.

28.3.9 Rövid összefoglaló

A gyorsítóválasztás előtt a teljes adat- és számítási út mért költsége dönt: betöltés, tensor-konverzió, batch, transfer, forward/backward, validáció és kiértékelés együtt. A batchméret a gradiensvarianciát 1/B1/B-vel csökkenti, de a memóriát növeli. A VRAM-ot paraméter, gradiens, Adam-állapot és aktivációk adják; kis modellnél ez nem korlát. A mixed precision csökkentheti a memóriát, de GradScaler kell hozzá. Az early stopping a validációs görbét modellválasztásra használja, a seed pedig csak azonos környezetben ad ismételhetőséget. A következő lépés a VII. ellenőrző pont, ahol a PyTorch-kísérletnek a mérési jegyzőkönyvvel együtt kell megállnia a szivárgásmentes értékelés próbáját.