C 생성에 대한 생각
Source: Hacker News
번역할 전체 텍스트를 제공해 주시면, 요청하신 대로 한국어로 번역해 드리겠습니다.
정적 인라인 함수가 데이터 추상화를 가능하게 함
C를 배울 때, GStreamer 초창기(아, 아직도 같은 웹 페이지를 가지고 있군요!)에서는 전처리기 매크로를 많이 사용했습니다.
시간이 지나면서 많은 매크로 사용이 인라인 함수로 대체되어야 한다는 이야기를 듣게 되었습니다; 매크로는 토큰 붙이기와 이름 생성에 쓰이고, 데이터 접근이나 다른 구현 로직에는 적합하지 않다는 것이죠.
제가 훨씬 나중에 깨달은 점은 always‑inline 함수는 데이터 추상화에 따른 성능 패널티를 완전히 없앤다는 것이었습니다.
예시
Wastrel에서는 WebAssembly 메모리의 제한된 범위를 memory 구조체로 표현하고, 다른 구조체에서 그 메모리에 접근하도록 정의했습니다:
struct memory {
uintptr_t base;
uint64_t size;
};
struct access {
uint32_t addr;
uint32_t len;
};
그 메모리에 대한 쓰기 가능한 포인터가 필요하면 다음과 같이 작성할 수 있습니다:
#define static_inline \
static inline __attribute__((always_inline))
static_inline void *write_ptr(struct memory m, struct access a) {
BOUNDS_CHECK(m, a);
char *base = __builtin_assume_aligned((char *)m.base, 4096);
return (void *)(base + a.addr);
}
BOUNDS_CHECK는 보통 생략됩니다; 메모리는 적절한 크기의PROT_NONE영역에 매핑됩니다.BOUNDS_CHECK를 매크로로 구현한 이유는, 체크가 실패해 프로세스가 종료될 경우__FILE__과__LINE__을 삽입할 수 있게 하기 위해서입니다.
명시적인 경계 검사가 활성화되든 아니든, static_inline 속성 덕분에 추상화 비용이 완전히 사라집니다. 경계 검사가 생략될 경우, 메모리의 size와 접근의 len은 전혀 할당되지 않습니다.
write_ptr가 static_inline이 아니었다면, 이러한 구조체 값들이 메모리를 통해 전달될까 걱정했을 것입니다. 이는 주로 구조체를 값으로 반환하는 함수와 관련된 문제인데, 예를 들어 AArch64에서는 struct memory를 반환할 때 인자를 받는 void (*)(struct memory) 호출과 같은 레지스터를 사용하지만, SysV x86‑64 ABI에서는 반환값에 두 개의 일반 레지스터만 할당합니다. 저는 이런 병목을 생각하고 싶지 않으며, static inline 함수가 그 문제를 자동으로 해결해 줍니다.
암시적 정수 변환 피하기
C는 기본 정수 변환 규칙이 특이해서 (예: uint8_t를 int로 승격) 부호 있는 정수에 대한 경계 조건도 이상합니다. C 코드를 생성할 때는 이러한 규칙을 우회하고 명시적으로 작성하는 것이 더 안전합니다:
u8_to_u32,s16_to_s32등과 같은 static‑inline 캐스트 헬퍼를 정의합니다.-Wconversion옵션을 켜서 실수로 발생하는 암시적 변환을 잡아냅니다.
static‑inline 캐스트 함수를 사용하면 생성된 코드가 피연산자가 특정 타입임을 단언할 수 있습니다. 이상적으로는 모든 캐스트가 헬퍼 함수에 존재하고, 생성된 코드 자체에는 캐스트가 나타나지 않아야 합니다.
Wrap Raw Pointers and Integers with Intent
Whippet은 C 로 작성된 가비지 컬렉터입니다. GC는 모든 데이터 추상화와 맞물려 있습니다: 객체는 절대 주소, 페이지된 공간 내 범위, 정렬된 영역 시작점으로부터의 오프셋 등으로 볼 수 있습니다. 이러한 개념들을 순수 size_t 혹은 uintptr_t 로만 표현하면 금세 악몽이 됩니다.
Whippet은 따라서 단일 멤버 구조체를 도입해 각 개념에 고유한 타입을 부여합니다:
/* api/gc-ref.h */
struct gc_ref {
uintptr_t addr;
};
/* api/gc-edge.h */
struct gc_edge {
uintptr_t edge_addr;
};
이 구조체들은 실수로 잘못 사용되는 것을 방지합니다—gc_edge_address는 절대 struct gc_ref 로 호출되지 않으며, 그 반대도 마찬가지입니다.
왜 이것이 컴파일러에 도움이 되는가
컴파일러가 어떤 항의 정확한 타입을 알게 되면, 잔여 C 코드에서 많은 실수를 피할 수 있습니다.
WebAssembly의 struct.set 연산을 컴파일한다고 가정해 봅시다. 텍스트 의미는 다음과 같습니다:
“Assert: Due to validation, val is some
ref.structstructaddr.”
이 단언을 C 로 옮기기 위해 포인터 서브타입의 숲을 구축합니다:
typedef struct anyref { uintptr_t value; } anyref;
typedef struct eqref { anyref p; } eqref;
typedef struct i31ref { eqref p; } i31ref;
typedef struct arrayref { eqref p; } arrayref;
typedef struct structref{ eqref p; } structref;
구체적인 타입, 예를 들어 (type $type_0 (struct (mut f64))) 에 대해서는 다음과 같이 생성합니다:
typedef struct type_0ref { structref p; } type_0ref;
$type_0 의 필드 설정 함수는 이제 type_0ref 를 매개변수로 받습니다:
static inline void
type_0_set_field_0(type_0ref obj, double val) {
/* ... */
}
따라서 소스 레벨의 타입 정보가 목표 언어까지 완전히 전파됩니다.
실제 객체 표현에 대해서도 비슷한 타입 숲이 존재합니다:
typedef struct wasm_any { uintptr_t type_tag; } wasm_any;
typedef struct wasm_struct{ wasm_any p; } wasm_struct;
typedef struct type_0 { wasm_struct p; double field_0; } type_0;
필요에 따라 type_0ref 와 type_0 * 사이를 오가는 작은 캐스트 루틴을 생성합니다. 모든 루틴이 static inline 이기 때문에 런타임 오버헤드가 전혀 없으며, 포인터 서브타이핑을 무료로 얻을 수 있습니다.
이러한 패턴 덕분에 저는 컴파일러에서 신뢰할 수 있고 고성능인 C 코드를 얻을 수 있었습니다. 공식적인 “베스트 프랙티스”라기보다는 제게 맞는 방법일 뿐이며, 여러분도 자유롭게 채택하셔도 됩니다.
struct.set $type_0 0
The instruction is passed a subtype of `$type_0`; the compiler can generate an up‑cast that type‑checks.
memcpy를 두려워하지 마세요
WebAssembly에서는 선형 메모리 접근이 반드시 정렬되지 않을 수도 있기 때문에, 주소를 (예를 들어) int32_t* 로 캐스팅하고 바로 역참조할 수 없습니다.
대신 이렇게 합니다:
memcpy(&i32, addr, sizeof(int32_t));
그리고 컴파일러가 가능하면 비정렬 로드를 생성하도록 믿습니다(가능합니다). 여기서 더 이상 설명할 필요가 없습니다!
ABI와 Tail Call을 위해 수동 레지스터 할당을 수행하세요
GCC는 마침내 __attribute__((musttail)) 를 지원합니다: 정말 다행이죠. 하지만 WebAssembly로 컴파일할 때는 30개의 인자 혹은 30개의 반환값을 갖는 함수가 생길 수 있습니다. 저는 C 컴파일러가 그런 함수와의 tail call 시 스택‑인자 요구사항을 안정적으로 섞어 주는 것을 신뢰하지 못합니다. musttail 의무를 만족하지 못하면 파일 자체를 컴파일조차 거부할 수 있기 때문입니다—목표 언어에 바람직한 특성이 아닙니다.
실제로 원하는 것은 모든 함수 매개변수가 레지스터에 할당되는 것입니다. 예를 들어 처음 n 개 값만 레지스터에 전달하고 나머지는 전역 변수에 저장하도록 하면 됩니다. 스택에 올릴 필요가 없습니다. 호출자는 프로로그에서 전역 변수를 다시 로드해 로컬에 넣을 수 있기 때문입니다.
이 방법의 재미있는 점은 C 로 컴파일할 때 다중 반환값을 깔끔하게 지원한다는 것입니다:
- 프로그램에서 사용되는 함수 타입 집합을 열거합니다.
- 모든 반환값을 저장할 수 있도록 적절한 타입의 전역 변수를 충분히 할당합니다.
.
3. 함수 에필로그에서 첫 번째 반환값을 제외한 “여분” 반환값들을 해당 전역 변수에 저장합니다.
4. 호출자는 호출 직후에 그 값들을 다시 로드합니다.
싫은 점
C 코드를 생성하는 것은 국부 최적입니다:
- GCC 혹은 Clang의 산업 수준 명령 선택 및 레지스터 할당을 그대로 얻을 수 있습니다.
- 많은 피홀 스타일 최적화를 직접 구현할 필요가 없습니다.
- 인라인될 가능성이 있는 C 런타임 루틴에 링크할 수 있습니다.
이 설계 포인트를 약간이라도 개선하기는 어렵습니다.
물론 단점도 있습니다. 스키마 언어 사용자로서 가장 짜증나는 점은 스택을 제어할 수 없다는 것입니다:
- 특정 함수가 얼마나 많은 스택을 필요로 할지 알 수 없습니다.
- 프로그램의 스택을 합리적인 방법으로 확장할 수 없습니다.
- 스택을 순회해 삽입된 포인터들을 정확히 열거할 수 없습니다(아마도 괜찮을 수도 있습니다).
- 스택을 슬라이스해 구분된 연속성을 캡처할 수 없습니다.
다른 큰 불편함은 부가 테이블에 관한 것입니다: 소위 제로‑코스트 예외를 구현하고 싶지만, 컴파일러와 툴체인의 지원이 없으면 불가능합니다.
마지막으로, 소스 레벨 디버깅이 까다롭습니다. 잔여화된 코드에 대응하는 DWARF 정보를 삽입하고 싶지만, C 코드를 생성할 때 이를 어떻게 해야 할지 모릅니다.
왜 Rust가 아니라 Rust냐고요?
제 경험에 비추어 보면, 라이프타임은 프론트엔드 문제입니다; 명시적인 라이프타임을 가진 소스 언어가 있다면 Rust를 생성하는 것을 고려할 것입니다. 그렇게 하면 출력이 입력과 동일한 보장을 갖는지 기계적으로 검증할 수 있기 때문입니다. 마찬가지로 Rust 표준 라이브러리를 사용한다면 말이죠. 하지만 라이프타임이 없는 언어에서 컴파일한다면 Rust를 사용해 얻을 수 있는 것이 무엇인지 잘 모르겠습니다: 암시적 변환이 적다는 장점은 있지만, 꼬리 호출 지원이 덜 성숙하고, 컴파일 시간이 길어지는 등… 결국 큰 차이는 없다고 생각합니다.
아무래도 완벽한 것은 없으며, 눈을 크게 뜨고 접근하는 것이 최선입니다. 여기까지 읽어주셨다면, 이 메모가 여러분의 생성 작업에 도움이 되길 바랍니다. 제 경우, 생성된 C 코드가 타입 체크를 통과하고 나서는 거의 디버깅 없이도 동작했습니다: 디버깅이 거의 필요 없었죠. 해킹이 항상 이렇게 쉬운 건 아니지만, 가능할 때는 그렇게 하고 싶습니다.
다음에 또 만나요, 해킹 즐겁게!