RubyKaigi 2025 이후 ZJIT가 Ruby에 병합되었으며, ZJIT의 아키텍처(바이트코드→HIR→LIR→ASM), YJIT와의 차이점, 그리고 향후 계획을 높은 수준에서 살펴봅니다.
Following Maxime’s presentation at RubyKaigi 2025, the Ruby developers meeting, and Matz-san’s approval, ZJIT has been merged into Ruby. Hurray! In this post, we will give a high-level overview of the project, which is very early in development.
ZJIT는 참조 Ruby 구현인 YARV에 내장된 새로운 just-in-time(JIT) Ruby 컴파일러로, YJIT를 만든 것과 같은 컴파일러 그룹이 개발했습니다. 우리( Maxime Chevalier-Boisvert, Takashi Kokubun, Alan Wu, Max Bernstein, Aiden Fox Ivey)는 올해 초부터 ZJIT를 개발해 왔습니다.
ZJIT는 여러 면에서 YJIT와 다릅니다:
가장 큰 차이는, 팀이 커뮤니티가 쉽게 기여할 수 있도록 더 전통적인 “교과서식” 컴파일러를 의도적으로 만들기로 선택했다는 점입니다.
흥미로운 트레이드오프도 있습니다. 예를 들어 YJIT의 아키텍처는 손쉬운 프로시저 간 타입 기반 특수화를 가능하게 하지만, ZJIT의 아키텍처는 옵티마이저에게 한 번에 더 많은 코드를 제공합니다.
ZJIT 아키텍처에 대해 이야기해 봅시다.
높은 수준에서 보면 ZJIT는 YARV 바이트코드를 입력으로 받아 IR을 만들고, 몇 가지 최적화를 수행한 뒤, 머신 코드를 방출합니다. 단순화하면 다음과 같습니다:
컴파일러를 통해 Ruby 코드가 흐르는 방식.
다음 샘플 Ruby 프로그램을 가져와 전체 컴파일러 파이프라인을 통과시켜 보겠습니다:
# add.rb
def add(left, right)
left + right
end
p add(1, 2)
p add(3, 4)
YARV에 대한 감을 잡는 것부터 시작해 봅시다.
Ruby VM은 두 함수를 YARV 바이트코드로 컴파일했습니다. 먼저 최상위(top-level) 함수(간결성을 위해 ...로 생략)가 보이고, 그 다음으로 add 명령 시퀀스(ISEQ)가 나옵니다. 많은 일이 일어나고 있지만, 중요한 점은 YARV가 로컬 변수를 가진 스택 머신이라는 것입니다. 대부분의 명령은 스택에서 입력을 pop하고 결과를 스택에 push합니다.
예를 들어 add에서 getlocal_WC_0(오프셋 0000, 0002)는 슬롯 0에 있는 left 로컬(세 번째 컬럼 참고)을 읽어서 스택에 push하는 특수화된 명령입니다. 슬롯 1의 right도 마찬가지입니다. 그리고 특수화된 + 핸들러인 opt_plus(오프셋 0004)를 호출하는데, 이 핸들러는 스택에서 인자들을 읽고 결과를 다시 스택에 push합니다.
$ ruby --dump=insns add.rb
...
== disasm: #<ISeq:add@add.rb:2 (2,0)-(4,3)>
local table (size: 2, argc: 2 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 2] left@0<Arg>[ 1] right@1<Arg>
0000 getlocal_WC_0 left@0 ( 3)[LiCa]
0002 getlocal_WC_0 right@1
0004 opt_plus <calldata!mid:+, argc:1, ARGS_SIMPLE>[CcCr]
0006 leave
$
opt_plus 오퍼코드는 제네릭 메서드 룩업 및 호출 연산이지만, VM의 명령 핸들러 안에 여러 fast-path 케이스가 인라인되어 있습니다. 두 개의 작은 정수(fixnum)를 더하는 흔한 경우를 처리하는 코드가 있고, 제네릭 send로 폴백하는 코드도 있습니다.
static VALUE
vm_opt_plus(VALUE recv, VALUE obj)
{
// fast path for fixnum + fixnum
if (FIXNUM_2_P(recv, obj) &&
BASIC_OP_UNREDEFINED_P(BOP_PLUS, INTEGER_REDEFINED_OP_FLAG)) {
return rb_fix_plus_fix(recv, obj);
}
// ... some other common cases like float + float ...
// ... fallback code for someone having redefined `Integer#+` ...
}
중요한 점은, opt_plus가 인자들의 타입만 검사하는 것으로는 충분하지 않다는 것입니다. 오퍼코드 핸들러는 또한(“bop check”1를 통해) Integer#+가 재정의되지 않았는지도 확인해야 합니다. Integer#+가 변경되었다면, VM이 새로 재정의된 메서드를 호출할 수 있도록 제네릭 연산으로 폴백해야 합니다.
인터프리터에서 바이트코드 함수를 일정 횟수(설정 가능한 횟수) 실행한 뒤, ZJIT는 몇몇 오퍼코드를 인자들을 프로파일링하는 수정 버전으로 바꿉니다. 예를 들어 opt_plus는 zjit_opt_plus로 다시 쓰이게 됩니다. 이 수정 버전은 스택에 있는 오퍼코드 입력 값들의 타입을 ZJIT가 알고 있는 특수한 위치에 기록합니다.
함수를 더 호출한 뒤(또 다른 설정 가능한 횟수), ZJIT가 이를 컴파일합니다. 컴파일러 파이프라인의 첫 단계인 HIR에서 opt_plus가 어떻게 되는지 보겠습니다. 같이 따라오고 있다면, 여기부터는 --enable-zjit로 구성된 Ruby가 필요합니다(문서 참고).
간결하게 인코딩된 바이트코드에서는 점프가 오프셋이고, 일부 제어 흐름은 암묵적이며, 대부분의 데이터 흐름은 스택을 통해 이뤄집니다.
반면 HIR은 그래프에 더 가깝습니다. 점프는 타깃에 대한 포인터를 가지며, 스택이 없습니다. 데이터를 사용하는 명령은 그 데이터를 만들어낸 명령을 직접 포인터로 가리킵니다.
모든 함수는 basic block의 리스트를 가집니다. 모든 basic block은 명령 리스트를 가집니다. 모든 명령은 ID(InsnId, v12 같은 형태)로 주소 지정이 가능하고, ( InsnId 뒤에 오는) 타입과 오퍼코드, 그리고 피연산자(operands)를 가집니다.
의미를 보여주기 위해, 바이트코드로부터 직접 구성된 HIR의 텍스트 표현을 여기에 보여드립니다(ZJIT를 --zjit로 활성화해야 하는 점에 주목하세요):
$ ruby --zjit --zjit-dump-hir-init add.rb
HIR:
fn add:
bb0(v0:BasicObject, v1:BasicObject):
v4:BasicObject = SendWithoutBlock v0, :+, v1
Return v4
$
HIR 텍스트 표현이 겉으로는 바이트코드와 비슷해 보일 수 있지만, 그건 둘 다 텍스트이기 때문일 뿐입니다. HIR의 그래프적 성격을 더 정확히 보여주는 그림은 다음과 같습니다:
화살표는 사용 지점에서 사용되는 데이터로 향하는 포인터를 나타냅니다. SendWithoutBlock과 Send 같은 많은 명령은 출력 데이터를 생성합니다. 우리는 이 출력 데이터를 생성한 명령의 이름으로 참조합니다. 그래서 Return 명령이 Send를 가리키는 것입니다.
(또한 opt_plus가 :+에 대한 제네릭 메서드 send로 다시 바뀐 것도 확인할 수 있습니다. 이는(앞서 언급했듯이) 내부적으로 opt_plus 같은 많은 opt_xyz 명령이 opt_send_without_block의 위장 형태이며, 타입 최적화는 컴파일러 파이프라인의 더 뒤에서 수행하기 때문입니다.)
이 예제를 분해해 봅시다:
v4:BasicObject = SendWithoutBlock v0, :+, v1 는 하나의 명령입니다.v4는 명령의 ID이자 출력 데이터의 이름입니다.BasicObject(또는 그 하위 클래스)입니다.opt_send_without_block입니다.v0입니다.:+입니다.v0와 v1입니다.그 다음 HIR은 최적화 파이프라인을 거칩니다.
일부 최적화 이후, HIR은 꽤 다르게 보입니다. 더 이상 제네릭 send 연산이 보이지 않고, 대신 타입 특수화된 코드가 나타납니다:
$ ruby --zjit --zjit-dump-hir add.rb
HIR:
fn add:
bb0(v0:BasicObject, v1:BasicObject):
PatchPoint BOPRedefined(INTEGER_REDEFINED_OP_FLAG, BOP_PLUS)
v7:Fixnum = GuardType v0, Fixnum
v8:Fixnum = GuardType v1, Fixnum
v9:Fixnum = FixnumAdd v7, v8
Return v9
$
옵티마이저는 각각의 피연산자가 런타임에 Fixnum인지 검사하는 GuardType 명령을 삽입했습니다. fixnum이 아니라면, 생성된 코드는 폴백으로 인터프리터로 점프합니다. 이렇게 하면 특수화된 코드인 FixnumAdd만 생성하면 됩니다.
하지만 GuardType와 FixnumAdd에 대한 이런 설명은 여전히 상징적이고 고수준입니다. 컴파일러 파이프라인에서 한 단계 더 나아가 LIR로 가봅시다.
LIR은 멀티플랫폼 어셈블러를 목표로 합니다. 사실상 제공하는 유일한 “고급” 기능은 레지스터 할당기입니다. HIR을 LIR로 변환할 때는 주로 고수준 연산을 어셈블리 같은 언어로 바꾸는 데 집중합니다. 이를 쉽게 하기 위해 원하는 만큼 가상 LIR 레지스터를 할당합니다. 그 다음 레지스터 할당기가 이를 물리 레지스터와 스택 위치로 매핑합니다.
다음은 LIR에서의 add 함수입니다:
$ ruby --zjit --zjit-dump-lir add.rb
LIR:
fn add:
Assembler
000 Label() -> None
001 FrameSetup() -> None
002 LiveReg(A64Reg { num_bits: 64, reg_no: 0 }) -> Out64(0)
003 LiveReg(A64Reg { num_bits: 64, reg_no: 1 }) -> Out64(1)
# The first GuardType
004 Test(Out64(0), 1_u64) -> None
005 Jz() target=SideExit(FrameState { iseq: 0x1049ca480, insn_idx: 4, pc: 0x6000002b2520, stack: [InsnId(0), InsnId(1)], locals: [InsnId(0), InsnId(1)] }) -> None
# The second GuardType
006 Test(Out64(1), 1_u64) -> None
007 Jz() target=SideExit(FrameState { iseq: 0x1049ca480, insn_idx: 4, pc: 0x6000002b2520, stack: [InsnId(0), InsnId(1)], locals: [InsnId(0), InsnId(1)] }) -> None
# The FixnumAdd; side-exit if it overflows Fixnum
008 Sub(Out64(0), 1_i64) -> Out64(2)
009 Add(Out64(2), Out64(1)) -> Out64(3)
010 Jo() target=SideExit(FrameState { iseq: 0x1049ca480, insn_idx: 4, pc: 0x6000002b2520, stack: [InsnId(0), InsnId(1)], locals: [InsnId(0), InsnId(1)] }) -> None
011 Add(A64Reg { num_bits: 64, reg_no: 19 }, 38_u64) -> Out64(4)
012 Mov(A64Reg { num_bits: 64, reg_no: 19 }, Out64(4)) -> None
013 Mov(Mem64[Reg(20) + 16], A64Reg { num_bits: 64, reg_no: 19 }) -> None
014 FrameTeardown() -> None
015 CRet(Out64(3)) -> None
$
HIR보다 훨씬 더 명시적으로 무엇이 일어나는지 보여줍니다. 여기서 다음과 같은 더 저수준의 디테일을 볼 수 있습니다:
FrameSetup과 FrameTeardownTestJz, Jo 같은, 인터프리터로 side-exit하기 위한 명시적 조건부 점프Sub와 Add다른 함수들의 LIR 출력에서는, 일부 고수준 HIR 구성물이 C 런타임(헬퍼) 함수 호출로 바뀌는 것을 볼 수도 있습니다.
이 예제에서는 눈에 띄지 않지만, HIR과 LIR의 또 다른 차이는 LIR이 하나의 큰 선형 블록이라는 점입니다. HIR과 달리 여러 basic block을 가지지 않습니다.
마지막으로 LIR에서 어셈블리로 갑니다.
어셈블리 목록은 블로그 글에 넣기엔 조금 길지만, GuardType과 FixnumAdd의 유용성을 보여주는 흥미로운 일부를 소개하겠습니다:
$ ruby --zjit --zjit-dump-disasm add.rb
...
# Insn: v7 GuardType v0, Fixnum
0x6376b7ad400f: test dil, 1
0x6376b7ad4013: je 0x6376b7ad4000
# Insn: v8 GuardType v1, Fixnum
0x6376b7ad4019: test sil, 1
0x6376b7ad401d: je 0x6376b7ad4005
# Insn: v9 FixnumAdd v7, v8
0x6376b7ad4023: sub rdi, 1
0x6376b7ad4027: add rdi, rsi
0x6376b7ad402a: jo 0x6376b7ad400a
...
$
GuardType와 FixnumAdd는 각각 매우 빠른 머신 명령 몇 개만으로 구현된다는 것을 볼 수 있습니다. 이것이 타입 특수화의 가치입니다!
이 어셈블리 스니펫은 x86 명령을 보여주지만, ZJIT에는 ARM 백엔드도 있습니다. ARM에서 생성되는 코드도 매우 비슷합니다.
ZJIT 프로젝트는 아직 매우 초기 단계입니다. 소스를 읽고 로컬 실험을 해보는 것은 권장하지만, 우리는 아직 ZJIT를 프로덕션에서 돌리고 있지 않으며 여러분도 그렇게 하지 않기를 권고합니다. 앞으로는 흥미롭고도 험난한 길이 펼쳐져 있습니다!
이런 이유로 우리는 당분간 YJIT를 계속 유지보수할 것이며, Ruby 3.5는 YJIT와 ZJIT를 모두 포함해 출시될 것입니다. 동시에, ZJIT가 YJIT와 동등한 수준(기능과 성능)으로 올라오도록 개선해 나갈 예정입니다.
현재 JIT가 실제 코드를 실행할 수 있게 해줄 몇 가지 기능을 더 작업 중입니다. 우선 side-exit를 구현하고 있습니다. 지금은 GuardType가 예상하지 못한 타입을 관측하면 중단(abort)합니다. 이상적으로는 실제로 인터프리터로 점프할 수 있어야 합니다.
side-exit는 우리에게 두 가지 흥미로운 일을 가능하게 해줄 것입니다:
그 다음에는 프로파일링을 하고 어떤 최적화가 가장 큰 영향을 주는지 살펴보는 작업을 하게 될 것입니다.
이 글을 읽어주셔서 감사합니다! 더 많은 정보와 문서를 곧 공개하겠습니다.
Integer#+ 같은 빌트인 메서드까지 포함해 (거의?) 어떤 메서드든 재정의할 수 있기 때문입니다(“basic operations”에서 따온 “bop”).YARV VM 개발자들은 작은 수(“fixnums”)를 더하는 것 같은 흔한 연산에 대해 지름길을 포함하고 싶어하지만, 매우 동적인 동작으로의 폴백도 여전히 지원하고자 합니다.↩