DOTS 정리 (2) – Unity Job 시스템 시작하기

https://learn.unity.com/tutorial/660ffc54edbc2a1b0887d446?uv=6&projectId=660ffcd3edbc2a162b7baa27#66ceb506edbc2a03a1163ee9

학습 참고 문서

공식 Sample Project 링크

https://github.com/Unity-Technologies/EntityComponentSystemSamples/tree/master


멀티코어 CPU를 활용하기 위해 Job을 사용하는 기본적인 방법

STEP

– 싱글 스레드 잡을 생성, 예약, 완료

– 병렬 잡을 생성, 예약, 완료

– 다른 잡에 종속되는 잡을 예약

– NativeArrays를 사용

파란 큐브와 빨간 큐브를 각각 Seeker와 Target으로 정하고 각Seeker은 가장 가까운 Target으로 이어지는 디버그 라인을 추가합니다.

각 Step은 서로 다른 방식으로 문제를 해결하여 Step마다 성능 비교를 할 수 있습니다.

SeekerPrefab의 인스턴스를 생성 -> 2D 평면을 따라 무작위 방향과 무작위 위치를 설정 ->

Target도 동일한 방식으로 생성 -> 모든 Seeker와 Target의 트랜스폼을 정적 배열에 할당하여

FindNearest 스크립트에서 액세스하여 Update()

Sample 위치

Step 1. Scene – No Job

일반적인 브루트 포스 순회 방법, 각 Seeker 마다 모든 Target과의 거리를 확인

Spawner.cs

using UnityEngine;

namespace Tutorials.Jobs.Step1
{
    public class Spawner : MonoBehaviour
    {
        // The set of targets is fixed, so rather than 
        // retrieve the targets every frame, we'll cache 
        // their transforms in this field.
        public static Transform[] TargetTransforms;

        public GameObject SeekerPrefab;
        public GameObject TargetPrefab;
        public int NumSeekers;
        public int NumTargets;
        public Vector2 Bounds;

        public void Start()
        {
            Random.InitState(123);

            for (int i = 0; i < NumSeekers; i++)
            {
                GameObject go = GameObject.Instantiate(SeekerPrefab);
                Seeker seeker = go.GetComponent<Seeker>();
                Vector2 dir = Random.insideUnitCircle;
                seeker.Direction = new Vector3(dir.x, 0, dir.y);
                go.transform.localPosition = new Vector3(
                    Random.Range(0, Bounds.x), 0, Random.Range(0, Bounds.y));
            }

            TargetTransforms = new Transform[NumTargets];
            for (int i = 0; i < NumTargets; i++)
            {
                GameObject go = GameObject.Instantiate(TargetPrefab);
                Target target = go.GetComponent<Target>();
                Vector2 dir = Random.insideUnitCircle;
                target.Direction = new Vector3(dir.x, 0, dir.y);
                TargetTransforms[i] = go.transform;
                go.transform.localPosition = new Vector3(
                    Random.Range(0, Bounds.x), 0, Random.Range(0, Bounds.y));
            }
        }
    }
}

Seeker.cs

using UnityEngine;

namespace Tutorials.Jobs.Step1
{
    public class Seeker : MonoBehaviour
    {
        public Vector3 Direction;

        public void Update()
        {
            transform.localPosition += Direction * Time.deltaTime;
        }
    }
}

Target.cs

using UnityEngine;

namespace Tutorials.Jobs.Step1
{
    public class Target : MonoBehaviour
    {
        public Vector3 Direction;

        public void Update()
        {
            transform.localPosition += Direction * Time.deltaTime;
        }
    }
}

FindNearest.cs

using UnityEngine;

