이 글에서는 저수준 최적화의 개념과 Zig 언어가 왜 최적화에 적합한지, 그리고 Zig의 comptime의 강력함에 대해 설명합니다. 실질적인 코드 예제와 성능 개선 사례를 통해 최적화를 심도있게 살펴봅니다.
"모든 것이 가능하지만, 흥미로운 것은 하나도 쉽지 않은 튜링 타르구덩이을 조심하세요." - 앨런 펄리스, 1982
여러분에게 흥미로운 주제는 무엇인가요? 분명 수많은 것들이 있을 텐데, 제가 꾸준히 흥미를 느끼는 주제 중 하나는 바로 프로그램 최적화입니다. 가장 큰 피보나치 수를 1초 내에 계산하려고 하든, 가장 빠른 금융 거래 DB를 만들고 싶든, rust로 뭔가를 리라이트하려고 하든, 최적화 과정은 보람차다는 걸 느끼셨을 겁니다.
최적화는 느린 코드와 빠른 코드를 갈라놓습니다. 최적화는 옛날의 유산이 아닙니다. 기술의 발전이 프로그램의 형태를 계속 바꾸지만, 최적화의 필요성을 없애지는 못합니다. 잘 최적화된 프로그램은 비용을 절감시키고, 상위 스케일링 기회를 부여하며, 시스템의 단순함을 지켜줍니다. 부실한 코드를 오토스케일링 클라우드에 올리느라 수천 달러를 쓸 건가요, 아니면 조금 더 나은 코드를 작성해서 가격도 낮고 레이턴시도 낮은 중간급 서버 몇 대로 돌릴 건가요?
이 글에서 저는 저수준 최적화의 개념과, 특히 Zig가 왜 그에 딱 맞는 언어인지 설명하려고 합니다. 읽으시고 도움이 되었다면 후원도 고려해 주세요 :)
누군가는 "컴파일러를 믿으세요. 걔가 최고임" 이라고 말할 수 있습니다. 실제로 대부분의 저수준 상황에서는 맞기도 하죠! 요즘 최적화 컴파일러의 수준은 엄청납니다. 시스템 자원 확충이나 IR 변환 기술의 진보, LLVM 같은 백엔드의 등장으로 놀라운 결과물을 보여줍니다.
하지만 컴파일러는 사실 무척 복잡한 짐승입니다. 최고의 최적화 백엔드도 어떤 경우에 비효율적 코드도 생성할 수 있습니다. 심지어 최신 컴파일러조차 언어 스펙을 깨는 (Clang은 부작용 없는 루프는 항상 종료된다고 가정합니다) 언어 스펙을 깹니다. 그러니 최대한 많은 정보를 컴파일러에 제공해야 하고, 정상적으로 동작하는지도 검증해야 합니다. 대부분의 저수준 언어에서는 코드를 이렇게 저렇게 비비다 보면 컴파일러가 특정 최적화 변환이 가능함을 깨닫기도 합니다. 하지만 항상 쉽진 않죠.
저수준 언어가 일반적으로 더 빠른 이유가 뭘까요? 고수준 언어가 GC, 문자열 인터닝, 인터프리팅 등 추가적인 작업을 많이 해서 그런거라 생각할 수도 있는데, 맞는 말이지만 그것만은 아닙니다. 고수준 언어에는 저수준 언어가 풍부하게 가진 한 가지—바로 개발자의 "의도"—가 부족합니다.
저수준 언어의 장황함 덕분에 컴파일러가 코드를 완전히 이해할 수 있게 해줍니다. 예시로, 아래의 JavaScript 코드를 보세요:
function maxArray(x, y) {
for (let i = 0; i < 65536; i++) {
x[i] = y[i] > x[i] ? y[i] : x[i];
}
}
사람이라면 이 코드가 x
의 각 값에 대해 x
와 y
중 더 큰 값을 대입한다는 걸 곧바로 파악하죠. 하지만 V8에서 생성된 바이트코드는 생각보다 상당히 방대합니다. Zig로 작성한다면 아래처럼 바뀝니다:
fn maxArray(
noalias x: *align(64) [65536]f64,
y: *align(64) const [65536]f64,
) void {
for (x, y, 0..) |a, b, i| {
x[i] = if (b > a) b else a;
}
}
더 장황하지만, 컴파일러에 정렬(alignment), 별칭금지(noalias), 고정된 배열 크기 및 타입 등을 모두 전달합니다—모두 컴파일 타임에요. 이런 정보로 훨씬 고급(벡터화까지 된) 코드를 생성합니다. 궁금하다면 Rust로 비슷하게 짜도 거의 동일한 어셈블리를 생성한다는 점도 참고하세요.
그러면 정말로 컴파일러를 "완전" 신뢰할 수 있을까요? 상황에 따라 다릅니다. 성능 병목 구간에서 처리량을 3배 향상시킬 변환을 발굴하고 싶다면, 컴파일러가 수행하는 일을 실제로 확인해야 하고, 내 의도를 더 잘 표현할 수 있는 방법을 찾아야 합니다. 원하는 변환(최적화)이 나오려면 코드를 살짝 바꿔야 할 수도 있습니다. 운이 없다면 컴파일러가 최선의 최적화를 적용해주지 않음을 발견할 수도 있고, 그땐 인라인 어셈블리를 직접 써서 마지막 한 방울까지 짜야 할 수도 있습니다.
고수준 언어는 어떨까요? 특이한 예 빼면 루프 분석 정도가 한계입니다. 즉, 컴파일러는 우리의 알고리즘이나 패러다임까지 바꿀 수 없습니다. 최적화 범위가 꽤 좁습니다.
저는 Zig의 장황함을 아주 좋아합니다. 그 덕분에 대부분의 언어보다 더 쉽게 성능 좋은 코드를 쓸 수 있거든요. Zig의 내장 함수, 옵션이 아닌 포인터, unreachable
키워드, 잘 고른 불법 행위, 그리고 Zig의 뛰어난 comptime 덕에 LLVM은 코드 정보를 받아먹기만 하면 됩니다.
물론, 모든 게 장밋빛인 건 아닙니다. 예를 들어, Rust의 메모리 모델은 함수 인자가 언제나 별칭(aliasing)하지 않는다고 컴파일러가 항상 가정할 수 있도록 해줍니다. Zig에선 직접 명시해야 하죠. Zig 함수가 항상 별칭 없는 인자로 호출된다는 걸 컴파일러가 확신할 수 없으면, Rust 함수가 Zig보다 빠를 수 있습니다.
LLVM IR에 주석(annotation)이 잘 달린 경우만을 최적화 관점의 척도로 삼는다면, Zig는 확실히 강점을 보입니다. 하지만 Zig의 진짜 무기는 컴파일 타임 실행—바로 comptime
에 있습니다.
comptime
이란?Zig의 comptime
은 코드 생성에 관한 기능입니다. 코드에 상수를 쓰고 싶은가요? 컴파일 타임에 만들어서 바이너리에 값을 박아넣을 수 있습니다. 여러 데이터 타입에 대해 일일이 해시맵 구조체를 새로 쓰고 싶지 않다구요? comptime
이 그걸 해줍니다. 컴파일 타임에 이미 알고 있는 데이터가 있고, 그걸로 최적화까지 하고 싶다구요? 네, 가능합니다! Zig의 comptime
은 메타프로그래밍의 한 종류입니다.
그럼 매크로와는 뭐가 다르냐고요? 미묘하긴 하지만 목적은 비슷합니다. 매크로는 코드의 원본 텍스트를 바꾸거나, 프로그램 AST 자체를 바꿔서 타입, 값 등에 따라 특정 코드를 인라인하게 해줍니다. 하지만 Zig에선 comptime
코드가 그냥 컴파일 타임에 실행되는 평범한 코드일 뿐입니다. 네트워크 IO 같은 부작용은 못 쓰며, 에뮬레이트된 머신은 타겟 환경과 동일하게 동작합니다.
Zig의 comptime
이 매크로와 얼추 맞먹는 유연함을 가지는 이유 두 가지: 첫째, Zig 코드의 거의 전부를 comptime
에서 실행할 수 있습니다. 둘째, 컴파일 타임에 모든 타입을 조사, 반영, 생성할 수 있습니다. Zig의 제네릭이 그렇게 구현됐어요.
Zig의 comptime
이 가진 유연성 덕에 다른 언어에서도 비슷한 장점이 생기고 있습니다. 예를 들어 Rust에는 표준 매크로보다 유연한 crabtime 크레이트가 있습니다. 저는 comptime
의 진짜 장점이 자연스럽게 Zig 언어에 녹아든 점에 있다고 생각합니다. C++의 constexpr
처럼 아예 새로운 "미니언어"를 익혀야 하는 게 아니니까요. C++도 발전 중이지만, Zig만큼 직관적이고 강력하려면 시간 좀 더 걸릴 겁니다.
그렇다면 Zig의 comptime
이 매크로의 모든 걸 대체하냐? 아닙니다. 토큰 붙이기(token-pasting) 매크로 같은 건 제공하지 않습니다. Zig의 철학은 읽기 쉬운 코드이기 때문에, 관련 없는 범위(scope)에 변수나 코드를 막 생성/수정하는 매크로는 용납되지 않습니다. 매크로는 다른 매크로를 정의/수정하거나 AST 변경, 작은 언어(DSL) 구현까지 할 수 있지만 comptime
으로는 직접 AST를 바꿀 순 없습니다. 만약 정말 원한다면 Zig로 DSL을 직접 구현해야 합니다. 예로, Zig의 print 함수는 comptime
을 활용해 포맷 문자열을 파싱합니다. 포맷 문자열이 일종의 DSL이죠. 이를 바탕으로 comptime
에서 여러분의 데이터 직렬화를 위한 함수 그래프가 생성됩니다. 더 궁금하다면: TigerBeetle DSL, comath: comptime math, zilliam (기하대수).
Zig comptime
을 배우고 싶다면 참고 자료:
comptime
을 활용한 문자열 비교두 문자열을 어떻게 비교할까요? 거의 모든 언어에 통용되는 패턴이 있습니다:
function stringsAreEqual(a, b) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++)
if (a[i] !== b[i]) return false;
return true;
}
길이가 다르면 당연히 다르고, 바이트 중 딱 하나만 달라도 다르죠. 간단하지만, 생성된 어셈블리에서도 드러나듯 바이트를 하나씩 개별 비교해야 한다는 한계가 있습니다. SIMD 등으로 더 큰 블록씩 비교할 수 있지만, 결국 두 문자열을 블록별로 순차적으로 메모리에서 읽어야 하죠. 하지만 현실적으로 문자열 중 하나(비교 대상이 되는 쪽)는 컴파일 타임에 이미 고정되어 있는 경우가 대부분입니다. 좀 더 나은 최적화가 가능한가요? 네!
fn staticEql(comptime a: []const u8, b: []const u8) bool {
if (a.len != b.len) return false;
for (0..a.len) |idx| {
if (a[idx] != b[idx]) return false;
}
return true;
}
여기선 한쪽 문자열이 반드시 컴파일 타임에 알려진 상수여야 합니다. 그 덕분에 개선된 코드 생성이 가능합니다.
(중략: 어셈블리 코드는 번역문에서 생략)
좋긴 한데, 기대한 것보단 완전 최적은 아닙니다. 기대 문자열 길이를 컴파일 타임에 알고 있으면, 바이트별이 아니라 더 큰 덩어리로 한번에 비교할 수 있습니다:
const std = @import("std");
fn staticEql(comptime a: []const u8, b: []const u8) bool {
const block_len = std.simd.suggestVectorLength(u8) orelse @sizeOf(usize);
if (a.len != b.len) return false;
const block_count = a.len / block_len;
const rem_count = a.len % block_len;
for (0..block_count) |idx| {
const Chunk = std.meta.Int(.unsigned, block_len * 8);
const a_chunk: Chunk = @bitCast(a[idx * block_len ..][0..block_len].*);
const b_chunk: Chunk = @bitCast(b[idx * block_len ..][0..block_len].*);
if (a_chunk != b_chunk) return false;
}
const Rem = std.meta.Int(.unsigned, rem_count * 8);
const a_rem: Rem = @bitCast(a[block_count * block_len ..][0..rem_count].*);
const b_rem: Rem = @bitCast(b[block_count * block_len ..][0..rem_count].*);
return a_rem == b_rem;
}
실제로 큰 문자열 비교에서 이 함수는 더 큰 SIMD 레지스터를 적극적으로 사용합니다. "Hello, World!\n"에 대해서도, [실행 시간을 엄청 줄였다](https://gist.github.com/RetroDev256/660824008ff5526ea785d8b3659c29f2)는 걸 벤치마크로 확인할 수 있습니다.

런타임 값도 comptime처럼!
----------------------------
Zig의 comptime은 단순히 컴파일 타임에만 한정되지 않습니다. 예를 들어, 간단한 경우엔 컴파일 타임에 여러 함수를 생성해 두고, 런타임에는 해당 함수만 선택적으로 호출하거나, 아니면 바이너리 부피가 부담될 땐 완전히 런타임 코드로 폴백도 가능합니다:
fn dispatchFn(runtime_val: u32) void { switch (runtime_val) { inline 0...100 => |comptime_val| { staticFn(comptime_val); }, else => runtimeFn(runtime_val), } }
fn staticFn(comptime val: u32) void { _ = val; // ... }
fn runtimeFn(runtime_val: u32) void { _ = runtime_val; // ... }
결론
----
comptime이 유용할까요? 저에겐 Zig로 코드를 쓸 때마다 꼭 쓰는 기능입니다. 언어에 매우 자연스럽게 녹아들고, 템플릿, 매크로, 제네릭, 수동 코드 생성의 필요성을 없앱니다. 다른 언어에서도 방법은 있지만, Zig가 훨씬 깔끔하죠. 저에게 Zig는 *실제로 유용한* 시나리오에서 성능 좋은 코드를 쉽게 쓰게 해줍니다. 다시 말해, Zig는 "튜링 타르구덩이"가 아닙니다.
가능성은 [여러분의 상상력에 달려있습니다](https://github.com/RetroDev256/comptime_suffix_automaton). 만약 직장에서 제대로 된 제네릭/코드 생성/템플릿/매크로/comptime 기능 없는 언어만 허락된다면, 유감입니다.
이 글이 도움이 되었기를 바랍니다. 유익하게 읽으셨으면 [후원](https://www.paypal.com/donate/?business=2Z3H3UQA37LML&no_recurring=0&item_name=Like+what+you+see?+A+dollar+or+two+will+help+me+learn+and+publish+more+-+Thanks!¤cy_code=USD)도 고려해 주세요. 마지막으로, 이제 언어 전쟁은 끝내도 좋겠네요! 튜링 완전성만 있으면 충분하고, 정말 중요한 건 더 큰 그림입니다. 좋아하는 언어가 있으면 못 가진 건 아니에요. 사람들이 "C가 파이썬보다 빠르다"고 잘못 말해도, 실제 벤치마크하는 건 언어 자체가 아니라 프로그램의 구조와 구현임을 잊지 마세요. Zig 만세~!