1P by GN⁺ | ★ favorite | 댓글 1개
  • 저수준 최적화는 컴파일러가 코드의 의도와 제약을 더 잘 이해하게 만드는 작업이며, Zig는 타입·정렬·별칭·컴파일타임 정보를 명시하기 쉬워 이 목적에 잘 맞음
  • LLVM 같은 최적화 컴파일러도 항상 최선의 코드를 만들지는 않으므로, 병목 구간에서는 생성 코드 확인과 코드 조정이 여전히 필요함
  • Zig는 noalias, align, 고정 배열 크기, 원소 타입을 컴파일 시점에 전달해 JavaScript 예시보다 작은 벡터화 코드를 만들 수 있음
  • comptime은 일반 Zig 코드를 컴파일 시점에 실행해 상수 생성, 제네릭 구현, 타입 반사, 문자열 비교 최적화 같은 메타프로그래밍을 가능하게 함
  • Zig의 강점은 AST를 직접 바꾸는 매크로보다 언어에 통합된 컴파일타임 실행에 있으며, 일부 런타임 값도 컴파일타임 특수화 함수로 디스패치할 수 있음

컴파일러를 믿되 확인해야 하는 이유

  • 최적화는 단순히 빠른 프로그램을 만드는 기술을 넘어 비용 절감, 확장성 향상, 시스템 단순성 유지와 연결됨
  • 최신 컴파일러는 LLVM 같은 백엔드로 인상적인 결과를 내지만, 일부 상황에서는 여전히 하위 최적 코드를 생성함
  • 저수준 언어가 빠른 이유는 가비지 컬렉션이나 인터프리터 오버헤드가 적어서만이 아니라, 컴파일러가 이해할 수 있는 의도 정보를 더 많이 표현할 수 있기 때문임
  • 컴파일러는 알고리듬이나 프로그래밍 패러다임 자체를 바꾸지 못하며, 대체로 루프처럼 제한된 범위 안에서 최적화를 수행함

JavaScript와 Zig의 배열 최댓값 예시

  • JavaScript 예시는 x[i] = y[i] > x[i] ? y[i] : x[i] 형태로 두 배열의 원소별 최댓값을 x에 저장함
  • 사람에게는 명확한 코드지만, V8이 생성한 바이트코드는 부풀어 있음
  • Zig 예시는 함수 인자에 최적화에 필요한 정보를 더 구체적으로 명시함
    • noalias x: x가 다른 포인터와 별칭이 아님
    • *align(64): 64바이트 정렬
    • [65536]f64: 배열 크기와 원소 타입
    • const: 읽기 전용 인자
  • 이 정보 덕분에 컴파일러는 더 나은 코드를 만들 수 있으며, 예시에서는 벡터화된 어셈블리가 생성됨
  • 동등한 Rust 코드도 거의 동일한 어셈블리를 생성함

Zig가 최적화에 유리한 지점과 한계

  • Zig는 장황한 표현을 허용해 LLVM에 코드 정보를 많이 전달할 수 있음
  • 최적화와 관련해 Zig가 제공하는 주요 요소는 다음과 같음
  • Rust의 메모리 모델은 함수 인자가 별칭을 만들지 않는다고 컴파일러가 항상 가정할 수 있게 하지만, Zig에서는 이를 직접 지정해야 함
  • 컴파일러가 Zig 함수 인자의 별칭 없음 여부를 알 수 없으면, 주석이 없는 Zig 함수는 Rust 함수보다 느릴 수 있음
  • 잘 주석 처리된 LLVM IR만 기준으로 삼아도 Zig는 좋은 결과를 내지만, 더 큰 강점은 컴파일타임 실행에 있음

comptime의 역할

  • Zig의 comptime은 컴파일 시점의 코드 생성을 위한 기능임
  • 컴파일타임에 가능한 작업은 다음과 같음
    • 상수를 생성해 바이너리에 포함
    • 여러 데이터 타입에 대해 같은 hashmap 구조를 반복 작성하지 않기
    • 컴파일 시점에 알려진 데이터를 바탕으로 불필요한 코드를 제거하도록 최적화 유도
    • 타입을 검사·반사·생성해 제네릭 구현
  • comptime 코드는 컴파일 시점에 실행되는 일반 Zig 코드이며, 네트워크 IO 같은 부작용은 가질 수 없음
  • 컴파일 시점의 에뮬레이션 머신은 컴파일 대상과 일치함
  • 거의 모든 Zig 코드는 comptime으로 컴파일 시점에 실행될 수 있고, 모든 타입은 컴파일 시점에 검사·반사·생성될 수 있음

