Hugo Future Imperfect Slim

L-Lawliet's Blog

记录游戏开发的点点滴滴

【翻译】Unity的TransformAccessArray —— 内部机制与最佳实践

<Unity’s TransformAccessArray — internals and best practices>文章的翻译

Lawliet

Colourful

“Unity’s TransformAccessArray — internals and best practices"文章的翻译

声明

原文地址:https://medium.com/toca-boca-tech-blog/unitys-transformaccessarray-internals-and-best-practices-2923546e0b41
本文翻译如有侵权,请及时联系笔者。


我最近接手了一位转组同事留下的渲染代码,这些代码大量使用了 Unity 的 TransformAccessArray 和 Unity Jobs。我之前听说过 TransformAccessArray,也见过它的使用,但从未有机会深入了解它的工作原理。

要搞懂它确实有点吃力,因为相关资料非常少,Unity官方文档也很简略。截至目前,最有用的文档页面是IJobParallelForTransformIJobParallelForTransformExtensions的子页面,但即使是这些页面,内容也不够详尽。

因此,这次正好是一个机会,让我深入研究 Unity Job System 的一些内部机制,以更好地理解 TransformAccessArrayIJobParallelForTransform 的工作原理,特别是它们与主线程上的“常规” Transform 访问之间的交互。我发现了一些非常有趣的结果,想在这篇文章中分享给大家。

接下来,我将介绍TransformAccessArray的一些内部机制、一个用于测试的Job脚本、几种Job使用模式的分析:具有Job依赖的TransformAccessArrayJob多次只读Transform访问单个Transform根节点,以及基于这些观察得出的最佳实践。文章最后还有一个简短的Unity性能优化愿望清单,希望能推动Job System的性能提升。

让我们开始吧!


TransformAccessArray的内部机制

Unity的Job System只能使用blittable的数据类型,这意味着 Job 无法访问托管类型(如 TransformMeshRenderer)。从安全性角度来看,这种限制可以防止数据竞争:即一个工作线程在写入某块内存时,另一个线程或主线程也在访问该内存地址。

TransformAccessArray 提供了一种绕过这一限制的方法,使得 Job 可以读取或写入 Transform 数据。其核心功能依赖于 Unity 内部的 C++ 类 TransformHierarchy。在Unite Berlin 2018 — Unity’s Evolving Best Practices演讲的 13:14 处,有一段关于该类的讨论,至今仍然非常相关。以下这句话非常关键,有助于理解 TransformAccessArray 的工作原理:

场景中的每个transform root都有一个对应的transform hierarchy。

image

场景层级中的transform root

从此处开始,我将交替使用“transform hierarchy(transform层级结构)”和“transform root(transform根节点)”这两个术语。

如果将上述内容与这段在IJobParallelForTransform.Schedule文档中的片段结合起来,我们可以初步感受到两者之间的联系:

该方法对不同层级结构中的Transform进行并行访问。共享同一个根对象的 Transform总是在同一个线程上处理。

ScheduleReadOnly方法则放宽了调度限制:

该方法提供了更好的并行性,因为它可以并行读取所有 Transform,而不仅仅是不同层级结构之间的并行。

当你使用一个 Transform 数组创建 TransformAccessArray 实例时,Unity 会在内部对其进行重排序,以提高访问效率。Transform 首先会按根节点进行分组,然后共享同一个根节点的 Transform 会被排序在一起,以便根据其内部的层级结构优化迭代速度。

当 Unity 执行 IJobParallelForTransform Job 时,它调用 Execute 方法的顺序可能与 TransformAccessArray 中 Transform 数组的原始顺序完全不同。这时,index 参数就派上用场了。它允许你将某次 Execute 调用映射回原始 Transform 数组中的对应元素。如果你注意到内部方法 TransformAccessArray.GetSortedToUserIndex 并好奇它的作用,这就是答案。

重要的是,TransformAccessArray 不会为了读取或写入 Transform 数据而复制任何数据。除非你以其他方式实现,否则不会有缓冲区或临时存储。这种直接访问是使用它的主要好处。

但这也意味着,在我们具体了解这一切如何协同工作之前,还有一段理论需要掌握。


Transform 层级访问的同步机制

