Zig 0.9에서 표준 라이브러리 Allocator API가 Allocgate로 인해 어떻게 바뀌는지, 왜 그런지, 그리고 기존 코드를 어떻게 수정해야 하는지 설명합니다.
the Zig programming language의 0.9 버전이 약 일주일 뒤 출시될 예정입니다. 이 릴리스에서 들어오는 주요 변경 사항 중 하나는 할당자(allocator)가 동작하는 방식의 재구성으로, Allocgate라는 이름이 붙었습니다. 이는 표준 라이브러리가 제공하는 Allocator API에 대한 호환성 파괴(breaking change)이므로, 할당자를 사용하는 거의 모든 코드는 이에 맞춰 업데이트가 필요합니다. 이 글에서는 이 변경의 정당화와, 코드를 조정하기 위해 어떤 단계를 밟아야 하는지 설명하려고 합니다.
요약(TL;DR)은 다음과 같습니다:
*Allocator를 넘겼다면, 이제는 Allocator를 직접 넘깁니다. Allocator는 이제 struct이며, 크기는 포인터 두 개입니다.var gpa: std.heap.GeneralPurposeAllocator(.{});)를 구성할 때, &gpa.allocator라고 쓰는 대신 gpa.allocator()를 호출해 타입이 소거된(type-erased) 할당자 구현을 얻습니다.자체 할당자를 작성하는 경우가 아니라면, 바꿔야 할 것은 이것이 전부일 것입니다.
Zig의 할당자는 동적 디스패치(dynamic dispatch)에 의존합니다. 할당과 해제 함수의 선택은 런타임이 되기 전에는 알 수 없으므로, 사용자가 어떤 할당자를 선택했는지와 무관한(그리고 라이브러리 코드를 작성할 때는 일반적으로 그렇게 해야 하는) 코드를 쓰고 싶다면, 이 함수들을 어떤 방식으로든 매개변수로 받아야 합니다. 대부분의 언어는 동적으로 알려진 각 함수의 주소를 저장하는 가상 함수 테이블 (vtable)이라는 구조를 사용합니다. Zig의 표준 라이브러리는 ArenaAllocator, GeneralPurposeAllocator 같은 다양한 할당자를 제공하며, 각각은 어떤 상태(state)와, 그 상태 위에서 동작해 메모리 할당자로 기능하게 하는 함수들(alloc, resize, free)의 집합을 가집니다. 이 세 함수의 주소를 모아 “할당자 vtable”을 이룹니다.
어떤 다형적 객체의 상태는 vtable과 함께 어떤 방식으로든 전달되어야 합니다. 한 가지 방법은 객체를 포인터 두 개의 쌍으로 표현하는 것입니다. 하나는 객체의 필드로, 다른 하나는 그 필드들에서 어떻게 동작할지 아는 vtable로 향합니다. Rust의 용어를 따라 이 (impl, vtable) 쌍을 “팻 포인터(fat pointer)”라고 부르겠습니다.
Dynamic Object
---------- ----------
| impl | -----> | fields |
|--------| | ... |
| vtable | ---. ----------
---------- |
| Vtable
| ----------
`-> | alloc |
| resize |
| free |
----------
다른 방법은 모든 객체마다 vtable의 별도 복사본을 저장하는 것입니다. 주소를 알고 있으면, vtable의 함수들은 자신들을 포함하는 struct의 주소를 유도할 수 있는데, C에서는 이를 container_of라고 부르며 Zig에서는 @fieldParentPtr라는 이름으로 제공됩니다. 실제로 이 builtin의 이런 용법은 매우 흔해서, 이 기법은 보통 그냥 “@fieldParentPtr 관용구(idiom)”로 설명되곤 합니다.
Object
--------------
| fields |
Dynamic | ... |
---------- | ---------- |
| vtable | -------> | alloc | |
---------- | | resize | |
| | free | |
| ---------- |
| ... |
--------------
(또한 세 번째 해법도 상상해볼 수 있습니다. 객체의 필드 안에 vtable에 대한 포인터를 넣고, 그 포인터에 대한 포인터를 전달하는 방식입니다. 이 방식은 vtable을 공유할 수 있으면서도 전달해야 하는 것은 단일 포인터라는 장점이 있습니다. 하지만 vtable에 접근하려면 이중 간접 참조(double indirection)가 필요하므로 더 비쌀 것입니다.)
실제로 벤치마크 까지 하지 않고서, 이 두 접근법의 장점은 각각 무엇일까요?
@fieldParentPtr 접근법을 쓰면 동적 객체를 단일 포인터로만 전달할 수 있습니다. 이는 성능을 개선하고, 동적 할당자를 보관해야 하는 어떤 struct(특히 대부분의 std 컨테이너)에 대해서도 메모리를 절약합니다.할당자의 경우, @fieldParentPtr 접근법이 유리해 보일 수 있습니다. 결국 평균적인 프로그램은 비교적 적은 수의 서로 다른 할당자에 대해 비교적 많은 포인터를 넘기기 때문에, 전자를 작게 유지하는 대신 후자를 크게 만드는 것은 합리적인 트레이드오프처럼 보입니다.
하지만 밝혀진 바에 따르면, 이 접근법에는 비가상화(devirtualization) 라는 컴파일러 최적화와 관련된 성능 문제가 있습니다. 가상 함수 호출은 알려진 함수 호출보다 여러 이유로 더 비싸므로, LLVM은 함수 포인터가 항상 특정 값을 가진다는 것을 증명할 수 있는 곳이라면 어디든 정적 호출로 다시 작성하고 싶어 합니다. 그런데 함수 포인터가 GeneralPurposeAllocator 같은 구조체 안에 들어 있을 때는 이것이 더 어려워집니다. Zig의 struct는 컴파일 타임 캡슐화조차 없고, 하물며 런타임 캡슐화는 더더욱 없습니다. 여러분이 GeneralPurposeAllocator 안으로 손을 뻗어 vtable 함수를 다른 동작으로 바꾸는 것을 막을 장치가 없습니다. std의 계약에 어긋나긴 하겠지만, 즉시 정의되지 않은 동작(undefined behavior)을 일으키지도 않을 것입니다. 즉, 내장된 Allocator에서 vtable을 읽는 어떤 코드든 vtable이 수정되었을 가능성에 대비해야 하며, 그 때문에 비가상화가 불가능해집니다.
반대로 팻 포인터를 사용할 때는 vtable이 공유되는 상수 객체이며, gpa.allocator()를 호출할 때마다 새로운 팻 포인터가 구성됩니다. 그래서 더 큰 팻 포인터를 전달하는 비용을 치르는 대신, 할당/해제 함수 호출은 잠재적으로 훨씬 빨라질 수 있습니다.
제가 알기로 @fieldParentPtr 접근법의 성능 문제는 약 두 달 전 std.rand API에서 처음으로 인지되었습니다. std.rand는 호출자가 선택한 RNG 알고리즘에 대해 다형적으로 동작할 수 있도록 비슷한 접근법을 사용합니다(아마도 RNG 알고리즘은 높은 처리량을 목표로 설계되므로, 간접 호출의 상대적 오버헤드가 더 커서 이 성능 문제가 여기서 더 눈에 띄었을 것입니다). 그 이슈에서는 이 접근법을 쓰는 다른 코드도 같은 문제를 겪을 것이라고 지적했습니다.
Allocgate와 함께, std.mem.Allocator API 역시 내장 vtable에서 팻 포인터로 변경되었습니다. 이것이 글 맨 앞에서 언급한 두 API 변경의 이유입니다. Allocator는 vtable 자체를 들고 있는 방식에서 팻 포인터 struct로 바뀌었고, vtable이 더 이상 할당자의 필드가 아니게 되었으므로 &gpa.allocator 대신 gpa.allocator()를 호출해 팻 포인터를 구성해야 합니다.
이는 또한 제가 예전에 겪은 특정한 실패 양상도 해결합니다. 즉, 다음처럼 써야 하는데
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const alloc = &gpa.allocator;
사용자가 이렇게 쓰는 경우입니다:
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
var alloc = gpa.allocator;
이 코드는 부모 struct에서 할당자를 복사 해 온 다음 alloc.alloc() 같은 메서드를 호출하려고 합니다. 이는 일반적으로는 정상 컴파일됩니다(포인터로 self를 받는 메서드를 호출할 때 Zig가 자동으로 객체의 주소를 취해 주기 때문입니다). 하지만 런타임에서는 정의되지 않은 동작을 일으키는데, alloc이 기대하는 struct 안에 더 이상 포함되어 있지 않음에도 @fieldParentPtr를 사용하려 하기 때문입니다. Allocgate 이후에는 이 코드는 대신 다음처럼 됩니다:
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const alloc = gpa.allocator();
… 그리고 이 방식은 조용히 틀리게 쓰기가 사실상 불가능합니다.
Allocgate는 Zig의 master 브랜치에 병합되었고 Zig 0.9에 포함되어 출시될 것입니다. 이는 상당히 인상적인 작업량을 수반했으며, 표준 라이브러리의 많은 부분이 변경되어야 했습니다. 표준 라이브러리의 어떤 할당자 추상화를 사용하는 코드든 더 나은 성능을 얻을 수 있기를 바랍니다. Zig의 기여자들은 변경의 세부 사항을 두고 여러 차례 의견을 주고받았고, 다수의 벤치마크도 살펴보았지만, 추가 데이터가 있다면 분명 환영할 것입니다.
저는 이것이, 아직 어떤 안정성 보장에도 자신을 묶어두지 않은 비교적 젊은 언어로서 Zig가 생태계 전체에 걸친 개선을 만들어낼 수 있는 좋은 사례라고 생각합니다. 물론 코드를 바꿔야 하는 언어 사용자 입장에서는 짜증나는 일이지만, 그들이 무엇을 선택하는지 알고 들어왔기를 바랍니다. Zig의 원칙 중 하나는 “로컬 최대값을 피하라(avoid local maxima)”인데, 언어의 현재 진화 단계에서는 이것이, 이전 버전과의 호환성이 깨지더라도 더 나은 해법으로 이동할 의지를 가지라는 뜻입니다.
런타임 다형성의 성능과 사용성을 더 개선할 잠재력이 있는 여러 주제들도 논의 중입니다. 예를 들어, Zig 또는 LLVM의 aliasing 모델이 개선되면 가상 함수 호출을 최적화하기 더 쉬워질 수 있습니다(예: struct의 특정 필드를 immutable로 표시할 수 있게 되는 것). Zig 개발자들은 또한 표준 라이브러리나 심지어 언어 자체에 인터페이스에 대한 일급 지원(first-class support)을 두는 것에도 관심을 표명했는데, 이는 사용자 입장에서 더 쉬워지고, 이런 변화가 있을 때마다 표준 라이브러리의 모든 API를 수정해야 하는 부담도 줄여줄 것입니다.
저는 Allocgate에 개인적으로 관여하지 않았습니다. 저는 단지 Zig에 관심이 있는 외부인으로서, 다른 이들이 이 변경에 대한 설명으로부터 도움을 받을 수 있다고 생각했을 뿐입니다. 대신, 저는 다음 분들에게 공로를 돌려야 합니다:
std.rand에서 문제를 처음 진단하고 벤치마크를 제공한 @ominitay;@fieldParentPtr 기반 API에서 벗어나야 할 필요를 발견한 Martin Wickham (@SpexGuy);Allocator에 대한 변경의 대부분을 구현한 Lee Cannon (@leecannon;