Async Rust의 상태 머신 최적화 문제를 살펴보고, 컴파일러 수준에서 바이너리 크기와 성능을 개선할 수 있는 방향을 제안합니다.
저는 이전에 async 팽창과 몇 가지 우회 방법을 설명했지만, 문제를 컴파일러에서 근본적으로 해결하는 쪽을 훨씬 더 선호합니다. Project Goal을 제출했고, 이 작업에 자금을 지원해 줄 도움을 찾고 있습니다.
저는 async Rust를 정말 좋아합니다! 실행기와 무관한 코드를 작성해서 거대한 서버와 아주 작은 마이크로컨트롤러에서 모두 동시 실행할 수 있다는 점이 놀랍습니다.
하지만 특히 그런 작은 마이크로컨트롤러에서는 async Rust가 우리가 약속받았던 zero cost abstractions와는 거리가 멀다는 점이 드러납니다. 바이너리 크기에서 모든 바이트가 중요하고, async는 많은 팽창을 유발하기 때문입니다. 이런 팽창은 데스크톱과 서버에서도 존재하지만, 훨씬 더 많은 메모리와 연산 자원을 쓸 수 있을 때는 눈에 훨씬 덜 띕니다.
저는 이전에 이 문제에 대한 몇 가지 우회 방법을 설명했지만, 문제의 근본 원인으로 들어가 컴파일러에서 async 팽창을 개선하는 작업을 훨씬 더 하고 싶습니다. 그래서 Project Goal을 제출했습니다.
이 글은 이 주제에 대한 제 블로그 시리즈 2편입니다. 주제에 대한 초기 탐구와 async 코드를 작성할 때 팽창 일부를 피하기 위해 할 수 있는 일을 보려면 1편을 참고하세요. 이번 2편에서는 내부 구현을 더 깊이 들여다보고, 1편의 방법들을 컴파일러 최적화로 옮겨 보겠습니다.
제가 이야기하지 않을 것은, futures가 필요 이상으로 커지고 복사가 많이 일어나는 자주 논의되는 문제입니다. 사람들은 이미 그 문제를 알고 있습니다. 실제로 그 일부를 다루는 열린 PR도 있습니다: https://github.com/rust-lang/rust/pull/135527
우리는 다음 코드를 보겠습니다:
fn foo() -> impl Future<Output = i32> {
async { 5 }
}
fn bar() -> impl Future<Output = i32> {
async {
foo().await + foo().await
}
}
무슨 일이 일어나는지 보기 더 쉽기 때문에 future에 대해 디슈가링된 문법을 사용하고 있습니다.
그렇다면 bar future는 어떤 모습일까요?
await 지점이 두 개이니 상태 머신도 최소 두 개의 상태를 가져야겠죠?
그렇습니다. 하지만 그게 전부는 아닙니다.
다행히도 컴파일러에게 여러 패스에서 MIR를 덤프해 달라고 요청할 수 있습니다. 흥미로운 패스 중 하나는 coroutine_resume 패스입니다. 이것은 마지막 async 전용 MIR 패스입니다. 왜 이것이 중요할까요? async는 MIR에는 아직 존재하지만 LLVM IR에는 존재하지 않는 언어 기능이기 때문입니다. 따라서 async를 상태 머신으로 변환하는 과정은 MIR 패스로 이루어집니다.
bar 함수는 360줄의 MIR를 생성합니다. 꽤 놀랍죠? 물론 나중에 어느 정도 최적화되긴 하지만, async가 아닌 버전은 이것에 23줄만 사용합니다.
컴파일러는 CoroutineLayout도 출력합니다. 기본적으로 다음 상태를 가진 enum입니다(주석은 제가 추가했습니다):
variant_fields: {
Unresumed(0): [], // 시작 상태
Returned (1): [],
Panicked (2): [],
Suspend0 (3): [_s1], // await 지점 1, _s1 = foo future
Suspend1 (4): [_s0, _s2], // await 지점 2, _s0 = _s1의 결과, s2 = 두 번째 foo future
},
그렇다면 Returned와 Panicked는 무엇일까요?
우선 Future::poll은 안전한 함수입니다. future가 이미 끝난 뒤에 호출하더라도 UB를 일으키면 안 됩니다. 그래서 Suspend1 이후 future는 Ready를 반환하고, future는 Returned 상태로 바뀝니다. 그 상태에서 다시 poll되면 poll 함수는 패닉합니다.
Panicked 상태는 async fn이 패닉한 뒤에 catch-unwind 메커니즘으로 그것을 잡았더라도, 그 future를 더 이상 poll할 수 없게 하기 위해 존재합니다. Panicked 상태의 future를 poll하면 패닉합니다. 이 메커니즘이 없다면 패닉 이후에 future를 다시 poll할 수 있게 됩니다. 하지만 future가 불완전한 상태일 수 있으므로 UB를 일으킬 수 있습니다. 이 메커니즘은 mutex poisoning과 매우 비슷합니다.
(Panicked 상태에 대한 제 설명이 90%는 맞다고 확신하지만, 실제로 이를 설명하는 문서를 찾지는 못했습니다.)
좋습니다, 일단은 그럴듯해 보입니다.
하지만 정말 그럴듯할까요? Returned 상태의 futures는 패닉합니다. 하지만 꼭 그래야 할 필요는 없습니다. 우리가 할 수 없는 유일한 것은 UB를 발생시키는 것뿐입니다.
패닉은 상대적으로 비용이 큽니다. 쉽게 최적화로 제거되지 않는 부작용이 있는 경로를 도입합니다. 대신 그냥 다시 Pending을 반환하면 어떨까요? 안전하지 않은 일은 일어나지 않으므로 Future 타입의 계약도 만족합니다.
저는 이것을 시험해 보기 위해 컴파일러에 직접 해킹해 넣었고, async 임베디드 펌웨어에서 바이너리 크기가 2%-5% 줄어드는 것을 확인했습니다.
그래서 저는 이것이 overflow-checks = false가 정수 오버플로에 대해 하는 것처럼 하나의 스위치가 되어야 한다고 제안합니다. 디버그 빌드에서는 잘못된 동작이 즉시 드러나도록 여전히 패닉하게 두고, 릴리스 빌드에서는 더 작은 futures를 얻는 것입니다.
비슷하게, panic=abort를 사용할 때는 Panicked 상태 자체를 완전히 없앨 수 있을지도 모릅니다. 그 여파를 살펴보고 싶습니다.
우리는 bar를 보았지만 아직 foo는 보지 않았습니다.
fn foo() -> impl Future<Output = i32> {
async { 5 }
}
최적의 해법이 무엇인지 보기 위해 이것을 수동으로 구현해 봅시다.
struct FooFut;
impl Future for FooFut {
type Output = i32;
fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Self::Output> {
Poll::Ready(5)
}
}
쉽죠? 아무 상태도 필요 없습니다. 그냥 숫자를 반환하면 됩니다.
컴파일러가 제공하는 버전에 대해 생성된 MIR가 어떤지 봅시다:
// MIR for `foo::{closure#0}` 0 coroutine_resume
/* coroutine_layout = CoroutineLayout {
field_tys: {},
variant_fields: {
Unresumed(0): [],
Returned (1): [],
Panicked (2): [],
},
storage_conflicts: BitMatrix(0x0) {},
} */
fn foo::{closure#0}(_1: Pin<&mut {async block@src\main.rs:5:5: 5:10}>, _2: &mut Context<'_>) -> Poll<i32> {
debug _task_context => _2;
let mut _0: core::task::Poll<i32>;
let mut _3: i32;
let mut _4: u32;
let mut _5: &mut {async block@src\main.rs:5:5: 5:10};
bb0: {
_5 = copy (_1.0: &mut {async block@src\main.rs:5:5: 5:10});
_4 = discriminant((*_5));
switchInt(move _4) -> [0: bb1, 1: bb4, otherwise: bb5];
}
bb1: {
_3 = const 5_i32;
goto -> bb3;
}
bb2: {
_0 = Poll::<i32>::Ready(move _3);
discriminant((*_5)) = 1;
return;
}
bb3: {
goto -> bb2;
}
bb4: {
assert(const false, "`async fn` resumed after completion") -> [success: bb4, unwind unreachable];
}
bb5: {
unreachable;
}
}
이런! 코드가 정말 많습니다!
4번째 줄을 보면 여전히 기본 3개 상태가 있고, 22번째 줄을 보면 여전히 그 상태를 기준으로 분기하고 있습니다. 여기에는 큰 최적화 기회가 있습니다. 즉, 상태를 전혀 두지 않고 매 poll마다 항상 Poll::Ready(5)를 반환하는 것입니다.
저는 이것도 컴파일러에 해킹해 넣어 보았고, 바이너리 크기를 0.2% 줄였습니다. 아주 큰 수치는 아니지만, 꽤 단순한 최적화이므로 여전히 할 만한 가치가 있어 보입니다.
이것은 동작을 약간 바꾸긴 하지만, 규약을 따르지 않는 실행기에서만 그렇습니다. 즉, future가 항상 Ready를 반환하게 됩니다. 현재 컴파일러의 동작은 이후의 poll이 모두 패닉하는 것입니다.
좋습니다. MIR 출력은 썩 좋지 않습니다. 하지만 LLVM이 뒤처리를 해 주지 않을까요?
음, 가끔은 그렇습니다. 하지만 future가 충분히 단순하고 opt-level=3로 빌드할 때만 그렇습니다. future가 너무 복잡해지거나(관용적인 async Rust 코드에서는 future가 아주 깊게 중첩되기 때문에 금방 그렇게 됩니다), 크기 최적화를 할 때(임베디드나 wasm에서는 자주 그렇습니다) LLVM은 이 모든 것을 다 제거하지 못합니다.
여기 godbolt 예제가 있습니다: https://godbolt.org/z/58ahb3nne
생성된 어셈블리를 살펴보면, foo가 5를 반환한다는 사실은 알고 있지만 bar의 결과를 10으로 최적화하지는 않는다는 점을 알 수 있습니다. foo의 poll 함수도 여전히 호출됩니다. 이는 컴파일러가 잠재적인 패닉을 완전히 고려하지 못하기 때문에 일어납니다. 실제로는 foo가 한 번만 호출되어 절대 패닉하지 않는다는 점을 알아차리지 못합니다.
IR에서 패닉 분기를 주석 처리하면 더 잘 최적화되는 것을 볼 수 있습니다: https://godbolt.org/z/38KqjsY8E
안타깝게도 여기서 LLVM은 우리의 구원자가 아닙니다. 정말로 좋은 입력을 제공해야 합니다.
opt-level=3에서는 더 잘하지만, 코드가 덜 자명해지면 결국 여기서도 따라가지 못합니다. 우리가 LLVM에게 시키는 일들 중 불필요한 부분을 최적화로 제거해야 한다는 사실을 LLVM이 눈치채기를 기대하고 있기 때문입니다.
인라인은 훌륭합니다. 추가 최적화 패스를 가능하게 해 주기 때문입니다. 안타깝게도 생성된 Rust future는 결코 인라인되지 않습니다. 각 future가 구현을 얻은 뒤에야 LLVM과 링커가 인라인 기회를 갖게 됩니다. 하지만 위에서 보았듯, 그때는 이미 너무 늦습니다.
인라인의 가장 중요한 기회는 다음과 같습니다:
async fn foo(blah: SomeType) -> OtherType {
// ...
}
async fn bar(blah: SomeType) -> OtherType {
foo(blah).await
}
이 패턴은 traits를 사용해 추상화를 만들 때 아주 자주 나타납니다. 현재 컴파일러에서는 bar가 foo 상태 머신을 호출하는 자기만의 상태 머신을 갖게 되는데, 이것은 매우 낭비적입니다. 대신 bar는 foo future를 그대로 반환함으로써 foo가 될 수 있습니다.
예제에 프리앰블과 포스트앰블을 추가하면 조금 더 어려워집니다.
async fn foo(blah: bool) -> i32 {
// ...
}
async fn bar(input: u32) -> i32 {
let blah = input > 10; // 프리앰블
let result = foo(blah).await;
result * 2 // 포스트앰블
}
이 패턴은 비동기 함수를 한 시그니처에서 다른 시그니처로 변환할 때 흔하며, trait impl에서 자주 일어납니다.
여기서도 bar는 자체 async 상태를 전혀 가질 필요가 없다는 점에 주목하세요. 단 하나의 await 지점을 넘겨 유지되는 데이터 중 foo가 캡처하지 않는 것은 없습니다. bar가 단순히 foo가 될 수는 없지만, 대부분은 foo의 상태에 의존할 수 있습니다. 수동 구현은 대략 다음과 같을 것입니다:
enum BarFut {
Unresumed { input: u32 },
Inlined { foo: FooFut }
}
impl Future for BarFut {
type Output = i32;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
// 여기서는 pin projection을 무시합니다
loop {
match self {
Unresumed { input } => {
let blah = input > 10; // 프리앰블
*self = BarFut::Inlined { foo: foo(blah) };
},
Inlined { foo } => {
break foo
.poll(cx)
.map(|result| result * 2) // 포스트앰블
},
}
}
}
}
이것은 현재 생성되는 것보다 훨씬 낫습니다. 첫 번째 await 지점까지 코드를 실행할 수만 있다면 Unresumed 상태도 없앨 수 있을 텐데 말입니다. 하지만 “futures는 poll되기 전까지 아무 일도 하지 않는다”는 것이 보장되므로, 그것은 바꿀 수 없습니다.
poll하는 future의 속성을 질의할 수 있다면 인라인으로 할 수 있는 최적화는 더 많습니다. 적어도 rustc의 현재 구조에서는 이것이 가능하지 않다고 생각합니다. 모든 async 블록은 개별적으로 변환되고, 이후에는 그것에 대한 데이터가 유지되지 않습니다.
예를 들어 어떤 future가 첫 번째 poll에서 항상 ready를 반환하는지 질의할 수 있다면, 호출자 future의 await 지점에 대해 별도의 상태를 만들 필요가 없습니다. 그것이 가능하고 이런 최적화를 재귀적으로 적용할 수 있다면, 많은 futures를 훨씬 더 단순한 상태 머신으로 축약할 수 있을 것입니다.
저는 아직 인라인을 시험해 보지는 않았지만, 이것은 바이너리 크기와 성능에 상당한 도움을 줄 것이라고 생각합니다.
상태 머신은 async 블록의 각 await 지점마다 추가 상태를 하나씩 얻습니다. 하지만 어떤 코드에서는 여러 상태를 하나로 축약할 수 있습니다.
다음 예제를 보세요:
pub async fn process_command() {
match get_command() {
CommandId::A => send_response(123).await,
CommandId::B => send_response(456).await,
}
}
이렇게 작성하는 것은 아주 자연스럽습니다. 하지만 실제로는 동일한 상태 두 개가 생깁니다:
/* coroutine_layout = CoroutineLayout {
field_tys: {
_s0: CoroutineSavedTy { // _s1과 동일
ty: Coroutine(
DefId(0:11 ~ mir_test[b831]::send_response::{closure#0}),
[
(),
std::future::ResumeTy,
(),
(),
(u32,),
],
),
source_info: SourceInfo {
span: src/main.rs:13:25: 13:49 (#14),
scope: scope[0],
},
ignore_for_traits: false,
},
_s1: CoroutineSavedTy { // _s0과 동일
ty: Coroutine(
DefId(0:11 ~ mir_test[b831]::send_response::{closure#0}),
[
(),
std::future::ResumeTy,
(),
(),
(u32,),
],
),
source_info: SourceInfo {
span: src/main.rs:14:25: 14:49 (#16),
scope: scope[0],
},
ignore_for_traits: false,
},
},
variant_fields: {
Unresumed(0): [],
Returned (1): [],
Panicked (2): [],
Suspend0 (3): [_s0], // 상태 2개
Suspend1 (4): [_s1],
},
storage_conflicts: BitMatrix(2x2) {
(_s0, _s0),
(_s1, _s1),
},
} */
이 함수의 MIR는 456줄이며 많은 기본 블록이 사실상 중복입니다.
코드를 수동으로 다음처럼 리팩터링할 수 있습니다:
pub async fn process_command() {
let response = match get_command() {
CommandId::A => 123,
CommandId::B => 456,
};
send_response(response).await;
}
여기서는 중복 상태가 생기지 않습니다:
/* coroutine_layout = CoroutineLayout {
field_tys: {
_s0: CoroutineSavedTy {
ty: Coroutine(
DefId(0:11 ~ mir_test[b831]::send_response::{closure#0}),
[
(),
std::future::ResumeTy,
(),
(),
(u32,),
],
),
source_info: SourceInfo {
span: src/main.rs:16:5: 16:34 (#14),
scope: scope[1],
},
ignore_for_traits: false,
},
},
variant_fields: {
Unresumed(0): [],
Returned (1): [],
Panicked (2): [],
Suspend0 (3): [_s0],
},
storage_conflicts: BitMatrix(1x1) {
(_s0, _s0),
},
} */
이제 전체 MIR 길이는 302줄이며 중복되는 것이 없습니다.
따라서 동일한 코드 경로와 상태를 찾아 하나로 축약하는 최적화 패스는 꽤 괜찮아 보입니다. 이 최적화는 인라인 패스와도 잘 결합될 가능성이 큽니다.
Returned의 패닉을 Poll::Pending으로 대체: 임베디드에서 바이너리 크기 2-5% 절감.smol 실행기를 사용한 합성 벤치마크에서 x86 성능 약 3% 증가.future 인라인은 이보다 더 큰 효과를 낼 가능성이 있습니다.
궁극적으로는 실제 시스템에서 벤치마크해 보기 전까지 개선 폭을 정확히 알기 어렵습니다.
이 글이 async Rust의 몇 가지 문제를 이해하는 데 도움이 되었기를 바랍니다!
저는 컴파일러에서 다음 항목들을 작업하고 싶습니다:
Returned 상태는 더 이상 패닉하지 않음제가 만든 해킹에 대한 링크입니다:
저는 컴파일러에서 이 작업을 하고 싶어서 이것을 Project Goal로 제출했습니다: https://rust-lang.github.io/rust-project-goals/2026/async-statemachine-optimisation.html
하지만 자금이 없으면 많은 일을 할 수 없기 때문에 여러분의 도움이 필요합니다.
이 작업의 혜택을 받을 회사나 조직에 속해 있고, 이 작업에 자금을 지원할 의향이 있다면(부분 지원도 괜찮습니다) dion@tweedegolf.com으로 연락해 주세요. 범위는 유연하고 필요한 자금 규모도 유연합니다. 다만 제 추산으로는 €30k면 이 작업 전부 또는 적어도 상당 부분을 완료할 수 있습니다.