FFI 속도 향상을 위한 Tiny JITs
(railsatscale.com)- Ruby에서 네이티브 함수를 호출할 때 FFI는 편리하지만,
strlen벤치마크에서는 C 확장보다 호출 오버헤드가 커 성능 병목이 됨 - 기준 벤치마크에서
String#bytesize직접 호출은 39.879M i/s, C 확장은 30.661M i/s, Ruby 간접 호출은 28.697M i/s, FFI는 15.682M i/s로 가장 느렸음 - 핵심 아이디어는
attach_function시점에 이미 아는 함수명·인자 타입·반환 타입을 이용해 외부 함수 호출용 기계어를 런타임에 만드는 것임 - 개념 증명 FJIT는 Ruby 3.5.0dev ARM64 환경에서
strlen호출을 32.508M i/s까지 끌어올려 FFI보다 2배 이상 빠르고 C 확장보다도 약간 빨랐음 - 현재는 ARM64, 단일 인자·단일 반환, 제한된 타입,
--rjit --rjit-disable, 특정 Ruby head 커밋 의존이라는 제약이 있어 실제 사용까지는 구현 확장이 필요함
Ruby에서 FFI가 느려지는 지점
- 기본 방향은 Ruby 코드를 최대한 많이 쓰고, 꼭 필요한 경우에만 네이티브 코드를 호출하는 것임
- YJIT는 Ruby 코드를 최적화할 수 있지만 C 코드는 최적화하지 못함
- 네이티브 라이브러리가 필요하다면 실제 함수 주변에 얇은 C 확장 래퍼를 두고, 대부분의 작업은 Ruby에서 처리하는 방식이 이상적임
- 이런 단순한 API는 FFI와 잘 맞지만, 기존 FFI는 C 확장과 같은 성능을 내지 못함
strlen 기준 벤치마크
- 비교 대상은 네 가지임
- FFI로 C의
strlen호출 - Ruby 메서드
B.strlen에서x.bytesize간접 호출 strlenRuby Gem으로 만든 C 확장 호출str.bytesize직접 호출
- FFI로 C의
- Ruby 3.5.0dev, ARM64 환경의 결과:
ruby-direct: 39.879M i/s, 25.08 ns/istrlen-cext: 30.661M i/s, 32.61 ns/i, 직접 호출보다 1.30배 느림strlen-ruby: 28.697M i/s, 34.85 ns/i, 직접 호출보다 1.39배 느림strlen-ffi: 15.682M i/s, 63.77 ns/i, 직접 호출보다 2.54배 느림
String#bytesize직접 호출이 가장 빠르고, 추가적인 간접 호출은 오버헤드를 더함ruby-direct와strlen-ruby의 차이는 스택 프레임 push/pop 비용을 보여주며, 이런 오버헤드 제거는 YJIT 같은 JIT 컴파일러가 잘 처리하는 영역임strlen-cext와strlen-ffi의 격차는 FFI로 네이티브 함수를 호출할 때 추가 비용이 크다는 점을 드러냄
FFI 호출을 JIT로 대체하는 방식
attach_function :strlen, [:string], :int호출 시점에는 필요한 정보가 이미 있음- 호출할 함수명:
strlen - 인자 타입: string
- 반환 타입: int
- 호출할 함수명:
- 이 정보를 이용하면 Ruby 값을 네이티브 타입으로 풀고, 외부 함수를 호출한 뒤, 반환값을 다시 Ruby 객체로 감싸는 기계어 코드를 생성할 수 있음
- 필요한 구성 요소는 세 가지임
- AArch64 gem: ARM64 기계어 생성
- Fisk gem: x86_64 기계어 생성
- JITBuffer gem: 실행 가능한 메모리 할당
- 기계어 생성만으로는 충분하지 않고, Ruby가 해당 기계어로 점프할 수 있어야 FFI 오버헤드를 건너뛸 수 있음
RJIT를 활용하는 경로
- RJIT는 Ruby로 작성된 Ruby용 JIT 컴파일러이며 Ruby와 함께 배포됨
- 내부 구조는 YJIT와 비슷하지만, 프로덕션 사용을 목표로 한 것은 아니어서 YJIT만큼 널리 알려지지는 않음
- Kokubun은 RJIT를 Gem으로 추출하는 기능 요청을 제출함
- 이 제안은 서드파티 Ruby JIT 컴파일러를 쉽게 만들 수 있도록 두 가지 기반을 제공함
- RJIT를 Gem으로 분리함
- Ruby 내부 타입을 Ruby 데이터 구조로 생성해, 서드파티 JIT가 Ruby 데이터 타입을 감싸고 푸는 데 필요한 정보를 얻을 수 있게 함
- 또 다른 변화는 JIT 엔트리 함수 포인터가 있으면 항상 실행하도록 하는 점임
- 서드파티 JIT가 기계어를 등록하면 Ruby가 자동으로 그 코드로 점프할 수 있음
- 이 두 요소가 있으면 FFI 인터페이스 역할을 하는 작고 단일 목적의 JIT 컴파일러를 만들 수 있음
FJIT 개념 증명
- FJIT 개념 증명은 “FFI JIT”의 약자로, 런타임에 외부 함수를 호출하는 기계어를 생성함
- 예제는 FFI와 비슷한 인터페이스로
strlen을 붙임module Cextend FJITattach_function :strlen, [:string], :int
attach_function이 호출되면 FJIT는 Ruby 문자열을 풀고, C의strlen을 호출하고, 문자열 길이를 Ruby 객체로 반환하는 기계어를 생성함
FJIT 벤치마크 결과
- Ruby 3.5.0dev,
+RJIT +PRISM, ARM64 환경의 결과:ruby-direct: 41.907M i/s, 23.86 ns/istrlen-fjit: 32.508M i/s, 30.76 ns/i, 직접 호출보다 1.29배 느림strlen-cext: 29.778M i/s, 33.58 ns/i, 직접 호출보다 1.41배 느림strlen-ruby: 28.851M i/s, 34.66 ns/i, 직접 호출보다 1.45배 느림strlen-ffi: 15.629M i/s, 63.98 ns/i, 직접 호출보다 2.68배 느림
String#bytesize직접 호출은 여전히 가장 빠름- FJIT가 생성한 기계어는 두 번째로 빨랐고,
strlenC 확장보다 약간 나은 결과를 냄 - FJIT는 FFI 호출보다 2배 이상 빠르며, Ruby 간접 호출보다도 빠름
- 이 결과는 C 확장과 같거나 더 나은 속도를 얻으면서도 “Ruby를 최대한 많이 작성한다”는 방식을 유지할 수 있음을 시사함
실제 적용 전 남은 제약
- 개념 증명 JIT 컴파일러는 현재 ARM64 플랫폼으로 제한됨
- 실제 구현으로 확장하려면 x86_64 백엔드를 추가해야 함
- 모든 인자 타입과 반환 타입을 아직 처리하지 않음
- 모든 인자 타입 지원은 가능하며 작업량이 과도하지는 않을 것으로 봄
- 현재는 단일 인자를 받고 단일 값을 반환하는 함수만 처리함
- 지금은 Ruby를
--rjit --rjit-disable플래그로 실행해야 함- Kokubun의 기능이 들어가면 이 조건은 필요 없어질 것으로 예상함
- 개념 증명은 당시의 current Ruby head에서만 실행됨
- 업데이트 기준으로 Ruby head에서는 RJIT가 제거되었기 때문에, 스크립트를 실행하려면 Ruby를
f32d5071b7b01f258eb45cf533496d82d5c0f6a1커밋으로 체크아웃해야 함
댓글과 토론
Hacker News 의견들
-
Java 제약 해결기 Timefold가 CPython에 정의된 함수를 호출하게 하려고 FFI를 많이 다뤘는데, 성능 문제 대부분은 호스트 언어와 외부 언어 사이를 프록시로 오갈 때 생겼음
JNI나 새 외부 인터페이스로 직접 FFI 호출을 하면 Java 메서드 직접 호출과 비슷할 정도로 빠르지만, CPython과 Java의 가비지 컬렉터가 서로 잘 맞지 않아 동기화에 흑마술이 필요함
반면 JPype나 GraalPy 같은 프록시는 매개변수와 반환값 변환이 필요하고, 반대 방향 FFI 호출까지 추가로 일으킬 수 있음. CPython 객체를 Java에 넘기면 Java가 그 객체의 프록시를 만들고, 그 프록시를 다시 CPython에 넘기면 풀어내지 않고 프록시의 프록시를 만들어버림
결과적으로 JPype 프록시는 CPython을 FFI로 직접 호출하는 것보다 1402% 느렸고, GraalPy 프록시는 453% 느렸음
결국 CPython 바이트코드를 Java 바이트코드로 번역하고, 사용된 CPython 클래스에 대응하는 Java 자료구조를 생성해서 프록시 대비 100배 빨라졌음. 덧붙이면 CPython 바이트코드는 매우 불안정하고 문서화가 빈약하며 VM 특성도 까다로워 다른 바이트코드로 직접 매핑하기 어렵기 때문에 번역하거나 읽으려 들지 않는 편이 좋음
자세한 내용은 글에 정리해 둠: https://timefold.ai/blog/java-vs-python-speed- 경험 없이 말하자면 Python과 Java가 각각 C로 가는 FFI는 훨씬 나아 보이는데, 작은 C 브리지로 둘을 연결하는 게 일반해법이 되지 않을까 싶음
- cgo is not Go도 참고할 만함
Go 코드와 C 코드는 주소 공간, 시그널 핸들러, 스레드 TLS 슬롯 같은 자원을 어떻게 공유할지 맞춰야 하는데, 실제로는 Go가 C 코드의 가정을 우회해야 한다는 뜻에 가까움. C 코드는 한 스레드에서만 돈다고 가정하거나, 다중 스레드 환경에 전혀 대비하지 않을 수도 있음
C는 Go의 호출 규약이나 늘어나는 스택을 모르기 때문에 C 코드를 호출하려면 고루틴 스택 세부 정보를 기록하고 C 스택으로 전환한 뒤, 자신이 어떻게 호출됐는지나 전체 Go 런타임을 모르는 C 코드를 실행해야 함
Python, JNI를 쓰는 Java, libffi를 쓰는 언어, cgo를 쓰는 Go 중 무엇으로 바인딩하거나 C 코드를 감싸든 결국 C의 세계에서 살아가는 셈임
https://dave.cheney.net/2016/01/18/cgo-is-not-go / https://archive.vn/GZoMK - 이런 경우에는 큐, 파일, HTTP 같은 프로세스 간 통신 방식이 어디쯤 들어맞을지 궁금함
-
Rails At Scale과 byroot의 글들 덕분에 Ruby 내부 구조와 성능을 깊게 다루는 글을 보기 정말 좋은 시기임. 최근 Ruby와 Rails 개선까지 보면 Ruby 개발자로 지내기에도 꽤 좋은 때임
- 정말 그런가 싶음. Ruby는 하락세로 보임 https://www.tiobe.com/tiobe-index/ruby/
특정 종류의 애플리케이션에서는 여전히 인기 있지만 전성기는 꽤 지난 듯하고, 최근 개선은 좋지만 2025년에 JIT이 기술적으로 그렇게 흥미로운지는 모르겠음
- 정말 그런가 싶음. Ruby는 하락세로 보임 https://www.tiobe.com/tiobe-index/ruby/
-
“서드파티 라이브러리를 호출하는 대신 외부 함수를 호출하는 데 필요한 코드를 그냥 JIT할 수 있지 않을까?”라는 접근은 LuaJIT FFI의 기반에 가까워 보임: https://luajit.org/ext_ffi.html
그래서 LuaJIT의 FFI가 매우 빠른 것 같음 -
“가능한 한 Ruby로 많이 작성하라. 특히 YJIT은 Ruby 코드는 최적화할 수 있지만 C 코드는 못 한다”는 말이 잘 이해되지 않음
Ruby는 꽤 느린 언어 아닌가? 네이티브로 내려간다면 가능한 한 많은 부분을 네이티브 코드로 하고 싶을 듯함- Java가 제대로 된 JIT을 갖추던 시기에 이런 작은 드라마가 있었음
한 주요 릴리스에서 어떤 UI 요소 동작을 처리하는 Java 코드가 병목으로 발견되어 다음 주요 릴리스에서 C로 다시 작성됐음
이후 JIT이 제대로 유용해지자, FFI 오버헤드가 손으로 튜닝한 C 코드와 JIT이 뽑아내는 코드의 차이보다 더 커졌고, 다음 주요 릴리스에서 다시 순수 Java 구현으로 되돌아감
그 세대 언어 기준 Java의 FFI는 꽤 빠른 편이었지만 몇 릴리스 뒤 더 나은 방식으로 바뀌었고, 그때쯤에는 Java UI 코드를 많이 안 해서 더는 따라가지 않았음. 같은 시기에 플랫폼별 코드와 일반 Java UI 코드 사이 인터페이스도 정리하고 있었기 때문에 최종적으로 어떻게 됐는지는 확실치 않음
이런 작업에서는 이런 시소 효과를 계속 주시해야 함. 몇 마일스톤 기다려 손튜닝 작업을 줄이는 게 나은지, 정치적·기술적 이유로 지금 당장 필요한지 따져봐야 함 - 이 지점에서 JIT이 등장함. 이상적으로는 JIT이 코드를 더 좋은 상태로 다시 최적화할 수 있음
한 번 실행하고 끝나는 작업에는 비효율적일 수 있지만, 오래 살아 있는 데스크톱·서버 작업부하에서는 전체 애플리케이션 관점에서 보상이 생김
예를 들어 Dalvik의 JIT은 꽤 약해서 수학 함수는 C로 호출하는 편이 빨랐지만, ART 이후에는 더 이상 그럴 필요가 없어졌고 JIT이 C 호출 비용을 넘어설 수 있었음
https://developer.android.com/reference/android/util/FloatMa... - JIT이나 AOT 컴파일러가 있는 관리형 언어에서는 많은 코드를 그 언어로 작성하는 편이 이상적일 때가 많음. 그래야 C 호출 경계를 넘을 때 불가능한 인라이닝과 다른 최적화가 가능해짐
이런 흐름을 가끔 “셀프 호스팅”이라고 부르며, 브라우저도 원래 C/C++로 썼을 법한 부분을 권한 있는 JavaScript로 옮기는 식으로 많이 활용함. 표준 라이브러리의 상당히 큰 부분이 의외로 네이티브 코드가 아님 - FFI는 코드 사이에 불투명하고 최적화할 수 없는 경계를 만든다. 이런 식으로 자주 왕복하는 코드는 비용이 큼
C#처럼 훨씬 빠르고 상호운용 비용이 거의 0에 가까운 언어에서도 여전히 호출 비용이 있고, 때로는 VM의 상태 플래그 변경이나 가비지 컬렉션 전환 비용까지 치름
Ruby YJIT이 측정 가능한 요인이 되기 시작했다면, 위 규칙도 점점 더 중요해질 것임 - Ruby의
each메서드가 Ruby로 다시 작성된 이유를 JPCamara가 훌륭하게 풀어 쓴 글이 있음: https://jpcamara.com/2024/12/01/speeding-up-ruby.html / https://bugs.ruby-lang.org/issues/20182
tender love의 보너스 글도 있음: https://railsatscale.com/2023-08-29-ruby-outperforms-c/
요약하면 JIT이 이긴다는 것임
- Java가 제대로 된 JIT을 갖추던 시기에 이런 작은 드라마가 있었음
-
FFI는 Foreign Function Interface, 즉 Ruby에서 C를 호출하는 방법임
- 완전히 안전하고 제정신인 접근은 실행 시 명령줄로 데이터를 넘겨받고 결과를 명령줄이나 메모리 페이지에 토해내는 C 코드를 작성하는 것임
그다음 Ruby에서 터미널로 그 C 프로그램을 플래그나 데이터와 함께 실행하면 Ruby가 C 코드를 실행할 수 있음
- 완전히 안전하고 제정신인 접근은 실행 시 명령줄로 데이터를 넘겨받고 결과를 명령줄이나 메모리 페이지에 토해내는 C 코드를 작성하는 것임
-
왜 이걸 JIT 컴파일해야 하는지 모르겠음. C로 작성할 수 있다면 로드 시점에 그냥 컴파일할 수도 있지 않나?
- 무엇을 C로 작성할 수 있다는 뜻인지 모르겠음. FFI 라이브러리는 네이티브 확장을 작성하지 않고도 Ruby에서 라이브러리 메서드를 동적으로 바인딩해 실행하게 해줌
이는 생산성을 크게 높이고, 같은 코드를 CRuby, JRuby, TruffleRuby에서 공유할 수 있게 함
부팅 시 모든 바인딩을 정적으로 알아낼 수 있다면 스텁을 작성해 메서드 테이블에 넣을 수는 있겠지만, 그래도 런타임에 일어나는 일이므로 JIT에 해당함. 그리고 시스템을 흐르는 타입에 적응할 수 없으니 받아들이는 값이나 최적화에서 보수적으로 갈 수밖에 없고, 그게 오늘날 libffi가 하는 일에 가까움
AOT 접근은 네이티브 확장을 작성하는 것임
- 무엇을 C로 작성할 수 있다는 뜻인지 모르겠음. FFI 라이브러리는 네이티브 확장을 작성하지 않고도 Ruby에서 라이브러리 메서드를 동적으로 바인딩해 실행하게 해줌
-
곁가지지만, 보통 FFI 자체보다는 FFI를 쓰는 gem을 피하게 됨. 컴파일이 너무 번거로울 때가 많아서 Rubygems/bundler 중간 단계를 빼고 직접 빌드하는 편이 더 쉬웠음
-
약간 관련해서, 이 라이브러리는 JVMCI를 사용해 JNI 없이 네이티브 라이브러리를 호출할 arm64/amd64 코드를 즉석에서 생성함: https://github.com/apangin/nalim
-
이게 정확히 libffi가 하는 일 아닌가?
- libffi는 느리고, 내가 알기로는 JIT을 하지 않음
libffi에서는 함수에 대한 디스크립터 객체를 만들고, 이 런타임 자료구조가 인자와 반환값 타입을 나타냄
FFI 호출 시 넘기려는 값들의 포인터 배열과 디스크립터를 전달해야 함. 내부에서는 아마 값 배열과 디스크립터를 함께 순회하면서 타입에 맞게 값을 스택에 배치하고, 함수가 끝나면 반환 타입에 따라 값을 꺼낼 것임. 이 과정 곳곳에서 타입별 분기가 들어갈 가능성이 큼
설령 libffi 호출 메커니즘이 JIT된다 해도, 인자 배열을 준비하는 작업은 여전히 느림. 중간 배열을 거치지 않고 인자에 직접 접근하는 FFI JIT보다 덜 직접적임
FFI JIT 코드는 인자 값을 바로 받아 Ruby 타입에서 C 타입으로 변환하고, 각 값마다 인라인 코드로 스택이나 레지스터의 올바른 위치에 넣은 뒤 함수를 호출하고 반환값을 Ruby 타입으로 바꿈. 사실상 손으로 확장 코드를 쓴 것과 비슷함
타입 추론이 있으면 변환 코드에서 타입 검사를 건너뛸 수 있음. 예를 들어arg1이 Ruby 문자열이라는 보장이 있으면 더 빠른 unsafe 버전의 변환 함수를 쓸 수 있음
JIT 코드는 최악의 경우 Ruby 타입 정도만 반영하면 되고, 인자 관련 배열이나 목록이 필요 없음. 어떤 C 타입으로 변환되는지 코드에 하드코딩되어 있으므로 런타임에 C 쪽을 설명하는 자료구조를 순회할 필요가 없음 - libffi는 Ruby가 무엇인지 모르기 때문에 Ruby 타입을 언박싱하는 법을 알 수 없음
이 글의 장점은 사용자가attach_function을 호출할 때 넘긴 정보를 바탕으로 타입 언박싱 코드가 생성된 기계어 코드 안에 사실상 캐시된다는 데 있음 - libffi는 FFI 호출을 JIT하지 않고, 여전히 인자 값을 직접 배치해야 함. 예를 들어 문자열 인자라면 Ruby 문자열 객체를 C 문자열 포인터로 변환하는 코드를 별도로 써야 함. 그리고 libffi는 꽤 느림
형제 댓글에 링크된tramp.c는 “역방향 FFI”, 즉 동적 사용자 작업을 함수 포인터로 노출하는 용도이고, 거기서의 JIT은 미리 컴파일된 코드를 호출하기 위한 총 3개 명령어 수준임 - libffi가 뭘 하는지 안다고 생각했는데, GOT 같은 걸로 장난치는 줄 알았음. 그런데 네 말이 맞는 것 같음
https://github.com/libffi/libffi/blob/master/src/tramp.c
- libffi는 느리고, 내가 알기로는 JIT을 하지 않음