Chapter sebelumnya menunjukkan bahwa aktivasi tumbuh mengikuti ukuran batch — namun kadang kita justru butuh batch besar: gradien yang lebih stabil, training yang lebih cepat per epoch, atau kompatibilitas dengan resep learning rate yang sudah teruji. Gradient accumulation memecahkan konflik ini dengan memisahkan dua hal yang biasanya menyatu: ukuran batch yang dihitung gradien, dan ukuran batch yang muat di memori. Model memproses micro-batch kecil beberapa kali, gradiennya ditumpuk di buffer p.grad, lalu optimizer melangkah sekali seolah-olah seluruh batch besar diproses sekaligus. Chapter ini membuktikan kesetaraan tersebut dengan angka nyata di CPU, membedah posisi zero_grad() yang benar, lalu membedah kecelakaan klasik saat pemanggilan tersebut dilupakan.

Batch Efektif dari Micro-batch Kecil #

Mekanismenya bersandar pada satu sifat autograd: loss.backward() menambahkan gradien ke buffer p.grad, bukan menimpanya. Jika micro-batch ke-i memiliki loss rata-rata L_i dan kita memanggil backward() pada L_i / N (dengan N jumlah micro-batch), gradien hasil penjumlahan menjadi:

text
grad_total = sum_i (1/N) * dL_i/dw = d(1/N * sum_i L_i)/dw

Ekspresi di kanan adalah persis gradien dari loss rata-rata seluruh batch besar — sehingga optimizer.step() sesudah akumulasi menghasilkan pembaruan yang identik. Syaratnya hanya dua: seluruh micro-batch harus berbobot sama, dan buffer gradien harus bersih saat siklus dimulai. Berkas gradient_accumulation.py menguji ketiga klaim itu secara nyata:

python
"""Gradient accumulation: batch efektif 32 dari 4 micro-batch x 8 sampel."""

import torch

torch.manual_seed(123)

# ---------- Data sintetis deterministik ----------
N = 512
X = torch.randn(N, 8)
w_benar = torch.randn(8, 1)
Y = X @ w_benar + 0.1 * torch.randn(N, 1)


def model_baru():
    torch.manual_seed(777)  # inisialisasi identik untuk semua jalur
    return torch.nn.Sequential(
        torch.nn.Linear(8, 64),
        torch.nn.Tanh(),
        torch.nn.Linear(64, 64),
        torch.nn.Tanh(),
        torch.nn.Linear(64, 1),
    )


def mse_penuh(model):
    with torch.no_grad():
        return float(((model(X) - Y) ** 2).mean())


# ---------- 1. Kesetaraan gradien pada satu langkah ----------
print("=== 1. Kesetaraan gradien: batch 32 vs akumulasi 4x8 ===")
batch32 = X[:32], Y[:32]

mA, mB = model_baru(), model_baru()
for p_a, p_b in zip(mA.parameters(), mB.parameters()):
    p_a.data.copy_(p_b.data)

lossA = ((mA(batch32[0]) - batch32[1]) ** 2).mean()
lossA.backward()

for i in range(4):
    xk, yk = X[8 * i : 8 * (i + 1)], Y[8 * i : 8 * (i + 1)]
    lossB = ((mB(xk) - yk) ** 2).mean() / 4  # bobot 1/N_micro
    lossB.backward()

selisih = max(
    float((p_a.grad - p_b.grad).abs().max())
    for p_a, p_b in zip(mA.parameters(), mB.parameters())
)
print(f"gradien jalur batch-32   : {float(mA[0].weight.grad.norm()):.6f}")
print(f"gradien jalur 4x8        : {float(mB[0].weight.grad.norm()):.6f}")
print(f"max |selisih gradien|    : {selisih:.2e}")


# ---------- 2. Pelatihan 6 epoch: kurva loss keduanya ----------
def latih_batch32(model):
    opt = torch.optim.SGD(model.parameters(), lr=0.05)
    riwayat = []
    for epoch in range(6):
        g = torch.Generator().manual_seed(2024 + epoch)
        urutan = torch.randperm(N, generator=g)
        for awal in range(0, N, 32):
            idx = urutan[awal : awal + 32]
            loss = ((model(X[idx]) - Y[idx]) ** 2).mean()
            opt.zero_grad()
            loss.backward()
            opt.step()
        riwayat.append(mse_penuh(model))
    return riwayat