如前文所述,Unity 的Job System对Job中可以访问的数据有非常严格的限制。对于 TransformAccessArray 及其隐式提供的 TransformHierarchy 实例,这意味着 Unity 必须有一些机制来避免并发修改 Transform 或读取过期值。其数据同步方法(内部称为 fence)可防止主线程和工作线程同时访问同一 Transform 时发生竞争条件

Unity 保护 Transform 访问的颗粒度是 Transform 层级结构。换句话说,所有共享同一个 Transform 根节点的 Transform 也共享同一个 fence。当一个使用 TransformAccessArray 的 Job 被调度时,Unity 会延迟所有对该 Job 所涉及的 Transform 层级结构中任何 Transform 的访问,直到该 Job 完成。这意味着,任何与该 TransformAccessArray 中任意 Transform 共享同一个根节点的 Transform,都会被阻塞。让我们用一个例子来说明:

image

两个操作同一 Transform 层级结构的 Job

上图展示了两个 Job,即使它们访问的不是同一个 Transform,只要它们共享一个 Transform 层级结构,就会发生阻塞。如果 Job AJob B 之前被调度,那么 Job B 必须等到 Job A 完成后才能开始。即使 Job A 剩余的任务只涉及其他层级结构,它也必须完全完成。这种阻塞行为会影响以下场景:

  • 其他 IJobParallelForTransform Job,即使是只读 Job。
  • 主线程对 Transform 的读取或写入,例如在 MonoBehaviour 脚本中。
  • 任何其他对 Transform 的访问,例如动画和蒙皮网格渲染器。

当 Job 被阻塞时,它们会留在 Job 执行队列中。当主线程被阻塞时(例如调用JobHandle.Complete 或尝试访问一个尚未完成的 Job 正在使用的 Transform),它可能会空闲下来,或者可能的情况下窃取工作。而工作窃取意味着主线程会拿起一个准备执行的 Job 并执行它。其背后的逻辑是:主线程做点事总比闲着好。但这会带来一些重要后果,后文会讨论。


TransformAccessArray测试脚本

我写了以下测试脚本,用于验证我前面提到的很多信息,并在不同的人造Job场景(译注:表明这些场景都是特意制造出来的)中验证我对 TransformAccessArray 的理解。没有哪个游戏会真的按这个顺序执行这些操作,但它仍然是模拟各种真实场景的好方法。

(代码已针对 Medium 的窄屏宽度优化显示)

using System.Threading;
using Unity.Jobs;
using Unity.Profiling;
using UnityEngine;
using UnityEngine.Jobs;

public class TransformAccessArrayTest : MonoBehaviour
{
   [SerializeField] [Range(1, 100)] private uint hierarchyRootsCount = 2;

   [Tooltip("Transform count in each transform hierarchy root")]
   [SerializeField] [Range(1, 100)] private uint hierarchyDepth = 4;

   [Tooltip("How long to delay the start of the TransformJob")]
   [SerializeField] private int delayJobStartMs;

   [Tooltip("Per-Transform job time")]
   [SerializeField] private int jobRuntimeMs = 1;
  
   [SerializeField] [Range(-1, 100)] private int desiredJobCount = -1;
   [SerializeField] private bool scheduleAsReadOnlyJob;
  
   [Tooltip("Schedule a second read-only IJobParallelForTransform job")]
   [SerializeField] private bool addParallelReadOnlyJob;

   [Tooltip("Applies to read-only jobs only")]
   [Range(1, 100)] [SerializeField] private int readonlyJobBatchSize = 2;
  
   [SerializeField] private bool readTransformsAfterJob = true;
   [SerializeField] private bool writeTransformsAfterJob;

   GameObject[] generatedGameObjects;
   TransformAccessArray accessArray;

   static readonly ProfilerMarker markerBefore = new("BeforeJobScheduled");
   static readonly ProfilerMarker markerReadAfter = new("ReadAfterJobScheduled");
   static readonly ProfilerMarker markerWriteAfter = new("WriteAfterJobScheduled");

   void OnValidate() => Cleanup();
   void OnDestroy() => Cleanup();

   void Cleanup()
   {
       if (generatedGameObjects != null)
       {
           for (int i = generatedGameObjects.Length - 1; i >= 0; i--)
               Destroy(generatedGameObjects[i]);

           generatedGameObjects = null;
           if (accessArray.isCreated)
               accessArray.Dispose();
       }
   }

