루비(Ruby)의 성능은 버전이 거듭될수록 꾸준히 향상되고 있으며, 루비 개발팀은 더욱 빠른 루비를 만들기 위해 끊임없는 노력을 기울이고 있습니다.
그러한 노력 중 하나가 바로 3×3 프로젝트입니다.
목표는 무엇일까요?
Ruby 3.0을 Ruby 2.0보다 3배 빠르게 만드는 것입니다.
이 프로젝트의 핵심 요소 중 하나가 바로 새로운 MJIT 컴파일러로, 이번 글에서 자세히 다뤄보겠습니다.
MJIT란 무엇인가?
MJIT는 "메서드 기반 JIT(Just-In-Time) 컴파일러(Method Based Just-in-Time Compiler)"의 약자입니다.
이것이 의미하는 바는 무엇일까요?
루비는 여러분의 코드를 YARV 명령어로 컴파일하고, 이 명령어들은 루비 가상 머신(Ruby Virtual Machine)에 의해 실행됩니다.
JIT는 여기에 한 단계를 더 추가합니다.
JIT는 자주 사용되는 명령어들을 바이너리 코드로 컴파일합니다.
그 결과 최적화된 바이너리가 생성되어 코드 실행 속도가 빨라집니다.
MJIT의 작동 원리
MJIT를 더 잘 이해하기 위해 실제로 어떻게 작동하는지 살펴보겠습니다.
Ruby 2.6에서는 --jit 옵션으로 JIT를 활성화할 수 있습니다.
다음과 같이 사용합니다:
ruby --jit app.rb
Ruby 2.6에는 JIT의 작동 방식을 정확히 파악하는 데 도움이 되는 다양한 JIT 전용 옵션이 포함되어 있습니다. ruby --help 명령으로 이 옵션들을 확인할 수 있습니다.
주요 옵션 목록은 다음과 같습니다
- --jit-wait
- --jit-verbose
- --jit-save-temps
- --jit-max-cache
- --jit-min-calls
이 중 verbose 옵션이 좋은 출발점이 될 것 같습니다!
또한 --jit-wait 옵션도 함께 사용하겠습니다. 이 옵션은 JIT 코드의 컴파일이 완료될 때까지 루비가 대기한 후 실행하도록 만듭니다.
일반적인 상황에서는 JIT가 별도의 워커 스레드에서 코드를 컴파일하며, 컴파일이 끝날 때까지 기다리지 않습니다.
테스트해볼 수 있는 명령어는 다음과 같습니다:
ruby --disable-gems --jit --jit-verbose=1 --jit-wait -e "4.times { 123 }"
실행 결과는 다음과 같습니다:
Successful MJIT finish
흥미롭지 않죠?
JIT가 아무 작업도 하지 않았습니다.
왜 그럴까요?
기본 설정에서는 메서드가 5번 이상 호출되어야 JIT가 동작하기 때문입니다(jit-min-calls).
다음과 같이 실행해 보면:
ruby --disable-gems --jit --jit-verbose=1 --jit-wait -e "5.times { 123 }"
이번에는 흥미로운 결과가 나옵니다:
JIT success (32.1ms): block in <main>@-e:1 -> /tmp/_ruby_mjit_p13921u0.c
이 출력은 무엇을 의미할까요?
블록을 5번 호출했기 때문에 JIT가 해당 블록을 컴파일했습니다. 이 출력으로 알 수 있는 정보는 다음과 같습니다:
- 컴파일에 걸린 시간(
32.1ms) - 정확히 무엇이 컴파일되었는지(
block in <main>) - 컴파일 소스로 생성된 파일 경로(
/tmp/_ruby_mjit_p13921u0.c)
이 파일은 C 소스 코드로, 오브젝트 파일(.o)로 컴파일된 후 공유 라이브러리 파일(.so)로 변환됩니다.
--jit-save-temps 옵션을 추가하면 이러한 파일들에 직접 접근할 수 있습니다.
예시는 다음과 같습니다:

