OpenMP: Parallel For & Scheduling
#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:
- Runtime membentuk team of threads (fork), biasanya sebanyak core / nilai
OMP_NUM_THREADS. - Ruang iterasi
0..N-1dibagi ke thread-thread sesuai kebijakan schedule. - 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
breakataureturnkeluar dari loop (continueboleh). whiledan 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).)
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(misalOMP_SCHEDULE="dynamic,64") atauomp_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:
staticpaling 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,100sudah hampir menyelesaikan masalah: interleaving meratakan beban segitiga, tanpa overhead dinamis.dynamic,1menang di sini karena badan iterasinya besar (ribuan sin), jadi overhead ambil-chunk tenggelam. Kalau badan loop-nya cuma beberapa instruksi,dynamic,1justru 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_backdi dalam parallel for. Mengubah struktur container dari banyak thread = race. Pre-allocate lalu tulis via indeks.- Nested parallelism tak sengaja —
parallel fordi dalam fungsi yang dipanggil dariparallel forlain. Default-nya region dalam jalan serial (aman), tapi kalauOMP_NESTED=truebisa terjadi ledakan thread.
Ringkasan
parallel for= fork team + bagi iterasi + implicit barrier. Syaratnya: canonical form dan iterasi benar-benar independen.- Selalu
default(none); akumulasi pakaireduction, bukan menulis keshared. - Schedule = trade-off load balance vs overhead vs lokalitas:
staticuntuk beban rata,dynamic/guideduntuk beban timpang,runtimeuntuk eksperimen. - Waktu loop paralel = waktu thread paling lambat; barrier membuat semua orang menunggu si paling sial.
Lihat Juga
- OpenMP: Setup & Hello World — prasyarat
- OpenMP: Reduction & Sinkronisasi — kelanjutan langsung dari klausa
reduction - OpenMP: Tasking — untuk kerja yang tidak berbentuk loop canonical
- OpenMP: False Sharing — kenapa hasil benar tapi lambat
- OpenMP: Affinity & First-Touch — interaksi schedule dengan NUMA
- Work-Span Model & Brent's Theorem — teori di balik "iterasi independen = parallelism maksimal"
- Parallel Prefix Sum (Scan) — cara menangani loop yang punya dependency