GPU sebagai Akselerator

Ribuan lane untuk masalah yang tepat: arsitektur GPU, model pemrogramannya, dan batasannya.

Daftar isi

Topik ini membahas GPU dari sisi arsitekturnya sebagai prosesor throughput, yaitu prosesor yang menukar kecepatan satu pekerjaan tunggal dengan banyaknya pekerjaan yang selesai per detik. Tiga hal dibedah berurutan, yaitu perbedaan filosofinya dengan CPU, model pemrograman SIMT beserta terminologinya, dan batasan jenis masalah yang dapat diselesaikannya. Satu program analitik dijalankan pada bagian akhir untuk menaksir kelayakan dua beban kerja yang berbeda tanpa perangkat keras GPU. Setelah membacanya, keputusan memindahkan sebuah pekerjaan ke GPU dapat diambil dengan perhitungan sederhana.

Dua filosofi eksekusi#

CPU dan GPU menyelesaikan persoalan yang sama, yaitu menjalankan instruksi, dengan filosofi yang berlawanan. CPU menyediakan sedikit inti yang masing-masing besar, yaitu dilengkapi prediksi cabang, eksekusi tak berurutan, dan cache yang besar, sehingga satu alur instruksi selesai secepat mungkin. Pendekatan ini mengupayakan latensi, yaitu waktu tunggu satu pekerjaan, serendah mungkin. GPU bergerak dari arah sebaliknya dengan ribuan lane, yaitu unit eksekusi sederhana yang hanya mengenal aritmetika, akses memori, dan sedikit kontrol. Waktu tunggu memori disembunyikan dengan mengalihkan eksekusi ke ribuan thread lain, yaitu unit eksekusi terkecil yang berjalan sendiri-sendiri.

Besaran yang dioptimalkan karenanya berbeda. CPU mengejar latensi terendah untuk satu urutan pekerjaan, sedangkan GPU mengejar throughput tertinggi untuk perangkat secara keseluruhan. Diagram berikut memperlihatkan perbandingan keduanya secara berdampingan.

Perbandingan filosofi eksekusi CPU dan GPUCPUintiintiintiintiGPUsedikit inti besar untuk latensi rendahribuan lane sederhana untuk throughput
Dua filosofi eksekusi: CPU dengan sedikit inti besar, GPU dengan ribuan lane sederhana

Kedua filosofi itu saling melengkapi dan bukan saling menggantikan. Pekerjaan yang pendek, bercabang, dan bergantung urutan tetap milik CPU, sedangkan pekerjaan panjang yang dapat dipecah menjadi ribuan potongan serupa milik GPU. Komputer umum menyediakan keduanya, sehingga menentukan tempat berjalannya setiap bagian program menjadi keputusan rancangan yang memengaruhi performa akhir.

Model pemrograman SIMT#

Model pemrograman GPU modern disebut SIMT, singkatan dari Single Instruction, Multiple Threads, yaitu satu instruksi yang dieksekusi banyak thread sekaligus pada data yang berbeda. Program yang dikirim ke GPU bernama kernel, yaitu satu fungsi yang dijalankan ribuan thread dengan nomor urut yang berbeda-beda, sehingga tiap thread menghitung satu bagian data. Thread dikumpulkan menjadi kelompok kecil yang dieksekusi bersama oleh satu unit instruksi, kelompok yang pada terminologi umum disebut warp dengan anggota umumnya 32 thread. Beberapa warp lagi dirangkai menjadi blok yang berbagi wilayah kerja, dan kumpulan blok membentuk grid utuh sebuah kernel.

Dua nama model pemrograman yang paling banyak dipakai adalah CUDA dan OpenCL, yang menyediakan cara menulis kernel dan mengelola kumpulan thread-nya dari bahasa C. Mekanisme SIMT bekerja lancar selama seluruh thread dalam satu warp mengambil cabang yang sama, karena satu unit instruksi cukup menggerakkan seluruh anggotanya serentak. Apabila arah cabang ditentukan oleh data, sebagian thread mengambil jalur pertama dan sebagian lagi jalur kedua, keadaan yang disebut divergence. Warp yang bercabang harus menjalankan kedua jalur bergantian dengan menonaktifkan thread yang tidak ikut pada tiap jalur, sehingga waktu eksekusinya memanjang meskipun setiap thread tetap menyelesaikan satu satuan pekerjaan.

Hierarki memori pada GPU#

