1 điểm bởi GN⁺ 2 giờ trước | 1 bình luận | Chia sẻ qua WhatsApp
  • Async Rust cho phép chạy cùng một đoạn mã độc lập với executor trên cả máy chủ và vi điều khiển, nhưng do state machine mà compiler tạo ra, kích thước binary tăng lên rất rõ, đặc biệt là trong môi trường embedded
  • Ngay cả ví dụ đơn giản như bar() với 2 điểm await cũng tạo ra 360 dòng MIR cùng các trạng thái Unresumed, Returned, Panicked, Suspend0, Suspend1, trong khi phiên bản đồng bộ chỉ cần 23 dòng
  • Nếu thay đổi để khi poll lại một future đã hoàn tất thì trả về Poll::Pending thay vì panic, vẫn có thể đáp ứng contract mà không gây hành vi unsafe, và trong thử nghiệm đã giúp giảm kích thước binary 2%~5% cho firmware embedded
  • Ngay cả async { 5 } không có await hiện tại cũng tạo state machine với mặc định 3 trạng thái, nhưng nếu tối ưu để luôn trả về Poll::Ready(5) thì kích thước binary embedded giảm 0,2%
  • Project Goal được đề xuất nhằm thúc đẩy trong compiler việc loại bỏ panic sau khi hoàn tất ở chế độ release, loại bỏ state machine cho async block không có await, inline future chỉ có một await, và gộp các trạng thái giống nhau

Vấn đề phình to ở cấp compiler của Async Rust

  • Async Rust cho phép chạy cùng một đoạn mã độc lập với executor trên cả máy chủ và vi điều khiển, nhưng trên các vi điều khiển nhỏ, mức tăng kích thước binary đặc biệt dễ thấy
  • Blog Rust từng giới thiệu async/await là abstraction không chi phí, nhưng trên thực tế async tạo ra rất nhiều bloat; vấn đề tương tự cũng tồn tại trên desktop và server, chỉ là ít lộ rõ hơn vì có nhiều bộ nhớ và tài nguyên tính toán hơn
  • Tiếp nối cách lách để tránh bloat khi viết mã async, một Project Goal đã được đệ trình để giải quyết vấn đề này ngay trong compiler
  • Vấn đề future lớn hơn mức cần thiết và bị sao chép quá nhiều không nằm trong phạm vi của bài này

Cấu trúc của future được tạo ra

  • Mã ví dụ có foo() trả về async { 5 }, còn bar() thực hiện foo().await + foo().await
  • bar có 2 điểm await nên state machine tối thiểu cần 2 trạng thái, nhưng trên thực tế còn tạo ra nhiều trạng thái hơn
  • Rust compiler có thể dump MIR ở nhiều pass khác nhau, và pass coroutine_resume là pass MIR cuối cùng dành riêng cho async
    • async vẫn còn trong MIR nhưng không còn trong LLVM IR, nên quá trình biến async thành state machine diễn ra ở các pass MIR
  • Hàm bar tạo ra 360 dòng MIR, trong khi phiên bản đồng bộ chỉ dùng 23 dòng
  • CoroutineLayout mà compiler in ra về bản chất là một tập trạng thái dưới dạng enum
    • Unresumed: trạng thái ban đầu
    • Returned: trạng thái đã hoàn tất
    • Panicked: trạng thái sau panic
    • Suspend0: điểm await đầu tiên, nơi lưu future của foo
    • Suspend1: điểm await thứ hai, nơi lưu kết quả đầu tiên và future foo thứ hai
  • Future::poll là hàm an toàn, nên dù bị gọi lại sau khi future đã hoàn tất cũng không được gây ra UB
    • Hiện tại sau Suspend1, nó trả về Ready rồi chuyển future sang trạng thái Returned
    • Nếu poll lại ở trạng thái này thì sẽ xảy ra panic
  • Trạng thái Panicked dường như dùng để ngăn việc poll lại future sau khi hàm async panic rồi panic đó bị bắt bởi catch_unwind
    • Sau panic, future có thể ở trạng thái không hoàn chỉnh, nên poll lại có thể dẫn đến UB
    • Cơ chế này rất giống với mutex poisoning
    • Cách diễn giải này về trạng thái Panicked chỉ ở mức khoảng 90% chắc chắn vì khó tìm được tài liệu xác nhận rõ ràng

