Ruby에서 객체 할당 속도 향상

Fast Allocations in Ruby 3.5 | Rails at Scale

3줄 요약

  • Ruby 3.5에서는 객체 할당 속도가 이전 버전에 비해 획기적으로 빨라집니다.
  • 특히 키워드 파라미터를 사용하는 경우 YJIT과 함께 최대 6.5배 이상 성능 향상을 보입니다.
  • 이러한 개선은 `Class#new` 메서드의 인라인화와 새로운 YARV 명령어 `opt_new` 도입을 통해 이루어졌습니다.

Ruby 3.5는 객체 할당 성능에 있어 기념비적인 발전을 이룩했습니다. 기존 Ruby 애플리케이션의 핵심인 객체 할당 프로세스를 최대 6배 이상 가속화하여, 전반적인 애플리케이션 성능 향상에 크게 기여할 것으로 기대됩니다. 이 글은 Ruby 3.5에서 달성된 객체 할당 속도 향상의 배경, 벤치마크 결과, 그리고 이를 가능하게 한 기술적 최적화에 대해 심층적으로 다룹니다. 특히 `Class#new`의 최적화가 이 성능 개선의 핵심을 이룹니다.

Ruby 3.5의 객체 할당 속도 향상을 검증하기 위해, YJIT 활성화 여부, 파라미터 유형(위치 및 키워드), 그리고 파라미터 개수(0개부터 8개까지)를 달리하여 광범위한 벤치마크가 수행되었습니다. 벤치마크는 Foo.new를 반복적으로 호출하여 객체 할당 비용을 측정하는 방식으로 진행되었습니다. 결과는 Ruby 3.4.2 대비 Ruby 3.5의 속도 향상 비율로 나타났으며, 1보다 큰 값은 속도 향상을 의미합니다.

벤치마크 결과: * 위치 파라미터 (Positional parameters): 파라미터 개수에 관계없이 일정한 속도 향상을 보였습니다. YJIT 없이 Ruby 3.4.2 대비 약 1.8배, YJIT 활성화 시 약 2.3배 빨라졌습니다. * 키워드 파라미터 (Keyword parameters): 파라미터 개수가 증가할수록 속도 향상 폭이 더욱 커지는 흥미로운 결과를 보였습니다. 3개의 키워드 파라미터만 사용해도 Ruby 3.4.2 대비 3배 빨라졌으며, YJIT을 활성화하면 6.5배 이상 빨라지는 놀라운 성능을 보여주었습니다. 이는 이번 최적화의 가장 큰 이점 중 하나입니다.

Class#new의 병목 현상: Class#new는 매우 단순한 메서드로, allocate를 통해 객체를 할당하고, 모든 파라미터를 initialize 메서드로 전달한 후 인스턴스를 반환하는 방식으로 작동합니다. 성능 저하의 주된 원인은 initialize 메서드 호출 오버헤드에 있었습니다. Ruby의 가상 머신(YARV)은 스택을 사용하여 파라미터를 전달하며, Ruby 함수 간 호출은 이 스택을 직접 사용하므로 효율적입니다. 그러나 C 함수는 레지스터나 머신 스택을 사용하기 때문에, Ruby에서 C 함수를 호출하거나 그 반대의 경우 파라미터 값을 Ruby 스택에서 레지스터로 복사하는 변환 과정이 필요했습니다. 특히 키워드 파라미터의 경우 C에서는 직접 지원되지 않아, 해시를 새로 할당하고 파라미터를 해시에 담아 전달하는 추가적인 오버헤드가 발생하여 병목 현상을 가중시켰습니다.

할당 속도 향상 달성: 초기에는 Class#new를 Ruby로 재작성하여 ... (스플랫 파라미터 포워딩) 문법을 사용하는 방안이 고려되었습니다. 그러나 당시 ... 문법은 추가 객체 할당을 유발하여 비효율적이었습니다. 이를 해결하기 위해 ...에 대한 최적화가 먼저 구현되어 추가 객체 할당 없이 파라미터 포워딩이 가능해졌습니다. 하지만 Ruby로 구현된 Class#newinitialize 호출 시 인라인 캐시(inline cache) 미스율이 높다는 문제가 있었습니다. Ruby의 단일형(monomorphic) 인라인 캐시는 단일 타입의 리시버에만 효율적이지만, Class#new는 다양한 클래스의 인스턴스를 생성하므로 캐시 적중률이 매우 낮아질 수 있었습니다.

이러한 한계를 극복하기 위해 YARV의 개발자인 Koichi Sasada의 제안에 따라 Class#new의 구현을 새로운 YARV 명령어인 opt_new를 통해 ‘인라인화’하는 방안이 채택되었습니다. 인라인화는 Class#new 메서드를 직접 호출하는 대신, 해당 메서드의 로직(allocateinitialize 호출)을 호출 지점에 직접 삽입하는 방식입니다. 이는 다음과 같은 주요 이점을 제공합니다. 첫째, 파라미터 복사 오버헤드가 제거됩니다. Ruby 스택에 푸시된 파라미터가 initialize 메서드에서 직접 소비될 수 있습니다. 둘째, Class#new를 위한 스택 프레임 생성 및 제거 과정이 사라져 추가적인 성능 향상을 가져옵니다. 셋째, initialize 호출 지점의 인라인 캐시 적중률이 크게 개선됩니다. 각 new 호출 지점마다 고유한 initialize 호출 지점이 생기므로, 캐시 효율성이 높아집니다.

인라인화의 단점: 이러한 최적화에도 몇 가지 단점은 존재합니다. 첫째, 더 많은 명령어를 생성하므로 메모리 사용량이 소폭 증가합니다. 하지만 실제 측정 결과, 전체 힙 크기 대비 명령어 시퀀스 크기 증가율은 미미한 0.5%에 불과했습니다. 둘째, caller 스택 트레이스에서 Class#new 프레임이 사라지는 역호환성 문제가 발생합니다. 이는 디버깅 시 스택 추적 정보가 달라지는 것을 의미합니다.

결론적으로, Ruby 3.5의 객체 할당 속도 향상은 단순히 숫자를 넘어선 중대한 기술적 진보입니다. `Class#new`의 인라인화와 `opt_new` 명령어 도입을 통해 파라미터 복사, 스택 프레임 오버헤드 제거, 그리고 인라인 캐시 효율성 증대라는 세 가지 핵심 목표를 달성했습니다. 특히 키워드 파라미터 사용 시의 놀라운 성능 개선은 Ruby 개발자들에게 매우 반가운 소식입니다. 이 최적화는 Ruby 언어의 성능을 한 단계 끌어올리는 데 크게 기여했으며, Koichi Sasada와 John Hawthorn을 비롯한 기여자들의 노고에 깊이 감사드립니다. Ruby 3.5는 더 빠르고 효율적인 애플리케이션 개발을 가능하게 할 것입니다.