- 들어가며 — Ruby 4.0은 ZJIT을 켜지 않은 채로 실었다
- 왜 새 컴파일러를 또 만드나 — YJIT과 ZJIT의 설계 차이
- 남은 병목 — 메서드 프레임 푸시
- Lightweight Frames — 포인터 하나만 쓰고 나머지는 미룬다
- 이 아이디어는 2023년에 이미 한 번 실패했다
- 실제 숫자 — 첫 PR은 거의 빨라지지 않았다
- 그 뒤 넉 달 — 스택 스필, 인라이너, 버전 수
- 정직한 현황 — ZJIT은 아직 기본이 아니다
- 언제 쓰지 말아야 하나
- 이 이야기는 병렬성과 무관하다
- 마치며
- 참고 자료
들어가며 — Ruby 4.0은 ZJIT을 켜지 않은 채로 실었다
Ruby 4.0.0은 2025년 12월 25일에 나왔습니다. 버전 번호가 3.5가 아니라 4.0이 된 것부터가 뉴스였지만, 런타임 쪽에서 눈에 띄는 건 ZJIT이었습니다. 공식 릴리스 노트의 표현을 그대로 옮기면 이렇습니다 — ZJIT은 YJIT의 다음 세대로 개발되는 새 JIT 컴파일러이고, 빌드하려면 Rust 1.85.0 이상이 필요하며, --zjit을 지정해야 켜집니다.
그리고 같은 문단에 이런 문장이 붙어 있습니다.
ZJIT is faster than the interpreter, but not yet as fast as YJIT. We encourage you to experiment with ZJIT, but maybe hold off on deploying it in production for now. Stay tuned for Ruby 4.1 ZJIT.
새 컴파일러를 릴리스에 실으면서 "아직 프로덕션에 넣지는 마시라"고 쓰는 릴리스 노트는 흔치 않습니다. 그만큼 정직한 문장이고, 동시에 다음 문장이 예고편입니다 — Ruby 4.1 ZJIT을 기대해 달라.
오늘은 2026년 7월 16일입니다. 4.0이 나온 지 일곱 달이 조금 안 됐고, 4.1은 아직 나오지 않았습니다. 그 사이 4.1을 겨냥한 ZJIT 작업에서 실제로 무엇이 머지됐는지를 커밋 단위로 따라가 보면, 이야기의 중심에 Lightweight Frames라는 물건이 있습니다. 이 글은 그 물건에 관한 글입니다.
왜 새 컴파일러를 또 만드나 — YJIT과 ZJIT의 설계 차이
먼저 배경. YJIT은 이미 잘 돌아가고 있고, Shopify를 비롯한 여러 곳이 프로덕션에서 씁니다. 그런데 같은 팀이 왜 새 컴파일러를 만들까요. ZJIT 런치 포스트가 이유를 두 개로 정리합니다.
첫째, 성능 천장을 올리려고. YJIT은 LBBV(lazy basic block versioning) 기반으로 작은 블록 단위를 조각조각 컴파일합니다. 이 방식은 워밍업이 빠르고 구현이 견고하지만, 컴파일 단위가 작으면 컴파일러가 볼 수 있는 문맥도 작습니다. ZJIT은 반대로 메서드 전체(혹은 더 큰 단위)를 SSA 형태의 고수준 IR(HIR)로 올려서 컴파일합니다. 단위가 커지면 상수 폴딩, 분기 폴딩, 타입 추론 같은 전통적인 최적화가 실제로 값을 하기 시작합니다.
둘째, 외부 기여를 받으려고. 런치 포스트의 표현으로는 "보다 전통적인 메서드 컴파일러가 됨으로써" 기여 장벽을 낮추겠다는 것입니다. LBBV는 아름답지만 컴파일러 교과서에 나오는 물건이 아닙니다. SSA와 linear scan은 나옵니다.
이 두 번째 이유는 립서비스가 아닙니다. 아래에서 볼 PR들을 보면 Lightweight Frames와 스택 스필은 k0kubun(Shopify), 메서드 인라이너와 버전 수 상한은 nirvdrum, 블록 인라이닝은 luke-gruber가 넣었습니다. 런치 포스트 말미에 붙은 기여자 명단은 git log --pretty="%an" zjit | sort -u로 뽑은 것이고, 30명이 넘습니다.
남은 병목 — 메서드 프레임 푸시
Ruby가 느린 이유를 하나만 꼽으라면 메서드 호출 오버헤드입니다. YJIT은 여러 호출 유형에 대해 기계어를 특화해서 이 비용을 많이 깎았습니다. 그런데 RubyKaigi 2026에서 k0kubun이 발표한 세션 초록(2026년 4월 22–24일, 하코다테)은 아직 하나가 남아 있다고 말합니다 — Ruby 메서드 프레임을 푸시하는 과정입니다. 세션 제목이 "Lightning-Fast Method Calls with Ruby 4.1 ZJIT"이고, 초록에 "YJIT에서 ZJIT으로 갈아탈 만한 설득력 있는 이유"를 4.1에서 논하겠다고 적혀 있습니다.
프레임 푸시가 왜 비싼지 보려면 인터프리터가 호출마다 무엇을 쓰는지 보면 됩니다. ec->cfp를 하나 밀어 올리고, 그 새 프레임에 cfp->pc(프로그램 카운터), cfp->iseq(어떤 명령 시퀀스인지), cfp->block_code, cfp->sp, 그리고 로컬 변수와 스택 슬롯을 채워 넣습니다. JIT이 만든 기계어는 대부분의 경우 이 값들을 쓰기만 하고 읽지 않습니다. 읽는 쪽은 인터프리터, 예외 처리, 백트레이스, 디버거입니다.
즉 호출마다 성실하게 채워 넣는 메타데이터의 대부분이, 거의 아무도 읽지 않는 보험입니다. 런치 포스트의 "To do" 항목이 이 문제를 이렇게 적어 놨습니다.
Right now we have a lot of traffic to the VM frame. JIT frame pushes are reasonably fast, but with every effectful operation, we have to flush our local variable state and stack state to the VM frame. The instances in which code might want to read this reified frame state are rare: frame unwinding due to exceptions,
Binding#local_variable_get, etc. In the future, we will instead defer writing this state until it needs to be read.
Lightweight Frames가 그 "in the future"입니다.
Lightweight Frames — 포인터 하나만 쓰고 나머지는 미룬다
설계 문서는 공개돼 있습니다. Shopify/ruby#909, k0kubun이 2025년 12월 10일에 연 이슈입니다. 핵심 문장은 첫 줄입니다.
When ZJIT pushes an interpreter frame, it should write only one metadata pointer, and others should be lazily materialized.
동작은 이렇습니다.
- 프레임 푸시.
ec->cfp는 평소대로 밀어 올린다. 다만 프레임에 채워 넣는 건 딱 하나 — 반환 주소를 담고 있는 네이티브 스택 슬롯의 주소를cfp->jit_return에 쓴다. x86_64에서는call명령이 밀어 넣은 스택 슬롯의 주소가 그것이고, arm64에서는 JIT-to-JIT 호출의 경우 콜리 쪽 프레임 셋업이 링크 레지스터를 저장한 슬롯의 주소를 쓴다. - 되살리기(materialize).
cfp->jit_return이 0이 아니면, 그 반환 주소를 키로 삼아 "반환 주소에서 메타데이터로" 가는 해시 테이블을 뒤져 컴파일 시점에 만들어 둔 메타데이터를 얻는다. 메타데이터에는 PC, ISEQ, 스택 크기, cme, env 플래그, self의 위치, specval의 타입과 위치가 들어 있다. 여기에 프레임 베이스 포인터까지의 오프셋이 함께 들어 있어서, 네이티브 스택에서 스택 슬롯과 로컬을 역으로 찾아낼 수 있다. - 언제 되살리나. 설계 문서가 세 가지를 꼽습니다 — 예외가 터져 longjmp가 JIT 프레임을 걷어내고 인터프리터가 실행을 넘겨받을 때(OSR), 백트레이스가 필요할 때(
Kernel#caller,rb_profile_frames를 쓰는 프로파일러), 그리고rb_debug_inspectorAPI로 JIT 프레임의 Binding에 동적으로 접근할 때.
머지된 PR(ruby/ruby#16262, 2026년 2월 27일 생성, 3월 27일 머지, 34개 파일 +800/-192)의 범위는 이 중 앞부분입니다. PR 설명의 표현으로는, JIT-to-JIT 호출에서 cfp->pc, cfp->iseq, 그리고 대부분의 cfp->block_code 쓰기를 없애고 대신 cfp->jit_return을 쓰는 것입니다.
지워지는 게 뭔지 한 번 더 짚으면 — 호출마다 세 번 쓰던 걸 한 번 쓰는 겁니다. 그게 전부입니다. 그리고 그 "전부"가 왜 그렇게 어려운지가 다음 절입니다.
이 아이디어는 2023년에 이미 한 번 실패했다
설계 문서에서 가장 값진 부분은 설계가 아니라 Prior art 절입니다. 같은 팀이 같은 아이디어를 전에 시도했고, 실패했습니다.
Frame outlining (2023). Alan Wu와 k0kubun이 실험한 방식입니다. cfp->pc에 태그 포인터를 넣어 "이 프레임은 아웃라인됐다"고 표시하고, 태그가 붙어 있으면 그 포인터가 가리키는 메타데이터로 프레임을 되살리는 구조였습니다. 문제는 대가였습니다. 설계 문서의 문장을 그대로 옮기면 이렇습니다.
Every read of
cfp->pc,cfp->sp, orcfp->iseqhad a branch on whethercfp->pcis tagged or not. If it's a tagged pointer, it points to frame metadata to materialize the outlined frame. Because we made every read ofpc/sp/iseqslower, the interpreter became slower. So we gave it up.
JIT을 빠르게 하려다 인터프리터를 느리게 만든 겁니다. Ruby에서 이건 사형 선고입니다 — JIT을 켜지 않은 코드가 느려지는 최적화는 받아들여질 수 없습니다.
Lightweight Frames는 정확히 이 함정을 피하려고 설계됐습니다. 차이는 낙관성입니다. 대부분의 cfp 읽기에서는 cfp->jit_return 검사를 낙관적으로 건너뛰고(디버그 모드에서만 어서션을 걸고), 위에서 꼽은 "되살려야 하는 세 가지 조건"에서만 검사합니다. 즉 검사 비용을 흔한 경로가 아니라 드문 경로에 몰아넣습니다.
Lazy frame push. 또 하나의 선행 작업은 ruby/ruby#10080("YJIT: Lazily push a frame for specialized C funcs", k0kubun, 2024년 2월 23일 머지)입니다. 이건 성공해서 YJIT에 들어갔고 지금도 master에 있습니다. 다만 접근이 다릅니다 — 아예 프레임을 푸시하지 않고(ec->cfp를 밀지 않고) 콜리가 예외를 던지려 할 때 비로소 푸시합니다. Lightweight Frames는 ec->cfp를 밀기는 하므로, 그 대신 lazy frame push가 치르던 검사 오버헤드를 없앨 수 있으리라는 게 설계 문서의 기대입니다.
여기서 배울 점은 기술이 아니라 방법론입니다. 이 팀은 3년 전에 죽은 아이디어의 사인(死因)을 정확히 알고 있었고, 그 사인을 피하도록 새 설계를 그렸습니다. 그래서 PR #16262의 벤치마크 표에서 첫 번째로 나오는 게 인터프리터 성능입니다. 그게 2023년에 죽은 자리니까요.
실제 숫자 — 첫 PR은 거의 빨라지지 않았다
여기서부터가 이 글에서 제일 정직해질 부분입니다. PR #16262의 설명을 저자 본인이 이렇게 써 놨습니다.
As of this PR, we don't expect a speedup in ZJIT on most benchmarks because the increased overhead of exits outweighs the benefit.
"이 PR 단계에서는 대부분의 벤치마크에서 속도 향상을 기대하지 않는다"는 것입니다. 800줄짜리 PR을 넣으면서 저자가 직접 쓴 문장입니다.
숫자를 봅시다. 아래는 모두 k0kubun 본인이 측정한 수치이고, 조건은 ruby-bench, x86_64-linux, ruby 4.1.0dev입니다. 열의 의미는 before/after이므로 1보다 크면 개선입니다.
먼저 인터프리터. 2023년에 죽은 그 자리입니다.
-------------- ------------- ------------- ------------
bench before (ms) after (ms) before/after
activerecord 140.8 ± 1.4% 138.8 ± 1.0% 1.015
chunky-png 408.4 ± 0.5% 402.4 ± 0.8% 1.015
hexapdf 1419.4 ± 0.8% 1392.1 ± 2.9% 1.020
liquid-c 32.9 ± 1.9% 33.5 ± 4.0% 0.982
railsbench 1331.8 ± 0.4% 1316.8 ± 0.5% 1.011
-------------- ------------- ------------- ------------
PR의 표현으로는 "인터프리터 성능에 유의미한 영향은 관찰되지 않았다". 전체 15개 벤치마크의 비율이 0.982에서 1.022 사이에 들어 있고, 오차 막대와 겹칩니다. 이건 개선이 아니라 회귀가 없다는 증거이고, 정확히 그게 이 표의 목적입니다.
다음은 ZJIT.
-------------- ------------- ------------- ------------
bench before (ms) after (ms) before/after
chunky-png 242.0 ± 0.8% 237.3 ± 0.7% 1.020
psych-load 821.7 ± 1.8% 797.7 ± 0.5% 1.030
railsbench 1230.6 ± 0.2% 1232.7 ± 0.2% 0.998
erubi-rails 489.6 ± 2.1% 505.0 ± 1.9% 0.969
lobsters 458.7 ± 0.8% 465.9 ± 2.7% 0.985
shipit 1117.6 ± 2.0% 1160.8 ± 1.4% 0.963
-------------- ------------- ------------- ------------
PR 설명이 이 표를 이렇게 요약합니다 — 헤드라인 벤치마크인 chunky-png와 psych-load는 ratio_in_zjit가 96.9–99.8%로 높고, 여기서 1–3% 정도의 개선이 안정적으로 나온다. ratio_in_zjit가 낮은 나머지는 약간의 회귀를 보이거나 결과가 불안정하다.
번역하면 이렇습니다. ZJIT이 코드의 거의 전부를 컴파일한 벤치마크에서만 이득이 났고, 나머지에서는 오히려 조금 느려졌습니다. shipit은 3.7% 느려졌고 erubi-rails는 3.1% 느려졌습니다. railsbench는 사실상 제자리입니다. 이걸 "ZJIT이 2~3% 빨라졌다"고 요약하면 거짓말입니다.
JIT-to-JIT 호출만 남긴 마이크로벤치마크에서는 이렇게 나옵니다.
----- ----------- ----------- ------------
bench before (ms) after (ms) before/after
fib 26.6 ± 0.1% 25.4 ± 0.1% 1.049
----- ----------- ----------- ------------
4.9% 개선. 다만 이건 저자가 이 변경을 분리해서 보려고 고른 최선의 조건입니다 — fib은 프레임 푸시 말고 하는 일이 거의 없는 함수입니다. 실제 애플리케이션에서 이 숫자를 기대하면 안 됩니다.
메모리 — 공짜가 아니다
그리고 대가가 있습니다. lobsters 기준으로 PR이 보고한 수치입니다.
before after delta
code_region_bytes 11,452,416 12,296,192 +843,776 (+7%)
zjit_alloc_bytes 18,331,996 19,605,972 +1,273,976 (+7%)
코드 크기가 7% 늘었고(843KB), Rust 쪽 힙도 7% 늘었습니다(1.27MB). 이유는 자명합니다 — 프레임에 안 쓴 값들을 나갈 때 써야 하니까, 사이드 이그짓 코드가 그만큼 커집니다.
PR의 산수를 그대로 따라가 보면 이렇습니다. 컴파일된 사이드 이그짓이 76,817개이고, 각각 cfp->iseq를 쓰기 위해 movabs + mov로 14바이트가 필요하니 76,817 × 14 = 1,075,438바이트가 늘어야 합니다. 그런데 실제로 늘어난 건 843,776바이트입니다. PR이 여기서 흥미로운 추론을 합니다 — 코드 크기 증가가 1MB에 못 미쳤으니, 메서드 호출의 인라인 코드는 오히려 작아진 모양이라고. 예측보다 23만 바이트쯤 덜 늘었고, 그 차액이 프레임 푸시에서 절약된 코드입니다.
이 정도로 자기 숫자를 뜯어보는 PR 설명은 드뭅니다. 그리고 이 산수는 실제로 맞아떨어집니다 — 세 자리 콤마까지 검산이 됩니다.
그 뒤 넉 달 — 스택 스필, 인라이너, 버전 수
PR #16262는 의도적으로 미완성이었습니다. 설명에 이렇게 적혀 있습니다 — "앞으로 이그짓을 줄이면 대부분의 슬로다운이 완화될 것이고, Lightweight Frames 위에 VM 스택 스필을 고치면 아마 전체적으로 개선으로 돌아설 것이다. 다만 미래의 diff가 너무 커지는 걸 피하려고 코어를 먼저 따로 랜딩한다."
그 예고된 후속 작업들이 실제로 들어왔습니다. master에서 확인되는 것들입니다.
- #17387 ZJIT: Avoid eager stack spills for inlined frame pushes (k0kubun, 2026년 7월 9일 머지, +862/-563). 예고했던 바로 그 스택 스필 작업입니다. 인라인된
FrameState의JITFrame이 콜러 쪽 스택 슬롯까지 인코딩해서, 가장 최근JITFrame하나만 되살리면 그 함수가 인라인한 모든 CFP의 스택 슬롯이 함께 스필됩니다. - #16966 ZJIT: Add method inliner (nirvdrum, 2026년 6월 13일 머지, +2564/-194). 메서드 인라이너. 저자가 "완전히 동작하지만 프로덕션급 인라이너라기보다는 출발점"이라고 명시했고, 기본 비활성으로 들어갔습니다.
- #17484 ZJIT: Enable the inliner by default (nirvdrum, 2026년 6월 30일 머지). 12일 뒤 기본 활성으로 뒤집혔습니다.
- #17562 ZJIT: Bump the
--zjit-max-versionsdefault to 4 (nirvdrum, 2026년 7월 6일 머지). ISEQ당 컴파일하는 버전 수 상한을 2에서 4로. - #17653 (luke-gruber, 2026년 7월 10일 머지).
yield블록 프레임 푸시와 그 iseq로의 JIT-to-JIT 호출을 인라인. #16966이 "가장 큰 한계"로 꼽았던 블록 인라이닝의 후속입니다.
인라이너가 진짜로 하는 일
인라이너 PR에서 재미있는 건 인라이닝 자체가 아니라 그것이 여는 것입니다. 저자의 문장입니다.
Simply eliminating method dispatch by inlining method bodies is interesting, but undersells what inlining enables. The real benefit is the extra context we have for making optimization decisions.
그래서 ZJIT의 optimize 파이프라인을 루프로 감쌌습니다 — 타입 특화 → 인라이닝 → 후속 최적화를 인라이닝이 고정점(fixed-point)에 도달할 때까지 반복합니다. 폭주를 막으려고 상한을 두었고, 기본값은 10회(--zjit-inline-max-iterations)입니다.
여기서도 저자가 자기 관찰에 캐비엇을 답니다.
While working with ruby-bench I found we rarely looped more than 2 or 3 times, at least with the currently defined defaults for inline threshold and budget. As those defaults were chosen arbitrarily, that's not a very strong claim.
"기본값을 임의로 골랐으니 강한 주장은 아니다." 이 정직함이 소스 코드에도 그대로 남아 있습니다. zjit/src/options.rs의 상수 위에 붙은 주석입니다.
/// Default --zjit-inline-threshold
/// TODO (nirvdrum 2026-06-25): 30 has proven to work well with ruby-bench, but we should finely
/// tune across more workloads.
pub const DEFAULT_INLINE_THRESHOLD: InlineThreshold = 30;
--zjit-inline-budget(기본 200)에도 같은 형태의 TODO가 붙어 있습니다. 즉 오늘 ZJIT 인라이너의 기본값은 ruby-bench에서만 검증된 값이고, 저자들이 그걸 문서화해 뒀습니다.
그리고 조심해야 할 숫자 하나
--zjit-max-versions를 2에서 4로 올린 PR #17562가 근거로 제시한 수치입니다. nirvdrum 본인의 측정이고, 조건은 optcarrot 단일 벤치마크, x86_64-linux입니다.
--zjit-max-versions=2: #1: 2315ms #2: 2299ms #3: 2288ms
--zjit-max-versions=4: #1: 829ms #2: 734ms #3: 732ms
2288ms에서 732ms면 3.1배입니다. 큰 숫자이고, 그래서 조건을 정확히 봐야 합니다. 측정 명령이 WARMUP_ITRS=0 MIN_BENCH_ITRS=3 MIN_BENCH_TIME=0입니다 — 워밍업 없이, 3회 반복입니다. 그리고 PR이 제시한 데이터는 optcarrot 하나뿐이고, ruby-bench 전체 표는 없습니다. PR 본문의 주장도 딱 그만큼입니다 — "ZJIT을 함께 들여다보다가 --zjit-max-versions 값을 올리면 상당한 개선이 있다는 걸 발견했다".
이 수치가 말해 주지 않는 것: 다른 벤치마크에서도 그런지, 워밍업 후 정상 상태에서도 그런지, 버전을 2배 더 만드는 메모리 대가가 얼마인지. 3.1배는 사실이지만 "ZJIT이 3배 빨라졌다"는 문장은 이 데이터가 지지하지 않습니다.
정직한 현황 — ZJIT은 아직 기본이 아니다
여기가 이 글의 결론입니다. RubyKaigi 2026 초록이 "YJIT에서 ZJIT으로 갈아탈 이유"를 말하겠다고 했고, 넉 달 동안 위의 PR들이 다 들어왔습니다. 그래서 ZJIT이 4.1의 기본 JIT이 됐을까요.
아닙니다. 오늘(2026년 7월 16일) 기준 ruby/ruby master의 ruby.c가 이렇게 돼 있습니다.
#if !USE_YJIT && USE_ZJIT
DEFINE_FEATURE(jit) = feature_zjit,
#else
DEFINE_FEATURE(jit) = feature_yjit,
#endif
#if USE_YJIT
# define DEFAULT_JIT_OPTION "--yjit"
#elif USE_ZJIT
# define DEFAULT_JIT_OPTION "--zjit"
#endif
읽으면 이렇습니다 — YJIT이 빌드에 들어 있는 한 --jit은 YJIT입니다. ZJIT이 기본이 되는 건 YJIT이 아예 빌드되지 않은 경우뿐입니다. 그리고 #15368로 ZJIT은 빌드 환경이 허락하면 YJIT과 함께 기본으로 컴파일되므로, 보통의 빌드에서 이 조건은 성립하지 않습니다.
한 가지 더 짚을 것 — Ruby는 애초에 어떤 JIT도 자동으로 켜지 않습니다. --yjit이든 --zjit이든 명시해야 합니다. --jit은 "이 빌드의 기본 JIT을 켜라"는 뜻이고, ruby --help의 설명이 그대로 "Enable the default JIT for the build; same as --yjit"입니다. 그러니 "ZJIT이 기본이 아니다"라는 말의 정확한 의미는 이겁니다 — JIT을 달라고 했을 때 받는 게 여전히 YJIT이다.
4.1은 아직 릴리스되지 않았고 계획은 계획입니다. 갈아타기가 실제로 일어나려면 위 #if가 뒤집히는 커밋이 있어야 하는데, 오늘 기준 그 커밋은 없습니다.
언제 쓰지 말아야 하나
정리하면 이렇습니다.
지금 ZJIT을 프로덕션에 넣지 마세요. 이건 제 의견이 아니라 만든 팀의 문장입니다 — 릴리스 노트가 "maybe hold off on deploying it in production for now"라고 썼고, 런치 포스트는 한 발 더 나아가 "이건 아주 새 컴파일러다. 크래시와 극단적인 성능 저하(혹은 개선)를 예상해야 한다"고 썼습니다.
성능을 원해서 4.0으로 올렸다면 답은 여전히 YJIT입니다. 여기서 한 가지 분명히 해 둘 것 — 위에 옮긴 표들은 ZJIT 대 YJIT 비교가 아닙니다. Lightweight Frames를 넣기 전의 ZJIT과 넣은 후의 ZJIT을 비교한 것이고, 그래서 저 숫자로 "ZJIT이 YJIT보다 몇 % 느리다"를 말할 수는 없습니다. ZJIT이 아직 YJIT만큼 빠르지 않다는 근거는 표가 아니라 릴리스 노트와 런치 포스트의 문장 자체입니다 — 둘 다 "not yet as fast as YJIT"이라고 명시합니다. 4.0 시점의 ZJIT 대 YJIT 격차를 수치로 못 박은 공개 자료는 제가 찾지 못했습니다. 런치 포스트가 가리키는 rubybench 대시보드는 시점에 따라 값이 바뀌는 살아 있는 그래프라, 여기 숫자로 옮기지 않았습니다.
그럼 언제 쓰나. 테스트 스위트를 --zjit으로 한 번 돌려 보는 것 — 런치 포스트가 명시적으로 권하는 게 그겁니다. 크래시나 오동작을 찾으면 Ruby 이슈 트래커나 GitHub에 보고해 달라고 되어 있습니다. 그리고 실제로 이게 값을 했습니다 — 4.0 시점에 ZJIT은 Ruby 전체 테스트 스위트, Shopify의 대형 앱 테스트 스위트와 섀도 트래픽, GitHub.com의 테스트 스위트를 통과했습니다.
인라이너를 끄고 싶다면. #17484가 인라이너를 기본으로 켜면서 대가를 명시했습니다 — 인라이닝은 컴파일 시간과 메모리를 늘립니다. 문제가 되면 임계값을 0으로 줘서 끌 수 있습니다. 다만 PR 본문에는 --zjit-inliner-threshold=0이라고 적혀 있는데, 실제 소스(zjit/src/options.rs)가 파싱하는 옵션 이름은 inline-threshold입니다. 즉 실제로 동작하는 건 --zjit-inline-threshold=0입니다. PR 설명 쪽의 오타로 보입니다.
이 이야기는 병렬성과 무관하다
한 가지 헷갈리기 쉬운 지점을 정리하고 갑니다. ZJIT은 단일 스레드 실행 속도의 이야기입니다. Ruby의 병렬성 이야기는 별개이고, 그건 Ractor입니다.
Ruby 4.0 릴리스 노트가 Ractor 쪽에도 꽤 큰 변화를 실었습니다 — Ractor::Port가 새 동기화 메커니즘으로 들어오면서 Ractor.yield와 Ractor#take가 제거됐고, Ractor#join과 Ractor#value가 Thread#join/Thread#value에 대응해 추가됐고, "글로벌 락에 대한 경합을 크게 줄이도록" 내부 자료구조가 많이 개선됐습니다. 그리고 릴리스 노트가 이렇게 씁니다 — Ractor는 3.0에서 실험 기능으로 들어왔고, "내년에" 그 실험 딱지를 떼는 걸 목표로 한다.
전역 락을 떼는 이야기가 궁금하다면 같은 문제를 정면으로 다룬 Python 3.15의 abi3t 편을 보시면 됩니다 — GIL을 뗀 다음에 남은 배포 문제를 다룹니다. Ruby와 Python의 접근은 다르지만(Ruby는 GVL을 떼는 대신 Ractor로 격리된 병렬 실행 단위를 두는 쪽), 두 언어가 같은 시기에 같은 종류의 벽에 부딪혔다는 건 흥미로운 대칭입니다. Ruby 생태계 전반의 지형은 모던 Ruby/Rails 2026 편에서 따로 정리했습니다.
어쨌든 ZJIT이 아무리 빨라져도 그건 Ractor 이야기와 만나지 않습니다. 둘은 다른 축입니다.
마치며
정리하면 이렇습니다. Ruby 4.0은 ZJIT을 실었지만 켜지 않았고, 릴리스 노트가 직접 "아직 YJIT만큼 빠르지 않다"고 썼습니다. 4.1을 겨냥한 작업의 중심은 Lightweight Frames이고, 아이디어는 단순합니다 — 메서드 호출마다 프레임에 성실히 채워 넣던 메타데이터 대부분을 아무도 읽지 않으니, 포인터 하나만 쓰고 나머지는 백트레이스·예외·Binding처럼 진짜로 읽힐 때 되살리자.
이 아이디어가 값진 건 새로워서가 아니라 두 번째 시도라서입니다. 2023년 frame outlining은 cfp 읽기마다 분기를 넣는 바람에 인터프리터를 느리게 만들고 폐기됐습니다. Lightweight Frames는 그 사인을 알고 설계됐고, 그래서 PR의 첫 번째 벤치마크 표가 "인터프리터가 안 느려졌다"를 증명하는 표입니다.
그리고 그 PR은 대부분의 벤치마크에서 아무것도 빠르게 만들지 못했습니다. 저자가 그렇게 썼습니다. 코드는 7% 커졌고 힙도 7% 커졌습니다. 이득은 ZJIT이 코드의 97% 이상을 컴파일한 두 벤치마크에서 1–3%뿐이었습니다. 그게 정직한 3월의 상태였고, 그 위에 스택 스필과 인라이너와 블록 인라이닝이 넉 달 동안 쌓였습니다.
그래서 오늘의 답은 이렇습니다 — 프로덕션이면 YJIT을 쓰세요. ZJIT은 테스트 스위트로 한 번 돌려 보고 깨지면 신고하는 단계입니다. 그리고 "Ruby 4.1에서 ZJIT이 기본이 된다"는 문장은 아직 계획이지 사실이 아닙니다. ruby.c의 #if가 뒤집히면 그때 사실이 됩니다. 그 커밋이 이 이야기의 진짜 결말이고, 5개월쯤 뒤에 알게 될 것입니다.
기반 공사가 지루해 보인다면, 그게 원래 기반 공사입니다.
참고 자료
- Ruby 4.0.0 Released — 공식 릴리스 노트 (2025-12-25)
- ZJIT is now available in Ruby 4.0 — Max Bernstein, Rails at Scale (2025-12-24)
- Shopify/ruby#909 — ZJIT: Lightweight Frames 설계 문서 (prior art 포함)
- ruby/ruby#16262 — ZJIT: Lightweight Frames (머지 2026-03-27, 벤치마크·메모리 수치)
- ruby/ruby#16966 — ZJIT: Add method inliner (머지 2026-06-13)
- ruby/ruby#17484 — ZJIT: Enable the inliner by default (머지 2026-06-30)
- ruby/ruby#17562 — ZJIT: Bump the --zjit-max-versions default to 4 (머지 2026-07-06)
- ruby/ruby#17387 — ZJIT: Avoid eager stack spills for inlined frame pushes (머지 2026-07-09)
- ruby/ruby#17653 — ZJIT: inline yield block frame pushes (머지 2026-07-10)
- ruby/ruby#10080 — YJIT: Lazily push a frame for specialized C funcs (머지 2024-02-23, 선행 작업)
- zjit/src/options.rs — 기본값과 TODO 주석의 출처
- Lightning-Fast Method Calls with Ruby 4.1 ZJIT — k0kubun, RubyKaigi 2026 (하코다테, 4월 22–24일)
- A new Register Allocator for ZJIT — Aaron Patterson, Rails at Scale (2026-05-27)
- Ruby — endoflife.date (릴리스 날짜 교차 확인)
현재 단락 (1/119)
Ruby 4.0.0은 2025년 12월 25일에 나왔습니다. 버전 번호가 3.5가 아니라 4.0이 된 것부터가 뉴스였지만, 런타임 쪽에서 눈에 띄는 건 ZJIT이었습니다. [공식 ...