Zig Sema: ZIR => AIR

Mitchell Hashimoto

Zig Sema: ZIR에서 AIR로

원문은 Mitchell Hashimoto님이 에 게재했습니다. 이 블로그 구독하기

이 글은 Zig 컴파일러 내부 구조 시리즈의 일부입니다.

“AstGen” 다음 컴파일러 단계는 “Sema”다. Sema는 AstGen 단계에서 나온 ZIR 출력을 받아 AIR를 생성하는 역할을 한다. AIR는 “Analyzed Intermediate Representation(분석된 중간 표현)”의 약자로, 완전히 타입이 지정된 중간 표현이며, ZIR은 타입이 없는 중간 표현이다. AIR는 이후 머신 코드로 바로 낮출 수 있다.

AstGen 페이지에서 언급했듯이, ZIR이 타입이 없는 이유 중 하나는 Zig 프로그램을 완전히 타이핑하려면 제네릭 타입 등을 완전히 구체화하기 위해 comptime 평가가 필요하기 때문이다. 따라서 Sema는 Zig 프로그램의 모든 comptime 평가도 수행한다. 바로 이곳에서 마법이 일어난다!

AIR는 ZIR이나 AST처럼 파일 단위가 아니라 함수 단위로 생성된다. 이 페이지에서는 함수 본문을 ZIR에서 AIR로 변환하는 과정에 집중한다. 더 큰 컴파일 과정이 Sema를 어떻게 호출하는지는 이후 글에서 다룰 예정이다.

참고: 일부 AIR는 파일 스코프에서 생성되므로, AIR가 함수 단위로 생성된다고 하는 것이 100% 정확한 표현은 아니다. 하지만 파일 스코프 AIR 과정을 이해하려면 컴파일 과정 전반을 더 깊이 다뤄야 하며, 이는 다음 글로 미뤄둔다. 이 페이지는 그 이해를 위한 중요한 토대가 될 것이다.

AIR는 어떻게 생겼을까?

AIR가 내부적으로 어떻게 구성되고 만들어지는지 본격적으로 살펴보기 전에, 예시 AIR를 먼저 보자. 간단한 Zig 프로그램과 그 프로그램이 생성하는 AIR는 다음과 같다:

export fn add(a: u32, b: u32) u32 {
    return a + b;
}
# Begin Function AIR: add:
  %0 = arg("a", u32)
  %1 = arg("b", u32)
  %2!= dbg_stmt(2:5)
  %3 = add(%0!, %1!)
  %4!= ret(%3!)
# End Function AIR: add

모든 프로그램의 AIR는 zig build-obj --verbose-air <file.zig>를 실행해 확인할 수 있다. 이를 위해서는 Zig 컴파일러의 디버그 빌드가 필요하다. 소스에서 Zig 컴파일러를 빌드하는 방법은 Zig 위키의 가이드를 참고하면 된다.

AIR는 함수 단위로 생성되고 렌더링된다. 위 출력에서 add 함수의 AIR 출력을 나타내는 주석 스타일 줄을 볼 수 있다. 또한 AIR는 export되거나 참조된 함수에 대해서만 생성된다는 점에 유의하자(지연 생성된다). 따라서 디버깅 목적으로는 AIR를 보고 싶은 함수에 보통 export를 붙인다.

컴파일러의 이전 단계들에 대해 읽어봤다면, AIR의 렌더링된 형태가 ZIR과 매우 유사하다는 것을 알 수 있을 것이다. 두 표현은 유사하고 많은 경우 인스트럭션 태그 이름까지 공유하지만, AIR는 완전히 별개의 중간 표현이다.

%1은 인스트럭션의 인덱스다. 뒤에 !가 붙어 있으면 해당 인스트럭션이 Zig 프로그램의 다른 어떤 부분에서도 사용되지 않거나 참조되지 않았음을 의미한다. 위 예제에서 %0%1은 덧셈 인스트럭션에서 %3을 만들기 위해 사용되고, %3은 return 인스트럭션에서 사용된다. 반면 디버그 문인 %2와 반환 결과인 %4는 사용되지 않은 상태다. AIR를 최종 형식으로 변환하는 백엔드는 필요하면 이 정보를 활용할 수 있다.

참고: 서론에서 언급했듯이, 일부 AIR는 함수 스코프가 아니라 파일 스코프에서 생성된다. 예를 들어 파일 스코프 변수 초기화, comptime 블록 등이 그렇다. 현재 이 AIR를 렌더링하는 것은 불가능하다. 다만 파일 스코프 AIR는 항상 comptime에서 평가되므로 보통 일련의 constant 인스트럭션에 불과하다.

AIR의 구조

