1 คะแนน โดย GN⁺ 2 시간 전 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • Async Rust ช่วยให้โค้ดที่ไม่ขึ้นกับ executor สามารถรันได้ทั้งบนเซิร์ฟเวอร์และไมโครคอนโทรลเลอร์ แต่ state machine ที่คอมไพเลอร์สร้างขึ้นทำให้ขนาดไบนารีเพิ่มขึ้นอย่างชัดเจน โดยเฉพาะในงาน embedded
  • แม้แต่ตัวอย่างง่าย ๆ อย่าง bar() ที่มีจุด await 2 จุด ก็ยังสร้าง MIR 360 บรรทัด และสถานะ Unresumed, Returned, Panicked, Suspend0, Suspend1 ขณะที่เวอร์ชัน synchronous ต้องใช้เพียง 23 บรรทัด
  • หากเปลี่ยนให้ future ที่เสร็จแล้วเมื่อถูก poll ซ้ำคืนค่า Poll::Pending แทน panic ก็ยังคงเป็นไปตามสัญญาโดยไม่เกิด unsafe behavior และจากการทดลองพบว่า ขนาดไบนารีของ embedded firmware ลดลง 2%~5%
  • แม้ async { 5 } ที่ไม่มี await ปัจจุบันก็ยังสร้าง state machine ที่มี 3 สถานะพื้นฐาน แต่หากปรับให้คืน Poll::Ready(5) ทุกครั้งแทน จะทำให้ขนาดไบนารี embedded ลดลง 0.2%
  • Project Goal ที่เสนอขึ้นมามุ่งผลักดันงานในคอมไพเลอร์ เช่น ตัด panic หลังเสร็จสิ้นในโหมด release, ตัด state machine ของ async block ที่ไม่มี await, inline future ที่มี await เดียว และรวมสถานะที่ซ้ำกัน

ปัญหาความเทอะทะระดับคอมไพเลอร์ของ Async Rust

  • Async Rust ช่วยให้โค้ดที่ไม่ขึ้นกับ executor สามารถทำงานได้พร้อมกันทั้งบนเซิร์ฟเวอร์และไมโครคอนโทรลเลอร์ แต่บนไมโครคอนโทรลเลอร์ขนาดเล็ก การเพิ่มขึ้นของขนาดไบนารีจะเห็นได้ชัดเป็นพิเศษ
  • บล็อก Rust เคยแนะนำ async/await ว่าเป็น zero-cost abstraction แต่ในทางปฏิบัติ async กลับสร้างความเทอะทะ (bloat) จำนวนมาก และแม้เดสก์ท็อปกับเซิร์ฟเวอร์ก็มีปัญหาเดียวกัน เพียงแต่มีหน่วยความจำและทรัพยากรประมวลผลมากพอจนมองเห็นได้น้อยกว่า
  • ต่อจาก วิธีเลี่ยง เพื่อลดความเทอะทะเวลาที่เขียนโค้ด async จึงมีการยื่น Project Goal เพื่อแก้ปัญหานี้ในคอมไพเลอร์
  • ปัญหาที่ future มีขนาดใหญ่เกินจำเป็นและมีการคัดลอกมากเกินไปไม่ได้อยู่ในขอบเขตนี้
    • ปัญหานี้เป็นที่รู้จักอยู่แล้ว และมี PR ที่เปิดไว้เพื่อจัดการบางส่วน: https://github.com/rust-lang/rust/pull/135527

