패닉을 구동하는 언와인딩 메커니즘을 파고들며, Rust의 패닉 경로를 단계적으로 다이어트해 약 4.3배 가속을 달성하는 방법을 보여주고, 이를 실용적으로 제공하는 Lithium 크레이트를 소개합니다.
2024년 11월 6일 Reddit Hacker News
세 달 전, 오류 처리를 위해 패닉을 사용할 수도 있는 이유에 대해 글을 썼습니다. 제목은 그럴싸하지만, 매크로와 라이브러리로 꼼수를 부린다 해도 패닉 자체는 이 목적에 잘 맞지 않습니다. 진짜 주인공은 패닉을 구동하는 _언와인딩(unwinding) 메커니즘_입니다. 이 글은 언와인딩이 무엇인지, 어떻게 빠르게 만들 수 있는지, 그리고 그것이 Rust와 C++ 프로그래머들에게 어떤 이점을 줄 수 있는지를 탐구하는 연재의 첫 글입니다.
Rust에서 더 빠른 예외와 언와인딩을 위해 Lithium 크레이트를 확인해 보세요.
일반적으로 함수는 호출 직후의 문장으로 반환합니다:
fn f() {
let x = g();
dbg!(x); // x = 123
}
fn g() -> i32 {
return 123;
}
이제 호출이 _대체 반환 지점(alternate return point)_을 지정해서 피호출자가 어디로 돌아갈지 결정할 수 있다고 상상해 봅시다:
// 상상해 본 문법
fn f() {
g() alternate |x| {
dbg!(x); // x = 123
};
}
fn g() -> () alternate i32 {
return_alternate 123;
}
언뜻 보면 단순해 보입니다. 대체 주소로 반환하는 게 기본 주소로 반환하는 것보다 특별히 비쌀 이유가 없으니, 분명 저렴해야 할 것 같습니다.
그런데 잠깐. 이 대체 반환 메커니즘, 어딘가 익숙하지 않나요…
// 상상해 본 문법
fn f() {
g() catch |x| {
dbg!(x); // x = 123
};
}
fn g() -> () throws i32 {
throw 123;
}
저건 바로 예외죠! 그리고 우리 모두 예외가 느리다는 걸 압니다. 어떻게 대체 반환 주소에서, 성능 코드에서는 무조건 피해야 한다는 그 예외로 이야기가 흘러간 걸까요?
대체 반환 메커니즘의 핵심은 _언와인더(unwinder)_입니다. 이것은 기본 반환 주소를 대체 반환 주소에 매핑하고, 대체 반환 값을 호출 경계를 넘어 전달하고, 그 값을 소비하는 역할을 하는 시스템 라이브러리입니다. 구체적인 API는 OS마다 다르지만, 리눅스에서는 주로 다음 두 함수가 핵심입니다:
_Unwind_RaiseException(Exception): 현재 기본 반환 지점에 있을 때 대체 반환을 수행합니다._Unwind_Resume(Exception): 현재 대체 반환 지점에 있을 때 대체 반환을 수행합니다.그렇다면 패닉과 예외를 느리게 만드는 구현상의 디테일은 무엇일까요? 연재에서 이를 파헤칠 것이고, 오늘은 언와인더를 수정하지 않은 채 Rust 쪽 패닉 처리를 가속하는 방법을 시도해 보겠습니다.
우선 criterion으로 현재 Rust의 패닉 성능을 측정해 봅시다:
// 패닉 메시지로 stderr 스팸되는 것을 막습니다
std::panic::set_hook(Box::new(|_| {}));
b.iter(|| {
let _ = std::panic::catch_unwind(|| panic!("Hello, world!"));
})
결과: 2.3814 µs. 초당 백만 번도 안 됩니다. 왜 이렇게 느릴까요?
panic!()을 호출하면 무슨 일이 일어나는지 봅시다. 몇 번의 매크로 단계를 거친 뒤, core::panic::panic_fmt에 도달합니다:
pub const fn panic_fmt(fmt: fmt::Arguments<'_>) -> ! {
// snip
extern "Rust" {
#[lang = "panic_impl"]
fn panic_impl(pi: &PanicInfo<'_>) -> !;
}
let pi = PanicInfo::new(
fmt,
Location::caller(),
/* can_unwind */ true,
/* force_no_backtrace */ false,
);
unsafe { panic_impl(&pi) }
}
포맷 인수는 타입 소거(type erasure)되어, 일부 최적화를 방해합니다.
게다가 많은 Rust 빌트인은 패닉할 수 있기 때문에 panic!은 core에 정의되어 있고, 패닉 메커니즘은 OS 의존적이라 실제 패닉 처리는 std에 구현되어 있습니다. 따라서 panic_impl은 크레이트 경계를 넘는 extern 함수이며, LTO 없이는 인라이닝될 수 없습니다.
pub fn begin_panic_handler(info: &core::panic::PanicInfo<'_>) -> ! {
struct FormatStringPayload<'a> { /* snip */ }
// snip
unsafe impl PanicPayload for FormatStringPayload<'_> {
fn take_box(&mut self) -> *mut (dyn Any + Send) {
// 안타깝지만 여기서 할당을 두 번 합니다. (a) 현재 스킴에서는 필요하고,
// (b) 패닉 + OOM을 제대로 처리하지도 못하거든요(아래 begin_panic 주석 참조).
let contents = mem::take(self.fill());
Box::into_raw(Box::new(contents))
}
// snip
}
// snip
crate::sys::backtrace::__rust_end_short_backtrace(move || {
if let Some(s) = msg.as_str() {
// snip
} else {
rust_panic_with_hook(
&mut FormatStringPayload { inner: &msg, string: None },
loc,
info.can_unwind(),
info.force_no_backtrace(),
);
}
})
}
fn rust_panic_with_hook(
payload: &mut dyn PanicPayload,
location: &Location<'_>,
can_unwind: bool,
force_no_backtrace: bool,
) -> ! {
// snip
match *HOOK.read().unwrap_or_else(PoisonError::into_inner) {
// snip
Hook::Custom(ref hook) => {
hook(&PanicHookInfo::new(location, payload.get(), can_unwind, force_no_backtrace));
}
}
// snip
rust_panic(payload)
}
여기서는 포맷 인수를 또 다른 타입 소거 박스에 감싼, 타입 소거된 패닉 페이로드 객체를 만들고, 언와인딩이 시작되기도 전에 패닉 후크를 호출합니다!
다행히 panic! 대신 std::panic::resume_unwind를 호출하면 이 로직의 대부분을 건너뛸 수 있습니다. 이 함수는 패닉 후크를 무시하고 임의의 포맷 문자열 대신 Box<dyn Any + Send>를 받으므로, 부담을 덜 수 있습니다:
b.iter(|| {
let _ = std::panic::catch_unwind(|| std::panic::resume_unwind(Box::new("Hello, world!")));
})
결과: 1.8379 µs, 24% 개선. 단지 간접화 제거만으로도 나쁘지 않네요!
resume_unwind는 호출을 rust_panic_without_hook으로 전달합니다:
pub fn rust_panic_without_hook(payload: Box<dyn Any + Send>) -> ! {
panic_count::increase(false);
struct RewrapBox(Box<dyn Any + Send>);
unsafe impl PanicPayload for RewrapBox {
fn take_box(&mut self) -> *mut (dyn Any + Send) {
Box::into_raw(mem::replace(&mut self.0, Box::new(())))
}
// snip
}
// snip
rust_panic(&mut RewrapBox(payload))
}
fn rust_panic(msg: &mut dyn PanicPayload) -> ! {
let code = unsafe { __rust_start_panic(msg) };
rtabort!("failed to initiate panic, error {code}")
}
extern "Rust" {
/// `PanicPayload` lazily performs allocation only when needed (this avoids
/// allocations when using the "abort" panic runtime).
fn __rust_start_panic(payload: &mut dyn PanicPayload) -> u32;
}
여기에도 여전히 타입 소거가 있습니다. 첫째, 페이로드가 Box<dyn Any + Send>이고, 둘째, &mut RewrapBox를 &mut dyn PanicPayload로 캐스팅합니다. 정적으로 타입이 주석된 대체 반환이라면 둘 다 필요 없습니다. 또한 더블 패닉 보호(panic_count)도 이 맥락에서는 필요하지 않습니다.
그렇다면 아예 __rust_start_panic을 직접 호출해 볼까요?
#![feature(std_internals)]
use core::any::Any;
use core::panic::PanicPayload;
struct RewrapBox(Box<dyn Any + Send>);
unsafe impl PanicPayload for RewrapBox {
fn take_box(&mut self) -> *mut (dyn Any + Send + 'static) {
Box::into_raw(core::mem::replace(&mut self.0, Box::new(())))
}
fn get(&mut self) -> &(dyn Any + Send + 'static) {
&*self.0
}
}
impl core::fmt::Display for RewrapBox {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str("Box<dyn Any>")
}
}
unsafe extern "Rust" {
safe fn __rust_start_panic(payload: &mut dyn PanicPayload) -> u32;
}
b.iter(|| {
let _ = std::panic::catch_unwind(|| {
__rust_start_panic(&mut RewrapBox(Box::new("Hello, world!")))
});
})
결과: 580.44 ns. 무려 68% 개선입니다! 지금은 패닉 카운터를 건드리고 있어 정상성(soundness) 이 깨졌지만, 곧 고치겠습니다.
이제 패닉 카운트의 (미러링된) 감소를 어떻게 우회할지 알아봅시다. 우리가 찾는 것은 std::panic::catch_unwind인데, 이 함수는 호출을 여기로 그대로 전달합니다. 여기에 #[inline(always)]를 추가하고, #[cold]를 제거하고, 패닉 카운트 감소를 제거하면, 성능 저하 없이 정상성을 회복할 수 있습니다.
다음으로 벗겨낼 추상화 레이어는 다음 두 함수입니다:
extern "Rust" fn __rust_start_panic(payload: &mut dyn PanicPayload) -> u32;
extern "C" fn __rust_panic_cleanup(payload: *mut u8) -> *mut (dyn Any + Send + 'static);
rustc의 -C panic="unwind/abort" 플래그에 따라 이 함수들을 제공하는 서로 다른 크레이트가 링크됩니다. 우리가 관심 있는 것은 panic_unwind입니다. 소스는 GitHub에서 볼 수 있습니다.
여기서 드디어 플랫폼 특화 코드로 들어갑니다. 저는 리눅스를 사용하므로, Itanium 예외 처리 ABI(Rust 코드에서는 GCC로 표기)에 관심이 있습니다. 구현은 꽤 간단합니다:
pub unsafe fn panic(data: Box<dyn Any + Send>) -> u32 {
let exception = Box::new(Exception {
_uwe: uw::_Unwind_Exception {
exception_class: rust_exception_class(),
exception_cleanup: Some(exception_cleanup),
private: [core::ptr::null(); uw::unwinder_private_data_size],
},
canary: &CANARY,
cause: data,
});
let exception_param = Box::into_raw(exception) as *mut uw::_Unwind_Exception;
return uw::_Unwind_RaiseException(exception_param) as u32;
// snip
}
pub unsafe fn cleanup(ptr: *mut u8) -> Box<dyn Any + Send> {
let exception = ptr as *mut uw::_Unwind_Exception;
if (*exception).exception_class != rust_exception_class() {
// snip
}
let exception = exception.cast::<Exception>();
// snip
let exception = Box::from_raw(exception as *mut Exception);
exception.cause
}
패닉을 던지기 위해 힙에 또 하나의 객체를 할당하고, 그것을 _Unwind_RaiseException에 전달합니다. 패닉을 잡을 때는 이를 다시 Box로 캐스팅한 뒤 cause 필드를 꺼냅니다.
정적으로 주석된 코드에 맞게 이 코드를 단순화하려면, 원래 Box로 감싸지 않고 예외 객체 안에 원인(cause)을 직접 포함하면 됩니다. 또한 우리의 예외를 Rust 패닉과 구분하기 위해 커스텀 예외 클래스를 사용하겠습니다:
#[repr(C)]
struct UwException {
class: u64,
destructor: Option<extern "C" fn(u32, *mut Self)>,
private: [*const (); 2],
}
#[repr(C)]
struct Exception<E> {
uw: UwException,
cause: E,
}
const CLASS: u64 = u64::from_ne_bytes(*b"RUSTpurp");
#[inline(always)]
fn throw<E>(cause: E) {
let exception = Box::new(Exception {
uw: UwException {
class: CLASS,
destructor: Some(destructor),
private: [core::ptr::null(); 2],
},
cause,
});
unsafe {
_Unwind_RaiseException(Box::into_raw(exception).cast());
}
std::process::abort();
}
extern "C" fn destructor(_code: u32, _exception: *mut UwException) {
std::process::abort();
}
#[inline(always)]
unsafe fn cleanup<E>(exception: *mut UwException) -> E {
if (*exception).class != CLASS {
std::process::abort();
}
Box::from_raw(exception.cast::<Exception<E>>()).cause
}
extern "C-unwind" {
fn _Unwind_RaiseException(exception: *mut UwException) -> u32;
}
b.iter(|| {
let _ = catch::<_, &'static str, _>(|| throw::<&'static str>("Hello, world!"));
})
결과: 562.69 ns, 약 3% 개선. 아주 크진 않지만, 여기서는 1 ns도 소중합니다.
이제 남은 힙 할당은 단 하나입니다. 시스템 언와인더용 _Unwind_Exception 헤더 옆에 예외 원인이 함께 들어 있습니다.
왜 스택에 놓지 못할까요? throw가 대체 반환을 수행하면, 그 호출 프레임은 catch 핸들러에 의해 덮어써질 수 있습니다. catch의 호출 프레임 안에 저장할 수도 있지만, 그러려면 그 포인터를 throw에 넘겨야 해서 API가 복잡해집니다.
스레드 로컬(thread-local)은 스택 할당만큼이나 저렴한 완벽한 절충안입니다:
thread_local! {
static LOCAL: UnsafeCell<MaybeUninit<[u8; 4096]>> = const {
UnsafeCell::new(MaybeUninit::uninit())
};
}
unsafe fn local_write<T>(x: T) -> *mut T {
let p = LOCAL.with(|local| local.get().cast::<T>());
unsafe {
p.write(x);
}
p
}
물론 이는 단지 개념 증명일 뿐입니다(중첩 예외나 4 KiB를 초과하는 예외에는 동작하지 않음). 그럼에도 결과 성능을 가늠하게 해 줍니다: 556.32 ns, 약 1.5% 개선.
2.3814 µs에서 시작해 556.32 ns까지 최적화했습니다. 기능 손실 없이 4.3배 가속입니다. Rust 컴파일러나 시스템 언와인더를 수정하지 않고도, 다음과 같은 최적화를 적용해 이 성과를 얻었습니다:
dyn PanicPayload 제거catch 경로를 hot으로 표시언와인딩은 예외 전파에 널리 쓰이지만, 그것만이 용도는 아닙니다. 예를 들어 성공이 오류보다 더 희귀하다면, 성공을 대체 경로로 두는 것도 가능합니다. 또 다른 가벼운 언와인딩의 활용처로는 코루틴이 있습니다. 관점을 바꾸면 프로젝트에서 다른 응용을 찾을 수도 있습니다.
이 최적화를 누구나 쓰기 쉽도록, Rust에서 효율적인 언와인딩을 지원하는 Lithium 크레이트를 공개했습니다. Li처럼 가벼우며, 여기 프로토타입이 지원하지 못한 기능도 포함합니다:
Send + 'static이 아닌 예외catch 내부의 네이티브 Rust 패닉 지원#![no_std] 지원GitHub 저장소를 확인하시고, 이슈도 언제든 열어 주세요!
주의사항도 있습니다:
std::panic::catch_unwind 안에서(즉 lithium::catch 대신) lithium::throw를 사용하는 것은 정상적이지 않습니다(unsound).다음 글에서는 Itanium과 SEH 설계를 살펴보고, 언와인더 구현을 파고들며, 그 지식을 바탕으로 예외를 대폭 가속하는 방법을 알아보겠습니다. 관심 있으시다면 RSS 구독을 눌러 주세요.