1P by GN⁺ | ★ favorite | 댓글 1개
  • Zig master 브랜치에 LLVM 백엔드의 비 ABI 정수 처리 개선과 새 @bitCast 의미론이 병합되어, 최적화 문제와 언어 동작 불일치를 함께 정리함
  • u4, i13, u40 같은 임의 비트폭 정수는 SSA 값에서는 bit-int로 다루되, 메모리 저장 시 ABI 크기 정수로 확장하는 방식으로 바뀜
  • 기존 @bitCast는 메모리 바이트 재해석에 가까웠지만, 새 정의는 타입의 논리적 비트 배열을 기준으로 해석해 endian 의존성을 줄임
  • 변경은 LLVM·C 백엔드와 comptime 실행까지 확장됐고, 표준 라이브러리·컴파일러·compiler_rt의 관련 사용처도 함께 점검됨
  • 놓치던 LLVM 최적화가 되살아나면서 Zig 컴파일러 자체에서 약 5% 성능 개선이 관찰됐고, 0.17.0에서 일부 런타임 성능 향상을 기대할 수 있음

LLVM 백엔드의 임의 비트폭 정수 처리 변경

  • Zig는 기존에 u4, i13, u40 같은 임의 비트폭 정수 타입을 LLVM IR의 bit-int 타입인 i4, i13, i40으로 직접 lowering해왔음
  • 이 방식은 LLVM의 메모리 표현 의미론이 최적화기에 불필요한 제약을 만들었고, Clang이 이런 LLVM IR을 만들지 않아 LLVM 내부 경로도 충분히 테스트되지 않았음
  • 지난 몇 년 동안 실제로 최적화 누락miscompilation 사례가 관찰됨
  • 새 방식은 SSA 값 조작에는 bit-int 타입을 유지하되, 메모리에 저장할 때 i8, i16, i32 같은 ABI 크기 타입으로 zero-extend 또는 sign-extend함
  • 이 lowering은 C의 _BitInt(N)를 Clang이 lowering하는 방식과 맞아, LLVM에서 더 잘 지원되는 경로로 기대됨

기존 @bitCast의 한계

  • 기존 @bitCast는 개념적으로 다음 동작에 가까웠음
    • 피연산자 값의 포인터를 얻음
    • 그 포인터를 목적지 타입 포인터로 캐스팅함
    • 해당 포인터에서 값을 로드함
  • 즉 기존 정의는 타입의 논리 구조보다 메모리의 바이트 재해석에 가까웠음
  • 시간이 지나며 실제 동작은 이 정의에서 벗어났고, 대부분의 타깃에서 @sizeOf(u24)@sizeOf([3]u8)보다 큰데도 [3]u8u24@bitCast하는 것이 허용됨
  • LLVM 백엔드는 충분히 명세화되지 않은 @bitCast 의미론을 구현하고 있었고, 정수 타입의 메모리 저장 방식을 바꾸자 컴파일러 테스트 스위트에서 Illegal Behavior와 크래시가 발생함
  • LLVM 백엔드에 기존 동작을 흉내 내는 로직을 추가하는 대신, 새로운 @bitCast 정의를 전반적으로 구현하는 방향이 선택됨

@bitCast 의미론

  • 새 의미론은 2024년에 제출되어 수락된 언어 제안 #19755를 기반으로 함
  • 이 의미론은 이미 self-hosted x86_64 백엔드에 구현되어 있었고, 이번 변경으로 LLVM·C 백엔드와 comptime 실행까지 확장됨
  • @bitCast는 메모리 바이트가 아니라 타입을 논리적으로 표현하는 비트 순서를 기준으로 동작함
    • u5는 least-significant bit부터 most-significant bit까지 5개의 논리 비트로 구성됨
    • [2]u5는 첫 번째 원소의 5비트 뒤에 두 번째 원소의 5비트가 이어진 10개의 논리 비트로 구성됨
  • u8을 같은 크기의 i8로 바꾸는 경우처럼 단순한 정수 간 변환은 비트가 그대로 유지되고, 최상위 비트가 부호 비트로 해석됨
  • 정수 타입과 packed struct 또는 packed union 사이의 @bitCast 의미론도 유지됨