ZIR이 AIR로 어떻게 변환되는지 살펴보기 전에, AIR의 형식과 개별 AIR 인스트럭션에 대해 설명하겠다. AIR의 구조를 이해하면 AIR가 만들어지는 과정을 이해하기가 훨씬 쉬워진다.

AIR의 구조는 ZIR과 매우 유사하지만 미묘한 차이가 있다:

pub const AIR = struct {
    instructions: std.MultiArrayList(Inst).Slice,
    extra: []const u32,
    values: []const Value,
};

ZIR과 마찬가지로 AIR는 근본적으로 instructions 필드에 저장된 일련의 인스트럭션이다. 그리고 ZIR 및 AST와 마찬가지로, 인스트럭션은 인스트럭션별 추가 데이터를 extra 슬라이스에 저장할 수 있다. 이 두 필드는 ZIR과 AST에서와 동일하게 동작한다. 이 시점에서 직관적으로 이해가 되지 않는다면, 해당 구조들(특히 extra 데이터가 어떻게 채워지는지 깊이 다룬 AST 구조)을 다시 복습하는 것을 권장한다.

참고: MultiArrayList에 대한 설명도 여기서는 생략한다. 이는 ZIR과 AST에서 사용된 것과 동일한 패턴이며 파서 탐색 글에서 이미 자세히 설명했기 때문이다.

AIR에 새로 존재하는 필드는 values 슬라이스다. 여기에는 ZIR의 comptime 실행으로 알게 된 값들이 들어 있다. 인스트럭션은 comptime에 알려진 값을 참조할 수 있다. 예를 들어 파일의 루트에 const a = 42;가 있다면, 42라는 값은 comptime에 알려져 있으므로 values 리스트에 저장된다. comptime 값의 더 많은 예시는 뒤에서 살펴보겠다.

문자열 상수를 위한 문자열 테이블 같은 필드가 없다는 점에 주목하자. AIR를 만드는 과정과 이후 AIR를 사용하는 Codegen 과정에서도 ZIR에 계속 접근할 수 있으므로, ZIR의 문자열 테이블에서 데이터를 조회할 수 있다.

단일 AIR 인스트럭션의 구조

단일 AIR 인스트럭션의 구조는 Inst 구조체다.

pub const Inst = struct {
    tag: Tag,
    data: Data,
};

이 구조체는 구조적으로 ZIR의 Inst 구조체와 동일하다. enum인 tag가 있고, 그 다음 태그별 data가 있는데 이는 가능한 데이터 타입들의 union이다. AIR의 TagData 타입은 ZIR의 타입들과는 별개이지만 기능적으로는 동일하다.

기본적인 예를 살펴보자. 시맨틱 분석에서 어떤 값이 상수라고 판단하면 .constant 인스트럭션을 생성한다. constant 태그를 가진 인스트럭션의 데이터는 ty_pl 필드로, 상수의 타입을 담고 있으며 페이로드는 comptime에 알려진 값을 가진 values 배열에 대한 인덱스다.

const c = 42;
%0 = constant(comptime_int, 42)
Inst{
    .tag = .constant,
    .data = .{
        .ty_pl = . {
            .ty = Type.initTag(.comptime_int),
            .pl = 7, // index into values
        },
    },
};

위 예시는 Zig 코드, 렌더링된 AIR, 그리고 내부 인스트럭션 표현을 보여준다. c가 어디에도 나타나지 않는다는 점에 유의하자. 이 AIR를 만들기 위해 분석된 ZIR은 대입문의 우변(rhs)인 상수 42에 대한 것뿐이기 때문이다.

Value, Type, TypedValue

Sema 전반에서 매우 자주 사용되는 세 가지 타입이 있다: Value, Type, 그리고 TypedValue다. Value는 정수, 구조체 등 comptime에 알려진 값을 나타낸다. Typeu8 같은 comptime에 알려진 타입이다(참고: 모든 타입은 comptime에 알려져 있다). 그리고 TypedValue는 정확한 타입이 알려진 값으로, 즉 값 42와 타입 u16을 짝지은 것이다.

조금 헷갈릴 수 있는 점은 Zig에서 타입도 유효한 값이라는 것이다. 타입은 타입이 “type”인 값이 될 수 있다. 예를 들어 Zig에서 const c = u8이라고 쓸 수 있다. c는 타입이 “type”이고 값이 “u8”인 경우다. 아래에서 이러한 시나리오의 다양한 예시를 보여줘 더 직관적으로 이해할 수 있도록 하겠다.

Value

Value 구조체는 아래와 같다:

pub const Value = extern union {
    tag_if_small_enough: Tag,
    ptr_otherwise: *Payload,
}

