Jump to content

OpenMP: Parallel For & Scheduling

From Haidhi Wiki
Revision as of 06:10, 11 July 2026 by SanzSkuy (talk | contribs) (Created page with "'''<code>#pragma omp parallel for</code>''' adalah konstruksi OpenMP yang paling sering dipakai: satu baris pragma yang membagi iterasi loop ke banyak thread. Artikel ini membahas cara kerjanya, aturan data sharing, dan yang paling sering disepelekan padahal menentukan performa: '''kebijakan scheduling''' — siapa mengerjakan iterasi yang mana. Prasyarat: OpenMP: Setup & Hello World. Semua contoh di sini di-compile dengan: <pre> g++ -O2 -fopenmp -o program program...")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

#pragma omp parallel for adalah konstruksi OpenMP yang paling sering dipakai: satu baris pragma yang membagi iterasi loop ke banyak thread. Artikel ini membahas cara kerjanya, aturan data sharing, dan yang paling sering disepelekan padahal menentukan performa: kebijakan scheduling — siapa mengerjakan iterasi yang mana.

Prasyarat: OpenMP: Setup & Hello World. Semua contoh di sini di-compile dengan:

g++ -O2 -fopenmp -o program program.cpp
OMP_NUM_THREADS=8 ./program

Dasar: parallel for

Contoh paling sederhana, SAXPY (y = a·x + y):

#include <cstdio>
#include <vector>
#include <omp.h>

int main() {
    const int N = 10'000'000;
    std::vector<double> x(N, 1.0), y(N, 2.0);
    const double a = 3.0;

    #pragma omp parallel for
    for (int i = 0; i < N; ++i)
        y[i] = a * x[i] + y[i];    // tiap iterasi independen — syarat mutlak

    printf("y[0] = %.1f, y[N-1] = %.1f\n", y[0], y[N-1]);
    return 0;
}

Yang terjadi di balik pragma itu:

  1. Runtime membentuk team of threads (fork), biasanya sebanyak core / nilai OMP_NUM_THREADS.
  2. Ruang iterasi 0..N-1 dibagi ke thread-thread sesuai kebijakan schedule.
  3. Di akhir loop ada implicit barrier: semua thread saling tunggu, baru program lanjut (join).

Dalam bahasa work-span: badan loop yang independen berarti span-nya Θ(1) — parallelism Θ(N), ini kasus terbaik ("embarrassingly parallel").

parallel vs for: dua konstruksi berbeda

parallel for sebenarnya gabungan dua hal: parallel (bikin team) dan for (bagi iterasi ke team yang sudah ada). Memisahkannya berguna kalau ada beberapa loop berurutan — team dibuat sekali saja, tidak fork-join berulang:

#pragma omp parallel          // fork sekali
{
    #pragma omp for nowait    // loop 1; nowait = hapus barrier di akhirnya
    for (int i = 0; i < N; ++i)
        a[i] = f(i);

    #pragma omp for           // loop 2; barrier di sini tetap ada
    for (int i = 0; i < M; ++i)
        b[i] = g(i);
}                             // join sekali

nowait hanya aman kalau loop berikutnya tidak membaca hasil loop sebelumnya dari iterasi milik thread lain. Kalau ragu, biarkan barrier-nya.

Syarat Loop: Canonical Form

Tidak semua loop bisa ditempeli parallel for. OpenMP menuntut canonical loop form: jumlah iterasi harus bisa dihitung sebelum loop jalan.

  • Variabel loop bertipe integer (atau random-access iterator C++).
  • Kondisi berbentuk i < n, i <= n, dsb. — batas tidak berubah selama loop.
  • Increment tetap: ++i, i += k.
  • Tidak ada break atau return keluar dari loop (continue boleh).
  • while dan loop linked-list tidak bisa — untuk itu pakai OpenMP: Tasking.

