本網站內容使用人工智慧(AI)或機器翻譯技術翻譯,可能存在錯誤。

Skip to content

使用 Vulkan 建立強健的管線快取

在建構 Vulkan 渲染器時,有許多新概念需要學習。其中有些概念比其他概念更容易處理,而管道快取便是較為直觀的新增功能之一。為了確保管道建立盡可能高效,您需要建立一個管道快取,並在每次需要建立新管道時使用它。 為了確保應用程式後續執行時無需耗費時間反覆編譯著色器微程式,您需要將管線快取資料儲存至檔案中,並在下次應用程式啟動時載入。這難道很難嗎?

事實證明,這其實相當困難。

管線快取裡有什麼

管線快取資料是一個(大多)不透明的資料塊;您會建立一個 VkPipelineCache 物件,可能需要提供初始資料塊作為起點,之後在某個時間點,您可以從這個物件中取得資料塊。

雖然除了閱讀圖形驅動程式原始碼之外,我們對該資料塊的內容所知甚少,¹ 但可以確定的是,管線快取資料必定以一個識別裝置的結構體開頭,其樣貌大致如下:

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];
}; 

標頭之後是驅動程式專屬的資訊,通常包含著色器微程式片段(其格式取決於 GPU)以及輔助資料,這些輔助資料可能包含任意由驅動程式定義的結構體。 部分驅動程式將此資料塊視為結構化檔案流並從中讀取資料;另有些驅動程式則將驅動程式原始碼中定義的原始結構儲存於該資料塊中,並透過 `memcpy` 或指標轉換來導航資料;毋庸贅言,驅動程式更新可能會使資料的儲存方式失效。

理論上,應用程式只需在達到穩態後(例如在應用程式退出之前…)使用 `vkGetPipelineCacheData` 擷取資料塊,將該資料塊儲存至檔案,然後在下一次執行時建立管線快取時,透過 `VkPipelineCacheCreateInfo::pInitialData` 傳遞此資料塊。 若該資料塊的內容不適用於當前版本的驅動程式——可能是驅動程式已更新,或是使用者切換至不同的 GPU——驅動程式理應忽略初始資料並建立一個空的管線快取。

但理論與實踐略有不同。實際上的經驗法則在於,驅動程式僅能正確處理先前由完全相同的驅動程式提供給您應用程式的精確二進位物件,而問題正始於此。2

驅動程式是否相同?

規格假設不同裝置之間的快取並不相容(這也是為何標頭中會包含 vendorID 和 deviceID),並依賴驅動程式建立一個管線 UUID(即 16 位元組的 GUID),用以精確識別所有能解讀管線快取 blob 的相關因素——您可以將其視為管線快取格式的版本號。 舉例來說,在驅動程式升級過程中,可能發生管道快取格式更新的情況,此時 UUID 通常不應改變,應用程式也不需要從頭重新編譯著色器。

然而,實際環境中的驅動程式往往會出現兩種類型的問題。

某些(較舊的)驅動程式未能正確驗證 UUID。因此,在驅動程式更新期間,應用程式可能會嘗試將具有過期 UUID 的二進位檔傳給驅動程式,驅動程式會試圖將其解讀為最新資料,結果可能導致 `vkCreatePipelineCache` 發生當機。請注意,一般而言,`vkCreatePipelineCache` 並不能保證會接受任意資料並能妥善處理。

某些驅動程式(包括相當新近的版本)在驅動程式更新時,可能會忽略更新 UUID,這實際上會破壞著色器管線二進位檔的相容性。這種情況可能發生在驅動程式版本更新時(儘管較為罕見),或(至少在某家主要供應商的現行驅動程式中輕易發生)於針對不同 ABI 編譯的、源自相同版本的驅動程式二進位檔之間。 若同一系統上隨附的 32 位元驅動程式與 64 位元驅動程式具有相同的管線 UUID,則從 32 位元版本的應用程式儲存快取,並從 64 位元版本載入該快取,可能會導致驅動程式當機——這正是當您發行 32 位元版本的應用程式,隨後依照 Google 指南將其更新為 64 位元時所發生的情況。

資料是否相同?

既然我們已知在標頭驗證方面會面臨什麼情況,接下來就是驗證資料。在呼叫 `vkGetPipelineCacheData` 之後,應用程式會儲存該二進位資料塊,並在下次執行時載入完全相同的二進位資料塊。

事實證明,將資料儲存至檔案基本上難以完美實現的。檔案系統問題以及進程穩定性問題,在某些情況下可能會導致檔案僅被部分寫入、末尾充斥著零(甚至垃圾資料),或是(作為特殊情況)檔案雖已建立卻始終保持零大小。 在行動裝置上,情況可能更為複雜,因為應用程式很可能在任意時刻被使用者或作業系統突然終止,這種情況在桌面裝置上較少發生。在 Android 平台上,使用多進程(多活動)應用程式也很常見,如果您的管道快取程式碼在兩個進程中運行且共用同一個輸出檔案,這些挑戰將變得更加難以解決。

