본문으로 건너뛰기
피드

비동기 러스트, 아직 MVP 상태에서 못 벗어났다는 꽤 아픈 지적

backend 약 11분

글쓴이는 비동기 러스트(async Rust)가 서버와 마이크로컨트롤러를 모두 커버하는 멋진 모델이지만, 컴파일러가 만드는 상태 기계가 아직 너무 비싸다고 지적한다. 특히 임베디드나 WASM처럼 바이너리 크기가 중요한 환경에서는 불필요한 panic 경로, 상태, 중복 MIR이 실제 비용으로 튄다.

  • 1

    async 블록은 await가 없어도 Returned, Panicked 같은 기본 상태를 가진 상태 기계로 변환됨

  • 2

    완료된 Future를 다시 poll할 때 panic하는 경로가 최적화를 방해하고 바이너리 크기를 키움

  • 3

    글쓴이의 컴파일러 실험에서는 release에서 완료 후 Pending 반환만으로 임베디드 펌웨어 바이너리 크기가 2-5% 줄었음

  • 4

    await가 없는 async 블록에서 상태 기계를 제거하면 0.2% 크기 절감, 합치면 x86 합성 벤치마크에서 약 3% 성능 향상이 나옴

  • 5

    단일 await Future 인라이닝과 동일 상태 병합은 아직 실험 전이지만 더 큰 효과가 예상됨

async Rust의 문제는 추상화가 아니라 ‘생성물’에 있음

  • 글쓴이는 async Rust 자체를 싫어하는 게 아님. 오히려 서버부터 작은 마이크로컨트롤러까지 executor에 덜 묶인 비동기 코드를 쓸 수 있다는 점은 엄청 좋다고 봄

    • 문제는 “zero cost abstraction”이라고 부르기엔 컴파일러가 만드는 코드가 아직 꽤 비싸다는 것
    • 데스크톱이나 서버에서는 메모리와 CPU가 넉넉해서 덜 보이지만, 임베디드에서는 바이너리 한 바이트도 바로 비용임
  • 예시로 든 코드는 아주 단순함

    • foo()async { 5 }를 반환함
    • bar()foo().await + foo().await를 계산함
    • 그런데 bar의 MIR은 360줄까지 늘어남. 같은 일을 비동기 없이 하면 23줄임. 네, 좀 세다
  • async는 MIR 단계에서 상태 기계로 변환됨

    • bar에는 await가 두 번 있으니 Suspend0, Suspend1 같은 상태가 생기는 건 납득 가능함
    • 그런데 여기에 Unresumed, Returned, Panicked 상태까지 기본으로 따라붙음
    • 완료된 Future를 다시 poll하면 panic해야 하고, panic 후 catch_unwind로 잡힌 Future를 다시 poll하지 못하게 막기 위한 상태도 필요하다는 논리임

중요

> 글쓴이가 본격적으로 찌르는 지점은 “안전성 때문에 UB만 피하면 되는데, 꼭 panic까지 해야 하냐”는 부분임. 이 panic 경로가 LLVM 최적화를 막고 바이너리 크기를 키운다는 게 핵심 주장임.

panic 경로 하나가 생각보다 비쌈

  • 현재 러스트 Future는 완료된 뒤 다시 poll되면 panic함

    • 하지만 Future::poll의 안전성 계약에서 중요한 건 UB를 만들지 않는 것임
    • 글쓴이는 release 빌드에서는 완료 후 다시 poll됐을 때 panic 대신 Poll::Pending을 반환하는 옵션을 제안함
  • 직접 컴파일러를 고쳐 실험해보니 임베디드 펌웨어 바이너리 크기가 2-5% 줄었다고 함

    • 임베디드에서 2-5%면 그냥 미세 최적화가 아니라 꽤 큰 숫자임
    • overflow-checks = false처럼 debug에서는 잘못된 동작을 빨리 드러내고, release에서는 크기를 줄이는 스위치로 만들자는 제안임
  • panic=abort일 때는 Panicked 상태 자체를 없앨 수 있을지도 본다고 함

    • unwind로 복구하는 모델이 아니라 바로 abort한다면, panic 이후 재poll 방지 상태가 필요 없을 수 있다는 얘기
    • 다만 이건 영향 범위를 더 봐야 한다고 선을 그음

