- Kenaikan peringkat Neat dalam benchmark related_post_gen bukan berasal dari optimisasi tingkat tinggi, melainkan dari perubahan kecil pada ABI: meneruskan array sebagai 3 argumen pointer alih-alih struct 24 byte
- Karena penghitung referensi, array Neat membutuhkan pointer berbasis objek array selain pointer awal dan akhir; berbeda dari array D yang berukuran 16 byte, ini masuk ke jalur penerusan lewat memori pada SystemV AMD64 ABI
- Aggregate tertentu yang lebih besar dari 16 byte saat pemanggilan disalin nilainya ke stack lalu diteruskan sebagai pointer, sehingga kehilangan keuntungan penerusan lewat register dan menambah besar biaya shuffle stack
- Dalam benchmark contoh, meneruskan
struct Vector { double x, y, z; } sebagai struct membuat 1 miliar eksekusi memakan waktu 12,3 detik, sedangkan meneruskan field sebagai argumen terpisah menurunkannya menjadi 5,3 detik
- C API harus mengikuti C ABI, tetapi untuk tipe seperti array, tuple, dan sumtype di dalam runtime bahasa, ketika ukurannya melebihi 16 byte, penerusan dengan memisahkan field layak di-benchmark
Bottleneck yang Terlihat di Neat
- Neat naik beberapa peringkat dalam benchmark related_post_gen
- Peningkatan performa ini bukan hasil pass optimisasi tingkat tinggi baru, melainkan akibat mengubah cara penerusan array
- Sebelumnya: argumen struct yang berisi 3 pointer
- Sesudahnya: meneruskan 3 pointer masing-masing sebagai argumen
- Dibandingkan D, Neat lebih lambat dari perkiraan, dan profiler menunjukkan operasi memindahkan area besar di stack untuk pemanggilan fungsi
- Bottleneck-nya lebih dekat ke biaya penataan ulang stack pada saat pemanggilan daripada komputasi itu sendiri
Mengapa Array Neat Menjadi 24 Byte
- Berbeda dari array D, Neat menggunakan penghitung referensi
- Array Neat berisi 3 pointer berikut
- Pointer awal
- Pointer akhir
- Pointer berbasis objek array tempat reference count disimpan
- Tiga pointer berukuran 24 byte, sehingga mengambil jalur berbeda dalam aturan penerusan argumen AMD64 dibanding array 16 byte yang terdiri dari dua pointer
- Alasan array D cepat dan array Neat lambat adalah karena ukurannya menjadi 24 byte dan melewati batas 16 byte
Batas 16 Byte pada SystemV AMD64 ABI
- Spesifikasi SystemV AMD64 ABI menetapkan bahwa jika ukuran aggregate tertentu melebihi dua eightbyte, seluruh argumen diteruskan lewat memori
- Untuk meneruskan struct lewat memori, sisi pemanggil perlu melakukan hal berikut
- Mengalokasikan ruang sebesar ukuran struct di stack
- Mengisi ruang itu dengan nilai yang akan diteruskan
- Meneruskan pointer ke lokasi struct tersebut ke fungsi
- Dalam kasus ini, nilai harus berada di stack, sehingga ruang optimisasi LLVM menjadi lebih kecil
- Nilai harus disalin dari register ke stack, dan perlu juga dilacak bagian stack mana yang sedang digunakan serta bagian mana yang dapat digunakan kembali
- LLVM menunjukkan hasil yang kurang baik dalam pelacakan penggunaan ulang stack ini
Benchmark Vektor 3 double
- Benchmark menggunakan vektor 3 field berbentuk
struct Vector { TYPE x, y, z; };
TYPE didefinisikan sebagai double
- Dua fungsi melakukan penjumlahan yang sama, tetapi cara meneruskan argumennya berbeda
vector_add_struct(struct Vector left, struct Vector right) menerima struct besar sebagai argumen
vector_add_fields(...) menerima left_x, left_y, left_z, right_x, right_y, right_z sebagai argumen terpisah
mode dan panjang eksekusi diterima sebagai argumen command line agar optimizer tidak melakukan constant folding pada seluruh perhitungan
impl.c dikompilasi terpisah untuk menghindari inlining
clang -O3 impl.c -c -o impl.o
clang -O3 harness.c impl.o -o benchmark
time ./benchmark 0 1000000000
time ./benchmark 1 1000000000
- Hasilnya adalah 12,3 detik untuk penerusan struct dan 5,3 detik untuk penerusan field secara terpisah
Perbedaan yang Terlihat di Assembly
- Versi penerusan struct menggunakan banyak instruksi untuk shuffle stack
- Versi field diuntungkan karena parameter sudah berada di register SSE saat masuk ke fungsi
- Versi penerusan struct harus memuat nilai dari stack setiap kali
- SystemV ABI bertujuan meneruskan nilai lewat register sebanyak mungkin, tetapi dalam kasus ini keuntungan tersebut hilang karena struct lebih besar dari 16 byte
- Dengan mempertimbangkan jumlah register yang tersedia di AMD64, penerusan nilai untuk tipe yang lebih besar dari 16 byte pun tampaknya akan berguna
Situasi yang Menjadi Mirip cdecl
- Cara menulis field ke stack lalu meneruskan pointer pada akhirnya menjadi mirip dengan ABI x86 lama
cdecl, yang meneruskan semuanya lewat stack
cdecl dikenal lambat, dan berbagai calling convention dibuat untuk membuatnya lebih cepat
- Masalahnya adalah AMD64 System V ABI, karena ukuran struct, dalam kasus ini berperilaku seperti cara penerusan lewat stack lama tersebut
Pengecualian Inlining dan LTO
- Dalam kode nyata, semua fungsi seperti ini bisa saja di-inline
- Jika LTO diaktifkan di
gcc, perbedaan performa antara kedua versi hilang
- Di
clang, hasil yang sama tidak muncul
- Tidak semua fungsi bisa atau harus di-inline
Saran untuk Implementor Bahasa dan Optimisasi API
- Saat memanggil C API, harus mengikuti C ABI
- Namun tipe tingkat tinggi di dalam bahasa non-C tidak harus direpresentasikan sebagai satu struct, meskipun bagi backend terlihat seperti struct
- Implementor bahasa dapat menentukan sendiri cara meneruskan array, tuple, sumtype, dan sebagainya
- Di Neat, tipe-tipe seperti ini yang lebih besar dari 16 byte dipilih untuk diteruskan sebagai field terpisah, dan benchmark menunjukkan manfaatnya
- Jika mengimplementasikan bahasa di AMD64 atau melakukan micro-optimization API, perlu mem-benchmark apakah membagi struct yang lebih besar dari 16 byte secara manual dapat membantu
- Khususnya dalam inner loop, perbedaan performanya bisa lebih besar dari perkiraan
Tambahan: Struct double dan SSE
- Pertanyaannya adalah mengapa struct tidak diteruskan lewat register SSE, padahal
double menurut spesifikasi termasuk kelas SSE
- Jawabannya adalah tidak diketahui alasannya, tetapi dalam praktiknya memang tidak diteruskan seperti itu
1 komentar
Komentar Hacker News
Masalahnya di sini adalah SysV amd64 ABI. ABI internal bahasa tidak harus memakai SysV. Kalau tidak diekspos ke pemanggil C SysV, boleh saja memakai konvensi pemanggilan apa pun yang diinginkan
https://llvm.org/docs/LangRef.html#calling-conventions
Untuk yang penasaran, perubahan terkait di neatlang ada di sini: https://github.com/Neat-Lang/neat/commit/f4ba38cefc1e26631a5...
Kelihatannya jauh lebih rumit daripada sekadar mengubah output konvensi pemanggilan LLVM. Mungkin penulis ingin mengekspos tipe-tipe ini ke program C dengan konvensi pemanggilan yang deterministik
Jangan mengikuti kebiasaan secara membabi buta; pilih saja cara yang paling masuk akal untuk situasi tertentu
Khususnya ABI sering condong ke kompatibilitas dengan CPU yang lebih tua, sementara CPU yang lebih baru mungkin punya ruang perbaikan dengan memakai fitur seperti register ekstensi tanpa perlu mengurangi ukuran struct. Software yang disesuaikan untuk hardware atau generasi tertentu jadi tidak bisa dipakai di sebagian mesin, jadi mungkin tidak terlalu menarik, tetapi akan keren kalau ada compiler yang bisa menghasilkan output seperti itu saat kita ingin mengoptimalkan kode secara ekstrem sesuai kemampuan hardware sistem sendiri
Biaya penerusan argumen sering kurang dipahami, jadi tulisan seperti ini menyenangkan. Bahkan di tempat seperti Google, objek 24 byte sering diteruskan by value, dan biayanya tersebar luas di semua fungsi sehingga sulit tampak di profiler
Namun hambatan seperti ini juga terekspos: https://github.com/ziglang/zig/issues/5973#issuecomment-1330...
Satu-satunya pengecualian yang terpikir adalah string_view
Kalau kurang beruntung, bisa terjadi cache miss dan harus menunggu sekitar 100 nanodetik untuk mengambil objek 24 byte dari memori utama. Kalau objek yang sama diteruskan langsung, ia berada di stack sehingga lebih mungkin ada di cache
Saat pertama beralih ke x64, saya sangat mengkhawatirkan objek vec3 di grafis (3 float) yang ukurannya membesar dari sizeof()=12 menjadi 16 byte, jadi saya melakukan banyak benchmark pada engine grafis
Tidak mengherankan, berkat alignment pembacaan 8 byte, 16 byte lebih cepat daripada 12 byte. Itu berlaku baik secara internal maupun di GPU. Jadi vec3 diam-diam menjadi vec4, dan vec4 terpisah tetap ada. Seperti biasa, yang perlu dilakukan adalah benchmark dari sudut pandang keseluruhan, bukan benchmark lokal
Untuk 16 byte, saya paham banyak akses bisa menjadi 2x8 byte yang aligned atau 1x16 byte, bukan 3x4 byte. Namun pada akses lain mungkin tidak begitu, dan ada juga masalah peningkatan tekanan cache
Secara akal sehat, nilai yang diteruskan lewat register bisa dimuat lebih awal berkat speculative execution sehingga lebih cepat daripada penulisan ke stack, dan manipulasi stack lebih cepat daripada alokasi heap
Karena itu kode spaghetti kotor dengan banyak variabel global bisa sangat cepat, sementara fungsi rekursif yang elegan atau argumen tuple/struct/list bisa luar biasa lambat. Yang pertama jauh lebih mudah dioptimalkan menjadi loop assembly yang rapat
Kalau kode elegan O(n) dan kode spaghetti O(n^2), perbedaannya bisa terasa. Maintainability juga perlu dipertimbangkan. Dalam satu arti, compiler ada untuk mengubah solusi elegan kita menjadi kode spaghetti
Objek global juga menghambat optimisasi compiler
Sebagai catatan, di MSVC, ukuran cutoff-nya 8 byte sebelum struct diteruskan lewat stack. Ini detail ABI, jadi jangan bergantung padanya dalam kode portabel
Namun kalau fungsinya tidak sering dipanggil, tidak perlu terlalu stres. Kalau fungsinya kecil dan sering dipanggil seperti contoh, buat compiler bisa melakukan inline pada kode, misalnya dengan LTO. Dengan begitu, optimisasi yang terbuka jauh lebih berguna daripada meneruskan argumen lewat register
Tulisan seperti ini saya golongkan sebagai “pengetahuan yang pas untuk jadi sumber masalah”. Walau mengikuti instruksinya dan mengompilasi secara terpisah untuk memaksa compiler membuat fungsi yang bisa dipanggil lewat ABI, LTO bisa membalikkan kesalahan ini.
Jika program ini dibangun dengan LTO, pada kedua mode ia menjadi jauh lebih cepat daripada mode mana pun pada program tanpa LTO. Kalau programnya sensitif terhadap performa, lakukan profiling, optimalkan bottleneck sampai batasnya, baru setelah itu commit hal seperti membongkar struct menjadi argumen-argumen
Ini berlaku untuk hampir semua kode yang dihasilkan compiler. Valgrind mungkin bisa mengukurnya, tetapi profiler berbasis sampling mungkin tidak, dan tidak ada alat yang menyoroti masalah code generation yang tersebar
Dalam cdecl calling convention bawaan Windows, struct yang lebih besar dari 8 byte tidak diteruskan lewat register [1]
[1]: https://learn.microsoft.com/en-us/cpp/build/x64-calling-conv...
Di amd64, sekalipun memakai SysV amd64 ABI, meneruskan dan mengembalikan struct yang lebih besar dari 16 byte by value sepenuhnya mungkin. Hanya saja lambat.
Meski begitu, sering kali pass-by-value layak dilakukan demi membuat kode lebih jelas. Tentu bukan dalam kasus ini, tetapi seperti yang ditunjukkan loeg, di dalam bahasa sendiri bisa memakai ABI kustom seperti compiler C++, Go, OCaml, dan SBCL
Pada contoh yang diberikan, ini bisa diperbaiki tanpa memengaruhi pemanggil dengan mengubah tipe parameter dari “struct Vector” menjadi “const struct Vector &” dan melakukan pass-by-reference.
Saya sering melihat banyak kode C++ yang punya bug pointer memakai pointer tanpa perlu, padahal pass-by-reference sudah cukup serta lebih mudah dan aman dipakai
Karena ABI mengatakan nilai harus diteruskan sebagai pointer, nilainya harus disimpan di suatu tempat untuk mendapatkan pointer, dan hal yang sama terjadi seperti ketika menuliskan const-ref secara eksplisit. Jika nilai struct diubah menjadi argumen terpisah, argumen bisa diteruskan lewat register
Saya selalu agak berasumsi LLVM akan membersihkan hal seperti ini dengan baik, tetapi ternyata tidak
Manfaat yang dijelaskan adalah sepenuhnya menghindari serialisasi dari register ke stack, dan tampaknya itu tidak bisa dihindari dengan pass-by-reference
Jika C++ diperbolehkan, ada lebih banyak opsi seperti argumen move untuk mengurangi penyalinan
Rule of thumb yang selalu saya dengar di C++ adalah: apa pun yang bukan tipe primitif sebaiknya diteruskan by reference kecuali ada alasan bagus untuk meneruskannya by value, dan jika benar-benar perlu barulah diteruskan sebagai pointer.
Ini karena ABI dan juga untuk menghindari copy constructor atau move constructor. Detail level rendah yang membosankan, tetapi kalau menginginkan performa tertinggi di C++, ini bagian yang harus diperhatikan. Jelasnya, ini hanya optimasi performa; kode yang meneruskan struct tetap berjalan benar, hanya saja kurang cepat