libffi, JIT 없이 성능 6배 향상 비결은?
libffi의 근본적 한계인 런타임 함수 호출 오버헤드를 극복할 새로운 접근법 제시
'플랜(Plan)' 개념 도입으로 함수 시그니처별 인자 배치 정보를 미리 컴파일하여 재계산 방지
GNOME Shell 사례에서 90% 이상이 최적화 가능한 GP64 호출 패턴임을 확인
직접 호출 대비 2.7배, 기존 ffi_call 대비 6배 성능 향상 달성
libffi의 '플랜(Plan)' 기반 최적화 메커니즘
기존 libffi는 매번 함수 호출 시마다 인자 배치 정보를 재계산하여 상당한 오버헤드를 발생시켰습니다. 제안된 '플랜(Plan)'은 이 정보를 시그니처별로 한 번만 컴파일하여 저장하고, 이후 호출 시에는 이 미리 컴파일된 이동(Move) 명령어 목록을 실행하는 방식입니다. 이는 마치 바이트코드 VM이 인터프리터 방식으로 동작하는 것과 유사하지만, 런타임 코드 생성 없이 순수 데이터 구조를 활용한다는 점에서 차별화됩니다. 특히 GP64와 같이 단순한 인자 전달 패턴에서는 핸드메이드 যথাযথ(Thunk)를 통해 메모리 복사 과정까지 생략하여 성능을 극대화합니다.
JIT 컴파일과의 비교 및 libffi의 선택
일반적으로 성능 향상을 위해 JIT(Just-In-Time) 컴파일이 선호되지만, 이는 쓰기 가능하고 실행 가능한 메모리 영역(W+X Memory)을 생성해야 하는 보안상의 부담이 있습니다. libffi는 이러한 보안 위험을 피하기 위해 의도적으로 인터프리터 방식을 유지하며, '플랜'은 이러한 제약 조건 하에서 최대한의 성능을 끌어내는 방안으로 제시됩니다. 즉, JIT의 속도와 libffi의 보안성을 절충하는 지점을 찾은 것입니다. 직접 호출 대비 2.7배, 기존 ffi_call 대비 6배의 성능 향상은 이러한 설계 철학의 결과입니다.
GNOME Shell에서의 실제 성능 검증
GNOME Shell 환경에서 libffi 호출 패턴을 분석한 결과, 약 90%의 호출이 GP64 패턴에 해당하여 '플랜'의 thunk 최적화 경로를 탈 수 있었습니다. 또한, by-value struct 인자를 사용하는 경우는 거의 발견되지 않았습니다. 이는 '플랜' 방식이 실제 사용되는 시나리오에서 반복적으로 호출되는 소수의 시그니처에 대해 한 번의 플랜 빌드 비용으로 지속적인 성능 이점을 제공할 수 있음을 시사합니다. GObject Introspection과 같이 시그니처별 cif를 캐싱하는 바인딩에서는 플랜을 함께 캐싱하여 효율성을 높일 수 있습니다.
API의 이식성과 향후 과제
새로운 '플랜' API는 옵트인(Opt-in) 방식으로 제공되며, 불변(Immutable) 객체이므로 스레드 간 공유 시 락(Lock)이 필요 없습니다. x86-64 아키텍처에서는 성능 향상을 제공하지만, 다른 ABI(Application Binary Interface)에서의 성능 향상 폭은 불확실합니다. 이는 각 ABI별로 per-call classification에 드는 비용이 다르기 때문입니다. 향후 다양한 ABI에 대한 최적화 가능성 탐색과 추가적인 테스트가 필요할 것으로 보입니다.