今天和大家分享一個(gè)優(yōu)化經(jīng)驗(yàn),主要關(guān)于獲取一個(gè)資源的依賴資源列表即對(duì)AssetDatabase.GetDependencies這個(gè)接口的調(diào)用效率優(yōu)化。通過(guò)一步步優(yōu)化最后在對(duì)工程中所有資源獲取依賴資源的執(zhí)行上提升了近100倍的效率。

在對(duì)AssetBundle進(jìn)行打包時(shí)候,需要獲取資源的依賴關(guān)系,并生成最后所有資源的BundleName。這里主要的瓶頸就是對(duì)資源的依賴關(guān)系數(shù)據(jù)獲取上。在工程實(shí)踐中發(fā)現(xiàn)整個(gè)構(gòu)建環(huán)節(jié)20分鐘,16分鐘是BuildAssetBundles開銷,3分鐘是GetDependencies開銷。在增量構(gòu)建中,BuildAssetBundles可降為1-3分鐘,而GetDependencies則仍需要3分鐘開銷。當(dāng)然對(duì)于資源數(shù)量較小的工程,這個(gè)優(yōu)化就是一個(gè)可有可無(wú)的選項(xiàng)對(duì)構(gòu)建速度影響不大。
還有一個(gè)常見(jiàn)的應(yīng)用場(chǎng)景就是快速查找資源資源的依賴數(shù)據(jù)以及被依賴數(shù)據(jù),也可以通過(guò)這次的優(yōu)化帶來(lái)體驗(yàn)上提升。
一
首先從分析AssetDatabase.GetDependencies這個(gè)接口的行為開始,簡(jiǎn)單的編寫一個(gè)測(cè)試函數(shù):
public static void Test()
{
long timeStamp = Stopwatch.GetTimestamp();
string[] dir = Directory.GetFiles("Assets/", "*.*", SearchOption.AllDirectories);
for (int i = 0; i < dir.Length; ++i)
{
if (dir[i].EndsWith(".meta", System.StringComparison.OrdinalIgnoreCase))
{
continue;
}
AssetDatabase.GetDependencies(dir[i], true);
}
UnityEngine.Debug.LogFormat(
"GetDependencies cost {0} ms.",
(Stopwatch.GetTimestamp() - timeStamp) * 1000 / Stopwatch.Frequency
);
}
通過(guò)執(zhí)行這個(gè)函數(shù)可以了解這個(gè)函數(shù)的開銷,以及獲得數(shù)據(jù)為之后的優(yōu)化做對(duì)比。
第一次執(zhí)行的時(shí)候較慢,有較高的硬盤讀寫,總共花費(fèi)6.3mins。
第二次執(zhí)行的時(shí)候快了近一倍,基本無(wú)硬盤讀寫,總共花費(fèi)3.2mins。
這里硬盤是使用SSD,如果使用機(jī)械鍵盤則這里性能堪憂,第二次基本所有內(nèi)容都進(jìn)了內(nèi)存,操作系統(tǒng)做了緩存,所以快了很多。所以換更好的硬盤可以提高這里的效率,不過(guò)現(xiàn)在的執(zhí)行時(shí)間還是太長(zhǎng)了。
如果對(duì)所有的資源進(jìn)行掃描通過(guò)GUID去查詢并獲取依賴關(guān)系,那應(yīng)該不止這么點(diǎn)時(shí)間。這里猜測(cè)Unity做過(guò)一些數(shù)據(jù)預(yù)處理與緩存來(lái)優(yōu)化這個(gè)接口效率。
這時(shí)候第一個(gè)優(yōu)化思路是Cache,通過(guò)緩存每次結(jié)果下次查詢時(shí)可以立即返回結(jié)果。不過(guò)由于資源會(huì)修改,依賴文件會(huì)發(fā)生變化,所以緩存可能會(huì)出錯(cuò)。如果不能判斷當(dāng)前緩存是否有效,則只能在確保資源部修改的情況下使用緩存數(shù)據(jù)。
這里使用AssetDatabase.GetAssetDependencyHash來(lái)驗(yàn)證緩存是否有效,這個(gè)接口返回Asset的一個(gè)Hash值(包括文件名以及meta文件),如果Hash值不變,我們可以認(rèn)為這個(gè)Asset直接依賴的資源文件不變,由于直接依賴是通過(guò)Asset文件內(nèi)部的GUID索引的,所以Hash不變即表示GUID不變,即依賴關(guān)系不變。這里緩存Hash值以及這個(gè)Asset的直接依賴。通過(guò)所有的直接依賴,可以快速的計(jì)算出這個(gè)Asset的全部依賴。
AssetDatabase.GetAssetDependencyHash接口非常高效,這里簡(jiǎn)單討論下。
這部分?jǐn)?shù)據(jù)在Import Asset的時(shí)候計(jì)算并緩存,所以可以高效獲取。每個(gè)Asset都有自己的AssetDependencyHash,Reimport的時(shí)候重新計(jì)算。這里判斷文件是否修改的依據(jù)是文件最后修改時(shí)間是否發(fā)生變化。獲取目錄下所有文件信息由于有操作系統(tǒng)文件系統(tǒng)做了索引是非常高效的。
由于Refresh是一個(gè)必要項(xiàng),這項(xiàng)開銷已經(jīng)花費(fèi)出去了,所以這里可以直接享受接口的高效率。
最后只要把每次的數(shù)據(jù)保存在本地,下次使用的時(shí)候先從本地加載,即可使這部分邏輯時(shí)間優(yōu)化到2800ms左右,優(yōu)化了近100倍,這里的執(zhí)行效率已經(jīng)非常優(yōu)異了,主要開銷在GetFiles上。
二
上面只討論了有緩存數(shù)據(jù)情況下的優(yōu)化情況,但實(shí)際緩存數(shù)據(jù)的加載和保存時(shí)間卻被忽略了。實(shí)際結(jié)果是這部分的數(shù)據(jù)量較大,加載和保存開銷也比較大,如果使用Json來(lái)存儲(chǔ)的話,這里大概要花費(fèi)1.5mins來(lái)讀寫這數(shù)據(jù)。
這里討論下對(duì)這個(gè)數(shù)據(jù)存儲(chǔ)效率的優(yōu)化,首先來(lái)看看數(shù)據(jù)結(jié)構(gòu):
public class DepenData
{
public string assetPath;
public Hash128 assetDependencyHash;
public string[] dependsPath;
}
// save data
Dictionary<string, DependData> m_data;
基本都是字符串?dāng)?shù)據(jù),存儲(chǔ)出來(lái)的文件都有300M左右(大概,具體忘了),把Json存儲(chǔ)改為二進(jìn)制以后,文件大小縮減為75M左右,加載時(shí)間從1.5mins變成了18.3S。較大的改進(jìn),不過(guò)還可以在改進(jìn)我想。
這里依賴數(shù)據(jù)是遞歸即 A依賴B,B依賴C,在A的DependData里面就會(huì)有ABC,而B的依賴數(shù)據(jù)里面有BC。這里可以發(fā)現(xiàn)BC出現(xiàn)了兩次,如果能把消除重復(fù)字符串,則可以近一步較少文件大小,提高讀寫速度。
修改后的結(jié)構(gòu)如下:
public class DepenData
{
public int assetPathIndex;
public Hash128 assetDependencyHash;
public int[] dependsPathIndex;
public string[] dependsPath; // 用于返回查詢結(jié)果,不保存
}
// save data
Dictionary<string, DependData> m_data;
List<string> m_strList;
// temp data
Dictionary<string, int> m_strIndex;
改進(jìn)后文件大小變?yōu)?8M,加載時(shí)間從18.3S優(yōu)化到3.3S。6倍的改進(jìn),挺棒的,這時(shí)候又在思考是否有改進(jìn)的余地。
第一個(gè)改進(jìn),把FileStream改為MemoryStream,數(shù)據(jù)則通過(guò)File.ReadAllBytes()讀取。這個(gè)改造可以把3.3S改進(jìn)為3S,主要是由于FileStream API調(diào)用的效率并不高,這里是通過(guò)減少調(diào)用頻率來(lái)改進(jìn)效率。對(duì)于FileStream每次ReadByte(2)和每次ReadByte(1024),可能有接近100倍的性能差異。
第二個(gè)改進(jìn),分析發(fā)現(xiàn)3S里面BinaryReader占用了2.7S,剩下數(shù)據(jù)結(jié)構(gòu)組織,填充Dictionary占用了0.3S。C#的BinaryReader實(shí)現(xiàn)并不高效,可以通過(guò)更高效的序列化數(shù)據(jù)方式來(lái)優(yōu)化。這里嘗試使用了FlatBuffers來(lái)替換BinaryReader,保存的開銷從880ms增長(zhǎng)到1200ms,讀取的時(shí)間從3000ms優(yōu)化到1200ms。又是一次大幅度的優(yōu)化,雖然現(xiàn)在收益時(shí)間已經(jīng)無(wú)關(guān)緊要了,不過(guò)實(shí)踐和驗(yàn)證想法也是不錯(cuò)的收獲。這里開啟FlatBuffers Unsafe模式應(yīng)該會(huì)有更高的收益,接近C++的性能,如果直接用C++寫性能果然會(huì)好很多吧。
三
Unity所有路徑都是Assets開頭,大量路徑字符串里面前綴包含重復(fù)數(shù)據(jù),數(shù)據(jù)結(jié)構(gòu)還可以再改進(jìn)......
把二進(jìn)制文件壓縮后從28M變成5M,確實(shí)很多冗余數(shù)據(jù),不過(guò)再改進(jìn)可能付出太多時(shí)間而受益太低。這次的優(yōu)化就到此為止了嘛。
這里上最后一個(gè)優(yōu)化思路異步化。
異步加載在游戲中是很常見(jiàn)的做法,所以這里其實(shí)再實(shí)現(xiàn)兩個(gè)異步化接口即可把這部分時(shí)間優(yōu)化為0,由于還有其他很多任務(wù)可以并行執(zhí)行,所以這部分時(shí)間在調(diào)整到適當(dāng)?shù)臅r(shí)機(jī)后可以忽略不計(jì)。
由于Unity的接口不能在多線程調(diào)用,所以一開始就不會(huì)往這個(gè)方面思考,后面問(wèn)題轉(zhuǎn)化后異步是一個(gè)非常優(yōu)異的做法,F(xiàn)latBuffers的改造非常繁瑣,浪費(fèi)了我大量測(cè)試時(shí)間。最后我把代碼回滾到二進(jìn)制版本,F(xiàn)latBuffers在運(yùn)行時(shí)確實(shí)能帶來(lái)巨大的效率提升,不過(guò)這里可能并不需要上這個(gè)利器了。
一、二的優(yōu)化是基于專注性思維的思考結(jié)果,而三則是發(fā)散性思維的思考結(jié)果。專注性思維容易陷入思維定式,這時(shí)候可以起來(lái)喝杯茶,出去散散步。
[完 Carber 2018-08-12]