Unity性能优化:Profile与Job

十月比较忙,博客也鸽了。由于种种原因,今后此博客大概会多一些C艹(尤其是Modern C艹)和Unity学习笔记的内容。

 

最近在搓DualVectorFoil的时候,发现效率有点低,每隔一会儿帧率就会降低一下。显然,这是触发了GC(垃圾回收)。这种时候就需要用Unity的Profiler测一下。

从Memory中,我们可以明显看到GC Alloc呈锯齿形,也就是申请的内存总量一直在增加,且这些申请的内存都是很快就不再使用的,因此在一次垃圾回收过后就都被回收掉了。在进行垃圾回收时,帧率明显下降。下方Timeline中的密集红色部分就是GC Alloc(申请托管内存)所占用的时间。点击红色部分,可以看到具体是哪个地方在申请内存。

从图中可以看出,是Mesh.get_colors()。对于之前没怎么在代码里用过Mesh的人(包括我)来说,这是一个很容易犯的错误。从Mesh中取得colors数组(以及vectices、uvs、normals等等)实际上要去引擎的native部分获取并填入managed数组,每次访问colors数组都会经历这个过程,因此会有GC Alloc。此处出问题的代码是这样的:

                for (int i = 0; i < mesh.colors.Length; i++)
                {
                    mesh.colors[i] = meshColor;
                }

这样每次赋值时并不是只赋值colors中的一个color,而是需要把整个colors从native部分复制过来,完成其中一个赋值,再整体复制回去。

其实这个问题在Unity官方的性能优化文章中已经讲过:

Every time we access a Unity function that returns an array, a new array is created and passed to us as the return value. This behaviour isn’t always obvious or expected, especially when the function is an accessor (for example, Mesh.normals).

其实Mesh.colors(vectices/uvs/normals等也类似)是一个属性(property)而不是字段(field),它的get和set是特别处理的:

    /// <summary>
    ///   <para>Vertex colors of the Mesh.</para>
    /// </summary>
    public Color[] colors
    {
      get
      {
        return this.GetAllocArrayFromChannel<Color>(Mesh.InternalShaderChannel.Color);
      }
      set
      {
        this.SetArrayForChannel<Color>(Mesh.InternalShaderChannel.Color, value);
      }
    }

因为colors开头不是大写,对于习惯了巨硬命名规则(Public Property首字母应大写)的老C井用户来说,是很容易把它错认为是普通字段而出现这种直接赋值的问题的。(而且我认为unity应该在这种明显容易出问题的地方多写几句summary注释才是。)

改进后的代码如下:

                for (int i = 0; i < cmd.MeshColors.Length; i++)
                {
                    cmd.MeshColors[i] = meshColor;
                }

                mesh.colors = cmd.MeshColors;

首先将每个color都放到一个缓存的数组里(这个数组不会每帧都创建),然后一次性赋给Mesh.colors。这个过程中我们就没有get过Mesh.colors,因此就没有GC Alloc了:

按照GC Alloc排序,除了Editor、Engine、Profiler带来的GC之外,用户代码中的GC Alloc为0。在Update中不产生GC Alloc,这是应该尽力去达到的目标。

 

 

另一个尝试是采用Job System对猫娘壁纸的优化。当你点击猫娘头部时,猫会喵喵叫,此时猫娘的口部开合是根据音量(为了防止动作过大也有做smooth)计算的。之前的计算大概是这样(这段代码大部分是出自Emote提供的模板):

        float[] samples = new float[sampleLength];
        clip.GetData(samples, sampleBeginTime);
        float curValue = 0f;
        for (var i = 0; i < sampleLength; i++)
            curValue += Mathf.Pow(Mathf.Abs(samples[i] / sampleDynamicRange), 1  / sampleGamma);

对此做一下profile(要看到更细节的方法调用,请开启deep profile):

可以看到除了GC Alloc(这个可以通过缓存数组来解决)之外,颜色比较亮的蓝色部分实际上是密集的方法调用,调用的自然是Mathf.Abs和Mathf.Pow,因为sample数组比较大,可能会调用七百到一千多次(每帧)。结合上面的代码,sample数组中的每个数都需要一次Abs和一次Pow,然后加到curValue之上。这个过程的前半部分实际上能够并行。所以我们可以来试一下Unity2018新加入的Job System。