โครงสร้างของ future ที่ถูกสร้างขึ้น

  • โค้ดตัวอย่างคือ foo() คืนค่า async { 5 } และ bar() ทำ foo().await + foo().await
    • ตัวอย่าง Godbolt: godbolt
  • bar มีจุด await 2 จุด ดังนั้น state machine จึงต้องมีอย่างน้อย 2 สถานะ แต่ในความเป็นจริงกลับมีสถานะมากกว่านั้น
  • Rust compiler สามารถ dump MIR ได้หลาย pass และ pass coroutine_resume คือ MIR pass สุดท้ายที่เฉพาะกับ async
    • async ยังคงอยู่ใน MIR แต่ไม่เหลืออยู่ใน LLVM IR แล้ว ดังนั้นกระบวนการแปลง async เป็น state machine จึงเกิดขึ้นใน MIR pass
  • ฟังก์ชัน bar สร้าง MIR 360 บรรทัด ขณะที่เวอร์ชัน synchronous ใช้เพียง 23 บรรทัด
  • CoroutineLayout ที่คอมไพเลอร์แสดงออกมานั้นโดยพื้นฐานคือชุดสถานะแบบ enum
    • Unresumed: สถานะเริ่มต้น
    • Returned: สถานะที่เสร็จสิ้นแล้ว
    • Panicked: สถานะหลัง panic
    • Suspend0: จุด await แรก และเก็บ future ของ foo
    • Suspend1: จุด await ที่สอง และเก็บผลลัพธ์แรกพร้อม future ของ foo ตัวที่สอง
  • Future::poll เป็นฟังก์ชันที่ปลอดภัย ดังนั้นแม้จะถูกเรียกอีกครั้งหลัง future เสร็จแล้ว ก็ต้องไม่ก่อให้เกิด UB
    • ปัจจุบัน หลัง Suspend1 จะคืน Ready และเปลี่ยน future ไปเป็นสถานะ Returned
    • หาก poll อีกครั้งในสถานะนี้ จะเกิด panic
  • สถานะ Panicked ดูเหมือนมีไว้เพื่อป้องกันไม่ให้ future ถูก poll อีกครั้ง หลัง async function panic แล้วถูกจับด้วย catch_unwind
    • เพราะหลัง panic แล้ว future อาจอยู่ในสถานะที่ไม่สมบูรณ์ และการ poll ซ้ำอาจนำไปสู่ UB
    • กลไกนี้คล้ายกับ mutex poisoning มาก
    • การตีความสถานะ Panicked แบบนี้ยังไม่มีเอกสารยืนยันชัดเจน จึงมั่นใจประมาณ 90%

การ poll หลังเสร็จสิ้นจำเป็นต้อง panic หรือไม่

  • future ในสถานะ Returned ปัจจุบันจะ panic แต่ไม่ได้จำเป็นว่าต้องเป็นแบบนั้นเสมอไป
    • เงื่อนไขที่จำเป็นจริง ๆ คือห้ามก่อให้เกิด UB
  • panic มีต้นทุนค่อนข้างสูง และเพิ่มเส้นทางที่มีผลข้างเคียงซึ่งยากต่อการลบออกด้วยการ optimize
  • หาก future ที่เสร็จแล้วเมื่อถูก poll ซ้ำคืน Poll::Pending ก็จะยังคงเป็นไปตามสัญญาของชนิด Future โดยไม่เกิด unsafe behavior
  • เมื่อแก้คอมไพเลอร์เพื่อทดลองแนวทางนี้ พบว่าใน embedded firmware ที่ใช้ async ขนาดไบนารีลดลง 2%~5%
  • มีข้อเสนอให้เปิดพฤติกรรมนี้เป็นสวิตช์คล้าย overflow-checks = false ของ integer overflow
    • ใน debug build ยังคง panic ต่อไปเพื่อให้พฤติกรรมที่ผิดถูกเปิดเผยทันที
    • ใน release build จะได้ future ที่มีขนาดเล็กลง
  • เมื่อใช้ panic=abort อาจมีโอกาสตัดสถานะ Panicked ออกไปได้เลย แต่ผลกระทบยังต้องพิจารณาเพิ่มเติม