매크로와 다른 점

  • comptime의 목적은 매크로와 비슷하지만 동작 방식은 다름
  • 일부 매크로는 원시 텍스트를 바꾸고, 일부는 프로그램의 AST를 직접 수정함
  • Zig의 comptime은 AST를 직접 변경하지 않으며, 토큰 붙이기 매크로와 같은 기능도 없음
  • Zig는 읽기 쉬운 언어를 목표로 하므로, 관련 없는 스코프에 변수를 만들거나 수정하는 매크로 스타일과 맞지 않음
  • 매크로가 할 수 있지만 Zig comptime이 직접 하지 못하는 작업은 다음과 같음
    • 다른 매크로 정의
    • AST 변경
    • 미니 언어 또는 DSL 직접 구현
  • 다만 Zig에서도 DSL을 만들 수 있으며, Zig의 print 함수는 comptime으로 포맷 문자열을 파싱해 데이터를 직렬화할 함수 그래프를 구성함
  • 예시로 TigerBeetle account testing DSL, comath, zilliam이 있음

comptime 문자열 비교 최적화

  • 일반적인 문자열 비교는 길이가 다르면 false를 반환하고, 길이가 같으면 각 바이트를 순서대로 비교함
  • 이 방식은 두 문자열에서 각각 바이트를 읽어 비교해야 함
  • 한쪽 문자열이 컴파일 시점에 이미 알려진 경우가 많으므로, Zig에서는 한 인자를 comptime으로 요구할 수 있음
    • fn staticEql(comptime a: []const u8, b: []const u8) bool
  • "Hello!\n" 같은 정적 문자열과 비교할 때 컴파일러는 길이 비교와 각 바이트의 상수 비교로 구성된 코드를 생성함
  • 이 섹션의 목적은 컴파일러가 자동으로 할 수 있는 최적화를 보여주는 데 그치지 않고, comptime으로 변환을 강제해 컴파일러가 보지 못하는 기회를 열 수 있음을 보이는 데 있음

더 큰 단위 비교와 SIMD 활용

  • 단순 comptime 문자열 비교는 여전히 바이트 단위 비교를 수행함
  • 개선된 버전은 std.simd.suggestVectorLength(u8) 또는 @sizeOf(usize)를 사용해 비교 블록 크기를 정함
  • 문자열 길이를 먼저 확인한 뒤, 비교할 수 있는 큰 블록 수와 나머지 바이트 수를 계산함
  • 각 블록은 std.meta.Int(.unsigned, block_len * 8)로 만든 정수 타입으로 @bitCast해 비교함
  • 남은 바이트도 별도 정수 타입으로 비교함
  • "Hello, World!\n" 예시의 생성 어셈블리는 더 큰 레지스터를 사용하고 조건 분기 수를 줄임
  • 더 큰 문자열 비교에서는 더 큰 SIMD 레지스터를 사용하는 어셈블리가 생성됨

런타임 값과 컴파일타임 특수화 함께 쓰기

  • Zig의 comptime은 컴파일 시점에 알려진 데이터에만 한정되지 않음
  • 단순한 경우에는 여러 절차를 컴파일 시점에 생성해 두고, 런타임 값에 따라 알맞은 절차로 동적 디스패치할 수 있음
  • 예시 코드는 switch (runtime_val)에서 inline 0...100 범위의 값은 staticFn(comptime_val)로 보내고, 나머지는 runtimeFn(runtime_val)로 처리함
  • 바이너리 크기 증가를 원하지 않으면 완전한 런타임 구현으로 폴백할 수 있음

결론

  • Zig의 comptime은 템플릿, 매크로, 제네릭, 수동 코드 생성을 대체하는 역할을 함
  • 다른 언어로도 비슷한 일을 할 수 있지만, Zig에서는 comptime이 언어에 더 자연스럽게 통합되어 있음
  • Zig는 실제로 유용한 상황에서 성능 좋은 코드를 작성하기 쉽게 만들며, 모든 것이 가능하지만 흥미로운 작업은 어려운 Turing tar-pit과 대비됨
  • 언어 전쟁에 대해서는 튜링 완전성만으로 충분하다는 큰 관점과, 동시에 사람들이 선호하는 언어를 가질 수 있다는 입장이 함께 남아 있음
  • “C가 Python보다 빠르다”처럼 언어 자체를 벤치마크 대상으로 보는 말은 잘못될 수 있으며, 실제 벤치마크 대상은 언어가 아니라 특정 코드와 구현임

댓글과 토론

