跳至内容

دليل شامل لنظام الوظائف (Job System) و Burst في Unity: كيفية استخدامهما بشكل صحيح؟

إذا كان الهدف هو مجرد تعدد مؤشرات الترابط (multithreading)، فإن استخدام Async لتبديل المؤشرات أو System.Threading هو الطريقة الأكثر ملاءمة ووضوحًا. يمكن أيضًا استدعاء Burst مباشرة دون الحاجة بالضرورة إلى نظام الوظائف (Job System)، كما هو موضح في مقالات القسم غير المتزامن (Async) والاستدعاء المباشر. ولكن إذا كان لديك عدد كبير من العمليات الحسابية الصغيرة، فهنا يجب أن تفكر في نظام الوظائف (Job System).

توثيق نظام الوظائف (Job System) في Unity غير مكتمل للغاية. هذه المقالة هي تسجيل لخبرة استخدام طويلة الأمد، ويجب أن تكون شاملة إلى حد ما.

هدف نظام الوظائف (Job System) وقيوده

على عكس نهج تعدد المؤشرات التقليدي، فإن نظام الوظائف (Job System) في Unity هو نظام يحاكي الإنتاجية العالية لوحدة معالجة الرسوميات (GPU) من خلال تعدد المؤشرات، مما يعني أن تكلفة توزيع المهام ضئيلة للغاية.

يمكن للوظيفة (Job) أن تتحمل فقط حجم العمليات الحسابية التي تتم خلال إطار واحد (frame)، وهي نواة صغيرة مثل دوال CUDA/Shader. ستظل العمليات الحسابية الثقيلة تؤثر على معدل الإطارات (FPS)، لأنه في بعض الأحيان يتم تخصيص الوظائف (Jobs) للمؤشر الرئيسي (main thread) للتشغيل. إذا كنت ترغب في تشغيل حسابات طويلة الأمد، إما قم بتقسيمها إلى العديد من الوظائف الصغيرة (Jobs)، وإلا فإن مؤشرات .NET التقليدية (threads) لا تزال هي الأفضل.

إذن السؤال المطروح هو: بما أنه مشابه لوحدة معالجة الرسوميات (GPU)، فلماذا لا نستخدم ComputeShader مباشرة؟ صحيح، كل ما يمكن للوظيفة (Job) القيام به، يمكن لـ ComputeShader القيام به، بل وأكثر سهولة. إلا في الحالات التالية:

  1. إذا كنت بحاجة إلى تبادل البيانات بشكل متكرر مع وحدة المعالجة المركزية (CPU)، وهو ما لا تجيده وحدة معالجة الرسوميات (GPU)، فإن الوظائف (Jobs) القائمة على وحدة المعالجة المركزية (CPU) لها ميزة.
  2. نظام ECS (DOTS) قائم على الوظائف (Jobs)، وذلك لنفس السبب السابق.

ملاحظة: WebGL لا يدعم نظام الوظائف (Jobs) ولا ComputeShader، ولا يدعم تسريع Burst، ولكن:

  • يتم استبدال WebGL بـ WebGPU، والذي يدعم ComputeShader، وتدعمه متصفحات الويب الحديثة (يتطلب iOS 17 تمكينه في الإعدادات، وهو مفعّل افتراضيًا في النسخة التجريبية 19).
  • يمكن تمكين دعم تعدد المؤشرات (multithreading) للوظائف (Jobs) في الإعدادات عبر WebAssembly 2023، ودعم المتصفحات لهذا أكثر شمولاً. لكن الوثائق الرسمية تقول إنه لا يمكن اعتباره حتى ميزة تجريبية، ولا يمكن استخدامه. يؤدي تمكينه في Unity 6 LTS إلى تعطل (Crash). لقد كانوا يتناقشون حول تعدد مؤشرات wasm لسنوات عديدة، خاصة وأن DOTS يحظى حاليًا باهتمام أكبر، بالإضافة إلى دعم .NET 8 لتعدد مؤشرات wasm، ربما ينتظرون دعم Unity 7 لـ .NET 8.

الأداء

يوفر تقديم مؤشرات الأداء فهماً أسهل لنية التصميم وملاءمته. يمكنك أيضًا قراءة المقال أولاً ثم العودة لاحقًا لمراجعة اختبارات الأداء.

