Semua teknik sejauh ini berjalan pada satu perangkat. Bila kita memiliki beberapa perangkat, cara paling sederhana untuk memanfaatkannya bukan memecah model, melainkan memecah data: setiap perangkat membawa salinan model yang identik, mengolah pecahan batch yang berbeda, lalu gradien mereka digabung sehingga seluruh kelompok berperilaku seperti satu proses dengan batch besar. Inilah data parallelism — pola dasar yang menopang hampir seluruh pelatihan multi-GPU modern. Chapter ini membedah mekanisme gabung-gradien secara manual dengan dua replika di CPU, membuktikan kesetaraannya dengan batch penuh lewat angka nyata, lalu menempatkan API klasik torch.nn.DataParallel berdampingan dengan penerusnya DDP.

Satu Model, Banyak Pecahan Data #

Alurnya terdiri dari empat tahap yang berulang tiap langkah pelatihan. Pertama, batch efektif dipecah ke N perangkat. Kedua, setiap replika menjalankan forward dan backward pada pecahannya sendiri. Ketiga, gradien dari semua replika dijumlahkan lalu dibagi N (operasi kolektif all-reduce). Keempat, setiap replika memakai gradien rata-rata yang sama sehingga bobotnya tetap identik selamanya:

Kunci matematisnya satu kalimat: rata-rata gradien dari N pecahan adalah gradien loss rata-rata seluruh batch — kita sudah membuktikan bentuk serupanya di chapter Gradient Accumulation , bedanya di sini penjumlahan terjadi lintas perangkat, bukan lintas waktu. Berkas data_parallel.py menyimulasikan dua replika di satu proses CPU:

python
"""Data parallelism manual di CPU: dua replika, dua pecahan data, gradien digabung."""

import torch

torch.manual_seed(11)

N = 256
X = torch.randn(N, 6)
w_benar = torch.randn(6, 1)
Y = X @ w_benar + 0.1 * torch.randn(N, 1)


def model_baru():
    torch.manual_seed(99)
    return torch.nn.Sequential(
        torch.nn.Linear(6, 48),
        torch.nn.Tanh(),
        torch.nn.Linear(48, 48),
        torch.nn.Tanh(),
        torch.nn.Linear(48, 1),
    )


def samakan(sumber, tujuan_list):
    with torch.no_grad():
        for tujuan in tujuan_list:
            for p_s, p_t in zip(sumber.parameters(), tujuan.parameters()):
                p_t.copy_(p_s)


def mse(model, x, y):
    return ((model(x) - y) ** 2).mean()


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


# ---------- 1. Kesetaraan gradien: batch penuh vs 2 pecahan ----------
print("=== 1. Gradien: batch penuh 64 vs gabungan 2 pecahan x 32 ===")
m_ref, m_a, m_b = model_baru(), model_baru(), model_baru()
samakan(m_ref, [m_a, m_b])

mse(m_ref, X[:64], Y[:64]).backward()
mse(m_a, X[:32], Y[:32]).backward()
mse(m_b, X[32:64], Y[32:64]).backward()

with torch.no_grad():  # "all-reduce": rata-rata gradien kedua replika
    for p_a, p_b in zip(m_a.parameters(), m_b.parameters()):
        p_a.grad.copy_((p_a.grad + p_b.grad) / 2)

selisih = max(
    float((p_r.grad - p_a.grad).abs().max())
    for p_r, p_a in zip(m_ref.parameters(), m_a.parameters())
)
print(f"norma gradien batch penuh  : {float(m_ref[0].weight.grad.norm()):.6f}")
print(f"norma gradien gabungan     : {float(m_a[0].weight.grad.norm()):.6f}")
print(f"max |selisih gradien|      : {selisih:.2e}")


# ---------- 2. Empat epoch: 1 proses vs simulasi DP 2 replika ----------
def urutan_epoch(epoch):
    g = torch.Generator().manual_seed(3030 + epoch)
    return torch.randperm(N, generator=g)


def latih_satu_proses(model):
    opt = torch.optim.SGD(model.parameters(), lr=0.03)
    riwayat = []
    for epoch in range(4):
        idx = urutan_epoch(epoch)
        for awal in range(0, N, 64):
            potongan = idx[awal : awal + 64]
            opt.zero_grad()
            mse(model, X[potongan], Y[potongan]).backward()
            opt.step()
        riwayat.append(mse_penuh(model))
    return riwayat


def latih_dp(m_a, m_b):
    opt_a = torch.optim.SGD(m_a.parameters(), lr=0.03)
    opt_b = torch.optim.SGD(m_b.parameters(), lr=0.03)
    riwayat = []
    for epoch in range(4):
        idx = urutan_epoch(epoch)
        for awal in range(0, N, 64):
            pecah_a, pecah_b = idx[awal : awal + 32], idx[awal + 32 : awal + 64]
            opt_a.zero_grad()
            opt_b.zero_grad()
            mse(m_a, X[pecah_a], Y[pecah_a]).backward()
            mse(m_b, X[pecah_b], Y[pecah_b]).backward()
            with torch.no_grad():  # gabung lalu sebar balik
                for p_a, p_b in zip(m_a.parameters(), m_b.parameters()):
                    rata = (p_a.grad + p_b.grad) / 2
                    p_a.grad.copy_(rata)
                    p_b.grad.copy_(rata)
            opt_a.step()
            opt_b.step()
        riwayat.append(mse_penuh(m_a))
    return riwayat


