- Saat berulang kali membangun situs web yang dibuat dengan Rust menggunakan Docker, muncul masalah waktu build
- Dengan konfigurasi Docker dasar, rebuild seluruh dependensi terjadi setiap kali dan memakan waktu lebih dari 4 menit
- Meski menggunakan
cargo-chef dan alat caching, build biner final masih tetap memakan banyak waktu
- Hasil profiling menunjukkan bahwa sebagian besar waktu dihabiskan untuk LTO (link-time optimization) dan optimasi modul LLVM
- Dengan menyesuaikan opsi optimasi, informasi debug, dan pengaturan LTO, ada sedikit peningkatan, tetapi kompilasi biner final tetap membutuhkan setidaknya 50 detik
Masalah dan latar belakang
- Setiap kali mengubah situs pribadi yang dibuat dengan Rust, pekerjaan merepotkan berupa membangun biner static link, menyalinnya ke server, lalu me-restart layanan terus berulang
- Ingin beralih ke deployment berbasis kontainer seperti Docker atau Kubernetes, tetapi kecepatan build Docker untuk Rust ternyata menjadi masalah besar
- Di dalam Docker, bahkan perubahan kode kecil pun membuat seluruh proyek harus dibangun ulang dari awal, sehingga sangat tidak efisien
Build Rust di Docker – pendekatan dasar
- Pendekatan Dockerfile yang umum adalah menyalin semua dependensi dan source code lalu menjalankan cargo build
- Dalam kasus ini, tidak ada keuntungan caching sehingga full rebuild terus berulang
- Untuk situs web penulis, full build memakan waktu sekitar 4 menit—belum termasuk waktu tambahan untuk mengunduh dependensi
Meningkatkan caching build Docker – cargo-chef
- Dengan alat
cargo-chef, hanya dependensi yang bisa lebih dulu di-cache dalam layer terpisah
- Dengan begitu, saat kode berubah, build dependensi bisa digunakan kembali sehingga kecepatan build diharapkan meningkat
- Dalam penerapan nyata, hanya 25% dari total waktu yang terpakai untuk build dependensi, dan build biner layanan web final masih tetap memakan waktu cukup besar (2 menit 50 detik~3 menit)
- Meskipun hanya terdiri dari dependensi utama (axum, reqwest, tokio-postgres, dll.) dan sekitar 7.000 baris kode sendiri, satu eksekusi rustc tetap membutuhkan 3 menit
Analisis waktu build rustc: cargo --timings
- Dengan
cargo --timings, kita bisa melihat waktu build untuk tiap crate (unit kompilasi)
- Hasilnya menunjukkan bahwa build biner final memakan sebagian besar total waktu
- Ini membantu untuk analisis yang lebih rinci, tetapi masih kurang untuk memahami perilaku internal kompiler secara spesifik
Memanfaatkan profiling bawaan rustc (-Zself-profile)
- Mengaktifkan fitur profiling bawaan rustc dengan flag -Zself-profile untuk mengukur waktu tiap operasi secara detail
- Untuk itu, profiling diaktifkan melalui variabel lingkungan
- Saat dianalisis dengan alat ringkasan (summarize), ditemukan bahwa LLVM LTO (link-time optimization) dan code generation modul LLVM menyumbang lebih dari 60% dari total waktu
- Visualisasi flamegraph juga menunjukkan bahwa tahap codegen_module_perform_lto menghabiskan 80% dari total waktu
LTO (link-time optimization) dan opsi optimasi build
- Build Rust pada dasarnya dibagi menurut codegen unit, lalu optimasi menyeluruh diterapkan relatif di tahap akhir melalui LTO
- LTO memiliki beberapa opsi seperti off, thin, fat: masing-masing memengaruhi performa dan hasil akhir
- Pada proyek penulis,
Cargo.toml mengatur LTO ke thin dan simbol debug ke full
- Dari pengujian berbagai kombinasi LTO/simbol debug:
- Dikonfirmasi bahwa simbol debug full meningkatkan waktu build, dan fat LTO menyebabkan build melambat sekitar 4 kali lipat
- Bahkan setelah menghapus LTO dan simbol debug, build masih membutuhkan setidaknya 50 detik
Optimasi tambahan dan catatan
- Sekitar 50 detik sebenarnya bukan masalah besar untuk situs penulis yang hampir tidak memiliki beban layanan nyata, tetapi rasa penasaran teknis mendorong analisis lebih lanjut
- Kompilasi inkremental (incremental compilation) juga bisa mempercepat build jika dimanfaatkan dengan baik di Docker, tetapi perlu dipadukan dengan kebersihan environment build dan cache Docker
Profiling detail tahap LLVM
- Bahkan setelah menghapus LTO dan simbol debug, tahap LLVM_module_optimize masih menghabiskan hampir 70% waktu
- Disadari bahwa biaya optimasi besar berasal dari nilai default opt-level (3) pada profil release, lalu diuji metode menurunkan opt-level hanya untuk biner
- Dari berbagai eksperimen kombinasi optimasi, tanpa optimasi (opt-level=0) waktu build sekitar 15 detik, sedangkan dengan optimasi (1~3) sekitar 50 detik
Analisis mendalam event trace LLVM
- Dengan flag tambahan rustc (
-Z time-llvm-passes, -Z llvm-time-trace), waktu eksekusi tiap tahap LLVM bisa ditelusuri secara detail
-Z time-llvm-passes menghasilkan output yang sangat besar sehingga sering melampaui batas log Docker, jadi pengaturan log perlu disesuaikan
- Jika log disimpan ke file untuk dianalisis, waktu eksekusi tiap LLVM optimization pass dapat dilihat satu per satu
- Opsi
-Z llvm-time-trace menghasilkan output JSON besar dalam format chrome tracing; ukurannya sangat besar sehingga sulit dianalisis dengan editor teks atau alat analisis biasa
- Dengan memecahnya per baris (jsonl), data itu dapat dianalisis di lingkungan CLI atau skrip
Insight utama dan kesimpulan
- Saat membangun proyek Rust yang kompleks di Docker, bottleneck kecepatan build terutama terjadi pada build biner final dan tahap optimasi LLVM terkait
- Saat menyesuaikan LTO, simbol debug, dan opt-level, terdapat trade-off yang jelas antara waktu build dan ukuran biner
- Dengan menyesuaikan opsi optimasi secara agresif, waktu build bisa dipangkas drastis, tetapi performa juga bisa menurun jika optimasi dimatikan
- Jika efisiensi build penting dalam lingkungan produksi dan proyek dengan dependensi crate besar, strategi yang baik adalah memanfaatkan profiling secara aktif untuk mengidentifikasi bottleneck detail secara konkret
- Saat merancang pipeline build Rust, diperlukan kombinasi yang matang antara LTO, opt-level, simbol debug, dan strategi cache
1 komentar
Opini Hacker News
Menarik bahwa proyek Rust sering tampak kecil di permukaan. Pertama, dependensi tidak berkaitan langsung dengan ukuran nyata codebase. Di C++, dependensi proyek besar sering di-vendor atau bahkan tidak dipakai sama sekali, jadi kalau ada 400 ribu baris kode yang lambat, orang bisa berpikir, "ya wajar lambat kalau kodenya sebanyak itu." Kedua, bagian yang jauh lebih bermasalah adalah macro. Macro yang berulang kali mengembang 10 atau 100 baris bisa dengan cepat mengubah proyek 10 ribu baris menjadi sejuta baris. Ketiga adalah generic. Setiap instansiasi generic menghabiskan resource CPU. Meski begitu, sedikit membela: fitur-fitur ini juga memberi keuntungan, misalnya sesuatu yang butuh 100 ribu baris di C, atau 25 ribu baris di C++, bisa menyusut jadi hanya beberapa ribu baris di Rust. Namun memang benar, saat fitur-fitur ini dipakai berlebihan, ekosistem jadi tampak lambat. Misalnya di perusahaan kami memakai async-graphql; library-nya sendiri sangat bagus, tetapi sangat bergantung pada procedural macro. Isu terkait performa sudah terbuka selama bertahun-tahun, dan setiap kali menambah data type, compiler terasa jelas makin lambat
Ryan Fleury membuat Epic RAD Debugger dalam C, 278 ribu baris, dengan gaya unity build (semua kode menjadi satu file sebagai satu unit kompilasi), dan clean compile di Windows cuma butuh 1,5 detik. Dari contoh ini saja sudah terlihat bahwa kompilasi bisa sangat cepat, jadi saya penasaran kenapa hal serupa tidak bisa dicapai di Rust atau Swift
Saya sangat bersyukur Go memprioritaskan kecepatan kompilasi dibanding optimisasi. Untuk server, networking, dan pekerjaan glue code, kompilasi yang sangat cepat adalah hal paling penting. Saya juga ingin type safety secukupnya, tapi tidak sampai menghalangi prototyping yang longgar. Keberadaan GC juga praktis. Menurut saya, setelah pengalaman mengembangkan dalam skala besar di Google, mereka sampai pada kesimpulan bahwa type yang sederhana, GC, dan kompilasi super cepat jauh lebih penting daripada kecepatan eksekusi atau kesempurnaan semantik. Melihat contoh software networking dan infrastruktur skala besar yang dibuat dengan Go, pilihan itu terasa sangat tepat. Tentu saja, Go mungkin tidak dipakai di lingkungan yang tidak bisa menerima GC atau yang lebih mementingkan ketepatan absolut, tetapi untuk lingkungan kerja saya, pilihan Go adalah yang paling optimal
Saya tidak paham argumen bahwa instalasi binary statis tunggal lebih sederhana daripada pengelolaan container
Di laptop saya (Mac M4 Pro), clean compile penuh Deno (proyek Rust besar) butuh 2 menit. Kalau dilihat dari command, debug sekitar 1 menit 54 detik, release sekitar 8 menit 17 detik. Angka ini diukur tanpa incremental compilation. Sebenarnya build untuk deployment dijalankan di sistem CI/CD, jadi saya sendiri tidak perlu menunggu langsung
Bagian tentang Cranelift ada di mana? Menurut saya, saya hampir menyerah mengembangkan game dengan Rust karena waktu kompilasinya terlalu lama. Setelah saya telusuri, ternyata LLVM lambat terlepas dari level optimisasi. Ini juga selalu disorot oleh pengembang bahasa Jai. Saya pernah mengalami build time turun dari 16 detik menjadi 4 detik dengan Cranelift. Salut untuk tim Cranelift!
Saya tidak menganggap Rust itu lambat. Dibanding bahasa sekelasnya, ia cukup cepat, dan dibanding kompilasi C++/Scala yang bisa makan 15 menit, Rust jauh lebih cepat
Dari sudut pandang mantan developer C++, saya kurang paham klaim bahwa build Rust itu lambat
Incremental compilation itu benar-benar sangat kuat. Setelah build awal, Anda bisa membekukan snapshot incremental cache dan jika tidak ada perubahan, itu bisa langsung dipakai untuk build/deploy cepat. Ini juga cocok dengan docker. Selain versi compiler atau pembaruan besar situs web, layer image build biasanya tidak perlu disentuh. Kalau hanya ada perubahan kode, cukup atur agar layer tersebut tidak ikut dibangun ulang sehingga efisien
Build time homepage saya 73ms. Static site generator melakukan kompilasi ulang hanya dalam 17ms. Eksekusi generator aslinya sendiri cuma 56ms. Saya lampirkan log build zig