تم اختبار 8 مشاريع، أولاً تم اختبار أداء 3 أنواع من المخصصات (allocators):

  • BenchAllocatorTemp: تنفيذ 100,000 عملية تخصيص باستخدام Allocator.Temp
  • BenchAllocatorTempJob: نفس الشيء، باستخدام مخصص Allocator.TempJob
  • BenchAllocatorPersistent: نفس الشيء، باستخدام مخصص Allocator.Persistent كانت النتيجة أن TempJob هي الأسرع، تليها Persistent.

ثم تم اختبار أداء 4 أنماط من الوظائف (Jobs):

  • BenchBaseLine: استخدام حلقة For لتنفيذ 100,000 عملية حسابية بسيطة كخط أساس مرجعي
  • BenchIJob: وقت جدولة 100,000 وظيفة (Job)
  • BenchIJobParallelFor: وقت جدولة 100,000 وظيفة (Job) دفعة واحدة باستخدام الوضع المتوازي (Parallel)
  • BenchIJobParallelForBurst: نفس الشيء، ولكن مع تفعيل Burst
  • BenchIJobParallelForBurstLoopVectorization: جدولة 10 وظائف (Jobs)، كل وظيفة تقوم بحساب 10,000 مرة باستخدام For، مع تفعيل تحسين المتجهات (vectorization) في Burst

فيما يلي نتائج الاختبار على جهاز الكمبيوتر الخاص بي:

|2x

Median هو الوسيط (median) لوقت تنفيذ مشروع الاختبار، بالمللي ثانية.

من الواضح أن تكلفة جدولة الوظائف (Jobs) صغيرة، وهي مصممة لتنفيذ عدد كبير من المهام. بالطبع، لقد أجريت هنا عمليات حسابية بسيطة للضرب فقط، لذلك كان التحسين الذي حققته الوظائف (Jobs) محدودًا.

أنواع البيانات

أولاً، لا يدعم Burst أنواع C# المدارة (managed types)، يمكن استخدام أنواع بطول مماثل لـ C، ويمكن نسخها مباشرة عبر memcpy (بدون حاجة إلى تسلسل/تجميع marshaling)، تسمى blittable types. تتضمن هذه الأنواع الأساسية مثل int وما شابه (أما char و string و bool فهي في بعض الأحيان أنواع مدارة، لا تستخدمها)، بالإضافة إلى المصفوفات أحادية البعد من النمط C-Style (new int[5]) من الأنواع blittable. ونظرًا لأن الوظائف (Jobs) تُستخدم حتمًا مع Burst، فإنها تتبع هذا القيد.

قام Unity بتغليف نوع آمن للمؤشرات (thread-safe) يسمى NativeArray خصيصًا للاستخدام مع الوظائف (Jobs). يمكن لهذه الأنواع مشاركة البيانات مع المؤشر الرئيسي (main thread) دون الحاجة إلى النسخ (Copy)، لأنه عند النسخ، يتم فقط تمرير مؤشر البيانات (pointer)، وتشير النسخ المتعددة إلى نفس منطقة الذاكرة. هناك أنواع مشتقة مثل NativeList، NativeQueue، NativeHashMap، NativeHashSet، NativeText، ولكن يمكن استخدام هذه الأنواع في مؤشر ترابط واحد فقط (single-threaded).

ملاحظة: لا يمكن استخدام تعليمات مثل nativeArray[0].x = 1.0f، أو nativeArray[0]++;، لأن القيمة لن تتغير، حيث أن ما يتم إرجاعه ليس مرجعًا (reference).

الأمان فيما يتعلق بالمؤشرات (Thread Safety)

يتم تحقيق الأمان فيما يتعلق بالمؤشرات من خلال تقييد الجدولة. لا يمكن تنفيذ سوى وظيفة واحدة (Job) للكتابة على نفس مثيل NativeArray، وإلا سيتم طرح استثناء (exception). إذا كان من الممكن تحقيق التوازي (parallelism) عن طريق تجزئة البيانات، فيمكن استخدام IJobParallelFor لتنفيذ عمليات على NativeArray على دفعات. إذا كانت البيانات للقراءة فقط، فيمكن تعريف متغير العضو باستخدام، على سبيل المثال، [ReadOnly] public NativeArray<int> input; للتعريف.

عندما تكتب الوظيفة (Job) في NativeArray، لا يمكن للمؤشر الرئيسي (main thread) قراءة NativeArray، وسيتم الإبلاغ عن خطأ، ويجب الانتظار حتى تكتمل الوظيفة.