namespace Tutorials.Jobs.Step1
{
    public class FindNearest : MonoBehaviour
    {
        public void Update()
        {
            // Find nearest Target.
            // When comparing distances, it's cheaper to compare
            // the squares of the distances because doing so
            // avoids computing square roots.
            Vector3 nearestTargetPosition = default;
            float nearestDistSq = float.MaxValue;
            foreach (var targetTransform in Spawner.TargetTransforms)
            {
                Vector3 offset = targetTransform.localPosition - transform.localPosition;
                float distSq = offset.sqrMagnitude;
                if (distSq < nearestDistSq)
                {
                    nearestDistSq = distSq;
                    nearestTargetPosition = targetTransform.localPosition;
                }
            }

            Debug.DrawLine(transform.localPosition, nearestTargetPosition);
        }
    }
}
탐색시마다 FindNearest의 컴포넌트
약 10 FPS의 성능을 보여준다.

Seeker의 1000개의 FindNearest가 Update()를 수행하면서 약 82ms를 프레임마다 실행 (개당 0.082ms)

Step 2. Scene – SingleThreadedJob

일반적인 브루트 포스 순회 방법, 하나의 FindNearest에서 Target과의 거리를 확인

Step2 위치

Spawner.cs

using UnityEngine;
using Seeker = Tutorials.Jobs.Step1.Seeker;
using Target = Tutorials.Jobs.Step1.Target;

namespace Tutorials.Jobs.Step2
{
    public class Spawner : MonoBehaviour
    {
        public static Transform[] TargetTransforms;

        // Cache the seeker transforms.
        public static Transform[] SeekerTransforms;

        public GameObject SeekerPrefab;
        public GameObject TargetPrefab;
        public int NumSeekers;
        public int NumTargets;
        public Vector2 Bounds;

        public void Start()
        {
            Random.InitState(123);

            SeekerTransforms = new Transform[NumSeekers];
            for (int i = 0; i < NumSeekers; i++)
            {
                GameObject go = GameObject.Instantiate(SeekerPrefab);
                Seeker seeker = go.GetComponent<Seeker>();
                Vector2 dir = Random.insideUnitCircle;
                seeker.Direction = new Vector3(dir.x, 0, dir.y);
                SeekerTransforms[i] = go.transform;
                go.transform.localPosition = new Vector3(
                    Random.Range(0, Bounds.x), 0, Random.Range(0, Bounds.y));
            }

            TargetTransforms = new Transform[NumTargets];
            for (int i = 0; i < NumTargets; i++)
            {
                GameObject go = GameObject.Instantiate(TargetPrefab);
                Target target = go.GetComponent<Target>();
                Vector2 dir = Random.insideUnitCircle;
                target.Direction = new Vector3(dir.x, 0, dir.y);
                TargetTransforms[i] = go.transform;
                go.transform.localPosition = new Vector3(
                    Random.Range(0, Bounds.x), 0, Random.Range(0, Bounds.y));
            }
        }
    }
}

FindNearest.cs

using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
using UnityEngine;

namespace Tutorials.Jobs.Step2
{
    public class FindNearest : MonoBehaviour
    {
        // The size of our arrays does not need to vary, so rather than create
        // new arrays every field, we'll create the arrays in Awake() and store them
        // in these fields.
        NativeArray<float3> TargetPositions;
        NativeArray<float3> SeekerPositions;
        NativeArray<float3> NearestTargetPositions;

        public void Start()
        {
            Spawner spawner = Object.FindFirstObjectByType<Spawner>();
            // We use the Persistent allocator because these arrays must
            // exist for the run of the program.
            TargetPositions = new NativeArray<float3>(spawner.NumTargets, Allocator.Persistent);
            SeekerPositions = new NativeArray<float3>(spawner.NumSeekers, Allocator.Persistent);
            NearestTargetPositions = new NativeArray<float3>(spawner.NumSeekers, Allocator.Persistent);
        }

        // We are responsible for disposing of our allocations
        // when we no longer need them.
        public void OnDestroy()
        {
            TargetPositions.Dispose();
            SeekerPositions.Dispose();
            NearestTargetPositions.Dispose();
        }

