2 poin oleh GN⁺ 2024-04-20 | 1 komentar | Bagikan ke WhatsApp

Tulisan ini menjelaskan secara rinci cara meningkatkan Calling Convention pada bahasa Rust.

Masalah pada Calling Convention Rust saat ini

  • Saat ini Rust tidak memiliki calling convention yang didefinisikan secara jelas
  • Dalam praktiknya, Rust memakai calling convention C bawaan dari LLVM
  • Saat ini Rust secara konservatif berusaha menghasilkan signature fungsi LLVM seperti yang kemungkinan akan dihasilkan Clang
    • demi kompatibilitas dengan debugger
    • untuk menghindari bug LLVM
  • Namun pendekatan ini terlalu konservatif, sehingga menghasilkan kode yang buruk bahkan untuk fungsi sederhana
fn extract(arr: [i32; 3]) -> i32 { arr[1] }
  • Kode di atas seharusnya diteruskan lewat register, tetapi malah diteruskan lewat pointer
  • Rust bahkan lebih konservatif daripada ABI C. Jika ditandai dengan extern "C", nilainya akan diteruskan lewat register.

Usulan calling convention baru

  • Untuk fungsi extern "Rust", calling convention yang lama tetap dipertahankan
  • Tambahkan flag -Zcallconv untuk mengatur calling convention fungsi extern "Rust"
    • -Zcallconv=legacy adalah cara saat ini
    • -Zcallconv=fast adalah cara baru yang akan dirancang
  • Mengapa calling convention lama harus dipertahankan?
    • demi kemudahan debugging, tata letaknya tidak mengikuti urutan ABI C
    • beberapa target seperti WASM mungkin tidak mendukungnya
    • pada build debug, hal ini bisa jadi tidak ada gunanya
  • Hal-hal yang perlu diperhatikan terkait function pointer dan blok extern "Rust" {}
    • Karena ini adalah flag per-crate, function pointer tidak bisa menggunakannya
    • Pemanggilan lewat function pointer lambat dan jarang, jadi memakai -Zcallconv=legacy
    • Jika perlu, buat shim untuk mengonversi calling convention
    • Untuk pemanggilan langsung seperti extern "Rust" { fn my_func() -> i32; }
      • hanya simbol yang tidak di-mangle yang bisa dipanggil
      • fungsi #[no_mangle] memakai calling convention lama

Cara memanfaatkan LLVM

  • Idealnya kita bisa menentukan calling convention langsung di LLVM, tetapi secara realistis itu sulit
  • Bisa diakali dengan prosedur berikut
    • untuk target tertentu, tentukan jumlah maksimum nilai yang bisa diteruskan lewat register
    • tentukan bagaimana return value akan diteruskan. Jika muat di register, kembalikan langsung; jika besar, teruskan lewat referensi
    • dari argumen yang diteruskan sebagai nilai, pilih mana yang harus diteruskan sebagai referensi
      • yang lebih besar daripada ruang yang bisa diteruskan lewat register
      • pada x86, sekitar 176 byte
    • tentukan argumen mana yang diteruskan lewat register agar pemakaian ruang register semaksimal mungkin
      • ini adalah masalah NP-hard sehingga butuh heuristik
      • sisanya diteruskan lewat stack
    • buat signature fungsi dalam LLVM IR
      • argumen yang diteruskan lewat register direpresentasikan sebagai non-aggregate seperti i64, ptr, double, <2 x i64> dan sejenisnya
      • argumen yang diteruskan lewat stack mengikuti "register input"
    • buat function prologue
      • decode argumen level Rust dari register input sehingga menghasilkan nilai %ssa yang sama seperti pada -Zcallconv=legacy
      • body fungsi bisa menghasilkan kode yang sama terlepas dari calling convention
      • kode decode yang tidak perlu akan dihapus oleh DCE
    • buat blok return fungsi
      • berisi instruksi phi untuk tipe return yang sama seperti pada -Zcallconv=legacy
      • encode ke format output yang diperlukan lalu kembalikan dengan ret
      • alih-alih ret, alur harus bercabang ke blok ini
    • jika ada fungsi non-polimorfik dan non-inline yang bisa dipakai sebagai function pointer
      • ketika diekspos ke luar crate atau diteruskan sebagai function pointer
      • buat shim yang memakai -Zcallconv=legacy lalu melakukan tail call ke implementasi sebenarnya
      • ini diperlukan untuk mempertahankan kesetaraan function pointer

Cara memeriksa batas penerusan register di LLVM

  • Ada program LLVM untuk memeriksa jumlah maksimum penerusan lewat register yang diizinkan LLVM
  • Pada x86, input bisa berupa 6 integer dan 8 vektor SSE, sedangkan output bisa berupa 3 integer dan 4 vektor SSE
  • Pada aarch64, input dan output sama-sama 8 integer dan 8 vektor
  • Jika melampaui batas ini, nilai akan diteruskan lewat stack