Dan satu syarat semantik yang jadi tanggung jawab kita, bukan compiler: tidak ada loop-carried dependency — iterasi i tidak boleh butuh hasil iterasi lain. Compiler tidak memeriksa ini; pragma adalah janji kita ke compiler. Janji palsu = hasil salah tanpa peringatan.

// TIDAK BISA diparalelkan begitu saja: iterasi i butuh hasil iterasi i-1
for (int i = 1; i < N; ++i)
    prefix[i] = prefix[i-1] + x[i];   // DAG-nya rantai lurus, span Θ(N)

(Kasus prefix sum ini punya solusi khusus — lihat Parallel Prefix Sum (Scan).)

Data Sharing: shared, private, firstprivate, lastprivate

Aturan default: variabel yang dideklarasikan di luar region paralel = shared (satu untuk semua thread), yang di dalam = private per thread. Variabel loop-nya sendiri otomatis private.

Klausa untuk mengatur eksplisit:

Klausa Arti
shared(v) Satu v untuk semua thread. Aman dibaca bersama; menulis bersama = race.
private(v) Tiap thread dapat salinan v sendiri, tidak diinisialisasi.
firstprivate(v) Seperti private, tapi salinannya diinisialisasi dari nilai v sebelum loop.
lastprivate(v) Seperti private, tapi nilai dari iterasi terakhir disalin balik keluar loop.
default(none) Wajib deklarasi eksplisit semua variabel. Selalu pakai ini — memaksa mikir, menangkap race saat compile.

Contoh dengan default(none):

double skala = 2.5;
std::vector<double> v(N);

#pragma omp parallel for default(none) shared(v) firstprivate(skala)
for (int i = 0; i < (int)v.size(); ++i)   // hati-hati: size() perlu di-cast/di-hoist
    v[i] = skala * i;

Lupa satu variabel saja dengan default(none) → error compile. Jauh lebih murah daripada race yang muncul sekali tiap seribu run.

Race Condition: Bug Nomor Satu

Program lengkap yang menunjukkan kenapa menulis ke variabel shared itu fatal:

#include <cstdio>
#include <omp.h>

int main() {
    const int N = 1000000;
    long salah = 0, benar = 0;

    #pragma omp parallel for
    for (int i = 0; i < N; ++i)
        salah += 1;               // RACE: semua thread menulis variabel yang sama

    #pragma omp parallel for reduction(+:benar)
    for (int i = 0; i < N; ++i)
        benar += 1;               // tiap thread punya salinan, digabung di akhir

    printf("salah = %ld  (harusnya %d)\n", salah, N);
    printf("benar = %ld\n", benar);
    return 0;
}

Output nyata (16 thread):

salah = 937500  (harusnya 1000000)
benar = 1000000

salah += 1 itu tiga operasi (baca–tambah–tulis); dua thread yang interleave saling menimpa hasil. Perbaikannya bukan critical atau atomic di dalam loop (itu menserialisasi lagi, kadang lebih lambat dari sekuensial) tapi klausa reduction. Detail di OpenMP: Reduction & Sinkronisasi.

Scheduling: Siapa Mengerjakan Iterasi yang Mana

Sintaks: schedule(jenis[, chunk]). Ilustrasi 16 iterasi, 4 thread:

static          T0:[ 0  1  2  3] T1:[ 4  5  6  7] T2:[ 8  9 10 11] T3:[12 13 14 15]
                blok kontigu sama besar, ditentukan di awal

static,2        T0:[0 1][8 9]    T1:[2 3][10 11]  T2:[4 5][12 13]  T3:[6 7][14 15]
                chunk 2 iterasi, dibagi round-robin, tetap ditentukan di awal

dynamic,2       T0:[0 1][6 7]... T1:[2 3][10 11]..  <- siapa cepat dia dapat
                thread yang selesai ambil chunk berikutnya dari antrean

guided          [0..5][6..9][10..12][13 14][15]     <- chunk mengecil
                mulai besar (sisa/p), makin lama makin kecil, diambil dinamis