   void Update()
   {
       // Support dynamically changing parameters in the Editor
       if (!accessArray.isCreated)
           CreateTransformArray();
      
       // Write to the transforms from the main thread
       using (markerBefore.Auto())
       {
           foreach (var go in generatedGameObjects)
               go.transform.position += new Vector3(0.000001f,0, 0);
       }

       var delayJobHandle = delayJobStartMs <= 0 ? default :
           new DelayJob() { DelayMs = delayJobStartMs }.Schedule();
       var transformJob = new TransformJob() { JobRuntimeMs = jobRuntimeMs };

       // Set the optional DelayJob as a job dependency of TransformJob
       if (scheduleAsReadOnlyJob)
       {
           transformJob.ScheduleReadOnly(accessArray, readonlyJobBatchSize, delayJobHandle);
       }
       else
       {
           transformJob.Schedule(accessArray, delayJobHandle);
       }

       // Schedule a second read-only job
       if (addParallelReadOnlyJob)
       {
           new ReadOnlyJob() { JobRuntimeMs = jobRuntimeMs }
               .ScheduleReadOnly(accessArray, readonlyJobBatchSize, delayJobHandle);
       }

       // Fake some work so the loops below can't be removed as no-ops
       var position = Vector3.zero;
       if (readTransformsAfterJob)
       {
           using var _ = markerReadAfter.Auto();
           foreach (var go in generatedGameObjects)
               position += go.transform.position;
       }
      
       if (writeTransformsAfterJob)
       {
           using var _ = markerWriteAfter.Auto();
           position = Vector3.Min(position * 0.0000001f, Vector3.one * 0.1f);
           foreach (var go in generatedGameObjects)
               go.transform.position = position;
       }
   }

   void CreateTransformArray()
   {
       generatedGameObjects = new GameObject[hierarchyRootsCount * hierarchyDepth];
       var transforms = new Transform[hierarchyRootsCount * hierarchyDepth];
       var transformsIndex = 0;
       for (int i = 0; i < hierarchyRootsCount; i++)
       {
           var parent = new GameObject($"generated_{i}");
           generatedGameObjects[transformsIndex] = parent;
           transforms[transformsIndex++] = parent.transform;
          
           // Not technically a depth, but it has the same impact
           // for Unity's job scheduling
           for (int j = 0; j < hierarchyDepth - 1; j++)
           {
               var child = new GameObject($"{parent.name}_{j}");
               child.transform.SetParent(parent.transform);
               generatedGameObjects[transformsIndex] = child;
               transforms[transformsIndex++] = child.transform;
           }
       }

       accessArray = new TransformAccessArray(transforms, desiredJobCount);
   }
}

struct DelayJob : IJob
{
   public int DelayMs;
   public void Execute() => Thread.Sleep(DelayMs);
}
  
struct TransformJob : IJobParallelForTransform
{
   public int JobRuntimeMs;
   public void Execute(int i, TransformAccess t) => Thread.Sleep(JobRuntimeMs);
}
  
// Use a second type to easily identify it in the profiler
struct ReadOnlyJob : IJobParallelForTransform
{
   public int JobRuntimeMs;
   public void Execute(int i, TransformAccess t) => Thread.Sleep(JobRuntimeMs);
}

脚本启动时,会根据 Hierarchy Roots CountHierarchy Depth 属性生成一个游戏对象层级结构。

image

脚本属性与生成的场景游戏对象

下面的序列图展示了 Update 方法的执行流程,黄色框展示了某些属性的影响。

image

Job与行为的序列图

如果启用 Add Parallel Read Only Job 属性,脚本会在序列图的第二步之后立即调度第二个 IJobParallelForTransform Job,即 ReadOnlyJob。为了简化脚本,该 Job 访问的是同一个 TransformAccessArray,但即使它们使用不同的 TransformAccessArray 实例,只要访问的是同一个 Transform 层级结构,结果也是一样的。

DelayJob 是一个可选的 Job 依赖项,它允许我们模拟某些 Job 依赖其他 Job 输出的场景(尽管本脚本中并没有实际输出)。

我们使用 -job-worker-count 4 命令行参数(译注:Unity Hub可以在工程项添加)将 Unity 配置为使用 4 个工作线程,来分析几种场景。