제가 이해한 JIT의 작동 방식을 정리하면 다음과 같습니다:
- 메서드 호출 횟수를 카운트합니다
- 특정 메서드가 5번 호출되면(
jit-min-calls기본값) JIT가 트리거됩니다 - 해당 메서드의 명령어를 담은 C 파일이 생성됩니다(YARV 명령어가 인라인 형태로 포함됩니다)
- GCC와 같은 일반 C 컴파일러를 사용해 백그라운드에서 컴파일이 진행됩니다(
--jit-wait사용 시 제외) - 컴파일이 완료되면, 해당 메서드가 호출될 때 결과물인 공유 라이브러리 파일이 사용됩니다
그렇다면 실제 성능은 어느 정도일까요?
MJIT 성능 테스트: 정말 빨라졌을까?
MJIT의 목표는 루비를 더 빠르게 만드는 것입니다.
현재 그 성능은 어느 정도일까요?
직접 확인해 보겠습니다!
먼저 마이크로 벤치마크 결과입니다:
| 벤치마크 | 결과 (JIT 미사용 Ruby 2.6 대비) |
|---|---|
| while 루프 | 8배 빠름 |
| 문자열 추가가 포함된 while 루프 | 10% 빠름 |
| 곱셈이 포함된 while 루프 (Integer) | 4배 빠름 |
| 곱셈이 포함된 while 루프 (Bignum) | 20% 느림 |
| 문자열 upcase | 10% 빠름 |
| 문자열 match | 2% 느림 |
| 문자열 match? | 10% 빠름 |
| 10,000개 난수 배열 처리 | 20% 빠름 |
성능 편차가 상당하지만, 여기서 하나의 결론을 도출할 수 있습니다...
MJIT는 반복문(루프)에서 특히 강력합니다!
그렇다면 좀 더 복잡한 애플리케이션에서는 어떨까요?
간단한 Sinatra 앱으로 테스트해 보겠습니다:
require 'sinatra' get '/' do "apples, oranges & bananas" end
코드는 짧아 보이지만, 이 작은 코드 조각은 500개가 넘는 서로 다른 메서드를 실행합니다. JIT에게 일감을 주기에 충분한 양입니다!
참고로 테스트 환경은 Sinatra 2.0.4와 Thin 1.7.2입니다.
다음 명령어(apache bench)로 벤치마크를 실행할 수 있습니다:
ab -c 20 -t 10 https://localhost:4567/
결과는 다음과 같습니다:

결과를 보면 Ruby 2.6이 2.5보다 빠르지만, JIT를 활성화하면 Sinatra가 오히려 11% 느려집니다!
왜 그럴까요?
정확한 원인은 알 수 없지만, JIT 도입에 따른 오버헤드 때문이거나 코드가 충분히 최적화되지 않았기 때문일 수 있습니다.
C 프로파일러(callgrind)를 사용한 분석 결과, Sinatra에서 JIT 최적화 코드(앞서 발견한 컴파일된 C 파일)의 사용률은 매우 낮았습니다(2% 미만). 반면 8배 속도 향상을 보인 while 문의 경우 사용률이 매우 높았습니다(24.22%).
JIT 적용 시 while 벤치마크 결과:

JIT 적용 시 Sinatra 벤치마크 결과:

이것이 원인의 일부일 수 있습니다. 저는 컴파일러 전문가가 아니기 때문에 이것만으로 확실한 결론을 내리기는 어렵습니다.
마무리
MJIT는 Ruby 2.6에서 사용할 수 있는 "JIT(Just-in-Time) 컴파일러"로, --jit 플래그로 활성화할 수 있습니다. MJIT는 유망한 기술이며 일부 소규모 프로그램의 속도를 높일 수 있지만, 아직 개선해야 할 부분이 많습니다!
이 글이 유익했다면 루비를 좋아하는 친구들에게 공유해 주세요 🙂
읽어주셔서 감사합니다.