ต่อให้ไม่มี await ก็ยังสร้าง state machine เสมอ

  • foo() คืนค่าเพียง async { 5 } ดังนั้นรูปแบบที่เหมาะที่สุดหากเขียนเองคือ future ที่ไม่มีสถานะ และคืน Poll::Ready(5) ทุกครั้ง
  • แต่ MIR ที่คอมไพเลอร์สร้างขึ้นยังคงมี 3 สถานะพื้นฐานคือ Unresumed, Returned, Panicked
    • ตอน poll จะตรวจ discriminant ของสถานะปัจจุบันแล้วจึงแตกแขนง
    • หาก poll อีกครั้งหลังเสร็จ จะ panic ด้วย assert `async fn` resumed after completion
  • ในกรณีนี้สามารถ optimize ได้โดยไม่สร้าง state machine และคืน Poll::Ready(5) ทุกครั้งแทน
  • เมื่อลองใส่การเปลี่ยนแปลงนี้ในคอมไพเลอร์แบบทดลอง พบว่าขนาดไบนารี embedded ลดลง 0.2%
    • แม้การลดลงจะไม่มาก แต่เป็น optimization ที่เรียบง่าย จึงอาจคุ้มค่าที่จะนำมาใช้
  • optimization นี้เปลี่ยนพฤติกรรมเล็กน้อย แต่ผู้ที่ได้รับผลกระทบมีเพียง executor ที่ไม่ทำตามข้อกำหนดเท่านั้น
    • ปัจจุบันคอมไพเลอร์จะ panic เมื่อมีการ poll ภายหลัง
    • หลัง optimize แล้ว future จะคืน Ready เสมอ

พึ่งพา LLVM อย่างเดียวไม่เพียงพอ

  • แม้ผลลัพธ์ MIR จะไม่มีประสิทธิภาพ แต่บางครั้ง LLVM ก็สามารถจัดการให้ได้ทั้งหมด อย่างไรก็ดีเงื่อนไขค่อนข้างจำกัด
    • future ต้องเรียบง่ายพอ
    • ต้องใช้ opt-level=3
  • เมื่อ future ซับซ้อนขึ้น LLVM จะลบส่วนเกินออกไม่ได้ และเพราะโค้ด async Rust ตามแบบที่ใช้กันทั่วไปมักมี future ซ้อนลึก ความซับซ้อนจึงเพิ่มขึ้นอย่างรวดเร็ว
  • ในสภาพแวดล้อมที่มัก optimize เพื่อขนาดอย่าง embedded หรือ wasm, LLVM ไม่สามารถ optimize ทุกอย่างได้หมด
  • ตัวอย่าง Godbolt: https://godbolt.org/z/58ahb3nne
    • ใน assembly ที่สร้างขึ้น LLVM รู้ว่า foo คืนค่า 5 แต่ยัง optimize คำตอบของ bar ให้เป็น 10 ไม่ได้
    • การเรียก poll ของ foo ก็ยังคงอยู่
    • สาเหตุคือยังมีเส้นทาง panic ที่เป็นไปได้ซึ่งคอมไพเลอร์ไม่สามารถสรุปทิ้งได้ทั้งหมด
    • LLVM ไม่รู้ว่าแท้จริงแล้ว foo ถูกเรียกเพียงครั้งเดียวและไม่ panic
  • หากคอมเมนต์ทิ้ง branch ที่ panic ใน IR จะ optimize ได้ดีขึ้น: https://godbolt.org/z/38KqjsY8E
  • แทนที่จะหวังการ optimize ภายหลังจาก LLVM คอมไพเลอร์ควรส่งอินพุตที่ดีกว่าให้ LLVM ตั้งแต่แรก

