跟Unity學代碼優化

第一次在知乎寫文章,發篇水文。

今天我們來聊聊如何跟Unity學代碼優化,準確地說,是通過學習Unity的IL2CPP技術的優化策略,應用到我們的日常邏輯開發中。

做過Unity開發的同學想必對IL2CPP都很清楚,簡單地說,IL2CPP就是Unity用來替代mono的一種script backend。至於說Unity為什麼用IL2CPP替代mono,就是另外的話題了,本文就不細港了。

IL2CPP由兩部分組成:

  • 一個AOT(ahead of time)compiler。完全用C#寫的。

  • 一個VM runtime library。主體C++,外加部分平台特定的彙編代碼。

IL2CPP AOT compiler的工作原理就如字面意思,讀取並Parse (雖然並不知道用Mono.Cecil算不算Parse)IL Assembly ,分析並優化,然後生成cpp代碼。IL2CPP的實現也很簡單,生成的C++代碼基本跟IL一一對應,有興趣的同學可以自己試一下寫點C#,然後看看生成的C++代碼。

IL2CPP正式release已經有一年多了,一開始人人質疑,現在大家已經基本接受。這種轉變肯定不是一日促成的,主要還是靠Unity對IL2CPP的重視和持續跟進的優化。

這兩個月,Unity官博發了一個IL2CPP優化三部曲,接下來我們就看看如何從其中學習代碼優化思路。

首先是第一個優化例子:

1 public abstract class Animal { 2 public abstract string Speak(); 3 } 4 5 public class Cow : Animal { 6 public override string Speak() { 7 return "Moo"; 8 } 9 }10 11 public class Pig : Animal {12 public override string Speak() {13 return "Oink";14 }15 }16 17 public class Farm: MonoBehaviour {18 void Start () {19 Animal[] animals = new Animal[] {new Cow(), new Pig()};20 foreach (var animal in animals)21 Debug.LogFormat("Some animal says "{0}"", animal.Speak());22 23 var cow = new Cow();24 Debug.LogFormat("The cow says "{0}"", cow.Speak());25 }26 }

這個是最教條主義的面向對象編程入門示例,很顯然,從常識來思考的話,示例中的animal.Speak()是多態的,而cow.Speak()不是,前者會做一次virtual function call,而後者會做一次direct function call,兩者的性能差距是一次虛函數表查詢。

但是,IL2CPP實際上並不會這麼做。IL2CPP的優化策略非常保守,而且為了實現簡單,IL2CPP並不會在讀IL指令的時候維護上下文狀態。因此IL2CPP看到cow.Speak()沒有辦法判斷cow的具體類型,保險起見,只能做一次虛函數表查詢,也就是表現為virtual function call。

當然優化起來也很簡單,程序員人肉加hint即可。而且這種hint方式我們在各種語言里都能見到,那就是給Cow的類型定義加一個sealed修飾符,問題終結。

優化一方面要跳過不需要的邏輯,另一方面還要簡化無法跳過的邏輯。畢竟對於大多數情況,virtual function call的開銷是逃不掉的。接下來,IL2CPP開發組又介紹了他們優化virtual function call的思路。

先看示例代碼:

1 class BaseClass { 2 public virtual string SayHello() { 3 return "Hello from base!"; 4 } 5 } 6 7 class GenericDerivedClass<T> : BaseClass { 8 public override string SayHello() { 9 return "Hello from derived!";10 }11 }12 13 public class VirtualInvokeExample : MonoBehaviour {14 void Start () {15 Debug.Log(MakeRuntimeBaseClass().SayHello());16 }17 18 private BaseClass MakeRuntimeBaseClass() {19 var derivedType = typeof(GenericDerivedClass<>).MakeGenericType(typeof(int));20 return (BaseClass)FormatterServices.GetUninitializedObject(derivedType);21 }22 }

MakeRuntimeBaseClass().SayHello()這個坑相信大家剛接觸Unity的時候都踩過,由於iOS平台不支持JIT compile method,這裡如果不做hint,就會導致真機運行時crash。

IL2CPP的runtime library實現也類似,會在SayHello這個virtual function call的過程中查一次虛表,如果找不到調用方法,就會拋出一個託管的異常。

代碼在這裡:

1 static inline void GetVirtualInvokeData(Il2CppMethodSlot slot, void* obj, VirtualInvokeData* invokeData) {2 *invokeData = ((Il2CppObject*)obj)->klass->vtable[slot];3 if (!invokeData->methodPtr)4 RaiseExecutionEngineException(invokeData->method);5 }

這裡對於我們寫邏輯的來說,其實真沒什麼可優化了。而且對於有指令級優化經驗的程序員,會把這個機會交給CPU的branch prediction。