await가 없어도 상태 기계를 만드는 건 너무 과함

  • async { 5 }처럼 await가 전혀 없는 Future도 현재는 상태 기계로 만들어짐

    • 수동으로 구현하면 poll()에서 그냥 Poll::Ready(5)를 반환하면 끝임
    • 하지만 컴파일러 출력에는 Unresumed, Returned, Panicked 상태와 discriminant switch가 들어감
  • 글쓴이는 await 없는 async 블록에서는 상태 기계 없이 항상 Ready를 반환하도록 컴파일러를 고쳐봄

    • 결과는 임베디드 바이너리 크기 0.2% 절감
    • 크진 않지만 구현 난이도 대비 챙길 만한 최적화라는 평가
  • 이 변경은 동작을 살짝 바꿈

    • 현재는 완료 후 다시 poll하면 panic함
    • 바뀐 모델에서는 계속 Ready를 반환함
    • 다만 spec을 지키지 않는 executor에서만 차이가 나는 케이스라고 봄

LLVM이 알아서 치워줄 거라는 기대는 깨짐

  • 단순한 코드와 opt-level=3에서는 LLVM이 어느 정도 정리해줌

    • 하지만 Future가 조금만 깊게 중첩되면 금방 한계가 옴
    • 임베디드나 WASM처럼 크기 최적화를 거는 환경에서는 더 잘 안 치워짐
  • Godbolt 예시에서는 LLVM이 foo가 5를 반환한다는 걸 알면서도 bar를 10으로 접지 못함

    • foo의 poll 함수 호출도 남아 있음
    • 이유는 잠재적인 panic 경로를 컴파일러가 완전히 제거하지 못하기 때문
  • panic branch를 IR에서 주석 처리하면 최적화가 더 잘 됨

    • 즉 문제는 “LLVM이 멍청하다”가 아니라 “LLVM에게 너무 지저분한 입력을 주고 있다”에 가까움
    • async 최적화는 MIR 단계에서 더 똑똑하게 해야 한다는 결론으로 이어짐

Future 인라이닝과 상태 병합이 다음 큰 타깃

  • 현재 생성된 Rust Future는 실질적으로 잘 인라인되지 않는다고 함

    • async fn bar() { foo().await } 같은 흔한 패턴에서도 bar용 상태 기계가 따로 생기고, 그 안에서 foo 상태 기계를 호출함
    • trait 구현에서 시그니처를 바꾸거나 얇은 래퍼를 만들 때 이런 패턴이 자주 나옴
  • 글쓴이가 원하는 최적화는 단일 await Future를 더 과감하게 접는 것임

    • bar가 별도 상태를 가질 필요가 없다면 foo Future를 사실상 재사용할 수 있음
    • preamble과 postamble이 있어도, poll 시점에 필요한 변환만 얹고 내부 상태는 foo에 맡기는 구조가 가능하다고 봄
  • 동일한 await 상태를 병합하는 예시도 나옴

    • match get_command()에서 A면 send_response(123).await, B면 send_response(456).await를 호출하는 코드는 자연스럽지만, 컴파일러는 거의 같은 상태를 2개 만듦
    • 수동으로 response 값을 먼저 고르고 send_response(response).await로 바꾸면 MIR이 456줄에서 302줄로 줄고 중복 상태도 사라짐
sequenceDiagram
    participant 개발자코드 as 개발자 코드
    participant 러스트컴파일러 as 러스트 컴파일러
    participant MIR as MIR 상태 기계
    participant LLVM as LLVM
    개발자코드->>러스트컴파일러: async 함수 작성
    러스트컴파일러->>MIR: await 지점별 상태 생성
    MIR->>MIR: Returned/Panicked/Suspend 상태 추가
    MIR->>LLVM: 최적화 가능한 코드로 전달
    LLVM-->>MIR: panic 경로 때문에 일부 최적화 실패
    MIR-->>개발자코드: 더 큰 바이너리와 추가 poll 비용

숫자로 보면 ‘감’이 아니라 실제 비용임

  • 글쓴이의 실험 결과는 꽤 구체적임

    • 완료 후 poll panic을 Pending 반환으로 바꾸면 임베디드 바이너리 크기 2-5% 절감
    • await 없는 async 블록에서 상태 기계를 제거하면 0.2% 절감
    • 두 변경을 합치면 x86 합성 벤치마크에서 smol executor 기준 약 3% 성능 향상
  • 아직 Future 인라이닝은 직접 테스트하지 않았지만, 더 큰 효과가 날 수 있다고 봄

    • async Rust 코드는 Future가 깊게 중첩되는 패턴이 많아서 인라이닝이 먹히면 파급이 큼
    • 특히 trait abstraction을 많이 쓰는 코드베이스에서 체감 가능성이 있음
  • 글쓴이는 이 작업을 Rust Project Goal로 제출했고, 컴파일러 쪽에서 실제로 다뤄보고 싶다고 함

    • 제안 항목은 release에서 Returned panic 제거, await 없는 async 블록의 상태 기계 제거, 단일 await Future 인라이닝, 동일 상태 병합
    • 마지막에는 funding이 필요하다고 솔직하게 적음. 오픈소스 컴파일러 최적화도 결국 시간과 돈 문제라는 현실 엔딩임

