- Dengan ditambahkannya
musttailke Clang, bahasa keluarga C kini dapat memanfaatkan tail call yang dijamin, dan penerapannya pada parser protobuf mendemonstrasikan performa di atas 2GB/dtk - Intinya adalah membuat pemanggilan fungsi lebih mirip
jmpdaripadacall, sehingga penggunaan stack pada pemanggilan beruntun turun dari O(n) menjadi O(1) dan dapat diperlakukan seperti loop iteratif - Protobuf wire format harus menginterpretasikan tag/nilai dan bercabang ke field dalam urutan arbitrer, sehingga struktur
while+switchtradisional memiliki masalah optimisasi yang mirip dengan dispatch opcode interpreter - Parser eksperimental upb menghubungkan fungsi-fungsi parser kecil dengan tail call, alih-alih memakai satu fungsi besar, sehingga pada jalur cepat ia menghindari penggunaan stack, spill register, prolog/epilog
- Pendekatan ini memiliki batasan: kualitas kode memburuk tajam jika tercampur non-tail call, dan
musttailadalah ekstensi non-standar; karena itu, untuk merilis parser cepat secara nyata diperlukan disiplin pemanggilan dan langkah portabilitas
Parsing protobuf berkecepatan tinggi yang dibuka oleh musttail Clang
- Atribut pernyataan
[[clang::musttail]]/__attribute__((musttail))ditambahkan ke branch main Clang, sehingga C, C++, dan Objective-C dapat memperoleh jaminan tail call - Di sini tail call tidak dipakai sebagai teknik pemrograman fungsional, melainkan sebagai alat optimisasi untuk menurunkan biaya branching pada parser dan interpreter
- Hasil penerapan teknik ini pada parsing protobuf dalam
upbpull/310 mendemonstrasikan performa parsing di atas 2GB/dtk- Diperkenalkan sebagai hasil yang lebih dari dua kali lebih cepat dibanding tingkat terbaik sebelumnya
- Karena beberapa teknik berkontribusi bersama, interpretasi bahwa “tail call saja membuatnya 2x lebih cepat” tidak tepat
- Tail call adalah salah satu elemen kunci yang memungkinkan peningkatan performa ini
- Perubahan setelahnya dibahas dalam A Tail Calling Interpreter For Python (And Other Updates)
Mengapa tail call berperilaku seperti struktur iteratif
- Tail call adalah pemanggilan fungsi terakhir yang dilakukan tepat sebelum sebuah fungsi mengembalikan nilai
- Jika optimisasi tail call diterapkan, compiler menghasilkan instruksi
jmpalih-alihcallbiasa- Melewati pembuatan stack frame baru atau penyimpanan return address
- Caller
f()langsung melompat ke calleeg() g()langsung kembali ke fungsi yang memanggilf()
- Berkat sifat ini, tail call dapat menggantikan struktur iteratif
- Bahkan pada
ntail call beruntun, penggunaan stack turun dari O(n) menjadi O(1) - Overhead
callhilang, sehingga pemanggilan fungsi dapat diperlakukan seperti branch biasa
- Bahkan pada
- Ide ini bukan hal baru; akarnya dapat ditelusuri hingga makalah Guy Steele tahun 1977 dan “Lambda Papers” antara 1975–1980
- Clang sebelumnya sudah dapat mengoptimalkan tail call pada build optimisasi seperti
-O2, tetapi perilaku lama lebih mendekati best-effort- Pada build tanpa optimisasi, besar kemungkinan ia dikompilasi menjadi
callsungguhan - Agar tail call aman digunakan sebagai struktur iteratif, optimisasi harus dijamin di semua mode build
musttailmenyediakan jaminan ini
- Pada build tanpa optimisasi, besar kemungkinan ia dikompilasi menjadi
Bottleneck yang sama pada loop interpreter dan parser protobuf
- Mike Pall dari LuaJIT menulis interpreter LuaJIT 2.x dalam assembly, bukan C, dan menganggapnya sebagai salah satu alasan utama interpreter tersebut cepat
- Compiler C menghadapi dua masalah khususnya pada loop utama interpreter
- Semakin besar fungsi dan semakin kompleks control flow, semakin sulit bagi register allocator untuk mempertahankan data penting di register
- Jika jalur cepat dan jalur lambat bercampur dalam fungsi yang sama, jalur lambat ikut menurunkan kualitas kode jalur cepat
- Protobuf wire format juga memiliki struktur yang mirip interpreter
- Wire format adalah rangkaian pasangan tag/nilai
- Tag memuat nomor field dan wire type
- Tag berperilaku mirip opcode yang memberi tahu cara mem-parse data field tersebut
- Nomor field dapat muncul dalam urutan arbitrer, sehingga kode harus siap melakukan dispatch ke bagian mana pun
- Parser protobuf tradisional biasanya memakai struktur
switchdi dalam loopwhile, dan selama sebagian besar masa keberadaan protobuf, pendekatan ini digunakan sebagai tingkat terbaik - Dalam parsing nyata, pengecualian seperti ketidakcocokan wire type, data rusak, atau mencapai akhir buffer dapat terjadi hampir di setiap tahap
- Jalur cepat harus dijaga sesingkat dan sestabil mungkin
- Untuk kasus sulit, diperlukan kode fallback yang lebih besar dan kompleks, dan terkadang juga melakukan pemanggilan fungsi out-of-line
Desain parser upb berbasis tail call
- Parser eksperimental upb tidak memakai satu fungsi parsing besar, melainkan memisahkan tiap operasi menjadi satu fungsi kecil
- Tiap fungsi memanggil operasi berikutnya dengan tail call
- Berkat calling convention x86-64, argumen parsing bersama diteruskan melalui register
- Semua fungsi parsing memakai set argumen yang sama, sehingga perpindahan nilai antar-panggilan berkurang
- Fungsi parser field fixed-width 4 byte pada contoh bekerja dengan alur berikut
- Mendekode informasi field dari
data - Jika wire type tidak cocok, melakukan
MUSTTAIL returnkefallback() - Melewati tag dan menyimpan data ke pesan
- Setelah membaca tag berikutnya, melakukan tail call ke
dispatch()yang bercabang ke parser field yang sesuai
- Mendekode informasi field dari
- Assembly yang dihasilkan Clang tidak memiliki prolog, epilog, spill register, maupun penggunaan stack pada jalur cepat
- Titik keluar hanya berupa
jmpkefallbackataudispatch - Karena argumen sudah berada di register yang benar, tidak diperlukan kode penerusan parameter tambahan
- Titik keluar hanya berupa
- Struktur ini secara konseptual memandang loop interpreter besar sebagai satu fungsi kompleks, tetapi implementasi nyatanya dipecah menjadi fungsi-fungsi tingkat basic block dan meneruskan control flow lewat tail call
- Jika jalur cepat dan jalur lambat dipisahkan ke fungsi berbeda, kemungkinan perubahan kode fallback menggoyahkan kualitas kode jalur cepat menjadi lebih kecil
- Jika perlu, inlining dapat dicegah dengan
noinline - Urutan assembly pada jalur cepat pada praktiknya dapat dipatok
- Jika perlu, inlining dapat dicegah dengan
Kualitas code generation C yang terlihat pada contoh LuaJIT
- Jika pola yang sama diterapkan pada contoh LuaJIT, hasil yang mendekati assembly tulisan tangan dapat diperoleh dari kode C
- Fungsi contoh
ADDVNmelakukan operasi berikut- Mengekstrak register dan indeks konstanta dari instruksi
- Jika type check gagal, pindah ke fallback
- Menambahkan konstanta ke nilai register
- Membaca opcode berikutnya dan melakukan tail call ke fungsi dalam tabel opcode
- Pada assembly yang dihasilkan, perbaikan yang tersisa relatif kecil
- Ada
jmpterpisah setelah conditional branch - Alih-alih
jmp qword ptr [rsi + 8*rax], ia memuat keraxlalu memakaijmp rax
- Ada
- Bagian seperti ini diperlakukan sebagai isu code generation kecil yang dapat diperbaiki di Clang
Batasan berupa non-tail call dan portabilitas
- Hal paling penting yang perlu diperhatikan dalam pendekatan ini adalah kualitas assembly memburuk tajam jika ada non-tail call di dalam fungsi
- Satu non-tail call memaksa pembuatan stack frame
- Banyak data dapat di-spill ke stack
- Untuk menghindarinya, diperlukan disiplin untuk meng-inline pemanggilan fungsi lain atau hanya melakukannya sebagai tail call
- Pada parsing protobuf, penanganan varint adalah salah satu kesulitan utama
- Kasus umum dan cepat adalah varint 1 byte
- Varint yang lebih panjang bukanlah error, tetapi kasus yang jarang
- Jika penanganan pengecualian ini di-inline, kualitas kode jalur cepat dapat memburuk
- Jika melakukan tail call ke fungsi fallback, setelah penanganan selesai sulit untuk melanjutkan operasi semula, sehingga fallback harus menangani operasi sampai tuntas
- Akibatnya muncul duplikasi kode dan kompleksitas
- Pembaruan 2025-01-27 menambahkan cara untuk meredakan masalah ini lewat calling convention
__attribute__((preserve_most))adalah calling convention yang dapat digunakan untuk fungsi fallback; ia memindahkan tanggung jawab mempertahankan hampir semua register ke callee sehingga biaya spill bergeser ke sisi fallback- Bug crash Clang terkait atribut ini diperbaiki pada 2023
__attribute__((preserve_none))adalah calling convention yang dapat digunakan untuk fungsi tail calling; ia menghilangkan beban mempertahankan register dan memakai lebih banyak register untuk argumen- Di antara kedua cara tersebut,
preserve_nonedinilai sebagai pilihan yang lebih baik karena kurang intrusif
- Batasan lain adalah
musttailmerupakan ekstensi compiler non-standar- Diharapkan menyebar ke GCC, Visual C++, dan lainnya serta distandarkan, tetapi itu bukan hal yang dekat
- Tanpa
musttail, setidaknya satureturnsungguhan diperlukan untuk setiap iterasi loop konseptual - Fallback ini belum diimplementasikan di upb, dan diperkirakan diperlukan makro yang melakukan tail call ke dispatch atau sekadar return, bergantung pada ketersediaan
musttail
Status penerapan upb dan kemungkinan perluasan
- Parser di atas 2GB/dtk diajukan ke
upb, sebuah library protobuf kecil yang ditulis dalam C - Kode tersebut berfungsi sepenuhnya dan lulus semua protobuf conformance test, tetapi pada saat penulisan belum di-rollout ke mana pun
- Desain ini belum diimplementasikan pada protobuf versi C++
- Setelah itu,
upbdiperbarui agar menggunakanmusttail, sehingga satu hambatan besar untuk memproduksikan parser cepat dihapus - Teknik yang sama juga dapat memberi keuntungan performa besar bagi interpreter bahasa utama yang ditulis dalam C, seperti Python, Ruby, PHP, dan Lua
1 komentar
Pendapat di Hacker News
Ada sintaks dalam proposal standar C untuk tail call, bentuknya
return goto (expression);Hal yang saya sukai dibanding
[[musttail]]standar adalah adanya jaminan bahwa masa hidup objek lokal berakhir. Jadi ini bisa diimplementasikan tanpa escape analysis yang luas[0] https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3266.htm#5...
return gotolebih mudah diimplementasikan.[[musttail]]juga, sekilas, tampaknya mengakhiri masa hidup objek lokalSetelah saya baca cepat, tertulis bahwa fungsi yang dipanggil pada posisi tail harus memiliki tipe yang sama dengan target panggilan. Ini adalah syarat untuk memastikan tidak perlu konversi nilai balik, dan agar ruang penerusan argumen serta calling convention tetap terjaga
Keluhan yang sering saya lihat tentang
[[musttail]]yang saya implementasikan di Clang adalah bahwa batasan ini terlalu ketat tanpa perlu. Beberapa arsitektur mengizinkan tail call meski tipenya tidak sepenuhnya cocok: https://github.com/llvm/llvm-project/issues/54964Memang benar kalau dikatakan “kalau begitu kodenya jadi tidak portabel”, tetapi optimisasi tail call itu sendiri pada dasarnya tidak portabel. Misalnya, beberapa target secara fundamental tidak mendukung optimisasi tail call, seperti WASM yang tidak memiliki ekstensi tail call
Saya antusias karena ada perubahan dan tambahan yang memang perlu masuk, bahkan ide-ide yang perlu diperjelas, tetapi siklus pembaruan C++ yang agresif pada akhirnya terasa seperti menambal benjolan di atas benjolan lain
Masalahnya terutama ketika fitur-fitur saling berinteraksi buruk jauh lebih awal dari yang diperkirakan. Saya berharap proses standardisasi tidak hanya bersandar pada dokumen pembenaran, tetapi memilih dengan sangat konservatif sambil menguji fitur secara memadai pada codebase yang besar dan beragam
Jika tertarik pada sisi Rust, ada RFC lama untuk menambahkan keyword
becomeyang menyediakan optimisasi tail call yang dijaminAwalnya ditunda agar fokus pada target edisi 2018, dan keputusan itu tepat, tetapi gagasan ini belakangan ditinjau kembali. Bisa saja kembali lagi
[0]: https://github.com/rust-lang/rfcs/pull/1888
[1]: https://github.com/rust-lang/rfcs/pull/3407
Cara interpreter biasanya mendapatkan peningkatan kecepatan seperti ini di C++ adalah dengan memakai computed goto. Dengan begitu, tidak ada noise terkait calling convention pada jalur dari satu opcode ke opcode berikutnya
Alasan utama pendekatan computed goto atau tail call lebih cepat daripada loop
switchklasik adalah karena mengurangi beban branch predictor. Secara statis, tiap opcode memiliki satu indirect branch, bukan struktur yang secara statis hanya memiliki satu indirect branchJika tiap fungsi kecil dan menerima variabel penting sebagai argumen, alokasi register menjadi jauh tidak serapuh itu
Namun saya penasaran apakah pernyataan itu tetap berlaku ketika ukuran interpreter membesar
Masalah yang tersisa dalam memakai tail call untuk perpindahan konteks adalah bahwa kita menggunakan fungsi-fungsi yang harus memakai calling convention. Sayangnya, register terbuang untuk memulihkan status saat fungsi berakhir
Analisis lebih rinci dan alternatif menggunakan compiler perantara ada di blog remake LuaJIT: https://sillycross.github.io/2022/11/22/2022-11-22/
Seperti semua hal lain dalam ilmu komputer, ketika keseimbangan biaya menurut jenis operasi berubah, algoritma terbaik bisa kembali ke cara yang dipakai 15 atau 20 tahun lalu. Karena itu, pemrograman sering tampak seperti tren. Menghidupkan kembali sesuatu bukan berarti tanpa alasan, tetapi melupakan alasan kenapa dulu itu bukan obat mujarab tetap menjadi masalah
Jika JIT utama menjadi lebih cepat atau lebih lambat, manfaatnya relatif terhadap biaya eksekusi berubah, dan ambang batas pemicunya juga disesuaikan. Lalu jumlah kode yang berjalan di lapisan lain berubah, dan biaya teramortisasi lapisan itu juga bisa memburuk. Rasanya seperti menyeimbangkan pendulum ganda
Jika lapisan JIT bisa dibuat cukup cepat dan kasar, interpreter bisa dilewati sama sekali. Dari luar, beban kognitif untuk menyeimbangkan pembukuan antara interpreter dan kira-kira dua JIT terlihat besar, sehingga beberapa bahasa tampaknya menahan interpreter dan memakai JIT yang dioptimalkan untuk waktu kompilasi, bukan kecepatan output
Saya tidak ingat bahasa apa, tetapi setahu saya setidaknya satu tim akhirnya juga menghapus compiler perantara karena masalah keseimbangan ini. Lebih baik fokus pada dua hal daripada menangani tiga
Namanya selalu membuat saya bingung, mungkin
preserve_allataupreserve_none. Masalahnya adalah perspektif siapa yang dipakai untuk “preserve”Saya memahami atribut
musttailsedang dalam proses ditambahkan ke GCC. Patch-nya sedang ditinjau, dan semantiknya kompatibel dengan Clangpreserve_most. Apakah ada kemungkinan sesuatu yang mirip masuk ke GCC? Tanpa ini, panggilan non-tail akan merusak interpreterClang tampaknya punya heuristik yang mengubah urutan pemanggilan untuk panggilan
musttail. Misalnya di i686, ia mengubahnya menjadi panggilannoplt. Hal semacam ini tidak ada di dokumentasi Clang: https://clang.llvm.org/docs/AttributeReference.html#musttailSecara realistis, yang mungkin dilakukan adalah compiler mengeluarkan pesan diagnostik ketika tidak bisa menghasilkan tail call. Bagi banyak pengguna, kemungkinan itu sudah cukup. Jaminan tail call seperti di Scheme tampaknya sulit terjadi
Dukungan C++ juga disebutkan, tetapi di C++ sepertinya tail call akan sangat sedikit
Misalnya
foo() { auto a = SomeClassWithADestructor(); return bar(); }bukan tail call, karena destruksiaterjadi setelah pemanggilanbar()bar?Saya penasaran apakah standar C++ memang mengharuskan destructor dipanggil di akhir blok, atau boleh dipanggil segera setelah variabel tidak lagi digunakan
Mungkin contohnya terlalu sederhana, tetapi
__attribute__((musttail))tampaknya tidak benar-benar diperlukan untuk menghasilkan kode yang bagusJika fungsi penanganan error adalah jalur yang jarang dilalui, kecepatan pemanggilannya juga sepertinya tidak terlalu penting
Struktur seperti
if (unlikely(malformed)) return error(); switch (data_type) { case x: return handle_x(); case y: return handle_y(); }tampaknya cukup andal menghasilkan jump table yang bagusKalau tidak, struktur ini tidak akan berjalan dan stack langsung meledak. Inti dari
[[musttail]]adalah bahwa eliminasi tail call itu wajib. Compiler tidak punya pilihan lainTentu saja, ungkapan “memaksa” mungkin tidak sepenuhnya tepat. Tidak ada ketentuan bahwa compiler harus memiliki struktur stack frame tunggal untuk semua jalur eksekusi fungsi, dan juga tidak ada ketentuan bahwa fungsi internal linkage atau fungsi namespace anonim yang alamatnya tidak diambil harus memakai ABI standar. Namun semua compiler yang pernah saya lihat, termasuk Clang, pada praktiknya melakukan itu. Jadi kita butuh cara untuk memberi tahu compiler agar tidak mengkhawatirkan ABI dan tidak membuang waktu mempertahankan register antar-pemanggilan
Jump table tentu saja dibuat dengan baik. Namun jika hasilnya dijalankan melalui sesuatu seperti
perf reportdan bytecode uji tidak merepresentasikan loop pendek, Anda akan melihat salah satu dari dua hal. Entah ada branch misprediction di setiap dispatch, atau compiler berpikir “sepertinya ini mau memakai interpreter” lalu memindahkan indirect jump ke akhir setiap case. Saya pernah melihat hal seperti ini di Clang. Dalam kedua kasus, alokasi register pada kode yang dihasilkan kemungkinan besar akan cukup burukSaya penasaran seberapa cepat trampoline, yaitu cara yang mengembalikan fungsi berikutnya sebagai function pointer lalu memanggilnya dari loop luar. Keuntungannya adalah ini C yang portabel
Bahasa pemrograman Scheme mengharuskan semua tail call tidak menambah stack. Karena itu, para implementor telah mengeksplorasi berbagai teknik, termasuk trampoline
Saya tidak punya referensi untuk dikutip, tetapi jawabannya bisa ditemukan dalam makalah-makalah tentang kompilasi Scheme ke C. Jika bahasa target tidak punya jaminan optimisasi tail call, program yang dihasilkan akan menjadi lambat
Selain itu, inilah alasan khususnya para implementor bahasa tingkat tinggi tidak senang ketika optimisasi tail call dihapus dari spesifikasi JavaScript. Ada juga solusi yang mempertahankan optimisasi tail call sekaligus stack inspection
https://github.com/schemedoc/bibliography/blob/master/page8....
Jika melompat lewat function pointer, kemungkinan itu tidak bisa diprediksi sejauh itu, dan sulit mendapatkan keuntungan yang sama
Tentu saja harus diukur, dan saya sendiri belum mencobanya
Saya pernah menulis decoder/encoder Protobuf dan parser IML dalam C, serta binding Python, dan ada beberapa hal yang ingin saya katakan tentang pengukuran kecepatan parsing
Jika pustaka ini hanya disediakan sebagai binding untuk bahasa terkelola, ada variabel tambahan yang bisa mengungguli semua hal lain dari sisi performa. Saya tidak tahu soal Ruby atau PHP, tetapi di Python saya melihat peningkatan kecepatan yang drastis ketika tidak memakai enumerator. Jika enumerator Protobuf dikonversi menjadi enumerator Python, keuntungan apa pun yang bisa diperoleh dari kode C akan tertindih oleh waktu pembuatan berbagai objek Python. Perbedaannya bisa mencapai beberapa digit orde besaran. Lebih jauh lagi, semua struktur data pendukung juga bisa diimplementasikan dalam C dan hanya mengekspos antarmuka minimal ke Python. Sulit menjawab seberapa adil perbandingan seperti ini dengan kode yang memakai struktur bawaan Python
Parser Protobuf milik Google untuk Python mungkin masih bisa “lebih cepat” daripada 2GB/s lebih. Alasannya, ia tidak mem-parsing apa pun selain pesan tingkat teratas. Struktur internal pesan baru di-parse saat diperlukan. Jika kode langsung membaca seluruh hasil parsing, kemungkinan besar ia lebih lambat dari 2GB/s, tetapi persoalannya adalah bagaimana membandingkan kedua pendekatan ini secara praktis. Tidak ada jawaban yang jelas karena hasil nyata bergantung pada sifat aplikasinya
Dalam kasus umum, parsing Protobuf tidak bisa dilakukan secara streaming karena pemrosesan duplikat. Dalam praktiknya, kode yang mem-parsing isi Protobuf akan terkena bottleneck I/O, karena harus menunggu akhir pesan sebelum mulai mem-parsing. Secara terpisah, tergantung pada pesan Protobuf yang tipikal dalam aplikasi, parsing mungkin bisa diparalelkan, dan dengan begitu kemungkinan besar dapat mengungguli parser single-thread. Namun seperti contoh-contoh sebelumnya, ini tidak bisa disebut strategi yang umumnya menang
Biasanya jauh lebih efisien untuk menggabungkan parsing dengan pembuatan objek domain. Aplikasi hampir selalu harus melalui tahap ini. Bagaimana fitur ini bisa diakses dari parser sering kali menentukan parser mana yang akan menang
Kesimpulannya, Protobuf, dan mungkin parser secara umum, bukanlah target yang baik untuk pengukuran dan perbandingan kecepatan. Ia terlalu low-level dan desainnya juga kurang baik, sehingga sulit dijadikan tolok ukur benchmark performa
Akan bagus jika dijelaskan lebih rinci bagaimana aturan bahwa field terakhir yang menang menghalangi parsing streaming
GCC dan Clang sudah lama memiliki opsi
-foptimize-sibling-calls, sehingga tail call bisa didapat bahkan pada build debugTentu saja, menjadi peningkatan besar jika fitur ini distandardisasi, dijamin, dan bisa dikontrol pada level fungsi
[1] https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html
[2] https://clang.llvm.org/docs/ClangCommandLineReference.html#t...