GPU Saya Lebih Lambat daripada CPU yang Digantikannya
Saya pernah memindahkan sebuah komputasi berat dari CPU ke GPU, berharap ia akan melesat. Alih-alih, ia merangkak — jauh lebih lambat daripada kode biasa yang seharusnya ia gantikan. Saya membeli kecepatan dan entah bagaimana malah memasang perlambatan.
Perangkat kerasnya bukan hambatannya. GPU modern adalah salah satu benda tercepat yang bisa Anda pasang di sebuah komputer. Masalahnya adalah saya memberinya pekerjaan yang dibentuk dengan cara yang nyaris secara unik buruk untuknya, lalu terkejut ketika ia tidak bisa berjalan.
Peningkatan yang justru penurunan
Ekspektasinya sederhana dan, menurut saya, aman: GPU itu cepat, pekerjaan saya lambat, jadi memindahkan pekerjaan itu ke GPU seharusnya membuatnya cepat. Itulah versi awam tentang cara kerja akselerasi, dan itu keliru dengan cara yang spesifik dan mendidik.
Ketika saya mengukur versi yang “terakselerasi” itu, ia tidak sedikit lebih lambat. Ia lebih lambat berkali-kali lipat — jenis hasil yang memberitahu Anda bahwa masalahnya bukanlah optimasi yang hilang melainkan ketidakcocokan yang mendasar. Ada sesuatu tentang bentuk dari apa yang saya lakukan yang secara aktif melawan perangkat kerasnya.
Apa arti “menaruhnya di GPU” yang sebenarnya
Inilah yang sebenarnya telah saya lakukan. Saya punya sebuah loop yang sudah ada: ambil satu unit pekerjaan, hitung, lanjut ke berikutnya, ribuan kali berulang. Untuk “memakai GPU,” saya cukup membuat setiap unit individual berjalan di GPU — dan membiarkan loop-nya tetap persis di tempatnya.
Jadi ribuan kali berturut-turut, program saya berpaling ke GPU, menyerahkan satu tugas kecil, menunggu jawabannya, lalu berbalik untuk yang berikutnya. Loop-nya masih yang memegang kendali. GPU hanyalah seorang kontraktor yang sangat cepat yang saya telepon, satu per satu, untuk setiap paku yang ingin saya pakukan.
Biaya tersembunyi: ada ongkos hanya untuk meminta
Setiap kali Anda menyerahkan sepotong pekerjaan ke GPU, ada biaya tetap untuk menyiapkan dan mengirimkannya — kecil bila sendirian, tak terlihat bila Anda membayarnya sekali. Saya membayarnya ribuan demi ribuan kali. Ketika saya mengamatinya dengan saksama, sebagian besar waktu jalannya sama sekali bukan komputasi. Itu adalah overhead: GPU duduk menganggur, menunggu diberitahu apa yang harus dilakukan, sementara program saya menghabiskan waktunya mengajukan satu pertanyaan mungil demi pertanyaan mungil lainnya.
Saya telah membangun sebuah proses yang nyaris sepenuhnya berupa tindakan meminta, dengan pekerjaan yang sesungguhnya sebagai kesalahan pembulatan. Versi CPU memang lambat, tetapi setidaknya ia tetap sibuk. Versi GPU lambat karena ia menghabiskan sebagian besar hidupnya menunggu.
Sebuah GPU adalah bus, bukan taksi
Model mental yang akhirnya memperbaikinya: GPU bukanlah cara yang lebih cepat untuk melakukan satu hal. Ia adalah cara untuk melakukan sejumlah besar hal yang sama sekaligus. Ia adalah bus berkapasitas lima puluh kursi, bukan mobil sport.
Memanggilnya sekali per item itu seperti menjalankan bus itu bolak-balik melintasi kota dengan satu penumpang setiap perjalanan. Bus-nya benar-benar cepat. Throughput Anda menyedihkan — dan ia menyedihkan justru karena Anda memakai kendaraan yang dibuat untuk kerumunan demi mengangkut orang satu per satu. Kesalahannya bukan pada bus. Melainkan pada pengaturannya.
Solusinya: berhenti melakukan loop, mulai melakukan batching
Perbaikannya bukanlah kernel yang lebih cepat atau kartu yang lebih baik. Itu adalah menyusun ulang loop-nya. Alih-alih “untuk setiap item, kirim ke GPU,” saya membentuk ulang pekerjaannya sehingga GPU bisa mengambil banyak item sekaligus dan memprosesnya bersama-sama — mengubah ribuan pengiriman mungil menjadi segelintir pengiriman besar. Overhead yang telah saya bayar berulang-ulang runtuh menjadi sesuatu yang saya bayar beberapa kali saja alih-alih ribuan kali.
Ini adalah salah satu bentuk umum dari apa yang orang maksud dengan “vektorisasi” atau batching: mengekspresikan banyak operasi serupa secara bersama-sama alih-alih sebagai rangkaian panjang langkah-langkah kecil. Ini sering kali menjadi pembeda antara GPU yang mempermalukan CPU Anda dan GPU yang dipermalukan olehnya, dan jarak antara keduanya bisa sangat besar.
Mengapa inilah inti seluruh dari perangkat kerasnya
Saya butuh waktu untuk menghayati mengapa strukturnya begitu penting, alih-alih memperlakukannya sebagai detail penyetelan. Keunggulan GPU di sini tidak datang dari satu operasi mana pun yang cepat. Ia datang dari melakukan sejumlah besar operasi serupa pada saat yang sama. Paralelisme itulah keseluruhan nilai jualnya. Jika kode Anda tidak bisa mengekspresikan pekerjaannya sebagai “lakukan semua ini pada saat yang sama,” tidak ada yang bisa dipegang oleh paralelisme itu — dan GPU yang menjalankan pekerjaan serial, satu per satu, hanyalah sebuah CPU yang mahal, kikuk, dan berbentuk buruk.
Jadi “taruh di GPU” tidak pernah menjadi instruksi yang sebenarnya. Instruksi yang sebenarnya adalah “bentuk ulang pekerjaannya sehingga bisa dilakukan sekaligus.” Perangkat kerasnya hanya akan secepat yang diizinkan oleh bentuk masalahnya.
Alat tidak bisa menyelamatkan bentuk yang salah
Pelajaran ini berlaku jauh melampaui GPU. Meraih perangkat keras yang lebih cepat hanya berbuah sedikit jika struktur komputasi Anda melawan perangkat keras itu. Untuk pekerjaan seperti ini, performa lebih banyak bersemayam pada bentuk komputasinya — bagaimana ia diorganisir, di-batch, dan diekspresikan — daripada pada kecepatan nominal dari benda yang menjalankannya. Anda bisa menaruh algoritma lambat di mesin cepat dan mendapatkan kekecewaan yang cepat.
Saya menyimpan perlambatan itu dalam pikiran sebagai pengingat bahwa “saya membuatnya lebih cepat” dan “saya memindahkannya ke perangkat keras yang lebih cepat” bukanlah kalimat yang sama. Yang pertama adalah tentang memahami pekerjaannya. Yang kedua adalah tentang berharap silikonnya akan memahaminya untuk Anda. Ia tidak akan.
— Tidak ada sinyal, tidak ada imbal hasil, bukan nasihat investasi.