schedule(static) — default di kebanyakan implementasi
Pembagian ditentukan sebelum loop jalan, overhead nol, deterministik. Bonus besar: kalau loop yang sama dijalankan berulang (misal tiap timestep), thread yang sama pegang elemen yang sama → data masih hangat di cache-nya, dan cocok dengan pola first-touch di sistem NUMA (lihat OpenMP: Affinity & First-Touch).
schedule(static, chunk)
Sama-sama ditentukan di awal, tapi interleaved. Berguna kalau beban naik/turun secara teratur sepanjang ruang iterasi.
schedule(dynamic, chunk)
Thread ambil chunk dari antrean bersama tiap kali selesai. Menyeimbangkan beban timpang, tapi tiap pengambilan ada overhead sinkronisasi. dynamic,1 (chunk default) = load balancing terbaik, overhead terburuk; chunk besar = sebaliknya. Lokalitas cache juga hancur karena pembagian berubah tiap run.
schedule(guided, chunk)
Seperti dynamic tapi chunk mengecil eksponensial (mulai ≈ sisa_iterasi/jumlah_thread, minimum chunk). Kompromi: sedikit pengambilan di awal, granularitas halus di akhir buat menambal ketimpangan.
schedule(auto)
Terserah compiler/runtime.
schedule(runtime)
Baca dari environment variable OMP_SCHEDULE (misal OMP_SCHEDULE="dynamic,64") atau omp_set_schedule(). Emas buat eksperimen: ganti kebijakan tanpa recompile.

Benchmark: Membandingkan Semua Schedule

Program lengkap. Bebannya sengaja timpang — iterasi ke-i mengerjakan i putaran, jadi iterasi akhir jauh lebih berat (beban segitiga). Trik schedule(runtime) + omp_set_schedule() dipakai supaya satu binary bisa menguji semua kebijakan:

#include <cstdio>
#include <cmath>
#include <omp.h>

// Kerja per iterasi sengaja dibuat timpang:
// iterasi ke-i mengerjakan i putaran, jadi iterasi akhir jauh
// lebih berat dari iterasi awal (beban berbentuk segitiga).
double kerja(long i) {
    double s = 0.0;
    for (long j = 0; j < i; ++j)
        s += std::sin(j * 1e-9);
    return s;
}

void jalankan(omp_sched_t jenis, int chunk, const char *nama, long n) {
    omp_set_schedule(jenis, chunk);
    double total = 0.0;

    double t0 = omp_get_wtime();
    #pragma omp parallel for schedule(runtime) reduction(+:total)
    for (long i = 0; i < n; ++i)
        total += kerja(i);
    double t1 = omp_get_wtime();

    printf("%-16s : %6.3f s  (checksum %.4f)\n", nama, t1 - t0, total);
}

int main() {
    const long N = 30000;
    printf("threads = %d, N = %ld\n\n", omp_get_max_threads(), N);

    jalankan(omp_sched_static,  0,   "static",      N);
    jalankan(omp_sched_static,  100, "static,100",  N);
    jalankan(omp_sched_dynamic, 1,   "dynamic,1",   N);
    jalankan(omp_sched_dynamic, 100, "dynamic,100", N);
    jalankan(omp_sched_guided,  0,   "guided",      N);
    return 0;
}

Hasil nyata di mesin 16 thread (angka pasti beda-beda tiap mesin, polanya yang penting):

threads = 16, N = 30000

static           :  0.259 s  (checksum 4499.5500)
static,100       :  0.191 s  (checksum 4499.5500)
dynamic,1        :  0.183 s  (checksum 4499.5500)
dynamic,100      :  0.192 s  (checksum 4499.5500)
guided           :  0.181 s  (checksum 4499.5500)