Memori pada GPU berjenjang seperti pada CPU, tetapi peruntukan tiap jenjangnya berbeda. Global memory adalah memori utama perangkat yang kapasitasnya bergigabyte dan dapat diakses seluruh thread, tetapi waktunya jauh lebih lambat daripada register. Shared memory adalah memori kecil berkecepatan tinggi yang melekat pada setiap blok dan hanya dapat diakses thread dalam blok yang sama, sehingga berperan sebagai papan kerja bersama satu kelompok. Puncak jenjangnya berupa register per thread yang paling cepat dan paling terbatas jumlahnya.

Cara menyembunyikan waktu tunggu memori menjadi pembeda paling penting dari CPU. CPU mengandalkan cache besar yang bekerja otomatis tanpa perintah, sedangkan GPU lebih mengandalkan ribuan thread yang bergiliran, yaitu ketika satu thread menunggu data dari global memory, thread lain dieksekusi pada jalurnya. Shared memory melengkapi strategi ini dengan memberi pemrogram tempat menyalin data yang sering dipakai mendekati lane yang membutuhkannya. Konsekuensinya, kernel yang efisien adalah kernel yang akses memorinya tertib dan dapat diramalkan, karena kekacauan akses menimbulkan waktu tunggu yang tidak tertutup oleh thread lain. Tiga jenjang di atas adalah lapis utamanya, dan GPU sesungguhnya memuat penyangga lain di sekelilingnya, tetapi ketiganya sudah cukup untuk membaca perilaku hampir semua kernel.

Mengukur kelayakan dengan roofline#

Keputusan memindahkan pekerjaan ke GPU dapat dihitung terlebih dahulu dengan model roofline, yaitu model performa yang membandingkan kemampuan komputasi dengan kemampuan memori. Rumusnya berbunyi P=min⁡(Ppuncak,I×BW)P = \min(P_{\text{puncak}}, I \times BW), dengan PP performa yang dapat tercapai, PpuncakP_{\text{puncak}} kecepatan komputasi maksimum perangkat, II intensitas aritmetika, dan BWBW bandwidth memori, yaitu laju maksimum byte yang dapat dilewati per detik. Intensitas aritmetika menyatakan banyaknya operasi yang dikerjakan untuk setiap byte yang dilewati, misalnya satu penjumlahan untuk dua belas byte pada penjumlahan vektor. Rumus itu memilih batas yang lebih lambat di antara keduanya, karena performa tidak pernah melampaui kemampuan komputasinya maupun kemampuan memorinya menyuplai data.

Beban kerja dengan II kecil berada di kaki roofline, yaitu dibatasi memori, sehingga penambahan kemampuan komputasi tidak menaikkan performanya sedikit pun. Kondisi ini menyentuh sisi lemah GPU, karena unit eksekusi yang dibeli berlipat-lipat hanya berdiam menunggu data dari memori. Beban kerja dengan II besar mendekati langit-langit komputasi, dan di sanalah ribuan lane benar-benar bekerja penuh. Angka-angka ini dapat dihitung tanpa perangkat keras apa pun, dan itulah pekerjaan program pada bagian berikutnya.

Demo analitik dua beban kerja#

Program berikut membandingkan dua beban kerja secara analitik, yaitu penjumlahan vektor besar sebagai pekerjaan yang cocok dan cabang bergantian data sebagai pekerjaan yang tidak cocok. Untuk beban pertama, program menghitung byte yang dilewati, jumlah operasinya, intensitas aritmetikanya, lalu performa rooflinenya. Untuk beban kedua, program menghitung rasio cabang per elemen beserta efisiensi lane pada warp yang bercabang seimbang. Nilai puncak 5e12 operasi per detik dan bandwidth 500e9 byte per detik adalah angka model yang mewakili orde besaran GPU diskret kelas menengah, bukan hasil pengukuran perangkat tertentu.

python masalah-gpu.py
# masalah-gpu.py - menaksir kelayakan GPU dua beban kerja secara analitik
# Seluruh angka adalah hasil model, bukan pengukuran perangkat keras nyata.

N = 1_000_000                     # jumlah elemen per vektor

# Beban kerja reguler: penjumlahan vektor c = a + b, satu float 4 byte
total_byte = 3 * N * 4            # baca a, baca b, tulis c
operasi = N                       # satu penjumlahan per elemen
intensitas = operasi / total_byte
print("Penjumlahan vektor (reguler):")
print(f"  byte dilewati     : {total_byte:,}")
print(f"  operasi           : {operasi:,}")
print(f"  intensitas I      : {intensitas:.4f} operasi per byte")