값은 자신이 어떤 종류의 값인지 설명하는 tag를 가진다. 여기서 의도적으로 “type” 대신 “kind”라는 단어를 쓴 이유는, 값 자체는 타입이 없지만 특정 경우에는 타입을 자명하게 알 수 있기 때문이다. 일부 tag 값은 페이로드를 갖지 않지만, 그 외의 경우에는 페이로드가 필요하다. ptr_otherwise 필드는 값을 얻는 데 필요한 추가 정보를 담은 페이로드에 대한 포인터다.

Payload 타입은 더 구체적인 페이로드 타입의 Payload 타입 필드에 대한 포인터다. 이는 Zig에서 다형성 타입이 사용되는 한 가지 방식이다. 이후 @fieldParentPtr 내장 함수를 사용해 전체 타입을 알아낸다. 이는 Zig에서 흔한 패턴이며 더 자세히 설명하는 것은 이 페이지의 범위를 벗어난다. 이 동작 방식을 이해하려면 @fieldParentPtr 가이드를 검색해 보길 바란다.

정수

정수 값을 살펴보자. 상수 42는 아래의 Value로 표현된다:

Value{
    .ptr_otherwise = &Payload.U64{
        .tag = .int_u64,
        .data = 42,
    },
};

참고: 이 값은 정확히 올바르지는 않다. ptr_otherwise 필드는 전체 Payload.U64 구조체가 아니라 Payload를 가리킨다. 하지만 위 표현이 의도를 더 명확하게 보여주므로 이 글에서는 이 형식을 계속 사용하겠다.

볼 수 있듯이 값 42int_u64 플래그로 표현된다. 이는 int_u64u64에 들어갈 수 있는 모든 값을 표현하는 데 사용되기 때문이다. 이것이 42u64 타입이라는 의미는 아니다 — 여전히 u8, u16 등이 될 수 있다. Type이 없는 Value는 타입이 없는 상태다. 혹은 일종의 타입이 있다는 점이 신경 쓰인다면, 값을 엄밀히 말해 타입이 지정되지 않은 것으로 생각해도 된다.

타입 값 (TypedValue가 아님)

Zig에서 타입도 값이 될 수 있다. 예를 들어 const c = u8이라는 문장은 완전히 유효한 Zig 코드다. 타입 u8을 상수 c에 대입하는 것이며, c 자체의 타입은 type이다(즉 타입을 담고 있는 값이다). 이는 매우 헷갈릴 수 있으며, 실제 시맨틱 분석에서 타입 값이 사용되면서 더욱 혼란스러워지므로 여기서 미리 설명해 둔다.

값으로서의 상수 u8은 아래의 Value로 표현된다:

Value{
    .tag_if_small_enough = .u8_type,
};

이 값은 페이로드가 없다. 값은 태그 u8_type으로 정확히 표현된다. 값은 u8 타입 자체다. 하지만 값 자체는 여전히 타입이 없는 상태다. 다만 이 경우 u8_type에 대한 유일한 유효한 타입이 type이므로 타입을 자명하게 알 수 있다. 그럼에도 Value 구조체 자체는 기술적으로는 여전히 타입이 없는 것이다(예를 들어 언젠가 Zig가 모든 정수 타입을 나타내는 inttype이라는 키워드를 도입한다면, 이 값이 inttype 또는 type 중 어느 것이든 되는 것이 유효할 수 있다).

조금 더 복잡한 타입 값을 살펴보자: const c = [4]bool.

Value{
    .ptr_otherwise = &Payload.Ty{
        .tag = .ty,
        .data = Type{
            // .tag = .array
            // type data including element type (bool), length (4), etc.
        },
    },
};

이 값은 태그가 .ty(“type”의 약자)이다. 이는 어떤 타입 자체가 값인 경우다. 페이로드는 4개의 boolean 요소를 가진 배열을 설명하는 Type 구조체다. Type 구조체에 대해서는 다음 섹션에서 더 자세히 설명하겠다. 여기서 중요한 점은 Value가 배열 이 아니라 배열 타입 값을 나타내고 있다는 것이다.

아직도 헷갈린다면, Type에 정의된 toValue 함수를 살펴보자. 타입은 값이 될 수 있으므로 Type은 항상 값으로 변환될 수 있지만, 그 역은 항상 성립하지는 않는다.

Type

Type 구조체는 Value와 동등하지만 타입 전용 타입들을 사용한다:

pub const Type = extern union {
    tag_if_small_enough: Tag,
    ptr_otherwise: *Payload,
}

이 구조는 Value와 거의 동일하게 동작하므로 덧붙일 내용은 많지 않다. 최소한 하나의 구체적인 예시를 위해 [4]bool이 어떻게 표현되는지 살펴보자:

Type{
    .ptr_otherwise = &Payload.Array{
        .tag = .array,
        .data = .{
            .len = 4,
            .elem_type = Type{.tag_if_small_enough = .bool},
        },
    }
}

TypedValue

TypedValue는 단순히 TypeValue를 함께 묶어 값에 정확한 타입 정보를 짝지어 놓은 것이다. 이 둘을 함께해야만 값의 정확한 타입을 알 수 있다.

pub const TypedValue = struct {
    ty: Type,
    val: Value,
};

Sema의 구조

Zig 컴파일러에서 “AstGen” 다음 단계는 관용적으로 “Sema”라고 불린다. Sema는 이 단계를 주로 담당하는 구조체의 이름이기도 하다. Sema의 소스는 src/Sema.zig에 있으며, 매우 큰 파일이다(이 글을 쓰는 시점에 18,000줄이 넘는다). 스스로를 “Zig 컴파일러의 심장”이라고 칭할 정도다.

Sema에는 많은 public API가 있지만, 가장 중요한 것은 analyzeBody다. Sema 구조체는 내부 상태를 위한 많은 필드를 가지고 있다. 모두 살펴보지는 않고 주요 필드 일부를 아래에 소개한다. 설명을 쉽게 하기 위해 비슷한 필드끼리 묶었으므로 순서는 실제 소스와 다르다.

pub const Sema = struct {
    mod: *Module,
    gpa: Allocator,
    arena: Allocator,
    perm_arena: Allocator,

    code: Zir,
    owner_decl: *Decl,
    func: ?*Module.Fn,
    fn_ret_ty: Type,

    air_instructions: std.MultiArrayList(Air.Inst) = .{},
    air_extra: std.ArrayListUnmanaged(u32) = .{},
    air_values: std.ArrayListUnmanaged(Value) = .{},
    inst_map: InstMap = .{},

    // other fields...
};

첫 번째 그룹은 Sema 과정을 위한 주요 필수 입력이다. gpa는 Sema 과정과 선언의 생명 주기를 넘어 유지되는 데이터를 할당하는 데 사용된다. arena는 Sema가 끝난 후 해제되는 임시 데이터를 할당하는 데 사용된다. 그리고 perm_arena는 현재 시맨틱 분석 중인 선언의 생명 주기에 묶인 데이터를 할당하는 데 사용된다.

mod는 분석 중인 모듈이다. 모듈은 이 페이지에서 다루지 않지만, 단일 프로그램의 모든 Zig 코드를 캡슐화한다. 이 페이지에서는 모든 모듈 API를 무시하고 넘어가겠다.

두 번째 그룹은 Sema가 시맨틱 분석 중인 대상을 설명하는 입력이다. code는 분석 중인 선언을 포함하는 파일의 ZIR이다. owner_decl은 현재 분석 중인 선언으로, 보통 함수, comptime 블록, 테스트 등이 된다. funcfn_ret_ty는 함수를 분석할 때 추가되는 정보다.

세 번째 그룹은 Sema 과정의 출력이다. 이 필드들이 대부분 Air 구조체를 구성하는 필드임을 알 수 있을 것이다. 이 필드들은 Sema 과정 전반에 걸쳐 채워지며 최종 Air 결과를 만드는 데 사용된다.

inst_map 필드는 특히 중요하며 Sema 전반에서 사용된다. 이는 ZIR에서 AIR로의 맵이다. 모든 ZIR 인스트럭션이 AIR 인스트럭션으로 이어지지는 않지만, AIR 인스트럭션이 나중에 해석되는 특정 ZIR 인스트럭션(예: 덧셈 피연산자, 함수 매개변수 등)을 참조할 수 있도록 자주 사용된다.

함수 본문 분석하기

Sema의 핵심 함수는 analyzeBody다. 이는 “본문(body)” — 함수 본문, 루프 본문, 블록 본문 등 — 에 대한 ZIR을 분석해 해당 본문의 AIR를 생성하는 데 사용된다. 살펴보기 가장 간단한 본문은 함수 본문이다. 시작하기 위해 아주 간단한 함수와 그 함수가 생성하는 AIR를 살펴보자:

export fn add() u32 {
    return 40 + 2;
}
# Begin Function AIR: add:
  %1 = constant(comptime_int, 40)
  %2 = constant(comptime_int, 2)
  %3 = constant(comptime_int, 42)
  %4 = constant(u32, 42)

  %0!= dbg_stmt(2:5)
  %5!= ret(%4!)
# End Function AIR: add

다시 한 번: 함수의 AIR는 해당 함수를 export한 뒤 zig build-obj --verbose-air example.zig를 실행하면 덤프할 수 있다.

