今天我們來聊聊如何跟Unity學代碼優化,準確地說,是通過學習Unity的IL2CPP技術的優化策略,應用到我們的日常邏輯開發中。 ...
今天我們來聊聊如何跟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 : HasSize 17 { 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等等。
最近開始在訂閱號寫文章了,覺得合適的會轉過來博客。但是幾番對比,發現訂閱號的寫文章體驗完爆各種博客。
有興趣的同學可以關註下訂閱號「說給開發游戲的你」,下麵是二維碼。