TECH 으로 돌아가기
TECH HACKER NEWS 오늘 6분 읽기 38 READS

CPython 3.14도 채택한 '꼬리 호출 인터프리터', Rust로는 어떻게 만들까

프로그래밍 언어의 인터프리터가 어떻게 동작하는지 생각해본 적 있나요? 파이썬이나 루비 같은 언어는 소스 코드를 일단 바이트코드라는 중간 형태로 바꾼 다음, 가상 머신이 그 명령을 하나씩 읽어서 실행해요. 이때 '다음 명령이 뭔지 읽고, 해당하는 처리 코드로 점프하는' 부분을 디스패치(dispatch)라고 하는데요. 인터프리터는 이 디스패치를 초당 수억 번 반복하기 때문에, 여기서의 아주 작은 비효율이 언어 전체의 성능을 좌우해요. 오늘 소개할 글은 요즘 인터프리터 성능 최적화의 핵심 기법으로 떠오른 '꼬리 호출 디스패치'를 Rust로 구현해보는 이야기예요.

전통적인 방식은 왜 느릴까

교과서적인 인터프리터는 이렇게 생겼어요. 무한 루프 안에 거대한 match(다른 언어의 switch)문 하나가 있고, opcode마다 분기 하나씩 달려 있는 구조요. 단순하고 읽기 좋은데, 현대 CPU 입장에서는 두 가지가 아파요.

첫째는 분기 예측 문제예요. 분기 예측이 뭐냐면, CPU가 파이프라인을 놀리지 않으려고 '다음에 어디로 점프할지'를 미리 찍어서 명령을 앞당겨 실행하는 기능인데요. 루프 방식에서는 모든 opcode가 단 하나의 분기 지점을 통과해요. 그 한 지점에서 수백 개의 목적지로 흩어지니까 예측기가 패턴을 잡기 어렵고, 예측이 틀릴 때마다 미리 해둔 작업을 버리고 파이프라인을 다시 채워야 해요. 둘째는 레지스터 할당 문제예요. 거대한 함수 하나에 모든 로직이 들어 있으니, 컴파일러가 명령 포인터나 스택 포인터처럼 늘 쓰는 값을 레지스터에 계속 붙잡아 두지 못하는 경우가 생기거든요.

꼬리 호출 디스패치라는 해법

그래서 나온 개선책의 역사가 있어요. GCC 확장인 computed goto는 각 핸들러 끝에서 다음 핸들러로 직접 점프하게 해서 분기 지점을 흩어놓는 기법인데, 오랫동안 정석이었죠. 그리고 요즘 각광받는 게 꼬리 호출(tail call) 디스패치예요. 꼬리 호출이 뭐냐면, 함수의 맨 마지막 동작이 다른 함수를 호출하는 것뿐일 때, 스택 프레임을 새로 쌓지 않고 지금 프레임을 재활용해서 그냥 점프해버리는 최적화예요. 이걸 이용해 opcode 핸들러를 각각 독립된 작은 함수로 만들고, 핸들러 끝에서 다음 opcode의 핸들러를 꼬리 호출로 부르는 거죠. 컴파일러가 이를 보장해주면(clang의 musttail 속성이 그 역할이에요) call이 사실상 jmp 명령으로 컴파일되고 스택도 안 자라요. 함수가 잘게 쪼개지니 레지스터 할당이 좋아지고, 분기 지점도 핸들러마다 흩어져서 예측기가 일하기 편해지고요.

이 기법의 실적이 꽤 화려해요. WebAssembly 인터프리터 wasm3가 이 구조로 유명하고, 구글의 protobuf 파싱 라이브러리 upb는 이 방식으로 초당 2GB급 파싱 속도를 뽑아냈어요. 결정판은 CPython 3.14인데, clang의 musttail과 preserve_none 호출 규약을 이용한 꼬리 호출 인터프리터를 도입해서 벤치마크에서 최대 10% 안팎의 개선을 얻었죠.

Rust에서는 왜 이게 문제가 될까

그런데 Rust에는 함정이 있어요. Rust는 꼬리 호출 최적화를 언어 차원에서 보장하지 않거든요. LLVM이 릴리즈 빌드에서 알아서 해줄 때도 많지만, '해줄 수도 있다'와 '보장한다'는 하늘과 땅 차이예요. 디버그 빌드나 특정 플랫폼에서 최적화가 빠지는 순간, 명령 하나 실행할 때마다 스택이 한 칸씩 쌓여서 금방 터져버리니까요. 그래서 Rust에는 꼬리 호출을 명시적으로 보장하는 become 키워드가 제안돼서 나이틀리 채널에서 실험 중이에요. return 대신 become을 쓰면 컴파일러가 꼬리 호출을 보장하거나, 불가능하면 컴파일 에러를 내는 방식이죠. 이 글은 바로 이 지형 위에서 Rust로 꼬리 호출 인터프리터를 실제로 구현하며, 전통적인 match 루프 대비 코드 구조와 성능이 어떻게 달라지는지를 탐구해요.

인터프리터 안 만들어도 배울 게 있어요

언어를 직접 만들 일이 없어도 이 글이 가치 있는 이유가 있어요. 분기 예측과 레지스터 할당이 성능에 미치는 영향은 파서, 상태 머신, 이벤트 루프, 직렬화 라이브러리처럼 '작은 결정을 초고속으로 반복하는' 모든 코드에 그대로 적용되거든요. 우리가 매일 쓰는 파이썬이 왜 조금씩 빨라지고 있는지 그 원리를 이해하는 재미도 있고, Rust라는 언어가 이런 저수준 요구를 어떻게 언어 기능으로 수용해가는지 지켜보는 관찰 포인트이기도 해요.

한 줄로 정리하면, 함수형 언어의 유물처럼 여겨지던 꼬리 호출이 인터프리터 성능 최적화의 주인공으로 돌아왔다는 이야기예요. 여러분이라면 어떻게 하시겠어요? 읽기 좋은 match 루프를 지키는 쪽과, 나이틀리 기능을 감수하고 성능을 좇는 쪽 중에서요.


🔗 출처: Hacker News

SOURCE · HACKER NEWS
원문 전체 보기 → https://lordgoati.us/blog/tail-call/
SHARE
처리 중...