정렬

정렬 (Alignment)

메모리에서 값을 읽거나 쓸 때, 그 값이 들어 있는 주소가 아무 위치에나 있어도 되는 건 아니에요. 타입마다 정렬(alignment)이라는 규칙이 있고, 이번 섹션에서 그 규칙이 무엇인지, 그리고 Zig의 포인터·슬라이스가 이 정렬을 어떻게 다루는지 함께 살펴볼게요.

출처: Zig Documentation

본문

정렬이란 무엇일까

모든 타입은 정렬(alignment)이라는 값을 가져요. 이건 바이트 수로 표현되는데, 그 타입의 값을 메모리에서 불러오거나(load) 저장할 때(stored) 메모리 주소가 이 수로 나누어떨어져야 한다는 뜻이에요. 어떤 타입의 정렬 값이 궁금하다면 @alignOf로 알아볼 수 있어요.

정렬 값은 CPU 아키텍처에 따라 달라질 수 있지만, 항상 2의 거듭제곱이고 1 << 29보다 작아요.

포인터 타입은 바이트 단위로 정렬을 명시적으로 지정할 수 있어요. 지정하지 않으면 기본 타입의 정렬과 같다고 가정해요.

예를 들어 i32 변수를 하나 선언하고, 그 포인터의 타입과 실제 주소가 정렬을 지키는지 확인해 볼게요.

// test_variable_alignment.zig
const std = @import("std");
const builtin = @import("builtin");
const expect = std.testing.expect;
const expectEqual = std.testing.expectEqual;

test "variable alignment" {
    var x: i32 = 1234;

    try expectEqual(*i32, @TypeOf(&x));

    try expect(@intFromPtr(&x) % @alignOf(i32) == 0);

    // The implicitly-aligned pointer can be coerced to be explicitly-aligned to
    // the alignment of the underlying type `i32`:
    const ptr: *align(@alignOf(i32)) i32 = &x;

    try expectEqual(1234, ptr.*);
}
$ zig test test_variable_alignment.zig
1/1 test_variable_alignment.test.variable alignment...OK
All 1 tests passed.

여기서 x는 기본 정렬로 선언됐고, &x도 그에 맞춰 암시적으로 정렬된 *i32예요. 그런데 이 암시적 정렬 포인터는 *align(@alignOf(i32)) i32처럼 **명시적으로 정렬을 적어 준 포인터 타입으로 강제(coerce)**될 수 있어요. 주소가 실제로 @alignOf(i32)로 나누어떨어지는지도 함께 확인하고 있죠.

더 큰 정렬 → 더 작은 정렬

포인터의 캐스팅에도 정렬이 관여해요. *i32*const i32로 강제될 수 있는 것과 같은 방식으로, 더 큰 정렬을 가진 포인터는 더 작은 정렬을 가진 포인터로 암시적으로 캐스트될 수 있어요. 반대 방향, 즉 작은 정렬을 큰 정렬로 바꾸는 건 암시적으로 되지 않아요.

변수와 함수에 정렬 지정하기

변수와 함수에도 정렬을 지정할 수 있어요. 이렇게 하면 그 변수·함수를 가리키는 포인터가 지정된 정렬을 갖게 됩니다.

// test_variable_func_alignment.zig
const expectEqual = @import("std").testing.expectEqual;

var foo: u8 align(4) = 100;

test "global variable alignment" {
    try expectEqual(4, @typeInfo(@TypeOf(&foo)).pointer.attrs.@"align");
    try expectEqual(*align(4) u8, @TypeOf(&foo));
    const as_pointer_to_array: *align(4) [1]u8 = &foo;
    const as_slice: []align(4) u8 = as_pointer_to_array;
    const as_unaligned_slice: []u8 = as_slice;
    try expectEqual(100, as_unaligned_slice[0]);
}

fn derp() align(@sizeOf(usize) * 2) i32 {
    return 1234;
}
fn noop1() align(1) void {}
fn noop4() align(4) void {}

test "function alignment" {
    try expectEqual(1234, derp());
    try expectEqual(fn () i32, @TypeOf(derp));
    try expectEqual(*align(@sizeOf(usize) * 2) const fn () i32, @TypeOf(&derp));

    noop1();
    try expectEqual(fn () void, @TypeOf(noop1));
    try expectEqual(*align(1) const fn () void, @TypeOf(&noop1));

    noop4();
    try expectEqual(fn () void, @TypeOf(noop4));
    try expectEqual(*align(4) const fn () void, @TypeOf(&noop4));
}
$ zig test test_variable_func_alignment.zig
1/2 test_variable_func_alignment.test.global variable alignment...OK
2/2 test_variable_func_alignment.test.function alignment...OK
All 2 tests passed.

