Unity Job System详解(2)——如何将业务逻辑Job化

【前言】

Job化只是加速计算,并不改变原有的业务计算逻辑。

并不是一上来就使用Job,而是要先优化下原有的业务逻辑。如果优化完了之后提升并不大,可以考虑再Job化。

最好真的有一块的业务逻辑需要你Job化,要不然可能看不懂接下来再说什么。

【最简单的情况】

创建Job,继承IJob接口,确定Job中需要有哪些字段:

  • 先熟悉原有的业务逻辑,评估下哪块的计算需要用Job加速
  • 评估需要计算的最小对象是什么,每一个对象对应一个计算结果
    • 例如有100个角色,每个角色都会在Tick中按照某种逻辑计算一遍,这里的最小对象就是角色
    • 例如一个角色有10个同样的组件,每个组件都会在Tick中执行某个逻辑计算,这里的最小对象就是组件
    • 例如组件中有一个某个类的数组,在Tick中会对数组中的所有成员执行某个计算,这里的最小对象就是该类
    • 例如该类中有个For循环在做耗时计算,这里的最小对象就是这个耗时的计算
  • 看单个对象的计算出来的结果是什么,这个结果就是Job计算的结果,如果计算结果是float类型的,那么意味着Job有一个字段为public NativeArray<float> result,或者public NativeList<float>
  • 再看计算出来结果所需的输入数据是什么,涉及到的所有输入数据都会是Job中的输入数据,也即Job中有个对应的该输入数据的字段

创建Job类

我们可以在A类中new Job、Job.Schedule、jobHandle.Complete等(下文简称做Job操作),但最好不要这么做,而是写一个与Job对应的类(Job类),在类中做Job操作,给A类提供传递数据的接口即可。

这样可以将Job操作作为A类的一个组件,而不用破坏A类中的原有逻辑,在A类中加个bool变量,可以选择使用原有逻辑还是使用Job。

Job类中需要有以下字段:

  • Job结构体中的所有字段在Job类中都要有对应的字段
    • 对于Job中的NativeContainer字段:该字段只是持有Job类中对应字段的引用,内存的分配都是在Job类中完成的,Job结构体中只负责使用即可
    • 对于其他字段:都是在Job类中new Job时赋值的
  • A类或B类字段:
    • 假设原有计算逻辑在A类中,计算得到结果后会调用A类或者B类的某个方法,将计算结果作为参数传递。那么在Job类中得到计算结果后,也需要调用该方法,需要在Job类中持有A类或B类的引用

Job类中需要有以下方法:

  • Init方法:Job类中的字段的值向A类看齐,一般是从A类中传递进去的。A类调用Job类的Init方法,传递需要计算的数据,Job类在Init方法中给NativeContainer分配内存,并赋值
  • Dispose方法:所有NativeContainer都需要手动释放内存
  • AddData方法:A类向Job类添加对象的输入数据的值
  • ApplyData方法:实例化Job,给Job结构体字段赋值,并调度Job
  • Complete方法:完成Job,并继续处理输出数据

根据实际情况,Job类中的一些字段可以省略,Job类中的方法可以合并,例如Init和AddData合并,AddData和ApplyData合并(这种情况下可以一次性把数据加添完成),ApplyData和Complete合(这种情况要求立即计算完成,随后就要用到计算结果)。

综合下来,最简单的Job代码如下:

public class JobClass//可以是个单例类
{
    //所有Job输入数据字段

    //引用类型字段

    //输出数据
    public NativeList<float> result;

    private JobHandle jobHandle;
    private Job job;

    public bool inited = false;


    public void Init()//一些参数
    {
        if (inited)
            return;
        //NativeContainer内存分配
        result = new NativeList<float>(10, Allocator.Persistent);

        inited = true;
    }

    public void AddData()
    {
        //从A类中调用该方法,传入输入数据
    }

    public void ApplyData()
    {
        job = new Job()
        {
            //输入数据字段赋值
            result = result,
        };
        jobHandle = job.Schedule();
    }

    public void Complete()
    {
        if(!jobHandle.IsCompleted)
        {
            jobHandle.Complete();
        }
        //处理输出
        //调用A类中的方法
    }

