Hugo Future Imperfect Slim

L-Lawliet's Blog

记录游戏开发的点点滴滴

Unity DOTS(Jobs)

Unity DOTS(Jobs)

Lawliet

Colourful

详细介绍Unity DOTS-Jobs的入门和技巧

安装

打开Package Manager,使用Add package from git URL添加com.unity.jobs
这样就能把想相关依赖包也加入。


NativeContainer

简单来说,Unity针对Jobs和Entities定义了一种可以在C#上安全访问原生内存的数据类型。它包含了一个指向非托管分配内存的指针,在Jobs环境下,Job可以通过NativeContainer安全的访问主线程共享(不是拷贝数据)的数据,提高了访问效率。
注意:NativeContainer虽然包含了指向非托管分配内存的指针,但它自己是托管对象。

NativeContainer实际上是符合NativeContainer安全的数据结构统称,包括以下几种:

名称介绍
NativeArrayArray的NativeContainer形式
NativeListList的NativeContainer形式
NativeHashMapHashMap的NativeContainer形式
NativeMultiHashMap一对多HashMap的NativeContainer形式
NativeQueueQueue的NativeContainer形式

分配器(Allocator)

当创建一个NativeContainer时,你必须指定你需要的内存分配类型。分配的类型由jobs运行的时间来决定。这种情况下你可以在每一种情况下使分配器达到可能的最好性能。

这里对于NativeContainer的内存分配有三个分配器类型。当你初始化你的NativeContainer时你需要指定一个合适的分配器。

  • Allocator.Temp:是最快的分配类型。它适用于分配一个生命周期只有一帧或更短时间的操作。你不应当把一个分配器为Temp类型分配的NativeContainer传递给jobs使用。你同时需要在函数返回之前调用Dispose方法(例如MonoBehaviour.Update,或者其他从原生到托管代码的调用)
  • Allocator.TempJob:是速度介乎与Temp与Persistent之间的分配类型。这是一个生命周期为四帧的内存分配而且它是线程安全的。如果你在四帧之内没有调用Dispose,控制台会打印一个由原生代码生成的警告信息。绝大部分小jobs使用这种类型的NativeContainer分配器。
  • Allocator.Persistent:是最慢的分配类型,但它可以持续存在到你需要的时间,如果必要的话可以贯穿应用程序的整个生命周期。它是直接调用malloc的一个封装。长时间的jos可以使用这种分配类型。当性能比较紧张的时候你不应当使用Persistent。

安全性系统

安全性系统内置于所有NativeContainer类型,它会追踪所有关于任何NaiveContainer的读写。

注意:所有安全性检查只在Unity编辑器下可用

安全性系统是由DisposeSentinel和AtomicSafetyHandle组成:

  • DisposeSentinel:内存泄漏检测,不过只会在内存泄漏的发生很久之后触发错误。

  • AtomicSafetyHandle:原子安全检测,对写入冲突进行检查,如果两个及以上的Jobs同时对同一个NaiveContainer写入数据,就会抛出异常。需要等上一个job写入完成后,下一个才能安全写入。读取则不受限制,可以并行读取。

默认情况下,job声明了NaiveContainer之后就拥有读写权限,这样会降低读写访问性能。如果job不用写入操作可以添加[ReadOnly]特性。

[ReadOnly]
public NativeArray<int> input;

注意:这边没有针对从一个job中访问静态数据的保护。访问静态数据可以绕过所有的安全性系统并可能导致Unity奔溃。

自定义NativeContainer

根据官方案例做了一些调整(修改/屏蔽了2020.3缺少的API接口):

  • UnsafeUtility.MallocTracked改为UnsafeUtility.Malloc,去掉最后一个参数。
  • UnsafeUtility.FreeTracked改为UnsafeUtility.Free,去掉最后一个参数。
  • 屏蔽UnsafeUtility.IsNativeContainerType<T>调用
  • 屏蔽AtomicSafetyHandle.SetNestedContainer调用

首先是定义NativeContainer结构,有以下条件:

  • struct类型
  • 有unsafe标签
  • 需要标记[NativeContainer]特性,告诉JobSystem这是拥有AtomicSafetyHandle的容器
  • 由于需要手动管理、释放内存,因此需要实现IDisposable接口
[NativeContainer]
public unsafe struct NativeAppendOnlyList<T> : IDisposable where T : unmanaged
{
  //...
}

