2P by GN⁺ | ★ favorite | 댓글 1개
  • 작은 언어 벤치마크에서 CRuby는 뒤에서 세 번째였지만, 병목이 언어 전체가 아니라 YJIT가 들여다볼 수 없는 반복 구현에 있음을 보여줌
  • Ruby 3.3.6은 M3 MacBook Pro에서 Fibonacci 12.17초, Loops 28.80초였고 node.js는 둘 다 1초대였으며, M2 MacBook Air에서는 Ruby가 각각 16.33초와 33.43초로 더 느렸음
  • ruby --yjit만 켜면 Fibonacci는 16.88초에서 2.06초로 크게 줄지만, Loops는 33.43초에서 25.57초로만 줄어 Range#each가 병목으로 남음
  • Ruby 3.3의 Integer#times와 Ruby 3.4의 Array#each처럼 C 구현을 Ruby 쪽으로 옮긴 메서드는 YJIT 최적화 대상이 되어 반복 벤치마크가 13~14초대로 개선됨
  • CRuby는 with_yjit 훅으로 YJIT 활성화 시 Ruby 구현을 쓰고, 비활성화 시 C 구현을 유지할 수 있어 핵심 라이브러리를 YJIT 친화적 Ruby 코드로 바꾸는 흐름이 생김

벤치마크에서 드러난 Ruby의 위치

  • 최근 공유된 language comparison repo는 여러 언어의 작은 벤치마크를 협업으로 만드는 저장소임
  • 이 저장소의 CRuby 결과는 R과 Python만 Ruby보다 느린 뒤에서 세 번째였음
  • 벤치마크는 두 가지 축을 봄
    • Loops: 반복문, 조건문, 기본 수학 연산 성능을 강조함
    • Fibonacci: 함수 호출 오버헤드와 재귀 비용을 드러냄
  • Loops 예제는 중첩 반복으로 총 10,000 × 100,000회, 즉 10억 번 반복을 수행함
  • Fibonacci 예제는 최적화된 구현이 아니라 의도적으로 단순한 naive Fibonacci를 사용함

초기 측정값과 YJIT 적용 효과

  • M3 MacBook Pro에서 Ruby 3.3.6은 다음 수준이었음
    • Fibonacci: 12.17초
    • Loops: 28.80초
    • node.js: 두 예제 모두 1초 조금 넘는 수준
  • M2 MacBook Air에서는 같은 벤치마크가 더 느리게 나옴
    • Ruby Fibonacci: 16.33초
    • Ruby Loops: 33.43초
    • node.js Fibonacci: 1.36초
    • node.js Loops: 2.07초
  • 원래 저장소의 실행 명령은 YJIT 없이 ruby ./code.rb 40 형태였음
  • ruby --yjit ./code.rb 40로 실행하면 결과가 달라짐
    • Fibonacci: 2.06초
    • Loops: 25.57초
  • YJIT는 Fibonacci에서 큰 효과를 냈지만, Loops에서는 개선 폭이 제한적임

Range#each가 YJIT에 불리한 이유

  • Loops 코드의 핵심 반복은 (0...10_000).each(0...100_000).each 형태의 Range#each
  • Ruby 3.4 기준 Range#each는 여전히 C로 작성되어 있음
  • CRuby의 range.cRange 클래스와 each 메서드를 C 함수 range_each에 연결함
  • range_each는 여러 범위 형태를 처리하기 위해 다양한 경로로 분기함
    • (0...).each
    • (0...100).each
    • ("a"..."z").each
  • C 함수 자체는 빠를 수 있지만, YJIT는 그 내부를 볼 수 없음
    • 최적화는 함수 호출 지점에서 멈추고, C 함수가 반환된 뒤 다시 이어짐
    • YJIT는 뜨거운 실행 경로에 특화된 최적화를 만들 수 있는데, C 구현은 이 이점을 제한함

Integer#times로 바꿨을 때의 변화

  • Ruby 3.3에서 Integer#timesC 함수에서 Ruby 메서드로 전환
  • 핵심 구조는 while i < self, yield i, i = i.succ로 이루어진 단순한 Ruby 반복문임
  • Ruby 코드로 작성된 덕분에 YJIT가 내부를 분석하고 최적화할 수 있음
  • Range 반복을 10_000.times100_000.times로 바꾸면 Loops 시간이 크게 줄어듦
    • Range#each: 25.57초
    • Integer#times: 13.66초
  • 다른 측정 환경에서는 Integer#times가 9초까지 줄었고, Ruby 3.4에서는 8초 결과도 나옴