    public void Dispose()
    {
        if (!inited)
            return;

        //NativeContainer内存释放
        if (result.IsCreated) result.Dispose();

        inited = false;
    }
}

public struct Job:IJob
{
    //输入数据字段

    //输出数据
    public NativeList<float> result;

    public void Execute()
    {

    }
}

【复杂一些的情况】

业务逻辑中的数据是从其他类中获取的

A类中并没有完整的输入数据,在A类中业务逻辑计算时所需的数据也是通过持有其他类获取到的,那么在Job类中需要有一个字段List<OtherClass>,用于保存其他类,在AddData时将其他类放到该List中。

在ApplyData中,先有个InitJobData的步骤,在该步骤中,先将Job类中的输入数据字段赋值好,再去new job

业务逻辑涉及递归计算

例如A类持有C类,数据在C类中,C1和C2继承C类,C2中有个D类,D类中持有C类,C和D类中都包含输入数据,这就构成了递归。

在业务逻辑中,进行递归的调用即可拿到数据。而在Job中无法将含有数据的C类和D类传递过去,也不建议Execute中通过各种传递引用的方式获取。

因此,需要在InitJobData中获取所有递归数据并放入NativeList中。为了找到每个对象的输入数据是什么,需要一个额外的结构体来记录每个对象在List中数据的开始Index和长度,复杂些还需要记录递归中每个层级的信息在哪,这涉及如何用数组表示一棵树。

随后,在Job类和Job结构体中存在一个保存结构体数据的NativeArray。Array中元素的索引表示对象的Index,这和Job类中其他NativeList或NativeArray中的元素索引含义一致。

Log输出

在原有的业务逻辑中,会存在一些log输出,首先尝试修改原有的逻辑,看是否可以在传递数据前就输出log。如果一定要在Execute中输出log,那么只能给每个log一个索引,Job多增加一个NativeArray<int> logResult。在Job完成时,根据索引,打印出具体的log。直接在Execute中输出log是不被允许的。

业务逻辑计算复杂

通常来说,除了工具类的静态方法可以直接在Execute中调用外,我们需要将业务逻辑在Execute中重新实现一遍。

如果业务逻辑本身很复杂,那么重新实现一遍会很麻烦,尤其是各种输入数据来自不同的类时。

为此,你可能需要重写很多东西。关于原来业务逻辑中要追看每一个函数调用,直到没有调用,即使调用栈很深,也要耐心追完。

根据实际情况,一个添加数据的接口AddData可能不够用了,要多个,或者用命令模式封装下输入参数。

涉及Unity API

我们知道Unity API不能在子线程中调用,如果业务逻辑的计算涉及了调用UnityAPI,需要向类似Log那样处理。

不同的是,调用API可能有一些参数,这时需要创建一个结构体,将参数封装起来。等Job完成后再去调用API。

与原来相比,API的调用被放在了计算结果之后,需要评估这样做是否可行。如果修改原有逻辑后仍不可行,那么就无法使用Job。

存在无效数据

如果对象的数据之间存在关联,可能在将某个对象的数据Add后,在下一个对象数据Add前,上一个对象的数据被修改了,导致该对象不用继续进行逻辑计算了。面对这种情况,在InitJobData前,需要先把这个对象的数据从List中剔除

【一些优化】

对象数量变化

由于业务逻辑上的一些条件判断,每次需要参与计算的对象数量是不断变化的。如果当前需要参与计算的对象数量为1或者为2,那么应该直接在主线程上运行,即调用Job.Run,或者只有走原有逻辑。

因为调用Job.Schedule也有一定的性能开销。无法确定Job节省的开销和调用多出来的开销相比原来是多还是少,具体多少对象的数量直接在主线程上运行,需要结合实际情况测试

如何优化数据Copy耗时

输入数据可能很多,例如在某个类的一个int[]数据都是输入数据,我们需要将数据copy到NativeArray中,如果数据量很大,这个copy的耗时甚至可能抵消掉Job的优势。解决方式是提前Copy数据。