AIR 인스트럭션의 세부 사항을 모르더라도 무슨 일이 일어나고 있는지 파악할 수 있을 것이다. 대략적인 단계 흐름은 아래와 같다:

  1. 타입이 없는 상수 정수 40을 본다(인스트럭션 %1)
  2. 타입이 없는 상수 정수 2를 본다(인스트럭션 %2)
  3. comptime에 덧셈을 수행하고 결과가 타입 없는 상수 정수 42임을 알게 된다(인스트럭션 %3).
  4. 상수 42를 타입 u32와 짝짓는다(인스트럭션 %4). 값 42u32 타입에 자동으로 들어가므로 변환이 필요하지 않다. 이 타이핑은 반환 타입인 u32에 맞추기 위해 필요하다.
  5. %4에서 생성된 값을 반환한다(인스트럭션 %5).

깔끔하다! 다소 장황하긴 하지만, 이 과정이 어떻게 우리의 add 함수를 구현하는지 명확히 볼 수 있다. 또한 comptime 평가가 수행되어 덧셈이 이 단계에서 이미 처리되었고, 따라서 결과가 이미 미리 알려져 있다는 것도 알 수 있다. 이것이 결국 머신 코드로 낮춰질 때 실제 덧셈 결과는 이미 미리 계산되어 있는 상태다.

다음으로, 이 AIR가 어떻게 단계별로 생성되었는지 살펴보자.

함수 단계별로 따라가기

주요 AIR 생성 루프는 analyzeBodyInner(analyzeBody가 호출함)에 있다. 이 함수는 ZIR 인스트럭션을 순서대로 순회하면서 각 ZIR 인스트럭션마다 0개 이상의 AIR 인스트럭션을 생성한다.

const result = while (true) {
    const inst = body[i];
    const air_inst: Air.Inst.Ref = switch (tags[inst]) {
        .alloc                        => try sema.zirAlloc(block, inst),
        .alloc_inferred               => try sema.zirAllocInferred(block, inst, Type.initTag(.inferred_alloc_const)),
        .alloc_inferred_mut           => try sema.zirAllocInferred(block, inst, Type.initTag(.inferred_alloc_mut)),
        .alloc_inferred_comptime      => try sema.zirAllocInferredComptime(inst, Type.initTag(.inferred_alloc_const)),
        .alloc_inferred_comptime_mut  => try sema.zirAllocInferredComptime(inst, Type.initTag(.inferred_alloc_mut)),
        .alloc_mut                    => try sema.zirAllocMut(block, inst),
        // hundreds more...
    };

    try sema.inst_map.put(sema.gpa, inst, air_inst);
    i += 1;
}

이 덕분에 주어진 ZIR 집합에 대해 AIR가 어떻게 생성되는지 이해하기가 매우 쉬워진다. ZIR을 덤프한 뒤 인스트럭션을 하나씩 따라가며 AIR가 어떻게 생성되는지 파악할 수 있다. 위 예제 add 함수의 ZIR은 다음과 같다:

