找回密码
 立即注册
查看: 788|回复: 0

[笔记] 跟Unity学代码优化

[复制链接]
发表于 2020-12-18 10:39 | 显示全部楼层 |阅读模式
第一次在知乎写文章,发篇水文。
今天我们来聊聊如何跟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. 1 public abstract class Animal {
  2. 2   public abstract string Speak();
  3. 3 }
  4. 4  
  5. 5 public class Cow : Animal {
  6. 6    public override string Speak() {
  7. 7        return "Moo";
  8. 8    }
  9. 9 }
  10. 10  
  11. 11 public class Pig : Animal {
  12. 12     public override string Speak() {
  13. 13         return "Oink";
  14. 14    }
  15. 15 }
  16. 16
  17. 17 public class Farm: MonoBehaviour {
  18. 18    void Start () {
  19. 19        Animal[] animals = new Animal[] {new Cow(), new Pig()};
  20. 20        foreach (var animal in animals)
  21. 21            Debug.LogFormat("Some animal says '{0}'", animal.Speak());
  22. 22  
  23. 23        var cow = new Cow();
  24. 24        Debug.LogFormat("The cow says '{0}'", cow.Speak());
  25. 25    }
  26. 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. 1 class BaseClass {
  2. 2    public virtual string SayHello() {
  3. 3        return "Hello from base!";
  4. 4    }
  5. 5 }
  6. 6
  7. 7 class GenericDerivedClass<T> : BaseClass {
  8. 8    public override string SayHello(){
  9. 9        return "Hello from derived!";
  10. 10    }
  11. 11 }
  12. 12
  13. 13 public class VirtualInvokeExample : MonoBehaviour {
  14. 14    void Start () {
  15. 15        Debug.Log(MakeRuntimeBaseClass().SayHello());
  16. 16    }
  17. 17  
  18. 18    private BaseClass MakeRuntimeBaseClass() {
  19. 19        var derivedType = typeof(GenericDerivedClass<>).MakeGenericType(typeof(int));
  20. 20        return (BaseClass)FormatterServices.GetUninitializedObject(derivedType);
  21. 21    }
  22. 22 }
复制代码
MakeRuntimeBaseClass().SayHello()这个坑相信大家刚接触Unity的时候都踩过,由于iOS平台不支持JIT compile method,这里如果不做hint,就会导致真机运行时crash。
IL2CPP的runtime library实现也类似,会在SayHello这个virtual function call的过程中查一次虚表,如果找不到调用方法,就会抛出一个托管的异常。
代码在这里:

  1. 1 static inline void GetVirtualInvokeData(Il2CppMethodSlot slot, void* obj, VirtualInvokeData* invokeData) {
  2. 2    *invokeData = ((Il2CppObject*)obj)->klass->vtable[slot];
  3. 3    if (!invokeData->methodPtr)
  4. 4        RaiseExecutionEngineException(invokeData->method);
  5. 5 }
复制代码
这里对于我们写逻辑的来说,其实真没什么可优化了。而且对于有指令级优化经验的程序员,会把这个机会交给CPU的branch prediction。
但是IL2CPP团队还是选择把这个if优化掉了。简单地说就是自己写了个stub method,然后vtable[slot]本来应该为null的情况都给指到stub method。
这样,虽然在极少数需要抛出异常的情况下,多了一次函数调用的开销,但是对于绝大多数情况,都省了一次if检查开销。
按IL2CPP官博的说法是,这个优化提高了3%到4%的表现,我们就姑且信之,淆习一个。
接下来是原博的第三个示例:

  1. 1 interface HasSize {
  2. 2    int CalculateSize();
  3. 3 }
  4. 4  
  5. 5 struct Tree : HasSize {
  6. 6    private int years;
  7. 7    public Tree(int age) {
  8. 8        years = age;
  9. 9    }
  10. 10  
  11. 11    public int CalculateSize() {
  12. 12        return years*3;
  13. 13    }
  14. 14 }
  15. 15
  16. 16 public static int TotalSize<T>(params T[] things) where T : HasSize
  17. 17 {
  18. 18    var total = 0;
  19. 19    for (var i = 0; i < things.Length; ++i)
  20. 20        if (things[i] != null)
  21. 21            total += things[i].CalculateSize();
  22. 22    return total;
  23. 23 }
复制代码
注意第21行中的things != 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「说给开发游戏的你」,一起聊聊游戏技术。
http://weixin.qq.com/r/U0RlfdvErobRrZ8e9xFB (二维码自动识别)

看不到二维码点这里
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-12-23 12:26 , Processed in 0.092581 second(s), 25 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表