定义基础属性:

  • m_Buffer:容器的内存块指针,使用[NativeDisableUnsafePtrRestriction]特性解除了原生指针的限制
  • m_AllocatorLabel:分配器
  • m_Safety:原子安全句柄
  • s_staticSafetyId:原子安全句柄的ID
    [NativeDisableUnsafePtrRestriction]
    internal void* m_Buffer;
    internal int m_Length;
    internal Allocator m_AllocatorLabel;

#if ENABLE_UNITY_COLLECTIONS_CHECKS
    internal AtomicSafetyHandle m_Safety;

    internal static readonly int s_staticSafetyId = AtomicSafetyHandle.NewStaticSafetyId<NativeAppendOnlyList<T>>();
#endif

申请内存/释放内存:
NativeContainer由于是采用非托管内存来管理数据,所以需要使用UnsafeUtility.MallocUnsafeUtility.Free来分配和释放。
为了确保内存块大小正确,还需要使用UnsafeUtility.SizeOf<T>()来计算类型的内存大小。

//申请内存
int totalSize = UnsafeUtility.SizeOf<T>() * m_Length;
m_Buffer = UnsafeUtility.Malloc(totalSize, UnsafeUtility.AlignOf<T>(), m_AllocatorLabel);

//释放内存
UnsafeUtility.Free(m_Buffer, m_AllocatorLabel);

//写入元素到指针的偏移位置
UnsafeUtility.WriteArrayElement(m_Buffer, m_Length++, value);

原子安全句柄操作:
注意:涉及到AtomicSafetyHandle的需要包含在ENABLE_UNITY_COLLECTIONS_CHECKS里。


#if ENABLE_UNITY_COLLECTIONS_CHECKS

//创建原子安全句柄
m_Safety = AtomicSafetyHandle.Create();

//设置ID
AtomicSafetyHandle.SetStaticSafetyId(ref m_Safety, s_staticSafetyId);

//每次写入时,自动升级版本号
AtomicSafetyHandle.SetBumpSecondaryVersionOnScheduleWrite(m_Safety, true);

//检查读取权限
AtomicSafetyHandle.CheckReadAndThrow(m_Safety)

//检查写入权限
AtomicSafetyHandle.CheckWriteAndThrow(m_Safety);

//检查解除释放权限
AtomicSafetyHandle.CheckDeallocateAndThrow(m_Safety);

//释放句柄
AtomicSafetyHandle.Release(m_Safety);

#endif

Jobs

以上图片描述了整个Jobs的调度流程。

Job

常用的接口如下:
| 接口名称 | 作业数 | 线程 | 说明 |
| — | — | — | — |
| IJob | 单个 | 单个Worker线程 | 与其他Job和主线程并行的单个作业 |
| IJobParallelFor | 多个 | 多个线程(Main/Worker)并行 | 将任务拆分成多个Job,在多个线程并行执行 |
| IJobFor | 多个 | Run(Main线程)、
Schedule(单个Worker线程)
多个工作线程同时 | IJobFor可以理解为更灵活的IJobParallelFor,有多种模式 |

创建Job有以下几点要注意的:

  • Job一定是struct
  • Job里使用的成员变量只能为Blittable类型或NativeContainer类型
  • 使用的成员变量不能是引用对象(托管堆内存)
  • 不能调用静态对象
  • 只能在主线程调用的Unity接口无法使用

Jobs为了解决在多线程出现的数据竞争条件,会为每个Job发送一份数它需要操作的数据副本,而不是主线程中数据的引用,这有效隔离了数据。
由于复制数据是在原生环境操作,所以Job只能访问Blittable类型或NativeContainer类型。前者在托管环境和非托管环境传递不需要转换,而后者则直接使用UnsafeUtility.Malloc申请非托管内存。

IJob

根据示例,可以实现IJob接口,然后实现Execute

public struct AddJob : IJob
{
  public float number;
  public NativeArray<float> result;

  public void Execute()
  {
    for (var i = 0; i < position.Length; i++)
    {
      result[i] = number + i;
    }
  }
}

Schedule

调度一个Job,此时会把Job放入(Job)队列,然后JobSystem会在这个Job的依赖(前置)Job完成之后(如果有依赖项)开始调用这个Job。
注意:只能在主线程调用Schedule

NativeArray<float> result = new NativeArray<float>(10, Allocator.TempJob);

AddJob job = new AddJob();
job.number = 100;
job.result = result;

// 对Job进行调度,然后拿到调度的句柄
JobHandle handle = jobData.Schedule();

// 等待Job完成
handle.Complete();

