Rust stosuje optymalizację wartości niszy (znaną również jako wypełnianie niszy), aby wyeliminować przechowywanie dyskryminanty dla enumów, gdy wariant zawiera typ z nieprawidłowymi wzorcami bitowymi. Dla Option<&T>, wariant None jest reprezentowany przez wartość wskaźnika null – wzorzec bitowy, który jest nieprawidłowy dla &T, ponieważ odniesienia muszą być zawsze nienullowe. Pozwala to kompilatorowi na przechowywanie dyskryminanty implicitnie w samym wskaźniku, co zapewnia, że Option<&T> zajmuje dokładnie jedno słowo maszynowe. Ta optymalizacja dotyczy każdego typu posiadającego wartości niszy – nieprawidłowe wzorce bitowe, takie jak 0 dla NonZeroU32, wartości poza 0 lub 1 dla bool, czy konkretne wartości sentinela w strukturach #[repr(C)].
Podczas rozwijania wysokonakładowego abstrakcyjnego drzewa składniowego (AST) dla kompilatora przetwarzającego miliony węzłów, napotkaliśmy na poważne problemy z pamięcią związane z wskaźnikami rodzica i dzieci. Każdy węzeł wymagał opcjonalnych odniesień do swojego rodzica, lewego dziecka i prawego dziecka, początkowo zaimplementowanych jako Option<Box<Node>>.
Używanie Option<Box<Node>> generowało 16 bajtów na wskaźnik w systemach 64-bitowych – 8 bajtów dla wskaźnika Box i 8 bajtów dla dyskryminanty oraz wyrównania. Dla drzewa z 10 milionami węzłów sprowadzało się to do 480 megabajtów tylko na wskaźniki połączeń, przekraczając nasz budżet pamięciowy.
Rozważyliśmy trzy podejścia. Po pierwsze, zastąpienie Option<Box<Node>> surowymi wskaźnikami (*mut Node) używając null dla None. To wyeliminowało overhead, ale wymagało bloków unsafe w całym kodzie, narażając na wiszące wskaźniki i naruszając gwarancje bezpieczeństwa Rust. Po drugie, użycie alokatora arena z indeksami (usize) zamiast wskaźników. Chociaż przyjazne dla pamięci podręcznej, Option<usize> i tak wymagało 16 bajtów z powodu braku niszy w usize, a arytmetyka indeksów komplikowała API.
Wybraliśmy trzecie podejście: Option<NonNull<Node>> opakowane w bezpieczną abstrakcję ParentPtr. NonNull<T> przenosi niszę pod adresem 0, co pozwala Option<NonNull<Node>> pozostawać na poziomie 8 bajtów. Encapsulowaliśmy dereferencję unsafe w metodach opakowujących, zachowując bezpieczeństwo pamięci przy jednoczesnym osiągnięciu bezkosztowej abstrakcji. To zmniejszyło rozmiar pamięci AST o 50%, mieszcząc się w naszym ograniczeniu 256MB bez poświęcania bezpieczeństwa.
Dlaczego Option<Option<bool>> pozostaje pojedynczym bajtem, podczas gdy Option<Option<usize>> zwiększa się do 16 bajtów?
bool posiada 254 wartości niszy, ponieważ tylko wzorce bitowe 0 i 1 są ważne. Pierwsza warstwa Option zużywa jedną niszę (np. 2) do reprezentowania None, pozostawiając 253 pozostałych niszy. Druga warstwa Option zużywa kolejną niszę (np. 3) dla swojego wariantu None. W konsekwencji Option<Option<bool>> nadal mieści się w jednym bajcie. Z drugiej strony, usize nie ma nieprawidłowych wzorców bitowych – wszystkie 2^64 wartości to ważne adresy pamięci lub dane. Bez nisz, Option<usize> musi dołączyć bajt dyskryminanty, co skutkuje 16 bajtami (8 dla danych, 8 dla wyrównania). Zagnieżdżone warstwy Option nie mogą być optymalizowane dalej bez dostępnych niszy, więc Option<Option<usize>> pozostaje na 16 bajtach z wewnętrzną logiką dyskryminanty.
Dlaczego kompilator odrzuca optymalizację niszy dla enumów oznaczonych #[repr(C)] nawet gdy typ ładunku zawiera nisze?
Atrybut #[repr(C)] gwarantuje pamięciowe układanie zgodne z C z stabilnym porządkiem pól i wyraźnym przechowywaniem dyskryminanty w przewidywalnym przesunięciu. Standard języka C nie wspiera nakładających się wartości dyskryminanty z danymi ładunkowymi – dyskryminanty muszą znajdować się w dedykowanych lokalizacjach pamięci, aby zapewnić zgodność z FFI. Chociaż struktura taka jak NonNull<T> zawiera nisze (wskaźnik null), #[repr(C)] enumy nie mogą wykorzystać ich, aby nakładać się na dyskryminant, ponieważ zewnętrzny kod C oczekuje odczytu wyraźnej wartości dyskryminanty w stałym przesunięciu. To ograniczenie zachowuje interoperacyjność kosztem efektywności pamięci, zapewniając, że sizeof(Option<&T>) równa się sizeof(&T) + sizeof(dyskryminanta) pod #[repr(C)], zazwyczaj 16 bajtów zamiast 8.
Jak działa funkcja std::mem::discriminant dla typów takich jak Option<&T>, które nie mają wyraźnego przechowywania dyskryminanty w pamięci?
std::mem::discriminant zwraca nieprzezroczystą wartość Discriminant<T>, która unikalnie identyfikuje wariant enum niezależnie od reprezentacji pamięci. Dla Option<&T>, kompilator generuje kod, który wyprowadza dyskryminant analizując wartość wskaźnika – zwracając stałą reprezentującą Some, jeśli wskaźnik jest nienullowy, oraz stałą reprezentującą None, jeśli jest null. Chociaż nie ma oddzielnej lokalizacji pamięci dla przechowywania tagu dyskryminanty, typ Discriminant abstrahuje tę obliczenia, umożliwiając porównanie wariantów za pomocą == bez ujawniania szczegółów kodowania niszy. To pokazuje, że dyskryminant działa na podstawie semantycznej tożsamości wariantu, a nie fizycznego układu pamięci, co umożliwia spójne zachowanie w różnych reprezentacjach enum, zarówno zoptymalizowanych, jak i nieoptymalizowanych.