Analisisnya:

  • static paling lambat (~40% di belakang): thread terakhir kebagian blok iterasi terberat, thread lain selesai duluan lalu nganggur menunggu barrier. Waktu loop = waktu thread paling sial.
  • static,100 sudah hampir menyelesaikan masalah: interleaving meratakan beban segitiga, tanpa overhead dinamis.
  • dynamic,1 menang di sini karena badan iterasinya besar (ribuan sin), jadi overhead ambil-chunk tenggelam. Kalau badan loop-nya cuma beberapa instruksi, dynamic,1 justru bisa lebih lambat dari sekuensial.
  • Checksum harus identik di semua kebijakan — schedule mengubah pembagian kerja, bukan hasil. Kalau checksum berubah-ubah, ada race.

Panduan Memilih

Situasi Pakai
Beban tiap iterasi seragam (SAXPY, stencil, matmul) static — overhead nol, cache & NUMA friendly
Beban bervariasi tapi polanya teratur (segitiga, dsb.) static,chunk
Beban tak tertebak, badan iterasi besar (Mandelbrot, ray tracing, simulasi partikel) dynamic,chunk — mulai dari chunk sedang (32–128), tuning
Beban tak tertebak, mau kompromi tanpa tuning guided
Belum tahu / mau eksperimen runtime + OMP_SCHEDULE

Prinsipnya trade-off tiga arah: load balance vs overhead scheduling vs lokalitas cache. static mengorbankan yang pertama demi dua sisanya; dynamic,1 kebalikannya; sisanya ada di tengah.

collapse: Loop Bersarang

Kalau loop luar iterasinya sedikit (misal N=8 baris di mesin 16 thread), separuh thread nganggur. collapse(2) menggabungkan dua ruang iterasi jadi satu:

// tanpa collapse: hanya N iterasi dibagi ke thread → N=8, 16 thread = rugi
// dengan collapse(2): N*M iterasi dibagi ke thread
#pragma omp parallel for collapse(2)
for (int i = 0; i < N; ++i)
    for (int j = 0; j < M; ++j)
        c[i][j] = a[i][j] + b[i][j];

Syarat: kedua loop harus perfectly nested (tidak ada statement di antara for i dan for j) dan batas loop dalam tidak boleh bergantung pada loop luar. Kalau loop luar sudah punya iterasi jauh lebih banyak dari jumlah thread, collapse tidak perlu — malah bisa merusak lokalitas akses baris.

Jebakan Umum

  • Pragma adalah janji. Compiler percaya begitu saja bahwa iterasi independen. Loop-carried dependency tidak terdeteksi — hasil salah, tanpa error, kadang cuma sesekali.
  • Loop terlalu kecil. Fork-join dan barrier itu ada harganya (orde mikrodetik). Loop 1000 iterasi ringan bisa lebih lambat diparalelkan. Ukur dulu, paralelkan loop yang memang makan waktu. Bisa juga pakai klausa if(N > 10000) supaya paralel hanya untuk masalah besar.
  • Menulis elemen bertetangga dari thread berbeda. Benar secara logika, lambat secara hardware — dua thread menulis ke cache line yang sama saling melempar line-nya bolak-balik. Lihat OpenMP: False Sharing dan Cache line.
  • Memanggil fungsi yang tidak thread-safe di dalam loop (rand(), strtok(), ...). Pakai versi reentrant atau state per thread.
  • std::vector::push_back di dalam parallel for. Mengubah struktur container dari banyak thread = race. Pre-allocate lalu tulis via indeks.
  • Nested parallelism tak sengaja — parallel for di dalam fungsi yang dipanggil dari parallel for lain. Default-nya region dalam jalan serial (aman), tapi kalau OMP_NESTED=true bisa terjadi ledakan thread.

Ringkasan

  • parallel for = fork team + bagi iterasi + implicit barrier. Syaratnya: canonical form dan iterasi benar-benar independen.
  • Selalu default(none); akumulasi pakai reduction, bukan menulis ke shared.
  • Schedule = trade-off load balance vs overhead vs lokalitas: static untuk beban rata, dynamic/guided untuk beban timpang, runtime untuk eksperimen.
  • Waktu loop paralel = waktu thread paling lambat; barrier membuat semua orang menunggu si paling sial.

Lihat Juga

Kategori:HPC Kategori:OpenMP