具有Job依赖的TransformAccessArrayJob

本场景使用 2 个 Transform 根节点,每个根节点下有 4 个 Transform。测试脚本调度了一个 DelayJob,它是 TransformJobReadOnlyJob 的依赖项。

image

带 Job 依赖的 TransformAccessArray Job 设置

image

带 Job 依赖的 TransformAccessArray Job的Profiler截图

从 Profiler 截图中我们可以观察到几点:

主线程在 ReadAfterJobScheduled 中被阻塞,因为它试图读取 TransformJobReadOnlyJob 正在使用的 Transform。注意,主线程是在 DelayJob 执行期间被阻塞的,而 DelayJob 并不是 IJobParallelForTransform。这是因为 TransformJob 已经被调度执行,此时它已经将自身设置为该 Transform 层级结构同步 fence 的依赖项。

当主线程被阻塞时,Profiler 截图展示了上述两种行为:主线程先“偷取”并执行了 DelayJob,然后在其他 Job 执行期间处于空闲状态。这种行为是非确定性的,在不同帧之间可能会有所不同。

尽管 Desired Job Count 设置为 8,而总共有 8 个 Transform 可供处理,但 TransformJob 只分成了 2 个批次。这是因为非只读 Job 不会在同一 Transform 层级结构上并行执行。而 ReadOnlyJob 可以在同一层级结构上并行访问 Transform,因此被分成了 4 个批次(因为 Read Only Batch Size 设置为 2)。


多次只读TransformAccesses

本场景使用相同的 Transform 配置,但 TransformJobReadOnlyJob 都是只读 Job,并且没有 DelayJob(下一节会解释原因)。换句话说,在 BeforeJobScheduled 标记的写入访问之后,脚本只通过只读方式访问 Transform。

image

多次只读 transform accesses的设置

image

多次只读 transform accesses的Profiler截图

_

主线程再次在 ReadAfterJobScheduled 中被阻塞,因为它试图读取正在由 Job 使用的 Transform。我对这种阻塞行为感到惊讶,因为这些访问都是只读的,而且 Unity 可以通过 API 调用清楚地知道这一点。

这是 Unity 错过的一个优化机会:允许多个只读用户并行访问共享资源。理论上,Unity 可以让主线程无阻塞地读取 Transform 数据,并与其他只读 Job 并行运行,而不是让线程空闲。


死锁警告!

截至目前,以下问题影响 Unity 版本 2022.3.31f1 至 2022.3.52f1,以及 6000.0.3f1 及以上版本。

如果你在上一场景中设置 Delay Job Start 为非零值,Unity 将会卡死无响应。ReadAfterJobScheduled 中的 Transform 读取访问永远不会完成,陷入死锁。

导致死锁的最小事件顺序如下:

  1. 调度任意 Job A。
  2. 使用 ScheduleReadOnly 方法调度一个 IJobParallelForTransform 的Job B,它依赖于 Job A。
  3. 从主线程读取包含在 Job B 的 TransformAccessArray 中的某个 Transform 的位置。

这是一个常见的 Job 使用模式,但由于依赖于具体的执行时机,这个 Bug 可能是间歇性的,难以在实际项目中诊断。

在我们分析《Toca Boca Days》游戏的“应用无响应”(ANR)问题时,最常见的调用栈就出现在访问 Transform 的代码中。由于我们大量使用了 IJobParallelForTransform.ScheduleReadOnly,我怀疑我在研究 TransformAccessArray 时,偶然发现了这些卡死的根本原因。 意外收获!

相关 Unity 工单:UUM-86782


单个Transform根节点

我们再来看一个场景:只有一个根 Transform,但有很多子对象。我们将 Desired Job Count 设置为 -1,让 Unity 自己选择默认值。

image

单个根 Transform 的设置

image

单个根 Transform 的Profiler截图

__

如前所述,主线程在 ReadAfterJobScheduled 中被阻塞。

由于 TransformJob 不是以只读方式调度的,而且整个 TransformAccessArray 只有一个 Transform 层级结构,Unity 不会将该 Job 分发到多个工作线程,导致 Job 执行时间非常长。