Job System说白了就是Unity提供的适合游戏环境的线程池。为了提高效率以及防止竞态等问题,Job比起一般的线程来说有很多限制。

一个可以并行的任务就是一个Job(struct),目前Job有三种:IJob(普通的在另一个线程上运行的Job)、IJobParallelFor(针对可按index遍历的集合,遍历运行的Job)、IJobParallelForTransform(和上一个类似,不过能够访问、修改游戏对象的transform)。此处我们是处理一个sample数组,自然选用IJobParallelFor。

这个Job定义如下:

struct LipSyncSampleJob : IJobParallelFor
{
    public NativeArray<float> Samples;
    [ReadOnly] public float SampleDynamicRange;
    [ReadOnly] public float SampleGamma;
    public void Execute(int index)
    {
        Samples[index] = Mathf.Pow(Mathf.Abs(Samples[index] / SampleDynamicRange), 1 / SampleGamma);
    }
}

关于Job的限制:Job中不能有引用类型,集合只能使用Native系列,主要是NativeArray<T>和NativeList<T>。为进一步优化效率,可以在字段中加上[ReadOnly](Job中不会写入此字段)或[WriteOnly](Job中不会读取此字段)特性。Job中的基础值类型(int, float等)在运行完之后就清空了,如果要返回值,只能定义并使用NativeArray<T>来访问结果(Unity官方暂时没有改进这一点的打算,不知道是否是认为一个好的Job不应返回值)。另外,对于IJobParallelFor,为了防止竞态出现,限制了不能访问当前Job所分配到的范围以外的index。*

*IJobParallelFor在运行时生成多个Job,对于每个Job会分配一个遍历范围(比如1~100,101~200等,范围大小是用户指定,后面会提到),如果这个Job分到的是101~200的范围,那它就不能访问任何集合的1号元素或是100号元素。如果你确定不会出问题,可以使用特性[NativeDisableParallelForRestriction]取消这个限制。

定义好Job后,按如下方式调用:

        var nativeSample = new NativeArray<float>(samples, Allocator.Temp); //从float[]创建临时的native数组
        LipSyncSampleJob job = new LipSyncSampleJob()
        {
            Samples = nativeSample,
            SampleDynamicRange = sampleDynamicRange,
            SampleGamma = sampleGamma,
        };
        var handle = job.Schedule(sampleLength, 100); //每个Job遍历100个元素
        handle.Complete(); //等待完成
        for (int i = 0; i < sampleLength; i++)
        {
            curValue += nativeSample[i];
        }
        nativeSample.Dispose();

其中IJobParallelFor.Schedule(length, range)的range参数要根据任务的复杂程度和数量来自行确定。如果数组元素数量多,而每个任务都较为简单,那么就��该设置比较大的值;反之就设置比较小的值。测试一下效果:

可以看到任务被分配到8个线程上(1个主线程+7个工作线程)并行执行了。至于效率……很遗憾的是,在本例中性能并没有得到提升,因为本来这个处理时间也不算长,虽然并行确实使运算处理的时间降低到了0.05ms左右,但Job启动和结束带来的开销(以及GC Alloc)在这里显得过于明显,实际效率和之前差不多,且GC Alloc比之前更难解决,因此此处还是放弃了使用Job (╯□╰)。不过,有了这次的经验,今后在可以使用Job的地方就能放心大胆地去用了。

最后,不死心的我还试了下Parallel.For:

        System.Threading.Tasks.Parallel.For(0, sampleLength, new ParallelOptions() { MaxDegreeOfParallelism = 8 },
            i => _samples[i] = Mathf.Pow(Mathf.Abs(_samples[i] / sampleDynamicRange), 1 / sampleGamma));

结果:效率还是差不多,但是出现了至少MaxDegreeOfParallelism次的GC Alloc,所以也不实用。

添加评论

Loading