- Async Rust는 실행기와 독립적인 코드를 서버와 마이크로컨트롤러에서 함께 돌릴 수 있게 하지만, 컴파일러가 만드는 상태 머신 때문에 특히 임베디드에서 바이너리 크기 증가가 두드러짐
bar()처럼 await 지점이 2개인 단순 예제도 360줄의 MIR와Unresumed,Returned,Panicked,Suspend0,Suspend1상태를 만들며, 동기 버전은 23줄만 필요함- 완료된 future를 다시 poll했을 때
panic대신Poll::Pending을 반환하도록 바꾸면 unsafe 동작 없이 계약을 만족할 수 있고, 실험에서 임베디드 펌웨어의 바이너리 크기가 2%~5% 감소함 - await가 없는
async { 5 }도 현재는 기본 3개 상태의 상태 머신을 만들지만, 매번Poll::Ready(5)를 반환하도록 최적화하면 임베디드 바이너리 크기가 0.2% 감소함 - 제안된 Project Goal은 릴리스 모드의 완료 후 panic 제거, await 없는 async block의 상태 머신 제거, 단일 await future 인라인, 동일 상태 접기를 컴파일러에서 추진하려는 작업임
Async Rust의 컴파일러 수준 비대화 문제
- Async Rust는 실행기(executor)에 독립적인 코드를 서버와 마이크로컨트롤러에서 동시에 실행할 수 있게 해주지만, 작은 마이크로컨트롤러에서는 바이너리 크기 증가가 특히 눈에 띔
- Rust 블로그는 async/await를 무비용 추상화로 소개했지만, 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개 있으므로 상태 머신에 최소 2개 상태가 필요하지만, 실제로는 더 많은 상태가 생성됨- Rust 컴파일러는 여러 패스에서 MIR를 덤프할 수 있으며,
coroutine_resume패스는 마지막 async 전용 MIR 패스임- async는 MIR에는 남아 있지만 LLVM IR에는 남지 않으므로, async가 상태 머신으로 변환되는 과정은 MIR 패스에서 일어남
bar함수는 360줄의 MIR를 생성하며, 동기 버전은 23줄만 사용함- 컴파일러가 출력하는
CoroutineLayout은 사실상 enum 형태의 상태 집합임Unresumed: 시작 상태Returned: 완료된 상태Panicked: 패닉 이후 상태Suspend0: 첫 번째 await 지점이며foofuture를 저장함Suspend1: 두 번째 await 지점이며 첫 번째 결과와 두 번째foofuture를 저장함
Future::poll은 안전한 함수이므로 future가 이미 완료된 뒤 다시 호출되어도 UB를 일으키면 안 됨- 현재는
Suspend1이후Ready를 반환하고 future를Returned상태로 바꿈 - 이 상태에서 다시 poll하면 panic이 발생함
- 현재는
Panicked상태는 async 함수가 panic한 뒤catch_unwind로 이를 잡았을 때 해당 future를 다시 poll하지 못하게 하기 위한 상태로 보임- panic 이후 future는 불완전한 상태일 수 있으므로 다시 poll하면 UB로 이어질 수 있음
- 이 메커니즘은 mutex poisoning과 매우 유사함
Panicked상태에 대한 이 해석은 확실한 문서를 찾기 어려워 90% 정도 확신하는 수준임
완료 후 poll에서 꼭 panic해야 하는가
Returned상태의 future는 현재 panic하지만, 반드시 그래야 하는 것은 아님- 필요한 조건은 UB를 일으키지 않는 것뿐임
- panic은 비교적 비싸며, 최적화로 제거하기 어려운 부작용이 있는 경로를 추가함
- 완료된 future를 다시 poll했을 때
Poll::Pending을 반환하면 unsafe 동작 없이Future타입의 계약을 만족할 수 있음 - 컴파일러를 수정해 이 방식을 실험했을 때, async 임베디드 펌웨어에서 2%~5%의 바이너리 크기 감소가 확인됨
- 이 동작은 정수 오버플로의
overflow-checks = false처럼 스위치로 제공하는 방식이 제안됨- 디버그 빌드에서는 잘못된 동작을 즉시 드러내기 위해 계속 panic함
- 릴리스 빌드에서는 더 작은 future를 얻을 수 있음
panic=abort를 사용할 때는Panicked상태 자체를 제거할 수 있을 가능성이 있으며, 그 영향은 추가 검토가 필요함
await가 없어도 항상 상태 머신이 생성됨
foo()는async { 5 }만 반환하므로 수동 구현의 최적 형태는 상태 없이 항상Poll::Ready(5)를 반환하는 future임- 하지만 컴파일러가 생성한 MIR에는
Unresumed,Returned,Panicked라는 기본 3개 상태가 여전히 존재함- poll 시 현재 상태의 discriminant를 확인하고 분기함
- 완료 후 다시 poll하면
`async fn` resumed after completionassert로 panic함
- 이 경우에는 상태 머신을 만들지 않고 매번
Poll::Ready(5)를 반환하도록 최적화할 수 있음 - 컴파일러에 이를 실험적으로 적용했을 때 임베디드 바이너리 크기가 0.2% 감소함
- 절감 폭은 크지 않지만 단순한 최적화라 적용 가치가 있을 가능성이 있음
- 이 최적화는 동작을 조금 바꾸지만, 영향을 받는 것은 규약을 지키지 않는 실행기뿐임
- 현재 컴파일러는 이후 poll에서 panic함
- 최적화 후에는 future가 항상
Ready를 반환함
LLVM만으로는 충분하지 않음
- MIR 출력이 비효율적이어도 LLVM이 모두 정리해줄 수 있는 경우가 있지만, 조건이 제한적임
- future가 충분히 단순해야 함
opt-level=3을 사용해야 함
- future가 복잡해지면 LLVM이 제거하지 못하며, 관용적인 async Rust 코드에서는 future가 깊게 중첩되기 때문에 복잡도가 빠르게 커짐
- 임베디드나 wasm처럼 크기 최적화를 자주 하는 환경에서는 LLVM이 이를 모두 최적화하지 못함
- Godbolt 예제: https://godbolt.org/z/58ahb3nne
- 생성된 어셈블리에서 LLVM은
foo가 5를 반환한다는 점은 알지만,bar의 답을 10으로 최적화하지 못함 foo의 poll 함수 호출도 남아 있음- 컴파일러가 완전히 파악하지 못하는 잠재적 panic 경로 때문임
- LLVM은
foo가 실제로 한 번만 호출되고 panic하지 않는다는 점을 알지 못함
- 생성된 어셈블리에서 LLVM은
- IR에서 panic 분기를 주석 처리하면 더 잘 최적화됨: https://godbolt.org/z/38KqjsY8E
- LLVM에 사후 최적화를 기대하기보다, 컴파일러가 LLVM에 더 좋은 입력을 제공해야 함
future 인라인이 잘 되지 않음
- 인라인은 이후 최적화 패스를 가능하게 하므로 중요하지만, 생성된 Rust future는 현재 이른 단계에서 인라인되지 않음
- 각 future가 구현을 얻은 뒤 LLVM과 링커가 인라인 기회를 얻지만, 앞선 문제 때문에 그 시점은 너무 늦음
- 가장 직접적인 인라인 기회는
bar()가 단순히foo(blah).await만 수행하는 형태임- trait을 사용해 추상화를 만들 때 자주 나타나는 패턴임
- 현재 컴파일러는
bar용 상태 머신을 만들고 그 안에서foo상태 머신을 호출함 - 더 효율적으로는
bar가foofuture 자체가 될 수 있음
- preamble과 postamble이 있는 경우는 더 복잡함
- 예:
bar(input)이input > 10으로blah를 만든 뒤foo(blah).await하고 결과에* 2를 적용함 - async 함수를 다른 시그니처로 변환할 때, 특히 trait 구현에서 흔함
- 예:
- 이 형태의
bar도 자체 async 상태가 필요하지 않음- 단일 await 지점을 넘어 보존되는 데이터가
foo에 잡힌 값 외에는 없음 - 다만
bar가 단순히foo자체가 될 수는 없고, 대부분의 상태를foo에 의존할 수 있음
- 단일 await 지점을 넘어 보존되는 데이터가
- 수동 구현에서는
BarFut가Unresumed { input }와Inlined { foo: FooFut }상태를 가질 수 있음- 첫 poll에서 preamble을 실행해
foo(blah)를 만들고Inlined상태로 바꿈 - 이후
foo.poll(cx)결과에 postamble을 적용함
- 첫 poll에서 preamble을 실행해
- 첫 await 지점 전까지 코드를 미리 실행할 수 있다면
Unresumed상태도 제거할 수 있지만, future는 poll되기 전에는 아무것도 하지 않는다는 점이 보장되므로 바꿀 수 없음 - poll 중인 future의 속성을 질의할 수 있다면 추가 인라인 최적화가 가능함
- 예를 들어 future가 첫 poll에서 항상 ready를 반환한다는 사실을 알 수 있다면, 호출자 future에서 해당 await 지점의 상태를 만들 필요가 없음
- 이런 최적화를 재귀적으로 적용하면 많은 future를 훨씬 단순한 상태 머신으로 접을 수 있음
- 현재
rustc구조에서는 각 async block이 개별적으로 변환되고 이후 관련 데이터가 보존되지 않아 이런 질의가 가능하지 않은 것으로 보임 - future 인라인은 아직 실험되지 않았지만, 바이너리 크기와 성능에 큰 도움이 될 것으로 예상됨
동일한 상태 접기
- async block의 각 await 지점마다 상태 머신에는 추가 상태가 생김
- 다음과 같은 코드는 자연스럽지만, 두 분기에서 같은 async 함수를 await하므로 동일한 상태가 2개 생김
CommandId::A => send_response(123).awaitCommandId::B => send_response(456).await
- 이 경우
CoroutineLayout에는send_response의 동일한 coroutine 타입을 저장하는_s0,_s1이 각각 생기고,Suspend0,Suspend1두 상태가 만들어짐 - 이 함수의 MIR는 456줄이며, 많은 기본 블록이 사실상 중복됨
- 코드를 먼저 응답 값만 계산한 뒤 한 번만
send_response(response).await하도록 수동 리팩터링하면 중복 상태가 없어짐CommandId::A는123CommandId::B는456- 이후
send_response(response).await
- 리팩터링 후
CoroutineLayout에는 저장된 future가 하나만 있고Suspend0상태 하나만 남음 - 전체 MIR 길이는 302줄로 줄고 중복이 사라짐
- 따라서 동일한 코드 경로와 상태를 찾아 하나로 접는 최적화 패스가 유용해 보임
- 이 최적화는 future 인라인 패스와 잘 결합될 가능성이 있음
실험 링크와 추가 벤치마크
- 두 실험을 함께 적용하면
smol실행기를 사용한 x86 합성 벤치마크에서 약 3% 성능 향상이 나옴 - No panics in poll after ready: https://github.com/rust-lang/rust/compare/main...diondokter:rust:resume-pending
- No await, no statemachine: https://github.com/rust-lang/rust/compare/main...diondokter:rust:no-statemachine-when-no-await
Project Goal 지원 요청
- 이 작업은 컴파일러에서 진행하기 위해 Project Goal로 제출됨: https://rust-lang.github.io/rust-project-goals/2026/async-statemachine-optimisation.html
- 펀딩 없이는 많은 작업을 진행하기 어렵기 때문에, 이 작업의 혜택을 받을 회사나 조직의 부분적 또는 전체 지원이 필요함
- 연락처는
dion@tweedegolf.com임 - 작업 범위와 필요한 펀딩 규모는 유연하지만, €30k면 전체 또는 상당 부분을 완료할 수 있을 것으로 추정됨