Jump to content

OpenMP: Affinity & First-Touch

From Haidhi Wiki
Revision as of 06:18, 11 July 2026 by SanzSkuy (talk | contribs) (Created page with "Di mesin NUMA, performa program OpenMP ditentukan dua hal yang '''tidak kelihatan di kode''': di core mana tiap thread berjalan ('''affinity'''), dan di node memori mana tiap data tinggal ('''first-touch'''). Kode yang sama persis bisa beda kecepatan 2× hanya karena dua hal ini tidak selaras. Artikel ini membahas cara mengontrol keduanya — dan kenapa keduanya harus diatur ''bersama-sama''. Prasyarat: OpenMP: Parallel For & Scheduling dan konsep NUMA. = L...")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

Di mesin NUMA, performa program OpenMP ditentukan dua hal yang tidak kelihatan di kode: di core mana tiap thread berjalan (affinity), dan di node memori mana tiap data tinggal (first-touch). Kode yang sama persis bisa beda kecepatan 2× hanya karena dua hal ini tidak selaras. Artikel ini membahas cara mengontrol keduanya — dan kenapa keduanya harus diatur bersama-sama.

Prasyarat: OpenMP: Parallel For & Scheduling dan konsep NUMA.

Latar: NUMA dalam Satu Paragraf

Node cluster modern umumnya punya ≥2 socket CPU, dan tiap socket punya memory controller + RAM sendiri. Semua RAM tergabung dalam satu address space, tapi aksesnya tidak seragam: core di socket 0 mengakses RAM socket 0 (lokal) lebih cepat daripada RAM socket 1 (remote, lewat interconnect antar-socket seperti Intel UPI / AMD Infinity Fabric). Bedanya tipikal 1.5–2× di latensi, dan bandwidth interconnect jauh lebih kecil dari bandwidth memori lokal. Pertanyaannya: siapa yang menentukan sebuah data tinggal di RAM socket mana?

First-Touch: Memori Dialokasikan Saat Disentuh

Jawabannya kebijakan default Linux: first-touch. malloc/new hanya memberi alamat virtual — belum ada halaman (page) fisik sama sekali. Halaman fisik baru dialokasikan saat pertama kali ditulis, dan ditempatkan di node memori tempat thread yang menulisnya sedang berjalan.

Konsekuensinya brutal untuk pola kode yang paling umum:

double *a = new double[N];

for (long i = 0; i < N; ++i)      // init serial oleh thread master...
    a[i] = 0.0;                   // ...SEMUA halaman jatuh di node 0

#pragma omp parallel for
for (long i = 0; i < N; ++i)      // 16 thread di 2 socket mengeroyok...
    a[i] = berat(a[i]);           // ...RAM node 0. Interconnect macet.
INIT SERIAL:                            INIT PARALEL:
 Socket 0          Socket 1              Socket 0          Socket 1
 [RAM: a semua]    [RAM: kosong]         [RAM: a paruh 1]  [RAM: a paruh 2]
    ^  ^              |  |                  ^  ^              ^  ^
    T0 T1 ......... T8 T9                   T0 T1             T8 T9
         semua lewat sini                   lokal             lokal
         (interconnect = leher botol)

Perbaikannya satu baris: inisialisasi dengan loop paralel yang pembagiannya sama dengan loop komputasi:

#pragma omp parallel for schedule(static)
for (long i = 0; i < N; ++i)      // thread yang menyentuh duluan =
    a[i] = 0.0;                   // thread yang memakai nanti -> semua akses lokal

Kuncinya schedule(static) di kedua loop: pembagiannya deterministik, jadi thread t menyentuh dan memakai elemen yang sama. dynamic/guided merusak jaminan ini (pembagian berubah tiap run) — di mesin NUMA, beban timpang pun sering lebih murah ditangani dengan static,chunk daripada kehilangan lokalitas.

Benchmark: Membuktikannya

Triad ala STREAM (a = b + s·c), bandingkan init serial vs paralel:

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

// Bandingkan: ./firsttouch serial  vs  ./firsttouch paralel
int main(int argc, char **argv) {
    const long N = 1L << 26;               // 3 array x 512 MiB = 1.5 GiB
    const bool paralel = (argc > 1 && strcmp(argv[1], "paralel") == 0);

    double *a = new double[N];             // alokasi virtual: belum ada halaman fisik
    double *b = new double[N];
    double *c = new double[N];

    if (paralel) {
        // first-touch oleh thread yang sama yang nanti memakainya
        #pragma omp parallel for schedule(static)
        for (long i = 0; i < N; ++i) { a[i] = 0.0; b[i] = 1.0; c[i] = 2.0; }
    } else {
        // first-touch semuanya oleh thread master -> semua halaman di node 0
        for (long i = 0; i < N; ++i) { a[i] = 0.0; b[i] = 1.0; c[i] = 2.0; }
    }

    double best = 1e99;
    for (int ulang = 0; ulang < 5; ++ulang) {
        double t0 = omp_get_wtime();
        #pragma omp parallel for schedule(static)
        for (long i = 0; i < N; ++i)
            a[i] = b[i] + 3.0 * c[i];
        double t1 = omp_get_wtime();
        if (t1 - t0 < best) best = t1 - t0;
    }

    // triad: baca b, baca c, tulis a = 24 byte per iterasi
    printf("init %-7s : %.4f s per triad, %5.1f GB/s\n",
           paralel ? "paralel" : "serial", best, 24.0 * N / best / 1e9);

    delete[] a; delete[] b; delete[] c;
    return 0;
}

