저수준 최적화와 Zig
(alloc.dev)- 저수준 최적화는 Zig 언어에서 손쉽게 구현할 수 있음
- 컴파일러가 대부분 상황에서 최적화를 잘 수행하지만, 때로는 프로그래머의 의도를 명확히 전달해야 더 나은 성능을 얻을 수 있음
- Zig는 컴파일 타임 실행(comptime) 기능으로 고성능 코드 생성과 강력한 메타프로그래밍을 지원함
- 러스트와 비교할 때, Zig는 어노테이션과 명시적인 코드 구조로 더 정밀한 최적화 가능
- 문자열 비교 등 반복적인 연산에서 comptime을 활용해 평범한 함수보다 뛰어난 어셈블리 코드 생성 가능
최적화와 Zig
"모든 것이 가능하지만 흥미로운 것은 쉽게 얻을 수 없다."라는 유명한 경고처럼, 프로그램의 최적화는 언제나 개발자의 주요 관심사임. 클라우드 인프라의 비용, 레이턴시 개선, 시스템 단순화 등을 위해 코드 최적화가 반드시 필요함. 이 글에서는 Zig에서의 저수준 최적화 개념과 Zig의 강점을 중점적으로 설명함.
컴파일러를 신뢰할 수 있을까?
- 일반적으로 "컴파일러를 신뢰하라"는 조언이 많으나, 실제로는 컴파일러가 기대와 다르게 동작하거나 언어 사양을 위반하는 경우가 있음
- 고수준 언어는 의도(intent) 를 명확히 전달하기 어렵기 때문에 성능상의 제약이 따름
- 저수준 언어는 코드의 명시성 때문에 컴파일러가 최적화에 필요한 정보를 알 수 있으며, 예로 JavaScript와 Zig의 maxArray 함수를 비교하면 Zig가 명확한 타입, 정렬, alias 여부 등을 런타임이 아닌 컴파일 타임에 전달함
- 동일한 maxArray 연산을 Zig와 Rust로 작성하면 거의 동일한 고성능 어셈블리 코드를 얻게 되지만, 의도를 더 잘 표현할수록 최적화 결과가 향상됨
- 하지만 항상 컴파일러 성능을 신뢰할 수 없으므로, 병목 구간에서는 코드와 컴파일 결괏값을 직접 확인하고 최적화 방법을 모색해야 함
Zig의 역할
- Zig는 정확한 명시성과 풍부한 내장 함수, 포인터와 어노테이션, comptime, 잘 정의된 Illegal Behavior 등의 특성으로 인해 추상적인 정보 없이도 최적화된 코드를 만들 수 있음
- Rust는 메모리 모델 덕분에 기본적으로 인자 alias가 없음이 보장되지만, Zig에서는 직접 noalias 등 어노테이션 필요
- 만약 LLVM IR만을 기준으로 한다면 Zig의 최적화 수준도 높음
- 무엇보다 Zig의 comptime(컴파일 타임 실행) 이 강력한 최적화 도구임
comptime이란 무엇인가?
- Zig의 comptime은 코드 생성, 상수 값 임베딩, 타입 기반 제네릭 구조체 생성 등에 활용되며, 런타임 성능 향상에 중요한 역할을 함
- comptime으로 메타프로그래밍을 구현할 수 있음
- C/C++의 매크로나 Rust의 macro 시스템과 달리, comptime은 별도의 문법이 아닌 일반 코드임
- comptime 코드는 AST를 직접 변경하지 않고, 모든 타입에 대해 컴파일 타임에 검사, 반영, 생성 가능함
- comptime의 유연성은 Rust 등 여타 언어 개선에도 영향을 미쳤으며, 자연스럽게 Zig 언어에 통합되어 있음
comptime의 한계
- token-pasting과 같은 일부 macro 기능은 Zig comptime으로 대체 불가
- Zig는 코드의 가독성을 중시하기 때문에 범위를 벗어나서 변수 생성이나 매크로 정의 등은 허용하지 않음
- 대신 Zig comptime은 타입 리플렉션, DSL 구현, 문자열 파싱 최적화 등 폭넓은 메타프로그래밍 활용 예시가 존재함
comptime을 활용한 문자열 비교 최적화
- 일반적인 문자열 비교 함수를 모든 언어에서 구현할 수 있으나, Zig에서 두 문자열 중 하나가 comptime에 알려진 상수일 때 더 효율적인 어셈블리 코드 생성 가능
- 예를 들어, 한 문자열이 늘 "Hello!\n"이라면 이 값을 바이트 단위가 아닌, 더 큰 블록 단위로 비교하는 식의 최적화 활용 가능
- 이를 위해 comptime을 사용하면, SIMD 벡터, 블록 처리, 잔여 바이트 최적화 등 고성능 코드를 컴파일 타임에 생성 가능
- 이런 방식을 통해 반복적인 문자열 비교뿐 아니라, 정적 데이터 기반 다양한 맵핑, 완벽 해시 테이블, AST 파서 등 다양한 성능중심 코드 구현 가능
결론
- Zig는 저수준 최적화에 매우 적합하며, 명시적 코드 구조와 강력한 comptime 기능 덕분에 최고의 성능을 직접 구현할 수 있음
- Rust 등 다른 언어와 비교해도, Zig의 컴파일 타임 프로그래밍 능력과 명시성은 고성능 소프트웨어 개발에 큰 이점으로 작용함
- Zig의 최적화 능력은 앞으로도 더욱 중요한 경쟁력이 될 것임
Hacker News 의견
- zig에서 가장 흥미롭게 느끼는 부분은 빌드 시스템의 간편함, 크로스 컴파일, 그리고 높은 반복 속도 추구임. 나는 게임 개발자이기 때문에 성능이 중요하지만 대부분의 요구사항에 대해서는 대부분의 언어들이 충분한 성능을 제공함. 그래서 언어 선택의 최우선 기준은 아님. 어떤 언어로든 강력한 코드를 쓸 수 있지만, 수십 년 동안 유지보수할 수 있는 미래지향적인 프레임워크를 목표로 함. C/C++가 어디서나 지원되는 점에서 기본 선택지가 되었지만 zig도 그만큼 따라올 수 있을 것으로 느낌
- 재미로 아주 오래된 Kindle 기기(Linux 4.1.15)에 zig를 돌려봤는데 zig의 완성도에 놀라운 경험을 함. 대부분이 바로 작동했고, 오래된 GDB로도 이상한 버그를 디버깅할 수 있었음. 나도 zig에 매료됨. 자세한 경험은 여기에서 확인 가능
- 대부분의 언어로 강력한 코드를 쓸 수 있다고 느끼지만, 수십 년을 내다볼 수 있는 모듈러 코드를 원함. Zig를 좋아하지만, 장기 유지보수와 모듈성 측면에서는 단점이 있다고 생각함. Zig는 캡슐화에 적대적인 언어임. 구조체 멤버의 private 처리가 불가능함. 이 이슈 코멘트가 예시임. Zig의 입장은 내부 표현이란 게 따로 존재하지 않아야 하며 모든 사용자가 내부 구현을 알 수 있게 문서화/공개해야 한다는 것임. 하지만 API 계약, 즉 모듈러 소프트웨어의 핵심을 지키려면 내부 구현을 감출 수 있어야 하고, 그것이 불가능함. 언젠가는 Zig가 private 필드를 지원해 주길 바람
- Rust를 가볍게 사용해봤는데 마음에 들었음. 하지만 '나쁘다'는 얘기를 듣고 한동안 멈췄다가 다시 써보는 중임. 여전히 좋음. 사람들이 왜 그렇게 싫어하는지 잘 모르겠음. 못생긴 제네릭 문법은 C#과 Typescript도 마찬가지임. 빌림 검사기(Borrow Checker)도 저수준 언어 경험이 있으면 이해하기 쉬움
- Zig는 더 단순한 Rust, 그리고 더 나은 Go같은 느낌임. 한편 zig 위에 만들어진 도구 중에서 'bun'을 정말 감탄할 정도로 좋아함. bun 덕분에 삶이 엄청 간편해짐. Rust 기반의 'uv'도 비슷한 경험을 줌
- C/C++가 기본이라는 점에 동의함. C보다 더 나은 무언가를 만들려고 해봐야 대부분 결국 C++이 되고 말았음. 그래도 시도는 멈추지 않아야 함. Rust와 Zig가 아직도 더 나은것을 기대하게 만드는 증거임. 난 지금부터 C++를 더 배워볼 예정임
- 최첨단 컴파일러들이 언어 스펙을 깨뜨릴 때가 있다고 해도, Clang의 무한루프 종료 가정은 C11 이후 표준에 따르면 맞는 것임. C11에서는 다음과 같이 명시됨. "제어식이 상수 표현이 아니고, 입출력/volatile/sync/atomic 연산도 하지 않는 반복문은 컴파일러가 종료된다고 가정할 수 있음"
- C++에서는(향후 C++26 전까지) 모든 루프에 해당 규정이 적용되지만, 말씀하신 대로 C 언어에서는 "제어식이 상수 표현이 아닌 반복문"에만 해당됨. 즉, for(;;); 같은 명백한 무한 반복문은 실제로도 무한루프가 되어야 하고, Rust의 loop {} 역시 같아야 함. 그런데 LLVM 개발자들이 종종 자신들이 C++ 컴파일러만 만든다고 착각해서, Rust에서는 "무한루프 부탁드립니다" 해도 LLVM이 "C++ 기준으로는 그런 일 없으니 최적화!"를 적용시켜 문제 발생. 잘못된 언어에 잘못된 최적화가 적용된 셈임
- 컴파일타임(comptime) 기능이 없어서 문자열 비교를 인라인, 언롤하는 것은 C에서도 충분히 가능함. 관련 예시
- 지적이 맞음! 처음 예시는 너무 단순했음. 더 좋은 예시는 컴파일타임 서픽스 오토마톤이 있음. 또, 위에 링크한 godbolt 코드는 오히려 하지 말아야 할 두 가지 사례 중 하나를 보여주고 있음
- 예시로 든 JavaScript 코드가 V8에서 생성된 바이트코드가 비효율적이라고 한 부분은 좋은 비교 예시가 아니라고 생각함. Zig와 Rust에는 아주 최신 환경을 지정해서 컴파일하라고 하면서, V8은 그런 최적화 옵션을 강제하지 않음. 사실 현대 JIT들도 상황만 허락한다면 벡터화 가능함. 그리고 대부분 현대 언어들도 문자열 관련한 최적화는 비슷하게 처리함. 참고로 C++의 예시도 있음
- 사실상 JS와 Zig를 비교하는 건 사과와 과일 샐러드를 비교하는 격임. Zig 예시는 타입과 크기가 고정된 배열을 썼는데, JS는 런타임에 다양한 타입이 들어가는 'generic' 코드임. 이 때문에 JS에서는 타입 정보 제공만 잘하면 JIT이 훨씬 빠른 루프를 만들어냄(비록 벡터화까진 아니더라도). 실제론 TypedArray를 자주 쓰진 않는데, 초기화 비용이 크기 때문이고, 재사용이 잦을 때만 쓸만함. 또 글에서는 JS 코드가 부풀려졌다고 했지만, JIT이 어레이 길이 체크를 못 믿어서 가드를 넣는 경우가 크고, 실제로는 누구나 i < x.length 같은 루프를 써서 JIT 최적화가 됨. 그런 점에서 조금은 트집이지만, 미세한 차이이긴 함
- Rust와 Zig의 godbolt 예제를 더 오래된 CPU로 타겟 변경도 가능함. JS 쪽 타겟 제한은 생각을 못했음. 그리고 C++의 예시는 clang이 얼마나 좋은 코드를 내는지 보여주는 사례임. 다만 현조차로는 assembly가 썩 만족스럽지는 않음(zig가 특정 CPU 타겟으로 빌드되는 걸 감안해도). 컴파일타임 Suffix Automaton의 C++ 포팅 예시도 있다면 정말 흥미로울 듯. 이건 C++ 컴파일러가 추측 불가능한 comptime의 실제 활용 사례임
- "하이레벨 언어는 저수준 언어가 갖고 있는 'intent'가 부족하다"는 말에 의문임. 오히려 더 다양한 방식으로 상세하게 의도를 표현하는 게 하이레벨 언어의 장점이라고 봄
- 나도 동의함. 근본적으로 하이레벨 언어와 로우레벨 언어의 차이는, 하이레벨 언어에선 의도를 표현하고 로우레벨에선 구현 메커니즘 그 자체를 드러내야 한다는 차이임
- 여기서 '의도'란 "이 구매의 세금 계산"같은 업무적 의도가 아니라, "이 바이트를 왼쪽으로 세 칸 쉬프트"하는 식의, 컴퓨터에 무엇을 시키는가에 가까움. 예를 들어 purchase.calculate_tax().await.map_err(|e| TaxCalculationError { source: e })?; 같은 코드는 의도가 가득하지만, 실제로 머신 코드가 어떻게 나올지는 예측이 불가임
- Zig의 allocator 모델이 정말 마음에 듦. Go에서 GC 대신 요청별 allocator 같은 것을 쓸 수 있으면 좋겠음
- Go에서도 커스텀 allocator와 arena가 불가능하지는 않으나, 사용성이 매우 떨어지고 적절히 쓰기 힘듦. 언어 차원에서 소유권(ownership) 규칙을 표현하거나 강제할 방법도 없음. 결국 문법만 살짝 다른 C를 쓰는 꼴이고, GC 없으면 C++보다도 위험한 셈임
- "Zig의 장황함(verbosity)이 마음에 든다"는 말은 공감하지만, 솔직히 조금 어감이 이상함. C는 여기저기 허술한 반면, Zig는 반대로 너무 많은 '주석 소음(annotation noise)'를 요구하는 경우가 많은 편임(특히 수학식에서 명시적 정수 캐스팅 시). 관련 글 참고. 성능 측면에선 zig가 c보다 빠른 경우는 주로 Zig가 더 공격적인 LLVM 최적화 세팅(-march=native, 전체 프로그램 최적화 등) 때문임. 사실 C에서도 unreachable 같은 최적화 힌트는 언어 확장으로 가능하고, Clang도 상수 폴딩에 매우 적극적임. 즉, Zig의 comptime과 C의 코드젠 차이는 컴파일러 최적화 세팅에서 비롯된 경우가 많음. TL;DR: C가 느릴 땐 컴파일러 설정을 먼저 점검해야 함. 어차피 최적화의 핵심은 LLVM임
- 캐스팅 부분 예시라면, 오히려 함수를 하나 만들어서 캐스팅을 감싸는 식으로 코드 재활용성과 의도를 높여줄 수 있음
이 방식도 똑같은 어셈블리로 나오고, 활용도 높고 명확함fn signExtendCast(comptime T: type, x: anytype) T { const ST = std.meta.Int(.signed, @bitSizeOf(T)); const SX = std.meta.Int(.signed, @bitSizeOf(@TypeOf(x))); return @bitCast(@as(ST, @as(SX, @bitCast(x)))); } export fn addi8(addr: u16, offset: u8) u16 { return addr +% signExtendCast(u16, offset); }
- Zig의 아이디어들이 흥미롭고, 원래 기사에서 기대했던 것보단 컴파일타임(comptime)과 전체 프로그램 컴파일에 더 무게가 실려 있었음. 이에 공감함. 참고로 Virgil은 2006년부터 컴파일타임 전체 언어 활용, 전체 프로그램 컴파일 지원을 했음. Virgil은 LLVM 타겟팅은 아니므로 속도 비교는 결국 백엔드 비교임. Virgil은 이 접근법 덕에 메서드 호출을 미리 정적으로 결합(devirtualize), 안 쓰는 필드/객체도 최대한 제거, 필드-힙 객체까지 상수 전파, 완벽하게 특수화 등 매우 강한 최적화가 가능함
- 향후 AI 활용을 생각하면, 점점 더 명시적이고 장황한 언어들이 대세가 될 것 같음. AI로 코딩을 하냐, 그게 옳으냐를 떠나 많은 개발자들이 AI의 도움을 선호하게 되면서 언어들도 그에 맞춰 변할 것임
- 새 x86 백엔드가 도입된다면, 앞으로는 C와 Zig의 성능 차이가 Zig 프로젝트 자체에 기인하는 사례도 볼 수 있을 것 같음
- 명시적 integer 캐스팅 관련해서, 곧 좀 더 깔끔해지는 개선이 나올 예정임. 관련 논의 참고
- 캐스팅 부분 예시라면, 오히려 함수를 하나 만들어서 캐스팅을 감싸는 식으로 코드 재활용성과 의도를 높여줄 수 있음
- "C가 Python보다 빠르다" 같이 언어 그 자체로 벤치마킹하는 건 맞지 않지만, 언어의 일부 기능이 최적화에 큰 장벽이 되기도 함. 적절한 언어를 쓰면, 개발자와 컴파일러 모두 자연스럽고 빠른 방식으로 의도를 표현 가능함
- Zig의 for loop 문법이 너무 난잡하게 느껴짐. 리스트 두 개를 나란히 놓고 위치를 맞춰야 한다니, 보기만 해도 눈이 아픔. 근래 언어들이 너무 많은 '매직' 문법과 특수 기호를 쏟아붓는 게 실수라고 봄. 몇 시간 동안 들여다보고 있기 힘들 것 같음
- 이런 배열 두 개를 순회하는 패턴은 저수준 코드에서 매우 흔하고, 병렬로 순회하는 것도 마찬가지임. 그래서 Zig가 이를 명확하고 자연스럽게 지원하는 게 오히려 적절하다고 봄. 왜 그게 눈이 아플까 궁금함
- 최적화는 매우 중요함. 그 효과는 시간이 지날수록 더욱 커짐
- 다만, 그 소프트웨어가 실제로 쓰이기 전제하에 해당하는 얘기임