런타임 코드 생성 없이 기존 정보를 재사용하는 libffi의 호출 계획 캐시가 어떻게 호출 오버헤드를 크게 줄이는지 설명합니다.
libffi는 함수 호출 인터프리터입니다. 런타임에 함수 시그니처의 설명을 넘기면, 각 인수를 어디에 배치하고 어떻게 호출할지 그 자리에서 계산합니다. 바이트코드 VM이 명령을 해석하듯 호출 규약을 해석하는 것입니다. 미리 컴파일되는 것은 아무것도 없습니다. 애초에 시그니처를 사전에 알 수 없다는 것이 핵심이기 때문입니다.
속도가 필요할 때 보통 인터프리터를 선택하지는 않습니다. 일반적인 해법은 JIT입니다. 시그니처마다 전용 호출 스텁을 컴파일해, 인수를 레지스터에 넣고 점프하는 네이티브 코드를 만듭니다. 런타임에 해석할 것은 남지 않으니 더 빠릅니다. 하지만 그렇게 하려면 새 기계어 코드를 쓰기 가능하면서 실행 가능한 메모리에 기록해야 하고, 이는 현대 시스템이 바로 없애려 하는 방식입니다.
그래서 libffi는 의도적으로 인터프리터로 남아 있습니다. 제가 답하고 싶었던 질문은, 런타임에 코드를 생성하거나 어떤 페이지도 쓰기 가능하면서 실행 가능하게 매핑하지 않고, 이미 알고 있는 정보를 재사용하는 방식만으로 얼마나 더 빨라질 수 있느냐는 것이었습니다.
libffi를 통해 함수를 호출할 때, 작업은 두 곳으로 나뉩니다. ffi_prep_cif는 시그니처마다 한 번 실행됩니다. 전체를 분류하지만, 결과로는 두 가지만 남깁니다. 호출에 필요한 스택 프레임의 크기와 반환값이 어떤 방식으로 돌아오는지를 나타내는 작은 코드입니다. 프레임 크기는 호출을 만들기 전에 알아야 합니다. 레지스터에 들어가지 못하는 인수는 스택으로 흘러내리고, 그 공간을 미리 확보해야 하기 때문입니다. 반환 코드는 그다음에 필요합니다. 결과는 타입에 따라 rax, xmm0, 혹은 메모리로 돌아오므로, 어디에서 읽어야 할지 아는 무언가가 필요합니다. 둘 다 작고 크기가 고정되어 있어 ffi_cif 안에 들어갑니다. 하지만 prep이 버리는 것은, 실제로 대부분의 시간을 써서 계산한 부분입니다. 각 개별 인수가 어디로 가는지에 대한 정보입니다.
그래서 모든 ffi_call마다 마샬링 코드는 인수 목록을 다시 순회하며, 값을 제자리에 복사하기 전에 그 배치를 처음부터 다시 계산합니다. x86-64에서 인수 세 개짜리 호출이라면 관리용 작업만 대략 650개 명령어가 들고, 매번 완전히 똑같은 답을 만들어 냅니다.
이 명령어들의 대부분은 인수 바이트를 옮기지 않습니다. 바이트가 어디로 가야 하는지를 결정합니다. System V AMD64 ABI는 고정된 절차로 모든 인수를 분류합니다. 단일 인수에 그 절차를 적용하려면 타입을 따라가고, 구조체의 필드를 재귀적으로 내려가며 타입 기술자의 포인터를 추적하고, 각 8바이트 조각을 INTEGER 또는 SSE 레지스터 클래스로 분류하고, 남아 있는 레지스터에 여전히 들어가는지 아니면 스택으로 흘러야 하는지 확인해야 합니다. 이는 분기가 많고 포인터 추적이 많은 작업으로, CPU가 느리게 처리하는 종류이며, 절대 바뀌지 않는 배치를 계산하기 위해 모든 호출에서 다시 실행됩니다.
하지만 함수 인수의 배치는 시그니처만으로 결정되는 순수 함수입니다. 한 번 계산해서 기억해 두고, 이후의 모든 호출에서 그 작업을 건너뛸 수 있습니다.
해결책은 “계획”입니다. 배치를 평평한 이동 목록으로 컴파일한 것으로, 하나의 시그니처를 위한 작은 바이트코드입니다. 매 호출마다 ffi_call이 배치를 다시 계산하는 것이, 프로그램을 실행할 때마다 구문 트리를 다시 순회하며 해석하는 것과 같다면, 계획은 컴파일된 바이트코드입니다. 트리 순회는 한 번만 일어나고, 이후의 모든 호출은 그 평평한 목록만 실행하면 됩니다. build_plan은 인수 타입을 한 번 순회하고, ABI 규칙이 지시하는 대로 각 인수를 분류한 뒤, 조각마다 하나의 이동을 출력합니다. 이 8바이트 워드는 rdi로, 저 32비트 int는 부호 확장되어 rsi로, 이 double은 SSE 슬롯으로, 저렇게 너무 큰 것은 스택으로 흘러갑니다. 계획만 있으면 호출을 만드는 일은 그 이동들을 실행하는 것뿐입니다. 재분류는 없습니다.
opcode는 의도적으로 단순합니다. GP64는 워드를 일반 레지스터로 복사하고, SE8/SE16/SE32는 좁은 int를 부호 확장하며, SSE64/SSE32는 부동소수점을 옮기고, STACK은 스택으로 흘러간 인수를 memcpy합니다. 인수 세 개짜리 호출은 이들 셋이나 넷으로 컴파일됩니다. 실제 시그니처 두 개가 어떻게 바뀌는지 보겠습니다.
long (void *, void *, void *) long (void *, int, void *)
GP64 avalue[0] -> rdi GP64 avalue[0] -> rdi
GP64 avalue[1] -> rsi SE32 avalue[1] -> rsi (부호 확장)
GP64 avalue[2] -> rdx GP64 avalue[2] -> rdx
=> 모두 GP64: thunk => SE32가 있음: 해석
모든 인수가 일반 레지스터에 들어가는 단일 64비트 값이라면, 즉 대부분의 포인터 전달 코드라면, 계획은 인터프리터조차 필요 없습니다. thunk 가능으로 표시되고, .text에 있는 작은 수제 thunk가 인수 배열에서 값을 곧바로 인수 레지스터로 읽어 호출합니다. 이동 루프도, 중간 레지스터 이미지도, 앞뒤 복사도 전부 건너뜁니다. 오른쪽의 호출은 int를 유지하므로 부호 확장이 필요해서, 대신 이동 루프를 실행합니다.
이 이동들을 실행하는 데에는 미묘한 점이 있습니다. 루프는 실제 인수 레지스터를 절대 로드하지 않습니다. C에서는 값을 rdi에 넣고 호출을 가로질러 그대로 유지하는 방법이 없기 때문입니다. 레지스터는 컴파일러의 소유입니다. 그래서 각 이동은 System V 레지스터 파일을 반영하는 평범한 메모리 구조체에 기록됩니다. 여섯 개의 정수 레지스터와 여덟 개의 SSE 레지스터가 순서대로 배치된 구조체입니다. 그 이미지가 완성된 뒤에야 짧은 어셈블리 트램펄린이 그곳에서 모든 인수 레지스터를 한 번에 로드하고 대상 함수로 점프합니다. C 코드는 메모리 안에서 바이트를 옮기고, 레지스터는 호출 직전에 .text에서 최종 값을 한꺼번에 받습니다. 이 트램펄린은 ffi_call이 원래부터 사용하던 바로 그 것이므로, 계획이 바꾸는 것은 레지스터를 어떻게 로드하느냐가 아니라 배치를 언제 계산하느냐입니다.
계획은 평범한 데이터이고, thunk는 다른 함수와 마찬가지로 바이너리의 읽기 전용 텍스트에 들어 있습니다. 어떤 것도 쓰기 가능하면서 실행 가능하지 않습니다. 클로저가 이미 static trampolines를 통해 얻는 것과 같은 성질입니다.
계획은 작고 선택적인 API로 노출됩니다. 준비된 ffi_cif로부터 계획을 만들고, 원하는 만큼 여러 번 호출한 뒤, 다 쓰면 해제합니다.
ffi_call_plan *plan = ffi_call_plan_alloc(&cif); /* 계획을 한 번만 만든다 */
ffi_call_plan_invoke(plan, fn, &rv, av); /* 호출, 호출마다 준비 작업 없음 */
/* ... 다시 호출하고, 또 호출하고 ... */
ffi_call_plan_free(plan);
ffi_call 자체는 건드리지 않습니다. 이미 시그니처마다 ffi_cif를 캐시하는 바인딩이라면, 대부분이 그렇듯, 그 옆에 계획도 캐시하고 ffi_call_plan_invoke를 통해 호출하면 됩니다. 계획은 한 번 만들어지고 나면 불변이므로, 하나의 계획을 락 없이 어떤 스레드에서든 공유하고 호출할 수 있습니다. 빠른 경로가 처리할 수 없는 시그니처도 괜찮습니다. 그런 경우 invoke는 ffi_call로 되돌아갑니다.
이것이 공정한 비교입니다. 하나의 libffi, 같은 함수, 세 가지 방식으로 도달합니다. 그냥 직접 호출하는 경우, 같은 호출을 ffi_call로 하는 경우, 그리고 미리 만든 계획을 통해 하는 경우입니다. 같은 바이너리, 같은 기계(Core Ultra 7 255H), 같은 -O2이므로, 두 FFI 행 사이에서 달라지는 것은 API뿐입니다. 시간을 재는 루프는 그저 이것을 반복하는 것입니다.
ffi_type *at[] = { &ffi_type_pointer, &ffi_type_pointer, &ffi_type_pointer };
ffi_cif cif;
ffi_prep_cif(&cif, FFI_DEFAULT_ABI, 3, &ffi_type_sint64, at);
ffi_call_plan *plan = ffi_call_plan_alloc(&cif); /* 한 번만 생성 */
void *av[] = { &a, &b, &c };
long rv;
ffi_call_plan_invoke(plan, (void(*)(void))fn, &rv, av); /* <-- 우리가 시간을 재는 부분 */
ptr(p,p,p) ns/call 일반 호출 대비
regular function call 1.9 1x
ffi_call_plan_invoke 5.1 2.7x
ffi_call 31.0 16x
그 함수를 ffi_call을 통해 보통 방식으로 호출하면, 직접 호출하는 비용의 약 16배가 듭니다. 미리 만든 계획을 통하면 3배도 되지 않습니다. 계획은 ffi_call보다 약 6배 빠르며, 같은 라이브러리를 두 방식으로 접근한 비교이므로, 그 차이는 오로지 API에서 나옵니다.
계획이 제거하는 대부분은 호출마다 반복되는 재분류입니다. ffi_call은 매번 배치를 다시 만들지만, invoke는 미리 만들어 둔 이동만 실행합니다. 이 형태에서는 계획이 thunk를 사용하므로 레지스터 이미지도 건너뛰고, 일반 호출에 가깝게 도달합니다. 2ns 호출 위에 FFI 오버헤드가 약 3ns 더해지는 정도이며, ffi_call의 29ns와 대비됩니다.
정수와 부동소수점이 섞인 시그니처는 thunk를 사용하지 않습니다. 32비트 int는 부호 확장이 필요하고 double은 SSE 레지스터가 필요하기 때문입니다. 그래서 이동 루프를 실행하고 약간 더 높은 수치에 도달합니다. 그래도 재분류는 건너뜁니다. 값으로 전달되는 구조체 인수는 계획이 없으므로, invoke는 ffi_call로 되돌아가고 비용도 이전과 정확히 같습니다.
한 형태에서의 6배 수치가 의미 있으려면, 실제 프로그램이 그 형태를 사용하고, 또 계획을 한 번 만드는 비용이 충분히 상쇄될 만큼 자주 호출해야 합니다. 그래서 하나를 추적해 봤습니다.
GNOME Shell은 좋은 스트레스 테스트입니다. 데스크톱 UI 전체가 JavaScript에서 C로, GObject Introspection을 거쳐, 다시 libffi를 통해 호출되기 때문입니다. 저는 Whistler로 ffi_call에 eBPF uprobe를 붙이고 잠시 관찰했습니다. 상위 시그니처는 다음과 같았습니다.
21744 int (void *)
19139 void *(void *, unsigned long)
13083 void *(void *)
10116 void (void *, void *, void *, long, void *)
9918 void *(void *, void *)
호출의 약 90%는 순수한 64비트 GP, 즉 포인터와 long이며, 이는 thunk 경로입니다. 10만 회가 넘는 호출 동안 값으로 전달되는 구조체 인수는 단 하나도 나타나지 않았습니다. 그리고 이것들은 같은 소수의 시그니처가 계속 반복 호출되는 형태로, 계획을 한 번 만들고 영원히 호출하는 방식에 정확히 들어맞습니다. GObject Introspection 같은 바인딩은 이미 시그니처마다 ffi_cif를 가지고 있으므로, 그 바로 옆에 계획을 넣으면 됩니다.
이 모든 것은 어떤 릴리스가 아니라 libffi git 트리의 HEAD에 있으며, 기반으로 삼을 만한 것이 되려면 더 많은 테스트가 필요합니다. 가속은 x86-64 전용이지만 API는 이식 가능합니다. 다른 모든 곳에서 ffi_call_plan_invoke는 그저 ffi_call을 호출하므로, 바인딩은 모든 시그니처에 대해 조건 없이 계획을 만들고, 지원되는 곳에서는 가속 경로를 사용하면 됩니다. 바인딩 쪽에서 #ifdef를 둘 필요는 없습니다. 다른 ABI에도 이 빠른 경로를 만드는 것이 가치가 있는지는 분명하지 않습니다. 이득은 호출마다 건너뛸 수 있는 분류 작업의 양에 비례하고, 그것은 호출 규약마다 크게 다르기 때문입니다.
코드는 GitHub에 있습니다: libffi.
Hacker News에서 토론을 볼 수 있습니다.
게시 후 명확성을 위해 수정: 6fed8af.