การ inline future ยังทำได้ไม่ดี

  • การ inline สำคัญเพราะเปิดทางให้ optimization pass อื่น ๆ ทำงานต่อได้ แต่ future ที่ Rust สร้างขึ้นในปัจจุบันยังไม่ถูก inline ในช่วงต้น
  • หลังแต่ละ future ได้ implementation แล้ว LLVM และ linker จึงค่อยมีโอกาส inline แต่จากปัญหาก่อนหน้านี้ เวลานั้นถือว่าช้าเกินไป
  • โอกาส inline ที่ตรงไปตรงมาที่สุดคือรูปแบบที่ bar() ทำเพียง foo(blah).await
    • เป็นแพตเทิร์นที่พบได้บ่อยเมื่อสร้าง abstraction ด้วย trait
    • ปัจจุบันคอมไพเลอร์จะสร้าง state machine สำหรับ bar แล้วค่อยเรียก state machine ของ foo ข้างใน
    • ที่มีประสิทธิภาพกว่าคือให้ bar กลายเป็น future ของ foo เอง
  • กรณีที่มี preamble และ postamble จะซับซ้อนขึ้น
    • เช่น bar(input) สร้าง blah จาก input > 10 จากนั้น foo(blah).await แล้วนำผลลัพธ์ไป * 2
    • พบได้บ่อยเวลาที่แปลง async function ไปเป็น signature อื่น โดยเฉพาะใน trait implementation
  • bar ในรูปแบบนี้ก็ยังไม่จำเป็นต้องมีสถานะ async ของตัวเอง
    • เพราะไม่มีข้อมูลใดที่ต้องถูกเก็บข้ามจุด await เดียว นอกจากค่าที่ถูกจับไว้ใน foo
    • เพียงแต่ bar จะกลายเป็น foo ตรง ๆ ไม่ได้ และต้องพึ่งพาสถานะส่วนใหญ่จาก foo
  • หากเขียนเอง BarFut อาจมีสถานะ Unresumed { input } และ Inlined { foo: FooFut }
    • ในการ poll ครั้งแรก จะรัน preamble เพื่อสร้าง foo(blah) แล้วเปลี่ยนเป็นสถานะ Inlined
    • หลังจากนั้นจะนำผลลัพธ์ของ foo.poll(cx) ไปผ่าน postamble
  • หากสามารถรันโค้ดล่วงหน้าก่อนถึง await จุดแรกได้ ก็อาจตัดสถานะ Unresumed ออกได้ด้วย แต่มีการรับประกันว่า future จะไม่ทำอะไรเลยก่อนถูก poll จึงเปลี่ยนไม่ได้
  • หากสามารถ query คุณสมบัติของ future ที่กำลังถูก poll ได้ ก็จะเปิดทางให้มี optimization การ inline เพิ่มเติม
    • ตัวอย่างเช่น หากรู้ว่า future คืนค่า ready เสมอในการ poll ครั้งแรก ก็ไม่จำเป็นที่ future ฝั่งผู้เรียกจะต้องสร้างสถานะสำหรับ await จุดนั้น
    • หากนำ optimization แบบนี้ไปใช้แบบ recursive ก็อาจยุบ future จำนวนมากให้กลายเป็น state machine ที่เรียบง่ายกว่ามากได้
  • จากโครงสร้างของ rustc ในปัจจุบัน ดูเหมือนว่า async block แต่ละก้อนจะถูกแปลงแยกกัน และข้อมูลที่เกี่ยวข้องจะไม่ถูกเก็บต่อไว้ จึงไม่สามารถ query แบบนี้ได้
  • future inline ยังไม่ได้ถูกทดลองจริง แต่คาดว่าจะช่วยได้มากทั้งกับขนาดไบนารีและประสิทธิภาพ

การรวมสถานะที่เหมือนกัน

  • สำหรับแต่ละจุด await ใน async block state machine จะมีสถานะเพิ่มขึ้น
  • โค้ดลักษณะต่อไปนี้เขียนได้เป็นธรรมชาติ แต่เพราะ await async function เดียวกันในสอง branch จึงเกิดสถานะที่เหมือนกัน 2 ชุด
    • CommandId::A => send_response(123).await
    • CommandId::B => send_response(456).await
  • ในกรณีนี้ CoroutineLayout จะมี _s0, _s1 ซึ่งเก็บ coroutine type เดียวกันของ send_response และมีสถานะ Suspend0, Suspend1 สองสถานะ
  • MIR ของฟังก์ชันนี้มีความยาว 456 บรรทัด และหลาย basic block ก็แทบจะซ้ำกันทั้งหมด
  • หาก refactor เองโดยคำนวณเฉพาะค่าตอบกลับก่อน แล้วค่อยเรียก send_response(response).await เพียงครั้งเดียว สถานะซ้ำเหล่านี้จะหายไป
    • CommandId::A คือ 123
    • CommandId::B คือ 456
    • จากนั้น send_response(response).await
  • หลัง refactor แล้ว CoroutineLayout จะเหลือ future ที่เก็บไว้เพียงตัวเดียว และเหลือแค่สถานะ Suspend0 สถานะเดียว
  • ความยาว MIR ทั้งหมดลดลงเหลือ 302 บรรทัด และความซ้ำซ้อนหายไป
  • ดังนั้น optimization pass ที่ค้นหาเส้นทางโค้ดและสถานะที่เหมือนกันแล้วรวมเข้าด้วยกันจึงดูมีประโยชน์
    • optimization นี้มีแนวโน้มว่าจะทำงานร่วมกับ future inline pass ได้ดี