Hasil nyata di laptop (i5-13450HX, 1 socket, 1 NUMA node, 16 thread):

init serial  : 0.0634 s per triad,  25.4 GB/s
init paralel : 0.0644 s per triad,  25.0 GB/s

Tidak ada bedanya — dan itu memang seharusnya. Satu node NUMA berarti semua memori sama jauhnya; first-touch tidak mengubah apa-apa. Ini pelajaran penting: first-touch adalah masalah mesin multi-socket. Di node cluster 2-socket, benchmark yang sama tipikalnya menunjukkan init serial kehilangan hampir setengah bandwidth (semua thread mengantre ke satu memory controller + interconnect), sedangkan init paralel mendekati jumlah bandwidth kedua socket. Jalankan program di atas saat pertama kali dapat akses cluster — angkanya jauh lebih meyakinkan daripada artikel ini.

Jebakan C++: std::vector

std::vector<double> v(N);   // konstruktor MENG-NOL-KAN semua elemen...
                            // ...secara serial, di thread master.
                            // First-touch sudah terlanjur terjadi!

std::vector<double> v(N) melakukan value-initialization — itu sudah "menyentuh" seluruh array dari satu thread. Loop init paralel setelahnya percuma: halaman sudah punya rumah. Untuk array besar di mesin NUMA, pakai new double[N] / std::unique_ptr<double[]> (tidak menginisialisasi, tidak menyentuh) lalu sentuh pertama kali lewat loop paralel — atau tulis custom allocator.

Affinity: Mengikat Thread ke Core

First-touch mengatur di mana data tinggal. Percuma kalau thread-nya sendiri berpindah-pindah: default-nya OS scheduler bebas memigrasikan thread antar core, dan begitu thread pindah socket, semua data yang tadinya lokal jadi remote — plus isi cache-nya ditinggal. Affinity = mengikat thread ke tempat tertentu. Dua environment variable utama (OpenMP ≥ 4.0):

OMP_PLACES — mendefinisikan "tempat" yang tersedia
threads = satu hardware thread per place; cores = satu core fisik per place (hyperthread sesaudara jadi satu); sockets = satu socket per place. Bisa juga eksplisit: {0:8},{8:8}.
OMP_PROC_BIND — mengatur cara thread dipetakan ke places
false = tidak diikat (default GCC — thread bebas migrasi!); true = diikat, pemetaan terserah implementasi; close = rapat: thread berurutan di place bertetangga; spread = disebar merata ke seluruh places; master = semua di place yang sama dengan master. Bisa juga per-region lewat klausa: #pragma omp parallel proc_bind(spread).

Untuk melihat hasil pemetaannya, tidak perlu menebak — minta runtime melapor dengan OMP_DISPLAY_AFFINITY=true. Demo nyata di laptop 16 CPU (10 core: 6 P-core ber-hyperthread + 4 E-core), 4 thread:

$ export OMP_DISPLAY_AFFINITY=true OMP_AFFINITY_FORMAT="thread %n -> core %A"
$ OMP_NUM_THREADS=4 OMP_PLACES=cores OMP_PROC_BIND=close ./program
thread 0 -> core 0-1
thread 1 -> core 2-3      <- rapat: 4 place pertama, semua P-core bertetangga
thread 2 -> core 4-5
thread 3 -> core 6-7

$ OMP_NUM_THREADS=4 OMP_PLACES=cores OMP_PROC_BIND=spread ./program
thread 0 -> core 0-1
thread 1 -> core 6-7      <- disebar ke seluruh chip,
thread 2 -> core 12          sampai kena E-core (12, 14 tanpa hyperthread)
thread 3 -> core 14

(Notasi 0-1 berarti place-nya satu core fisik dengan 2 hyperthread.)

close atau spread?

Situasi Pakai Alasan
Memory-bound, thread < core, mesin multi-socket spread Menyebar thread = memakai memory controller & cache L3 semua socket
Thread banyak berbagi data / sinkronisasi intensif close Tetangga cache = komunikasi murah
Hybrid MPI+OpenMP, satu rank per socket close per rank Rank sudah dipisah per socket; thread-nya jangan nyebrang
Thread = jumlah core dua-duanya sama Semua place terisi; yang penting diikat (bukan false)