Integer#succ와 VM 수준 최적화

  • Integer#times 구현은 증가 연산에 i += 1 대신 i.succ 를 사용함
  • Integer#succ는 정수의 다음 값을 돌려주는 메서드임
  • Ruby VM 바이트코드에서 i.succopt_succ 한 단계로 표현됨
  • 반면 i += 1은 두 단계로 나뉨
    • putobject_INT2FIX_1_: 정수 1을 VM 스택에 올림
    • opt_plus: + 연산을 수행함
  • 일반 Ruby 프로그램에서는 신경 쓸 필요가 거의 없지만, JIT와 VM 수준에서는 수백만~수십억 번 반복될 때 한 단계 차이도 성능에 영향을 줌

Array#each도 Ruby 3.4에서 Ruby 구현으로 이동

  • Ruby 3.4에서는 Array#each도 C에서 Ruby 쪽 구현으로 전환됨
  • 첫 시도는 단순한 Ruby 코드였지만, CRuby 내부와 관련된 race condition이 있었음
  • 최종 구현은 Ruby 코드 안에서 Primitive를 사용함
    • Primitive.attr! :inline_block, :c_trace
    • Primitive.cexpr!
    • ary_fetch_next
  • 완전한 순수 Ruby라기보다 C 코드 평가와 Ruby 구조가 섞인 형태지만, YJIT는 여전히 상당한 최적화를 수행할 수 있음
  • 배열을 미리 만들어 Array#each로 반복하면 Integer#times와 비슷한 성능이 나옴
    • Range#each: 25.57초
    • Integer#times: 13.66초
    • Array#each: 13.96초

Ruby Microbench 측정

  • 별도 저장소 ruby_microbench는 원래 예제와 여러 Ruby 반복 형태를 비교함
  • Ruby 3.4에서 YJIT를 켠 결과는 다음과 같음
    • Fibonacci: 2.19초
    • array#each: 14.02초
    • range#each: 26.61초
    • times: 13.12초
    • for: 14.91초
    • while: 37.10초
    • loop do: 13.95초
  • Ruby 3.4에서 YJIT를 끄면 대부분 더 느려짐
    • Fibonacci: 16.49초
    • array#each: 34.29초
    • range#each: 33.88초
    • times: 33.18초
    • for: 36.32초
    • while: 37.14초
    • loop do: 50.65초
  • while 예제는 예상보다 느렸고, 구현 방식 문제일 수 있음
  • for inarray#each는 Ruby VM 바이트코드 수준에서 거의 동일해 성능도 비슷함
    • for in은 대체로 VM에서 #each 호출로 바뀌는 문법 설탕에 가까움

다른 Ruby 구현과의 비교

  • 같은 벤치마크는 여러 Ruby 구현에서도 실행됨
  • 일부 결과는 다음과 같음
    • TruffleRuby 24.1
      • Fibonacci: 0.92초
      • array#each: 0.97초
      • range#each: 0.92초
      • times: 2.39초
      • for: 2.06초
      • while: 3.90초
      • loop do: 0.77초
    • MRuby 3.3
      • Fibonacci: 28.83초
      • array#each: 144.65초
      • range#each: 126.40초
      • times: 128.22초
    • Artichoke
      • Fibonacci: 19.71초
      • array#each: 236.10초
      • range#each: 214.55초
      • times: 214.51초

Range#each를 Ruby로 몽키 패치한 실험

  • Range#each를 단순한 Ruby 구현으로 몽키 패치하면 성능이 크게 개선됨
  • 구현은 begin, end, loop, yield, i.succ를 사용하는 단순 형태임
  • 측정값은 다음과 같음
    • C 구현 Range#each: 25.57초
    • Ruby 구현 Range#each: 16.64초
  • 이 구현은 모든 Range 케이스를 처리하지 않는 과도하게 단순화된 버전
  • 그래도 C에서 Ruby로 옮기면 YJIT가 최적화할 수 있고, 일반 C 코드로 재현하기 어렵거나 불가능한 방식의 성능 개선이 가능함

