1 poin oleh GN⁺ 2025-06-28 | Belum ada komentar. | Bagikan ke WhatsApp
  • 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 rustc serta tahap optimasi LLVM
  • Setelah berturut-turut menerapkan cargo-chef, cargo --timings, -Zself-profile, dan measureme, terlihat bahwa masalahnya bukan sekadar dependensi, melainkan biaya LTO dan pembangkitan kode LLVM yang mendominasi waktu build
  • Pengaturan lama di Cargo.toml, yaitu lto = "thin" dan debug = "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, memakai Pin<Box<dyn Future>>, dan menghapus generik memberi perbaikan tambahan
  • Terakhir, setelah menerapkan -Zshare-generics dan 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.22 sebagai builder dan build dengan target x86_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

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 rustc untuk crate akhir web-http-server

cargo --timings dan self-profile rustc

  • cargo build --release --timings menampilkan waktu kompilasi per crate, dan waktu crate terakhir adalah 174,1 detik, kira-kira sejalan dengan output cargo build sebesar 2 menit 54 detik
  • Karena bottleneck terpusat pada satu crate terakhir, cargo --timings saja sulit menjelaskan penyebab detailnya
  • Untuk memakai fitur self-profile rustc, digunakan -Zself-profile
    • Untuk memakai flag -Z yang tidak stabil di compiler stabil, digunakan RUSTC_BOOTSTRAP=1
    • Untuk menghindari invalidasi cache cargo-chef, dipakai RUSTFLAGS='-Zself-profile' alih-alih cargo rustc -- -Z self-profile
  • Data self-profile dianalisis dengan alat summarize, flamegraph, dan crox dari measureme
  • Item teratas di summarize terkonsentrasi pada pekerjaan terkait LLVM
    • LLVM_lto_optimize: 851.95 detik, 33.389% dari total
    • LLVM_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_lto menghabiskan 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 rustc adalah sebagai berikut
    • LTO dimatikan
    • "thin" LTO
    • "fat" LTO
    • Jika tidak dinyatakan, digunakan “thin local LTO” yang terbatas hanya di dalam satu crate
  • Di Cargo.toml lama masih ada nilai yang disetel beberapa tahun lalu
    • lto = "thin"
    • debug = "full"
  • debug = "full" mengaktifkan simbol debug penuh yang secara default dikecualikan dari profil release
  • Saat berbagai kombinasi lto dan debug diukur, 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
  • 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 /target ke cache mount di Dockerfile dan mempertahankannya di antara build
  • Namun, untuk mempertahankan sifat docker build yang bisa selalu memiliki lingkungan bersih, sekaligus memanfaatkan sistem cache bawaan Docker, cargo-chef tetap 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 = 3 bawaan profil release agar hanya biner akhir yang kurang dioptimalkan
    • Karena dependensi di-cache, opt-level = 3 dipertahankan di profile.release.package."*"
    • Hanya crate terakhir yang diturunkan opt-level-nya
  • Hasil pengukuran sangat berbeda tergantung ada tidaknya optimasi
    • opt-level=0 untuk crate akhir: sekitar 15 detik
    • opt-level=1 untuk crate akhir: sekitar 48 detik
    • opt-level=2 atau 3 untuk crate akhir: sekitar 50~55 detik
    • opt-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

  • rustc memiliki 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-passes terbentur batas log default Docker BuildKit
    • BUILDKIT_STEP_LOG_MAX_SIZE
    • BUILDKIT_STEP_LOG_MAX_SPEED
  • Variabel lingkungan ini harus disetel pada Docker daemon, bukan pada pemanggilan docker build, dan di Linux bisa diatur pada docker.service melalui drop-in systemd
  • Setelah batas dilepas, keluar sekitar 200 ribu baris teks sehingga sulit ditangani langsung
  • -Z llvm-time-trace memang membuat file *.llvm_timings.json, tetapi file trace untuk biner akhir berupa JSON satu baris berukuran 1.4GiB
  • Firefox Profiler, Perfetto UI, dan chrome://tracing di Chromium semuanya bermasalah saat menangani file ini
  • JSON itu lalu diubah ke JSONL agar bisa diproses dengan alat umum
    • Array traceEvents dalam satu objek JSON dipecah menjadi satu baris per event
    • Setelah konversi, jumlah event menjadi 7,301,865 baris

Bottleneck yang terlihat dari event LLVM

  • Event trace LLVM kebanyakan berupa complete event dengan "ph":"X", dan field dur menunjukkan 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 detik
    • Total ModuleToPostOrderCGSCCPassAdaptor: 656.47 detik
    • Total DevirtSCCRepeatedPass: 632.44 detik
    • Total OptFunction: 189.62 detik
    • Total 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 OptFunction dan inlining InlinerPass

Menyesuaikan ambang inlining

  • Opsi inlining LLVM bisa diteruskan lewat -C llvm-args milik rustc
  • 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.detail berisi simbol mangled dari fungsi yang sedang dioptimalkan
  • Jika di-demangle dengan rustfilt, simbol Rust aslinya bisa dilihat
    • __rustc::__rust_alloc
    • serde_json::value::to_value
  • Alasan serde_json::value::to_value yang 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
  • Jika diagregasi kasar berdasarkan nama crate terluar, core adalah yang terbesar dengan 61.53 detik, dan 84% di antaranya berasal dari parameterisasi core::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_value dimonomorfisasi untuk tipe web_http_server yang mana
  • Dalam output v0, item mahalnya adalah sebagai berikut
    • <web_http_server::photos::PhotosState>::new::{closure#0}: 1.99 detik
    • web_http_server::run::{closure#0}: 1.56 detik
    • core::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::new awalnya total 5.3 detik
  • Upaya pertama yang hanya memecah fungsi menurunkannya sedikit saja menjadi 4.66 detik
  • Percobaan menggabungkan .await yang berdekatan sehingga jumlah .await turun 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 Future sebagai trait object
  • Fungsi yang dipakai membungkus impl Future<Output = T> menjadi Pin<Box<dyn Send + Future<Output = T>>>
  • Setelah diterapkan di setiap titik .await seperti erase(get_img_candidates()).await?, hasilnya:
    • Waktu terkait PhotosState::new turun menjadi 2.14 detik
    • Waktu build total tanpa profiling turun dari 48.8 detik ke 46.8 detik
  • #[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, RUSTFLAGS yang menurunkan tiga threshold inlining ke 10 diterapkan baik pada cargo chef cook maupun cargo 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-cmark menjadi non-generik
    • Crate lokal yang mengekspos versi non-generik dari API yang dipakai di lol_html dan deadpool_postgres
  • 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
  • -Zshare-generics adalah 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-generics diaktifkan, waktu kompilasi total turun dari 32.3 detik ke 29.1 detik
  • Instans drop_in_place masih 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 = 1 untuk 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 mendefinisikan T bisa membantu dalam sebagian kasus, tetapi sulit diterapkan pada tipe generik dan berisiko mengompilasi drop glue yang tidak terpakai
    • -Zshare-generics membantu, 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 = 0 untuk crate akhir juga bisa menjadi opsi yang cukup masuk akal

Belum ada komentar.

Belum ada komentar.