Có nhất thiết phải panic khi poll sau khi đã hoàn tất không?

  • Future ở trạng thái Returned hiện tại sẽ panic, nhưng không nhất thiết phải như vậy
    • Điều kiện cần chỉ là không gây ra UB
  • panic tương đối tốn kém, đồng thời thêm vào một nhánh có tác dụng phụ mà rất khó loại bỏ bằng tối ưu hóa
  • Nếu khi poll lại một future đã hoàn tất mà trả về Poll::Pending, vẫn có thể đáp ứng contract của kiểu Future mà không tạo ra hành vi unsafe
  • Khi thử nghiệm bằng cách chỉnh compiler theo hướng này, firmware embedded dùng async đã cho thấy giảm 2%~5% kích thước binary
  • Đề xuất là cung cấp hành vi này dưới dạng một tùy chọn, tương tự overflow-checks = false cho tràn số nguyên
    • Ở bản debug vẫn tiếp tục panic để lộ lỗi hành vi sai ngay lập tức
    • Ở bản release có thể nhận được future nhỏ hơn
  • Khi dùng panic=abort, có khả năng loại bỏ hoàn toàn trạng thái Panicked, nhưng tác động của việc này cần được xem xét thêm

Không có await nhưng vẫn luôn tạo state machine

  • foo() chỉ trả về async { 5 }, nên dạng tối ưu nhất nếu tự cài đặt bằng tay là một future không có trạng thái, luôn trả về Poll::Ready(5)
  • Nhưng MIR do compiler tạo ra vẫn có 3 trạng thái mặc định là Unresumed, Returned, Panicked
    • Khi poll, nó kiểm tra discriminant của trạng thái hiện tại rồi phân nhánh
    • Nếu poll lại sau khi đã hoàn tất thì sẽ panic với assert `async fn` resumed after completion
  • Trong trường hợp này có thể tối ưu để không tạo state machine mà chỉ luôn trả về Poll::Ready(5)
  • Khi áp dụng thử nghiệm vào compiler, kích thước binary embedded giảm 0,2%
    • Mức tiết kiệm không lớn, nhưng đây là tối ưu đơn giản nên vẫn có thể đáng để áp dụng
  • Tối ưu này có thay đổi hành vi một chút, nhưng chỉ ảnh hưởng đến các executor không tuân thủ quy ước
    • Compiler hiện tại sẽ panic ở lần poll sau
    • Sau tối ưu, future sẽ luôn trả về Ready

Chỉ LLVM là không đủ

  • Dù đầu ra MIR có kém hiệu quả, đôi khi LLVM vẫn có thể dọn dẹp hết, nhưng điều kiện khá hạn chế
    • future phải đủ đơn giản
    • phải dùng opt-level=3
  • Khi future phức tạp hơn, LLVM không thể loại bỏ hết, và vì mã async Rust theo kiểu thông dụng thường lồng future rất sâu nên độ phức tạp tăng rất nhanh
  • Trong các môi trường thường tối ưu theo kích thước như embedded hay wasm, LLVM không thể tối ưu hết mọi thứ
  • Ví dụ Godbolt: https://godbolt.org/z/58ahb3nne
    • Trong assembly được tạo ra, LLVM biết rằng foo trả về 5, nhưng không thể tối ưu đáp án của bar thành 10
    • Lời gọi hàm poll của foo vẫn còn đó
    • Lý do là tồn tại các nhánh panic tiềm năng mà compiler không thể xác quyết hoàn toàn
    • LLVM không biết rằng foo trên thực tế chỉ được gọi một lần và không panic
  • Nếu comment out nhánh panic trong IR thì tối ưu tốt hơn: https://godbolt.org/z/38KqjsY8E
  • Thay vì kỳ vọng LLVM tối ưu hậu kỳ, compiler nên cung cấp đầu vào tốt hơn cho LLVM