        public void Update()
        {
            // Copy every target transform to a NativeArray.
            for (int i = 0; i < TargetPositions.Length; i++)
            {
                // Vector3 is implicitly converted to float3
                TargetPositions[i] = Spawner.TargetTransforms[i].localPosition;
            }

            // Copy every seeker transform to a NativeArray.
            for (int i = 0; i < SeekerPositions.Length; i++)
            {
                // Vector3 is implicitly converted to float3
                SeekerPositions[i] = Spawner.SeekerTransforms[i].localPosition;
            }

            // To schedule a job, we first need to create an instance and populate its fields.
            FindNearestJob findJob = new FindNearestJob
            {
                TargetPositions = TargetPositions,
                SeekerPositions = SeekerPositions,
                NearestTargetPositions = NearestTargetPositions,
            };

            // Schedule() puts the job instance on the job queue.
            JobHandle findHandle = findJob.Schedule();

            // The Complete method will not return until the job represented by
            // the handle finishes execution. Effectively, the main thread waits
            // here until the job is done.
            findHandle.Complete();

            // Draw a debug line from each seeker to its nearest target.
            for (int i = 0; i < SeekerPositions.Length; i++)
            {
                // float3 is implicitly converted to Vector3
                Debug.DrawLine(SeekerPositions[i], NearestTargetPositions[i]);
            }
        }
    }
}

FindNearestJob.cs

using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;

// We'll use Unity.Mathematics.float3 instead of Vector3,
// and we'll use Unity.Mathematics.math.distancesq instead of Vector3.sqrMagnitude.
using Unity.Mathematics;

namespace Tutorials.Jobs.Step2
{
    // Include the BurstCompile attribute to Burst compile the job.
    [BurstCompile]
    public struct FindNearestJob : IJob
    {
        // All of the data which a job will access should 
        // be included in its fields. In this case, the job needs
        // three arrays of float3.

        // Array and collection fields that are only read in
        // the job should be marked with the ReadOnly attribute.
        // Although not strictly necessary in this case, marking data  
        // as ReadOnly may allow the job scheduler to safely run 
        // more jobs concurrently with each other.
        // (See the "Intro to jobs" for more detail.)

        [ReadOnly] public NativeArray<float3> TargetPositions;
        [ReadOnly] public NativeArray<float3> SeekerPositions;

        // For SeekerPositions[i], we will assign the nearest 
        // target position to NearestTargetPositions[i].
        public NativeArray<float3> NearestTargetPositions;

        // 'Execute' is the only method of the IJob interface.
        // When a worker thread executes the job, it calls this method.
        public void Execute()
        {
            // Compute the square distance from each seeker to every target.
            for (int i = 0; i < SeekerPositions.Length; i++)
            {
                float3 seekerPos = SeekerPositions[i];
                float nearestDistSq = float.MaxValue;
                for (int j = 0; j < TargetPositions.Length; j++)
                {
                    float3 targetPos = TargetPositions[j];
                    float distSq = math.distancesq(seekerPos, targetPos);
                    if (distSq < nearestDistSq)
                    {
                        nearestDistSq = distSq;
                        NearestTargetPositions[i] = targetPos;
                    }
                }
            }
        }
    }
}
약 30FPS 성능
하나의 FindNearest 객체에서 관리, 프레임마다 22.77ms, Worker Therad는 Idle
JobHandle.Complete가 21.45ms인 이유는 Complete 호출이 반환되려면 실제 잡이 실행 및 완료될 것을 기다려야 하기 때문임

FindNearest 업데이트 자체는 아무것도 하지 않고 잡이 실행 및 완료될 것을 기다리고 있음

Burst는 아직 미사용이라서 Job이 메인 스레드에서 실행 중

Step 1 보다 성능이 향상된 원인은 Step 1의 Seeker의 Update() 1000개에 대한 오버헤드가 발생되지 않음 (오직 1개)

트랜스폼을 순회할 때 메모리를 건너 뛰면서 트랜스폼에 액세스했는데 이는 캐시에 부하주는 작업을 배열로 변경하여 성능을 향상시킴

Job에 BurstCompile 속성이 추가되었는데 BurstCompile될 수 있음을 뜻함
활성화
30-> 102 FPS로 엄청난 성능 향상을 할 수 있음
JobHandle.Complete가 21.45ms -> 1.48ms로 단축됨 (유니티에서는 약 20배의 성능 향상이 있다고함)

