Odpowiedź na pytanie.
Rust stosuje strategię optymalizacji układu znaną jako wypełnianie wartości niszowych, aby wyeliminować obciążenie pamięci związane ze wskaźnikami enumów, gdy warianty zawierają typy z nieprawidłowymi wzorcami bitowymi. Kompilator identyfikuje "wartości niszowe" w obrębie reprezentowalnego zakresu typu—takie jak wartość zerowa dla NonZeroU32 lub wskaźnik null dla referencji—i przekształca te wzorce bitowe, aby zakodować inne warianty enum, takie jak None. Ta transformacja opiera się na tym, że typ ładunku posiada ograniczony zakres ważności określony przez jego właściwości wewnętrzne lub atrybuty rustc_layout. Aby typ mógł pełnić rolę wiarygodnego nośnika niszy, musi wykazać przynajmniej jeden wzorzec bitowy, który stanowi niezdefiniowane zachowanie podczas konstruowania lub odczytywania, co pozwala kompilatorowi zarezerwować ten wzór dla alternatywnych wariantów enum bez przydzielania dodatkowej przestrzeni na wskaźnik.
Sytuacja z życia
Podczas opracowywania silnika handlu wysokich częstotliwości, nasz zespół napotkał poważny nacisk na pamięć podczas przechowywania milionów znaczników czasowych zamówień w Vec<Option<u64>>. Każdy opcjonalny znacznik czasowy zajmował 16 bajtów z powodu wyrównania i obciążenia wskaźnika, mimo że same znaczniki czasowe były ściśle dodatnie, oparte na wartościach czasu Unix. Pilnie potrzebowaliśmy zmniejszyć zużycie pamięci bez poświęcania bezpieczeństwa lub uciekania się do wskaźników raw, które skomplikowałyby gwarancje Send i Sync wymagane do przetwarzania między wątkami.
Jednym z rozważanych podejść było ręczne pakowanie bitów za pomocą raw u64 oraz sentinelowych wartości zerowych z niebezpiecznymi funkcjami konwersji. To rozwiązanie obiecywało maksymalną efektywność pamięci, ale wprowadzało katastrofalne ryzyko: błąd logiczny mógłby skonstruować nieprawidłowy NonZeroU64 lub dereferencjonować wskaźnik null udający zero, naruszając inwarianty bezpieczeństwa pamięci Rust. Co więcej, wymagałoby to rozbudowanych ścieżek audytowych i bloków unsafe, których nasz zespół starał się unikać.
Innym rozważanym kandydatem było bezpośrednie użycie Optionstd::num::NonZeroU64, wykorzystujące gwarantowaną optymalizację niszy z biblioteki standardowej. To podejście utrzymywało pełne bezpieczeństwo typów i ergonomiczne wyrażenia match, jednocześnie zapewniając, że Option zajmuje dokładnie 8 bajtów zamiast 16. Głównym ograniczeniem było to, że musieliśmy zapewnić, że znaczniki czasowe nigdy nie były zerowe, co było zgodne z naszą logiką domeny, ponieważ wszystkie znaczniki czasowe były po 1970 roku.
Wybraliśmy drugie rozwiązanie, refaktoryzując nasz nowy typ Timestamp, aby otaczać NonZeroU64 i walidując dane wejściowe na granicy systemu. W wyniku tego osiągnęliśmy 50% redukcji zużycia pamięci dla naszej podstawowej pamięci podręcznej zleceń. Ta optymalizacja wyeliminowała „thrashing” pamięci i poprawiła opóźnienia wyszukiwania o 30%, wszystko osiągnięte bez jednego znaku kodu unsafe.
Czego często brakuje kandydatom
Dlaczego Option<u32> zajmuje 8 bajtów, podczas gdy Option<NonZeroU32> zajmuje tylko 4, i jak ta optymalizacja zachowuje się przy zagnieżdżonych typach takich jak Option<Option<NonZeroU32>>?
Typ u32 dopuszcza wszystkie 2^32 wzorce bitowe jako ważne, pozostawiając żadnego „dodatkowego” wzorca bitowego dla kompilatora do wykorzystania jako wariant None. W związku z tym kompilator musi dodać bajt wskaźnika (wyrównany do 4 bajtów), co powoduje łącznie 8 bajtów. Z drugiej strony, NonZeroU32 wyraźnie deklaruje, że wzór bitowy 0x00000000 jest nieprawidłowy, tworząc niszę, którą Rust wykorzystuje do kodowania None, co pozwala, aby wynikowy Option zajmował dokładnie 4 bajty.
W przypadku struktur zagnieżdżonych, optymalizacja łańcuchów działa skutecznie: Option<Option<NonZeroU32>> pozostaje 4 bajty, ponieważ zewnętrzny Option wykorzystuje inny nieprawidłowy wzór bitowy (np. 0x00000001) z dostępnej przestrzeni niszy NonZeroU32. Ta rekurencyjna optymalizacja trwa, pod warunkiem że typ nośnika posiada wystarczające nieprawidłowe wzorce bitowe, aby pomieścić wszystkie wartości wskazujące enum.
Jakie znaczenie mają jawne atrybuty układu takie jak #[repr(C)] lub #[repr(u8)] w kontekście optymalizacji niszy, i dlaczego ta interakcja ma znaczenie dla granic FFI?
Stosując #[repr(C)] lub #[repr(u8)], programista narzuca ustalony układ pamięci, w którym wskaźnik zajmuje określony przesunięcie o zdefiniowanej wielkości. Ta jawna reprezentacja skutecznie wyłącza optymalizację niszy, zapewniając zgodność ABI z strukturami C, które oczekują jawnych znaczników, ale zmuszają enum do zajmowania dodatkowej przestrzeni na wskaźnik.
W kontekście FFI ta różnica okazuje się kluczowa, ponieważ kod C oczekuje wskaźnika w przewidywalnym, stabilnym przesunięciu. Przesyłanie zoptimizowanego enumu Rust bez jawnych atrybutów repr przez granicę skutkuje niezdefiniowanym zachowaniem, podczas gdy #[repr(C)] gwarantuje stabilność układu przy koniecznym koszcie efektywności pamięci.
Co uniemożliwia MaybeUninit<T> pełnienie roli nośnika niszy dla optymalizacji enumów, nawet gdy T sam w sobie posiada nieprawidłowe wzorce bitowe, takie jak w Option<MaybeUninit<NonZeroU32>>?
MaybeUninit<T> jest architektonicznie zaprojektowane, aby pomieścić dowolny wzór bitowy, nie wywołując niezdefiniowanego zachowania, ponieważ jego celem jest reprezentacja potencjalnie niezainicjowanej pamięci. W związku z tym kompilator traktuje MaybeUninit<T> jako pozbawione nieprawidłowych wzorców bitowych, co oznacza, że jego zakres ważności obejmuje wszystkie 2^(8*sizeof(T)) możliwe kombinacje bitowe. Ta całkowita ważność eliminuje dostępne nisze, które mogłyby zostać wykorzystane do optymalizacji enumów, niezależnie od właściwości T.
W związku z tym Option<MaybeUninit<NonZeroU32>> zajmuje 8 bajtów—rozmiar MaybeUninit<u32> plus wyrównanie wskaźnika—mimo że podstawowy NonZeroU32 ma ograniczoną ważność. To zachowanie ilustruje, że optymalizacja niszy działa ściśle w oparciu o ograniczenia ważności bezpośredniego typu, a nie transakcyjne właściwości jego potencjalnych zawartości.