Dalam dunia pemrograman, optimisasi kompiler seharusnya membuat kode lebih cepat, bukan lebih lambat. Namun sebuah penelitian mendalam tentang performa Rust baru-baru ini mengungkapkan kasus mengejutkan di mana tingkat optimisasi tertinggi justru melumpuhkan performa, memicu diskusi luas di kalangan pengembang tentang kapan peningkatan kompiler bisa menjadi jebakan performa.
Kasus Performa yang Patologis
Seorang pengembang Rust baru-baru ini menemukan bahwa bounded priority queue kustom mereka berjalan jauh lebih lambat ketika dikompilasi dengan opt-level = 3 dibandingkan dengan opt-level = 2. Penalti performanya cukup signifikan - perlambatan 113% dalam benchmark. Hasil yang kontra-intuitif ini terjadi meskipun kedua tingkat optimisasi menargetkan arsitektur Haswell yang sama dan diuji pada prosesor AMD Zen 3 dan Intel Haswell asli.
Kode bermasalah tersebut melibatkan vektor terurut yang menggunakan binary_search_by dengan fungsi perbandingan yang pertama membandingkan jarak floating-point, kemudian ID integer. Meskipun ini terlihat seperti kode yang sederhana, strategi optimisasi kompiler yang berbeda menciptakan output assembly yang sangat berbeda yang menyebabkan perbedaan performa.
Dalam fungsi yang bercabang, id hanya dibandingkan jika distance sama, dan karena distance adalah float acak, ini hampir tidak pernah terjadi dan cabang yang sesuai hampir sempurna diprediksi. Fungsi tanpa cabang selalu membandingkan id dan distance, secara efektif melakukan dua kali pekerjaan.
Perbandingan Performa: Level Optimasi O2 vs O3
- Optimasi O2: 44,1% sampel di binary_search_by, 25,68% di fungsi compare
- Optimasi O3: 79,6% sampel di binary_search_by, 63,57% di fungsi compare
- Penalti performa: perlambatan +113% dengan O3 dibandingkan O2
Misteri Tingkat Assembly
Ketika pengembang menyelami kode assembly, mereka menemukan bahwa opt-level = 2 menghasilkan kode sederhana dengan lompatan kondisional, sementara opt-level = 3 menghasilkan kode yang lebih canggih menggunakan conditional moves. Conditional moves umumnya dianggap lebih modern dan efisien karena menghindari kesalahan prediksi cabang, tetapi dalam kasus spesifik ini, mereka menciptakan rantai ketergantungan yang membatasi performa.
Analisis teoritis menggunakan alat seperti uCA (uiCA) memprediksi versi conditional move akan memiliki throughput 2,7x lebih rendah karena masalah ketergantungan. Ini menyoroti bagaimana kompleksitas CPU modern - dengan fitur seperti paralelisme tingkat instruksi, prediksi cabang, dan eksekusi spekulatif - terkadang dapat bekerja melawan kode yang dioptimalkan dengan cara yang tak terduga.
Perbedaan Kode Assembly
- O2: Menggunakan lompatan kondisional (5 lompatan kondisional termasuk pemeriksaan NaN)
- O3: Menggunakan pemindahan kondisional (4 pemindahan kondisional, 1 lompatan kondisional)
- Throughput teoritis: Assembly O3 diprediksi 2,7x lebih rendah oleh alat analisis uCA
Eksperimen dan Solusi Komunitas
Komunitas pemrograman menanggapi dengan pengujian dan analisis ekstensif. Beberapa pengembang menemukan bahwa menambahkan #[inline(always)] ke fungsi perbandingan dapat mengurangi penalti O3 sekitar 50%, meskipun sedikit menurunkan performa O2. Yang lain menemukan bahwa menggunakan total_cmp untuk perbandingan floating-point alih-alih penanganan NaN manual menghasilkan assembly yang berbeda tetapi masalah performa yang serupa.
Beberapa komentator mencatat bahwa profile-guided optimization (PGO) mungkin membantu LLVM membuat keputusan yang lebih baik tentang kapan menggunakan conditional moves versus jumps. Diskusi juga menyentuh konteks historis, dengan referensi ke regresi performa Rust masa lalu yang terkait dengan optimisasi binary search dan penggunaan conditional move.
Solusi Potensial yang Dibahas
- Menambahkan
[inline(always)]ke fungsi compare: peningkatan ~50% di O3, penurunan +10% di O2 - Menggunakan
std::hint::unlikelypada branch yang jarang terjadi - Mengganti perbandingan float manual dengan metode
total_cmp - Profile-guided optimization (PGO) untuk keputusan compiler yang lebih baik
Implikasi yang Lebih Luas
Studi kasus ini mengungkap kebenaran yang lebih dalam tentang strategi optimisasi kompiler. Seperti yang dicatat seorang pengembang, LLVM tidak berasumsi bahwa kesetaraan float lebih kecil kemungkinannya daripada kondisi lain, yang dapat menyebabkan pilihan optimisasi yang kurang optimal untuk pola data tertentu. Insiden ini berfungsi sebagai pengingat bahwa optimisasi kompiler bukanlah sihir - mereka adalah algoritma yang membuat tebakan terdidik yang terkadang bisa salah untuk pola kode tertentu.
Diskusi ini juga menyoroti bagaimana pilihan bahasa pemrograman berinteraksi dengan karakteristik perangkat keras. CPU modern sangat kompleks, dan karakteristik performanya dapat menentang intuisi sederhana tentang apa yang merupakan kode teroptimasi. Apa yang bekerja dengan baik pada satu arsitektur atau dengan satu pola data mungkin gagal secara dramatis dalam keadaan yang berbeda.
Menavigasi Jebakan Optimisasi
Bagi pengembang yang menghadapi masalah serupa, komunitas menyarankan beberapa pendekatan. Menggunakan std::hint::unlikely pada cabang yang jarang diambil dapat mempengaruhi keputusan optimisasi. Beberapa menyebutkan bahwa __builtin_expect_with_probability GCC/Clang dengan probabilitas 0,5 dapat memaksa penggunaan conditional move ketika sesuai.
Pelajaran utamanya adalah bahwa optimisasi performa memerlukan pengujian empiris daripada asumsi. Seperti yang dikatakan seorang komentator dengan singkat: Dan inilah mengapa Anda pergi dan melihat assembly di godbolt untuk melihat apa yang sebenarnya terjadi. Kasus ini menunjukkan bahwa bahkan pengembang berpengalaman pun bisa terkejut dengan perilaku kompiler, menekankan pentingnya benchmarking dan analisis tingkat assembly untuk kode yang kritis terhadap performa.
Tim kompiler Rust secara historis menyeimbangkan pilihan optimisasi ini dengan hati-hati, dengan regresi performa masa lalu menunjukkan bahwa conditional moves bisa lebih cepat dalam beberapa benchmark sementara lebih lambat di yang lain. Variabilitas ini menggarisbawahi mengapa tidak ada strategi optimisasi yang cocok untuk semua situasi dan mengapa pengembang kompiler menyediakan beberapa tingkat optimisasi daripada satu pengaturan tercepat.
Dalam lanskap teknologi kompiler dan arsitektur perangkat keras yang terus berkembang, kasus seperti ini berfungsi sebagai pengingat berharga bahwa memahami interaksi antara kode, kompiler, dan CPU tetap penting untuk menulis perangkat lunak yang benar-benar berperforma tinggi.
Referensi: When O3 is 2x slower than O2
