Tworzenie solidnej pamięci podręcznej potoku za pomocą Vulkan

Podczas tworzenia renderera Vulkan trzeba nauczyć się wielu nowych pojęć. Niektóre z nich są łatwiejsze do opanowania niż inne, a jednym z prostszych dodatków jest pamięć podręczna potoku. Aby zapewnić jak największą wydajność tworzenia potoków, należy utworzyć pamięć podręczną potoku i korzystać z niej za każdym razem, gdy trzeba utworzyć nowy potok. Aby kolejne uruchomienia aplikacji nie musiały tracić czasu na wielokrotną kompilację mikrokodu shadera, należy zapisać dane pamięci podręcznej potoku do pliku. Następnie należy je załadować przy kolejnym uruchomieniu aplikacji. Jak trudne to może być?
Okazuje się, że całkiem trudne.
Co znajduje się w pamięci podręcznej potoku?
Dane pamięci podręcznej potoku to (w większości) nieprzejrzysty blob; tworzysz obiekt VkPipelineCache, ewentualnie podając mu początkowy blob, a potem w pewnym momencie możesz pobrać blob danych z tego obiektu.
Chociaż nie wiemy zbyt wiele o zawartości tego blobu, poza tym, co wynika z kodu źródłowego sterownika graficznego,1 dane z pamięci podręcznej potoku na pewno zaczynają się od struktury, która identyfikuje urządzenie i wygląda mniej więcej tak:
struct VkPipelineCacheHeaderOne<br>
{
uint32_t length; // == sizeof(VkPipelineCacheHeaderOne)
uint32_t version; // == VK_PIPELINE_CACHE_HEADER_VERSION_ONE
uint32_t vendorID;
uint32_t deviceID;
uint8_t uuid[VK_UUID_SIZE];
}; Po nagłówku następują informacje specyficzne dla sterownika, które zazwyczaj zawierają fragmenty mikrokodu shadera (którego format zależy od procesora graficznego) oraz dane pomocnicze, które mogą zawierać dowolne struktury zdefiniowane przez sterownik. Niektóre sterowniki traktują ten blob jako ustrukturyzowany strumień plików i odczytują z niego dane, inne przechowują w nim surowe struktury zdefiniowane w kodzie źródłowym sterownika i używają funkcji `memcpy` lub rzutowania wskaźników do poruszania się po danych; nie trzeba dodawać, że aktualizacja sterownika może unieważnić sposób przechowywania danych.
Teoretycznie aplikacja musi jedynie użyć funkcji `vkGetPipelineCacheData`, aby pobrać blok danych po osiągnięciu stanu stabilnego (na przykład przed zamknięciem aplikacji…), zapisać ten blok do pliku, a następnie przekazać go za pomocą funkcji `VkPipelineCacheCreateInfo::pInitialData` podczas tworzenia pamięci podręcznej potoku przy następnym uruchomieniu. Jeśli zawartość blobu nie działa w bieżącej wersji sterownika — być może sterownik został zaktualizowany lub użytkownik przełączył się na inny procesor graficzny — sterownik powinien zignorować dane początkowe i utworzyć pustą pamięć podręczną potoku.
Jednak teoria i praktyka nieco się różnią. Ogólna zasada w praktyce jest taka, że sterownik będzie w stanie poprawnie obsłużyć tylko dokładnie ten sam blob, który ten sam sterownik przekazał aplikacji wcześniej, i tu zaczynają się problemy.2
Czy sterownik jest ten sam?
Specyfikacja zakłada, że pamięć podręczna nie jest kompatybilna między różnymi urządzeniami (dlatego w nagłówku znajdują się identyfikatory vendorID i deviceID) i polega na sterowniku w zakresie ustalenia identyfikatora UUID potoku (który jest 16-bajtowym identyfikatorem GUID), który dokładnie identyfikuje pełen zestaw czynników umożliwiających interpretację blobu pamięci podręcznej potoku — można to traktować jako numer wersji formatu pamięci podręcznej potoku. Na przykład podczas aktualizacji sterownika może się zdarzyć, że format pamięci podręcznej potoku nie zostanie zaktualizowany, w którym to przypadku identyfikator UUID zazwyczaj nie powinien ulec zmianie, a aplikacja nie będzie musiała ponownie kompilować shaderów od podstaw.
Jednak sterowniki dostępne na rynku mają zazwyczaj dwa rodzaje problemów.
Niektóre (starsze) sterowniki zaniedbują prawidłową weryfikację identyfikatora UUID. W rezultacie podczas aktualizacji sterownika aplikacja może próbować przekazać sterownikowi obiekt z nieaktualnym identyfikatorem UUID, sterownik będzie próbował zinterpretować to jako najnowsze dane, co może spowodować awarię aplikacji „vkCreatePipelineCache”. Należy pamiętać, że ogólnie rzecz biorąc, funkcja „vkCreatePipelineCache” nie gwarantuje, że akceptuje dowolne dane i potrafi je poprawnie przetworzyć.
Niektóre sterowniki, w tym całkiem nowe, mogą pominąć aktualizację identyfikatora UUID podczas aktualizacji sterownika, co faktycznie narusza kompatybilność pliku binarnego potoku shaderów. Może się to zdarzyć podczas aktualizacji wersji sterownika (choć jest to rzadkie) lub (co zdarza się często w przypadku aktualnych sterowników co najmniej jednego dużego dostawcy) między plikami binarnymi sterowników, które są skompilowane z tej samej wersji dla różnych interfejsów ABI. Jeśli sterownik 32-bitowy i sterownik 64-bitowy dostarczane w tym samym systemie mają ten sam identyfikator UUID potoku, zapisanie pamięci podręcznej z 32-bitowej wersji aplikacji i załadowanie jej z wersji 64-bitowej może spowodować awarię sterownika — co dokładnie ma miejsce, gdy dostarczasz 32-bitową wersję aplikacji, a następnie aktualizujesz ją do wersji 64-bitowej zgodnie z wytycznymi Google.
Czy dane są takie same?
Teraz, gdy wiemy już, co nas czeka w zakresie walidacji nagłówków, następnym krokiem jest walidacja danych. Po wywołaniu funkcji `vkGetPipelineCacheData` aplikacja zapisuje obiekt blob, a przy następnym uruchomieniu wczytuje dokładnie ten sam obiekt.
Okazuje się, że zapisanie danych do pliku jest w zasadzie niemożliwe do wykonania w sposób poprawny. Problemy z systemem plików, a także kwestie stabilności procesu mogą w niektórych przypadkach prowadzić do sytuacji, w której pliki są zapisane tylko częściowo, mają fragmenty wypełnione zerami na końcu (lub nawet śmieciami) albo (jako przypadek szczególny) są utworzone, ale pozostają o zerowym rozmiarze. Na urządzeniach mobilnych sytuację może komplikować fakt, że aplikacja może zostać nagle zamknięta w dowolnym momencie przez użytkownika lub system operacyjny, co zdarza się rzadziej na komputerach stacjonarnych. W systemie Android często stosuje się również aplikacje wieloprocesowe (wielozadaniowe), a jeśli kod pamięci podręcznej potoku działa w obu procesach i korzysta z tego samego pliku wyjściowego, wyzwania te stają się jeszcze trudniejsze do rozwiązania.
Powodem, dla którego pliki o zerowym rozmiarze są szczególnie interesujące, jest to, że istnieje co najmniej jedna wersja sterownika, z którą się spotkaliśmy, w której przekazanie wartości niebędących NULL dla pInitialData i initialDataSize == 0 zwraca błąd podczas tworzenia pamięci podręcznej potoku. Co prowadzi nas do ostatniego zastrzeżenia.
Obsługa błędów jest trudna
Chociaż specyfikacja mówi, że vkCreatePipelineCache powinno zasadniczo zawsze zakończyć się sukcesem, o ile nie zabraknie pamięci, takie stwierdzenia w specyfikacji rzadko są dokładne. Podczas tworzenia pamięci podręcznej potoku sterownik powinien zignorować dane początkowe, jeśli są one niezgodne. Może to nastąpić, jeśli mają rozmiar zerowy, jeśli zapisany identyfikator UUID nie pasuje do oczekiwanego identyfikatora UUID lub jeśli deseryalizacja nie powiodła się z jakiegokolwiek innego powodu. Niektóre sterowniki zamiast tego nie tworzą pamięci podręcznej potoku.
Użytkownik zdecydowanie nie ponosi tu winy, więc przerwanie działania aplikacji nie byłoby uprzejme. Chociaż generalnie można kontynuować pracę bez pamięci podręcznej potoku, zazwyczaj jest to fatalny pomysł, ponieważ oznacza to, że każdy potok musi zostać skompilowany od nowa. Oznacza to, że pamięci podręczne potoków mają swoje zastosowanie, nawet jeśli nie są one serializowane na dysk, ponieważ pozwalają sterownikowi buforować wyniki kompilacji obiektów potoku w pamięci.
Wszystko to naturalnie prowadzi do…
To nie jest paranoja, jeśli naprawdę chcą cię dopaść
… do rozwiązania. Podczas serializacji danych pamięci podręcznej potoku do pliku używamy nagłówka, który zawiera wystarczającą ilość informacji, aby móc zweryfikować dane, a dane pamięci podręcznej potoku znajdują się bezpośrednio po nim:
struct PipelineCachePrefixHeader<br>
{<br>
uint32_t magic; // an arbitrary magic header to make sure this is actually our file<br>
uint32_t dataSize; // equal to *pDataSize returned by vkGetPipelineCacheData<br>
uint64_t dataHash; // a hash of pipeline cache data, including the header<br>
uint32_t vendorID; // equal to VkPhysicalDeviceProperties::vendorID<br>
uint32_t deviceID; // equal to VkPhysicalDeviceProperties::deviceID<br>
uint32_t driverVersion; // equal to VkPhysicalDeviceProperties::driverVersion<br>
uint32_t driverABI; // equal to sizeof(void*)<br>
uint8_t uuid[VK_UUID_SIZE]; // equal to VkPhysicalDeviceProperties::pipelineCacheUUID<br>
};Skrót danych pamięci podręcznej potoku pozwoli nam zweryfikować integralność danych. Aby zmniejszyć ryzyko, że błąd wejścia/wyjścia faktycznie spowoduje problem z integralnością, tworzymy plik tymczasowy i zapisujemy w nim ten nagłówek, a następnie dane pamięci podręcznej potoku, po czym przenosimy plik do docelowej lokalizacji za pomocą polecenia rename.3
Podczas ładowania pamięci podręcznej potoku odczytujemy nagłówek, odczytujemy dane, weryfikujemy odczytane dane przy użyciu dataSize i dataHash, a następnie sprawdzamy, czy dane można bezpiecznie przekazać do sterownika, porównując pozostałe pola z właściwościami urządzenia.4
Jeśli dane są prawidłowe, wywoływana jest funkcja `vkCreatePipelineCache` z prawidłowymi danymi początkowymi. Co istotne, jeśli to wywołanie zakończy się niepowodzeniem, sugeruje to, że sterownik implementuje dodatkowe kontrole, których nasza logika nie wykryła samodzielnie. Zamiast więc kontynuować bez pamięci podręcznej potoku, w tym przypadku tworzymy pustą pamięć podręczną potoku, wywołując ponownie funkcję `vkCreatePipelineCache` bez danych początkowych.
Tworzymy również pustą pamięć podręczną potoku, jeśli nie znaleziono pliku pamięci podręcznej potoku lub nasza logika walidacji sklasyfikowała dane jako bezużyteczne.
Uwaga: ponieważ w nagłówku uwzględniamy wersję sterownika (driverVersion), każda aktualizacja sterownika spowoduje przebudowę pamięci podręcznej potoku; uwzględniamy tę kontrolę, ponieważ całkowicie eliminuje to problemy, w których identyfikator UUID pamięci podręcznej potoku nie aktualizuje się, nawet jeśli powinien — zazwyczaj wersja sterownika jest aktualizowana w ramach procesu kompilacji, podczas gdy aktualizacja identyfikatora UUID jest bardziej ręczna. W przypadku aplikacji przeznaczonych wyłącznie na komputery stacjonarne może to być zbyt agresywne — ogólnie rzecz biorąc, sterowniki komputerów stacjonarnych prawdopodobnie lepiej radzą sobie z obsługą ważności pamięci podręcznej potoku, więc nie wszystkie z tych porad mają zastosowanie.
Wniosek
Niestety, sterowniki Vulkan nie zawsze działają poprawnie i nie zawsze ściśle przestrzegają specyfikacji. Dane pamięci podręcznej potoku są szczególnie wrażliwą częścią renderera Vulkan, ponieważ prawidłowe działanie operacji wejścia/wyjścia jest trudne do osiągnięcia, a w sterowniku często występują minimalne lub żadne kontrole integralności. Jednak dzięki odpowiedniej walidacji po stronie aplikacji można w praktyce wyeliminować problemy ze stabilnością wynikające z obsługi pamięci podręcznej potoku — wymaga to po prostu pracy.
- Co w dzisiejszych czasach jest całkowicie możliwe! Na przykład, oto implementacja vkGetPipelineCacheData dla radv. ↩
- Pozostała część tego artykułu opiera się na doświadczeniach związanych z ciągłym dostarczaniem klienta Roblox na Androida z obsługą Vulkan oraz przetrwaniem różnych aktualizacji systemu operacyjnego Android, aktualizacji sterowników i ogólnie radzeniem sobie zarówno z wczesnymi, jak i obecnymi sterownikami Vulkan od wszystkich głównych dostawców. ↩
- Teoretycznie zmiana nazwy powinna być operacją atomową, ale w praktyce dokładna semantyka i gwarancje różnią się w zależności od systemu plików; hash jest przydatny jako sposób na przeprowadzenie solidnego porównania. ↩
- W zależności od aplikacji można również używać różnych nazw plików w oparciu o, na przykład, vendorID lub driverABI; jest to bardziej interesujące w przypadku komputerów stacjonarnych, a mniej w przypadku urządzeń mobilnych. ↩
Pierwotnie opublikowano na: https://zeux.io/2019/07/17/serializing-pipeline-cache/
Arseny Kapoulkine od dziesięciu lat zajmuje się technologią gier. Pracował nad renderowaniem, symulacją fizyki, środowiskami uruchomieniowymi języków programowania, wielowątkowością i wieloma innymi obszarami, a wciąż odkrywa ekscytujące problemy w tworzeniu gier, które wymagają myślenia na niskim poziomie. Po udziale w wydaniu wielu tytułów na PS3, w tym kilku gier z serii FIFA, w 2012 roku dołączył do Roblox i od tego czasu pracuje nad wewnętrznym silnikiem, pomagając młodym twórcom gier w realizacji ich marzeń.
Ani firma Roblox Corporation, ani niniejszy blog nie promują ani nie wspierają żadnej firmy ani usługi. Ponadto nie udziela się żadnych gwarancji ani obietnic dotyczących dokładności, wiarygodności lub kompletności informacji zawartych w niniejszym blogu.
©2021 Roblox Corporation. Roblox, logo Roblox oraz Powering Imagination należą do naszych zarejestrowanych i niezarejestrowanych znaków towarowych w Stanach Zjednoczonych i innych krajach.


