• ในการบิลด์เว็บไซต์ Rust สำหรับดีพลอยด้วย Docker แม้จะแคช dependency แล้ว ก็ยังใช้เวลา ราว 175 วินาที กับ final crate เพียงตัวเดียว โดยคอขวดถูกตีวงแคบลงไปที่ภายใน rustc และขั้นตอน optimization ของ LLVM
  • หลังจากลองใช้ cargo-chef, cargo --timings, -Zself-profile, และ measureme ตามลำดับ ก็พบว่าไม่ได้เป็นแค่ปัญหา dependency ธรรมดา แต่เป็นต้นทุนของ LTO และ LLVM code generation ที่ครองเวลาบิลด์
  • ค่า lto = "thin" และ debug = "full" ใน Cargo.toml ที่ค้างมาจากการตั้งค่าเก่า ส่งผลอย่างมาก และเมื่อปิดทั้งสองอย่าง เวลาบิลด์ไบนารีสุดท้ายลดจาก 172.2 วินาที เหลือราว 50 วินาที
  • ในการติดตามของ LLVM พบว่า OptFunction, InlinerPass, core::ptr::drop_in_place, ฟังก์ชัน async ขนาดใหญ่ และ generic monomorphization เป็นตัวกินต้นทุนหลัก และการลดการ inline, แยกฟังก์ชัน, ใช้ Pin<Box<dyn Future>>, และลดการใช้ generic ก็ช่วยปรับปรุงเพิ่มเติมได้
  • สุดท้ายเมื่อเปิดใช้ -Zshare-generics และเปลี่ยนไปบิลด์บน Debian เวลา compile ก็ลดจาก 29.1 วินาที เหลือ 9.1 วินาที แสดงให้เห็นว่านอกจากโครงสร้างโค้ดแล้ว allocator และการใช้ target แบบ musl ก็มีผลต่อเวลาบิลด์อย่างมาก

คอขวดที่เผยออกมาในการบิลด์ด้วย Docker

  • เว็บไซต์นี้ให้บริการหลัก ๆ เป็น Rust binary ตัวเดียว โดยเดิมจะบิลด์ไบนารีแบบ static link แล้วคัดลอกไปที่เซิร์ฟเวอร์ก่อนรีสตาร์ต service
  • เมื่อต้องย้ายไปใช้การดีพลอยแบบ container กลับพบว่าการตั้งค่าให้ Docker บิลด์ Rust ได้เร็วเป็นเรื่องที่ยากกว่าที่คิด
  • Dockerfile พื้นฐานจะบิลด์ทุกอย่างใหม่ทั้งหมดทุกครั้งที่ source เปลี่ยน
    • ใช้ rust:1.87-alpine3.22 เป็น builder และบิลด์ด้วย target x86_64-unknown-linux-musl
    • image สุดท้ายคัดลอกเฉพาะไบนารีไปไว้บน Alpine
    • การทำ clean build ด้วยวิธีนี้ใช้เวลา 3 นาที 51 วินาที รวม 10 วินาทีสำหรับดาวน์โหลด crates

แยกการแคช dependency ด้วย cargo-chef แล้ว แต่ยังไม่พอ

  • cargo-chef สร้างไฟล์ recipe แบบย่อจาก workspace และใช้ไฟล์นั้นบิลด์ dependency ล่วงหน้าแยกไว้ใน Docker cache layer
  • เนื่องจากเว็บไซต์นี้ใช้ dependency หลายร้อยตัว จึงคาดหวังว่าผลของการแคชน่าจะมาก
  • แต่จากการวัดจริง การบิลด์ dependency ใช้เวลา 1 นาที 7 วินาที และการบิลด์ไบนารีสุดท้ายโดยใช้ dependency ที่แคชแล้วใช้เวลา 2 นาที 50 วินาที
  • มีเวลาเพียงราว 25% ของทั้งหมดที่ใช้กับ dependency ส่วนที่เหลือส่วนใหญ่ถูกใช้ไปกับการเรียก rustc ครั้งเดียวของ final crate web-http-server

