더 빠른 FFI를 위한 작은 JIT

Tiny JITs for a Faster FFI | Rails at Scale

3줄 요약

  • CRuby의 FFI는 C 확장보다 성능 오버헤드가 큽니다.
  • 작은 JIT 컴파일러를 FFI 래퍼에 적용하여 성능을 개선할 수 있습니다.
  • 개념 증명(FJIT) 결과, FFI 대비 상당한 속도 향상을 보여주며 C 확장과 유사하거나 더 빠른 성능을 달성했습니다.

루비(Ruby)는 생산성이 높은 언어이지만, 때로는 성능critical한 작업을 위해 네이티브(Native) 코드를 호출해야 할 필요가 있습니다. CRuby에서 네이티브 코드를 호출하는 일반적인 방법으로는 C 확장(C Extension)과 FFI(Foreign Function Interface) 라이브러리가 있습니다. 저자는 가능한 한 루비 코드를 많이 작성하길 권장하며, 네이티브 호출이 필요하더라도 루비에서 대부분의 로직을 처리하고 네이티브 코드는 최소한의 래퍼 역할만 하도록 설계하는 것이 YJIT의 최적화 이점을 활용하는 데 유리하다고 설명합니다. 하지만 FFI는 일반적으로 C 확장에 비해 성능 오버헤드가 크다는 단점이 있습니다. 이 글은 FFI의 성능 문제를 극복하기 위해 작은 규모의 JIT 컴파일러를 활용하는 새로운 접근 방식을 제안하고 그 가능성을 탐구합니다.

FFI의 성능 오버헤드를 구체적으로 이해하기 위해 strlen C 함수를 래핑하는 간단한 벤치마크를 수행했습니다. 이 벤치마크는 FFI 구현, C 확장 구현(strlen Gem), 그리고 String#bytesize의 간접 및 직접 호출 성능을 비교합니다. 벤치마크 결과는 String#bytesize 직접 호출이 가장 빠르며, C 확장이 그 다음, 간접 루비 호출, 그리고 FFI 구현이 가장 느리다는 것을 보여줍니다. 특히 FFI는 C 확장보다 훨씬 큰 오버헤드를 가집니다. 이 결과는 FFI를 통한 네이티브 함수 호출에 상당한 성능 비용이 따른다는 것을 명확히 합니다.

이러한 FFI의 성능 한계를 극복하기 위해 제안된 아이디어는 FFI 래퍼 코드를 JIT 컴파일하는 것입니다. FFI의 attach_function과 같은 메커니즘은 호출할 함수 이름, 매개변수 타입, 반환 타입 등의 정보를 컴파일 시점에 제공합니다. 이 정보를 활용하여 루비 타입을 네이티브 타입으로 변환하고 다시 루비 타입으로 변환하는 래핑 코드와 네이티브 함수 호출 코드를 런타임에 기계어 코드로 생성할 수 있습니다. 이 아이디어를 실현하기 위해서는 몇 가지 기술적 구성 요소가 필요합니다. 첫째, 기계어 코드를 생성할 수 있어야 하며, 이를 위해 저자는 AArch64와 Fisk Gem을 개발했습니다. 둘째, 생성된 기계어 코드를 실행하기 위해 실행 가능한 메모리를 할당할 수 있어야 하며, 이를 위해 JITBuffer Gem을 개발했습니다. 마지막으로 가장 중요한 것은 루비 런타임이 생성된 기계어 코드로 점프하여 FFI 오버헤드를 우회할 수 있는 메커니즘입니다. 최근 RJIT(Ruby로 작성된 JIT 컴파일러)를 Gem으로 분리하자는 제안은 이 문제를 해결하는 데 중요한 역할을 합니다. RJIT는 Ruby 내부 타입을 매핑하는 데이터 구조를 생성하여 서드파티 JIT 컴파일러가 루비 데이터 타입을 처리하는 데 필요한 정보를 제공하며, JIT 진입 함수 포인터가 있을 경우 항상 해당 코드를 실행하도록 함으로써 루비가 JIT 코드로 점프할 수 있게 합니다.

이러한 구성 요소를 바탕으로 저자는 “FJIT”라는 작은 개념 증명(Proof of Concept) JIT 컴파일러를 개발했습니다. FJIT는 FFI와 유사한 인터페이스를 제공하며, attach_function 호출 시 필요한 기계어 코드를 동적으로 생성합니다. strlen 함수를 FJIT로 래핑하여 다시 벤치마크를 수행한 결과는 매우 고무적입니다. FJIT 구현은 FFI보다 2배 이상 빠르며, C 확장 구현보다도 약간 더 빠른 성능을 보여주었습니다. 이는 작은 JIT 컴파일러가 FFI의 성능 오버헤드를 효과적으로 제거하고 C 확장과 동등하거나 그 이상의 성능을 달성할 수 있음을 시사합니다.

결론적으로, 작은 JIT 컴파일러를 FFI에 적용하는 접근 방식은 CRuby에서 네이티브 코드를 호출하는 성능을 획기적으로 개선할 잠재력을 가지고 있습니다. 이를 통해 "가능한 한 루비 코드를 많이 작성하라"는 철학을 유지하면서도 네이티브 호출의 성능 병목 현상을 해소할 수 있습니다. Zig와 같이 FFI 없이 네이티브 코드를 효율적으로 호출하는 언어들의 장점을 루비에서도 누릴 수 있게 되는 것입니다. 현재 이 개념 증명은 ARM64 플랫폼 및 특정 함수 시그니처로 제한되는 등 여러 제약 사항이 있지만, 이는 해결 불가능한 문제가 아니며 향후 발전 가능성이 높습니다.