%0 = extended(struct_decl(parent, Auto, {
  [25] export add line(6) hash(4ca8d4e33898374bdeee80480f698dad): %1 = block_inline({
    %10 = func(ret_ty={
      %2 = break_inline(%10, @Ref.u32_type)
    }, body={
      %3 = dbg_stmt(2, 5)
      %4 = extended(ret_type()) node_offset:8:5
      %5 = int(40)
      %6 = int(2)
      %7 = add(%5, %6) node_offset:8:15
      %8 = as_node(%4, %7) node_offset:8:15
      %9 = ret_node(%8) node_offset:8:5
    }) (lbrace=1:21,rbrace=3:1) node_offset:7:8
    %11 = break_inline(%1, %10)
  }) node_offset:7:8
}, {}, {})

함수 본문은 ZIR 인스트럭션 %3에서 시작해 %9에서 끝난다. %3analyzeBodyInneradd 함수 본문을 분석할 때 처음으로 마주하는 인스트럭션이다. %2%10 같은 그 이전(그리고 이후)의 ZIR 인스트럭션들은 더 일찍 분석되며, 그 과정은 조금 뒤에 다루겠다.

%3: dbg_stmt

첫 번째 인스트럭션은 .dbg_stmt다. 메인 루프에서 이것이 zirDbgStmt로 이어지는 것을 볼 수 있다. 해당 함수를 약간 단순화한 형태로 아래에 옮겨 적었다:

fn zirDbgStmt(sema: *Sema, block: *Block, inst: Zir.Inst.Index) CompileError!void {
    const inst_data = sema.code.instructions.items(.data)[inst].dbg_stmt;
    _ = try block.addInst(.{
        .tag = .dbg_stmt,
        .data = .{ .dbg_stmt = .{
            .line = inst_data.line,
            .column = inst_data.column,
        } },
    });
}

이는 ZIR에서 AIR 인스트럭션으로 변환되는 간단하고 좋은 예다. 이보다 더 단순할 수는 없다. 이 경우 dbg_stmt ZIR은 거의 그대로 dbg_stmt AIR 인스트럭션으로 변환된다. 그 결과 앞서 본 %0 AIR 인스트럭션이 생성된다:

%0!= dbg_stmt(2:5)

%4: extended(ret_type())

.extended ZIR 인스트럭션은 zirExtended를 호출하고, 이 함수는 하위 opcode를 순회하며 .ret_typezirRetType에 매핑한다. 이 함수는 아래와 같다:

fn zirRetType(
    sema: *Sema,
    block: *Block,
    extended: Zir.Inst.Extended.InstData,
) CompileError!Air.Inst.Ref {
    const src: LazySrcLoc = .{ .node_offset = @bitCast(i32, extended.operand) };
    try sema.requireFunctionBlock(block, src);
    return sema.addType(sema.fn_ret_ty);
}

분석 중인 함수의 반환 타입은 Semafn_ret_ty 필드에서 확인할 수 있다. addType 함수는 타입 정의를 위한 인스트럭션을 추가한다. 우리 함수의 경우 결과 타입은 u32이며, 이는 잘 알려진(well-known) 타입이므로 추가 인스트럭션을 생성하지 않는다.

실험 삼아 반환 값을 잘 알려지지 않은 타입인 u9로 바꾸면, AIR는 다음과 같은 인스트럭션을 생성한다:

%5 = const_ty(u9)

%5: int(40)

다음은 상수 40에 대한 .int 인스트럭션이다. 이는 메인 본문 분석 루프에서 zirInt 함수 호출로 이어진다. zirInt 함수는 정수 값을 읽어 addConstant를 호출한다. 두 함수는 아래와 같다.

fn zirInt(sema: *Sema, block: *Block, inst: Zir.Inst.Index) CompileError!Air.Inst.Ref {
    const int = sema.code.instructions.items(.data)[inst].int;
    return sema.addConstant(ty, try Value.Tag.int_u64.create(sema.arena, int));
}
pub fn addConstant(sema: *Sema, ty: Type, val: Value) SemaError!Air.Inst.Ref {
    const gpa = sema.gpa;
    const ty_inst = try sema.addType(ty);
    try sema.air_values.append(gpa, val);
    try sema.air_instructions.append(gpa, .{
        .tag = .constant,
        .data = .{ .ty_pl = .{
            .ty = ty_inst,
            .payload = @intCast(u32, sema.air_values.items.len - 1),
        } },
    });
    return Air.indexToRef(@intCast(u32, sema.air_instructions.len - 1));
}

addConstant 함수는 comptime에 알려진 값을 추가하기 위해 Sema의 여러 곳에서 호출된다. 먼저 addType을 사용해 타입을 추가하는데, 이는 이전 인스트럭션에서 본 바 있다. 이 경우 타입은 comptime_int이며, 잘 알려진 타입이므로 새로운 인스트럭션을 생성하지 않는다. 다음으로 값이 air_values에 추가되고, 마지막으로 constant AIR 인스트럭션이 air_instructions에 추가된다. 그 결과 다음과 같은 AIR 인스트럭션이 생성된다:

%1 = constant(comptime_int, 40)

한 가지 유의할 점은 .constant 인스트럭션의 페이로드가 air_values 슬라이스에 대한 인덱스를 참조한다는 것이다. 모든 comptime 알려진 값은 air_values에 저장되며, 인스트럭션 내의 모든 참조는 air_values 슬라이스에 대한 인덱스로 저장된다.

%6은 상수 2에 대해 %5와 동일하므로 생략하겠다.

%7: add(%5, %6)

다음으로 첫 번째 실질적인 논리 연산인 덧셈이 나온다. .add 인스트럭션은 zirArithmetic 함수로 이어진다. 이 함수는 많은 이진 수학 연산에 사용된다. 함수는 아래와 같다:

fn zirArithmetic(
    sema: *Sema,
    block: *Block,
    inst: Zir.Inst.Index,
    zir_tag: Zir.Inst.Tag,
) CompileError!Air.Inst.Ref {
    const inst_data = sema.code.instructions.items(.data)[inst].pl_node;
    sema.src = .{ .node_offset_bin_op = inst_data.src_node };
    const lhs_src: LazySrcLoc = .{ .node_offset_bin_lhs = inst_data.src_node };
    const rhs_src: LazySrcLoc = .{ .node_offset_bin_rhs = inst_data.src_node };
    const extra = sema.code.extraData(Zir.Inst.Bin, inst_data.payload_index).data;
    const lhs = sema.resolveInst(extra.lhs);
    const rhs = sema.resolveInst(extra.rhs);

    return sema.analyzeArithmetic(block, zir_tag, lhs, rhs, sema.src, lhs_src, rhs_src);
}

상단의 모든 상수 변수 할당은 ZIR 인스트럭션을 디코딩하는 과정이다. 특히 중요한 할당은 lhsrhs다. resolveInst 함수는 주어진 ZIR 인스트럭션에 대한 AIR 인스트럭션 인덱스를 찾는 데 사용된다. 따라서 %5%6 ZIR 인스트럭션이 주어지면, lhsrhs는 각각 결과 AIR 인덱스로 설정된다. 이러한 AIR 인스트럭션들은 이전 루프 반복에서 인자들에 대한 .constant 인스트럭션을 생성하면서 설정된 것이다.

ZIR ⇒ AIR 매핑은 어디서 유지될까? ZIR에서 AIR로의 매핑은 analyzeBodyInner 루프에 의해 inst_map 필드에서 유지된다. 업데이트될 수 있는 다른 곳도 몇 군데 있지만, 본문 루프가 주된 위치다.

이어서 analyzeArithmetic으로 넘어간다. 이 함수는 코드의 거의 대부분을 이 산술 연산에 대해 comptime 분석을 수행할 수 있는지 판단하는 데 할애하는 긴 함수다. 핵심 부분은 아래와 같다:

const maybe_lhs_val = try sema.resolveMaybeUndefVal(block, lhs_src, casted_lhs);
const maybe_rhs_val = try sema.resolveMaybeUndefVal(block, rhs_src, casted_rhs);

if (maybe_lhs_val) |lhs_val| {
    if (maybe_rhs_val) |rhs_val| {
        if (is_int) {
            return sema.addConstant(
                scalar_type,
                try lhs_val.intAdd(rhs_val, sema.arena),
            );
        }
    }
}

return block.addBinOp(.add, casted_lhs, casted_rhs);

먼저 resolveMaybeUndefVal을 호출한다. 이 함수는 AIR 인스트럭션 인덱스를 받아 그에 대한 comptime 알려진 값을 로드하려고 시도한다. 값을 comptime에 알 수 없는 경우 반환 값이 null이 되므로 optional을 반환한다.

다음으로 optional을 언래핑하려고 시도한다. lhsrhs 모두에 대해 comptime 알려진 값을 찾았고 둘 다 정수 타입이라면, comptime 덧셈을 수행해 최종 상수를 만들 수 있다. 우리 프로그램에서 결과 42를 가진 .constant AIR 인스트럭션이 생성되는 과정이 바로 이 경우다:

%3 = constant(comptime_int, 42)

값이 comptime에 알려지지 않은 경우를 대비한 fallthrough인 block.addBinOp도 함께 옮겨 적었다. 이는 런타임 계산을 위해(컴파일 타임 대신) .add AIR 인스트럭션을 추가한다. 실험해 보려면 상수 2var b: u32 = 2 같은 변수로 바꾸고 그 변수를 덧셈에 사용해 보자. 변수는 comptime에 연산할 수 없으므로 .add 연산이 생성될 것이다.

%8: as_node(%4, %7)

다음 인스트럭션은 안전한 타입 강제 변환을 구현한다. ZIR을 보면 이는 덧셈 결과를 반환 타입으로 변환하는 것이다. 우리가 이미 알고 있는 것에 더 구체적으로 말하면, comptime 정수 42u32로 변환해야 한다.

.as_node 인스트럭션은 zirAsNode를 호출하고, 이는 결국 coerce의 핵심 로직으로 이어진다. coerce 함수는 Sema 전반에서 한 타입에서 다른 타입으로의 안전한 타입 강제 변환을 수행하는 데 사용된다.

이 함수에 대한 학습은 독자에게 맡기겠다. 로직은 상당히 직관적이지만 많은 타입 강제 변환 케이스를 처리해야 하므로 장황하다. comptime_int에서 u32로의 변환의 경우, 값이 u32에 들어간다고 판단되어 값이 그대로 반환된다. 타입 변환을 위해 추가 처리가 필요하지 않다.

%9: ret_node(%8)

마지막으로 return 인스트럭션은 ZIR에서 ret_node로 인코딩된다. 이는 zirRetNode로 이어지며, 이 함수는 결과와 함께 .ret AIR 인스트럭션을 생성한다. 이 인스트럭션의 생성 과정에는 새롭게 탐구할 내용이 없다.

zirRetNode 함수는 always_noreturn을 반환한다. 이 값은 analyzeBodyInner 루프를 강제로 종료시켜 함수 본문의 AIR 생성을 완료시킨다.

return은 항상 함수 본문의 AIR 생성을 완료시킬까? 여러 개의 return 문은 어떻게 될까? return은 항상 현재 블록에 대한 AIR 생성을 완료시킨다. 도달할 수 없는 코드는 허용되지 않으며 AstGen 단계에서 잡히므로, 여러 개의 return 문이 존재하는 유일한 방법은 그들이 서로 다른 블록에 있는 경우다. 따라서 함수의 맥락에서는 여러 analyzeBodyInner 호출로 재귀하기 때문에 여러 return 문이 있어도 문제가 되지 않는다.

Comptime을 사용할 수 없는 경우

처음 살펴본 예제는 모든 로직이 comptime에서 처리 가능했기 때문에 다소 지루했다. comptime은 const 값에 대해서만 가능하고 var 값에 대해서는 불가능하므로, var를 사용해 런타임 덧셈의 AIR를 살펴볼 수 있다:

export fn add() u32 {
    var b: u32 = 2;
    return 40 + b;
}
# Begin Function AIR: add:
  %2 = constant(comptime_int, 2)
  %3 = constant(u32, 2)
  %6 = constant(comptime_int, 40)
  %8 = constant(u32, 40)

  %0!= dbg_stmt(2:5)
  %1 = alloc(*u32)
  %4!= store(%1, %3!)
  %5!= dbg_stmt(3:5)
  %7 = load(u32, %1!)
  %9 = add(%8!, %7!)
  %10!= ret(%9!)
# End Function AIR: add

상수 2를 변수에 할당된 값 2로 바꾸자 훨씬 더 많은 AIR가 생성된다! 이제 .alloc을 통한 할당, .store를 통한 값 저장, 그리고 런타임 .add 연산도 볼 수 있다.

이는 AIR가 어떻게 생성되는지 학습하고 추적해 보기 좋은 프로그램이다. 이전 예제와 동일한 코드 경로를 많이 따르므로, 독자를 위한 연습 문제로 남겨두겠다.

Comptime 대상 에뮬레이션

comptime에 평가되는 코드에 대해, Zig 컴파일러는 필요할 때 대상 플랫폼의 특성을 에뮬레이션한다. 이는 Zig에서 comptime 평가를 안전하고 가능하게 만드는 핵심 기능이다.

이에 대한 예시는 @floatCast의 구현에서 볼 수 있다:

const target = sema.mod.getTarget();
const src_bits = operand_ty.floatBits(target);
const dst_bits = dest_ty.floatBits(target);
if (dst_bits >= src_bits) {
    return sema.coerce(block, dest_ty, operand, operand_src);
}

return block.addTyOp(.fptrunc, dest_ty, operand);

이 함수는 getTarget을 통해 대상에 대한 정보를 가져온 뒤, 대상 플랫폼에서 float이 지원할 수 있는 비트 수를 판단한다. 대상 타입이 원본보다 더 많은 비트를 가지고 있다면 타입을 안전하게 강제 변환할 수 있다. 그렇지 않다면 float은 잘라낸다(truncate).

Sema 과정 마무리하기

Sema는 함수마다 한 번씩 호출된다. 이는 파일 단위가 아니라 함수 단위로 실행되는 첫 번째 과정이다. 모든 선언을 순회하는 대신, Sema는 참조된 선언마다 한 번씩만 호출된다. 참조된 모든 선언을 파악하기 위해 Sema는 프로세스의 진입점 함수에서 시작한다.

이는 Zig의 “지연 분석(lazy analysis)”을 구현한 것이다. 즉, 선언이 참조되지 않으면 특정 오류가 컴파일러 오류로 나타나지 않는다는 의미다. 참조된 코드만 컴파일되므로 컴파일 과정이 극도로 빨라지고 결과 코드젠도 더 작아지지만, 때로는 혼란스러운 동작을 초래하기도 한다.

이 페이지에서 설명한 기본 지식을 갖추면, 어떤 Zig 프로그램이든 따라가며 어떻게 AIR로 변환되는지 파악할 수 있을 것이다. Zig 컴파일러가 무엇을 생성하는지 확인하기 위해 zig ast-checkzig build-obj --verbose-air 명령어를 자주 활용하는 것을 잊지 말자.

이 과정이 끝나면 AIR는 최종 형식으로 낮추기 위해 “codegen” 과정으로 넘겨진다. Codegen은 공유되는 컴파일러 “프론트엔드”와 여러 “백엔드” 사이의 경계다. 백엔드는 LLVM일 수도 있고 WASM 같은 네이티브 백엔드일 수도 있다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글