مستوحاة من
أول انتصار تحليل مع وصف مشهد الجزيرة من الرسوم المتحركة ديزني
Moana ، ذهبت أبعد من ذلك إلى دراسة استخدام الذاكرة. لا يزال هناك الكثير الذي يمكن القيام به مع المهلة الزمنية ، لكنني قررت أنه سيكون من المفيد التحقيق أولاً في الوضع.
لقد بدأت تحقيق وقت التشغيل باستخدام إحصائيات pbrt المضمنة ؛ يحتوي pbrt على إعداد يدوي لتخصيصات كبيرة للذاكرة لتتبع استخدام الذاكرة ، وبعد اكتمال التقديم ، يتم عرض تقرير تخصيص الذاكرة. إليك ما كان تقرير تخصيص الذاكرة لهذا المشهد في الأصل:
BVH- 9,01
1,44
MIP- 2,00
11,02
أما بالنسبة لوقت التشغيل ، فقد اتضح أن الإحصائيات المدمجة مختصرة ولم يُبلغ عنها إلا تخصيص الذاكرة للأشياء المعروفة التي يبلغ حجمها 24 جيجابايت. قال
top
أنه في الواقع تم استخدام حوالي 70 غيغابايت من الذاكرة ، أي 45 غيغابايت لم تؤخذ في الاعتبار في الإحصائيات. إن الانحرافات الصغيرة مفهومة تمامًا: تتطلب مخصصات الذاكرة الديناميكية مساحة إضافية لتسجيل استخدام الموارد ، وبعضها مفقود بسبب التجزئة ، وما إلى ذلك. لكن 45 غيغابايت؟ هناك شيء سيئ يختبئ هنا بالتأكيد.
لفهم ما نفتقده (وللتأكد من تتبعنا بشكل صحيح) ، استخدمت
كتلة الكتلة لتتبع التخصيص الفعلي للذاكرة الديناميكية. إنها بطيئة نوعًا ما ، ولكنها تعمل على الأقل بشكل جيد.
البدائيون
أول شيء وجدته عند تتبع الكتلة كان سطرين من التعليمات البرمجية التي خصصت مثيلات الفئة الأساسية
Primitive
، والتي لم تؤخذ في الاعتبار في الإحصائيات ، في الذاكرة. إشراف صغير
يسهل إصلاحه . بعد ذلك نرى ما يلي:
Primitives 24,67
عفوًا إذن ما هو بدائي ، ولماذا كل هذه الذاكرة؟
يميز pbrt بين
Shape
، وهو الهندسة النقية (المجال ، المثلث ، إلخ)
Primitive
، وهو مزيج من الهندسة والمواد ، وأحيانًا وظيفة الإشعاع والوسيط المتضمن داخل وخارج سطح الهندسة.
هناك
العديد من الخيارات للفئة الأساسية
Primitive
:
GeometricPrimitive
، وهي حالة قياسية: مزيج "الفانيليا" من الهندسة والمواد ، وما إلى ذلك ، بالإضافة إلى
TransformedPrimitive
، وهو بدائي مع التحولات المطبقة عليه ، إما كمثال على كائن أو لتحريك البدائيين مع التحولات التي تتغير بمرور الوقت. اتضح أن كلا النوعين في هذا المشهد هما مضيعة للفضاء.
هندسي - أساسي: 50٪ مساحة إضافية
ملحوظة: تم عمل بعض الافتراضات الخاطئة في هذا التحليل. تم تنقيحها في الوظيفة الرابعة من السلسلة .4.3 غيغابايت المستخدمة في
GeometricPrimitive
. من المضحك أن تعيش في عالم حيث 4.3 غيغابايت من ذاكرة الوصول العشوائي المستخدمة ليست أكبر مشكلتك ، ولكن دعنا نرى مع ذلك حيث حصلنا على 4.3 غيغابايت من
GeometricPrimitive
. فيما يلي الأجزاء ذات الصلة من تعريف الصف:
class GeometricPrimitive : public Primitive { std::shared_ptr<Shape> shape; std::shared_ptr<Material> material; std::shared_ptr<AreaLight> areaLight; MediumInterface mediumInterface; };
لدينا
مؤشر إلى vtable ، وثلاث مؤشرات أخرى ، ثم
MediumInterface
تحتوي على مؤشرين آخرين بحجم إجمالي 48 بايت. لا يوجد سوى عدد قليل من الشبكات التي ينبعث منها ضوء في هذا المشهد ، لذا فإن
areaLight
دائمًا ما يكون مؤشرًا فارغًا ، ولا توجد بيئة تؤثر على المشهد ، لذلك فإن كلا من المؤشرات
mediumInterface
أيضًا فارغة. وبالتالي ، إذا كان لدينا تطبيق متخصص للفئة
Primitive
، والذي يمكن استخدامه في غياب الإشعاع والوظائف المتوسطة ، فسوف نوفر ما يقرب من نصف مساحة القرص التي تشغلها
GeometricPrimitive
- في حالتنا ، حوالي 2 غيغابايت.
ومع ذلك ، لم أصلح ذلك وأضيف تطبيقًا بدائيًا جديدًا إلى pbrt. نحن نسعى جاهدين لتقليل الاختلافات بين التعليمات البرمجية المصدر pbrt-v3 على github والنظام الموصوف في كتابي ، لسبب بسيط للغاية - إبقائها متزامنة تجعل من السهل قراءة الكتاب والعمل مع التعليمات البرمجية. في هذه الحالة ، قررت أن التنفيذ الجديد تمامًا
Primitive
، الذي لم يذكر في الكتاب أبدًا ، سيكون فرقًا كبيرًا. لكن هذا الإصلاح سيظهر بالتأكيد في الإصدار الجديد من pbrt.
قبل المضي قدمًا ، دعنا نجعل الاختبار:
الشاطئ من الجزيرة من فيلم "Moana" الذي قدمه pbrt-v3 بدقة 2048x858 و 256 عينة لكل بكسل. كان إجمالي وقت العرض على مثيل 12-core / 24-thread من Google Compute Engine بتردد 2 غيغاهرتز مع أحدث إصدار من pbrt-v3 ساعتين و 25 دقيقة و 43 ثانية.TransformedPrimitives: 95٪ مساحة مهدرة
كانت الذاكرة المخصصة تحت 4.3 جيجابايت
GeometricPrimitive
ناجحة للغاية ، ولكن ماذا عن 17.4 جيجابايت تحت
TransformedPrimitive
؟
كما ذكر أعلاه ،
TransformedPrimitive
استخدام
TransformedPrimitive
للتحويلات مع تغيير في الوقت ، وبالنسبة لحالات الكائنات. في كلتا الحالتين ، نحتاج إلى تطبيق تحويل إضافي إلى
Primitive
الحالي. هناك عضوان فقط في فئة
TransformedPrimitive
:
std::shared_ptr<Primitive> primitive; const AnimatedTransform PrimitiveToWorld;
حتى الآن جيد جدًا: مؤشر إلى بدائي وتحول يتغير بمرور الوقت. ولكن ما الذي يتم تخزينه بالفعل في
AnimatedTransform
؟
const Transform *startTransform, *endTransform; const Float startTime, endTime; const bool actuallyAnimated; Vector3f T[2]; Quaternion R[2]; Matrix4x4 S[2]; bool hasRotation; struct DerivativeTerm {
بالإضافة إلى المؤشرات إلى مصفوفتي انتقال والوقت المرتبط بها ، هناك أيضًا تحلل المصفوفات في مكونات النقل والتدوير والتحجيم ، بالإضافة إلى القيم المحسوبة مسبقًا المستخدمة للحد من الحجم المشغول بتحريك المربعات المحيطة (انظر القسم 2.4.9 من كتابنا
التقديم المادي ). كل هذا يصل إلى 456 بايت.
لكن
لا شيء يتحرك في هذا المشهد. من وجهة نظر التحولات لمثيلات الكائنات ، نحتاج إلى مؤشر واحد للتحويل ، ولا حاجة إلى قيم التحلل ومربعات الإحاطة المنقولة. (أي ، هناك حاجة إلى 8 بايت فقط). إذا قمت بإنشاء تطبيق
Primitive
منفصل
Primitive
الثابتة للكائنات ، فسيتم ضغط 17.4 جيجابايت إجمالاً إلى 900 ميجابايت (!).
أما بالنسبة لـ
GeometricPrimitive
، فإن إصلاحه هو تغيير غير تافه مقارنة بما هو موضح في الكتاب ، لذلك سنقوم أيضًا بتأجيله إلى الإصدار التالي من pbrt. على الأقل نفهم الآن ما يحدث مع فوضى 24.7 جيجابايت من الذاكرة
Primitive
.
مشكلة في ذاكرة التخزين المؤقت للتحويل
أكبر كتلة من الذاكرة غير المحسوبة التي حددتها الكتلة هي
TransformCache
، التي احتلت ما يقرب من 16 غيغابايت. (إليك رابط إلى
التطبيق الأصلي .) الفكرة هي أن مصفوفة التحويل نفسها غالبًا ما تستخدم عدة مرات في المشهد ، لذا من الأفضل أن يكون لها نسخة واحدة في الذاكرة ، بحيث تقوم جميع العناصر التي تستخدمها ببساطة بتخزين مؤشر إلى نفس الشيء التحويل.
استخدم
TransformCache
std::map
لتخزين ذاكرة التخزين المؤقت ، وأفاد massif أنه تم استخدام 6 من 16 غيغابايت لعقد الشجرة السوداء الحمراء في
std::map
. هذه كمية كبيرة: يتم استخدام 60٪ من هذا الحجم للتحولات نفسها. دعونا نلقي نظرة على الإعلان لهذا التوزيع:
std::map<Transform, std::pair<Transform *, Transform *>> cache;
هنا ، يتم العمل بشكل مثالي:
Transform
استخدام
Transform
بالكامل كمفاتيح للتوزيع. والأفضل من ذلك ، يقوم pbrt
Transform
بتخزين مصفوفتين 4 × 4 (مصفوفة التحويل والمصفوفة العكسية) ، مما يؤدي إلى تخزين 128 بايت في كل عقدة من الشجرة. كل هذا غير ضروري على الإطلاق للقيمة المخزنة له.
ربما يكون مثل هذا الهيكل طبيعيًا تمامًا في عالم حيث من المهم بالنسبة لنا أن يتم استخدام نفس مصفوفة التحويل في مئات أو الآلاف من البدائيين ، وبصفة عامة لا يوجد العديد من مصفوفات التحول. ولكن بالنسبة لمشهد يحتوي على مجموعة من مصفوفات التحول الفريدة في الغالب ، كما هو الحال في حالتنا ، فإن هذا مجرد نهج رهيب.
بالإضافة إلى حقيقة أن المساحة تضيع على المفاتيح ، فإن البحث في
std::map
عن اجتياز الشجرة الحمراء السوداء ينطوي على الكثير من عمليات المؤشر ، لذلك يبدو من المنطقي تجربة شيء جديد تمامًا. لحسن الحظ ، لم يتم كتابة سوى القليل عن
TransformCache
في الكتاب ، لذلك من المقبول تمامًا إعادة كتابته بالكامل.
وأخيرًا ، قبل البدء: بعد فحص التوقيع على طريقة
Lookup()
، تظهر مشكلة أخرى:
void Lookup(const Transform &t, Transform **tCached, Transform **tCachedInverse)
عندما توفر وظيفة الاستدعاء
Transform
، تقوم ذاكرة التخزين المؤقت بحفظ وإرجاع مؤشرات التحويل التي تساوي النقطة التي تم تمريرها ، ولكنها أيضًا تمر بالمصفوفة العكسية. لجعل هذا ممكنًا ، في التنفيذ الأصلي ، عند إضافة تحويل إلى ذاكرة التخزين المؤقت ، يتم دائمًا حساب المصفوفة العكسية وتخزينها بحيث يمكن إرجاعها.
الشيء الغبي هنا هو أن معظم نظراء الاتصال الذين يستخدمون ذاكرة التخزين المؤقت للتحويل لا يستعلمون أو يستخدمون المصفوفة العكسية. أي ، يتم إهدار أنواع مختلفة من الذاكرة على التحولات العكسية غير القابلة للتطبيق.
في
التطبيق الجديد ، يتم إضافة التحسينات التالية:
- يستخدم جدول تجزئة لتسريع البحث ولا يتطلب تخزين أي شيء بخلاف صفيف
Transform *
، والذي يقلل ، في جوهره ، من حجم الذاكرة المستخدمة إلى القيمة المطلوبة حقًا لتخزين جميع Transform
. - يبدو توقيع طريقة البحث الآن مثل
Transform *Lookup(const Transform
&t)
Transform *Lookup(const Transform
&t)
Transform *Lookup(const Transform
&t)
؛ في مكان واحد تريد فيه وظيفة الاستدعاء الحصول على المصفوفة العكسية من ذاكرة التخزين المؤقت ، تستدعي Lookup()
مرتين فقط.
بالنسبة
للتجزئة ، استخدمت
دالة التجزئة FNV1a . بعد تنفيذه ، وجدت
مشاركة أراس في وظائف التجزئة ؛ ربما كان علي فقط استخدام xxHash أو CityHash لأن أدائهم أفضل ؛ ربما يوما ما سيفوز خزي و سأصلحه.
بفضل تطبيق
TransformCache
الجديد ، انخفض الوقت الإجمالي لبدء تشغيل النظام بشكل ملحوظ - حتى 21 دقيقة و 42 ثانية. أي أننا حفظنا 5 دقائق و 7 ثوانٍ أخرى ، أو تسارعنا 1.27 مرة. علاوة على ذلك ، أدى الاستخدام الأكثر كفاءة للذاكرة إلى تقليل المساحة التي تشغلها مصفوفات التحويل من 16 إلى 5.7 جيجابايت ، وهو ما يعادل تقريبًا كمية البيانات المخزنة. سمح لنا هذا بعدم محاولة الاستفادة من حقيقة أنها ليست إسقاطية بالفعل ، وتخزين مصفوفات 3x4 بدلاً من 4x4. (في الحالة المعتادة ، سأكون متشككا في أهمية هذا النوع من التحسين ، ولكن هنا سيوفر لنا أكثر من غيغابايت - الكثير من الذاكرة! وهذا بالتأكيد يستحق القيام به في عارض الإنتاج.)
تحسين الأداء الصغيرة لإكمال
تكلفنا بنية
TransformedPrimitive
معممة جدًا كلًا من الذاكرة والوقت: قال المحلل أن جزءًا كبيرًا من الوقت عند بدء التشغيل تم إنفاقه في وظيفة
AnimatedTransform::Decompose()
، التي تحلل تحويل المصفوفة إلى دوران رباعي ، ونقل ومقياس. نظرًا لعدم وجود شيء يتحرك في هذا المشهد ، فإن هذا العمل غير ضروري ، وقد أظهر فحص شامل لتطبيق
AnimatedTransform
أنه لا يتم الوصول إلى أي من هذه القيم إذا كانت مصفوفتي التحويل متطابقتين بالفعل.
بإضافة
خطين إلى المنشئ بحيث لا يتم تنفيذ التحليلات للتحولات عندما لا تكون مطلوبة ، قمنا بحفظ دقيقة واحدة أخرى 31 من وقت البدء: ونتيجة لذلك ، وصلنا إلى 20 دقيقة و 9 ثوانٍ ، أي بشكل عام تسارعت 1.73 مرة.
في
المقالة التالية ، سنتناول المحلل بجدية ونحلل ما أصبح مهمًا عندما قمنا بتسريع عمل الأجزاء الأخرى.