Resep standar yang aman untuk hampir semua kasus:

export OMP_PLACES=cores
export OMP_PROC_BIND=spread      # atau close; yang penting BUKAN false
export OMP_NUM_THREADS=<jumlah core fisik>

OMP_PLACES=cores (bukan threads) berarti satu thread OpenMP per core fisik — untuk kode HPC yang memory/FPU-bound, hyperthreading jarang membantu dan sering mengganggu.

Menyelaraskan Semuanya

Tiga komponen harus konsisten satu sama lain — lepas satu, dua lainnya ikut rusak:

  1. Affinity: thread diikat ke core (OMP_PROC_BIND ≠ false) → pemetaan thread→socket stabil.
  2. First-touch: array disentuh pertama kali oleh loop paralel → pemetaan data→socket mengikuti thread.
  3. Schedule: schedule(static) di loop init dan semua loop komputasi → pemetaan iterasi→thread sama terus, dari init sampai timestep terakhir.

Pola lengkapnya dalam kode simulasi tipikal:

double *u = new double[N];         // jangan std::vector untuk grid besar

#pragma omp parallel for schedule(static)
for (long i = 0; i < N; ++i)       // "init paralel" — pemanasan first-touch
    u[i] = kondisi_awal(i);

for (int step = 0; step < STEPS; ++step) {
    #pragma omp parallel for schedule(static)   // pembagian SAMA dengan init
    for (long i = 0; i < N; ++i)
        u_baru[i] = update(u, i);
    std::swap(u, u_baru);
}

Alat Diagnosa

  • numactl --hardware — berapa node, core mana milik siapa, RAM bebas per node.
  • numastat -p <PID> — halaman milik proses ada di node mana; cara paling langsung memergoki first-touch yang salah.
  • numactl --interleave=all ./program — paksa halaman disebar round-robin; obat instan (bukan optimal) untuk program orang lain yang init-nya serial.
  • OMP_DISPLAY_ENV=true — cetak semua setting OpenMP efektif saat program mulai.
  • lstopo (hwloc) — gambar topologi mesin: socket, cache, core.
  • likwid-pin / likwid-perfctr — binding + pengukuran per-socket, lihat Profiling: likwid.

Satu catatan sistem: Linux punya automatic NUMA balancing (/proc/sys/kernel/numa_balancing) yang diam-diam memigrasikan halaman mendekati thread yang mengaksesnya. Ini bisa menutupi dosa first-touch setelah beberapa detik — tapi dengan overhead page-fault, dan tidak bisa diandalkan. Saat benchmark serius, sadari fitur ini ada.

Jebakan Umum

  • Lupa bahwa default-nya tidak diikat. Tanpa OMP_PROC_BIND, GCC membiarkan thread migrasi. Semua kerja keras first-touch bisa hangus saat OS memindahkan thread ke socket sebelah.
  • std::vector(N), calloc, memset setelah alokasi — semuanya menyentuh array secara serial sebelum sempat di-first-touch paralel.
  • Memory pool / allocator yang mendaur ulang. Halaman yang sudah pernah disentuh tetap di node lamanya — free() lalu malloc() lagi belum tentu memberi halaman perawan. Program panjang yang alokasinya didaur ulang tidak bisa mengandalkan first-touch begitu saja.
  • Granularitas halaman: 4 KiB = 512 double. First-touch bekerja per halaman, bukan per elemen. Array kecil atau pembagian yang tidak sejajar halaman → beberapa halaman di perbatasan jadi "milik bersama". Untuk array raksasa ini noise; jangan pusing.
  • Menguji di laptop lalu heran tidak ada efeknya — seperti hasil benchmark di atas: 1 node NUMA = tidak ada remote access. Fenomena ini baru hidup di mesin multi-socket.
  • Hybrid MPI+OpenMP: mpirun sering membatasi cpuset tiap rank. Aturan mainnya: satu rank per socket/NUMA-node, OpenMP mengisi core di dalamnya dengan close. Detail di OpenMP: Hybrid MPI+OpenMP.

Ringkasan

  • Halaman memori tinggal di node tempat thread yang pertama menulisnya — bukan yang malloc.
  • Init serial = semua data numpuk di node 0 = semua thread socket lain antre di interconnect. Obatnya: init paralel dengan schedule(static) yang sama dengan loop komputasi.
  • Ikat thread-nya: OMP_PLACES=cores + OMP_PROC_BIND=spread/close. Tanpa binding, first-touch cuma janji manis.
  • Verifikasi, jangan percaya: OMP_DISPLAY_AFFINITY=true untuk thread, numastat untuk data.
  • Efeknya baru terasa di mesin multi-socket — laptop single-node tidak akan menunjukkannya (dibuktikan di atas: 25.4 vs 25.0 GB/s).

Lihat Juga

Kategori:HPC Kategori:OpenMP