Step 3. Scene – ParallelJob

일반적인 브루트 포스 순회 방법, 하나의 FindNearest에서 Target과의 거리를 확인

Step 1, 2는 각 1,000개 Step 3는 각 5000개의 인스턴스를 사용

FindNearest.cs

using Spawner = Tutorials.Jobs.Step2.Spawner;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
using UnityEngine;

namespace Tutorials.Jobs.Step3
{
    // Exactly the same as in prior step except this schedules a parallel job instead of a single-threaded job.
    public class FindNearest : MonoBehaviour
    {
        NativeArray<float3> TargetPositions;
        NativeArray<float3> SeekerPositions;
        NativeArray<float3> NearestTargetPositions;

        public void Start()
        {
            Spawner spawner = GetComponent<Spawner>();
            TargetPositions = new NativeArray<float3>(spawner.NumTargets, Allocator.Persistent);
            SeekerPositions = new NativeArray<float3>(spawner.NumSeekers, Allocator.Persistent);
            NearestTargetPositions = new NativeArray<float3>(spawner.NumSeekers, Allocator.Persistent);
        }

        public void OnDestroy()
        {
            TargetPositions.Dispose();
            SeekerPositions.Dispose();
            NearestTargetPositions.Dispose();
        }

        public void Update()
        {
            for (int i = 0; i < TargetPositions.Length; i++)
            {
                TargetPositions[i] = Spawner.TargetTransforms[i].localPosition;
            }

            for (int i = 0; i < SeekerPositions.Length; i++)
            {
                SeekerPositions[i] = Spawner.SeekerTransforms[i].localPosition;
            }

            FindNearestJob findJob = new FindNearestJob
            {
                TargetPositions = TargetPositions,
                SeekerPositions = SeekerPositions,
                NearestTargetPositions = NearestTargetPositions,
            };

            // Execute will be called once for every element of the SeekerPositions array,
            // with every index from 0 up to (but not including) the length of the array.
            // The Execute calls will be split into batches of 100.
            JobHandle findHandle = findJob.Schedule(SeekerPositions.Length, 100);

            findHandle.Complete();

            for (int i = 0; i < SeekerPositions.Length; i++)
            {
                Debug.DrawLine(SeekerPositions[i], NearestTargetPositions[i]);
            }
        }
    }
}

FindNearestJob.cs

using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;

namespace Tutorials.Jobs.Step3
{
    [BurstCompile]
    public struct FindNearestJob : IJobParallelFor
    {
        [ReadOnly] public NativeArray<float3> TargetPositions;
        [ReadOnly] public NativeArray<float3> SeekerPositions;

        public NativeArray<float3> NearestTargetPositions;

        public void Execute(int index)
        {
            float3 seekerPos = SeekerPositions[index];
            float nearestDistSq = float.MaxValue;
            for (int i = 0; i < TargetPositions.Length; i++)
            {
                float3 targetPos = TargetPositions[i];
                float distSq = math.distancesq(seekerPos, targetPos);
                if (distSq < nearestDistSq)
                {
                    nearestDistSq = distSq;
                    NearestTargetPositions[index] = targetPos;
                }
            }
        }
    }
}
약 31.8FPS
ParallelJob을 이용하여 Job이 다른 모든 워커 스레드에 병렬적으로 분할되도록 함
100개의 인덱스 단위로 병렬 작업

Step 4. Scene – ParallelJob_Sorting

정렬을 이용한 병렬 작업

각 10,000개의 인스턴스 생성

FindNearest.cs

using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
using UnityEngine;
using Spawner = Tutorials.Jobs.Step2.Spawner;

namespace Tutorials.Jobs.Step4
{
    public class FindNearest : MonoBehaviour
    {
        NativeArray<float3> TargetPositions;
        NativeArray<float3> SeekerPositions;
        NativeArray<float3> NearestTargetPositions;

