Unity匿名函数的堆内存优化

Unity匿名函数的堆内存优化

Unity的坑很多,Mono的坑更多,这里记一次解决lambda函数alloc的问题。


起因

在一次性能优化中,需要将很多对象缓存起来,因为它们会被经常访问,每次重新获取都会产生新的alloc,这个是需要避免的。
我把所有对象丢进了一个Dictionary里,再需要的时候就遍历一遍。为了方便,我又封装了一个的foreach,类似下图。

1 public static class Utils
2 {
3    public static void Forecah<TKey, TValue>(this Dictionary<TKey, TValue> dict, 
4 System.Action<TKey, TValue> EnumeratorFunc)
5    {
6        if (dict == null || EnumeratorFunc == null)
7            throw new System.ArgumentNullException();
8        var i = dict.GetEnumerator();
9        while (i.MoveNext())
10        {
11            EnumeratorFunc(i.Current.Key, i.Current.Value);
12        }
13   }
14 }

然后我上Profiler进行测试,发现还是有104B的alloc。同事提醒我在匿名函数里最好别用这个。修改了实现后,发现alloc终于为0了,但为什么匿名函数会有alloc(每次调用)呢?我决定探个究竟。


分析

一、测试环境:Win10,Unity5.3.4f1,VS2013
测试数据:

1 public class lambdaTest : MonoBehaviour
2 {
3    Dictionary<int, int> table = new Dictionary<int, int>();
4    public int count;
5    void Start()
6    {
7        table.Add(1, 1);
8        table.Add(2, 2);
9        table.Add(3, 3);
10        table.Add(4, 4);
11        table.Add(5, 5);
12        count = 0;
13    }
14 }

二、在Update函数里测试各种情况,目前分2种情况:
1.匿名函数中,未使用外部变量

  • 匿名函数
  • 成员函数

2.匿名函数中,使用外部变量

  • 匿名函数
  • 成员函数
1  void Update()
2  {
3    Profiler.BeginSample("AnonymousWithoutParam");  // 未使用外部变量的匿名函数
4    AnonymousWithoutVariable();
5    Profiler.EndSample();
6    Profiler.BeginSample("FunctionWithoutVariable"); // 未使用外部变量的成员函数
7    FunctionWithoutVariable();
8    Profiler.EndSample();
9    Profiler.BeginSample("AnonymousParam"); // 使用外部变量的匿名函数
10    AnonymousVariable();
11    Profiler.EndSample();
12    Profiler.BeginSample("FunctionVariable"); // 使用外部变量的成员函数
13    FunctionVariable();
14    Profiler.EndSample();
15  }

各类函数代码

1.未使用外部变量的匿名函数

1  void AnonymousWithoutVariable()
2  {
3    table.Forecah((k, v) =>
4    {
5        int c = 0;
6        c = k + v;
7    });
8  }

2.未使用外部变量的成员函数

1  void FunctionWithoutVariable()
2  {
3    table.Forecah(AddWithoutVariable);
4  }
5  void AddWithoutVariable(int k, int v)
6  {
7    int c = 0;
8    c = k + v;
9  }

3.使用外部变量的匿名函数

1  void AnonymousVariable()
2  {
3    table.Forecah((k, v) =>
4    {
5        count = k + v;
6    });
7  }

4.使用外部变量的成员函数

1  void FunctionVariable()
2  {
3    table.Forecah(AddtVariable);
4  }
5  void AddtVariable(int k, int v)
6  {
7    count = k + v;
8  }

然后,我们把脚本挂在场景的摄像机上,挂上Profiler开始测试,得到结果如下:
请输入图片描述

除了第一个函数(AnonymousWithoutVariable)外都有104B的alloc。这是个神奇的现象,但这个时候看代码已经没意义了,还是去看IL吧。 用ildasm解开代码,得到以下内容:
请输入图片描述
这里看到,编译器增加了一堆东西,其中比较关键的是:

1. 静态Action声明