Debug.Log(result[0]); //Log: 100

// 需要在最后手动释放NativeContainer
result.Dispose();

JobHandle

当你调用Schedule方法时会返回一个JobHandle。它可以用来阻塞主线程等待完成,或者用来作为其他Job的前置依赖。
下面代码就展示了JobHandle的几种用法:

// 调度Job a
JobHandle aJobHandle = aJob.Schedule();

// 调度Job b,但需要依赖Job a(等待Job a完成)
JobHandle bJobHandle = bJob.Schedule(a);

JobHandle cJobHandle = cJob.Schedule();

// 可以将多个JobHandle合并成单个,然后作为其他Job的前置依赖(需要等待多个Job完成)
JobHandle bcJobHandle = JobHandle.CombineDependencies(bJobHandle, cJobHandle);

JobHandle dJobHandle = dJob.Schedule(bcJobHandle);

// 阻塞主线程,等待完成
dJobHandle.Complete();

P.S. Job在调度时不会立刻开始执行,如果需要在主线程等待作业,并需要访问作业所使用的NativeContainer数据,可以调用Complete方法刷新内存缓存中的Job并开始执行,才可以确保后续逻辑运行时Job已经执行完成,才能在主线程安全的访问Job中的NativeContainer。1

并行Job

IJobParallelFor是并行Job的其中一个实现接口(后面版本还有IJobParallelForTransform等),它主要是把相同任务拆分成多个Job来同时执行。
在讲解IJobParallelFor前,首先要根据下图理解一些概念:

  • Execute(n):单个可执行的任务
  • Batch:多个任务(Execute(n))集合
  • Batches:多个Batch的集合
  • Native Job:放置在Job Queue的原生作业,里面包含了一个Batches

Jobs

根据官方图示,IJobParallelFor的执行流程如下:

  1. Main Thread:
  2. 在主线程创建的并行Job
  3. 根据ParallelFor Job的数据源长度,创建可执行的任务(Execute(n)),放入Data Source。
  4. C# Job System
    1. 将任务划分成多个Batch块,每个Batch有batchSize(innerloopBatchCount)个任务。
  5. Job Queue
    1. 创建Native Job,放入Batch块
    2. 将Native Job放入到Job Queue
  6. Native Job System
    1. 从Job Queue弹出Native Job,放入工作线程并执行其Batches
    2. 存储结果到Native内存(可以理解为Native Container)

在调度IJobParallelFor时,需要设定数据源长度,这可以告诉Job System需要创建多少个任务(Execute方法),因为Job System无法知道哪个NativeContainer是数据源,也不知道数据源长度和任务数的关系。
举个例子就是:NativeArray每三个数据表示为一个三位坐标x、y、z,需要一起处理,那么创建的任务数就是NativeArray.Length/3,并不是NativeArray.Length
在调度时还需要设置batchSize(innerloopBatchCount),这是告诉Job System单个batch的任务颗粒度是多少(也就是有多少个Execute方法)。由于一个Native Job在执行完之后会窃取其他的Native Job的剩余批次(一次只能窃取剩余批次的一半,以确保缓存局部性2)。
batchSize可以控制Job的数量,以及线程之间重新分配工作的细化程度。值设置的较小(例如1)可以使线程之间的工作分布更均匀,但也会带来一些开销,所以需要根据实际情况来设置这个值,如果单个任务较为简单,可以将值设置较大,如果单个任务较为复杂耗时,可以将值设置更小。

//调度一个IJobParallelFor,将array作为数据源,将array.Length作为数据源长度。batchSize设置为64
parallelForJob.Schedule(array.Length, 64);

IJobFor

IJobFor可以理解为更灵活的IJobParallelFor。他提供了三种调用方法:

  • Run:直接在主线程顺序执行。还是会根据数据源长度来创建多个任务(Execute)
  • Schedule:可以在单个工作线程(或主线程)顺序执行。如果调用JobHandle.Complete方法会直接在主线程执行。
  • ScheduleParallel:根据官网描述跟IJobParallelFor.Schedule一样。

看起来IJobFor的用途并不广泛,所以篇章较少。


内存管理

StructLayout

StructLayout是一个控制数据字段物理内存布局的特性,他可以添加到Class或者Struct中。

  • LayoutKind:指定如何排列类或结构。使用LayoutKind的枚举值。
  • Pack:控制类或结构的数据字段在内存中的对齐方式。
  • Size:指示类或结构的绝对大小。
  • CharSet:指示在默认情况下是否应将类中的字符串数据字段作为LPWSTR或LPSTR进行封送处理。