        public void Start()
        {
            Spawner spawner = GetComponent<Spawner>();
            TargetPositions = new NativeArray<float3>(spawner.NumTargets, Allocator.Persistent);
            SeekerPositions = new NativeArray<float3>(spawner.NumSeekers, Allocator.Persistent);
            NearestTargetPositions = new NativeArray<float3>(spawner.NumSeekers, Allocator.Persistent);
        }

        public void OnDestroy()
        {
            TargetPositions.Dispose();
            SeekerPositions.Dispose();
            NearestTargetPositions.Dispose();
        }

        public void Update()
        {
            for (int i = 0; i < TargetPositions.Length; i++)
            {
                TargetPositions[i] = Spawner.TargetTransforms[i].localPosition;
            }

            for (int i = 0; i < SeekerPositions.Length; i++)
            {
                SeekerPositions[i] = Spawner.SeekerTransforms[i].localPosition;
            }

            SortJob<float3, AxisXComparer> sortJob = TargetPositions.SortJob(new AxisXComparer { });

            FindNearestJob findJob = new FindNearestJob
            {
                TargetPositions = TargetPositions,
                SeekerPositions = SeekerPositions,
                NearestTargetPositions = NearestTargetPositions,
            };

            JobHandle sortHandle = sortJob.Schedule();
            JobHandle findHandle = findJob.Schedule(SeekerPositions.Length, 100, sortHandle);
            findHandle.Complete();

            for (int i = 0; i < SeekerPositions.Length; i++)
            {
                Debug.DrawLine(SeekerPositions[i], NearestTargetPositions[i]);
            }
        }
    }
}

FindNearestJob.cs

using System.Collections.Generic;
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;

namespace Tutorials.Jobs.Step4
{
    [BurstCompile]
    public struct FindNearestJob : IJobParallelFor
    {
        [ReadOnly] public NativeArray<float3> TargetPositions;
        [ReadOnly] public NativeArray<float3> SeekerPositions;

        public NativeArray<float3> NearestTargetPositions;

        public void Execute(int index)
        {
            float3 seekerPos = SeekerPositions[index];

            // Find the target with the closest X coord.
            int startIdx = TargetPositions.BinarySearch(seekerPos, new AxisXComparer { });

            // When no precise match is found, BinarySearch returns the bitwise negation of the last-searched offset.
            // So when startIdx is negative, we flip the bits again, but we then must ensure the index is within bounds.
            if (startIdx < 0) startIdx = ~startIdx;
            if (startIdx >= TargetPositions.Length) startIdx = TargetPositions.Length - 1;

            // The position of the target with the closest X coord.
            float3 nearestTargetPos = TargetPositions[startIdx];
            float nearestDistSq = math.distancesq(seekerPos, nearestTargetPos);

            // Searching upwards through the array for a closer target.
            Search(seekerPos, startIdx + 1, TargetPositions.Length, +1, ref nearestTargetPos, ref nearestDistSq);

            // Search downwards through the array for a closer target.
            Search(seekerPos, startIdx - 1, -1, -1, ref nearestTargetPos, ref nearestDistSq);

            NearestTargetPositions[index] = nearestTargetPos;
        }

        void Search(float3 seekerPos, int startIdx, int endIdx, int step,
                    ref float3 nearestTargetPos, ref float nearestDistSq)
        {
            for (int i = startIdx; i != endIdx; i += step)
            {
                float3 targetPos = TargetPositions[i];
                float xdiff = seekerPos.x - targetPos.x;

                // If the square of the x distance is greater than the current nearest, we can stop searching.
                if ((xdiff * xdiff) > nearestDistSq) break;

                float distSq = math.distancesq(targetPos, seekerPos);

                if (distSq < nearestDistSq)
                {
                    nearestDistSq = distSq;
                    nearestTargetPos = targetPos;
                }
            }
        }
    }

    public struct AxisXComparer : IComparer<float3>
    {
        public int Compare(float3 a, float3 b)
        {
            return a.x.CompareTo(b.x);
        }
    }
}

x축을 따라 TargetPositions를 정렬합니다