cargo --timings และ self-profile ของ rustc

  • cargo build --release --timings แสดงเวลา compile ราย crate โดย final crate ใช้เวลา 174.1 วินาที ซึ่งใกล้เคียงกับ 2 นาที 54 วินาทีที่เห็นจาก output ของ cargo build
  • เพราะคอขวดไปรวมอยู่ที่ final crate ตัวเดียว จึงยากจะรู้สาเหตุเชิงลึกด้วย cargo --timings อย่างเดียว
  • จึงใช้ฟังก์ชัน self-profile ของ rustc ผ่าน -Zself-profile
    • ใช้ RUSTC_BOOTSTRAP=1 เพื่อให้ใช้ flag ไม่เสถียร -Z บน stable compiler ได้
    • ใช้ RUSTFLAGS='-Zself-profile' แทน cargo rustc -- -Z self-profile เพื่อหลีกเลี่ยงการทำให้แคชของ cargo-chef ใช้ไม่ได้
  • ใช้เครื่องมือ summarize, flamegraph, crox จาก measureme เพื่อวิเคราะห์ข้อมูล self-profile
  • รายการบนสุดจาก summarize กระจุกอยู่ที่งานฝั่ง LLVM
    • LLVM_lto_optimize: 851.95 วินาที, 33.389%
    • LLVM_module_codegen_emit_obj: 674.94 วินาที, 26.452%
    • LLVM_thin_lto_import: 317.75 วินาที, 12.453%
    • LLVM_module_optimize: 189.00 วินาที, 7.407%
  • ใน flamegraph นั้น codegen_module_perform_lto กินเวลา ราว 80% ของทั้งหมด

ผลกระทบของการตั้งค่า LTO และ debug symbol

  • Rust compiler จะแบ่ง crate ออกเป็น codegen unit แล้วส่งต่อให้ LLVM เป็นโมดูลแยกกัน
  • LTO เป็นตัวเลือกที่ทำ inline และ optimization ระหว่าง codegen unit หรือระหว่าง crate ตอนลิงก์
  • ตัวเลือก LTO ของ Cargo และ rustc มีดังนี้
    • ปิด LTO
    • "thin" LTO
    • "fat" LTO
    • ถ้าไม่ระบุ จะใช้ “thin local LTO” แบบจำกัดอยู่ภายใน crate เดียว
  • ใน Cargo.toml เดิมยังมีค่าที่ตั้งไว้เมื่อหลายปีก่อนค้างอยู่
    • lto = "thin"
    • debug = "full"
  • debug = "full" จะเปิด debug symbol แบบเต็ม ซึ่งปกติถูกปิดไว้ใน release profile
  • เมื่อวัดชุดค่าผสมของ lto และ debug หลายแบบ พบว่าต่างกันมาก
    • ปิด LTO, debug=none: 50.0 วินาที / 21.0MiB
    • Thin local LTO, debug=full: 88.2 วินาที / 256.8MiB
    • "thin" LTO, debug=full: 172.2 วินาที / 197.5MiB
    • "fat" LTO, debug=full: 287.1 วินาที / 155.9MiB
  • debug symbol แบบเต็มเพิ่มเวลาคอมไพล์ขึ้น 30~50% และ fat LTO ใช้เวลานานกว่าการปิด LTO ทั้งหมดประมาณ 4 เท่า
  • แม้จะปิด LTO และ debug symbol แล้ว ก็ยังต้องใช้เวลาราว 50 วินาทีในการคอมไพล์ไบนารีสุดท้ายเพียงตัวเดียว

เหตุผลที่ยังคงใช้ Docker cache แทน incremental compilation

  • ในการพัฒนาแบบ local สามารถเมานต์ไดเรกทอรี /target เป็น cache mount ใน Dockerfile และเก็บไว้ระหว่างการบิลด์เพื่อใช้ incremental compilation ได้
  • แต่เพื่อคงคุณสมบัติที่ docker build สามารถเริ่มจากสภาพแวดล้อมที่สะอาดทุกครั้ง และยังใช้ระบบแคชของ Docker เองได้ จึงยังคงใช้ cargo-chef ต่อไป

ต้นทุน optimization ของ LLVM ที่ยังเหลือหลังจากปิด LTO

  • แม้ปิด LTO และ debug symbol แล้ว การคอมไพล์ไบนารีสุดท้ายก็ยังใช้เวลาราว 50 วินาที
  • เมื่อดู self-profile อีกรอบ พบว่าราว 70% ของเวลาไปอยู่ที่ LLVM_module_optimize ซึ่งเป็นขั้นตอนที่ LLVM ทำ optimization กับโค้ด
  • จึงทดลองลด opt-level = 3 ซึ่งเป็นค่า default ของ release profile ลงเฉพาะกับไบนารีสุดท้ายให้ optimize น้อยลง
    • dependency ยังคงถูกแคชอยู่ จึงคง opt-level = 3 ไว้ใน profile.release.package."*"
    • ลด opt-level เฉพาะ final crate
  • ผลที่วัดได้ต่างกันมากตามระดับ optimization
    • final opt-level=0: ราว 15 วินาที
    • final opt-level=1: ราว 48 วินาที
    • final opt-level=2 หรือ 3: ราว 50~55 วินาที
    • final opt-level="z": ราว 42 วินาที
  • เมื่อเปิด optimization แบบใดก็ตามกับไบนารีสุดท้าย จะเกิดเส้นฐานที่ราว 50 วินาที และถ้าปิด optimization ทั้งหมดจะเร็วขึ้นเป็นราว 15 วินาที