تخصيص الذاكرة (Allocation)

أولاً، تحتاج أنواع Native إلى التخلص منها يدويًا Dispose() بعد الانتهاء من استخدامها، فهي لا تُدمر تلقائيًا. لهذا أضاف Unity تتبع تسرب الذاكرة (memory leak tracking).

عند إنشاء أنواع Native باستخدام new، يجب اختيار أحد أنواع المخصصات الثلاثة: Temp، TempJob، Persistent. تتراوح سرعة التخصيص من الأسرع إلى الأبطأ. دورة حياة Temp هي إطار واحد (frame)، و TempJob هي 4 إطارات. ماذا يعني هذا؟

  • Temp تعني أنه يمكنك استخدامه داخل الوظيفة الحالية، ويجب التخلص منه Dispose() قبل انتهاء الوظيفة. لذلك، إذا نسيت التخلص منه، فسوف يبلغ Unity عن خطأ فورًا في عملية التصيير (render) التالية، ولكن سرعة التخصيص هذه في الواقع بطيئة.
  • TempJob يعني شروط إبلاغ أكثر تساهلاً، عمليًا لا يزال مطلوبًا استخدامه خلال إطار واحد، ولكن يمكن التخلص منه في الإطار التالي.
  • Persistent لن يبلغ عن أخطاء، ويجب عليك أن تكون حذرًا بنفسك.

كان مشروع BenchAllocator في اختبار الأداء السابق يختبر أداء هذه الأنواع الثلاثة. يمكنك أن ترى أن Allocator.Temp استغرق وقتًا أطول بأربع مرات من TempJob. الوثائق تقول أن Temp هو الأسرع، إما أن هذا خطأ (bug)، أو أنه مشكلة في وضع المحرر (Editor mode).

تنفيذ وظيفة أحادية المؤشر (Single-threaded Job)

التدفق الكامل هو كتابة فئة IJob بنفسك، ثم يقوم المؤشر الرئيسي (main thread) بجدولتها Schedule، ثم استدعاء Complete لانتظار انتهاء الوظيفة (Job) بشكل متزامن (blocking).

public struct MyJob : IJob {
    public NativeArray<float> result;

    public void Execute() {
        for (int j = 0; j < result.Length; j++)
            result[j] = result[j] * result[j];
    }
}

void Update() {
    result = new NativeArray<float>(100000, Allocator.TempJob);

    MyJob jobData = new MyJob{
        result = result
    };

    handle = jobData.Schedule();
}

private void LateUpdate() {
    handle.Complete();
    result.Dispose();
}

ولكن المشكلة هي أننا نستخدم الوظائف (Jobs) لعدد كبير من المهام، فدور المهمة الفردية هذا محدود. النمط المتوازي (Parallel) المشابه لوحدة معالجة الرسوميات (GPU) أكثر فائدة.

النمط المتوازي (Parallel Job)

يتحول الكود السابق إلى النمط المتوازي عن طريق تغيير IJob إلى الوراثة من IJobParallelFor.

public struct MyJob : IJobParallelFor {
    public NativeArray<float> result;

    public void Execute(int i) {
        result[i] = result[i] * result[i];
    }
}

void Update() {
    result = new NativeArray<float>(100000, Allocator.TempJob);

    MyJob jobData = new MyJob{
        result = result
    };

    handle = jobData.Schedule(result.Length, result.Length / 10);
}

private void LateUpdate() {
    handle.Complete();
    result.Dispose();
}

في النمط المتوازي، لا تحتاج إلى كتابة حلقة For بنفسك، حيث سيتم تنفيذ Execute مرة واحدة لكل عنصر، مشابهًا لـ Shader.

Schedule(result.Length, result.Length / 10) تعني تنفيذ Execute لكل وحدة من الفهرس 0 إلى طول result.Length، وتوزيعها على 10 عامل (worker).

يمكنك الاطلاع على اختبار الأداء السابق للفرق في الأداء بين IJob و IJobParallelFor.

قيود التوازي (Parallel)

في IJobParallelFor، يمكنك الكتابة فقط في العنصر i، وهو لا يعرف أي مصفوفة (Array) عضو تريد الكتابة فيها، لذلك يمكن الكتابة فقط في العنصر i لجميع المصفوفات (Arrays). ولكن يمكن إضافة السمة [NativeDisableParallelForRestriction] إلى NativeArray لإيقاف فحص الأمان، مع ضمان عدم وجود تعارض في الكتابة بنفسك.