이어서 FindNearestJob에서 바이너리 검색을 통해 탐색기의 x 좌표와 가장 가까운 타겟의 위치를 찾고

그 인덱스 주위를 탐색해 가장 가까운 타겟을 찾습니다.

그러면 대부분의 경우 탐색기를 모든 타겟과 비교하는 대신 몇몇 타겟과만 비교하는 작업이 되어 더 간단합니다.

먼저 SortJob은 Collections 패키지의 네이티브 배열 확장 메서드로서 SortJob 구조체를 반환합니다.

그 구조체 자체는 잡이 아니지만 Schedule 메서드를 호출할 경우 하나가 아닌 두 개의 잡이 예약됩니다.

이 메서드는 먼저 segmentSortJob을 생성하고 예약하는데 이는 배열의 서로 다른 세그먼트를 독립적으로 정렬하는 병렬 잡입니다.

이후 두 번째 작업이자 싱글 스레드 작업이며 같은 배열 내 세그먼트들을 병합하는 SegmentSortMerge가 예약됩니다

이 두 잡은 기존 배열 내에서 MergeSort를 수행합니다.

결정적으로 두 번째의 병합 잡은 세그먼트 정렬이 완료될 때까지 기다려야 합니다.

그래서 두 번째 잡을 예약할 때 첫 번째 잡의 잡 핸들을 전달하면 첫 번째 잡은 두 번째 잡의 종속 관계가 됩니다.

잡이 종속 관계를 가질 경우 워커 스레드들은 그 종속 관계의 실행이 모두 완료될 때까지 해당 잡을 실행하지 않습니다.

둘 이상의 잡을 예약해야 하며 해당 잡들의 실행 순서를 지정해야 할 때는 종속 관계를 통해 그 순서를 정할 수 있습니다.

이 코드에서 FindNearestJob을 예약할 때 sortJob 핸들을 종속성으로 전달하는데 이는 MergeSort가 완료될 때까지

이 잡의 실행이 시작되어서는 안 되기 때문입니다.

이후 잡이 완료되면 잡의 모든 종속성도 묵시적으로 완료되므로 SegmentSort, SegmentMerge, FindNearestJob의 세 잡이 모두 완료됩니다.

FindNearestJob을 보면 Collections 패키지의 네이티브 배열 확장 메서드인 BinarySearch를 호출하고 있습니다.

이는 단지 X 좌표와 비교해서 SeekerPos와 일치하는 TargetPositions 내 인덱스를 반환합니다.

대부분은 정확히 일치하는 결과를 찾을 수 없을 것이므로 반환되는 인덱스 값은 대부분 음수가 될 겁니다.

하지만 이 메서드에 숨겨진 요령이 있다면 반환된 인덱스 값의 비트를 뒤집어서 배열에서 가장 가까운 일치 값의 인덱스를 얻을 수 있다는 겁니다.

해당 값을 고정해서 범위 내에 있게 하면 됩니다.

이제 x 좌표가 탐색기의 x 좌표와 가장 가까운 타겟의 인덱스를 알아냈지만 타겟을 위아래로 검색하여 탐색기와 더욱 가까운 타겟이 존재하지 않는지 확인합니다.

이 검색에서 중요한 요령은 x 좌표값의 차이가 지금까지 찾아낸 최단 거리보다 더 클 경우 검색을 중단할 수 있다는 겁니다.

TargetPositions가 x 좌표에 따라 정렬되니까요

이 임곗값을 넘고 나면 해당 임곗값 반대편에 존재하는 모든 후보들은 우리가 이미 찾아낸 후보보다 더 가까이에 있을 수 없습니다.

31 FPS의 성능
SegmentSortMerge는 하나의 스레드에서만 실행
트랜스폼을복사하는 데 시간이 더 오래 걸림

“DOTS 정리 (2) – Unity Job 시스템 시작하기”에 대한 1개의 생각

  1. 핑백: DOTS 정리 (3) – HelloCube로 엔티티 알아보기 - 어제와 내일의 나 그 사이의 이야기

댓글 달기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

위로 스크롤