ความยากในการเก็บข้อมูล trace ของ LLVM

  • rustc มี flag สำหรับดูข้อมูลของ LLVM
    • -Z time-llvm-passes: แสดงข้อมูลโปรไฟล์ของ LLVM เป็นข้อความธรรมดา
    • -Z llvm-time-trace: ส่งออกโปรไฟล์ LLVM ในรูปแบบ Chrome tracing
  • -Z time-llvm-passes ไปติดข้อจำกัด log เริ่มต้นของ Docker BuildKit
    • BUILDKIT_STEP_LOG_MAX_SIZE
    • BUILDKIT_STEP_LOG_MAX_SPEED
  • ตัวแปรสภาพแวดล้อมเหล่านี้ต้องตั้งที่ Docker daemon ไม่ใช่ที่คำสั่ง docker build และบน Linux สามารถตั้งให้ docker.service ผ่าน systemd drop-in ได้
  • เมื่อปลดข้อจำกัดแล้ว ก็มีข้อความออกมาราว 200,000 บรรทัด ซึ่งจัดการโดยตรงได้ยาก
  • -Z llvm-time-trace สร้างไฟล์ *.llvm_timings.json แต่ไฟล์ trace ของไบนารีสุดท้ายเป็น JSON บรรทัดเดียวขนาด 1.4GiB
  • ทั้ง Firefox Profiler, Perfetto UI และ chrome://tracing ของ Chromium ต่างมีปัญหาในการจัดการไฟล์นี้
  • จึงแปลง JSON ให้เป็น JSONL เพื่อใช้กับเครื่องมือทั่วไป
    • แยก array traceEvents ของ JSON object เดียวออกเป็นหนึ่งบรรทัดต่อหนึ่ง event
    • หลังแปลงแล้วมีทั้งหมด 7,301,865 บรรทัดของ event

คอขวดที่เห็นจาก event ของ LLVM

  • event ของ LLVM ส่วนใหญ่เป็น complete event ที่มี "ph":"X" โดย field dur แสดง duration ในหน่วยไมโครวินาที
  • "ph":"M" เป็น metadata event ซึ่งในการวิเคราะห์นี้ไม่ได้ให้ข้อมูลที่เป็นประโยชน์มากนัก
  • ใน event แบบ aggregate รายการที่ใช้เวลามากมีดังนี้
    • Total ModuleInlinerWrapperPass: 665.37 วินาที
    • Total ModuleToPostOrderCGSCCPassAdaptor: 656.47 วินาที
    • Total DevirtSCCRepeatedPass: 632.44 วินาที
    • Total OptFunction: 189.62 วินาที
    • Total InlinerPass: 182.25 วินาที
  • การรันครั้งนี้ใช้เวลาราว 110 วินาที บนเครื่อง 16 คอร์ จึงมีบาง pass ที่ถูกนับเวลาซ้ำ
  • แกนหลักของปัญหาคือการ optimize ฟังก์ชันอย่าง OptFunction และการ inline อย่าง InlinerPass

การปรับ threshold ของการ inline

  • ตัวเลือก inline ของ LLVM สามารถส่งให้ rustc ผ่าน -C llvm-args ได้
  • ณ เดือนมิถุนายน 2025 คำสั่ง rustc -C llvm-args='--help-list-hidden' แสดงตัวเลือกเกี่ยวกับ inline ราว 100 รายการ
  • ตัวเลือกที่ใช้ในการทดลองมี 3 ตัว
    • --inlinedefault-threshold=225
    • --inline-threshold=225
    • --inlinehint-threshold=325
  • threshold โดยคร่าว ๆ คือยอมให้ inline ฟังก์ชันที่มีต้นทุนต่ำกว่าค่านั้น ดังนั้นเมื่อปรับให้ต่ำลง การ inline ก็จะลดลง
  • เมื่อลด threshold ทั้งสามเหลือ 50 เวลา compile ลดจาก 48.8 วินาที เหลือ 42.2 วินาที
  • สำหรับกรณีใช้งานเป็นเว็บไซต์ส่วนตัวที่แทบไม่มีโหลด ผู้เขียนมองว่า threshold 10 ก็น่าสนใจเช่นกัน