零大小檔案之所以特別值得關注,是因為我們曾遇到至少一個驅動程式版本,在建立管道快取時,若傳入非空的 `pInitialData` 和 `initialDataSize == 0` 參數,系統會回傳錯誤。這也引出了最後一項注意事項。

錯誤處理很困難

儘管規格說明指出,除非記憶體不足,否則 vkCreatePipelineCache 基本上應始終成功,但規格中的此類陳述鮮少完全準確。在建立管線快取時,若初始資料不相容,驅動程式理應忽略該資料。此情況可能發生於檔案大小為零、儲存的 UUID 與預期 UUID 不符,或因其他原因導致反序列化失敗時。然而,部分驅動程式卻會直接失敗而無法建立管線快取。

此處絕對非用戶之過,因此強制終止應用程式實非妥當之舉。雖然通常可在無管道快取的情況下繼續執行,但這通常是個糟糕的主意,因為這意味著每個管道都必須從頭重新編譯。換言之,即使管道快取未序列化至磁碟,它們仍具實用價值,因為它們允許驅動程式在記憶體中跨管道物件快取編譯結果。

所有這些自然引領我們到……

如果他們真的想對付你,那就不算偏執

……解決方案。在將管線快取資料序列化至檔案時,我們會使用一個包含足夠驗證資訊的標頭,並緊接著寫入管線快取資料:

  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> 
};

透過管道快取資料的雜湊值,我們便能驗證資料的完整性。為降低 I/O 錯誤實際導致資料完整性問題的機率,我們會建立一個暫存檔,先將此標頭寫入檔案,接著寫入管道快取資料,最後透過 `rename` 指令將檔案移動至目標位置。3

載入管線快取時,我們會讀取標頭、讀取資料,並利用 dataSize 和 dataHash 驗證所讀取的資料,接著透過比對剩餘欄位與裝置的屬性,確認資料可安全地傳遞給驅動程式。4

若資料有效,則會傳入正確的初始資料並呼叫 `vkCreatePipelineCache`。關鍵在於,若此呼叫失敗,則表示驅動程式實作了我們的邏輯無法自行偵測到的額外檢查。因此,與其不使用管線快取繼續執行,我們會在此情況下再次呼叫 `vkCreatePipelineCache`(不傳入初始資料),以建立一個空的管線快取。

此外,若未找到管線快取檔案,或我們的驗證邏輯判定資料無法使用,我們也會建立空的管線快取。

注意:由於我們將 driverVersion 納入標頭中,任何驅動程式更新都會導致管線快取重新建立;我們加入此檢查,是因為這能徹底消除管線快取 UUID 即使應該更新卻未更新的問題——通常 driverVersion 會作為建置流程的一部分而更新,而 UUID 的更新則更傾向於手動操作。 對於僅針對桌面環境的應用程式,此做法可能過於激進——一般而言,桌面驅動程式在處理管道快取有效性方面通常表現更為穩定,因此並非所有建議都適用。

結論

遺憾的是,Vulkan 驅動程式並非總是正確無誤,也未必完全遵循規格規範。管道快取資料是 Vulkan 渲染器中特別脆弱的部分,因為 I/O 操作很難做到完全正確,且驅動程式中的完整性檢查往往極少甚至完全沒有。然而,透過足夠的應用程式端驗證,您實際上可以消除因管道快取處理所導致的穩定性問題——只是需要花點功夫。

  1. 而這在當今絕對是可行的!例如,這裡提供了一個針對 radv 的 vkGetPipelineCacheData 實作範例。↩
  2. 本文其餘內容基於我們持續在 Android 平台上發布支援 Vulkan 的 Roblox 客戶端,並在歷經各種 Android 作業系統更新、驅動程式更新,以及處理各大廠商早期與現行 Vulkan 驅動程式的過程中所累積的經驗。↩
  3. 理論上,檔案重命名應為原子操作,但在實務中,其確切語意與保證會因檔案系統而異;因此,使用雜湊值進行穩健的比對是相當有用的方法。↩
  4. 視應用情境而定,您可能還需根據供應商識別碼 (vendorID) 或驅動程式 ABI 等因素使用不同的檔案名稱;此做法在桌面端較為實用,但在行動裝置上則較不常見。↩

原文發表於:https://zeux.io/2019/07/17/serializing-pipeline-cache/

Arseny Kapoulkine 過去十年間一直致力於遊戲技術領域。他曾從事渲染、物理模擬、語言執行環境、多執行緒等眾多領域的工作,至今仍在遊戲開發中持續發掘那些需要低階思維來解決的令人興奮的難題。在協助推出多款 PS3 遊戲(包括數款《FIFA》系列作品)後,他於 2012 年加入 Roblox,並自此持續投入內部引擎的開發,協助年輕的遊戲開發者實現他們的夢想。

Roblox 公司及本部落格均不對任何公司或服務進行背書或支持。此外,對於本部落格所含資訊的準確性、可靠性或完整性,亦不作任何保證或承諾。

©2021 Roblox Corporation。Roblox、Roblox 標誌及「Powering Imagination」均為我們在美國及其他國家的註冊及未註冊商標。