배열·벡터에서 달라지는 동작

  • 새 의미론이 기존과 달라지는 지점은 배열과 벡터 같은 aggregate 타입이 관련될 때임
  • 예를 들어 [2]u8u16으로 @bitCast하면 기존 의미론에서는 대상 endian에 따라 결과가 달랐음
    • big-endian 타깃에서는 첫 번째 배열 원소가 상위 8비트가 됨
    • little-endian 타깃에서는 첫 번째 배열 원소가 하위 8비트가 됨
  • 새 의미론은 논리적 비트 표현만 고려하므로 endian에 독립적이며, 모든 타깃에서 첫 번째 배열 원소가 하위 8비트가 됨
  • 일반적으로는 little-endian 타깃에서의 기존 동작과 더 가까움
  • [2]u3@Vector(3, u2)로 변환하는 것처럼 비정형적인 변환도 가능함
    • 배열의 논리 비트를 이어 붙인 뒤 2비트 단위로 읽어 벡터 원소를 구성함
    • 정수를 @Vector(n, u1)@bitCast해 개별 비트 벡터로 분해하는 용도에도 쓸 수 있음

함께 반영된 제안과 마이그레이션

  • 이번 작업 중 @bitCast와 관련된 작은 수락 제안도 함께 구현됨
    • 포인터 벡터와의 @bitCast 금지: #18936
    • enum에 대한 @bitCast 허용: #35602의 일부
  • 새 의미론은 기존 의미론과 의미 있게 다르기 때문에 표준 라이브러리, 컴파일러, compiler_rt 같은 지원 라이브러리의 @bitCast 사용이 점검됨
  • 관련 PR은 codeberg.org/ziglang/zig/pulls/35711이며, master에 병합되면서 여러 이슈도 함께 닫힘
  • 변경된 의미론과 권장 마이그레이션 절차는 Zig 0.17.0 릴리스 노트에 정리될 예정임

0.17.0에서 기대되는 성능 효과

  • 원래 목표였던 LLVM 백엔드의 비 ABI 정수 lowering 변경은 놓치던 최적화를 되살리는 데 성공함
  • 관련 결과는 demonstrably successful로 확인할 수 있음
  • Zig 컴파일러 자체는 내부적으로 임의 비트폭 정수를 많이 쓰지 않는데도, 더 나은 최적화 덕분에 약 5% 성능 개선을 보임
  • 0.17.0에서는 일부 코드에서 작은 런타임 성능 향상이 생길 수 있음

댓글과 토론