编程时对于数据要有静态和动态区分的概念,输入数据中可能有不变的静态数据,每帧都变化的动态数据。通常,静态数据占用输入数据的很大一部分,可以在Job的Init时就copy完静态数据。

需要注意,如果在原有的业务逻辑上没注意区分动静数据分离,那么需要修改数据结构,优化原有逻辑。

但是copy不可避免的让内存中存在两份数据,一份Job用的数据,一份原有逻辑用的数据。为了优化内存,必须干掉一份数据。

因此,在Job类中必须提供数据的访问方法(所以,通常Job类都会是一个单例类)

在A类中,会存在是否使用Job的bool变量(即开关)。因此,原有逻辑和Job类不能有数据访问的耦合,需要提供一个中间者,让双方都可以访问数据,这个中间者就是一个StaticDataManager。

原有逻辑和Job类通过一个类型Id去访问StaticDataManage中的数据。(静态数据对不同的实例对象而言都是一样的,如果对象只有一种类型,那么静态数据就只有一份,如果对象有多种类型,对象必然包含关于自身类型的信息,即类型Id,每个类型对应一份静态数据)

注意,为了让Job能够访问数据,数据要使用NativeContainer。

为了缓存友好,我们通常会将所有静态数据集中起来,而不是分散在各个地方。如果原有逻辑的静态数据是分散的,那么需要对原有逻辑进行改造,将各个地方的数据载入逻辑剔除。在Editor上将所有静态数据导出成一份文件,在合适的时机将所有静态数据加载到内存并放入静态数据管理类中。

为了更近一步的节省内存并加快数据访问速度,可以将静态数据放入BlobAsset中。(请自行了解BlobAsset)

多个Job共用数据

 多个Job共用的静态数据可以从StaticDataManager中获取,多个Job也可能共用动态数据,也即数据间形成依赖了,一个Job需要用的数据可能是上一个Job数据的结果。

对此,可以将不同Job共用的动态数据抽出来,放在DynamicDataManager中。同样的,原有逻辑也从中访问动态数据,通过实例Id来访问。

在DynamicDataManager可以通过一个栈和一个自增的id来维护实例Id。

注意,共用动态数据时,看情况是否使用Job依赖,还是手动分成多个Job。建议只对小而简单的Job使用Job依赖,这样出了问题好排查。

另外也可以考虑将多个Job类合并成一个Job类。

延迟Complete

如果需要立刻用到计算结果,例如手动分成的多个Job,在其他情况下尽量延迟Complete,必要时可以修改原有逻辑,否则还要主线程去等待。加上Profiler后,在FrameDebugger中看该Profiler是在主线程还是在工作线程中来判断Job是否在主线程中运行。

使用并行Job

使用并行Job可能需要对原有逻辑进行较大幅度的修改,要确保不同对象的数据不会产生关联和依赖。这同样要求你看清楚,业务逻辑计算的每一个调用。要注意以下问题:

1.数据生成和使用要分离。例如:如果在一帧中,有A、B、C、D、E五个分段逻辑,假设在B段循环处理了100个对象,计算对象数据,在循环中紧接着又用了对象的数据,那么数据生成和数据使用耦合在一起了,就不能做并行计算了。需要将处理对象的逻辑修改成,先循环计算所有对象数据,再循环使用所有对象数据,才能做并行计算。

2.输入数据的组织形式需要变化。例如原来有x和y两类静态数据,l、m、n三类动态数据。在非并行Job中,可能存在一个结构体,包含了该对象的全部数据。由于在Job内执行时,仍是对每个对象顺序处理。

在并行Job中,对每类数据都要一个NativeList存放数据。同时,要有一个结构体记录对象的每类数据在NativeList中的位置,Job类中还要有个NativeList保存结构体的信息。

3.选择有效数据。在非并行的Job中,可能一帧内会添加同一个对象的多个不同数据,由于是顺序处理的,不会出现什么问题。而在并行Job中,需要选择其中某个数据作为有效数据,一般选择最后一个。

4.合理使用原子操作。存在一个动态数据被不同对象读写的情况,这种情况下需要考虑使用原子操作。

猜你喜欢

转载自blog.csdn.net/enternalstar/article/details/134408003
job
今日推荐