OptFunction และ generic monomorphization

  • ใน event ของ OptFunction ค่า args.detail จะมี mangled symbol ของฟังก์ชันที่กำลังถูก optimize
  • หาก demangle ด้วย rustfilt ก็จะเห็น Rust symbol เดิม เช่น
    • __rustc::__rust_alloc
    • serde_json::value::to_value
  • เหตุที่ serde_json::value::to_value เดียวกันปรากฏหลาย hash เป็นเพราะ generic function ถูก monomorphize ด้วย type parameter ที่ต่างกัน
  • ฟังก์ชันจาก crate อื่นก็ถูก optimize ใน final crate ด้วย เพราะตำแหน่งที่ฟังก์ชันถูก monomorphize เป็น type เฉพาะนั้นอยู่ในบริบทของ crate ที่เรียกใช้
  • ตัวอย่างฟังก์ชันที่ใช้เวลา optimize สูง ได้แก่
    • closure ภายใน web_http_server::photos::PhotosState::new
    • closure ภายใน web_http_server::run
    • tokio_postgres::connect_raw
    • generic function ขนาดราว 500 บรรทัดของ pulldown_cmark
    • core::ptr::drop_in_place ของ concrete type หลายแบบ
  • หากรวมแบบหยาบตามชื่อ crate ชั้นนอก core มากที่สุดที่ 61.53 วินาที และในนั้น 84% คือการ parameterize ของ core::ptr::drop_in_place

