Wasm 예외 처리 제안의 개념과 "제로 코스트" 예외의 배경, Wasm 바이트코드 수준의 설계, 그리고 Cranelift 컴파일러와 Wasmtime 런타임에서의 구현 세부(제어 흐름, ABI, 언와인더, GC 상호작용, 인라이닝 등)와 성능상 주의점을 설명한다.
주: 이 글은 Bytecode Alliance 블로그여기에도 교차 게시되었습니다.
이 글은 제가 최근 Wasmtime, 즉 제가 핵심 팀 멤버/메인터로 활동 중인 오픈소스 WebAssembly 엔진과 그 컴파일러 백엔드인 Cranelift에 Wasm 예외 처리 제안을 구현하기까지의 여정을 정리한 글입니다.
처음 이 작업을 논의할 때, 저는 Wasmtime 격주 프로젝트 회의에서 즉흥적으로 “컴파일러 쪽 2주, Wasmtime 쪽 1주쯤?”이라고 추정했습니다. 독자 여러분, 고백하자면: 저는 틀렸습니다. 이건 결코 3주의 일이 아니었습니다. 이 작업은 올해 3월 말부터 8월까지 이어졌습니다(공정하게 말하면 대략 반일 투입이었습니다; 맡은 일이 많거든요). 교훈을 얻으시길
이 글에서는 먼저 예외가 무엇이며 왜 어떤 언어가 예외를 원하는지(그리고 다른 언어는 대신 무엇을 하는지), 특히 (소위) “제로 코스트” 예외 처리의 핵심이 무엇인지 살펴봅니다. 그다음 Wasm이 바이트코드 수준에서 최소공배수를 제공하면서도 고유한 특성을 가진 기반을 어떻게 규정했는지 봅니다. 이후에는 _컴파일러_가 예외를 지원한다는 것이 무엇을 의미하는지—제어 흐름에 미치는 영향, 언와인더와의 통신을 어떻게 구체화(reify)하는지, ABI와의 교차점은 무엇인지 등—를 한 바퀴 돌아보고, 마지막으로 Wasmtime이 이것을 어떻게 하나로 엮고(성능 함정을 피하고 사양이 의도한 성능 특성을 충실히 유지하기 위해) 주의하는지 살펴보겠습니다.
많은 독자분들은 이미 Python, Java, JavaScript, C++, Lisp, OCaml 등 다양한 언어에서 예외를 보셨을 것입니다. 하지만 우리가 (i) 예외가 정확히 무엇을 의미하는지 분명히 하고, (ii) 예외가 왜 그렇게 인기 있는지 논의하기 위해 간단히 복습해 보겠습니다.
예외 처리는 비지역적(nonlocal) 제어 흐름 메커니즘입니다. 특히 대부분의 제어 흐름 구조는 함수 내부적(intraprocedural) 이고(현재 함수의 다른 코드로 제어를 보냄) 렉시컬(lexical) 합니다(정적 분석으로 알 수 있는 위치를 대상으로 함). 예를 들어 if 문과 loop는 둘 다 이런 식으로 동작합니다: 로컬 함수 내부에 머물며 정확히 어디로 제어를 이동할지 알 수 있죠. 반면 예외는(혹은 그럴 수 있는데) 함수 간(interprocedural) 이며(다른 어떤 함수의 지점으로도 제어를 옮길 수 있음) 동적(dynamic) 입니다(대상 위치가 런타임 상태에 따라 달라짐).2
조금 더 풀어보면: 오류나 다른 조건을 신호하고 현재 계산을 “되감기(unwind)”—즉 현재 컨텍스트를 벗어나기—가 필요할 때 예외가 throw 됩니다. 그리고 특정 종류의 예외에 관심이 있고 현재 “활성(대기)” 상태인 핸들러가 그것을 catch 합니다. 핸들러는 현재 함수 안에 있을 수도 있고, 이 함수를 호출한 임의의 상위 함수에 있을 수도 있습니다. 따라서 throw/catch 결과는 함수에서의 비정상적 조기 반환을 초래할 수 있습니다.
이 메커니즘이 필요한 이유는 프로그램이 오류를 처리하는 방식을 생각해 보면 이해할 수 있습니다. 예를 들어 Rust 같은 언어에서는 fn foo(...) -> Result<T, E> 형태의 시그니처를 흔히 봅니다. Result 타입은 foo가 보통은 T 타입의 값을 반환하지만, 대신 E 타입의 오류를 낼 수도 있음을 나타냅니다. 이것을 인체공학적(쓰기 좋은)으로 만드는 핵심은 오류가 반환되면 실행을 “단락(short-circuit)”해서 그 오류를 위로 전파하는 방식—예컨대 Rust의 ? 연산자처럼 “오류면 이 함수에서도 그 오류를 곧장 반환”—을 제공하는 것입니다.3 이는 여러 면에서 개념적으로 매우 깔끔합니다: 왜 오류 처리는 프로그램의 다른 데이터 흐름과 달라야 할까요? 결과 타입에 오류 가능성을 포함해 기술하고, 평범한 제어 흐름으로 처리하자는 것이죠. 그래서 아래와 같이 쓸 수 있습니다.
fn f() -> Result<u32, Error> {
if bad {
return Err(Error::new(...));
}
Ok(0)
}
fn g() -> Result<u32, Error> {
The `?` propagates any error to our caller, returning early.
let result = f()?;
Ok(result + 1)
}
그리고 g에서 f의 오류를 더 멀리 전파하려고 특별히 뭔가를 할 필요는 없습니다. ?만 쓰면 됩니다.
하지만 여기에 비용 이 있습니다. 오류를 낼 수 있는 모든 함수가 더 큰 반환 타입을 가지게 되어 ABI에 영향이 있을 수 있고(최소한 또 하나의 반환 레지스터, 아니면 스택에 Result를 배치하고 메모리 로드/스토어가 필요한 표현), 또한 그런 함수를 호출한 뒤마다 그 결과가 오류인지 확인하는 조건 분기가 적어도 하나 필요합니다. 즉 “행복 경로”(예외가 없는 경로)의 동적 효율이 영향을 받습니다. 이상적으로는 오류가 실제로 발생할 때까지는 어떤 비용도 들지 않아야 합니다(그리고 오류가 발생하는 경우엔 수용 가능한 범위에서 비용이 조금 더 들어도 되죠—트레이드오프이니까요).
이는 언어 런타임의 도움 으로 가능해집니다. 각 반환에서 Result 반환 타입과 오류 검사들을 없앤다고 해 봅시다. 그렇다면 오류를 처리하는 코드로 도달할 필요가 있는데, 다른 방식으로 이 코드에 도달해야 합니다. 어쩌면 이 코드로 직접 점프할 수 있을까요?
“제로 코스트 예외 처리”의 핵심 아이디어는, 컴파일러가 부가 테이블(side-table)을 만들어 그 코드—“핸들러”라고 부르는—의 위치를 알려주게 하는 것입니다. 우리는 호출 스택을 걸어 올라가며, 호출자와 그 호출자, 그 위로 계속 방문하면서 우리가 올린 오류 조건에 관심 있는 함수를 찾을 수 있습니다. 이 로직은 이런 부가 테이블과 “언와인더”(스택을 “되감는” 역할)의 도움으로 구현됩니다. 오류가 발생하지 않으면 런타임에서 이 로직은 실행되지 않습니다. 그리고 더 이상 “행복 경로”에서 오류 반환을 검사하는 명시적 체크도 없습니다. 그래서 이런 스타일의 오류 처리를 “제로 코스트”라고 부르는 겁니다: 더 정확히는 오류가 없을 때 제로 코스트이고, 오류 시 언와인딩은 여전히 비쌀 수 있습니다.
이는 대부분의 프로덕션 언어에서 예외 처리를 구현하는 현 상황과 같습니다. 예컨대 C++ 세계에서는, 예외 처리가 흔히 Itanium C++ ABI4에 의해 구현되는데, 컴파일러가 내보내는 포괄적인 테이블과 시스템 언와인딩 라이브러리, 그리고 컴파일러 생성 코드가 복잡한 춤을 추며 핸들러를 찾고 거기로 제어를 넘겨 줍니다. 인터프리터나 JIT 컴파일 언어 구현에서도 핸들러 테이블과 스택 언와인더는 흔합니다. 예컨대 SpiderMonkey는 바이트코드에 try notes를 두고(“try 블록”에서 이름이 옴) HandleException 함수가 스택 프레임을 걸어 핸들러를 찾습니다.
WebAssembly 사양은 이제(버전 3.0부터) 예외 처리를 포함합니다. 이 제안은 표준화, 툴체인, 브라우저 진영의 여러 분들이 오랜 기간 작업해 왔고, CG(표준화 그룹)가 사양에 병합하여 최근 릴리스된 “Wasm 3.0” 마일스톤에 포함했습니다. 이미 이 제안에 익숙하시다면 아래의 Cranelift/Wasmtime 고유 부분까지 건너뛰셔도 됩니다.
먼저: Wasm이 왜 예외를 지원하기 위해 바이트코드 정의에 확장을 필요로 하는지 살펴봅시다. 앞서 설명했듯, 제로 코스트 예외 처리의 핵심 아이디어는 언와인더가 스택 프레임을 방문하며 핸들러를 찾고, 표준적인 함수 반환 경로 바깥의 첫 번째 핸들러로 직접 제어를 넘긴다는 것입니다. 호출 스택은 Wasm 코드에서 보호 되어 있어(읽기/쓰기가 직접 불가; Wasm의 제어 흐름 무결성 부분), 이런 방식으로 동작하는 언와인더는 필연적으로 Wasm 런타임의 권한 있는 부분이어야 합니다. “유저스페이스”에 구현할 수 없습니다. Wasm 바이트코드에는, 일련의 리턴을 통한 방법 외에, 먼 호출자에게 직접 제어를 넘기는 수단이 없기 때문입니다. 이 누락된 기능을 사양 확장이 추가합니다.
구현은 단 세 개의 opcode(!)와 바이트코드 수준 타입 시스템의 몇 가지 새 타입으로 요약됩니다. (즉—이 글의 분량을 감안하면—속이기 좋을 만큼 단순합니다.) 이 opcode는 다음과 같습니다:
try_table: 내부 본문을 감싸며, 그 본문이 실행되는 동안 활성 인 핸들러 들을 지정합니다. 예:(block $b1 ;; 이 블록 끝으로의 전방 점프를 위한 라벨 정의
(block $b2 ;; 마찬가지로 또 다른 라벨
(try_table
(catch $tag1 $b1) ;; 태그 `$tag1`의 예외는 $b1의 끝에 있는 코드가 잡음
(catch_all $b2) ;; 다른 모든 예외는 $b2의 끝에 있는 코드가 잡음
body...)))
이 예에서 body 안에서 예외가 던져지고 지정된 태그 중 하나와 일치하면, 제어는 주어진 블록 끝에 정의된 위치로 이동합니다. (이는 Wasm의 다른 제어 흐름 전이와 같습니다. 예를 들어 분기 br $b1도 $b1의 끝으로 점프합니다.)
이 구성은 범용 “catch” 메커니즘이며, 예외를 가진 대부분의 프로그래밍 언어의 전형적인 try/catch 블록을 직접 변환하기에 충분히 강력합니다.
throw: 새 예외를 직접 던지는 명령입니다. 예외의 태그를 함께 전달합니다. 예: throw $tag1.throw_ref: 이미 잡혀서 참조로 보유 중인 예외를 재던질 때 사용합니다(아래에 더 자세히!).이게 전부입니다! 이 세 opcode를 구현하면 “끝”입니다.
물론 이야기는 여기서 끝이 아닙니다. 보통 소스 언어는 예외의 일부로 데이터 를 담는 능력을 제공합니다. 즉, 오류 조건이 고정된 종류의 집합 중 하나인 것에서 끝나지 않고 몇몇 필드를 포함합니다. (예: “파일을 찾을 수 없음”이 아니라 “파일을 찾을 수 없음: $PATH”.)
정적 태그만 있는 바이트코드 수준 throw/catch에 전역 상태를 더해 구축할 수도 있지만 번거롭습니다. 대신, Wasm 사양은 각 예외에 페이로드 를 제공합니다. 완전 일반성을 위해 이 페이로드는 실제로 값들의 리스트 (즉, 완전한 곱(product) 타입—구조체 타입)를 가질 수 있습니다.
앞서 “태그”를 언급했지만 자세히 설명하진 않았습니다. 이 태그가 페이로드 정의의 핵심입니다. 각 태그는 실질적으로 그 페이로드 값들의 리스트 타입도 함께 지정하는 타입 정의입니다. (기술적으로 Wasm AST에서 태그 정의는 반환이 없는 파라미터만 있는 함수 타입 을 이름 붙여 재사용하는 깔끔한 트릭입니다.) 샘플 모듈로 정의를 보여 봅시다:
(module
;; 특정 종류의 예외를 정의하고 그 페이로드 값을 지정하는 "태그" 정의
(tag $t (param i32 i64))
(func $f (param i32 i64)
;; 새 예외를 던지고, 동적으로 가장 가까운 핸들러가 잡도록 함
(throw $t (local.get 0) (local.get 1)))
(func $g (result i32 i64)
(block $b (result i32 i64)
;; 아래 본문을 실행하되, 지정된 핸들러(catch 절)를
;; 스코프에 두어 일치하는 예외를 잡음.
;;
;; 여기서는 본문에서 태그 `$t` 예외가 던져지면
;; 블록 `$b`의 끝으로(마치 그리로 분기한 것처럼) 제어가 이동하고,
;; 그 예외의 페이로드 값들이 피연산자 스택에 푸시됨
(try_table (catch $t $b)
(call $f (i32.const 1) (i64.const 2)))
(i32.const 3)
(i64.const 4))))
여기서 하나의 태그를 정의했고(텍스트 포맷에서 이름 $t를 붙였지만 바이너리 포맷에서는 인덱스 0으로만 식별), 두 개의 페이로드 값을 갖습니다. 이 태그의 예외를 해당 타입의 값들로 던질 수 있고(함수 $f처럼), 그와 정확히 같은 타입을 반환하는 블록 끝을 catch 대상으로 지정하면 잡을 수 있습니다. 함수 $g를 호출하면, 예외 페이로드 값 1과 2가 예외와 함께 던져지고, try_table이 이를 잡아 $g의 결과는 1과 2가 됩니다. (3과 4 값은 Wasm 모듈 검증(타입 일치)을 위해 존재하지만, $f에서 throw가 일어나므로 동적으로 도달되지 않아 반환되지 않습니다.)
여기서 Wasm은 바이트코드이기 때문에, 실금속 ISA에 비해 일반화를 약간 더하고 Wasm 생산자(모듈을 생성하는 툴체인)에게 편의를 제공할 수 있습니다. 이런 면에서 Wasm은 컴파일러 IR에 조금 더 가깝습니다. 반면 대부분의 다른 예외 던지기 ABI는 페이로드가 고정 정의입니다(예: 한두 개의 머신 레지스터 크기 값). 실무적으로는 어떤 생산자는 모든 예외 태그에 대해 작은 고정 시그니처를 선택할 수도 있지만, 어차피 Wasm 뒤에는 컴파일러와 런타임이 있으므로 굳이 인위적인 한계를 둘 이유는 없습니다.
지금까지 Wasm의 원시 연산으로 기본적인 예외 던지기/잡기가 가능함을 봤습니다. 그런데 C++처럼 스코프 자원을 가진 언어는 어떨까요? 예를 들어 아래와 같이 쓴다면,
struct Scoped {
Scoped() {}
~Scoped() { cleanup(); }
};
void f() {
Scoped s();
throw my_exception();
}
throw는 f 바깥으로 제어를 이동시켜 일치하는 핸들러로 위로 올라가야 하지만, 그래도 s의 소멸자는 실행되어 cleanup을 호출해야 합니다. 이는 실제로 오류 조건을 처리하려는 게 아니므로 엄밀히 말해 “catch”는 아닙니다. 탐색을 중단하고 싶지 않거든요.
이런 프로그램을 컴파일하는 일반적 접근법은 “잡고 재던지기(catch-and-rethrow)”입니다. 즉, 프로그램은 대략 아래처럼 낮춰집니다.
try {
throw ...
} catch_any(e) {
cleanup();
rethrow e;
}
여기서 catch_any는 이 지점을 지나 스택을 전파하는 모든 예외를 잡고, rethrow는 같은 예외를 재던집니다.
Wasm의 예외 원시 연산은 우리가 필요한 구성요소를 정확히 제공합니다: 모든 예외를 잡고 잡힌 예외를 참조로 박스화 하는 catch_all_ref 절과, 이전에 잡은 예외를 재던지는 throw_ref 명령입니다.5
실제로는 “catch” 옵션의 2×2 매트릭스가 있습니다. 특정 태그를 catch할 수도 있고 catch_all 할 수도 있으며; 예외를 즉시 페이로드 값들로 언팩해서 잡을 수도 있고(위에서 본 것처럼), 참조로 잡을 수도 있습니다. 그래서 catch, catch_ref, catch_all, catch_all_ref가 존재합니다.6
Wasm 제안의 마지막 세부사항이자, 사실 제가 가장 흥미롭고 독특하다고 생각한 부분이 있습니다. 위 소개와, 다른 언어 의미론/런타임에 대한 친숙함을 고려하면, 예외 종류를 식별하고 throw와 특정 catch 핸들러를 매칭하는 “태그”는 정적 레이블일 것이라 예상할 수 있습니다. 즉, 내가 태그 $tA로 예외를 던지면, 어떤 모듈에서든 스택 위쪽에서 $tA 핸들러를 가장 먼저 만나는 곳이 그것을 잡아야 한다는 식이죠.
하지만 Wasm은 바이트코드로서 격리를 중시하는 중요한 특성이 있습니다. 정적 모듈 과 동적 인스턴스 를 구분하며, 모듈에는 “정적 멤버”가 없습니다. 모듈이 정의하는 모든 엔티티(예: 메모리, 테이블, 글로벌 변수)는 모듈의 각 인스턴스마다 복제됩니다. 이는 인스턴스 간에 깔끔한 분리를 만들어, 예를 들어 어떤 공통 모듈(저수준 글루/헬퍼 모듈)을 여러 곳에서 별도의 인스턴스로 마음껏 재사용해도 서로 통신하거나 간섭하지 않음을 보장합니다.
인스턴스 A가 어떤 (동적으로 제공되는) 함수 참조를 호출했고, 그것이 궁극적으로 A의 콜백을 호출하는 경우를 생각해 봅시다. 인스턴스는 콜백 내부에서 예외를 던져 중간의 다른 Wasm 인스턴스 함수들을 가로질러 바깥쪽 스택 프레임들까지 전부 언와인드하려고 합니다:
A.f ---------call---------> B.g --------call---------> A.callback
^ v
catch $t throw $t
| |
`----------------------------<-------------------------------------'
인스턴스 A는 자신의 콜백 함수에서 f로 던진 예외가 A 인스턴스에게만 로컬 한 관심사이며 B가 간섭할 수 없다고 기대합니다. 결국 그 예외 태그가 A 내부에서 정의됐고, Wasm이 모듈성을 유지한다면, B는 예외 처리를 내부적으로 사용하더라도 그 태그를 이름 붙여 해당 예외를 잡을 수 없어야 합니다. 두 모듈은 상호작용하지 않아야 합니다. 이것이 모듈성의 의미이고, 이로써 인스턴스의 동작을 지역적으로 추론할 수 있으며 “세상의 나머지” 영향은 import/export에 국한됩니다.
불행히도, 단순한 “정적” 태그 매칭을 설계하면, B가 A와 같은 모듈의 인스턴스인 경우 문제가 생길 수 있습니다. 그때 B도 내부적으로 $t 태그를 사용하고 그 태그에 대한 핸들러를 등록하면, 원하는 throw/catch 동작을 방해하여 모듈성을 깨뜨릴 수 있습니다.
그래서 Wasm 예외 처리 표준은 태그도 메모리, 테이블, 글로벌처럼 동적 인스턴스 를 갖는다고 규정합니다(PL 이론 용어로는 태그가 생성적(generative) 임). 모듈의 각 인스턴스는 그 모듈에서 정적으로 정의된 태그들에 대한 자체 동적 정체성을 만들고, 이 동적 정체성을 사용해 예외에 꼬리표를 붙이고 핸들러를 찾습니다. 이는 위의 예에서 B가 어떤 인스턴스이든, A 인스턴스가 자신의 태그 $t를 B가 import하도록 export하지 않으면, B가 명시적으로 던진 예외를 잡을 방법이 없음을 의미합니다(여전히 모든 예외를 잡을 수는 있고, 정리를 위해 잡고 재던질 수도 있음). 로컬 모듈성 추론이 회복됩니다.
태그를 Wasm 메모리처럼 동적 엔티티로 만들면, 다른 엔티티에서 하듯 import/export도 허용할 수 있습니다. 따라서 예외 페이로드의 가시성과 어떤 모듈이 특정 예외를 잡을 수 있는지는 인스턴스화 그래프와 import/export 링크에 의해 완전히 제어됩니다. 다른 Wasm 저장소 엔티티와 정확히 동일합니다.
이건 놀랍습니다(적어도 제게는 그랬습니다)! 이는 언와인더 구현에 꽤 독특한 도전 과제를 만듭니다—본질적으로 각 스택 프레임에 대해 정적 코드 위치와 핸들러 리스트뿐 아니라 인스턴스 정체성을 알아야 함을 의미합니다.
Wasmtime에 예외 처리 원시 연산을 넣기 전에, 먼저 기반 컴파일러 백엔드인 Cranelift에서 예외를 지원해야 합니다.
왜 이것이 컴파일러의 문제일까요? 예를 들어 새로운 수학 연산을 구현하는 Wasm 명령(이미 IR에 많은 산술 연산이 있음)이나 Wasm 메모리(이미 IR에 로드/스토어가 있음)와 무엇이 다를까요?
간단히 말해 복잡성은 세 가지 맛으로 옵니다. (i) 예외 핸들러라는 새로운 제어 흐름이 생기는데, 이는 일반 분기나 호출과 근본적으로 달리 “외부에서 작동”합니다(언와인더에 의해); (ii) 언와인더가 컴파일된 코드와 상호작용하는 방식을 좌우하는, 우리가 정의해야 하는 ABI의 새로운 측면; (iii) 핸들러의 “스코프”적 특성과 특히 인라이닝과의 상호작용입니다. 아래에서 각각 이야기하겠습니다.
아래 논의의 많은 부분은 Wasmtime/Cranelift를 위한 RFC에서 시작되었습니다. 2024년 8월에 Daniel Hillerstrom이 제안했고 제 동료 Nick Fitzgerald의 도움을 받았으며, 당시 논의됐습니다. 이후 구현 과정에서 흥미로운 뉘앙스를 발견하며 여러 선택지가 다듬어졌고 함께 논의했습니다.
컴파일러 IR(중간 표현) 관점에서 예외 핸들러를 생각하는 방법은 몇 가지가 있습니다. 먼저, 예외 처리는 (i) 제어 흐름의 한 형태이며, (ii) 다른 제어 흐름이 갖는 각종 컴파일러 단계의 함의를 모두 갖는다는 점을 인정해야 합니다. 예를 들어 레지스터 할당기는 제어가 한 기본 블록에서 다른 블록으로 이동할 때(“엣지 무브”) 레지스터를 올바른 상태로 만드는 방법을 고려해야 합니다. 예외 catch는 새로운 종류의 엣지이므로 레지스터 할당기도 이를 알아야 합니다.
예외를 던질 수 있는 모든 호출이나 다른 opcode가 가능한 모든 핸들러로의 일반 제어 흐름 엣지를 가진다고 볼 수 있습니다. 이를 “일반 엣지(regular edges)” 접근이라 부르겠습니다. 장점은 레트로핏이 꽤 간단하다는 겁니다. out-edge를 가진 새로운 제어 흐름 opcode만 추가하면 되는데, IR에서 이미 있던 종류입니다. 단점은 던질 수 있는 opcode나 핸들러가 많은 함수에서는 오버헤드가 꽤 커질 수 있다는 겁니다. 그리고 제어 흐름 그래프의 오버헤드는 나쁜 오버헤드입니다. 많은 분석의 실행 시간은 엣지와 노드(기본 블록) 수에 크게 의존하며 때로는 초선형적으로 커집니다.
다른 주요 옵션은 IR의 의미에 암묵적 인 새로운 제어 흐름을 구축하는 것입니다. 예컨대 소스 언어의 “try 블록” 의미를 하나의 영역(region)으로 낮추고, 하나의 핸들러 집합을 부착하는 방식입니다. 이는 try 블록 내의 모든 호출 사이트에서 각 핸들러로 out-edge를 추가하는 것보다 분명히 효율적입니다. 반면, 이 변경이 얼마나 침습적인지 과소평가하기 어렵습니다. 이는 모든 IR 순회가 이런 새로운 암묵 엣지를 어차피 고려해야 함을 의미합니다. Cranelift처럼 큰 기존 컴파일러에서는 Rust의 타입 시스템에 많이 의존해 여러 리팩터링을 할 수 있지만, 근본 불변을 바꾸는 것은 그 범위를 넘어섭니다. 이런 변경은 장기적인 문제의 꼬리를 낳을 수 있고, 컴파일러 변경의 인지적 부담을 영구적으로 높입니다. 일반적으로 우리는 더 작고 단순한 코어와 얽히지 않고 합성 가능한 복잡성을 지향합니다.
그래서 선택은 명확했습니다. Cranelift에서는 함수를 호출하고 (일부) 예외를 잡는 새 명령 try_call 하나를 도입하기로 했습니다. 즉, 이제 두 가지 가능한 반환 경로가 있습니다: 정상 반환, 그리고 (아마도 여러 개 중 하나인) 예외적 반환. 처리할 예외와 블록 대상은 예외 테이블 에 열거됩니다. 이 opcode는 제어 흐름 엣지를 갖기 때문에, 조건 분기처럼 블록 종료자(terminator)입니다. Cranelift IR(CLIF)로 보면 대략 다음과 같습니다:
function %f0(i32) -> i32, f32, f64 {
sig0 = (i32) -> f32 tail
fn0 = %g(i32) -> f32 tail
block0(v1: i32):
v2 = f64const 0x1.0
;; 예외를 잡는 호출 사이트
try_call fn0(v1), sig0, block1(ret0, v2), [ tag0: block2(exn0), default: block3(exn0) ]
;; 정상 반환 경로
block1(v3: f32, v4: f64):
v5 = iconst.i32 1
return v5, v3, v4
;; tag0에 대한 예외 핸들러
block2(v6: i64):
v7 = ireduce.i32 v6
v8 = iadd_imm.i32 v7, 1
v9 = f32const 0x0.0
return v8, v9, v2
;; 그 외 모든 예외에 대한 핸들러
block2(v10: i64):
v11 = ireduce.i32 v10
v12 = f32.const 0x0.0
v13 = f64.const 0x0.0
return v11, v12, v13
}
몇 가지 측면을 짚어 봅시다. 먼저, 왜 호출만 신경 쓸까요? 다른 예외 소스는요? IR의 중요한 불변이 있습니다. 예외 throw 는 항상 외부에서 기원 합니다. 즉, 예외가 던져졌다면, 호출 스택을 충분히 깊이 내려가면 그 throw가 런타임으로의 호출로 구현된 것을 찾게 됩니다. IR 자체에는 다른 throw를 일으키는 opcode가 없습니다! 이는 충분합니다: (i) 여기서는 Wasmtime에 필요한 것만 만들면 되고, (ii) Wasm의 throw opcode는 Wasmtime 런타임으로의 “libcall”(라이브러리 호출)로 구현할 수 있습니다. 따라서 Cranelift가 컴파일한 코드 내에서는 예외 throw는 항상 호출 사이트에서 발생합니다. 우리는 try_call 하나만 추가하고 핸들러 정보를 그 opcode에 직접 붙이면 됩니다.
다음으로 주목할 특징은 핸들러가 평범한 기본 블록이라는 점입니다. 이는 다른 컴파일러 IR(예: LLVM)에서 예외 핸들러가 확실히 특별하다는 점을 본 분께만 놀랍지 않을 수 있습니다. 거기서는 핸들러가 “랜딩패드” 명령으로 시작해야 하고, 평범한 기본 블록처럼 분기해서 들어갈 수 없습니다. 대략 다음과 같습니다:
function %f() {
block0:
;; 정상 반환은 `block1`, 예외 핸들러는 `block2`인 호출 사이트
v0 = try_call ..., block1, [ tag0: block2 ]
block1:
;; 정상 반환; 반환값 사용
return v0
block2 exn_handler: ;; 특별히 표시된 블록!
;; 예외 핸들러 페이로드 값
v1 = exception_landing_pad
...
}
블록(정상 vs 예외 핸들러)의 이분화는 바람직하지 않습니다. 예외 엣지가 새로운 가로지르는 관심사(cross-cutting concern)를 추가하는 것처럼, 제약을 가진 새 종류의 블록도 모든 분석/변환이 고려해야 합니다. 우리의 명시적 설계 목표(그리고 이를 보여주는 테스트)가 있었는데, 동일한 블록이 평범한 블록이자 핸들러 블록이 될 수 있게 하는 것입니다—그게 꼭 일반적이어서가 아니라(보통 핸들러는 정상 코드 경로와 매우 다른 일을 합니다), IR의 이상한 사소한 특징을 하나 줄이기 위해서입니다.
하지만 핸들러가 평범한 블록이라면, 데이터 흐름 문제가 매우 흥미로워집니다. 예외를 잡는 호출은, IR의 다른 모든 opcode와 달리, 조건부로 정의되는 값 을 가집니다. 즉, 정상 함수 반환 값은 피호출자가 정상 반환할 때만 유효하고, 예외 페이로드 값 은 언와인더에서 전달되어 잡힌 예외에 대한 정보를 담으며, 피호출자가 우리가 잡는 예외를 던질 때만 유효합니다. 이런 값들이 유효한 방식으로만 사용되도록 어떻게 표현을 보장할까요? 모두를 해당 opcode의 일반 SSA 정의로 만들 수는 없습니다. 그러면 모든 후속자(정상/예외)에서 사용할 수 있게 되어 아래처럼 됩니다:
function %f() {
block0:
;; 정상 반환은 `block1`, 예외 핸들러는 `block2`인 호출 사이트
v0 = try_call ..., block1, [ tag0: block2 ]
block1:
;; 합법적 사용: 정상 반환 시 정의됨
return v0
block2:
;; 이런! 여기서 `v0`를 썼지만, 예외가 잡혀 핸들러 블록에 도달하면
;; 정상 반환 값은 정의되지 않음
return v0
}
이 때문에 어떤 컴파일러는 핸들러 블록을 특별하게 만들기도 합니다. 블록의 세계를 둘로 나누면, 정상/예외 반환 값이 적절한 곳에서만 사용되도록 보장할 수 있기 때문이죠. 일부 컴파일러 IR은 예외 반환 페이로드를, 정규 블록 시작 지점의 phi처럼 핸들러 블록 시작 지점에 반드시 있어야 하는 “랜딩패드” 명령으로 구체화합니다. 그러나 이 이분화는 바람직하지 않습니다.
여기서 우리의 통찰은(꽤 많은 논의 끝에) 정의를 있는 곳—엣지—에 두는 것이었습니다. 즉, 정상 반환 값은 우리가 정상 반환 엣지를 따르게 되었을 때만 정의되고, 예외 페이로드도 마찬가지입니다. 하지만 후속 블록에 반드시 있어야 하는 특수 명령을 만들고 싶지는 않습니다. 이는 분산된 이상한 불변이고, IR을 변환할 때 버그를 낳기 쉽죠. 대신, 우리가 블록 파라미터 기반 SSA 를 쓰고 있다는 점을 활용해 블록 호출 인수의 허용 범위를 넓혔습니다.
이전에는 brif v1, block2(v2, v3), block3(v4, v5)처럼, 분기에서 선택된 후속자에 값 사용 리스트로 블록 파라미터에 값을 할당했다면, 이제는 (i) SSA 값, (ii) 특수 “정상 반환값” 센티넬, (iii) 특수 “예외 반환값” 센티넬을 허용합니다. 후자 둘은 여러 개일 수 있으므로 인덱싱됩니다. 그래서 try_call에서의 블록 호출을 block2(ret0, v1, ret1)처럼 쓸 수 있는데, 이는 호출의 두 반환값과 일반 SSA 값 하나를 전달합니다. 또는 block3(exn0, exn1)처럼 예외 페이로드 두 개만 전달할 수 있습니다. 우리는 IR에 대한 새 적절성 검사로 (i) 정상 반환값은 정상 반환 블록 호출에서만, 예외 페이로드는 핸들러 테이블 블록 호출에서만 사용되도록 하고, (ii) 정상 반환 인덱스가 시그니처의 반환 수를 넘지 않도록, (iii) 예외 페이로드 인덱스가 ABI의 예외 페이로드 수를 넘지 않도록 보장합니다. 하지만 이런 검사는 모두 명령 단위의 지역(local) 검사입니다. 블록 전체에 분산된 불변이 아닙니다. 좋습니다. 그리고 우리가 가진 다른 모든 명령 방식과도 부합합니다. (블록 호출 인수 타입은 후속 블록의 블록 파라미터 타입과 대조해 검사되는데, 이는 모든 분기에서 하는 일반 검사와 동일합니다.) 그래서 위에서 반복하자면, 호출 사이트는 아래처럼 됩니다.
block1:
try_call fn0(v1), block2(ret0), [ tag0: block3(exn0, exn1) ]
원했던 모든 성질을 가집니다: 블록은 한 종류뿐, 명시적 제어 흐름, 그리고 SSA 값은 합법적인 곳에서만 정의됨.
이 모든 것은 지나고 보면 다소 당연해 보일 수 있지만, 위의 GitHub 논의와 Cranelift 주간 회의록이 증언하듯, 단순성과 일반성을 최대화하고 기묘한 점과 발목잡기를 최소화하도록 어떻게 설계해야 할지는 처음에는 전혀 명확하지 않았습니다. 최종 설계에 꽤 만족합니다. 핵심 블록 파라미터 SSA 제어 흐름 그래프의 자연스러운 확장처럼 느껴지고, 저는 이것을 컴파일러에 넣을 수 있었습니다 (물론 몇몇PR과 Cranelift에 대한 연관된수정, 그리고 regalloc2의기능및 테스트 수정도 있었고, 아마 몇 개는 빠뜨렸겠지만요) 큰 어려움 없이요.
이제 예외 핸들러를 표현할 수 있는 IR을 정의했습니다—그렇다면 이 함수 본문과 언와인더의 상호작용은 어떨까요? 이 인터페이스를 못 박기 위해 다른 종류의 의미론이 필요합니다. 본질적으로 이는 ABI(응용 바이너리 인터페이스)의 속성입니다.
앞서 언급했듯, 네이티브 코드(C++ 등)에는 기존 예외 처리 ABI가 있습니다. 네이티브 ABI에서 영감을 얻고 가능한 한 정렬하고자 하는 의지는 분명 있지만, Wasmtime에서는 이미 자체 ABI를 정의해 왔기 때문에7 기존 표준에 반드시 구속되지는 않습니다.
특히, 그렇게 하고 싶지 않은 매우 그럴듯한 이유가 있습니다. 특정 예외 핸들러로 언와인드하려면 ABI가 규정한 대로 레지스터 상태를 복원해야 하고, 표준 Itanium ABI는 대상 ISA의 통상적인 callee-saved(“비휘발성”) 레지스터를 복원할 것을 요구합니다. 그러나 이는 (i) throw 시점의 레지스터 상태가 있어야 하고, (ii) 스택을 거슬러 올라가며 각 스택 프레임에서 언와인드 메타데이터를 처리해 저장된 레지스터 값을 스택 프레임에서 읽어내야 합니다. 후자는 4년 전 제가 만든 범용 “언와인드 의사 명령” 프레임워크로 이미 지원되지만, 여전히 우리 언와인더에 복잡성을 더합니다(그리고 이 복잡성은 정확성을 떠받치는 역할을 하게 됩니다). 전자는 Wasmtime의 일반적인 런타임 진입 트램펄린에서는 극도로 어렵습니다. 그래서 우리는 더 단순한 예외 ABI를 선택합니다. 모든 핸들러가 있는 try_call—즉 예외를 잡는 호출 사이트—은 모든 레지스터를 클러버(clobber)합니다. 이렇게 하면 컴파일러의 평소 레지스터 할당 동작이 모든 라이브 값을 스택에 저장하고, 정상 반환이든 예외 반환이든 복원합니다. 우리는 스택(스택 포인터와 프레임 포인터 레지스터)만 복원하고 프로그램 카운터(PC)를 핸들러로 리다이렉트하면 됩니다.
예외 던지기 언와인더에서 신경 써야 하는 ABI의 다른 측면은 예외 페이로드입니다. 네이티브의 Itanium ABI는 대부분 플랫폼에서 런타임 정의 페이로드를 담는 두 개의 레지스터(예: x86-64의 rax와 rdx, aarch64의 x0와 x1)를 지정합니다. 단순성을 위해 우리도 같은 규칙을 채택합니다.
좋습니다. 그렇다면 이를 만족하도록 레지스터 할당기 동작과 함께 try_call은 어떻게 구현할까요? 우리는 이미 꽤 복잡한 ABI 처리 로직을 가지고 있습니다(머신 독립적으로다섯가지아키텍처 구현 포함). 하지만 일반적인 패턴을 따릅니다. 레지스터 할당기 레벨에서 단일 명령을 생성하고, 고정 레지스터 제약을 가진 use/def를 내보냅니다. 즉, 레지스터 할당기에게 파라미터가 특정 레지스터에 있어야 한다고 말합니다(예: x86-64 System V에선 rdi, rsi, rcx, rdx, r8, r9, aarch64에선 x0부터 x7)—그러면 필요한 무브는 레지스터 할당기가 처리합니다. 가장 단순한 경우(aarch64) 호출은 대략 아래처럼 보일 수 있습니다(레지스터 할당기 use/def와 제약 주석 첨부):
bl (call) v0 [def, fixed(x0)], v1 [use, fixed(x0)], v2 [use, fixed(x1)]
하지만 항상 이렇게 단순하진 않습니다. 호출은 항상 단일 명령이 아니며, 이는 예외 처리 지원에서 꽤 문제가 되었습니다. 특히 반환값이 레지스터 수를 초과하면 ABI에 따라 메모리로 반환하는데, 이때 호출 이후에 로드 명령 을 추가해 스택 위치에서 여분의 결과를 로드합니다. 그래서 호출 사이트는 아래처럼 명령을 생성할 수 있습니다.
bl v0 [def, fixed(x0)], ..., v7 [def, fixed(x7)] # 첫 8개 반환값
ldr v8, [sp] # 9번째 반환값
ldr v9, [sp, #8] # 10번째 반환값
문제는, 우리는 try_call이 종결자(terminator)라고 말했는데 IR 레벨에서는 맞지만 레지스터 할당기 레벨에서는 더 이상 그렇지 않게 된다는 점입니다. 그리고 레지스터 할당기 역시 올바른 제어 흐름 그래프를 기대합니다. 그래서 저는 리팩터링을 해서, 이런 반환값 로드들을 하나의 레지스터 할당기 레벨 의사 명령으로 합쳤고, 이는 다시 몇 가지 레지스터 할당기 수정( 256개 초과 피연산자 허용, 최악의 경우 할당을 위해 라이브레인지 분할을 더 공격적으로, 그리고 라이브레인지 분할 수정의 추가 수정, 퍼징 개선)으로 이어졌습니다.
Cranelift 컴파일 코드에서 예외 처리와 레지스터 할당의 상호작용을 생각할 때 하나의 마지막 질문이 생길 수 있습니다. Cranelift에는 레지스터 할당기가 임의의 두 명령 사이에 이동(move) 을 삽입할 수 있다는 불변이 있습니다—레지스터 간 이동, 스택 프레임의 스필 슬롯과의 로드/스토어, 다른 스필 슬롯 간 이동—그리고 실제로 레지스터에 담을 수 있는 상태보다 더 많은 상태가 있을 때 언제든지 그렇게 합니다. 또한 블록 간 “엣지 무브”도 삽입해야 합니다. 코드의 다른 위치로 점프할 때 레지스터 값이 다르게 배치되어야 할 수 있기 때문입니다. 언와인더가 핸들러를 호출하기 위해 코드의 다른 위치로 점프할 때, 예상대로 상태가 맞도록 필요한 이동이 모두 실행되었는지 보장해야 합니다.
여기서의 답은 아무것도 하지 않아도 된다는 신중한 논변입니다. (문제에 대한 최고의 해법이죠. 물론 옳을 때만요!) 논지의 핵심은 크리티컬 엣지에 있습니다. 크리티컬 엣지는 다중 후속자를 가진 블록에서 다중 선행자를 가진 블록으로 가는 엣지입니다. 예를 들어 아래 그래프에서
A D
/ \ /
B C
A가 B 또는 C로 갈 수 있고, D도 C로 갈 수 있다면 A→C는 크리티컬 엣지입니다. 크리티컬 엣지의 문제는 A→C 전이에 반드시 실행되어야 하는 코드를 둘 곳이 없다는 겁니다(A에 둘 수 없는데 B나 C로 갈 수 있고, C에 둘 수도 없는데 A나 D에서 왔을 수 있으니까요). 그래서 레지스터 할당기는 이를 금지하고, 코드 생성 시 엣지 위에 빈 블록(e 아래)을 삽입해 “분할”합니다.
A D
/ \ |
| e |
| \ /
B C
핵심 통찰은, 핸들러가 있는 try_call은 항상 한 개 초과의 후속자를 가진다는 점(정상 반환 경로 후속자도 항상 필요하기 때문)8이고, 이 경우 크리티컬 엣지를 분할하므로 예외 catch 경로의 즉시 후속 블록은 단 하나의 선행자만 가진다는 점입니다. 그래서 레지스터 할당기는 예외를 잡을 때 실행해야 하는 이동을 선행자가 아니라 후속(핸들러) 블록에 항상 둘 수 있습니다. 엣지 무브를 어디에 둘지에 대한 우리의 규칙은 후속 블록(엣지 “이후” 블록)에 두는 것을 선호하며, 그 블록이 다중 인엣지를 갖지 않는 한 그렇습니다. 그래서 이미 그랬던 겁니다. 우리가 유의해야 할 유일한 점은, 핸들러 테이블에서 IR 레벨 핸들러 블록(C 위) 대신 삽입된 엣지 블록(e 위)의 주소를 기록해야 한다는 것입니다.
그리고 레지스터 할당 관점에서는 사실상 이게 전부입니다!
이제 Cranelift의 예외 지원 기본기를 다뤘습니다. 이 시점에서, 컴파일러 쪽은 랜딩했지만 Wasmtime 쪽은 아직인 상태로 잠깐 컨텍스트 스위칭을 했고, 그 사이 bjorn3이 이 지원을 곧장 잡아 rustc_codegen_cranelift—Cranelift 기반 Rust 컴파일러 백엔드—에 패닉 언와인딩 지원을 추가했습니다. 그들이 기여한 몇 가지 작은 변경과 엣지 케이스 수정, 리팩터 이후, rustc_codegen_cranelift의 패닉 언와인딩 지원이 동작했습니다. 이는 제가 만든 것이 쓸만하고 비교적 탄탄하다는 매우 좋은 중간 검증이었습니다.
컴파일러가 예외를 지원하게 되었고, Wasm 예외 의미론도 이해했으니, 이제 Wasmtime에 지원을 넣어봅시다! 얼마나 어렵겠습니까?
저는 세 opcode(try_table, throw, throw_ref) 각각의 코드생성을 설계하는 것부터 시작했습니다. 이 작업 맨 초기에, Wasm 예외 처리 제안을 읽기는 했지만 완전히 체화하진 못했던 터라, “기본” throw/catch를 먼저 구현한 뒤 나중에 어떻게든 exnref 객체를 만들 수 있으리라 생각했습니다. 그리고 (지금 생각하면) 다소 요령주의적이지만, 값을 튜플처럼 묶어 exnref로 인덱싱되는 그런 튜플의 테이블을 만들어 Wasmtime이 externref에서 하는 것처럼 exnref를 만들 수 있겠다고 생각했죠.
하지만 곧 몇 가지를 깨닫고 이 이해는 깊어졌습니다:
우리가 예외 객체 저장소를 위해 GC 힙의 존재를 반드시 요구하지 않는 저렴한 “부분집합” 구현을 만들 수 있는지에 대한 질문이 있었습니다(광범위하게 논의된 PR). 이론적으로는 예외를 C 수준 setjmp/longjmp 용도로 쓰지만 다른 GC 기능은 쓰지 않는 게스트에게 좋을 것입니다. 하지만 몇 가지 이유로 약간 까다롭습니다. 첫째, 이 부분집합은 throw_ref를 제외하도록 요구할 것입니다(그래야 예외 객체 저장의 또 다른 형태를 고안하지 않아도 되니까요). 하지만 사양을 부분집합으로 나누는 건 그다지 좋지 않습니다—그리고 throw_ref는 GC 게스트 언어만을 위한 것이 아니라 재던지기에도 필요합니다. 둘째, 더 일반적으로는 이는 추가 유지보수와 테스트 표면을 더하는데, 당장은 원치 않습니다. 대신 GC를 충분히 저렴하게 만들고, 그 성장 휴리스틱을 똑똑하게 만들어 “빈번한 setjmp/longjmp” 스트레스 테스트에서도(예를 들어) 매우 작은(몇 KB) GC 힙 안에서 살게 하여 사실상 목적 맞춤 저장소를 근사할 수 있다고 예상합니다. 제 동료 Nick Fitzgerald(Wasmtime의 GC 지원을 구축하고 개선을 주도 중)가 좋은 이슈에 우리의 트레이드오프와 아이디어를 정리해 두었습니다.
이 모든 걸 감안하면, 우리는 예외 객체 구현을 하나만 만들 것입니다—좋습니다!—하지만 그것은 새로운 종류의 GC 객체가 되어야 합니다. 이는 예외 객체를 먼저 구축하는 큰 PR로 이어졌습니다(실제 던지기/잡기 지원에 앞서), 그리고 호스트 API로 이를 할당하고 필드를 검사하게 했습니다. 본질적으로, 이들은 불변 필드를 가진 구조체이며, 덜 노출된 타입 격자와 서브타이핑이 없습니다.
그래서 저는 throw 명령의 libcall(런타임 구현)을 구현하고 있었고, 마침내 핵심에 다다랐습니다: 핸들러를 찾기 위해 스택 프레임을 걷는 언와인더 자체. 모든 것을 하나로 묶는 마지막 기능 조각입니다. 거의 다 왔습니다!
하지만 잠깐: 이 사양 문구를 보시죠. 9단계에서 스토어에서 “태그 주소”를 로드합니다: 예외 인스턴스 {tag z.tags[x], fields val^n}를 할당합니다. 런타임 의미론에서 스토어(z)의 tags 배열은 무엇일까요? 태그는 정적 정체성이 아니라 동적 정체성을 갖습니다! (이는 제가 위에서 설명한 내용을 배우던 시점입니다.)
이건 문제였습니다. 저는 정수(u32)로 식별되는 태그에 핸들러를 연관시키는 예외 테이블을 정의했기 때문입니다—Cranelift IR의 대부분 엔티티가 그러하듯, Wasmtime이 인덱스(예: 모듈 내 태그 인덱스)를 정의하고 정적 태그 ID를 비교하면 충분하다고 여겼습니다.
어쩌면 문제가 아닐 수도 있습니다. 정적 인덱스가 모듈(정의 또는 import된 태그)의 엔티티 ID를 정의하고, 인스턴스 ID와 함께 비교해 핸들러가 일치하는지 보면 될지도요. 하지만 스택 프레임에서 인스턴스 ID는 어떻게 얻을까요?
알고 보니 Wasmtime에는 그런 방법이 없었습니다. 아직 아무것도 그걸 필요로 하지 않았기 때문입니다(이 부족한 부분은 Wasm coredump를 구현할 때도 눈치채긴 했지만, 그때는 고칠 만큼의 동인/필요가 없었죠). 그래서 저는 이슈를 등록하고 몇 가지 아이디어를 적었습니다. 모든 프레임에 인스턴스 포인터를 저장하는 새 필드를 추가할 수 있습니다—그리고 사실 이것은 최소한 하나의 프로덕션 Wasm 구현(SpiderMonkey 웹 엔진)이 하는 일과 간단한 버전으로 비슷합니다(다만 링크된 [SMDOC] 주석대로, 서로 다른 인스턴스 간 전이 프레임에서만 인스턴스 포인터를 저장합니다. 언와인더가 스택을 선형으로 걸을 때는 이걸로 충분합니다). 하지만 이는 모든 Wasm 함수에 오버헤드를 더합니다(SpiderMonkey 접근을 따르면 인스턴스 간 전이 트램펄린 추가가 필요한데, Wasmtime에 큰 변경이 됩니다). 예외 처리는 실무에서 여전히 비교적 드뭅니다. 이상적으로는 가능한 한 작은 추가 복잡성으로 “사용한 만큼만 지불(pay-as-you-go)”하는 방식을 원했습니다.
대신 저는 예외 핸들러 목록에 “동적 컨텍스트” 항목을 추가하는 아이디어를 냈습니다. 아이디어는 SSA 값을 목록에 주입하고, 핸들러 테이블 메타데이터에 지정된 스택 위치에 저장해 스택 워커가 그것을 찾게 하는 것입니다. Cranelift 관점에서는 이는 임의의 불투명 값입니다. Wasmtime은 이를 언와인더가 사용할 원시 인스턴스 포인터(vmctx)를 저장하는 데 씁니다.
이로써 설계가 더 일반화된 상태로 예쁘게 채워졌습니다. 이는 예외 페이로드와 대칭입니다. 컴파일된 코드는 프레임을 읽을 때 언와인더에게 컨텍스트/상태를 전달 할 수 있고, 언와인더는 언와인드할 때 데이터를 컴파일된 코드에 전달 할 수 있습니다.
의도하진 않았지만—그때는 전혀 그럴 생각이 없었지만—이것은 인라이닝 문제 도 깔끔하게 해결합니다. 간단히 말해, 우리는 IR이 “로컬” 하길 원합니다. 함수 경계를 특별히 취급하지 않아야 합니다. 그래야 인라이너가 IR을 합성해도 아무 문제가 없습니다. 함수 전체에 “현재 인스턴스” 상태를 저장하면, 한 모듈(따라서 인스턴스)에서 다른 모듈로 함수를 인라인할 때 당연히 깨집니다!
대신 우리는 동적 컨텍스트 항목을 가진 핸들러 테이블에 멋진 연산 의미론을 줄 수 있습니다. 언와인더는 왼쪽에서 오른쪽으로 읽어가며, 각 동적 컨텍스트 항목에서 자신의 “현재 동적 컨텍스트”를 갱신하고, 태그 핸들러 항목에서 태그 매칭을 검사합니다. 그러면 인라이너는 예외 테이블을 합성 할 수 있습니다. try_call 호출 사이트가 피호출자 함수 본문을 인라인하고, 그 본문이 또 다른 호출 사이트를 가진다면, 핸들러 테이블은 예외 테이블 항목을 단순히 이어 붙이면 됩니다.
여기서 중요한 점: Wasm 의미론의 또 하나 놀라운 사실은, 태그의 출처를 이해하기 위한 전역 프로그램 분석 없이는, 핸들러를 정적으로 해결하거나 핸들러 목록을 최적화하는 특정 최적화 를 할 수 없다는 것입니다. 예를 들어, 태그 0에 대한 핸들러 다음에 태그 1에 대한 핸들러가 있고, try_table 본문 안에서 태그 1에 대한 throw가 직접 보인다고 해도, 이를 반드시 해결할 수는 없습니다. 태그 0과 1이 같은 태그일 수 있으니까요!
어떻게 그럴 수 있죠? 태그 import 를 생각해 보세요:
(module
(import "test" "e0" (tag $e0))
(import "test" "e1" (tag $e1))
(func ...
(try_table
(catch $e0 $b0)
(catch $e1 $b1)
(throw $e1)
(unreachable))))
이 모듈을 같은 동적 태그 인스턴스를 두 번 제공해(두 import 모두에) 인스턴스화할 수 있습니다. 그러면 첫 번째 핸들러(블록 $b0로)가 매칭됩니다. 또는 서로 다른 태그를 주면 $b1 블록이 매칭됩니다. 최적화 게임에서 이기는 유일한 방법은 아예 하지 않는 것입니다—원래 핸들러 목록을 보존해야 합니다. 다행히 이는 컴파일러 일을 더 쉽게 만듭니다. 우리는 try_table의 핸들러를 그대로 Cranelift 예외 핸들러 테이블로 베낀 다음, 언와인더의 핸들러 매칭 로직이 정확히 그 순서대로 읽는 메타데이터로 다시 직렬화합니다.
예외 객체는 GC로 관리되는 객체이므로, 올바르게 루트 되어야 합니다. 즉, 다른 GC 객체 내부 참조 외에, 이러한 객체에 대한 모든 핸들은 GC가 객체를 계속 살려 두도록(그리고 이동형 GC의 경우 참조가 갱신되도록) 알아야 합니다.
Wasm→Wasm 예외 던지기 시나리오에서는 비교적 쉽습니다. 제어 흐름 전이의 양쪽에서 참조가 컴파일된 코드에 루트되어 있고, 참조가 언와인더를 잠깐 거치기만 하면 됩니다. 적절한 타입으로 조심스럽게 다루기만 하면 잘 동작합니다.
호스트/Wasm 경계를 가로질러 예외를 전달하는 것은 딴 얘기입니다. 우리는 {host, Wasm} × {host, Wasm}의 전체 매트릭스를 지원합니다. 즉, Wasm이 호출한 호스트 네이티브 코드에서 예외가 던져질 수도 있고(Wasm import를 통해), Wasm 코드에서 예외가 던져져 Wasm을 호출한 호스트 코드로 일종의 오류처럼 반환될 수도 있습니다. 이는 예외를 anyhow::Error 안에 박스화해, 호스트 코드에서 Result와 ? 연산자로 Rust 스타일의 값 기반 오류 전파를 사용하도록 함으로써 동작합니다.
Error 내부 값이 Wasmtime Store 안의 예외 객체를 보유할 때 무슨 일이 일어날까요? Wasmtime은 이것이 루트되어 있음을 어떻게 알까요?
최근 작업 전 Wasmtime의 답은 두 가지 외부 루팅 래퍼 중 하나를 사용하는 것이었습니다: Rooted와 ManuallyRooted. 둘 다 Store 내부의 테이블 인덱스를 보유하고, 그 테이블에 실제 GC 참조가 들어 있습니다. 이는 GC가 루트를 쉽게 보고 갱신하게 합니다.
차이는 수명 규율에 있습니다. ManuallyRooted는 이름이 암시하듯 수동으로 언루팅해야 합니다. Drop 구현이 없고, 쉽게 누수를 유발합니다. 반면 Rooted는 LIFO(후입선출) 규율을 가지며, 임베더(Wasmtime 사용자)가 생성한 Scope라는 RAII 타입을 기반으로 합니다. 그 동적 스코프를 벗어나 탈출하는 Rooted GC 참조는 언루팅되며, 런타임에 사용되면 에러(패닉)를 일으킵니다. 둘 다 의도적으로 스코프를 탈출하도록 설계된 값—예외— 에는 이상적이지 않습니다. ? 전파로 탈출해야 하니까요.
우리가 채택한 설계는 훨씬 더 단순한 접근입니다. Store에 “보류 중 예외”를 위한 단일 명시적 루트 슬롯을 만들고, 호스트 코드는 이를 설정한 뒤 Result의 오류 타입으로 센티넬 값 (wasmtime::ThrownException)을 반환합니다(anyhow::Error로 박스화). 이렇게 하면 전파가 기대대로 동작하면서 무한정 누수도 없고(루트되는 보류 예외는 하나뿐), 루트되지 않은 예외가 전파되는 일도 없습니다(실제 GC 참조가 전파되는 것이 아니라 센티넬만 전파되기 때문).
이 루팅 딜레마를 고민하던 중 사이드 퀘스트로, 저는 또한 “소유” 루트된 참조를 만드는 것이 가능해야 한다는 걸 깨달았고, 그 결과 OwnedRooted가 ManuallyRooted를 대체했습니다. 이 타입은 Drop 시 언루팅을 위해 Store 접근을 요구하지 않습니다. 핵심 아이디어는 별도의 아주 작은 할당에 대한 refcount를 “drop 플래그”로 보유하고, 스토어가 주기적으로 이 drop 플래그를 스캔하여 루트를 지연 제거(lazy)하는 겁니다. 임계치 기반 알고리즘으로 그 스캔을 총합 선형 시간으로 만듭니다.9
이제 이게 있으니, 이론상 Error 타입에서 OwnedRooted<ExnRef>를 직접 전달해 호스트 코드를 통해 예외를 전파할 수도 있겠지만, 스토어 루트 방식이 충분히 단순하고, 별도 할당이 없어 약간 성능상 이점도 있으므로 지금 당장 API를 바꿀 강한 필요는 느끼지 않습니다.
이제 모든 설계 선택지를 논의했으니, 예외 던지기/잡기의 생애를 처음부터 끝까지 훑어봅시다. 간단히 Wasm→Wasm 던지기/잡기를 가정하겠습니다.
try_table 안에서 실행되며, 이는 각 핸들러 케이스에 대해 예외 핸들러 catch 블록을 생성하게 됩니다. create_catch_block 함수는 translate_exn_unbox를 호출하는 코드를 생성하는데, 이는 예외 객체에서 모든 필드를 읽어 핸들러 경로의 Wasm 피연산자 스택에 푸시합니다. 이 핸들러 블록은 현재 렉시컬 핸들러 스택을 추적하는 HandlerState에 등록됩니다(그리고 Wasm 블록형 연산자를 빠져나올 때 핸들러를 상태에서 팝할 수 있도록 체크포인트를 나눠줍니다). 이 핸들러들은 이터레이터로 제공되어 translate_call 메서드에 전달되고, 결국 try_call 명령에 예외 테이블을 생성하는 데 쓰입니다. 이 try_call이 곧 예외를 던질 Wasm 코드를 호출합니다.throw opcode에 도달하면, 이는 FuncEnvironment::translate_exn_throw을 통해 세 단계 연산 시퀀스로 번역됩니다: 현재 인스턴스 ID를 가져오고(런타임으로 libcall), 그 인스턴스 ID와 고정 태그 번호로 새 예외 객체를 할당한 뒤 Wasm 피연산자 스택에서 팝한 값들로 슬롯을 채우고, throw_ref로 위임합니다.throw_ref opcode 구현은 throw_ref libcall을 호출합니다.HostResult 트레이트 구현을 통해). 결국 이 케이스에 도달해 보류 중 예외 센티넬을 보고 compute_handler를 호출합니다. 이제 예외 던지기 구현의 핵심에 다다랐습니다.compute_handler는 Handler::find로 스택을 걷습니다. 이는 다시 visit_frames에 기반해, 프레임 포인터 체인이 있는 코드에 대해 예상대로 동작합니다: 단일 연결 리스트로 프레임을 걷습니다. 각 프레임에서 compute_handler가 Handler::find에 준 클로저가 그 프레임의 프로그램 카운터(반환 주소—다음 아래 프레임을 만든 호출 다음 명령)를 lookup_module_by_pc로 모듈을 찾아내고, 그 모듈에는 PC를 모듈 내에서 조회할 줄 아는 ExceptionTable(Cranelift 메타데이터에서 컴파일 시 생성된 직렬화 메타데이터 파서)이 있습니다. 이는 핸들러에 대한 이터레이터를 생성하며, 이를 순서대로 검사해 일치하는 것이 있는지 봅니다. (Cranelift에서 나온 예외 핸들러 테이블 항목의 그룹은 여기에서 후처리되어 위 루틴이 검색하는 테이블을 만듭니다.)UnwindState::UnwindToWasm이 됩니다.UnwindToWasm 상태는 unwind libcall의 이 케이스를 일으키며, 이는 어떤 libcall이든 에러 코드를 반환할 때 호출됩니다. 결국 반환이 없는 함수 resume_to_exception_handler를 호출하는데, 이 작은 인라인 어셈 함수는 말 그대로 그 일을 합니다. 이 세 줄의 어셈블리는 rsp와 rbp를 새 값으로 설정하고, 새 rip(PC)로 점프합니다. 네 가지 네이티브 컴파일 아키텍처 각각에 동일한 스텁이 있습니다(x86-64 위 링크, aarch64, riscv64, s390x10). 그러면 위에서 만든 catch 블록으로 제어가 이전되고, Wasm은 계속 실행되며, 예외 페이로드를 언박스하고 핸들러를 실행합니다!이제 Wasm 예외 처리가 생겼습니다! 흥미로운 설계 질문들을 많이 풀어야 했지만, 끝은 꽤 싱겁게 찾아왔습니다. 저는 마지막 PR을 머지했고, 후속 정리 PR(1)과 몇 가지 퍼즈 버그 수정(1234567)—대부분 널 포인터 처리와 타입 시스템 엣지 케이스에 관한 것—과 테일콜과의 상호작용 하나, 그리고 그것이 드러낸 s390x ABI 버그 하나를 처리한 뒤, 기본적으로 안정적이 되었습니다. 사용자 리포트도 꽤 빨리 나왔습니다: 여기에서는 Wasm 내부에서 예외 기반 setjmp/longjmp를 사용하는 Lua 인터프리터가 동작한다고 보고했고, 여기에서는 Kotlin-on-Wasm이 실행되어 큰 테스트 스위트를 통과했다고 합니다. 나쁘지 않네요!
모두 합쳐 37개의 PR, diff 통계 +16264 -4004(총 16KLoC)—처음 낙관적으로 예상했던 “작거나 중간 규모” 프로젝트는 분명 아니었지만, 이렇게 구축해 비교적 쉽게 안정 상태로 가져올 수 있어 기쁩니다. 이는 (주로 Cranelift 쪽이었던) 과거 작업과는 다른 방식으로 보람찬 여정이었습니다—과거 많은 프로젝트가 정말로 매우 오픈엔드한 설계 또는 연구 질문이었던 반면, 여기서는 상위 형태가 이미 있었고, 모든 작업은 고품질의 디테일을 설계하고 시스템의 나머지와의 흥미로운 상호작용을 풀어내는 일이었습니다. 특히 IR 설계가 얼마나 깔끔하게 나왔는지 만족합니다. 그리고 Cranelift/Wasmtime 기여자분들의 정말 훌륭한 지속적 논의(특히 Nick Fitzgerald와 Alex Crichton에게 감사)가 없었다면 이렇게 되지 못했을 겁니다.
여담으로: Wasm 예외 처리의 용도를 떠나, Cranelift 자체의 예외 지원이 유용해진 것도 기쁩니다. 위에서 언급했듯 cg_clif는 준비되자마자 거의 곧바로 가져다 썼습니다. 게다가 예상치 못한 즐거운 놀라움으로, 이후 Alex가 Wasmtime의 트랩 언와인딩을 setjmp/longjmp 대신 Cranelift 예외 핸들러를 사용하도록 갈아엎었습니다. 후자는 Rust에서 오래된 의미론적 질문/이슈가 있었기 때문입니다. 이를 위해 하나의 intrinsic이 더 필요했는데, 전체 예외 언와인더 없이 사용자 정의 언와인드 로직에 예외 핸들러 주소를 노출하는 가장 좋은 방법을 Alex와 논의한 뒤 제가 구현했습니다. 그 외에는 try_call과 우리의 예외 ABI의 꽤 직접적인 적용이었습니다. 일반적인 빌딩 블록은 대체로 유용하네요!
이 글의 초안에 피드백을 준 Alex Crichton과 Nick Fitzgerald에게 감사드립니다!
1
변명하자면, 예외 처리와 가비지 컬렉션(GC)의 상호작용을 과소평가했습니다. exnref가 완전 일급 값이며 호스트 API를 포함해 지원이 필요하다는 걸 그때는 깨닫지 못했죠. 또한 예외는 호스트/게스트 경계를 넘을 수 있고, 아시다시피 그건 금방 아주 재미있어집니다. 적어도 컴파일러 쪽에서는 겨우 두 배 틀렸습니다!
2
구현 관점에서, 예외의 동적이고 함수 간적인 특성이, 조건문·루프·호출 같은 고전적 제어 흐름보다 훨씬 더 흥미롭고 손이 많이 가게 만듭니다! 예외 throw의 대상은 오직 런타임에만 계산할 수 있고, 그 위치로 “페이로드”와 함께 제어를 이전하는 규약이 필요하기 때문에, 단순히 올바른 곳으로 점프를 생성하는 대신 런타임 데이터 구조, “스택 워크”, 조회 테이블이 필요한 이유가 바로 이것입니다.
3
관심 있는 분들을 위해: 이것은 모나드입니다. 예컨대 Haskell은 일찍 반환되는 “결과 또는 오류” 타입을 Either로 구현하며, 개념을 명시적으로 그렇게 설명합니다. ? 연산자는 모나드의 “bind” 역할을 합니다. 오류를 낼 수 있는 계산을 오류가 없을 때의 값 사용과 연결하고, 대신 오류가 주어지면 그 오류를 곧장 반환합니다.
4
Intel Itanium (IA-64)의 이름을 딴 것입니다. 이 ISA는 C++에 대해 이 스킴이 처음 구현된 아키텍처였고, 이제는 사실상 죽었습니다(너무 이른 죽음! 지독히 오해받았죠!). 그 유산만 남아 있습니다…
5
간단히 언급할 만한 점으로, Wasm 예외 처리 제안은 다소 구불구불한 여정을 거쳤습니다. 표준화되지는 않았지만 일부 브라우저에 출하된 더 이른 변형(지금은 “레거시 예외 처리”라 부름)은 재던지기를 다른 방식으로 처리했습니다. 특히 그 제안은 재던질 수 있는 일급 예외 객체 참조를 제공하지 않고, 명시적 rethrow 명령을 가졌습니다. 저는 그 초기 설계 논쟁에 참여하지는 않았지만, 개인적으로는, 항상 예외 객체를 할당하는 의미론을 그대로 구현하는 한, 예외 객체 참조를 일급으로 제공해 평범한 데이터 흐름으로 전달할 수 있게 하는 쪽이 훨씬 낫다고 봅니다. 구현도 더 단순해집니다.11
6
정확히 말하면(약간 놀랄 수도 있어) catch_ref는 핸들러 목적지의 피연산자 스택에 페이로드 값 그리고 예외 참조를 모두 푸시합니다. 본질적으로 규칙은 이렇습니다: 태그-특정 변형은 항상 페이로드를 언팩하고; 또한, _ref 변형은 항상 예외 참조를 푸시합니다.
7
특히 Wasmtime에서는 어떤 두 시그니처 사이든 보편적 테일콜이 동작하도록 자체 ABI를 정의해 두었습니다. 이 ABI(tail)는 표준 System V 호출 규약을 기반으로 하지만, 스택 인자를 정리하는 주체가 호출자가 아니라 피호출자라는 점이 다릅니다.
8
물론 컴파일러 해킹은 엣지 케이스에서 과도한 고생이 없이는 성립하지 않으므로, 흥미로운 버그가 하나 있었습니다. 빈 핸들러 목록 케이스인데, 이 미묘한 이유로 모든 try_call에 대해 어쨌든 엣지 분할을 강제해야 합니다.
9
물론 이걸 하는 동안 저는 C/C++ API에 CVE-2025-61670를 만들어 버렸습니다. (i) C FFI 바인딩의 단순 오타(as vs from—소유권 이전 시 중요!)와 (ii) C++ 래퍼가 단일 소유권을 제대로 유지하지 않는다는 사실을 몰랐기 때문입니다. ASAN 테스트가 없어 처음에 못 봤고; Alex가 Python 바인딩을 업데이트하는 중 해당 누수를 빠르게 찾아내 이슈를 발견하고 CVE를 관리했습니다. 죄송하고, 고맙습니다!
10
어셈블리 세 줄도 올바르게 작성하기 어렵더군요. s390x 변형은 버그가 있었습니다. 레지스터 제약을 잘못 잡았기 때문입니다(s390x에서 GPR 0은 특수하고, 레지스터로의 분기에는 GPR 1–15만 사용할 수 있습니다. 이를 표현하려면 다른 제약이 필요했습니다). 그 결과 미스컴파일이 발생했죠. 이를 추적해 주신 s390x 컴파일러 해커 Ulrich Weigand께 감사드립니다.
11
물론 항상 예외를 박스화하는 것이 제안을 구현하는 유일한 방법은 아닙니다. 참조로 잡히지 않는다면 예외를 “언박스”하여 할당을 건너뛰고, 페이로드를 다른 엔진 상태를 통해 직접 전달하는 최적화도 가능할 것입니다. Wasmtime에는 이 최적화가 구현되어 있지 않으며, 작은 예외 객체의 할당 성능이 대부분의 사용 사례에 충분하리라 기대합니다.