기술 맥락

  • async Rust는 개발자가 보기엔 await 몇 개지만, 컴파일러 입장에서는 “어디서 멈췄고 무엇을 저장해야 하는가”를 기억하는 상태 기계를 만들어야 해요. 그래서 await가 많거나 Future가 중첩되면 코드 크기가 자연스럽게 불어나요.

  • 이 글의 핵심은 상태 기계가 필요한 건 맞지만, 현재 rustc가 너무 보수적으로 만든다는 점이에요. 완료 후 재poll을 막기 위한 panic 경로와 panic 이후 상태까지 넣다 보니, 실제로는 일어나지 않을 경로 때문에 최적화가 막히거든요.

  • LLVM에 맡기면 되지 않냐는 질문이 당연히 나오는데, 글쓴이는 그게 한계가 있다고 봐요. async 변환은 MIR에서 일어나고, LLVM은 이미 펼쳐진 상태 기계와 panic 경로를 받아서 최적화해야 하니 원래 의도를 복원하기 어렵거든요.

  • 임베디드와 WASM에서 이 문제가 더 크게 보이는 이유는 바이너리 크기 자체가 제품 제약이기 때문이에요. 서버에서는 2-5%가 티 안 날 수 있지만, 펌웨어에서는 메모리 한계나 업데이트 크기 때문에 바로 의사결정 포인트가 돼요.

  • 그래서 제안도 런타임 해킹이 아니라 컴파일러 최적화에 가까워요. await가 없는 async는 상태 기계를 만들지 않고, 동일한 await 상태는 합치고, 단일 await 래퍼 Future는 인라인해서 애초에 LLVM에 더 좋은 입력을 주자는 흐름이에요.

이 글의 포인트는 ‘async Rust가 느리다’가 아니라 ‘컴파일러가 너무 보수적인 상태 기계를 만들고, 그 비용을 LLVM이 나중에 다 치워주길 기대하고 있다’는 데 있음. 서버에서는 티가 덜 나도, 임베디드와 WASM에서는 이런 몇 퍼센트가 바로 제품 제약이 됨.

댓글

댓글

댓글을 불러오는 중...

backend

30살 된 FastCGI가 아직도 리버스 프록시 백엔드 프로토콜로 더 낫다는 주장

HTTP를 리버스 프록시와 백엔드 사이 프로토콜로 쓰는 관행이 desync 공격과 신뢰 헤더 문제를 계속 만든다는 글이다. 저자는 FastCGI가 1996년 나온 오래된 프로토콜이지만 명시적 프레이밍과 신뢰 정보 분리 덕분에 이 구간에서는 HTTP보다 안전한 선택일 수 있다고 주장한다.

backend

삼성SDS, 삼성전기 SAP ERP 클라우드 전환 — 다운타임 140시간을 34시간으로 줄였다

삼성SDS가 삼성전기의 차세대 SAP ERP 클라우드 전환 프로젝트를 완료. 국내 최초 RISE with SAP 프리미엄 서플라이어 기반 사례이고, Downtime Optimized Conversion 적용으로 8.5TB HANA DB 전환 다운타임을 76% 단축. DVM으로 DB 용량 35% 축소, 업무 효율 25% 이상 개선.

backend

월 $20 인프라로 MRR $10K 회사 여러 개 돌리는 법

VPS 하나, Go 바이너리, SQLite, 로컬 GPU, GitHub Copilot 조합으로 월 $20 이하의 인프라 비용으로 MRR $10K 넘는 회사를 여러 개 운영하는 개발자의 실전 플레이북. AWS 없이도 충분히 확장 가능한 아키텍처를 구축할 수 있음을 구체적 수치와 코드로 보여줌.

backend

소프트웨어 개발자를 위한 USB 입문 — 유저스페이스 드라이버 직접 만들기

커널 코드 없이 libusb를 사용해 유저스페이스에서 USB 드라이버를 작성하는 방법을 안드로이드 Fastboot 프로토콜을 예시로 설명하는 튜토리얼. USB 엔드포인트 유형, 열거(enumeration) 과정, 컨트롤/벌크 전송의 동작 원리를 실습 중심으로 다룸.

backend

IBM과 Arm, 양쪽 워크로드 모두 실행 가능한 하드웨어 공동 개발 발표

IBM과 Arm이 클라우드 이전이 어려운 규제 워크로드를 겨냥해 양쪽 아키텍처를 모두 지원하는 하드웨어를 공동 개발함. 가상화, 규제 준수, 공통 기술 세 가지에 초점을 맞추고 있으며, 출시까지 약 3년이 소요될 것으로 예상됨.