Mengapa kompiler Rust bisa selambat ini?
(sharnoff.io)- Pada build situs web Rust untuk deployment Docker, meskipun dependensi sudah di-cache, crate terakhir saja masih memakan waktu sekitar 175 detik, dan bottleneck menyempit ke bagian internal
rustcserta tahap optimasi LLVM - Setelah berturut-turut menerapkan
cargo-chef,cargo --timings,-Zself-profile, danmeasureme, terlihat bahwa masalahnya bukan sekadar dependensi, melainkan biaya LTO dan pembangkitan kode LLVM yang mendominasi waktu build - Pengaturan lama di
Cargo.toml, yaitulto = "thin"dandebug = "full", memberi dampak besar; setelah keduanya dimatikan, build biner akhir turun dari 172,2 detik ke sekitar 50 detik - Dalam pelacakan LLVM,
OptFunction,InlinerPass,core::ptr::drop_in_place, fungsi async yang besar, dan monomorfisasi generik muncul sebagai biaya utama; mengurangi inlining, memecah fungsi, memakaiPin<Box<dyn Future>>, dan menghapus generik memberi perbaikan tambahan - Terakhir, setelah menerapkan
-Zshare-genericsdan berpindah ke build berbasis Debian, waktu kompilasi turun dari 29,1 detik ke 9,1 detik, menunjukkan bahwa bukan hanya struktur kode, tetapi juga allocator dan penggunaan target musl sangat memengaruhi waktu build
Bottleneck yang terlihat dalam build Docker
- Situs web ini terutama disajikan sebagai satu biner Rust, dan sebelumnya build dilakukan sebagai biner statically linked, lalu disalin ke server dan layanannya di-restart
- Saat berpindah ke deployment berbasis kontainer, ternyata menyiapkan build Rust yang cepat di Docker jauh lebih rumit daripada perkiraan
- Dockerfile dasar membangun ulang semuanya setiap kali source berubah
- Menggunakan
rust:1.87-alpine3.22sebagai builder dan build dengan targetx86_64-unknown-linux-musl - Image akhir hanya menyalin binernya ke Alpine
- Clean build dengan cara ini memakan waktu 3 menit 51 detik, termasuk 10 detik untuk mengunduh crates
- Menggunakan
Memisahkan cache dependensi dengan cargo-chef, tetapi belum cukup
- cargo-chef membuat file recipe yang disederhanakan dari workspace, lalu berdasarkan itu membangun dependensi terlebih dahulu di layer cache Docker terpisah
- Karena situs web ini menggunakan ratusan dependensi, efek cache semula diharapkan besar
- Dalam pengukuran nyata, build dependensi memakan waktu 1 menit 7 detik, sedangkan build biner akhir dengan dependensi yang sudah di-cache memakan waktu 2 menit 50 detik
- Hanya sekitar 25% dari total waktu yang dipakai untuk dependensi, dan sebagian besar sisanya habis dalam satu pemanggilan
rustcuntuk crate akhirweb-http-server
cargo --timings dan self-profile rustc
cargo build --release --timingsmenampilkan waktu kompilasi per crate, dan waktu crate terakhir adalah 174,1 detik, kira-kira sejalan dengan outputcargo buildsebesar 2 menit 54 detik- Karena bottleneck terpusat pada satu crate terakhir,
cargo --timingssaja sulit menjelaskan penyebab detailnya - Untuk memakai fitur self-profile
rustc, digunakan-Zself-profile- Untuk memakai flag
-Zyang tidak stabil di compiler stabil, digunakanRUSTC_BOOTSTRAP=1 - Untuk menghindari invalidasi cache
cargo-chef, dipakaiRUSTFLAGS='-Zself-profile'alih-alihcargo rustc -- -Z self-profile
- Untuk memakai flag
- Data self-profile dianalisis dengan alat
summarize,flamegraph, dancroxdari measureme - Item teratas di
summarizeterkonsentrasi pada pekerjaan terkait LLVMLLVM_lto_optimize: 851.95 detik, 33.389% dari totalLLVM_module_codegen_emit_obj: 674.94 detik, 26.452%LLVM_thin_lto_import: 317.75 detik, 12.453%LLVM_module_optimize: 189.00 detik, 7.407%
- Dalam flamegraph,
codegen_module_perform_ltomenghabiskan sekitar 80% dari keseluruhan waktu
Dampak pengaturan LTO dan simbol debug
- Kompiler Rust membagi crate menjadi codegen unit lalu menyerahkannya ke LLVM sebagai modul terpisah
- LTO adalah opsi yang melakukan inlining dan optimasi antarc_codegen unit atau antarcrate pada saat linking
- Opsi LTO di Cargo dan
rustcadalah sebagai berikut- LTO dimatikan
"thin"LTO"fat"LTO- Jika tidak dinyatakan, digunakan “thin local LTO” yang terbatas hanya di dalam satu crate
- Di
Cargo.tomllama masih ada nilai yang disetel beberapa tahun lalulto = "thin"debug = "full"
debug = "full"mengaktifkan simbol debug penuh yang secara default dikecualikan dari profil release- Saat berbagai kombinasi
ltodandebugdiukur, perbedaannya besar- LTO mati,
debug=none: 50.0 detik / 21.0MiB - Thin local LTO,
debug=full: 88.2 detik / 256.8MiB "thin"LTO,debug=full: 172.2 detik / 197.5MiB"fat"LTO,debug=full: 287.1 detik / 155.9MiB
- LTO mati,
- Simbol debug penuh menambah waktu kompilasi 30~50%, dan fat LTO memakan waktu sekitar 4 kali lebih lama dibanding saat LTO dimatikan sepenuhnya
- Bahkan setelah LTO dan simbol debug dimatikan, kompilasi satu biner akhir masih memakan sekitar 50 detik
Mengapa tetap mempertahankan cache Docker alih-alih kompilasi inkremental
- Dalam development lokal, kompilasi inkremental bisa dipakai dengan menghubungkan direktori
/targetke cache mount di Dockerfile dan mempertahankannya di antara build - Namun, untuk mempertahankan sifat
docker buildyang bisa selalu memiliki lingkungan bersih, sekaligus memanfaatkan sistem cache bawaan Docker,cargo-cheftetap dipakai
Biaya optimasi LLVM yang tersisa setelah LTO
- Bahkan setelah LTO dan simbol debug dimatikan, kompilasi biner akhir masih memakan sekitar 50 detik
- Dari self-profile yang diperiksa ulang, sekitar 70% waktu dihabiskan di
LLVM_module_optimize, yaitu tahap ketika LLVM mengoptimalkan kode - Dilakukan eksperimen untuk menurunkan
opt-level = 3bawaan profil release agar hanya biner akhir yang kurang dioptimalkan- Karena dependensi di-cache,
opt-level = 3dipertahankan diprofile.release.package."*" - Hanya crate terakhir yang diturunkan
opt-level-nya
- Karena dependensi di-cache,
- Hasil pengukuran sangat berbeda tergantung ada tidaknya optimasi
opt-level=0untuk crate akhir: sekitar 15 detikopt-level=1untuk crate akhir: sekitar 48 detikopt-level=2atau3untuk crate akhir: sekitar 50~55 detikopt-level="z"untuk crate akhir: sekitar 42 detik
- Begitu optimasi apa pun diaktifkan pada biner akhir, muncul baseline sekitar 50 detik; jika optimasi dimatikan sepenuhnya, build menjadi sekitar 15 detik
Sulitnya mengumpulkan data pelacakan LLVM
rustcmemiliki flag untuk melihat informasi LLVM-Z time-llvm-passes: mencetak informasi profil LLVM sebagai teks biasa-Z llvm-time-trace: mengeluarkan profil LLVM dalam format Chrome tracing
-Z time-llvm-passesterbentur batas log default Docker BuildKitBUILDKIT_STEP_LOG_MAX_SIZEBUILDKIT_STEP_LOG_MAX_SPEED
- Variabel lingkungan ini harus disetel pada Docker daemon, bukan pada pemanggilan
docker build, dan di Linux bisa diatur padadocker.servicemelalui drop-insystemd - Setelah batas dilepas, keluar sekitar 200 ribu baris teks sehingga sulit ditangani langsung
-Z llvm-time-tracememang membuat file*.llvm_timings.json, tetapi file trace untuk biner akhir berupa JSON satu baris berukuran 1.4GiB- Firefox Profiler, Perfetto UI, dan
chrome://tracingdi Chromium semuanya bermasalah saat menangani file ini - JSON itu lalu diubah ke JSONL agar bisa diproses dengan alat umum
- Array
traceEventsdalam satu objek JSON dipecah menjadi satu baris per event - Setelah konversi, jumlah event menjadi 7,301,865 baris
- Array
Bottleneck yang terlihat dari event LLVM
- Event trace LLVM kebanyakan berupa complete event dengan
"ph":"X", dan fielddurmenunjukkan durasi dalam mikrodetik "ph":"M"adalah metadata event, tetapi tidak banyak informasi berguna untuk analisis ini- Di event agregat, item dengan waktu terbesar adalah sebagai berikut
Total ModuleInlinerWrapperPass: 665.37 detikTotal ModuleToPostOrderCGSCCPassAdaptor: 656.47 detikTotal DevirtSCCRepeatedPass: 632.44 detikTotal OptFunction: 189.62 detikTotal InlinerPass: 182.25 detik
- Eksekusi ini berlangsung sekitar 110 detik di mesin 16-core, sehingga sebagian waktu pass terhitung ganda
- Dua poros utama adalah optimasi fungsi
OptFunctiondan inliningInlinerPass
Menyesuaikan ambang inlining
- Opsi inlining LLVM bisa diteruskan lewat
-C llvm-argsmilikrustc - Per Juni 2025,
rustc -C llvm-args='--help-list-hidden'menampilkan sekitar 100 opsi terkait inlining - Tiga opsi yang dipakai dalam eksperimen adalah
--inlinedefault-threshold=225--inline-threshold=225--inlinehint-threshold=325
- Threshold kira-kira mengizinkan inlining fungsi yang biayanya lebih rendah dari nilai itu, sehingga menurunkannya akan mengurangi inlining
- Ketika ketiga threshold diturunkan ke 50, waktu turun dari 48.8 detik ke 42.2 detik
- Untuk kasus penggunaan berupa situs pribadi dengan beban sangat ringan, threshold 10 juga dinilai menjanjikan
OptFunction dan monomorfisasi generik
- Pada event
OptFunction,args.detailberisi simbol mangled dari fungsi yang sedang dioptimalkan - Jika di-demangle dengan rustfilt, simbol Rust aslinya bisa dilihat
__rustc::__rust_allocserde_json::value::to_value
- Alasan
serde_json::value::to_valueyang sama muncul dengan beberapa hash berbeda adalah karena fungsi generik mengalami monomorfisasi untuk parameter tipe yang berbeda - Fungsi dari crate lain juga dioptimalkan di crate akhir, karena tempat fungsi dimonomorfisasi ke tipe tertentu berada dalam konteks crate pemanggil
- Contoh fungsi yang memakan banyak waktu optimasi adalah
- closure di dalam
web_http_server::photos::PhotosState::new - closure di dalam
web_http_server::run tokio_postgres::connect_raw- fungsi generik sekitar 500 baris di
pulldown_cmark - berbagai tipe konkret dari
core::ptr::drop_in_place
- closure di dalam
- Jika diagregasi kasar berdasarkan nama crate terluar,
coreadalah yang terbesar dengan 61.53 detik, dan 84% di antaranya berasal dari parameterisasicore::ptr::drop_in_place
Melihat lokasi fungsi async lebih jelas dengan v0 symbol mangling
- Legacy symbol mangling bawaan membuat closure sulit dibedakan
- Dengan menambahkan
-C symbol-mangling-version=v0, nomor closure dan informasi tipe generik menjadi lebih jelas - Misalnya, bisa terlihat seluruh argumen generik yang menunjukkan
serde_json::value::to_valuedimonomorfisasi untuk tipeweb_http_serveryang mana - Dalam output v0, item mahalnya adalah sebagai berikut
<web_http_server::photos::PhotosState>::new::{closure#0}: 1.99 detikweb_http_server::run::{closure#0}: 1.56 detikcore::ptr::drop_in_place::<axum::routing::Endpoint<web_http_server::AppState>>: 1.22 detik
- Sekilas closure ini tampak kecil, tetapi setelah LLVM IR di-dump, ternyata fungsi async dan async block direpresentasikan secara internal sebagai closure bertingkat
- Rust sendiri sudah memiliki open issue terkait mangling untuk async function/block
Fungsi async besar dan Pin<Box<dyn Future>>
- Item yang mahal bukan closure itu sendiri, melainkan isi fungsi async yang besar
- Waktu optimasi terkait
PhotosState::newawalnya total 5.3 detik - Upaya pertama yang hanya memecah fungsi menurunkannya sedikit saja menjadi 4.66 detik
- Percobaan menggabungkan
.awaityang berdekatan sehingga jumlah.awaitturun dari 10 menjadi 3 justru naik menjadi 6.24 detik - Karena fungsi async secara internal diturunkan menjadi state machine yang kompleks, dicoba pendekatan menghapus detail implementasi dari caller dengan menjadikan
Futuresebagai trait object - Fungsi yang dipakai membungkus
impl Future<Output = T>menjadiPin<Box<dyn Send + Future<Output = T>>> - Setelah diterapkan di setiap titik
.awaitsepertierase(get_img_candidates()).await?, hasilnya:- Waktu terkait
PhotosState::newturun menjadi 2.14 detik - Waktu build total tanpa profiling turun dari 48.8 detik ke 46.8 detik
- Waktu terkait
#[inline(never)]dan menonaktifkan inlining pada fungsi poll juga dicoba, tetapi hasilnya tidak sebaik boxing
Hasil setelah menggabungkan beberapa perubahan
- Ada tiga pendekatan yang diterapkan
- Mengurangi inlining lewat LLVM args
- Memecah fungsi mahal di crate utama dan melakukan boxing pada Future async
- Mengurangi generik pada API dependensi agar lebih sedikit bagian yang dikompilasi ulang di crate akhir
- Di Dockerfile akhir,
RUSTFLAGSyang menurunkan tiga threshold inlining ke 10 diterapkan baik padacargo chef cookmaupuncargo build - Pada crate utama, perubahan tersebar di 10 file dengan 898 baris ditambahkan dan 657 baris dihapus
- Perubahan di sisi dependensi juga ikut masuk
- PR yang membuat fungsi generik di
pulldown-cmarkmenjadi non-generik - Crate lokal yang mengekspos versi non-generik dari API yang dipakai di
lol_htmldandeadpool_postgres
- PR yang membuat fungsi generik di
- Dengan kombinasi ini, waktu kompilasi akhir menjadi 32.3 detik
Pembaruan 2025-06-27: -Zshare-generics dan meninggalkan Alpine
- Berdasarkan saran dari Bluesky dan Lobsters, dua eksperimen tambahan dilakukan
- Mengaktifkan
-Zshare-generics - Tidak lagi memakai Alpine
- Mengaktifkan
-Zshare-genericsadalah flag untuk memakai ulang instans generik dari dependensi crate- Pada build release, flag ini tidak aktif secara default
- Pada dev build di stable toolchain, flag ini aktif
- Flag ini hanya bisa dipakai di nightly
- Setelah
-Zshare-genericsdiaktifkan, waktu kompilasi total turun dari 32.3 detik ke 29.1 detik - Instans
drop_in_placemasih banyak dikompilasi, tetapi waktu optimasinya turun dari 21.7 detik ke 17.4 detik - Setelah beralih dari Alpine ke Debian dan menghapus
--target=x86_64-unknown-linux-musl, waktu kompilasi total turun drastis dari 29.1 detik ke 9.1 detik - Latar belakang saran ini adalah bahwa allocator bawaan bisa berdampak besar terhadap waktu build
Angka akhir dan pekerjaan yang masih tersisa
- Perubahan akhirnya adalah sebagai berikut
- Titik awal: sekitar 175 detik
- Menonaktifkan LTO dan simbol debug: 51 detik, -71%
opt-level = 1untuk crate akhir: 48.8 detik, -4%- Mengurangi inlining dengan
-C llvm-args: 40.7 detik, -16% - Perubahan kode lokal: 37.7 detik, -7%
- Perubahan dependensi: 32.3 detik, -14%
-Zshare-generics: 29.1 detik, -10%- Meninggalkan Alpine: 9.1 detik, -69%
- Dalam proses analisis ini, alat dan dokumentasi benar-benar cukup baik untuk menghasilkan perbaikan nyata
- Namun, masih ada beberapa masalah kompleks yang tersisa
- Waktu kompilasi pada graph pemanggilan fungsi async yang dalam masih perlu diperbaiki lebih lanjut
- Pendekatan penanganan khusus agar
core::ptr::drop_in_place<T>dikompilasi di crate yang mendefinisikanTbisa membantu dalam sebagian kasus, tetapi sulit diterapkan pada tipe generik dan berisiko mengompilasi drop glue yang tidak terpakai -Zshare-genericsmembantu, tetapi bukan solusi penuh- Mungkin masih dibutuhkan alat yang bisa mengisolasi bagian mana dari codebase yang paling banyak memakan waktu kompilasi dan menyarankan mitigasi
- Secara praktis, memilih
opt-level = 0untuk crate akhir juga bisa menjadi opsi yang cukup masuk akal
Belum ada komentar.