Inline future chưa hoạt động tốt

  • Inline rất quan trọng vì nó mở đường cho các pass tối ưu hóa tiếp theo, nhưng các future Rust được tạo ra hiện chưa được inline ở giai đoạn sớm
  • Chỉ sau khi mỗi future có phần hiện thực riêng thì LLVM và linker mới có cơ hội inline, nhưng do những vấn đề ở trên nên lúc đó đã quá muộn
  • Cơ hội inline trực tiếp nhất là khi bar() chỉ đơn thuần thực hiện foo(blah).await
    • Đây là mẫu rất hay gặp khi dùng trait để tạo lớp trừu tượng
    • Compiler hiện tại tạo state machine cho bar, rồi từ trong đó gọi state machine của foo
    • Hiệu quả hơn là để bar chính là future của foo
  • Trường hợp có preamble và postamble thì phức tạp hơn
    • Ví dụ: bar(input) tạo blah từ input > 10, sau đó foo(blah).await rồi áp dụng * 2 lên kết quả
    • Mẫu này rất phổ biến khi chuyển đổi async function sang chữ ký khác, đặc biệt trong triển khai trait
  • Dạng bar này cũng không nhất thiết cần trạng thái async riêng
    • Không có dữ liệu nào cần được giữ qua điểm await duy nhất ngoài giá trị đã được foo capture
    • Tuy vậy bar không thể đơn giản trở thành chính foo, mà phần lớn trạng thái có thể dựa vào foo
  • Nếu tự cài đặt bằng tay, BarFut có thể có các trạng thái Unresumed { input }Inlined { foo: FooFut }
    • Ở lần poll đầu tiên, nó chạy preamble để tạo foo(blah) rồi chuyển sang trạng thái Inlined
    • Sau đó áp dụng postamble lên kết quả của foo.poll(cx)
  • Nếu có thể thực thi sẵn mã trước điểm await đầu tiên thì còn có thể bỏ luôn trạng thái Unresumed, nhưng điều này không thể thay đổi vì bảo đảm rằng future không làm gì trước khi được poll
  • Nếu có thể truy vấn thuộc tính của future đang được poll thì sẽ có thêm cơ hội inline tối ưu
    • Ví dụ, nếu biết future luôn trả về ready ở lần poll đầu tiên, future gọi nó sẽ không cần tạo trạng thái cho điểm await đó
    • Nếu áp dụng đệ quy kiểu tối ưu này, rất nhiều future có thể được gộp thành state machine đơn giản hơn nhiều
  • Với cấu trúc hiện tại của rustc, có vẻ mỗi async block được biến đổi riêng lẻ và dữ liệu liên quan không được giữ lại sau đó, nên không thể thực hiện các truy vấn như vậy
  • Inline future vẫn chưa được thử nghiệm, nhưng được kỳ vọng sẽ mang lại lợi ích lớn cho cả kích thước binary lẫn hiệu năng

Gộp các trạng thái giống nhau

  • Với mỗi điểm await trong async block, state machine sẽ có thêm một trạng thái
  • Mã như sau là tự nhiên, nhưng vì cả hai nhánh đều await cùng một async function nên tạo ra 2 trạng thái giống nhau
    • CommandId::A => send_response(123).await
    • CommandId::B => send_response(456).await
  • Trong trường hợp này, CoroutineLayout sẽ có _s0, _s1 lưu cùng một kiểu coroutine của send_response, đồng thời tạo hai trạng thái Suspend0, Suspend1
  • MIR của hàm này dài 456 dòng, và nhiều basic block thực chất bị lặp lại
  • Nếu refactor thủ công để trước tiên chỉ tính giá trị phản hồi, rồi chỉ gọi một lần send_response(response).await, các trạng thái trùng lặp sẽ biến mất
    • CommandId::A123
    • CommandId::B456
    • sau đó send_response(response).await
  • Sau khi refactor, CoroutineLayout chỉ còn một future được lưu và chỉ còn một trạng thái Suspend0
  • Tổng độ dài MIR giảm xuống còn 302 dòng, và sự trùng lặp biến mất
  • Vì vậy, một pass tối ưu hóa tìm các đường đi mã và trạng thái giống nhau để gộp lại có vẻ rất hữu ích
    • Tối ưu này có khả năng kết hợp rất tốt với pass inline future

Liên kết thử nghiệm và benchmark bổ sung

Kêu gọi hỗ trợ cho Project Goal

  • Công việc này đã được đệ trình dưới dạng Project Goal để thực hiện trong compiler: https://rust-lang.github.io/rust-project-goals/2026/async-statemachine-optimisation.html
  • Nếu không có tài trợ thì rất khó đẩy được nhiều phần việc, vì vậy cần sự hỗ trợ một phần hoặc toàn phần từ các công ty hay tổ chức sẽ hưởng lợi từ công việc này
  • Thông tin liên hệ là dion@tweedegolf.com
  • Phạm vi công việc và mức tài trợ cần thiết có thể linh hoạt, nhưng ước tính €30k có thể hoàn thành toàn bộ hoặc phần lớn công việc