وضع القراءة فقط لا يفرض قيودًا على جميع حاويات Native.

بالإضافة إلى ذلك، لا يمكن تمكين تحسين المتجهات للحلقات (loop vectorization) في IJobParallelFor، إلا إذا كانت حساباتك تستخدم بالفعل تحسين المتجهات (عن طريق استدعاء دوال أخرى تم تحسينها بالفعل)، وإلا فلن يكون الأداء هو الأمثل.

استخدام حاويات مثل NativeList في الوضع المتوازي

جميع الحاويات بخلاف المصفوفات (Arrays) مثل NativeList تكون في وضع القراءة فقط في الوضع المتوازي. إذن كيف تتم الكتابة؟ في التصميم، ينقسم NativeList إلى حالتين عمل: Add و Set. نمط الاستخدام الصحيح هو أن تقوم وظيفة (Job) واحدة بعملية Add، والوظيفة الثانية بعملية Set.

عند استخدام Add، يمكن استخدام ParallelWriter و AsParallelWriter، كما يلي:

    public struct AddListJob : IJobParallelFor {
        public NativeList<float>.ParallelWriter result;

        public void Execute(int i) {
            result.AddNoResize(i);
        }
    }

    public void RunIJobParallelForList() {
        var results = new NativeList<float>(10, Allocator.TempJob);
        var jobData = new AddListJob() {
            result = results.AsParallelWriter(),
        };
        var handle = jobData.Schedule(10, 1);
        handle.Complete();
        Debug.Log(String.Join(",", results.ToArray(Allocator.TempJob)));
        results.Dispose();
    }

في هذه الحالة، تكون سعة NativeList ثابتة، ويجب تخصيص الذاكرة مسبقًا قبل البدء، ولا يمكن استخدام سوى AddNoResize(). يتم تنفيذ هذه الطريقة باستخدام قفل ذري (atomic lock) على الخاصية Length، مما يتسبب في هبوط كبير في الأداء.

ثم استخدم التحويل غير المفقود (lossless conversion) من NativeList إلى NativeArray: NativeList.AsDeferredJobArray(). المصفوفة NativeArray التي يتم إرجاعها بواسطة هذه الطريقة هي كسولة (lazy)، حيث يتم التحويل فقط عند تشغيل الوظيفة (Job) فعليًا، لذا يمكن تمريرها قبل تنفيذ الوظيفتين (Jobs):

var addJob = new AddListJob { result = results.AsParallelWriter() };
var jobHandle = addJob.Schedule(10, 1);

var setJob = new SetListJob { array = results.AsDeferredJobArray() };
setJob.Schedule(10, 1, jobHandle).Complete();

ملاحظة: كل من AsDeferredJobArray أو AsArray يُرجعان عرضًا (View)، أي منظور للبيانات الأصلية. ما زال يجب التخلص Dispose من البيانات المصدر.

النمط المتوازي للمصفوفات ثنائية الأبعاد

يمكن لـ IJobParallelFor التوازي فقط حسب العنصر الفردي في المصفوفة (Array). ولكن في الواقع، يكون التوازي لكل صف من المصفوفة ثنائية الأبعاد أكثر فائدة، ويمكنه أيضًا تمكين تحسين المتجهات للحلقات (loop vectorization)، مما يؤدي إلى أداء أعلى. يمكن استخدام IJobParallelForBatch لتنفيذ هذه العملية.

أولاً نقوم بإنشاء مصفوفة ثنائية الأبعاد مسطحة (flattened) بحجم [10*15]، ثم نقوم بجدولتها باستخدام IJobParallelForBatch.Schedule(int length, int batchCount). يمثل batchCount عدد البيانات التي تتحملها كل وظيفة (Job)، وسيتم تنفيذ Execute length/batchCount مرة.

var results = new NativeArray<float>(10*15, Allocator.TempJob);
var jobData = new MyJob2D {
    result = results
};
var handle = jobData.Schedule(10*15, 15);
handle.Complete();
Debug.Log(String.Join(",", results));
results.Dispose();

ثم يأتي تنفيذ MyJob2D.

[BurstCompile]
public struct MyJob2D : IJobParallelForBatch {
    public NativeArray<float> result;

    public void Execute(int i, int count