메모리 안전성을 위한 호출 규약 최적화: Fil-C 심층 분석
시스템 프로그래밍에서 메모리 안전성은 종종 막대한 성능 저하를 수반합니다. 함수 포인터를 잘못된 시그니처로 캐스팅하거나 va_list를 오용하는 것과 같이 적대적인 방식으로 동작하는 프로그램의 경우, 안전성을 보장하기 위해서는 일반적으로 철저한 런타임 체크가 필요합니다. Filip Pizlo의 프로젝트인 Fil-C는 일반적인 경우의 효율성을 희생하지 않으면서, 타입 위반을 패닉(panic)으로 포착하거나 안전한 동작을 부여함으로써 이 문제를 해결하는 것을 목표로 합니다.
이를 달성하기 위해 Fil-C는 함수 호출에 대해 계층적 접근 방식을 채택합니다: 폴백(fallback) 역할을 하는 기본적이고 안전한 방식의 규약과, 컴파일러가 호출이 안전함을 증명할 수 있을 때 체크를 건너뛸 수 있도록 하는 일련의 공격적인 최적화 방식입니다.
일반적인 호출 규약: 안전성 기준선
최적화하기 전에, Fil-C는 함수가 어떻게 호출되든 관계없이 안전성을 보장하는 일반적인 호출 규약을 정의합니다. 이 과정은 엄격합니다:
- Resolution: 직접 호출은 심볼 이름을 flight pointer(capability pointer와 정수 값의 튜플)로 해결하는 "getter call"로 낮아집니다.
- Verification: 시스템은 capability가 null이 아닌지, 구체적으로 함수 capability인지, 그리고 포인터의 정수 값이 capability의 callable value와 일치하는지 확인합니다.
- Buffering: 인자는 8바이트 단위로 정렬되며, 두 개의 스레드 로컬 Calling Convention (CC) 버퍼가 할당됩니다—하나는 페이로드용, 다른 하나는 capability용입니다.
- Transfer: 제어권이 피호출자(callee)에게 전달되며, 피호출자는
byref파라미터를 힙에 할당하고 CC 버퍼에서 로컬 데이터 흐름으로 인자를 복사합니다. - Return: 반환 과정은 인자 전달 방식과 유사하며, CC 버퍼를 사용하여 결과를 호출자(caller)에게 다시 전달합니다.
이 과정은 견고하지만 비효율적입니다. 레지스터를 전혀 사용하지 않고, 스레드 로컬 버퍼에 대한 지속적인 메모리 액세스와 모든 호출에 대한 다중 계층의 간접 참조를 요구하기 때문입니다.
산술 시그니처 인코딩을 통한 레지스터 최적화
CC 버퍼의 오버헤드를 제거하기 위해, Fil-C는 레지스터 기반 호출 규약을 도입합니다. 여기서 핵심 혁신은 **산술 인코딩(arithmetic encoding)**을 사용하여 함수 시그니처를 64비트 정수로 표현하는 것입니다.
산술 인코딩의 작동 방식
Fil-C는 시그니처(최대 16개의 인자와 2개의 반환 값)를 단일 int64로 인코딩합니다. 타입에 숫자 값을 할당함으로써(예: int = 0, double = 2, pointer = 7), 시그니처의 완벽한 해시를 생성합니다. 예를 들어, char* (*)(int, char*, double) 시그니처는 60125로 인코딩됩니다.
Fast Path와 Thunks
Fil-C의 모든 함수 객체는 signature 필드와 두 개의 진입점(entry point)을 포함합니다: fast_entrypoint (네이티브 레지스터 기반)와 generic_entrypoint (버퍼 기반).
호출이 이루어질 때, 호출자는 피호출자의 시그니처가 예상된 인코딩과 일치하는지 확인합니다. 일치한다면, 호출자는 레지스터를 통해 인자를 전달하며 fast_entrypoint로 직접 점프합니다. 시그니처가 다를 경우, 시스템은 한 쌍의 thunks를 사용합니다:
- Caller Entrypoint Thunk: 레지스터 기반 호출을 일반적인 버퍼 기반 규약으로 변환합니다.
- Callee Entrypoint Thunk: 일반적인 버퍼 기반 호출을 레지스터 기반 호출로 변환합니다.
이 thunks는 LLVM IR에서 linkonce_odr로 생성되어, 링커가 모듈 전체에서 단 하나의 복사본만 유지하도록 보합니다합니다. 이 메커니즘을 말미암아 Fil-C는 (일반적인 경로를 통해) 안전성을 유지하면서도 PizBench9019 벤치마크에서 1% 이상의 속도 향상을 달성할 수 있습니다.
직접 호출자 해결(Direct Caller Resolution) 제거
레지스터 전달 방식에서도 직접 호출은 여전히 getter call과 capability 체크를 요구합니다. Fil-C는 ELF 심볼 맹글링(mangling)을 활용하여 이를 더욱 최적화합니다.
시그니처 맹글링된 구현체
Getter를 호출하는 대신, 컴파일러는 구현체 자체를 위한 ELF 심볼을 내보내며, 시그니처를 포함하도록 맹글링합니다(예: pizlonatedFI60125_foo). 호출자와 피호출자가 시그니처에 동의한다면, 호출은 getter, capability 체크, 시그니처 체크를를 모두 건너뛰고 구현체로의 직접 점프가 됩니다.
Weak Symbols를 통한 예외 상황 처리
이 최적화은 ELF 로딩과 C++ 인라인 함수와 관련된 복잡성을 야기합니다. thunk가 자기 자신을 호출하는 무한 루프를 방지하기 위해, Fil-C는 호출 지점 thunk에 hidden visibility를 사용하고, 구현체와 별칭(alias)을 구분하기 위해 특정 명명 규칙(pizlonatedFIP vs pizlonatedFI)을 사용합니다.
COMDAT 그룹 내에서 종종 weak definition으로 나타나는 C++ 인라인 함수의 경우, 링커가 호출자가 참조하려는 구현체를 누락시킬 수 있습니다. Fil-C는 이를 다음과 같이 해결합니다:
- LLVM을 수정하여 로컬하게 정의된 COMDAT 심볼이 NULL일 수 있음을 인정합니다.
- 이러한 심볼에 대한 직접 호출 시 NULL 체크를 수행하도록 합니다.
이를 통해 COMDAT resolution이 발생하여 함수가 누락될 경우, 런타임 크래시가 발생하는 대신 링크 타임에 에러를 포착할 수 있습니다.
성능 향상 요약
일반적인 버퍼 기반 접근 방식에서 직접 호출 레지스터 기반 접근 방식으로 전환함으로써, Fil-C는 함수 호출의 일반적인 경우에서 거의 모든 오버헤드를 제거합니다. 전환 과정은 다음과 같습니다:
| Feature | Generic Convention | Optimized Convention |
|---|---|---|
| Argument Passing | Thread-local buffers | CPU Registers |
| Resolution | Getter call | Direct jump to mangled symbol |
| Safety Checks | Full capability & size check | Single signature match or NULL check |
| Return Values | Buffer-based | CPU Registers |
이러한 최적화들을 결합하면, 엄격한 메모리 안전성 보장을 유지하면서도 상당한 성능 향상을 제공하며, 이는 고수준의 안전성이 반드시 고수준의 성능 저하를 수반하지 않는다는 것을 증명합니다.