def latih_akumulasi(model, n_micro=4):
    opt = torch.optim.SGD(model.parameters(), lr=0.05)
    riwayat = []
    for epoch in range(6):
        g = torch.Generator().manual_seed(2024 + epoch)
        urutan = torch.randperm(N, generator=g)
        opt.zero_grad()  # mulai siklus dengan gradien nol
        for awal in range(0, N, 8):
            idx = urutan[awal : awal + 8]
            loss = ((model(X[idx]) - Y[idx]) ** 2).mean() / n_micro
            loss.backward()
            if (awal // 8 + 1) % n_micro == 0:  # 4 micro-batch terkumpul
                opt.step()
                opt.zero_grad()
        riwayat.append(mse_penuh(model))
    return riwayat


print()
print("=== 2. Pelatihan 6 epoch (batch efektif 32) ===")
hA = latih_batch32(model_baru())
hB = latih_akumulasi(model_baru())
print(f"{'epoch':>5s} {'batch 32':>12s} {'akumulasi 4x8':>14s}")
for e, (a, b) in enumerate(zip(hA, hB), start=1):
    print(f"{e:>5d} {a:>12.6f} {b:>14.6f}")
print(f"loss akhir selisih       : {abs(hA[-1] - hB[-1]):.2e}")

# ---------- 3. Jebakan: lupa zero_grad di akhir siklus ----------
print()
print("=== 3. Jebakan lupa zero_grad ===")
mC = model_baru()
opt = torch.optim.SGD(mC.parameters(), lr=0.05)
norma = []
for siklus in range(2):
    for i in range(4):  # micro-batch sama tiap siklus
        loss = ((mC(X[8 * i : 8 * (i + 1)]) - Y[8 * i : 8 * (i + 1)]) ** 2).mean() / 4
        loss.backward()
    norma.append(float(mC[0].weight.grad.norm()))
    opt.step()
    # sengaja TIDAK memanggil zero_grad: gradien menumpuk
print(f"norma gradien siklus 1   : {norma[0]:.6f}")
print(f"norma gradien siklus 2   : {norma[1]:.6f} (harusnya tetap {norma[0]:.6f})")
print(f"rasio penumpukan         : {norma[1] / norma[0]:.2f}x")

Output yang Dihasilkan:

text
=== 1. Kesetaraan gradien: batch 32 vs akumulasi 4x8 ===
gradien jalur batch-32   : 1.417473
gradien jalur 4x8        : 1.417473
max |selisih gradien|    : 2.38e-07

=== 2. Pelatihan 6 epoch (batch efektif 32) ===
epoch     batch 32  akumulasi 4x8
    1     0.102887       0.102887
    2     0.096491       0.096491
    3     0.087625       0.087625
    4     0.082930       0.082930
    5     0.086107       0.086107
    6     0.077625       0.077625
loss akhir selisih       : 7.45e-09

=== 3. Jebakan lupa zero_grad ===
norma gradien siklus 1   : 1.417473
norma gradien siklus 2   : 2.646772 (harusnya tetap 1.417473)
rasio penumpukan         : 1.87x

Mari kita telaah kode di atas baris per baris:

  1. Baris 14-17 (model_baru): setiap pemanggilan mengatur torch.manual_seed(777) lebih dulu sehingga seluruh jalur memperoleh inisialisasi bobot yang identik — syarat agar perbandingan gradien dan loss adil.
  2. Baris 30-45 (kesetaraan satu langkah): model mA menghitung gradien dari loss rata-rata 32 sampel sekali jalan; model mB menjalankan empat micro-batch 8 sampel dengan loss dibagi 4 (/ 4) sebelum backward(). Norma gradien kedua jalur sama hingga enam desimal dan selisih maksimum hanya 2,38e-07 — sisa pembulatan float32 karena urutan penjumlahan yang berbeda.
  3. Baris 56-92 (dua loop pelatihan): kedua fungsi memakai generator torch.Generator ber-seed tetap per epoch sehingga urutan data identik. Jalur akumulasi memanggil opt.zero_grad() di awal siklus dan setiap kali empat micro-batch terkumpul (% n_micro == 0), lalu opt.step() sekali — jumlah pembaruan per epoch hanya seperempat jalur batch penuh.
  4. Baris 94-97 (kurva loss): enam epoch menghasilkan MSE yang sama hingga enam desimal di setiap barisnya; selisih akhir 7,45e-09. Batch efektif 32 benar-benar tercapai dari pecahan 4×8.
  5. Baris 99-114 (kecelakaan zero_grad): siklus kedua sengaja tidak mengosongkan gradien. Norma gradien naik dari 1,417473 ke 2,646772 — rasio 1,87x, bukan 2x penuh, karena bobot model sudah bergeser setelah opt.step() pertama sehingga gradien siklus kedua tidak lagi identik.

Posisi zero_grad yang Benar #

Aturannya ringkas: panggil zero_grad() tepat sebelum siklus akumulasi dimulai, dan biarkan backward() menumpuk sampai N micro-batch habis. Dua pola yang benar adalah (a) zero_grad() di awal setiap siklus seperti kode di atas, atau (b) zero_grad() segera setelah step() — keduanya setara. Yang keliru adalah menaruh zero_grad() di dalam loop micro-batch: gradien terhapus setiap langkah dan batch efektif kembali menjadi 8, membatalkan seluruh tujuan teknik ini.

Kapan Akumulasi, Kapan Batch Asli #

AspekBatch besar asli (32)Akumulasi 4 × 8
Memori aktivasi puncaksebanding batch 32sebanding batch 8 (4x lebih hemat)
Jumlah optimizer.step() per epoch164
Aritmetika pembaruanidentikidentik (selisih ~1e-07)
Kecepatan per epochpaling cepat bila muatsedikit lebih lambat (4 forward-backward beruntun)
Efek samping BatchNormstatistik per 32 sampelstatistik per 8 sampel (perlu evaluasi)

Satu catatan penting: layer seperti BatchNorm menghitung statistik per forward, sehingga akumulasi mengubah perilakunya dibanding batch asli — berbeda dengan LayerNorm yang tidak bergantung batch. Detail interaksi batch dan konvergensi dibahas di chapter Batch Size dan Epoch .

Akumulasi memangkas aktivasi dengan memperkecil batch per forward. Teknik berikutnya menyerang sisi lain: aktivasi yang tersimpan di dalam graph — dengan cara membuangnya lebih dulu lalu menghitung ulang saat dibutuhkan. Mari kita lanjut ke Activation Checkpointing.

Rujukan resmi untuk perilaku penjumlahan gradien backward() tersedia di docs.pytorch.org/docs/stable/notes/autograd.html .