ใช้ v0 symbol mangling เพื่อดูตำแหน่งของ async function ให้ชัดขึ้น

  • legacy symbol mangling แบบ default ทำให้แยก closure ได้ยาก
  • เมื่อเพิ่ม -C symbol-mangling-version=v0 จะเห็นหมายเลข closure และข้อมูล generic type ได้ชัดขึ้น
  • ตัวอย่างเช่น สามารถเห็น generic argument ทั้งหมดของ serde_json::value::to_value ที่ถูก monomorphize ด้วย type ของ web_http_server
  • รายการที่แพงใน output แบบ v0 มีดังนี้
    • <web_http_server::photos::PhotosState>::new::{closure#0}: 1.99 วินาที
    • web_http_server::run::{closure#0}: 1.56 วินาที
    • core::ptr::drop_in_place::<axum::routing::Endpoint<web_http_server::AppState>>: 1.22 วินาที
  • แม้ภายนอกจะดูเป็น closure เล็ก ๆ แต่เมื่อ dump LLVM IR กลับพบว่า async function และ async block ถูกแทนภายในเป็น nested closure
  • ใน Rust มี open issue เกี่ยวกับ mangling ของ async function/block อยู่แล้ว

async function ขนาดใหญ่และ Pin<Box<dyn Future>>

  • รายการที่แพงจริง ๆ ไม่ใช่ตัว closure เอง แต่เป็น เนื้อในของ async function ที่มีขนาดใหญ่
  • เวลา optimization ที่เกี่ยวกับ PhotosState::new ตอนแรกมีรวม 5.3 วินาที
  • การลองแยกฟังก์ชันแบบง่าย ๆ ครั้งแรก ลดลงได้เพียงเล็กน้อยเป็น 4.66 วินาที
  • การรวม .await ที่อยู่ติดกันเพื่อลดจำนวน .await จาก 10 จุดเหลือ 3 จุด กลับแย่ลงเป็น 6.24 วินาที
  • เนื่องจาก async function ถูกลดรูปภายในเป็น state machine ที่ซับซ้อน จึงลองลบรายละเอียด implementation ออกจากฝั่ง caller ด้วยการ erase Future ให้เป็น trait object
  • ฟังก์ชันที่ใช้มีรูปแบบห่อ impl Future<Output = T> เป็น Pin<Box<dyn Send + Future<Output = T>>>
  • เมื่อนำไปใช้ตามจุด .await เช่น erase(get_img_candidates()).await? ได้ผลดังนี้
    • เวลาที่เกี่ยวกับ PhotosState::new ลดลงเหลือ 2.14 วินาที
    • เวลาบิลด์รวมแบบไม่เปิด profiling ลดจาก 48.8 วินาที เหลือ 46.8 วินาที
  • มีการลอง #[inline(never)] และปิดการ inline ของฟังก์ชัน poll ด้วย แต่ผลไม่ดีเท่าการทำ boxing

ผลลัพธ์เมื่อรวมหลายการเปลี่ยนแปลง

  • แนวทางที่นำมาใช้มี 3 อย่าง
    • ลดการ inline ผ่าน LLVM args
    • แยกฟังก์ชันที่แพงใน main crate และทำ boxing ให้ async Future
    • ลด generic ใน API ของ dependency เพื่อลดส่วนที่ต้องคอมไพล์ซ้ำใน final crate
  • Dockerfile สุดท้ายใส่ RUSTFLAGS ที่ลด threshold ของการ inline ทั้งสามตัวเหลือ 10 ให้กับทั้ง cargo chef cook และ cargo build
  • ใน main crate มีการเปลี่ยนแปลงรวม เพิ่ม 898 บรรทัด ลบ 657 บรรทัด กระจายใน 10 ไฟล์
  • ฝั่ง dependency ก็มีการปรับด้วย
    • PR เพื่อทำให้ generic function ของ pulldown-cmark กลายเป็น non-generic
    • local crate ที่เปิดเผย API รุ่น non-generic สำหรับสิ่งที่ใช้ใน lol_html, deadpool_postgres
  • เมื่อรวมทั้งหมดนี้ เวลา compile สุดท้ายเหลือ 32.3 วินาที

อัปเดต 2025-06-27: -Zshare-generics และการเลิกใช้ Alpine

  • จากข้อเสนอใน Bluesky และ Lobsters จึงมีการทดลองเพิ่มอีก 2 อย่าง
    • เปิดใช้ -Zshare-generics
    • เลิกใช้ Alpine
  • -Zshare-generics เป็น flag สำหรับ reuse generic instance จาก crate dependency
    • ไม่ได้เปิดเป็นค่า default ใน release build
    • แต่เปิดใช้อยู่ใน dev build ของ stable toolchain
    • flag นี้ใช้ได้เฉพาะบน nightly
  • เมื่อเปิด -Zshare-generics เวลา compile รวมลดจาก 32.3 วินาที เหลือ 29.1 วินาที
  • แม้ยังมีการ compile instance ของ drop_in_place จำนวนมากอยู่ แต่เวลา optimization ของส่วนนี้ลดจาก 21.7 วินาที เหลือ 17.4 วินาที
  • เมื่อเปลี่ยนจาก Alpine เป็น Debian และเอา --target=x86_64-unknown-linux-musl ออก เวลา compile รวมลดลงมากจาก 29.1 วินาที เหลือ 9.1 วินาที
  • เบื้องหลังของข้อเสนอนี้คือ allocator ค่า default อาจมีผลต่อเวลาบิลด์อย่างมาก

ตัวเลขสุดท้ายและโจทย์ที่ยังเหลือ

  • การเปลี่ยนแปลงสุดท้ายมีดังนี้
    • จุดเริ่มต้น: ราว 175 วินาที
    • ปิด LTO และ debug symbol: 51 วินาที, -71%
    • final crate opt-level = 1: 48.8 วินาที, -4%
    • ลดการ inline ด้วย -C llvm-args: 40.7 วินาที, -16%
    • เปลี่ยนโค้ดภายใน: 37.7 วินาที, -7%
    • เปลี่ยน dependency: 32.3 วินาที, -14%
    • -Zshare-generics: 29.1 วินาที, -10%
    • เลิกใช้ Alpine: 9.1 วินาที, -69%
  • ตลอดกระบวนการวิเคราะห์ เครื่องมือและเอกสารต่าง ๆ ใช้งานได้ดีพอที่จะนำไปสู่การปรับปรุงจริง
  • แต่ก็ยังมีปัญหาซับซ้อนบางอย่างค้างอยู่
    • เวลาคอมไพล์ของ async call graph ที่ลึกยังควรได้รับการปรับปรุงเพิ่ม
    • แนวทางทำ special-case ให้ core::ptr::drop_in_place<T> ถูก compile ที่ crate ที่นิยาม T อาจช่วยได้ในบางกรณี แต่ใช้กับ generic type ได้ยาก และเสี่ยงคอมไพล์ drop glue ที่ไม่ได้ใช้งานด้วย
    • -Zshare-generics ช่วยได้ แต่ยังไม่ใช่คำตอบทั้งหมด
    • อาจต้องมีเครื่องมือเพิ่มเพื่อแยกให้เห็นว่า codebase ส่วนไหนใช้เวลาคอมไพล์มาก และเสนอวิธีบรรเทาได้
  • ในทางปฏิบัติ การตั้ง opt-level = 0 ให้ final crate ก็อาจเป็นตัวเลือกที่เพียงพอได้เช่นกัน

ยังไม่มีความคิดเห็น

ยังไม่มีความคิดเห็น