여기서 foou8이지만 align(4)라서 &foo의 타입이 *align(4) u8이 되는 걸 확인할 수 있어요. 그리고 이 정렬된 포인터는 *align(4) [1]u8, 다시 []align(4) u8과 같은 타입으로도 함께 묶이죠. 함수도 마찬가지로 derpalign(@sizeOf(usize) * 2)라서 그 포인터 타입에 그 정렬이 그대로 새겨져요.

@alignCast로 정렬 키우기

가진 포인터나 슬라이스의 정렬이 작은데, 실제로는 더 큰 정렬이 보장되어 있다는 걸 여러분이 알고 있다면 @alignCast로 더 정렬된 포인터로 바꿀 수 있어요. 이 캐스트는 런타임에는 아무 일도 하지 않는(no-op) 연산이지만, 대신 안전성 검사(safety check)를 하나 넣어 줘요. 즉 실제로 정렬이 맞지 않으면 프로그램이 그 자리에서 걸려서 알려주죠.

// test_incorrect_pointer_alignment.zig
const std = @import("std");

test "pointer alignment safety" {
    var array align(4) = [_]u32{ 0x11111111, 0x11111111 };
    const bytes = std.mem.sliceAsBytes(array[0..]);
    try std.testing.expectEqual(0x11111111, foo(bytes));
}
fn foo(bytes: []u8) u32 {
    const slice4 = bytes[1..5];
    const int_slice = std.mem.bytesAsSlice(u32, @as([]align(4) u8, @alignCast(slice4)));
    return int_slice[0];
}

이 예시는 안전성 검사가 실제로 동작하는 모습을 보여줘요. array는 4바이트로 정렬돼 있지만, bytes[1..5]처럼 한 바이트씩 어긋난 슬라이스를 @alignCast[]align(4) u8이라 우기면 정렬이 맞지 않아 panic이 나요.

$ zig test test_incorrect_pointer_alignment.zig
1/1 test_incorrect_pointer_alignment.test.pointer alignment safety...thread 975727 panic: incorrect alignment
/home/ci/work/zig-bootstrap/zig/doc/langref/test_incorrect_pointer_alignment.zig:10:68: 0x1253b96 in foo (test_incorrect_pointer_alignment.zig)
    const int_slice = std.mem.bytesAsSlice(u32, @as([]align(4) u8, @alignCast(slice4)));
                                                                   ^
/home/ci/work/zig-bootstrap/zig/doc/langref/test_incorrect_pointer_alignment.zig:6:48: 0x12539ce in test.pointer alignment safety (test_incorrect_pointer_alignment.zig)
    try std.testing.expectEqual(0x11111111, foo(bytes));
                                               ^
/home/ci/work/zig-bootstrap/out/host/lib/zig/compiler/test_runner.zig:295:25: 0x1207381 in mainTerminal (test_runner.zig)
        if (test_fn.func()) |_| {
                        ^
/home/ci/work/zig-bootstrap/out/host/lib/zig/compiler/test_runner.zig:74:28: 0x1206b52 in main (test_runner.zig)
        return mainTerminal(init);
                           ^
/home/ci/work/zig-bootstrap/out/host/lib/zig/std/start.zig:789:88: 0x12032af in callMain (std.zig)
    if (fn_info.param_types[0].? == std.process.Init.Minimal) return wrapMain(root.main(.{
                                                                                       ^
/home/ci/work/zig-bootstrap/out/host/lib/zig/std/start.zig:248:5: 0x1202c81 in _start (std.zig)
    asm volatile (switch (native_arch) {
    ^
error: the following test command terminated with signal ABRT:
/home/ci/work/zig-bootstrap/out/zig-local-cache/o/576c866ce90db702b7dc4eb04c165766/test --seed=0x7d8a90dd

한 줄로 정리하면 이렇게 돼요. 타입마다 정렬이 있고, 포인터는 더 큰 정렬을 더 작은 정렬로 암시적으로 내릴 수 있지만 그 반대는 @alignCast로 확실한 경우에만, 그것도 안전성 검사와 함께 올려야 한다는 것. 어느 쪽이든 정렬 자체는 런타임 성능과 메모리 레이아웃에 영향을 주는 중요한 개념이에요.

더 알아보기

  • @alignOf — 어떤 타입의 정렬 값을 알아내는 내장 함수
  • @alignCast — 포인터 정렬을 더 크게 바꾸는 내장 함수
  • alignOf — 정렬 관련 내장 함수 목록
  • Incorrect-Pointer-Alignment — 정렬이 맞지 않는 포인터가 초래하는 안전하지 않은 동작
  • Pointers, Slices — 정렬이 적용되는 포인터와 슬라이스 타입