zoukankan      html  css  js  c++  java
  • 跟Unity3D学代码优化

    今天我们来聊聊如何跟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等等。

     

    最近开始在订阅号写文章了,觉得合适的会转过来博客。但是几番对比,发现订阅号的写文章体验完爆各种博客。

    有兴趣的同学可以关注下订阅号「说给开发游戏的你」,下面是二维码。

  • 相关阅读:
    LeetCode Power of Three
    LeetCode Nim Game
    LeetCode,ugly number
    LeetCode Binary Tree Paths
    LeetCode Word Pattern
    LeetCode Bulls and Cows
    LeeCode Odd Even Linked List
    LeetCode twoSum
    549. Binary Tree Longest Consecutive Sequence II
    113. Path Sum II
  • 原文地址:https://www.cnblogs.com/fingerpass/p/how-il2cpp-optimizes-code.html
Copyright © 2011-2022 走看看