1 .field private static class [System.Core]System.Action`2<int32,int32> 'CS$<>9__CachedAnonymousMethodDelegate1'

2. 静态成员函数

.method private hidebysig static void '<AnonymousWithoutVariable>b__0'(int32 k, int32 v) cil managed

3. 非静态成员函数

.method private hidebysig instance void  '<AnonymousVariable>b__2'(int32 k, int32 v) cil managed

我们先从没有产生alloc的函数开始分析。 这个函数没有使用外部变量,那么编译器就会把这个函数变成一个静态函数。 在调用的时候才会new出来,但一旦new出来之后就不会再new了,这就是它为什么没有alloc的原因。

调用函数的IL代码(注意IL_000C行的注释)

1  .method private hidebysig instance void  AnonymousWithoutVariable() cil managed
2  {
3   // 代码大小       45 (0x2d)
4   .maxstack  8
5   IL_0000:  nop
6   IL_0001:  ldarg.0
7   IL_0002:  ldfld      class [mscorlib]System.Collections.Generic.Dictionary2<int32,int32> 
8   lambaTest::table
9   IL_0007:  ldsfld     class [System.Core]System.Action2<int32,int32> 
10  lambaTest::'CS$<>9__CachedAnonymousMethodDelegate1'
11  IL_000c:  brtrue.s   IL_0021  // 判断这个函数是否new过,没new就往下走,否则就跳到IL_0021行。
12  IL_000e:  ldnull
13  IL_000f:  ldftn      void lambaTest::'<AnonymousWithoutVariable>b__0'(int32, int32)
14  IL_0015:  newobj     instance void class 
15  [System.Core]System.Action2<int32,int32>::.ctor(object, native int)
16  IL_001a:  stsfld     class [System.Core]System.Action2<int32,int32> 
17  lambaTest::'CS$<>9__CachedAnonymousMethodDelegate1'
18  IL_001f:  br.s       IL_0021
19  IL_0021:  ldsfld     class [System.Core]System.Action2<int32,int32> 
20  lambaTest::'CS$<>9__CachedAnonymousMethodDelegate1'
21  IL_0026:  call       void Utils::Forecah<int32,int32>(class 
22  [mscorlib]System.Collections.Generic.Dictionary2<!!0,!!1>,
23   class 
24 [System.Core]System.Action2<!!0,!!1>)
25  IL_002b:  nop
26  IL_002c:  ret
27  } // end of method lambaTest::AnonymousWithoutVariable

其他会产生的alloc的函数,都是成员函数(包括匿名的,也变成成员的了),所以每次都会new一个action对象。

一个例子,使用外部变量的IL代码。其中IL_000e行就会new一个对象。

1  .method private hidebysig instance void  AnonymousVariable() cil managed
2  {
3    // 代码大小       26 (0x1a)
4   .maxstack  8
5   IL_0000:  nop
6   IL_0001:  ldarg.0
7   IL_0002:  ldfld      class [mscorlib]System.Collections.Generic.Dictionary`2<int32,int32>
8   lambaTest::table
9   IL_0007:  ldarg.0
10  IL_0008:  ldftn      instance void lambaTest::'<AnonymousVariable>b__2'(int32, int32)
11  IL_000e:  newobj     instance void class
12  [System.Core]System.Action`2<int32,int32>::.ctor(object,  native int)
13  IL_0013:  call       void Utils::Forecah<int32,int32>(class
14  [mscorlib]System.Collections.Generic.Dictionary`2<!!0,!!1>,
15                                                        class 
16  [System.Core]System.Action`2<!!0,!!1>)
17  IL_0018:  nop
18  IL_0019:  ret
19  } // end of method lambaTest::AnonymousVariable

第二个例子。

1  .method private hidebysig instance void  FunctionWithoutVariable() cil managed
2  {
3    // 代码大小       26 (0x1a)
4    .maxstack  8
5    IL_0000:  nop
6    IL_0001:  ldarg.0
7    IL_0002:  ldfld      class [mscorlib]System.Collections.Generic.Dictionary2<int32,int32>
8    lambaTest::table
9    IL_0007:  ldarg.0
10   IL_0008:  ldftn      instance void lambaTest::AddWithoutVariable(int32, int32)
11   IL_000e:  newobj     instance void class 
12   [System.Core]System.Action2<int32,int32>::.ctor(object, native int)
13   IL_0013:  call       void Utils::Forecah<int32,int32>(class 
14   [mscorlib]System.Collections.Generic.Dictionary2<!!0,!!1>,
15                                                        class 
16   [System.Core]System.Action2<!!0,!!1>)
17   IL_0018:  nop
18   IL_0019:  ret
19   } // end of method lambaTest::FunctionWithoutVariable

结论

当不使用外部变量的匿名函数时,编译器会把这个函数变成静态函数,在首次调用时初始化,之后就再也不会new新的对象。 当使用外部变量时,每次调用都会生成一个临时action变量,这个就是alloc的原因。


解决办法以及相关建议

这里会麻烦一点,需要声明一下:

1  public Action<int, int> pCall;
2  void Start()
3  {
4    pCall = CallVariable;
5    ... // 其他初始化代码
6   }
7   void FixedCall()
8   {
9    table.Forecah(pCall);
10  }
11  void CallVariable(int k, int v)
12  {
13    count = k + v;
14  }

测试结果

请输入图片描述
FixedCall是实际的函数名,aaaaa是profiler采样的函数名

相关建议
在高频调用时,研发团队可以考虑通过声明一个变量来解决或者不要使用,如果调用频率很低,那就不用去关心了。

相关测试代码
https://github.com/jlu3389/UnityLambdaAllocTest


这是侑虎科技第212篇原创文章,作者仲光泽(QQ:593172),欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)

同时,作者也是U Sparkle活动参与者哦,UWA欢迎更多开发朋友加入 U Sparkle开发者计划,这个舞台有你更精彩!

  • bitJr 发表在 2017年03月15日 回复

    其实不用看IL代码,直接用Reflector把.NET的平台选成None看没有语法糖的代码就看出来会new一个匿名对象。

  • Xin 发表在 2017年03月15日 回复

    强势留言!