print()
print("=== 2. Empat epoch: 1 proses batch 64 vs DP 2 replika x 32 ===")
m_ref, m_a, m_b = model_baru(), model_baru(), model_baru()
samakan(m_ref, [m_a, m_b])
h_satu = latih_satu_proses(m_ref)
h_dp = latih_dp(m_a, m_b)
print(f"{'epoch':>5s} {'1 proses':>12s} {'DP 2 replika':>13s}")
for e, (a, b) in enumerate(zip(h_satu, h_dp), start=1):
    print(f"{e:>5d} {a:>12.6f} {b:>13.6f}")
print(f"selisih loss akhir         : {abs(h_satu[-1] - h_dp[-1]):.2e}")
print(f"bobot replika identik      : {all(torch.equal(p_a, p_b) for p_a, p_b in zip(m_a.parameters(), m_b.parameters()))}")

# ---------- 3. Tanpa sinkronisasi, replika bercerai ----------
print()
print("=== 3. Tanpa sinkronisasi: replika bercerai ===")
m_a, m_b = model_baru(), model_baru()
samakan(m_a, [m_b])
opt_a = torch.optim.SGD(m_a.parameters(), lr=0.03)
opt_b = torch.optim.SGD(m_b.parameters(), lr=0.03)
for epoch in range(4):  # tiap replika melihat pecahannya sendiri saja
    idx = urutan_epoch(epoch)
    for awal in range(0, N, 64):
        opt_a.zero_grad()
        opt_b.zero_grad()
        mse(m_a, X[idx[awal : awal + 32]], Y[idx[awal : awal + 32]]).backward()
        mse(m_b, X[idx[awal + 32 : awal + 64]], Y[idx[awal + 32 : awal + 64]]).backward()
        opt_a.step()
        opt_b.step()
with torch.no_grad():
    beda = max(
        float((p_a - p_b).abs().max())
        for p_a, p_b in zip(m_a.parameters(), m_b.parameters())
    )
print(f"max |selisih bobot|        : {beda:.4f} (bukan 0: tidak sinkron)")

Output yang Dihasilkan:

text
=== 1. Gradien: batch penuh 64 vs gabungan 2 pecahan x 32 ===
norma gradien batch penuh  : 1.434136
norma gradien gabungan     : 1.434136
max |selisih gradien|      : 1.19e-07

=== 2. Empat epoch: 1 proses batch 64 vs DP 2 replika x 32 ===
epoch     1 proses  DP 2 replika
    1     5.249649      5.249649
    2     1.177442      1.177442
    3     0.406797      0.406797
    4     0.363478      0.363478
selisih loss akhir         : 0.00e+00
bobot replika identik      : True

=== 3. Tanpa sinkronisasi: replika bercerai ===
max |selisih bobot|        : 0.0464 (bukan 0: tidak sinkron)

Mari kita telaah kode di atas baris per baris:

  1. Baris 24-29 (samakan): ketiga model dibangun lalu disamakan bobotnya lewat copy_ di bawah no_grad — mensimulasikan kondisi awal data parallelism, yakni seluruh replika memulai dari bobot yang persis identik.
  2. Baris 40-59 (kesetaraan gradien): model referensi menghitung gradien dari loss rata-rata 64 sampel; dua replika masing-masing menghitung gradien 32 sampel, lalu baris 49-52 menjalankan “all-reduce” manual: (g_a + g_b) / 2. Norma gradien hasilnya identik enam desimal dengan selisih maksimum 1,19e-07.
  3. Baris 82-106 (latih_dp): loop simulasi DP per langkah: pecah batch 64 menjadi dua 32, backward di kedua replika, gabung gradien, sebar balik gradien rata-rata yang sama, lalu kedua optimizer melangkah. Empat epoch menghasilkan kurva loss yang identik dengan jalur satu proses — selisih akhir 0,00e+00 — dan torch.equal membuktikan bobot kedua replika tetap bit-per-bit sama.
  4. Baris 117-138 (tanpa sinkronisasi): eksperimen negatif menghapus penggabungan gradien. Setelah empat epoch, selisih bobot antar replika mencapai 0,0464 — dua “model” berbeda — membuktikan bahwa all-reduce tiap langkah bukan opsion, melainkan jantungnya data parallelism.

DP Klasik dan DDP Modern #

PyTorch pernah menyediakan jalur satu-proses lewat torch.nn.DataParallel(model) — model dibungkus sekali dan batch otomatis dipecah ke beberapa GPU dalam satu proses Python. API tersebut masih dapat dipanggil, namun jalur ini terhambat GIL (utamanya satu thread yang mengatur banyak GPU) dan memuat replikasi model tiap langkah, sehingga dokumentasi resmi PyTorch kini menganjurkan DistributedDataParallel yang menjalankan satu proses per perangkat. Perbandingannya:

AspekDataParallel (klasik)DistributedDataParallel (modern)
Arsitektur proses1 proses, banyak thread GPU1 proses per perangkat
Penggabungan gradiendi GPU utama (tidak seimbang)all-reduce terdistribusi
Dampak GILterasa pada banyak GPUtidak ada (proses terpisah)
Status resmitidak dianjurkan, warisan lamastandar pelatihan multi-GPU
Kebutuhan kodeminimal (bungkus model)init_process_group + launcher

Simulasi manual di atas menuntut kita mengatur penggabungan gradien sendiri — tepat di titik ini DDP mengambil alih pekerjaan itu dengan satu proses per perangkat dan launcher khusus. Chapter berikutnya membahas Distributed Data Parallel secara penuh: inisialisasi grup proses, sampler data terdistribusi, dan pola torchrun.

Rujukan resmi perbandingan DataParallel vs DistributedDataParallel tersedia di docs.pytorch.org/docs/stable/generated/torch.nn.parallel.DistributedDataParallel.html .