Ruby 속도 향상을 위해 C 코드를 Ruby로 다시 쓰기
(jpcamara.com)- 작은 언어 벤치마크에서 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.c는Range클래스와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#times는 C 함수에서 Ruby 메서드로 전환됨 - 핵심 구조는
while i < self,yield i,i = i.succ로 이루어진 단순한 Ruby 반복문임 - Ruby 코드로 작성된 덕분에 YJIT가 내부를 분석하고 최적화할 수 있음
- Range 반복을
10_000.times와100_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.succ는opt_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_tracePrimitive.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 in과array#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초
- TruffleRuby 24.1
Range#each를 Ruby로 몽키 패치한 실험
Range#each를 단순한 Ruby 구현으로 몽키 패치하면 성능이 크게 개선됨- 구현은
begin,end,loop,yield,i.succ를 사용하는 단순 형태임 - 측정값은 다음과 같음
- C 구현
Range#each: 25.57초 - Ruby 구현
Range#each: 16.64초
- C 구현
- 이 구현은 모든
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=devmake install
- 실행 시
--yjit-dump-disasm플래그를 사용함./ruby --yjit --yjit-dump-disasm test.rb 40
Integer#times의i.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는 항상 크리스마스에 릴리스되어서 예측 가능하고 귀여운 일정임
성능 개선은 포인트 릴리스에도 들어갈 수 있었던 것으로 기억함
- Ruby는 항상 크리스마스에 릴리스되어서 예측 가능하고 귀여운 일정임
-
Integer#succ는 성능뿐 아니라 가독성 때문에 자주 쓰게 됨
예를 들어 UUID 라이브러리의#bytes메서드에서 코드를 읽을 때 머릿속을 “비트 슬라이싱 모드”로 유지하려고 두 번 사용했음
16번 루프는0xF.succ, 루프 안에서 256으로 나누는 부분은0xFF.succ로 표현함: https://github.com/okeeblow/DistorteD/blob/ba48d10/Globe%20G...- 이 경우에
0xF.succ가0x10보다 낫다고 느끼는 이유가 궁금함
- 이 경우에
-
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 참고
- TruffleRuby는 시작 시간과 최고 성능에 도달하는 시간을 감안해야 하고, 이는 네이티브/JVM 런타임 설정에 따라 달라짐
-
Ruby가 정말 빨라졌고, 특히 TruffleRuby는 더 인상적임
- 그게 Oracle 것임: https://github.com/oracle/truffleruby
- Rails는 Truffle에서 동작하지 않고, 이해한 바로는 당분간도 어려울 것 같음
Ruby 성능에 가장 큰 영향을 줄 가능성이 높은 조합이라 아쉬움
-
YJIT가 Rust로 작성됐다는 건 몰랐는데 정말 흥미로움
- 처음에는 C로 작성됐다가 Rust로 포팅됐고, 좋은 결정이었던 것 같음
단점은 적절한 도구체인이나 플랫폼이 없으면 빌드 시 활성화되지 않을 수 있다는 점이지만, 괜찮은 절충으로 보임
- 처음에는 C로 작성됐다가 Rust로 포팅됐고, 좋은 결정이었던 것 같음
-
더 오래 운영됐고 더 많은 언어를 다루는 언어 비교 저장소도 있음: https://github.com/niklas-heer/speed-comparison
- 또 하나의 언어 비교 저장소인데 표현 방식이 읽기 어려움
차트 축 라벨과 막대 라벨이 서로 겹치고, 세로 격자선도 없음
간단한 HTML 표였으면 좋겠음
- 또 하나의 언어 비교 저장소인데 표현 방식이 읽기 어려움
-
Python이 벤치마크에서 가장 느린 언어였는데도 2024년 10월 기준 GitHub에서 가장 많이 쓰이는 언어라는 점이 흥미로움
느린 언어일수록 인기 있다는 상관관계가 있는 것처럼 보임- 다시 비교하려면 컴파일 시간도 포함하고, 해당 빌드가 예상되는 실행 횟수에 걸쳐 상각해야 함
Rust를 꽤 깊게 좋아하는 입장이지만, 모든 언어와 런타임, 인터프리터, 컴파일러는 도구임
문제와 해결 접근법에 따라 좋은 도구 모음이 필요하고, 몇 번만 실행될 수 있는 프로그램이라면 느린 실행 속도는 별로 중요하지 않음
Python, R 같은 프로그램에는 그런 경우가 많음 - 음식과 비슷해서, 위에 설탕을 얹으면 사람들이 훨씬 좋아함
대체로 Ruby는 느리지만, 코딩하기 정말 좋아서 입문자에게 더 매력적임 - 느린 언어는 더 고수준이라 사용하기 쉬움
- 인기 상위 20개 언어를 봐도 그 상관관계가 유지되는지 궁금함
- 다시 비교하려면 컴파일 시간도 포함하고, 해당 빌드가 예상되는 실행 횟수에 걸쳐 상각해야 함
-
내 Advent of Code 풀이가 놀랄 정도로 비슷하게 생겨서 판도를 바꿀 변화처럼 보임