Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

Ruby 2.6의 MJIT란 무엇이며 어떻게 작동할까? 완벽 가이드

루비(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 옵션을 추가하면 이러한 파일들에 직접 접근할 수 있습니다.

예시는 다음과 같습니다:

Ruby 2.6의 MJIT란 무엇이며 어떻게 작동할까? 완벽 가이드

제가 이해한 JIT의 작동 방식을 정리하면 다음과 같습니다:

  1. 메서드 호출 횟수를 카운트합니다
  2. 특정 메서드가 5번 호출되면(jit-min-calls 기본값) JIT가 트리거됩니다
  3. 해당 메서드의 명령어를 담은 C 파일이 생성됩니다(YARV 명령어가 인라인 형태로 포함됩니다)
  4. GCC와 같은 일반 C 컴파일러를 사용해 백그라운드에서 컴파일이 진행됩니다(--jit-wait 사용 시 제외)
  5. 컴파일이 완료되면, 해당 메서드가 호출될 때 결과물인 공유 라이브러리 파일이 사용됩니다

그렇다면 실제 성능은 어느 정도일까요?

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의 MJIT란 무엇이며 어떻게 작동할까? 완벽 가이드

결과를 보면 Ruby 2.6이 2.5보다 빠르지만, JIT를 활성화하면 Sinatra가 오히려 11% 느려집니다!

왜 그럴까요?

정확한 원인은 알 수 없지만, JIT 도입에 따른 오버헤드 때문이거나 코드가 충분히 최적화되지 않았기 때문일 수 있습니다.

C 프로파일러(callgrind)를 사용한 분석 결과, Sinatra에서 JIT 최적화 코드(앞서 발견한 컴파일된 C 파일)의 사용률은 매우 낮았습니다(2% 미만). 반면 8배 속도 향상을 보인 while 문의 경우 사용률이 매우 높았습니다(24.22%).

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

Ruby 2.6의 MJIT란 무엇이며 어떻게 작동할까? 완벽 가이드

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

Ruby 2.6의 MJIT란 무엇이며 어떻게 작동할까? 완벽 가이드

이것이 원인의 일부일 수 있습니다. 저는 컴파일러 전문가가 아니기 때문에 이것만으로 확실한 결론을 내리기는 어렵습니다.

마무리

MJIT는 Ruby 2.6에서 사용할 수 있는 "JIT(Just-in-Time) 컴파일러"로, --jit 플래그로 활성화할 수 있습니다. MJIT는 유망한 기술이며 일부 소규모 프로그램의 속도를 높일 수 있지만, 아직 개선해야 할 부분이 많습니다!

이 글이 유익했다면 루비를 좋아하는 친구들에게 공유해 주세요 🙂

읽어주셔서 감사합니다.