p_puncak = 5e12                   # operasi per detik, nilai model
bandwidth = 500e9                 # byte per detik, nilai model
performa = min(p_puncak, intensitas * bandwidth)
print(f"  performa roofline : min({p_puncak:.0e}, {intensitas:.4f} x {bandwidth:.0e})")
print(f"                    = {performa:.2e} operasi per detik")
print(f"  porsi dari puncak : {performa / p_puncak:.1%}")
print()

# Beban kerja tak beraturan: satu cabang bergantung data per elemen
warp = 32                         # thread dalam satu warp
slot_bermanfaat = warp            # setiap thread tetap mengerjakan satu slot
slot_total = 2 * warp             # dua jalur cabang berjalan bergantian
efisiensi = slot_bermanfaat / slot_total
print("Cabang bergantung data (tak beraturan):")
print(f"  rasio cabang      : 1 cabang per elemen")
print(f"  slot warp         : {slot_total} slot untuk {slot_bermanfaat} slot bermanfaat")
print(f"  efisiensi lane    : {efisiensi:.0%} pada warp yang bercabang seimbang")
text Keluaran
Penjumlahan vektor (reguler):
  byte dilewati     : 12,000,000
  operasi           : 1,000,000
  intensitas I      : 0.0833 operasi per byte
  performa roofline : min(5e+12, 0.0833 x 5e+11)
                    = 4.17e+10 operasi per detik
  porsi dari puncak : 0.8%

Cabang bergantung data (tak beraturan):
  rasio cabang      : 1 cabang per elemen
  slot warp         : 64 slot untuk 32 slot bermanfaat
  efisiensi lane    : 50% pada warp yang bercabang seimbang

Keluarannya menyimpan dua cerita berbeda. Penjumlahan vektor satu juta elemen menghasilkan intensitas 0,0833 operasi per byte, dan hasil kalinya dengan bandwidth 500e9 hanya sekitar 4,17e10 operasi per detik atau 0,8 persen dari puncak, artinya pekerjaan ini dibatasi memori dan ribuan lane menganggur menunggu data. Cabang bergantian data membebani warp satu cabang per elemen, dan warp yang bercabang seimbang hanya mencapai efisiensi lane 50 persen karena kedua jalurnya dijalankan bergantian. Kedua temuan itu berpadu menjelaskan mengapa kode yang bercabang jarang dipercepat GPU, yaitu terhenti di memori pada satu sisi dan terhenti di divergensi pada sisi lain.

Kesalahan umum#

Tiga kekeliruan berikut paling sering muncul ketika GPU mulai dipakai sebagai akselerator.

  1. Menyamakan satu inti GPU dengan satu inti CPU. Angka inti pada GPU menghitung lane sederhana yang tidak memiliki prediksi cabang maupun cache sendiri, sehingga satu inti GPU jauh lebih lemah daripada satu inti CPU. Perbandingan yang bermakna dilakukan pada tingkat perangkat utuh untuk beban kerja tertentu.
  2. Melupakan biaya pemindahan data antara host dan device. Host adalah prosesor utama yang menjalankan program, sedangkan device adalah GPU-nya, dan data yang dipakai kernel harus dikirim ke sana lalu diambil kembali. Kernel yang tampak cepat di atas kertas dapat kalah bila waktu pengiriman itu dihitung, sehingga pekerjaan kecil sering lebih cepat diselesaikan sepenuhnya di CPU.
  3. Mengharapkan percepatan pada kode yang bercabang bergantung data. Divergensi warp menjalankan kedua jalur bergantian, dan akses memori yang tak teratur menambah waktu tunggu, sehingga hasil akhirnya bisa lebih lambat daripada versi CPU-nya.

Ringkasan#

  • GPU adalah prosesor throughput dengan ribuan lane sederhana yang menyembunyikan waktu tunggu memori melalui ribuan thread bergiliran, sedangkan CPU mengejar latensi rendah dengan sedikit inti besar.
  • Model SIMT mengeksekusi satu instruksi untuk banyak thread dalam warp beranggota umumnya 32, dan cabang bergantian data memaksa kedua jalur dijalankan bergantian.
  • Memori GPU berjenjang dari register per thread, shared memory per blok, hingga global memory yang besar dan lambat.
  • Rumus P=min⁡(Ppuncak,I×BW)P = \min(P_{\text{puncak}}, I \times BW) menaksir kelayakan pekerjaan, dengan intensitas kecil berarti dibatasi memori dan cabang berlebih berarti efisiensi lane turun.

Referensi#