ReadOnlyJob 不受层级结构数量限制,被分成了 4 个较小的单元。不过,在某些帧中,ReadOnlyJob 会同时有 4 个或 5 个任务在执行。这种差异是因为 TransformParallelForLoopStruct.Execute 方法(这是每个线程在 C# 中的 Job 起始点)会调用 JobsUtility.GetWorkStealingRange 来“偷取”其他线程的任务批次,直到所有任务完成。由于线程到达该代码的顺序是非确定性的,理论上我们可能会看到 1 到 5 个线程在执行该 Job。


TransformAccessArray Job 性能最佳实践

以下是基于我的研究和分析得出的建议,适用于 Unity 2022.3.52f1 和 6000.0.26f1 版本。这些建议可以帮助你更好地利用 TransformAccessArray Job。

关于 Job System 的一般性最佳实践还有很多内容,但本文重点聚焦于访问 Transform 的 Job。我还是要给出一条通用建议:尽量使用Burst 编译器。我的示例代码中没有使用它,因为它与我的研究无关,但在实际项目中你几乎总是应该使用它。


1. 使用 ScheduleReadOnly 调度只读 Job

如果一个 Job 只需要读取 Transform 数据(即不写入),请务必使用 IJobParallelForTransform.ScheduleReadOnly 方法来调度它。这样可以获得更好的并行性。不过,请务必阅读前面的“死锁警告!”部分,因为当前 Unity 版本中存在一个严重的 Bug。


2. 避免深层次的 Transform 层级结构

尽可能将深层次的 Transform 层级结构拆分为多个根 Transform。务必避免“文件夹”式层级结构(即大量游戏对象挂在一个根节点下)。这条建议对 Unity 的许多系统和性能都有显著影响。对于使用 TransformAccessArray 的 Job,具体好处包括:

  • 为非只读 Job 提供更多并行化机会。Job System 不会将这类 Job 分发到更多(超过Transform 根节点数量)的工作线程上。【注:有n个根节点,那么最多只能分发到n个工作线程并行处理】
  • 降低 Job 无意中阻塞主线程或产生“隐式” Job 依赖的可能性。记住,只要一个 Job 访问了某个 Transform 层级结构,而另一个 Job 也访问了同一个层级结构,那么后者必须等前者完成,即便它是只读的。

3. 在真机上分析 Job 行为

在目标硬件或代表性设备上,使用 Profiler 分析游戏中 Job 的行为。不同平台和构建配置下的性能瓶颈可能完全不同。如果你发现某些帧中,TransformAccessArray Job 的工作线程数量少于预期,请检查是否有限制并行性的因素,并审查你使用的 desiredJobCountinnerloopBatchCount 参数。你应设置足够小的值以保持所有工作线程忙碌,但又不能太小,以免线程频繁地抓取或偷取任务。这里没有放之四海而皆准的值。作为参考,Unity 默认为非只读 Job 设置的最小 Transform 数量为 32,而所有并行 Job 的最大任务批次数量为 16。


4. 优先使用局部空间数据

尽可能使用局部空间的 Transform 数据,例如 localPositionGetLocalPositionAndRotation,而不是世界空间数据,如 positionlocalToWorldMatrix。这条建议适用于所有与 Transform 交互的场景,而不仅仅是 Job。Transform 层级结构存储的是局部空间的变换矩阵,因此局部空间值可以直接访问。而世界空间值需要 Unity 递归地乘以所有父节点的局部矩阵,每次访问时都会重新计算。这个过程比直接访问局部空间值慢得多,且随着父节点数量增加而变得更慢。如果必须使用世界空间值,请尽量减少调用次数,并使用 GetPositionAndRotationSetPositionAndRotation


5. 重用 TransformAccessArray 实例

在帧之间重用 TransformAccessArray 实例,不要无谓地重新创建。每次创建新实例时,Unity 都会分配内存、准备状态并对 Transform 进行排序,这些都会耗时。例如,如果你需要从数组中移除一个 Transform,请使用 TransformAccessArray.RemoveAtSwapBack 方法,而不是重新创建数组。不过,这里有一个关于 fence 的注意事项:当你修改一个已有的 TransformAccessArray 时,Unity 会触发一个同步点,等待所有之前使用该数组的 Job 完成。因此,也不要为了重用数组而引入隐式的 Job 依赖。


6. 不要用 Transform 存储中间数据

不要使用 TransformAccessArray 中的 Transform 来存储 Job 之间的中间数据。与其他方式相比,Job 中访问 Transform 的速度相对较慢,且有阻塞主线程的风险,并行化机会也更少。理想情况下,Job 中只应在需要最新值时读取 Transform 数据,并且每帧最多写入一次 Transform。考虑专用的 TransformAccessArray Job 仅用于提取和输出 Transform 值,并使用临时的 NativeArray 存储中间数据。这种策略可以显著提升 Job 的并行性。


7. 注意 Transform 访问模式以避免阻塞

这条建议可能是最难诊断和实施的,但影响可能非常明显。主线程通常是帧的critical path,因此了解 TransformAccessArray Job 访问了哪些 Transform 层级结构,以及主线程何时访问这些层级结构,是非常重要的。如前面的场景所示,最坏的情况是:主线程在调度了一个访问某 Transform 层级结构的 Job 后不久,又试图访问同一个层级结构,从而导致阻塞。具体的解决方案因项目而异,但一个通用策略是:仔细规划主线程在帧中访问 Transform 的时机,并调整 Transform 层级结构以更好地匹配 Job 的执行图。


Unity Job System性能优化愿望清单

在撰写本文的过程中,我发现了一些 Unity 的限制,我认为这些限制可以在不更改或极少更改 API 的情况下得到改进。如果实现,这些改进可以在一些常见场景中为用户带来性能提升。如果有 Unity Job System 的工程师看到,欢迎交流!


1. 允许主线程与 Job 并行只读访问 Transform 层级结构

我相信 Unity 应该允许主线程与 Job 同时只读访问同一个 Transform 层级结构。我确信目前不这么做是有原因的,但在并行编程中,只读访问共享资源是非常常见的模式,而且在大多数环境中,只读访问比读写访问更高效。理论上,Unity 可以在不更改 API 的情况下实现这一改进,因为只读语义已经存在。


2. 打破 TransformAccessArray Job 对 Transform 层级结构的“整体”依赖

目前,一个 Job 必须等待所有之前访问过任何一个它所依赖的 Transform 层级结构的 Job 完成后才能开始。

image

两个共享 Transform 层级结构的 Job

在上图中,Job A 访问了一个 Transform 层级结构,Job B 访问了同一个层级结构以及其他几个。由于这一个层级结构的重叠,Job B 必须等 Job A 完成后才能开始,即使 Job A 并不是 Job B 的显式依赖。

当然,在这个简单的例子中,可以将 Job B 拆成两部分:一部分受 Job A 阻塞,访问共同的层级结构;另一部分不受阻塞,访问其余层级结构。但在复杂场景中,这种做法很快就会变得难以管理。

Unity 可以为用户无缝处理这种复杂性。由于 TransformAccessArray 实例已经按 Transform 根节点对 Transform 进行了分组,Unity 应该可以在后台将 Job 拆分成多个部分,每个部分对应一个 Transform 层级结构。每个部分可以在其对应的层级结构 fence 解除阻塞后立即开始执行。虽然这可能会带来一些关于任务批次大小的微妙差异,但潜在的性能提升是值得的。


3. 缓存 localToWorldMatrix 的计算结果

我还没有深入研究具体的实现,但也许 Transform 层级结构应该缓存最近一次计算的 localToWorldMatrix 值,这是所有其他世界空间 Transform 值的基础。在大多数访问世界空间值的 TransformAccessArray Job 中,这个值可能会被反复计算,而且随着层级结构加深,成本会越来越高。


结论

并行编程总是复杂的,而 Unity 又增加了一些“曲折”,了解这些细节对于榨干 Job System 的性能至关重要。本文中的不同场景模拟了你在实际游戏中可能遇到的情况,尽管真实项目中的复杂性更高。

有趣的是,我的研究还揭示了 Job System 中一个潜在的死锁问题,我认为这个问题在实际项目中可能相当普遍。我工作的《Toca Boca Days》游戏很可能就遇到了这个问题。希望我的 Bug 报告能成为 Unity 的一个修复,惠及整个 Unity 社区。

最后,再次强调:在你进行复杂的优化之前,请先分析你的游戏,找出真正的性能瓶颈

Thanks to Fredrik Bergljung for your support and to an anonymous tech reviewer!

最新文章

分类

关于

记录游戏开发的点点滴滴