Hacker News 의견들
  • Zig에서 가장 끌리는 부분은 빌드 시스템의 쉬움, 크로스 컴파일, 빠른 반복 속도를 목표로 한다는 점임
    게임 개발자라 성능 요구는 있지만, 대부분의 언어가 필요한 성능은 충분히 내준다고 봐서 언어 선택의 1순위는 아님
    어떤 언어로도 강력한 코드는 쓸 수 있지만, 핵심은 수십 년 동안 모듈식 코드를 유지할 수 있는 미래 대응성이 높은 프레임워크를 고르는 것임
    C/C++는 어디서나 지원된다는 이유로 기본 답이었고, Zig도 그 수준에 도달할 수 있을 것처럼 느껴짐

    • Zig를 좋아하지만, 장기 유지보수성과 모듈성은 오히려 가장 약한 부분 중 하나라고 봄
      Zig는 캡슐화에 적대적이고, 구조체 멤버를 비공개로 만들 수 없음: https://github.com/ziglang/zig/issues/9909#issuecomment-9426...
      핵심 인용은 “비공개 필드와 getter/setter는 Java가 대중화한 안티패턴이며, 필드는 존재하는 데이터이니 조심스럽게 이름 붙이고 공개 API의 일부로 문서화하라”는 입장임
      내부 표현을 숨길 수 없으면 소프트웨어 모듈성의 기반인 API 계약을 제대로 만들기 어렵고, 사용자 코드를 깨지 않고 내부 표현을 바꿀 수 있어야 함
      Zig의 입장은 내부 표현이라는 것이 따로 있어서는 안 되고, 표현 자체를 공개·문서화·보장해야 한다는 쪽이라 보이며, 언젠가 이 결정을 되돌려 비공개 필드를 지원하길 바람
    • 재미 삼아 오래된 Kindle 기기에서 잘려나간 Linux 4.1.15 위에 Zig를 돌려봤는데, 꽤 흥미로운 경험이었고 Zig의 성숙도에 pleasantly surprised 했음
      많은 것이 바로 동작했고, 오래된 GDB로 이상한 버그까지 디버깅할 수 있었음
      나도 Zig에 설득됐고, 관련해서 여기 썼음: https://news.ycombinator.com/item?id=44211041
    • Rust를 조금 써봤고 마음에 들었지만 평이 나쁘다는 얘기를 듣고 잠시 멈췄다가, 다시 써보니 여전히 마음에 듦
      왜 그렇게 미워하는지 잘 모르겠음
      보기 싫은 제네릭은 C#이나 TypeScript에도 있고, 빌림 검사기는 저수준 작업을 해봤다면 납득되는 개념임
    • Zig는 더 단순한 Rust이자 더 나은 Go처럼 보임
      Zig 위에 만들어진 도구 중 정말 감탄하는 것은 bun이고, bun을 쓰고 나서 삶이 훨씬 단순해졌음
      Rust로 만들어진 uv에도 비슷한 말을 할 수 있음
    • 콘솔에서 Zig가 어떻게 동작할지 궁금함
      보통 콘솔은 C/C++가 아닌 것을 싫어하지만, Zig는 C로 변환할 수 있으니 완전히 배제되지는 않을지도 모름
  • “최신 컴파일러조차 언어 명세를 깨뜨린다(Clang은 부작용 없는 모든 루프가 종료된다고 가정한다)”는 주장에 대해, 컴파일러가 가끔 명세를 깨뜨린다는 건 의심하지 않지만 그 경우 Clang은 적어도 C11 이후 기준으로는 맞음
    C11에는 제어식이 상수식이 아니고, 입출력·volatile 접근·동기화·원자 연산을 하지 않는 반복문은 구현체가 종료된다고 가정할 수 있다고 되어 있음

    • C++는 앞으로 C++26이 나오기 전까지 모든 루프에 대해 그렇게 말하지만, 지적한 대로 C 자체는 그렇지 않고 “제어식이 상수식이 아닌” 경우만 해당함
      그래서 C의 단순 무한 루프 for (;;);는 실제로 무한 루프로 컴파일되어야 하고, Rust의 덜 불투명한 loop {}도 그래야 함
      그런데 LLVM은 자신들이 항상 C++ 컴파일러를 만드는 게 아니라는 점을 잊는 사람들이 만들다 보니, Rust가 “무한 루프 주세요”라고 하면 LLVM이 “C++에 따르면 그런 건 없으니 최적화하겠다”고 해버리는 지점이 있었고, 그건 다른 언어에 잘못 적용한 것임
  • 문자열 비교를 인라인화하고 펼치기 위해 꼭 comptime이 필요한 건 아님
    C에서도 가능함: https://godbolt.org/z/6edWbqnfT
    오타는 수정했음

  • JavaScript 예시와 Zig/Rust 예시를 비교하는 건 좋은 비교가 아니라고 봄
    Zig와 Rust 컴파일러에는 매우 현대적인 대상 CPU를 고르라고 해놓고, V8은 같은 조건이 아닌 듯함
    최적화 JIT도 조건만 맞으면 벡터화할 줄 앎
    참고로 대부분의 현대 언어는 문자열에 대해 같은 최적화를 수행하며, C++ 예시는 여기 있음: https://godbolt.org/z/TM5qdbTqh

    • 전반적으로 사과와 과일 샐러드를 비교하는 느낌이지만, JS와 Zig의 사용처 차이를 보여주는 데는 적절함
      Zig 예시는 고정 크기의 알려진 타입 배열을 쓰고, JS 코드는 런타임에 “제네릭”이라 xy가 어떤 객체든 될 수 있음
      JS에서는 그 비용을 치러야 하는 게 맞지만, 아이러니하게도 이 특정 예시에서는 JIT에 타입 정보를 더 잘 전달할 수 있음
      항상 같은 크기의 Float64Array로 이 함수를 호출하게 만들면 JIT가 이를 알고 더 빠른 루프를 만들 수 있음. 벡터화는 아니어도 훨씬 나아짐
      다만 타입 배열은 초기화 비용이 커서 큰 타입 배열을 한 번 할당하고 많이 재사용하는 경우가 아니면 실제로는 잘 안 씀
      또 글에서 JS 바이트코드가 꽤 부풀었다고 했는데, 아마 JIT가 65536이 두 배열 길이와 같다는 걸 보장하지 못해 가드를 넣는 부분이 클 것임
      그래도 실제로는 아무도 그런 식으로 for 루프를 쓰지 않고 i < x.length로 쓸 것이며, 이 경우 JIT는 적어도 배열 검사 하나는 제거함
    • Rust와 Zig의 godbolt 예시에서는 target을 더 오래된 CPU로 바꿀 수 있음
      JS 대상의 한계를 생각하지 못한 건 미안함
      링크한 C++ 예시는 Clang이 C++에서 무엇을 할 수 있는지 보여주는 좋은 예지만, Zig가 특정 CPU를 대상으로 컴파일한다는 점을 감안해도 생성된 어셈블리는 다소 아쉬워 보임
      https://github.com/RetroDev256/comptime_suffix_automaton의 C++ 포트를 보면 매우 흥미로울 것 같음
      이건 C++ 컴파일러가 깔끔하게 추론할 수 없는 comptime 활용임
  • “고수준 언어에는 저수준 언어에 풍부한 의도가 부족하다”는 문장이 정말 맞는지 모르겠음
    의도 표현은 고수준/저수준 스펙트럼의 요소가 아닌 듯하고, 오히려 의도를 더 자세히 표현할 수 있는 방법이 많을수록 더 고수준에 가까워야 함

    • 동의하고 더 나아가, 고수준 언어와 저수준 언어의 근본 차이는 고수준 언어에서는 의도를 표현하고, 저수준 언어에서는 underlying 메커니즘을 표현할 수밖에 없다는 데 있다고 봄
    • 여기서 말하는 의도는 “이 구매의 세율을 계산하라”가 아니라 “이 바이트를 왼쪽으로 세 자리 이동하라”에 가까운 것 같음
      무엇을 달성하려는지보다 기계가 무엇을 하게 만들려는지에 대한 의도임
      purchase.calculate_tax().await.map_err(|e| TaxCalculationError { source: e })?; 같은 코드는 의도로 가득하지만, 최종적으로 어떤 기계어가 나올지는 알 수 없음
  • for 루프 문법은 끔찍함
    리스트 두 개가 나란히 있고, 한 리스트의 항목 위치가 다른 리스트의 항목 위치와 대응된다는 건가?
    보기만 해도 눈이 아픔
    현대 언어들이 파서에 온갖 “마법”을 추가하고 코드 곳곳에 작은 기호를 흩뿌리기 시작하면서 잘못된 길로 간 것 같음
    몇 시간씩 들여다보고 싶은 형태가 아님

    • 그런 배열은 언어와 상관없이 저수준 코드에서 매우 흔한 패턴이고, 병렬로 순회하는 것도 마찬가지임
      그래서 Zig가 정확히 그 일을 편하게 하면서도 무슨 일이 일어나는지 분명히 보이게 하는 문법을 제공하는 건 자연스러움
      개인적으로는 꽤 잘했다고 보는데, 왜 눈이 아픈지 궁금함
  • Zig의 할당자 모델이 정말 마음에 듦
    Go에서도 가비지 컬렉션 대신 요청 단위 할당자 같은 것을 쓸 수 있으면 좋겠음

    • Go에서도 커스텀 할당자와 아레나는 가능하고 실제로 존재하지만, 사용성이 매우 나쁘고 제대로 쓰기 어려움
      언어 자체에 소유권 규칙을 표현하고 강제할 방법이 없어서, 결국 문법만 조금 다른 C를 쓰면서 잘 되길 바라는 꼴이 됨
      가비지 컬렉션이 없다는 조건에서는 C++조차 Go보다 훨씬 안전함
  • Rust의 메모리 모델은 컴파일러가 함수 인자가 절대 별칭 참조하지 않는다고 항상 가정할 수 있게 하지만, Zig에서는 이를 수동으로 지정해야 함
    이런 별칭 지정은 피하고 있음
    이해하는 사람이 적고, 잘못 쓰면 코드에 이해하기 어려운 버그가 생길 수 있기 때문임

  • Zig의 comptime 유연성이 다른 언어들에 좋은 개선을 가져왔다는 주장에 대해, 컴파일 시간 함수 실행과 상수 인자를 받는 함수는 D가 2007년에 도입했고 여러 언어가 비슷한 것을 채택하게 만들었음
    https://dlang.org/spec/function.html#interpretation

  • Zig를 좋아하지만 “Zig의 장황함이 좋다”는 말은 이상하게 들림
    C는 여러 구석에서 너무 느슨한 게 맞지만, Zig는 현재 기준으로는 반대쪽으로 조금 과하게 흔들려 주석 같은 타입 표기 소음이 너무 많아질 때가 있음
    특히 수식에서 명시적 정수 캐스팅이 그렇고, 관련해서 여기 조금 썼음: https://floooh.github.io/2024/08/24/zig-and-emulators.html
    성능 면에서 Zig 코드가 비슷한 C 코드보다 빠르다면 대개 Zig의 더 공격적인 LLVM 최적화 설정 때문임
    예를 들어 Zig는 기본적으로 -march=native와 전체 프로그램 최적화를 사용하고, 프로젝트의 모든 Zig 코드를 하나의 컴파일 단위로 컴파일함
    unreachable을 최적화 힌트로 쓰는 식의 거의 모든 “트릭”도 C에서 가능하며, 다만 때때로 비표준 언어 확장이 필요함
    C 컴파일러, 특히 Clang도 상수 접기를 매우 공격적으로 하고, 깊은 호출 스택이 있어도 상수로 접을 수 있는 큰 코드 영역을 줄여버릴 수 있음
    그래서 코드 생성만 보면 Zig의 comptime과 큰 차이가 없을 때가 많음
    comptime의 장점은 조용히 런타임 코드로 되돌아가지 않는다는 점이고, comptime이 아닌 코드도 C처럼 같은 상수 접기 최적화 대상이 됨
    예컨대 “순수한” 비-comptime 함수가 상수 인자로 호출되면 컴파일러는 여전히 함수 호출을 그 결과로 바꿔버림
    요약하면 C 코드가 Zig 코드보다 느리다면 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은 이 컴파일 모델이 가능하게 하는 도달 가능성 분석과 특수화 최적화에 크게 기대고 있음
      예를 들어 메서드 호출을 공격적으로 탈가상화하고, 도달 불가능한 필드와 객체를 제거하며, 필드와 힙 객체를 통해 상수 승격을 하고, 다형성 코드를 완전히 단형화함
    • x86 백엔드가 나오면 C와 Zig 사이의 성능 차이 중 Zig 프로젝트 자체에만 귀속할 수 있는 차이를 볼 수 있을지도 모름
    • 명시적 정수 캐스팅과 관련해서는 곧 정리가 들어올 것 같음: https://ziggit.dev/t/short-math-notation-casting-clarity-of-...
    • 앞으로 몇 년간 언어 전반의 추가 기울 더 장황하고 명시적인 언어 쪽으로 꽤 강하게 움직일 것 같음
      오직 AI가 더 다루기 쉽게 만들기 때문임
      코딩에 AI를 쓰는 것이 좋은 생각인지, 어느 정도까지 좋은지는 별개 문제지만, 많은 개발자가 그렇게 믿고 있고 언어들은 그들을 수용하려 할 것임