YJIT 표준 라이브러리와 with_yjit

  • Aaron Patterson의 Ruby Outperforms C는 GraphQL 파싱용 C 확장을 Ruby로 다시 작성했고, YJIT 최적화 덕분에 Ruby 코드가 C보다 빨랐던 사례임
  • CRuby의 핵심 YJIT 팀은 일부 핵심 기능에서 C 코드를 제거하거나, YJIT가 켜졌을 때만 Ruby 구현을 쓰는 방식을 적용하고 있음
  • with_yjit 블록은 YJIT가 활성화된 경우에만 해당 Ruby 구현을 적용함
    • YJIT가 꺼져 있으면 C 구현이 계속 실행됨
    • YJIT가 켜지면 YJIT 최적화 가능한 Ruby 버전을 사용함
  • Ruby 3.3부터 YJIT는 지연 초기화가 가능하며, with_yjit 코드는 YJIT가 활성화되는 순간 적절한 메서드 버전을 적용함
  • with_yjit는 YJIT 훅이며, 호출된 뒤 런타임에서 undef :with_yjit로 제거됨

YJIT가 생성한 기계어를 보는 방법

  • CRuby를 --enable-yjit=dev 옵션으로 빌드하면 YJIT가 생성하는 기계어 디스어셈블리를 볼 수 있음
  • 빌드 예시는 다음과 같음
    • ./configure --enable-yjit=dev
    • make install
  • 실행 시 --yjit-dump-disasm 플래그를 사용함
    • ./ruby --yjit --yjit-dump-disasm test.rb 40
  • Integer#timesi.succ는 VM 바이트코드에서 opt_succ로 나타남
  • YJIT의 Rust 구현은 opt_succ에 대해 다음을 수행함
    • 수신자가 Fixnum인지 가드함
    • Fixnum이 아니면 다른 실행 경로로 나감
    • Fixnum이면 내부 태그 표현 때문에 1을 더하기 위해 실제로는 2를 더함
    • 오버플로우가 발생하면 다른 경로로 나감
  • 이 예시는 JIT 최적화가 Ruby 코드, C 코드, VM 바이트코드, Rust 구현, 기계어까지 여러 층을 오가며 작동함을 보여줌

CRuby 최적화의 방향

  • Ruby 구현 작업은 Ruby보다 낮은 수준의 언어에서 많이 이루어지며, CRuby에서는 주로 C와 일부 Rust가 사용됨
  • YJIT 같은 계층은 더 많은 언어 기능을 평범한 Ruby 코드로 옮길 가능성을 만듦
  • 핵심 기능이 Ruby로 더 많이 작성되면 Ruby 개발자가 CRuby에 기여하기 쉬워질 수 있음
  • Java처럼 작은 저수준 코어 위에 언어 대부분이 자기 자신으로 작성되는 구조가 CRuby의 가능한 미래 중 하나로 거론됨
  • 현재 흐름에서는 C 구현을 그대로 유지하기보다, YJIT가 최적화할 수 있는 Ruby 구현을 핵심 경로에 도입하는 방식이 계속 중요함

댓글과 토론