先来说一下LayoutKind:

  • Auto:由运行库自动决定对象在内存中的布局方式,因为这是运行库内部规则根据不同运行环境决定的字段顺序和对齐方式,无法确保在非托管代码中的内存布局一致,因此对象无法传给托管代码以外使用。
  • Sequential:Class或者Struct中的字段会按其(声明)顺序在内存排列,编译器插入填充字节满足对齐要求。
    • Sequential对于blittable类型,托管代码和非托管代码的内存布局一致,可以互相传递。
    • Sequential对于非blittable类型,托管代码不受影响,运行时会自动插入填充字节或调整字段顺序。而非托管代码则会按照声明顺序排序。与托管代码的内存布局可能不同,因此不能直接传递。
  • Explicit:显式指定字段偏移量。
    • Explicit对于blittable类型,托管代码和非托管代码的内存布局一致,都是根据字段偏移量来确定内存布局,可以互相传递。
    • Explicit对于非blittable类型,托管代码不受影响,运行时会自动插入填充字节或调整字段顺序。而非托管代码则会按照字段偏移量来确定内存布局。与托管代码的内存布局可能不同,因此不能直接传递。

默认值:

  • Struct默认为Sequential,但拥有引用类型字段时,默认会变成LayoutKind.Auto。
  • Class默认为LayoutKind.Auto。

Blittable

上文提及到Job的成员变量需要使用Blittable类型或者NativeContainer类型,这里说的Blittable是指那些在托管代码和非托管代码中为相同内存布局的类型总称,特点就是托管和非托管互相传递时不需要进行特殊转换。

  • 常见的Blittable:
类型名字段大小
System.Byte1
System.SByte1
System.Int162
System.UInt162
System.Int324
System.UInt324
System.Single4
System.Int648
System.UInt648
System.Double8
System.IntPtr4(32位) 或 8(64位)
System.UIntPtr4(32位) 或 8(64位)
  • 常见的Blittable复杂类型:

    • Blittable基元类型的一维数组,如整数数组。但是,包含基元类型一维数组字段的类型并不是Blittable。
    • 所有字段为Blittable的类型,并且使用LayoutKind.SequentialLayoutKind.Explicit布局的Struct也是Blittable
  • 不是Blittable的情况:

    • bool这个最容易理解错,true在不同平台可能会有不同值,对应的字节数可能是1、2或者4。
    • char涉及不同的编码。
    • 对象引用不是Blittable类型,这包括本身是Blittable对象的引用数组。
  • 有疑问的情况:

    • int[][]使用UnsafeUtility.IsBlittable判定为Blittable,这个跟官网描述不一致。
    • 非Blittable的struct对象,其数组也判定为Blittable,这个也能笔者疑惑。

注意:有疑问的情况列举的是基于Unity的UnsafeUtility.IsBlittable接口返回结果,实际上.Net的官网描述跟Unity判定笔者是觉得有出入。

原生内存分配

上文分别提到NativeContainer,分别使用了UnsafeUtility.SizeOf和UnsafeUtility.AlignOf两个接口,这两个接口都是分配原生内存时需要使用的接口。

  • UnsafeUtility.SizeOf是返回指定结构类型的总字节数,其中包括因为字段对齐而填充的字节。
  • UnsafeUtility.AlignOf是指定结构类型在内存中所需的最小对齐大小。在分配内存时需要把结果告诉内存分配,从而确保数据结构正确对齐,提高访问速度。

SizeOf和AlignOf都跟StructLayout相关,AlignOf实际上是根据结构体最大字段字节和StructLayout.Pack取最小值得出来的,表示这个结构体的最小对齐大小。而SizeOf则是根据结构体的对齐内存和填充内字节后的实际字节数得到的。3 4


  1. JobSystemJobDependencies, https://docs.unity3d.com/cn/2020.3/Manual/JobSystemJobDependencies.html ↩︎

  2. Why does cache locality matter for array performance?, https://stackoverflow.com/questions/12065774/why-does-cache-locality-matter-for-array-performance ↩︎

  3. 理解.NET结构体字段的内存布局, https://www.cnblogs.com/eventhorizon/p/18913041 ↩︎

  4. aligned_malloc实现内存对齐, https://blog.csdn.net/jin739738709/article/details/122992753 ↩︎

最新文章

分类

关于

记录游戏开发的点点滴滴