但是IL2CPP團隊還是選擇把這個if優化掉了。簡單地說就是自己寫了個stub method,然後vtable[slot]本來應該為null的情況都給指到stub method。

這樣,雖然在極少數需要拋出異常的情況下,多了一次函數調用的開銷,但是對於絕大多數情況,都省了一次if檢查開銷。

按IL2CPP官博的說法是,這個優化提高了3%到4%的表現,我們就姑且信之,淆習一個。

接下來是原博的第三個示例:

1 interface HasSize { 2 int CalculateSize(); 3 } 4 5 struct Tree : HasSize { 6 private int years; 7 public Tree(int age) { 8 years = age; 9 }10 11 public int CalculateSize() {12 return years*3;13 }14 }15 16 public static int TotalSize<T>(params T[] things) where T : HasSize17 {18 var total = 0;19 for (var i = 0; i < things.Length; ++i)20 if (things[i] != null)21 total += things[i].CalculateSize();22 return total;23 }

注意第21行中的things[i] != null,這裡如果T具現為Tree類型,就會做一次裝箱操作。

如果對代碼生成有了解的同學,可能還會聯想到generic sharing,也就是泛型函數具現為不同的引用類型時可以共享同一個方法實例,而具現為值類型時就會決議到不同的方法實例。

同時由於IL2CPP的AOT性質,編譯期就已經知道了這些事情,所以IL2CPP完全可以把具現的每個值類型泛型函數實例特殊處理,去掉裡面的裝箱操作。

事實上,IL2CPP就是這麼乾的,也確實讓程序員少操了不少心。

小結一下,以上優化技巧,我們應該如何在寫邏輯的時候應用上?下面就逐條淆習一下:

  • 第一個例子中,IL2CPP藉助編譯期hint獲得了額外的優化元信息。

針對這一點不太好列舉寫邏輯時候的應用情景,如果經常用可以給類型加註記或Attribute的語言(比如C#)可能會有類似的優化經驗。

假設我們要開發一個非侵入式的序列化庫,核心需求是把傳進來的object序列化成位元組流。

對於庫來說,傳進來的是一個未知的object,需要藉助反射拿到類型元信息,然後動態生成序列化代碼,以供之後的該類型object序列化使用。

這就跟JIT一樣,相當於在每種類型的object第一次序列化的時候,庫需要動態生成方法,這個成本相當高,不過好在可以之後攤還。但是對於有些服務端來說,這種隨機的性能壓力是不可忍受的。

因此我們可以hint住可能會序列化的類型定義,形成一種約束,規定程序員在運行時只能給庫這些hint過的類型的object。

這樣,序列化庫初始化的時候一次性生成好這些類型的序列化函數,就能把不確定的消耗轉化為確定的消耗,把運行時的消耗提前,提高整體的性能表現。

  • 第二個例子中,IL2CPP把nullcheck的極少數分支轉為stub method,消除了nullcheck。

其實我們在寫邏輯的時候,也不知不覺就會寫出各種帶if-elseif的噁心邏輯,這時候我們也可以用類似於stub method/stub class的方法,既能讓代碼變優雅,又能提高效率。

舉個例子,我們有一個IServiceProvider,它會根據配置的不同實例化為不同的ServiceProvider。那麼,一種設計是每個用到ServiceProvider的地方都checknull,另一種設計是讓ServiceProvider一開始初始化為一個TrivialServiceProvider,後面該怎麼用就怎麼用。

其實兩種設計並沒有絕對的好壞之分,完全看IServiceProvider在邏輯中扮演什麼角色。

如果IServiceProvider的介面並不具有默認值語義,那有可能第一種設計更適合你。但是相反的話,第二種比第一種更優雅,而且對於trivial占極少數情況的邏輯,還能獲得額外的性能表現。

  • 第三個例子中,IL2CPP對可以優化的情況做了特殊處理。

這類例子就比較多了,比如redis的zset在元素少的時候會用ziplist,元素多的時候才改為skiplist等等。

最近開始在訂閱號寫文章了,覺得合適的會轉過來專欄。但是幾番對比,發現訂閱號的寫文章體驗完爆各種博客以及知乎專欄。

有興趣的同學可以關注下訂閱號gamedev101「說給開發遊戲的你」,一起聊聊遊戲技術。

weixin.qq.com/r/U0Rlfdv (二維碼自動識別)

看不到二維碼點這裡


推薦閱讀:

聊聊寫博客的這兩年&&《Unity 3D腳本編程:使用C#語言開發跨平台遊戲》正式出版

TAG:游戏开发 | C# | Unity游戏引擎 |