ลิงก์การทดลองและ benchmark เพิ่มเติม

ขอการสนับสนุน Project Goal

  • งานนี้ถูกยื่นเป็น Project Goal เพื่อดำเนินการในคอมไพเลอร์: https://rust-lang.github.io/rust-project-goals/2026/async-statemachine-optimisation.html
  • หากไม่มีเงินทุนก็ยากที่จะเดินหน้างานได้มาก จึงต้องการการสนับสนุนบางส่วนหรือทั้งหมดจากบริษัทหรือองค์กรที่จะได้รับประโยชน์จากงานนี้
  • ติดต่อได้ที่ dion@tweedegolf.com
  • ขอบเขตงานและขนาดเงินทุนที่ต้องการยังยืดหยุ่นได้ แต่คาดว่า €30k น่าจะเพียงพอสำหรับทำงานทั้งหมดหรืออย่างน้อยส่วนใหญ่ให้เสร็จ

1 ความคิดเห็น

 
GN⁺ 2 시간 전
ความเห็นจาก Lobste.rs
  • พอเห็นแค่ชื่อเรื่องก็คิดไว้แบบหนึ่ง แต่บทความนี้ สร้างสรรค์กว่าที่คาดไว้มาก

    • มองว่าใกล้เคียงข้อเท็จจริงเลย หลังจาก เปิดตัว MVP มา 7 ปี ก็แทบไม่มีความคืบหน้าเลยทั้งด้านการออกแบบภาษาและการพัฒนาคอมไพเลอร์ และเพราะคนที่เป็นกำลังหลักในการทำ MVP ต่างก็ลดบทบาทในโปรเจ็กต์ลงในช่วงเวลาใกล้กัน ทำให้การส่งต่องานหลังจากนั้นแทบหยุดชะงัก
      หวังว่าคนที่อยากทำงานนี้จะได้รับการสนับสนุนที่จำเป็น
  • I want to work on this in the compiler and as such have submitted it as a Project Goal

    Stop generating statemachines that don’t have to be there
    Make the compiler’s job easier by removing panic paths and branches
    Make statemachines smaller

    ดีใจที่เห็นว่าปัญหานี้กำลังถูกหยิบมาจัดการ ก่อนหน้านี้เคยเห็นหลายบทความบอกว่า rustc โยนโค้ดให้ LLVM มากเกินไป แล้วคาดหวังให้ตัว optimize จัดการทุกอย่างให้ โดยเฉพาะบทความนี้ยังขอ เงินสนับสนุน สำหรับงานดังกล่าวด้วย

  • โอ้โห ฉันนี่โง่เอง
    ฉันคิดมาตลอดว่า async มัน “อ้วน” โดยเนื้อแท้ เพราะไม่ว่ารูปแบบไหนก็ต้องมี runtime, การติดตามงาน, และการ polling เพื่อตรวจว่าทำเสร็จหรือยัง
    เพราะ overhead นั้นมันไม่เป็นศูนย์อยู่แล้ว ฉันเลยมองว่า “zero-cost abstraction” ที่พูดกันเป็นเรื่องของฟีเจอร์ภาษา และแยกจาก runtime ที่พ่วงเข้ามา
    ไม่เคยนึกเลยว่าจะต้องไปดูว่า rustc ปล่อยอะไรออกมาก่อนส่งให้ LLVM

  • สำหรับคนที่ไม่คุ้นกับ async Rust:

    It's amazing how we can write executor agnostic code that can run concurrently on huge servers and tiny microcontrollers.

    อันนี้จริงมาก ต้นไม้ของการเรียก async ที่ซ้อนกัน แม้จะลึกแค่ไหน ถ้าผ่านการ optimize เต็มที่แล้วก็จะยุบกลายเป็น struct เดียว ที่มี state machine อยู่ข้างใน เป็นวิธีที่ฉลาดมากจริงๆ

  • ถ้าไปถึงกรณีนี้ใน release build มันจะกลายเป็น deadlock แบบหนึ่งหรือเปล่า? หรืออาจเกิดการรั่วเพราะมี task ที่รอ future ที่เป็น Pending ตลอดเวลา?

    • ใช่ future แบบนั้นจะ ค้างอยู่ และไม่มีวัน complete แต่สถานะแบบนี้จะไปถึงได้เฉพาะในโค้ด async ระดับล่างที่มีบั๊กอยู่แล้วเท่านั้น และโค้ดที่ติดตาม future ที่ complete แล้วได้ไม่ถูกต้อง ก็น่าจะมีแนวโน้มก่อทั้งการรั่วและ deadlock อยู่แล้ว
      การ poll ผิดผ่าน .await ทำไม่ได้
  • มีความคิดอยู่สองสามข้อ:

    1. บทความนี้ดูเหมือนกำลังบอกว่าควรย้าย logic การ optimize ออกมาจาก LLVM ให้มากขึ้น แล้วเอามาไว้ที่ระดับ MIR แทน เช่น พอจะเข้าใจได้ว่าทำไมการ inline ฟังก์ชัน async ถึงทำใน MIR ได้ง่ายกว่าใน LLVM ถ้าทำกับ async ได้แล้ว ก็สงสัยว่าควร generalize logic นั้นไปยังฟังก์ชัน synchronous ด้วยไหม แล้วค่อยตัด optimization pass บางส่วนออกจาก LLVM แม้จะรู้ว่าเป็นงานใหญ่มาก และคำถามนี้ก็เป็นเรื่องทิศทางมากกว่าความเป็นไปได้เชิงปฏิบัติ พอคอมไพเลอร์ส่วน frontend/middle-end ซับซ้อนถึงระดับหนึ่ง ก็ดูมีเหตุผลที่ optimization แบบทั่วไปของ LLVM จำนวนมากอาจควรถูกย้ายไปทำที่อื่นแทน
    2. ฉันก็ยังไม่ชอบ panic=unwind อยู่ดี นอกจาก test harness บางแบบแล้ว แทบไม่เคยเห็นข้อดีที่มากพอจะชดเชยต้นทุนเมื่อเทียบกับ panic=abort เลย แม้แต่ test harness เอง บน Linux ก็ดูเหมือนจะใช้ทางเลือกคล้ายกันได้ เช่น ใช้ clone แบบแปลกๆ แล้ว wait เฉพาะ thread ที่รัน แทน pthread_join ตรงนี้ฉันอาจเข้าใจผิดก็ได้
  • ลิงก์นี้เพิ่งใช้ไม่ได้สำหรับคนอื่นด้วยไหม?
    แก้ไข: หน้า blog post โผล่มาได้ประมาณครึ่งวินาทีแล้วก็เด้งไปหน้า 404
    แก้ไข 2: เข้าไปที่รายการบทความในบล็อกแล้วลองกดไปหลายอัน พอเปิดบทความนั้นจากในรายการก็ยังไปหน้า 404 เหมือนเดิม จะทำบล็อกที่เป็น static page หรืออย่างน้อยควรจะเป็นแบบนั้น ให้พังได้ขนาดนี้ยังไงกัน?

    • น้ำเสียงดูหยาบและก้าวร้าวเกินจำเป็นนิดหน่อย เว็บไซต์ก็มีบั๊กกันได้ การรายงานปัญหานั้นมีประโยชน์ แต่คอมเมนต์นี้ฟังดูประชดประชันไปหน่อย
      สำหรับข้อมูลเพิ่มเติม ฉันลองทำตามขั้นตอนเดิมเหมือนกันแล้ว แต่ไม่เจอ 404 เลย ทั้งบนมือถือและเดสก์ท็อป และลองทั้งเปิดกับปิด JavaScript แล้วด้วย เพราะงั้นอาการที่เจออาจซับซ้อนกว่าที่เห็นก็ได้