1 bình luận

 
Ý kiến trên Lobste.rs
  • Đây là một bài viết mang tính xây dựng hơn tôi tưởng rất nhiều khi chỉ nhìn tiêu đề

    • Tôi nghĩ nó khá sát với sự thật. Đã 7 năm kể từ khi phát hành MVP, nhưng hầu như không có tiến triển nào đáng kể trong thiết kế ngôn ngữ hay triển khai trình biên dịch, và những người chủ yếu tạo ra MVP cũng giảm mức độ tham gia dự án vào khoảng cùng thời điểm, khiến việc chuyển giao sau đó gần như bị đình trệ
      Mong rằng những ai muốn làm việc này sẽ nhận được sự hỗ trợ cần thiết
  • 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

    Thật tốt khi thấy vấn đề này đang được xử lý. Tôi đã vài lần đọc những bài viết nói rằng hiện tại rustc đang chuyển quá nhiều mã sang LLVM và chỉ mong trình tối ưu hóa xử lý hết, đặc biệt bài này còn đang kêu gọi tài trợ cho công việc đó

  • Trời ạ, hóa ra tôi đã ngốc thật
    Tôi luôn nghĩ async về bản chất sẽ luôn “cồng kềnh” ở một mức nào đó vì nó cần runtime, theo dõi tác vụ và polling để xác nhận hoàn thành. Phần overhead đó đâu thể bằng 0
    Tôi vẫn cho rằng “trừu tượng không chi phí” ở đây là nói về tính năng ngôn ngữ, tách biệt với runtime được thêm vào
    Tôi thậm chí chưa từng nghĩ đến việc xem rustc thực sự tạo ra gì trước khi chuyển cho LLVM

  • Dành cho những ai chưa quen với async Rust:

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

    Điều này thực sự đúng. Ngay cả một cây lồng nhiều lời gọi async, sau tối ưu hóa tối đa, cũng sẽ được cô đặc thành một struct duy nhất với máy trạng thái bên trong. Cách làm này thực sự rất thông minh

  • Nếu đi tới trường hợp này trong bản build phát hành thì có tạo ra một kiểu deadlock không? Hay cũng có thể gây rò rỉ do các tác vụ cứ chờ những công việc luôn ở trạng thái Pending?

    • Đúng vậy. Những future như thế sẽ rơi vào trạng thái bị kẹt và sẽ không bao giờ hoàn thành. Tuy nhiên, trạng thái đó chỉ có thể đạt tới trong mã async mức thấp vốn đã có lỗi, và đoạn mã không theo dõi đúng future đã hoàn thành thì rất có thể vốn dĩ cũng đã gây rò rỉ và deadlock rồi
      Không thể poll sai bằng .await
  • Tôi có vài suy nghĩ:

    1. Bài này có vẻ đang lập luận rằng nên đưa thêm nhiều logic tối ưu hóa ra khỏi LLVM và chuyển lên tầng MIR. Ví dụ, tôi hiểu vì sao việc inline hàm async ở MIR lại dễ hơn ở LLVM. Nếu đã làm được việc đó cho async trong MIR, thì liệu có thể khái quát hóa logic đó cho cả hàm đồng bộ và loại bỏ một phần các optimization pass của LLVM không? Tôi biết đây là một việc lớn, và đây gần như là câu hỏi về định hướng hơn là câu hỏi thực dụng. Có vẻ như khi trình biên dịch frontend/middle-end đủ phức tạp, sẽ hợp lý hơn nếu chuyển một phần đáng kể các tối ưu hóa tổng quát của LLVM sang nơi khác
    2. Tôi vẫn không thích panic=unwind. Ngoài một số test harness, tôi gần như chưa thấy lợi ích nào của nó đủ để bù lại chi phí so với panic=abort. Ngay cả test harness, ít nhất trên Linux, có lẽ cũng có thể áp dụng lựa chọn tương tự bằng cách dùng clone theo kiểu khá khó hiểu để wait trên thread thực thi thay vì pthread_join. Có thể tôi sai ở điểm này
  • Có ai khác cũng vừa thấy link bị hỏng không?
    Sửa: bài blog hiện lên khoảng nửa giây rồi chuyển sang trang 404
    Sửa 2: Tôi vào danh sách bài viết của blog rồi bấm thử vài thứ, và ngay cả khi mở đúng bài đó trong danh sách cũng vẫn ra trang 404. Làm sao người ta có thể phá hỏng một blog là trang tĩnh, hoặc ít nhất đáng ra phải là vậy, theo cách này được chứ?

    • Giọng điệu này có vẻ hơi thô lỗ và công kích một cách không cần thiết. Website cũng có thể gặp lỗi, và việc báo lỗi là hữu ích, nhưng bình luận này nghe khá cáu kỉnh
      Tham khảo thêm, tôi đã thử làm theo đúng các bước tái hiện có vẻ giống vậy nhưng hoàn toàn không gặp 404. Tôi thử trên cả điện thoại lẫn máy tính, với JavaScript bật và tắt. Vì thế có thể hiện tượng họ gặp phức tạp hơn vẻ bề ngoài