Rust에서 커스텀 할당자를 데이터 구조에 주입하는 여러 접근을 살펴보고, lyon의 테셀레이터에 적용한 실험과 성능 관찰을 정리한다.
최근 저는 Rust에서 커스텀 할당자(custom allocator)를 살펴보고 있었습니다. 최근 Zig 프로그래밍 언어에 대한 인터넷의 화제가 제 관심을 다시 할당자로 돌려놓았지만, 커스텀 할당자는 오래된 주제입니다. 저는 C++ 시절에 Andrei Alexandrescu의 발표를 보고 한참 전에 이것저것 만져본 기억이 있습니다. 간단하면서도 빠르게 동작하는 것부터 시작해, 조합 가능한 구성 요소로 확장해 나갈 수 있고, 물론 토끼굴도 충분히 깊어서 오랜 시간을 들인 끝에 고급 묘기를 부리며 뿌듯해질 수 있는 해킹거리라 만족스러웠습니다. 하지만 이미 옆길로 새고 있군요. 오늘은 메모리 할당자를 만드는 이야기가 아니라, 서로 다른 할당자를 받아들일 수 있는 데이터 구조를 작성하는 것에 대해 이야기하려고 합니다. 또한 전역 할당자를 교체하는 이야기도 아닙니다. 프로그램의 서로 다른 부분이 각자 메모리 할당 전략을 선택할 수 있게 만드는 이야기입니다. 먼저 Rust 생태계에서의 동기와 현황을 피상적으로 조금 떠들고, 그 다음 lyon의 테셀레이터(tessellator)에 커스텀 할당자를 몇 가지 방식으로 통합해본 실험을 살펴보겠습니다.
저는 오랫동안 “하나뿐인 전역 할당자” 전략을 약간 싫어했습니다. 꽤 단순하고 괜찮은 기본값이라서, 많은 프로그래머가 메모리를 다루는 다른 방법들이 있다는 사실을 잊어버릴 정도입니다. 저는 업무에서 성능 프로파일을 자주 보는데, 할당/해제는 항상 눈에 띄면서도 어딘가 잡히지 않는 느낌이 있습니다. 할당 오버헤드에 대한 단순한 벤치마크를 뽑아내기 어려운데, 그 오버헤드의 상당 부분이 캐시 미스와, 동일한 할당자를 여러 스레드가 사용할 때의 경합에서 오기 때문입니다.
할당 성능 문제를 완화하기 위해, 중간 상태를 담는 데이터 구조를 계속 유지할 수 있도록 알고리즘을 구성하는 일이 매우 흔합니다. 그러면 다음 실행에서 이전 실행의 할당을 재사용할 수 있습니다. 예를 들어 lyon의 테셀레이터는 그렇게 작성되어 있습니다. WebRender에서도 임시 할당을 유지하려고 합니다. 저는 이 접근을 “할당 재활용(recycling allocations)”이라고 부르겠습니다. 요지는 할당자와 상호작용하는 것이 느리다는 사실을 받아들이고, 그것을 피하기 위한 장치를 만드는 것입니다.
또 다른 선택지는 한 걸음 물러나서, 메모리 할당이 정말 느려야만 하는지 생각해 보는 것입니다. 서로 다른 할당 패턴이 무엇일까요? 모두가 범용 할당자(general purpose allocator)로 가장 잘 처리될까요? 범프 할당자(bump allocator)는 짧은 수명의 할당을 아주 빠르게 제공하는 데 탁월합니다. 또한 공통되고 잘 정의된 스코프를 가진 할당 묶음과도 잘 맞습니다. 예를 들어 게임의 한 프레임, 혹은 그래픽 렌더링 엔진의 한 프레임 같은 경우입니다. 대부분의 할당은 생성된 스레드에 머뭅니다. 가능한 곳에서는 더 단순한 스레드 로컬(thread-local) 할당자를 사용하는 것이 이점을 줄 수 있습니다. 물론 예측하기 어려운 수명을 가지며, 스레드 간 이동할 수도 있고 안 할 수도 있는 리소스를 뒷받침하는 장수(long-lived) 할당도 있습니다. 이런 것은 전역 할당자가 잘 처리합니다. 그런데 이런 것들이 대부분의 프로그램에서 할당의 대부분일까요? 저는 그렇지 않다고 생각하고, 저만 그렇게 생각하는 것도 아닙니다. 어떤 언어들(Zig, Jai 등)은 라이브러리 설계의 핵심 기둥으로, 할당이 필요한 모든 곳에서 올바른 할당 전략을 선택할 수 있고 편리하게 만들 수 있다는 점을 삼고 있습니다.
임시 데이터를 위해 커스텀 할당자를 사용해 메모리 할당 비용을 피하는 것은, 재활용과 비슷한 이득을 위해 더 많은 일을 하는 것처럼 보일 수 있습니다. 하지만 이득은 단지 할당자 오버헤드를 건너뛰는 것 이상일 수 있습니다. 나중에 쓰기 위해 할당을 유지해 두면, 사용 직후 캐시 계층에서 “따뜻한” 메모리를 잡아두고, 이후에 이 특정 코드가 다시 실행될 때만 그 메모리를 재사용하도록 강제하는 셈이 됩니다. 바로 다음에 실행되는 다른 작업이 그 메모리를 자신의 임시 할당에 쓰면 이득을 볼 수도 있는데, 그러지 못하고 덜 준비된 다른 메모리 조각을 찾아야 합니다. 메모리 예산이 빡빡한 상황에서는 임시 데이터 구조를 쌓아두는 것이 귀중한 리소스를 점유할 수도 있습니다.
물론 이런 장점들은 할당 수명 패턴을 이해하고 코드 각 부분에 맞는 전략을 선택해야만 얻을 수 있습니다. 범프 할당자를 알고리즘 아래에 붙인다고 항상 빨라지는 것이 아닙니다. 밑의 할당자가 빠르더라도 CPU 캐시에 따뜻한 메모리를 재사용하지 못하면 오히려 느려질 수도 있습니다. 프로그래머에게 분명 추가적인 정신적 부담이고, 모두에게 적절한 노력 수준도 아닐 수 있습니다. 이 선택지가 모두에게 좋은지 여부와 별개로, 저는 그 선택지에 쉽게 도달할 수 있는 상태가 좋은 상태라고 생각합니다.
Rust는 대부분의 언어처럼, 대다수의 사람이 메모리 할당이 어떻게 일어나는지 고민하지 않아도 되도록 시작했습니다. 모든 할당은 기본 범용 전역 할당자를 거칩니다. 좋은 소식은 표준 라이브러리가 결국 커스텀 할당자를 선택할 수 있는 방향으로 진화할 수 있도록 대비해 두었다는 점입니다.
커스텀 메모리 할당자 지원을 추가/노출하기 위한 제안이 두 가지 있습니다. 제가 가장 관심 있는 것은 allocator-api 기능이며, 오늘날 nightly 버전의 rustc에서 사용할 수 있습니다. 안정 Rust에서는 기능이 안정화될 때까지 allocator-api2 crate를 폴리필(polyfill)로 사용할 수 있습니다. 또 다른 것으로 storage proposal이 있는데, 이것은 allocator-api의 기능 상위집합을 제공하는 것이 목표이며, 주로 커스텀 스토리지를 사용해 Vec 타입 안에서 SmallVec 같은 것을 직접 표현할 수 있게 하려는 것입니다.
요약하자면, 두 제안의 주요 개념적 차이는 다음과 같습니다.
allocator-api는 할당, 해제, 재할당을 위한 메서드를 가진 비교적 직관적인 Allocator 트레이트를 추가합니다. 그 트레이트에게 메모리 할당을 요청하면 포인터를 돌려줍니다.storage 제안은 대신 포인터 자체를 대체하려고 하며, 이를 통해 인라인 할당(데이터 구조와 함께 이동할 수 있는)을 표현할 수 있게 하려 합니다.제 개인적인 의견으로는 allocator-api 제안을 선호합니다. 훨씬 단순하면서도 중요한 기능의 대다수를 커버하기 때문입니다. 더 일반적인 해법의 매력을 이해하지만, 추가되는 기능이 저에게는 추가 복잡성의 무게를 충분히 상쇄하지 못합니다. SmallVec(및 다른 데이터 구조에 대한 동등물)은 별도 구현으로 둘 수 있고, 굳이 Vec에 통합될 필요 는 없습니다. 물론 이는 아주 개인적인 관점이고, 이미 이 논쟁에 감정적으로 투자한 사람을 이 주장으로 설득할 수 있다고는 생각하지 않습니다. 그리고 그것이 이 글의 목표도 아닙니다.
서두가 너무 길었습니다. 이제 이 글의 핵심으로 들어갑니다.
Rust에서 데이터 구조가 커스텀 할당자를 받아들이게 만드는 방법은 몇 가지가 있습니다. 이 글에서는 특히 “임시 데이터 구조”라는 경우에 집중합니다. 알고리즘의 내부 필요에 묶여 있고 보통 수명이 매우 짧은 것들 말입니다. 이 점을 기억해 주세요. 프로그램의 “흥미로운 상태”를 나타내며 더 오래 사는 데이터 구조라면 트레이드오프가 달라질 수 있습니다. 먼저 문제를 단순화하기 위해, 코드 조각을 읽기 쉽게 만들고자 단 하나의 벡터만 포함하는 가상의 Tessellator 구조체로 축소하겠습니다. 더 현실적이고 복잡한 경우로의 확장은 독자에게 과제로 남깁니다(혹은 모험심이 있다면, 글 뒤에서 링크하는 lyon 저장소의 브랜치를 살펴보세요). 그리고 나서 lyon의 fill 테셀레이터에서 여러 접근이 실제로 어떻게 전개됐는지 간단히 덧붙이겠습니다.
struct Tessellator {
data: Vec<u32, CustomAllocator>,
}
가능한 한 가장 단순합니다. 유연하지는 않지만, 유연함이 항상 목표인 것은 아닙니다. 작은 모듈식 크레이트를 만들 때는 이 접근을 택하기 어려울 수 있지만, 애플리케이션에 속한 코드라면 보통 이것이 가장 효율적이고 작업량도 최소입니다.
struct Tessellator<A: Allocator = Global> {
data: Vec<u32, A>,
}
이 버전이 가장 유연합니다. 데이터 구조가 소유하는지 빌리는지, Send 가능한지 등 위에서 추상화합니다. 또한 할당자 타입이 흔히 제로 사이즈(예: Global)인 경우를 활용합니다.
저는 이미 제네릭인 데이터 구조이거나, Send/Sync 같은 제약을 강제하고 싶지 않을 때는 기본적으로 이 접근을 택할 것 같습니다.
하지만 원래는 제네릭 파라미터가 하나도 없던 복잡한 구조체에 이를 추가하는 것은 짜증날 수 있습니다.
&dyn Allocator를 쓰기로 결정할 수도 있습니다.성능 측면의 장단점은 다음과 같습니다.
정적 디스패치만이 할당자와 상호작용하는 유일한 방법은 아닙니다. lyon의 fill 테셀레이터는 이미 할당/해제를 느린 작업으로 취급하므로, 동적 디스패치의 오버헤드는 성능에 중요하지 않을 것입니다. 이런 맥락에서는 제네릭을 쓰지 않는 해법이 매력적일 수 있습니다.
pub struct Tessellator<'a> {
data: Vec<u32, &'a dyn Allocator>,
}
impl<'a> Tessellator<'a> {
pub fn new_in(allocator: &'a dyn Allocator) -> Tessellator<'a> {
Tessellator { data: Vec::new_in(allocator) }
}
}
impl Tessellator<'static> {
// A good default. The static lifetime is functionally equivalent to not having
// a lifetime constraint which means the borrow checker will be off our back for
// objects coming out of this constructor.
pub fn new() -> Tessellator<'static> {
Tessellator::new_in(&Global as &'static dyn Allocator)
}
}
// An alias for the common case can also help making things look a bit simpler for the
// common case.
type SimpleTessellator = Tessellator<'static>;
// This one can move around freely.
let mut a = Tessellator::new();
// This one is tied to the allocator handle on the stack.
let allocator = SomeBumpAllocator::new();
let mut b = Tessellator::new_in(&allocator);
다시 말해, 커스텀 할당자를 위해 라이프타임 파라미터를 추가하는 것이 이전 해법의 제네릭 파라미터처럼 마음에 들 수도/안 들 수도 있습니다. 이 경우에는 타입 별칭이 있더라도 저는 조금 거슬립니다. 코드에서 라이프타임 파라미터가 늘어나는 노이즈는 대부분 감수할 수 있지만, 공개 API가 사용자에게 어떻게 제시되는지(특히 생성된 문서에서) 저는 매우 신경을 씁니다. 개념적으로는 Tessellator와 TessellatorWithAllocator<'alloc>처럼, 단순한 타입 시그니처가 단순한 일반 케이스를 설명하고 더 복잡한 시그니처가 고급 사용을 설명하는 형태가 좋습니다. 복잡한 TessellatorWithAllocator<'a>를 실제 구조체로 두고 단순한 Tessellator를 별칭으로 둘 수도 있겠지만, 제가 아는 한 rustdoc은 실제 구조체의 메서드만 보여줄 수 있으니, 고급 파라미터가 사용자의 눈앞에 강제로 드러나는 방식이 마음에 들지 않습니다.
'static을 기본으로 둘 수 있고(Global과도 동작합니다), 라이프타임 때문에 타입을 갑자기 이동시키기 어려워지는 것은 아닙니다. 저는 이 점이 꽤 멋지다고 생각합니다.
이 방식에는 추가 제약이 하나 있는데, 데이터 구조가 미리 Send가 필요한지 여부를 결정해야 한다는 점입니다. 예를 들어 위의 코드 조각에 있는 것은 Send가 아닙니다.
또 하나 중요한 점은, 할당자 필드에 트레이트 객체를 쓰면 Vec나 Box 같은 타입(복잡한 데이터 구조에서 여러 곳에 쓰일 것이 분명합니다)이 Global의 0바이트에 비해 몸집이 늘어난다는 것입니다. 트레이트 객체는 두 포인터를 담으므로 요즘 Rust가 돌아가는 대부분의 환경에서 16바이트입니다. 이것이 문제가 되는지는 데이터 구조 안에 이런 것들이 얼마나 들어가는지, 그리고 구조체 크기에 성능이 얼마나 민감한지에 달려 있습니다. lyon의 fill 테셀레이터 맥락에서는 측정 가능한 차이가 없었습니다. 이 잠재적 부풀림 문제는 표준 컨테이너처럼 할당자를 저장하는 타입 위에 데이터 구조가 만들어져 있을 때의 이야기지만, 할당자를 저장하지 않는 동등한 컨테이너를 쓰는 것을 막는 것은 아무것도 없습니다. 곧 더 이야기하겠지만, 먼저 라이프타임 파라미터 문제를 다루고 싶습니다.
제네릭 및 빌린 트레이트 객체 접근에서 제가 겪은 문서 문제는, 두 개의 구조체를 사용해 해결할 수 있습니다. 하나는 다른 하나를 얇게 감싼 래퍼(wrapper)입니다. 그러면 보일러플레이트가 늘어나는 대가로 문서 모양을 완전히 제어할 수 있습니다.
struct Tessellator {
inner: TessellatorWithAllocator<'static>,
}
// or
struct Tessellator {
inner: TessellatorWithAllocator<Global>,
}
동적 디스패치는 괜찮지만 어떤 이유로든 구조체를 라이프타임에 대해 파라미터화하고 싶지 않다고 해봅시다. 문제 없습니다. 라이프타임을 그냥 정적으로 강제하면 됩니다.
struct Tessellator {
data: Vec<u32, &'static dyn Allocator>
}
impl Tessellator {
// A good default.
pub fn new() -> Tessellator {
Tessellator::new_in(&Global as &'static dyn Allocator)
}
/// Using a custom allocator.
///
/// Note: Users of this type can decide at their own risk to cast away the static
/// lifetime. This will not cause problem with this type as long as the provided
/// allocator outlives the data structure.
pub fn new_in(allocator: &'static dyn Allocator) -> Tessellator {
Tessellator { data: Vec::new_in(allocator) }
}
}
이 버전은 사실 정적 라이프타임으로 제한된 “빌린 트레이트 객체”의 부분집합이며, 문법도 더 깔끔합니다(어디에나 <'a>가 붙지 않습니다).
이것은 아마 C++ 같은 언어에서 쓰는 방식과 가장 비슷할 것입니다. 데이터 구조는 할당자가 자신보다 오래 살 것이라고 가정하고, 사용자가 정적 할당자를 쓰거나, 혹은 unsafe 코드를 쓴다고 “가정하고” 라이프타임을 캐스팅으로 없애되 컴파일러의 도움 없이 할당자가 데이터 구조보다 오래 살도록 보장하는 책임을 집니다.
Rust 커뮤니티의 많은 사람들이 이 접근을 못마땅해할 것이라는 걸 알지만, 솔직히 말해 커스텀 할당 전략 같은 고급 기능의 맥락에서는 그렇게 끔찍한 해법이라고 생각하지는 않습니다. Tessellator는 unsafe API를 노출하지 않지만, 사용자가 규칙을 깨려면 단지 할당자가 데이터 구조보다 오래 살아야 한다는 점을 문서로 명시합니다. 이런 수준의 메모리 관리 제어가 있는 다른 언어라면, 이런 계약은 API 사용자가 수동으로 지켜야 하고 그것이 정상으로 여겨집니다.
앞서 데이터 구조를 이루는 벡터, 박스, 맵 등에 들어가는 할당자 필드가 차지하는 추가 공간이 문제일 수도 있고 아닐 수도 있다고 했습니다. 이를 어떻게 할 수 있을까요? 할당자를 자체로 저장하지 않는 유사 컨테이너를 쓸 수 있습니다. Matklad가 최근에 글에서 Zig가 벡터 컨테이너를 제공하는데, 메모리 할당이나 해제가 필요할 수 있는 모든 메서드에 할당자를 파라미터로 받는다고 썼습니다. 이 접근에는 트레이드오프가 있습니다. Rust에서는 거의 모든 이런 할당 메서드가 unsafe로 표시되어야 하는데, 메모리 안전성이 사용자가 컨테이너를 생성할 때 쓴 동일한 할당자를 올바르게 전달하는지에 달려 있기 때문입니다. 더 큰 데이터 구조 맥락에서는 지키기 쉬운 계약입니다. 바깥 데이터 구조가 보통 루트에 할당자를 저장해 두고 내부 컨테이너에 전달하면 되니까요. 살펴봅시다.
struct Tessellator<'a> {
allocator: &'a dyn Allocator,
data: RawVector<u32>,
}
impl Tessellator<'static> {
// A good default.
pub fn new() -> Tessellator<'static> {
Tessellator::new_in(&Global as &'static dyn Allocator)
}
}
impl<'a> Tessellator<'a> {
pub fn new_in(allocator: &'a dyn Allocator) -> Tessellator<'a> {
Tessellator {
data: RawVector::with_capacity(128, allocator),
allocator,
}
}
}
impl<'a> Drop for Tessellator<'a> {
fn drop(&mut self) {
// Don't forget to free the memory!
unsafe {
self.data.deallocate(self.allocator);
}
}
}
이런 식의 unsafe 수동 할당 벡터를 가지고 놀고 싶다면, 저는 shared_vector 크레이트에 하나 작성해 두었습니다. 이것이 그 크레이트의 초기 목표는 아니었지만, 표준 라이브러리의 벡터와 꽤 비슷한 벡터 타입이 들어 있었고, 이를 리팩터링하여 대부분의 코드를 담고 할당자를 명시적으로 받는 RawVector<T>로 만들기 쉬웠습니다. 그 위에 Vector<T, Allocator> 타입이 얇은 래퍼로 구현되어 있습니다. 이 코드는 비교적 새 것이라 버그가 있을 수도 있지만, 지금까지 퍼징에서는 문제를 찾지 못했습니다.
저는 커스텀 할당자를 “큰 결심 없이” 실험해보고 싶었습니다. 그래서 앞서 말한 접근 중 일부를 lyon의 fill 테셀레이터에 적용해 보았습니다. 이 실험에 완벽한 크기입니다. 사소하지 않은 코드 덩어리에서 어떻게 전개되는지 보여줄 만큼 충분히 크지만, 몇 가지 접근을 시도하기에 관리 가능한 시간 안에 끝낼 수 있을 만큼 충분히 작습니다.
fill 테셀레이터는 벡터 패스에서 삼각형 메시를 생성하는 알고리즘을 구현합니다( canvas 스타일 API나 SVG 문서에서 볼 수 있는 종류의 패스입니다). 내부적으로는 다양한 것들을 위해 메모리를 많이 할당해야 하는데, 이 글에서는 그걸로 지루하게 하지 않겠습니다. 앞서 언급했듯이, 테셀레이터는 순수 함수가 아니라 구조체로 되어 있어서, 이후 사용에서 메모리 할당을 유지할 수 있습니다.
실험을 위한 코드는 다음 브랜치에서 찾을 수 있습니다. - 제네릭 파라미터: alloc-generic branch - 빌린 트레이트 객체: alloc-trait-object branch - Zig 스타일 수동 관리 트레이트 객체: alloc-raw branch
가장 모험적인 독자라면 용기를 내서 코드를 모두 읽고 비교해 볼 수도 있겠지만, 안 한다고 탓하진 않겠습니다. 이 작은 실험 후 제 생각은 다음과 같습니다.
Allocator 트레이트가 올바른 추상화라고 믿습니다.Default 트레이트를 구현하듯 관용적으로 해버리는 수준이 되었으면 합니다. 그 이상적인 세계에서는, 어떤 곳에서는 커스텀 할당자를 쓰도록 넌지시 유도하는 clippy 린트까지 있을지도 모릅니다.<things>가 사방에 있는 코드를 읽는 데 익숙해져야 할지도 모르겠습니다.저는 테셀레이터의 할당자 파라미터를 제네릭으로 만든 버전에서 다소 놀라운 성능 결과가 나왔다고 언급했습니다. 무슨 일이었을까요?
테셀레이터는 할당을 재활용하므로, 빠르거나 느린 할당자를 쓰는 것은 테셀레이터가 처음 실행될 때 성능에 주로 영향을 미쳐야 합니다. 저장소에는 동일한 할당자를 여러 번 재사용하는 벤치마크가 몇 개 들어 있으므로, 이 실험이 그 벤치마크에 영향을 주지 않는 것이 자연스럽습니다. 하지만 저는 과거에 정적 디스패치와 동적 디스패치 사이의 예상 밖 성능 차이에 여러 번 당해봤기 때문에, 확인 차원에서 벤치마크를 다시 돌렸습니다. 특히 몇 년 전 테셀레이터의 출력 파라미터를 제네릭 파라미터에서 트레이트 객체로 바꾸었을 때(테셀레이터를 제네릭 파라미터가 전혀 없게 만들었을 때) 성능이 크게 올라간 적이 있습니다. 이번에도 접근 방식들을 비교하며 같은 효과를 관찰했습니다. 할당자에 제네릭 파라미터를 추가하면 벤치마크가 확실히 5~8% 정도 악화됩니다. 다른 접근들은 (할당자 성능이 이 벤치마크의 요인이 아닐 테니) 대체로 동일한 성능을 보였습니다.
여기서 무슨 일이 벌어지는지 말하기는 어렵습니다. 코드 양이 꽤 있어서 생성된 코드를 눈으로 확인하는 것은 지금 당장 제가 감당할 수 있는 수고가 아닙니다. 대규모-ish 스케일에서 컴파일러 최적화가 어떻게 전개됐는지 보는 도구가 있으면 좋겠습니다. 저는 여기서 특별히 근거 있는 추측을 할 수 없습니다. 제가 아는 것은 다음뿐입니다.
관심이 있다면 다음은 성능 프로파일 몇 개입니다(링크는 시간이 지나면 사라질 수 있습니다).
제네릭 할당자 파라미터: (profile link)
test fill_events_01_logo ... bench: 36,856 ns/iter (+/- 6,452)
test fill_events_02_logo_pre_flattened ... bench: 22,195 ns/iter (+/- 503)
test fill_events_03_logo_with_tess ... bench: 87,390 ns/iter (+/- 1,464)
test fill_tess_01_logo ... bench: 71,749 ns/iter (+/- 1,279)
test fill_tess_03_logo_no_intersections ... bench: 82,140 ns/iter (+/- 379)
test fill_tess_05_logo_no_curve ... bench: 34,081 ns/iter (+/- 857)
test fill_tess_06_logo_with_ids ... bench: 70,721 ns/iter (+/- 1,166)```
비-제네릭: (profile link)
test fill_events_01_logo ... bench: 29,859 ns/iter (+/- 835)
test fill_events_02_logo_pre_flattened ... bench: 14,986 ns/iter (+/- 437)
test fill_events_03_logo_with_tess ... bench: 72,470 ns/iter (+/- 5,095)
test fill_tess_01_logo ... bench: 56,008 ns/iter (+/- 1,347)
test fill_tess_03_logo_no_intersections ... bench: 66,380 ns/iter (+/- 2,144)
test fill_tess_05_logo_no_curve ... bench: 28,297 ns/iter (+/- 171)
test fill_tess_06_logo_with_ids ... bench: 54,785 ns/iter (+/- 2,835)
빠른 소프트웨어는 메모리 할당을 염두에 두고 작성됩니다. 할당 재활용은 할당자 오버헤드를 덮어버리기 쉬운 방법이지만, 코드의 특정 부분에 맞는 올바른 할당자를 고르는 것은 또 다른 선택지이며, 더 효과적일 수도 있습니다. 이상적으로는 개발자가 두 가지 모두를 쉽게 꺼내 쓸 수 있어야 하고, 맥락에서 가장 말이 되는 것을 하면 됩니다.
저는 성능 지향 라이브러리가 추가할 기능으로 커스텀 할당자가 꽤 멋지다고 생각합니다. 오늘날 이를 지원하는 크레이트는 매우 적은데, 표준 라이브러리가 아직 커스텀 할당자를 안정화하지 않았기 때문이기도 하고, 또 전혀 생각하지 않아도 너무 쉽고 편리하기 때문이기도 할 것입니다. 생태계에는 특화된 커스텀 할당자를 위한 확실한 틈새가 있습니다. blink-alloc이 있고, 아마 다른 것도 몇 개 있을 겁니다. 이 틈새가 채워지고 할당자 API가 안정화되면 더 많은 사람이 이 재미에 합류하길 바랍니다. 저도 제 크레이트들에 커스텀 할당자를 통합할까요? 아마 그럴 것 같습니다. 어떤 방식이 좋은지에 대해선 더 생각해 봐야 합니다. 한 가지 방식이 다른 방식들보다 절대적으로 우월하다고는 생각하지 않습니다. 어떤 것에는 제네릭 파라미터가 очевид한 선택이지만, 다른 것들에서는 커스텀 할당자 지원을 위해 프로그램의 큰 덩어리를 제네릭으로 만드는 것이 지속 가능하지 않다고 느낍니다. 커스텀 할당자에 대한 동적 디스패치는 많은 경우 잘 작동할 수 있습니다. lyon에서는 Path 데이터 구조에는 제네릭 파라미터를, 테셀레이터에는 동적 디스패치를 쓰는 쪽으로 기울고 있습니다.
그리고 이 골치 아픈 제네릭 성능 상황이 있지만, 이는 더 일화적입니다. 요점은 어떤 것에 대해 코드를 제네릭으로 만드는 것은(좋든 나쁘든) 예상 밖의 결과를 낳을 수 있다는 점이고, 올바른 유일한 방법은 가정을 검증하는 것입니다. 저는 여전히 대체로 정적 디스패치가 더 자주 더 빠른 코드를 만들어낼 수 있길 바랍니다.
이 글에 대한 댓글/반응/토론: reddit
reddit 토론 몇 가지 이후의 메모를 덧붙입니다.
storage 제안 작성자 중 한 분과의 토론이 더 많은 맥락을 제공해 주었고, 그 제안을 더 긍정적으로 보게 만들었습니다.
Storage와 Allocator 트레이트가 둘 다 존재하고, 어떤 Storage 구현은 Allocator 구현을 활용할 수 있을 것입니다. Storage는 주로 인라인 메모리와 할당자가 제공한 메모리를 구분할 수 있게 해줄 것입니다.또한 저는 bumpallo 크레이트도 다시 떠올리게 되었습니다. 이것 또한 불안정한 Allocator 트레이트 지원을 구현합니다. 이 크레이트는 Allocator 트레이트를 폴리필하기 위해 allocator-api2에 의존하지 않습니다. 이는 건강한 생태계가 공통 기반 위에서 발전할 수 있도록 Allocator 트레이트의 안정화가 필요함을 강조합니다.
또 하나 흥미로운 하이라이트: okaoka는 전역 할당자에 훅을 걸어 할당자 간 전환을 가능하게 합니다. 제가 원했던 수준의 세밀함은 제공하지 않지만, 옵션을 제공하지 않는 서드파티 코드에 다른 할당자를 주입할 수 있게 해줍니다. 이 글에서 논의한 접근들 중 어떤 것도 그것을 가능하게 하지는 않습니다.