Rust의 enum 크기 최적화 (번역)

https://adeschamps.github.io/enum-size

Rust가 다른 언어에서 빌려온 아이디어 중에서 사람들이 가장 좋아하는 건 enum 같습니다.

sum 타입, algebraic 데이터타입 또는 태그가 달린 공용체(tagged union)라고도 하죠. 그리고 일반적으로 함수형 프로그래밍 언어와 관련된 개념이지만 다른 패러다임을 방해하는 요소는 없습니다.

enum은 많은 사람들이 그 유용성에 동의하는 것 같습니다. 어지간한 언어들에서 다 제공되고 있잖아요?

Elm, Swift 및 Typescript와 같은 다른 새로운 언어에도 이러한 기능이 있으며, C++17에는 표준 라이브러리에 std::variant가 생겼습니다. Boost에는 boost::variant가 있죠.

이 기사에서는 Rust의 enum에 약간 익숙하다고 가정하겠습니다. 다른 언어로 유사한 기능을 사용해본 적이 있어도 괜찮습니다. 이걸 처음본다면 Rust book의 enum을 먼저 보시는걸 권해요.




enum의 메모리 레이아웃

enum의 다양한 이름 중에서 태그달린 union은 메모리에서 enum이 표현되는 방식을 가장 잘 설명한다고 생각합니다.

Rust의 enum 표현은 정확하게, 모든 가능한 variants들의 union입니다. 가장 큰 페이로드의 사이즈를 공간으로 두고, 태그값을 갖죠. 태그는 현재 갖고있는 variant가 무엇인지 알려줍니다.

다음 enum을 참고해보세요

enum Name **
{ **
** Anonymous, // 0 바이트 페이로드

** Nickname(String),
** // 24 바이트 페이로드******
** FullName{ first: String, last: String }, // 48 바이트 페이로드**
**} **

이 경우에 가장 큰 variant는 FullName입니다.
Rust의 String은 24 바이트거든요. 포인터+사이즈+ capacity 각 8바이트씩의 총합이에요.

태그는 1바이트가 필요하지만 정렬(alignment) 상의 이유로 8 바이트가 필요합니다.

그래서 std::mem::size_of::() == 56가 되죠.

[ tag (8 bytes) ]
**[ **
** empty **
** OR **
** String **
** OR **
** { String, String } (48 bytes) **
**] **

그런데 겨우 세 가지 다른 값만 가질 수 있는 것에 8 바이트를 사용하는 건 낭비처럼 보입니다.

이러한 이유로 Rust는 참조와 관련된 몇 가지 일반적인 경우를 최적화했습니다.

예를 들어 null 포인터 최적화는 참조가 절대 null이 될 수 없다는 점을 가져다가 대신 태그를 나타내는 특수 값으로 사용합니다.

만약 Option<&T>을 쓴다면

[ tag (8 bytes) ][ pointer (8 bytes) ]
대신에

[ pointer (8 bytes) ]
를 쓰게되죠.

포인터가 null이면 Option이 None이라는 것을 알지만 유효한 포인터면 Some(&T)임을 알 수 있죠.




Stylo에서의 트레이드오프

Rust Belt Rust 2017에서 Josh Matthews는 Rust로 작성된 Stylo CSS 엔진을 주로 C++인 Firefox에 통합하는 것에 대해 훌륭한 강연을했습니다.

그의 슬라이드에서 설명한 고충 중 하나는 enum 중첩을 시작하면 태그가 누적되어 타입의 크기가 눈덩이처럼 불어날 수 있다는 점입니다.

예는 다음과 같습니다.

**enum BorderStyleValue **
**{ **
** Solid, **
** Dashed(Option) **
**} **


**type BorderStyle = Option; **

BorderStyle에는 3개의 레이어가 중첩된 enum이 있으며, 16바이트로 상당히 무겁습니다. 각각 4 바이트짜리 태그가 3개인 상황이죠.

이 경우엔, 약간 덜 인체 공학적이지만 메모리에 효율적인 것을 선택하기로 결정했습니다.

**enum BorderStyle **
**{ **
** None, **
** Solid, **
** DashedNone, **
** Dashed(u32) **
**} **

이 타입은 8 바이트에 불과하며 첫번째 버전과 똑같은 값을 나타낼 수 있지만, 작업하기에는 좋지 않습니다.

모든 것은 트레이드 오프고, 이 경우에는 메모리 효율이 더 중요했습니다.



케이크를 가지고 먹으세요.

(Have your cake and eat it too)

내가 모든것은 트레이드오프라고 말했나요?
글쎄요. 꼭 그렇지만은 않습니다.
널포인터 최적화를 기억하시나요?

한 타입의 가능한 모든 값이 할당된 비트들보다 훨씬 적은 비트에 들어갈 수 있고, 이러한 추가 비트들이 태그에 사용될 수 있는 경우가 많이 있습니다.

예를 들어 bool은 실제로 1 비트만 필요하지만 전체 바이트로 채워집니다.

정보를 채울 수 있는 7개의 비트가 더 있죠!

Rust 컴파일러의 최근 PR은 타입이 메모리에 배치되는 방식을 리팩토링하여 몇 가지 새로운 최적화를 추가했습니다.

예를 들어 Rust 1.22에서는 Option 이 2바이트였는데, 1.23에서는 1바이트가 됩니다.

사용되지 않는 추가 비트를 찾을 또 다른 위치로는 다른 enum의 태그가 있습니다.

Josh의 강연에서, BorderStyleValue의 원래 정의에서 각 중첩 enum은 두 variant만 구분했습니다.

각 태그는 유용한 정보의 단일 비트만 전달했음에도, 각 태그가 4바이트로 채워졌죠.

Rust 1.23에서는 이러한 모든 정보를 훨씬 더 좁은 공간에 압축할 수 있으며, BorderStyleValue는 다음과 같이 나타낼 수 있습니다.

[ all the tags (4 bytes) ][ u32 (4 bytes) ]

이제는 이전의 인체공학적이지 않지만 메모리 친화적인 버전과 마찬가지로 8바이트로 압축이 된다는 거죠.

앞으로 Stylo 개발자들은 더 이상 이와 같은 트레이드오프를 만들 필요가 없습니다.

그러나 이 타입에는 여전히 약간의 패딩이 있습니다. 태그는 4개의 바이트를 모두 사용하지 않죠.
하지만 이는 다른 enum에 이것을 래핑하면 그 크기가 이전처럼 미친듯이 불어나지는 않는다는 것을 의미합니다.

Result<BorderStyleValue, i32>도 여전히 8바이트일 것이고요.




결론

이것은 구현 세부사항을 지정하지 않아서 언제든 뛰어난 최적화를 덧붙일 수 있는 방법의 예시입니다.

이 기사의 ASCII 메모리 레이아웃 다이어그램이 유용한 멘탈 모델이라고 생각하지만 정확하지 않을 수 있습니다.

Rust가 enum의 메모리 레이아웃이 항상 태그 뒤에 union이 오는 것을 보장했다면 혹시나 있을 누군가의 코드를 깨뜨리지 않고는 이 최적화를 할 수 없었을 것입니다.

대신 컴파일러는 타입의 레이아웃을 최적화할 수 있는 자유를 유지하며, 새 버전의 컴파일러로 업데이트를 하기만 해도 코드의 메모리 효율성이 높아지는 장점이 있죠.