Penanganan struct dan enum di Rust

  • Diasumsikan rustc sudah mengubahnya menjadi aggregate dan union dasar
  • Penanganan return value
    • yang penting bukan ukuran struct, melainkan ukuran data sebenarnya tanpa padding
    • [(u64, u32); 2] berukuran 32 byte, tetapi jika padding 8 byte dikeluarkan maka data nyatanya 24 byte
    • definisikan ukuran efektif (Effective Size) suatu tipe
      • yaitu jumlah bit terdefinisi, tidak termasuk padding
      • [(u64, u32); 2] adalah 192 bit
      • bool adalah 1 bit
    • jika ukuran efektif lebih kecil daripada ruang register output, kembalikan sebagai nilai
    • pada x86, 3 integer + 4 SSE = 88 byte = 704 bit
  • Penanganan register argumen
    • ini adalah masalah Knapsack yang bersifat NP-hard
    • heuristik sederhana
      • jika ukuran efektif lebih besar daripada seluruh ruang register input, teruskan sebagai referensi
      • enum diganti menjadi pasangan discriminator-union
      • union bisa menyentuh bit yang tidak terinisialisasi, jadi diteruskan sebagai array u8 atau satu varian tak-kosong
      • ratakan menjadi elemen paling dasar seperti pointer, integer, float, boolean, dan sejenisnya
      • urutkan naik berdasarkan ukuran efektif
      • alokasikan prefiks terbesar yang masih muat ke register, sisanya ke stack
      • jika sebagian input yang menuju stack lebih besar daripada kelipatan kecil dari ukuran pointer, teruskan lewat pointer ke stack
      • selebihnya diteruskan langsung ke stack dalam urutan sebelum pengurutan
      • yang diteruskan lewat register dialokasikan dalam urutan ukuran menurun
      • boolean di-bit-pack per 64 buah

Pendapat GN+

  • Secara pribadi, calling convention Rust saat ini sangat disayangkan. Seharusnya Rust bisa menghasilkan performa yang jauh lebih baik daripada C++, tetapi itu belum terjadi
  • Ini adalah pendekatan yang sudah lama diimplementasikan oleh Go
  • Alasan Rust belum bisa menerapkannya
    • pembuatan kode ABI rumit dan LLVM tidak banyak membantu
    • tidak banyak orang di tim compiler yang benar-benar paham LLVM
    • ada kekhawatiran soal waktu kompilasi, tetapi karena hanya akan dipakai pada build optimisasi, mestinya bukan masalah besar
  • Penulis tidak punya waktu untuk memperbaikinya sendiri, tetapi bersedia membantu tim compiler Rust dengan keahliannya di LLVM
  • Atau, sekalian saja beralih ke extern "C" atau extern "fastcall" juga bisa menjadi alternatif

1 komentar

 
GN⁺ 2024-04-20
Opini Hacker News

Ringkasan:

  • Saat membuat calling convention yang dioptimalkan, penting untuk mengukur performa secara langsung. Kode yang terlihat aneh bisa jadi justru yang paling cepat.
  • CPU modern mengoptimalkan jejak instruksi yang dihasilkan compiler C, sehingga sering meneruskan lewat stack seperti compiler C bisa membantu.
  • Inlining sangat berhasil sehingga pemanggilan menjadi batas yang jarang terjadi, jadi sedikit ketidakteraturan di batas itu bisa ditoleransi demi menyederhanakan hal lain.
  • Struct di Rust harus dapat menyediakan referensi ke field, sehingga ukurannya bisa lebih besar daripada di C. Struct dengan 8 field Option<u8> berukuran 16 byte di Rust, sedangkan di C 9 byte.
  • Di Rust, implementasi yang setara dengan C bisa dibuat secara manual, tetapi tidak bisa dipetakan ke &Option<T> atau &mut Option<T>.
  • Rust masih belum memiliki calling convention untuk semantik level Rust. Apple punya motivasi untuk membangunnya, tetapi Rust tidak memiliki dukungan seperti itu.
  • Interoperabilitas antara Go dan Rust saat ini bisa dicapai dengan menggunakan Zig sebagai perantara.
  • Compiler Rust saat ini melakukan inlining dan optimisasi yang agresif, sehingga dipertanyakan apakah masalah ini layak untuk diselesaikan.
  • Untuk debugging, kekhawatiran ini bisa dihindari dengan menggunakan flag di Cargo.toml. Mengurutkan field berdasarkan ukuran adalah optimisasi yang mudah, dan bisa dimatikan dengan repr.