Lobste.rs 의견들
  • 글에서 말하는 논리적 비트 표현이 엔디언 독립적이라고 하지만, 실제 설명은 빅엔디언 비트 순서나 바이트 순서를 지원하지 않는 명백한 리틀엔디언 방식으로 보임

    • 여기서 엔디언 독립적이라는 뜻은 리틀엔디언/빅엔디언 아키텍처 사이에서 동작이 달라지지 않는다는 의미로 보임
  • 2026년 6월 25일자 새 개발 로그로, 새 @bitCast 의미론과 LLVM 백엔드 개선이 최근 풀 리퀘스트에 병합됐다는 내용임

  • 흥미롭지만, 드물게 테스트되는 빅엔디언 대상에서 아래처럼 작성된 코드가 갑자기 깨질 수 있지 않을까 싶음
    비-Zig 의사코드로 쓰면:

    if target_is_little_endian {  
        my_int = @bitCast(my_array);  
    } else {  
        my_int = @bitCast([my_array[1], my_array[0]]);  
    }  
    
    • 나도 그 생각을 했지만, 결국 피할 수 없는 변경을 미루면 문제가 더 커질 뿐이라고 봄
      실제로 큰 문제는 아닐 듯한데, Zig 저장소의 수천 개 @bitCast 중 이 변경의 영향을 받은 건 100개보다 훨씬 적었던 것 같음
      솔직히 배열/벡터와 스칼라 사이 변환에서 @bitCast가 어떻게 동작하는지 대부분의 Zig 사용자가 정확히 알고 있었다고도 생각하지 않음. 기존에는 작성자 시스템에서만 테스트되어 리틀엔디언에서만 동작하던 코드가 이제는 어디서나 동작하게 되는 경우도 많을 듯함
  • 예전 C 프로그래머로서, C의 비트 필드는 아키텍처마다 동작이 이식 가능하지 않아 별로 인기가 없었던 걸로 기억함
    새 Zig @bitCast 의미론은 서로 다른 아키텍처에서도 같은 결과를 주는 이식 가능한 추상 의미론이라서 딱 필요했던 방향이라고 봄
    최근 내 언어의 비트 필드와 비트 캐스트 설계를 하고 있어서, 내 코드가 어떻게 동작해야 할지 명확히 하려고 Zig 설계와 구현 문서를 더 자세히 볼 생각임

    • C 비트 필드에 대한 Zig의 주된 대안은 아마 packed structpacked union 이고, 둘 다 새 @bitCast 정의와 잘 맞도록 정의되어 있음
      packed struct는 필드의 비트를 “기반 정수”에 채워 넣는 방식임. 예를 들어 필드가 bool, u6, i9이고 기반 정수가 u16이면, u16의 최하위 비트가 bool, 다음 6비트가 u6, 나머지 9비트가 i9가 됨. 즉 Zig의 packed struct는 여러 시프트와 마스크 위에 얹힌 문법 설탕에 가까움
      packed union도 기반 정수를 가지지만, 모든 필드가 기반 정수와 정확히 같은 비트 수를 써야 함. 그래서 한 필드에 저장하고 다른 필드에서 읽는 동작은 새 의미론의 @bitCast와 거의 동일함. 다만 packed union/packed struct 필드는 배열이나 벡터 타입을 가질 수 없음
      개인적으로는 이런 도구들이 “비트 관련 구조”를 표현하기에 잘 맞는다고 봄. 여러 값을 packed struct로 비트 패킹해 C 비트 필드처럼 쓸 수 있고, 비트 연산 위의 문법 설탕이라 C에서 타입 안전하지 않은 매크로 더미로 처리하던 비트 플래그도 깔끔하게 표현 가능함
      예를 들어 RWX 접근 플래그는 C에서는 ACCESS_READ, ACCESS_WRITE, ACCESS_EXEC 매크로와 uint8_t API로 받을 수 있지만, Zig에서는 Access = packed struct(u8)read, write, exec, reserved 필드를 정의하고 API에서 Access를 받을 수 있음
      packed structpacked union을 쓰면 꽤 이상한 비트 배치도 표현할 수 있음. Mach-O 객체 포맷의 심볼 테이블 엔트리에는 역사적 이유로 보이는 특이한 n_type 필드가 있는데, 이를 packed union(u8) 안에 bits: packed struct(u8)stab: enum(u8) 형태로 모델링할 수 있음
      n_type 값을 다룰 때 수동 시프트나 마스킹이 필요 없음. n_type.bits.is_stab != 0을 확인하고 참이면 n_type.stab으로 switch하면 되고, 아니면 n_type.bits의 다른 필드를 보면 됨. 반대로 .{ .stab = .gsym }.{ .bits = .{ .ext = false, .type = .undf, .pext = false, .is_stab = 0 } }처럼 값을 만들 수도 있음
      원 글 주제와는 다른 언어 기능으로 조금 길어졌지만, 새 언어 설계에 참고할 만한 걸 찾는다면 Zig의 packed structpacked union을 직접 써보면 좋겠음. 단순하지만 꽤 괜찮은 도구라고 봄