Hacker News 의견들
  • 루프 예제가 10억 번 중첩 루프를 돌리는 이상한 벤치마크로 보이고, 손으로 최적화하면 실행 시간의 99% 이상이 앞부분에 몰릴 것 같음
    배열 원소별 활성 구간 분석(liveness analysis) 을 하면 바깥 루프 전체를 제거할 수 있어 보이는데, 이런 분석을 실제로 하는 컴파일러가 있는지 궁금함
    u가 컴파일 시점에 알려져 있지 않아도 안쪽 루프 역시 몇 개의 명령으로 바꿀 수 있을 듯하고, 이건 clang 같은 쪽이 곧 할 수도 있는 더 표준적인 최적화로 보임

    • 컴파일러는 보통 배열의 개별 원소에 대해 활성 구간 분석을 하지 않음
      추적해야 할 데이터가 너무 많고, 이런 잘못된 코드 같은 경우에만 유용할 가능성이 큼
      예전에 AI 컴파일러를 만들 때 텐서 개별 원소의 활성 구간 분석이 실제로 유용했을 텐데도, 컴파일 시간과 메모리 요구량이 말도 안 될 정도라 하지 않았음
    • 대부분의 경우에는 닫힌 형태로 계산 가능함:
      result = ((u * (u - 1)) / 2 * (100000/u)) + (100000%u * (100000%u - 1) / 2) + r)
  • 글은 앞으로 나올 Ruby 버전을 다루고 있고, 궁금해서 보니 ruby 3.4.0은 이번 크리스마스에, ruby 3.5.0은 다음 크리스마스에 나올 것으로 보임
    또 이런 루프에서 Python의 최소한의 JIT가 어떤 영향을 줄지도 궁금함
    Python 3.13은 JIT를 켜서 빌드해야 하니, 누군가 빌드한 환경에서 벤치마크를 돌려보면 흥미로울 듯함: https://drew.silcock.dev/blog/everything-you-need-to-know-ab...

    • Ruby는 항상 크리스마스에 릴리스되어서 예측 가능하고 귀여운 일정임
      성능 개선은 포인트 릴리스에도 들어갈 수 있었던 것으로 기억함
  • Integer#succ는 성능뿐 아니라 가독성 때문에 자주 쓰게 됨
    예를 들어 UUID 라이브러리의 #bytes 메서드에서 코드를 읽을 때 머릿속을 “비트 슬라이싱 모드”로 유지하려고 두 번 사용했음
    16번 루프는 0xF.succ, 루프 안에서 256으로 나누는 부분은 0xFF.succ로 표현함: https://github.com/okeeblow/DistorteD/blob/ba48d10/Globe%20G...

    • 이 경우에 0xF.succ0x10보다 낫다고 느끼는 이유가 궁금함
  • https://github.com/bddicken/languages에 기여 중이고, Lua 접근을 시도한 뒤 어딘가에서 언급된 TruffleRuby도 떠올렸지만 main.rb를 돌렸을 때는 TruffleRuby와 일반 Ruby 사이에 사실상 큰 차이가 없었음
    가끔은 일반 Ruby가 TruffleRuby보다 더 빨랐음
    글의 TruffleRuby 속도 벤치마크가 어떤 변경 이후에 나온 것인지 확인해보고 싶고, 검증할 수 있다면 메인 저장소에 커밋으로 추가해보고 싶음
    TruffleRuby 구현이 실제로 Node.js보다 빠르고 Bun이나 Go에 가까운 수준이라면 상당히 놀라움

    • TruffleRuby는 시작 시간과 최고 성능에 도달하는 시간을 감안해야 하고, 이는 네이티브/JVM 런타임 설정에 따라 달라짐
      자세한 내용은 https://github.com/oracle/truffleruby 참고
  • Ruby가 정말 빨라졌고, 특히 TruffleRuby는 더 인상적임

    • 그게 Oracle 것임: https://github.com/oracle/truffleruby
    • Rails는 Truffle에서 동작하지 않고, 이해한 바로는 당분간도 어려울 것 같음
      Ruby 성능에 가장 큰 영향을 줄 가능성이 높은 조합이라 아쉬움
  • YJIT가 Rust로 작성됐다는 건 몰랐는데 정말 흥미로움

    • 처음에는 C로 작성됐다가 Rust로 포팅됐고, 좋은 결정이었던 것 같음
      단점은 적절한 도구체인이나 플랫폼이 없으면 빌드 시 활성화되지 않을 수 있다는 점이지만, 괜찮은 절충으로 보임
  • 더 오래 운영됐고 더 많은 언어를 다루는 언어 비교 저장소도 있음: https://github.com/niklas-heer/speed-comparison

    • 또 하나의 언어 비교 저장소인데 표현 방식이 읽기 어려움
      차트 축 라벨과 막대 라벨이 서로 겹치고, 세로 격자선도 없음
      간단한 HTML 표였으면 좋겠음
  • Python이 벤치마크에서 가장 느린 언어였는데도 2024년 10월 기준 GitHub에서 가장 많이 쓰이는 언어라는 점이 흥미로움
    느린 언어일수록 인기 있다는 상관관계가 있는 것처럼 보임

    • 다시 비교하려면 컴파일 시간도 포함하고, 해당 빌드가 예상되는 실행 횟수에 걸쳐 상각해야 함
      Rust를 꽤 깊게 좋아하는 입장이지만, 모든 언어와 런타임, 인터프리터, 컴파일러는 도구임
      문제와 해결 접근법에 따라 좋은 도구 모음이 필요하고, 몇 번만 실행될 수 있는 프로그램이라면 느린 실행 속도는 별로 중요하지 않음
      Python, R 같은 프로그램에는 그런 경우가 많음
    • 음식과 비슷해서, 위에 설탕을 얹으면 사람들이 훨씬 좋아함
      대체로 Ruby는 느리지만, 코딩하기 정말 좋아서 입문자에게 더 매력적임
    • 느린 언어는 더 고수준이라 사용하기 쉬움
    • 인기 상위 20개 언어를 봐도 그 상관관계가 유지되는지 궁금함
  • Advent of Code 풀이가 놀랄 정도로 비슷하게 생겨서 판도를 바꿀 변화처럼 보임