استخدِم الطب الشرعي والعمل الاستقصائي لحل ألغاز تحليل أداء JavaScript

مقدمة

في السنوات الأخيرة، تم تسريع تطبيقات الويب بشكل كبير. تعمل العديد من التطبيقات الآن بسرعة كافية لدرجة أنّني سمعت بعض المطوّرين يتساءلون بصوت عالٍ "هل الويب سريع بما يكفي؟". قد يكون ذلك كافيًا لبعض التطبيقات، ولكن بالنسبة إلى المطوّرين الذين يعملون على تطبيقات عالية الأداء، نعلم أنّ ذلك ليس سريعًا بما يكفي. على الرغم من التقدّم المذهل في تكنولوجيا الأجهزة الافتراضية التي تستخدم JavaScript، أظهرت دراسة حديثة أنّ تطبيقات Google تقضي ما بين %50 و%70 من وقتها داخل V8. يحتوي تطبيقك على قدر محدود من الوقت، ويعني تقليل الدورات من نظام واحد أن نظامًا آخر يمكنه القيام بالمزيد. تذكَّر أنّ التطبيقات التي تعمل بمعدّل 60 لقطة في الثانية لا تملك سوى 16 ملي ثانية لكل لقطة، وإلا سيحدث بطء في الأداء. تابِع القراءة للتعرّف على كيفية تحسين JavaScript وتطبيقات JavaScript لإنشاء الملفات الشخصية، وذلك من خلال قصة من أرض الواقع عن محقّقي الأداء في فريق V8 الذين يتتبّعون مشكلة أداء غامضة في Find Your Way to Oz.

جلسة Google I/O لعام 2013

لقد قدّمت هذه المادة في مؤتمر Google I/O لعام 2013. يمكنك الاطّلاع على الفيديو أدناه:

ما أهمية الأداء؟

دورات وحدة المعالجة المركزية هي لعبة محصلتها صفر. وعندما يستهلك جزء واحد من النظام موارد أقل، يتيح لك ذلك استخدام المزيد من الموارد في جزء آخر أو تشغيل النظام بشكل أكثر سلاسة بشكل عام. غالبًا ما يكون التشغيل بشكل أسرع وإنجاز المزيد من المهام هدفين متنافسين، فالمستخدمون يطلبون ميزات جديدة بينما يتوقعون أيضًا أن يعمل تطبيقك بسلاسة أكبر. تستمر أجهزة JavaScript الافتراضية في العمل بشكل أسرع، ولكن هذا ليس سببًا لتجاهل مشاكل الأداء التي يمكنك حلّها اليوم، كما يعلم العديد من المطوّرين الذين يتعاملون مع مشاكل الأداء في تطبيقات الويب الخاصة بهم. في التطبيقات التي تعمل في الوقت الفعلي وبعدد لقطات كبير في الثانية، من المهم جدًا أن تكون خالية من أي تأخير. أجرت شركة Insomniac Games دراسة أظهرت أنّ عدد اللقطات في الثانية الثابت والمستدام مهم لنجاح اللعبة: "لا يزال عدد اللقطات في الثانية الثابت علامة على منتج احترافي ومصنوع بشكل جيد". يُرجى من مطوّري تطبيقات الويب الانتباه إلى ما يلي.

حلّ المشاكل في الأداء

حلّ مشكلة في الأداء يشبه حلّ جريمة. عليك فحص الأدلة بعناية، والتحقّق من الأسباب المشتبه بها، وتجربة حلول مختلفة. طوال الوقت، يجب عليك توثيق قياساتك حتى تتأكد من أنك قد أصلحت المشكلة بالفعل. هناك فرق بسيط للغاية بين هذه الطريقة وكيفية حل المحققين الجنائيين للقضية. يفحص المحققون الأدلة ويستجوبون المشتبه بهم ويجرون تجارب على أمل العثور على دليل قاطع.

V8 CSI: Oz

تواصل المطوّرون المذهلون الذين أنشأوا لعبة Find Your Way to Oz مع فريق V8 بشأن مشكلة في الأداء لم يتمكّنوا من حلّها بأنفسهم. في بعض الأحيان، كان Oz يتوقف عن العمل، ما يؤدي إلى حدوث بطء في الأداء. أجرى مطوّرو Oz بعض التحقيقات الأولية باستخدام لوحة المخطط الزمني في أدوات مطوّري البرامج في Chrome. عند النظر إلى استخدام الذاكرة، واجهوا الرسم البياني saw tooth المخيف. كان جامع البيانات المُهمَلة يجمع 10 ميغابايت من البيانات المُهمَلة مرة واحدة في الثانية، وكانت فترات توقُّف جمع البيانات المُهمَلة تتوافق مع البطء في الأداء. على غرار لقطة الشاشة التالية من "المخطط الزمني" في "أدوات مطوّري البرامج في Chrome":

المخطط الزمني لأدوات مطوّري البرامج

تولّى المحقّقان "جاكوب" و"يانغ" من فريق V8 القضية. ما حدث كان نقاشًا طويلاً بين "جاكوب" و"يانغ" من فريق V8 وفريق Oz. لقد لخّصت هذه المحادثة في الأحداث المهمة التي ساعدت في تتبُّع هذه المشكلة.

الدليل

الخطوة الأولى هي جمع الأدلة الأولية ودراستها.

ما نوع التطبيق الذي ننظر إليه؟

العرض التوضيحي لـ Oz هو تطبيق تفاعلي ثلاثي الأبعاد. لهذا السبب، يكون حساسًا للغاية لعمليات الإيقاف المؤقت الناتجة عن عمليات جمع المهملات. يُرجى العِلم أنّ التطبيق التفاعلي الذي يعمل بمعدل 60 لقطة في الثانية يستغرق 16 ملي ثانية لتنفيذ جميع مهام JavaScript، ويجب أن يترك بعضًا من هذا الوقت لمتصفّح Chrome من أجل معالجة طلبات الرسومات ورسم الشاشة.

تُجري Oz الكثير من العمليات الحسابية على القيم المزدوجة وتُجري مكالمات متكررة إلى WebAudio وWebGL.

ما نوع مشكلة الأداء التي نواجهها؟

نلاحظ حدوث توقّفات مؤقتة، أي حالات إسقاط للإطارات، أي بطء في الأداء. ترتبط هذه التوقفات المؤقتة بعمليات تجميع الموارد المهملة.

هل يتّبع المطوّرون أفضل الممارسات؟

نعم، مطوّرو Oz على دراية جيدة بأداء جهاز JavaScript الافتراضي وأساليب التحسين. تجدر الإشارة إلى أنّ مطوّري Oz كانوا يستخدمون CoffeeScript كلغة مصدر وينشئون رمز JavaScript من خلال برنامج تجميع CoffeeScript. وقد جعل ذلك بعض التحقيقات أكثر صعوبة بسبب عدم التوافق بين الرمز الذي يكتبه مطوّرو Oz والرمز الذي يستهلكه V8. تتيح "أدوات مطوّري البرامج في Chrome" الآن استخدام خرائط المصدر التي كانت ستسهّل ذلك.

لماذا يتم تشغيل أداة تجميع المهملات؟

تتم إدارة الذاكرة في JavaScript تلقائيًا للمطوّر من خلال الجهاز الافتراضي. يستخدم محرّك V8 نظامًا شائعًا لجمع المهملات يتم فيه تقسيم الذاكرة إلى جيلَين (أو أكثر). يحتوي الجيل الجديد على كائنات تم تخصيصها مؤخرًا. إذا استمر الكائن لفترة كافية، فسيتم نقله إلى الجيل القديم.

يتم جمع الجيل الجديد بمعدل تكرار أعلى بكثير من الجيل القديم. وهذا مقصود، لأنّ جمع الجيل الجديد أقل تكلفة بكثير. غالبًا ما يكون من الآمن افتراض أنّ توقُّفات GC المتكرّرة ناتجة عن جمع البيانات من الجيل الجديد.

في V8، يتم تقسيم مساحة الذاكرة الحديثة إلى كتلتَين متجاورتَين من الذاكرة بحجم متساوٍ. يتم استخدام كتلة واحدة فقط من هاتين الكتلتين في أي وقت معين وتسمى المساحة إلى. طالما أنّ هناك ذاكرة متبقية في المساحة المخصّصة، يكون تخصيص عنصر جديد غير مكلف. يتم نقل المؤشر في المساحة المخصصة إلى الأمام بعدد وحدات البايت المطلوبة للكائن الجديد. ويستمر ذلك إلى أن تنفد مساحة "إلى". عند هذه النقطة، يتم إيقاف البرنامج ويبدأ جمع البيانات.

ذاكرة V8 young

في هذه المرحلة، يتم تبديل المساحة المصدر والمساحة الوجهة. يتم فحص ما كان مساحة الوجهة وأصبح الآن مساحة المصدر من البداية إلى النهاية، ويتم نسخ أي عناصر لا تزال نشطة إلى مساحة الوجهة أو يتم نقلها إلى كومة الذاكرة المؤقتة للجيل القديم. إذا أردت معرفة التفاصيل، أنصحك بالقراءة عن خوارزمية تشيني.

يجب أن تفهم بشكل بديهي أنه في كل مرة يتم فيها تخصيص كائن إما ضمنيًا أو صراحةً (عبر استدعاء جديد، أو []، أو {})، يقترب تطبيقك أكثر فأكثر من عملية جمع البيانات المهملة وإيقاف التطبيق المخيف مؤقتًا.

هل من المتوقّع أن يبلغ حجم البيانات غير الصالحة 10 ميغابايت في الثانية لهذا التطبيق؟

باختصار، لا، فالمطوّر لا يفعل أي شيء يتوقّع من خلاله الحصول على 10 ميغابايت في الثانية من البيانات غير الصالحة.

المشتبه بهم

المرحلة التالية من التحقيق هي تحديد المشتبه بهم المحتملين ثم تقليصهم.

المشتبه به الأول

Calling new during the frame. تذكَّر أنّ كل عنصر يتم تخصيصه يقرّبك أكثر من إيقاف عملية جمع المهملات مؤقتًا. يجب أن تسعى التطبيقات التي تعمل بمعدلات عدد اللقطات في الثانية العالية على وجه الخصوص إلى عدم إجراء أي عمليات تخصيص لكل لقطة. يتطلب هذا عادةً نظامًا مدروسًا بعناية لإعادة تدوير العناصر خاص بالتطبيق. تحقّق محققو V8 مع فريق Oz ولم يكونوا يستدعون دالة new. في الواقع، كان فريق Oz على دراية تامة بهذا الشرط وقال "سيكون ذلك محرجًا". يمكنك شطب هذا من القائمة.

المشتبه به رقم 2

تعديل "شكل" عنصر خارج الدالة الإنشائية يحدث هذا عندما تتم إضافة خاصية جديدة إلى كائن خارج الدالة الإنشائية. يؤدي ذلك إلى إنشاء فئة مخفية جديدة للكائن. عندما يرى الرمز المحسّن هذه الفئة المخفية الجديدة، سيتم تشغيل إلغاء التحسين، وسيتم تنفيذ الرمز غير المحسّن حتى يتم تصنيف الرمز على أنه ساخن وتحسينه مرة أخرى. سيؤدي هذا التغيير في إلغاء التحسين وإعادة التحسين إلى حدوث بطء في الأداء، ولكنه لا يرتبط بشكل صارم بإنشاء كميات زائدة من البيانات غير الضرورية. بعد مراجعة دقيقة للرمز، تم التأكد من أن أشكال الكائنات كانت ثابتة، وبالتالي تم استبعاد المشتبه به رقم 2.

المشتبه به رقم 3

العمليات الحسابية في الرموز غير المحسّنة في الرموز البرمجية غير المحسّنة، تؤدي جميع العمليات الحسابية إلى تخصيص عناصر فعلية. على سبيل المثال، المقتطف التالي:

var a = p * d;
var b = c + 3;
var c = 3.3 * dt;
point.x = a * b * c;

يؤدي ذلك إلى إنشاء 5 عناصر من النوع HeapNumber. الأولى الثلاثة للمتغيرات، a وb وc. والرابع للقيمة المجهولة (a * b) والخامس من #4 * c؛ ويتم تعيين الخامس في النهاية إلى point.x.

ينفّذ Oz الآلاف من هذه العمليات لكل لقطة. إذا حدثت أي من هذه العمليات الحسابية في دوال لم يتم تحسينها مطلقًا، فقد تكون هي سبب البيانات غير الصالحة. لأن العمليات الحسابية في التخصيص غير المحسّن تستهلك الذاكرة حتى للنتائج المؤقتة.

المشتبه به الرابع

تخزين رقم دقة مزدوجة في سمة يجب إنشاء عنصر HeapNumber لتخزين الرقم والسمة التي تم تغييرها للإشارة إلى هذا العنصر الجديد. لن يؤدي تغيير السمة للإشارة إلى HeapNumber إلى إنشاء بيانات مهملة. ومع ذلك، من الممكن أن يتم تخزين العديد من الأرقام ذات الدقة المزدوجة كخصائص كائن. الرمز مليء بعبارات مثل ما يلي:

sprite.position.x += 0.5 * (dt);

في الرمز البرمجي المحسَّن، في كل مرة يتم فيها تعيين قيمة محسوبة حديثًا للمتغير x، يتم تخصيص كائن HeapNumber جديد ضمنيًا، وهو عبارة تبدو غير ضارة، مما يجعلنا أقرب إلى إيقاف جمع المهملات مؤقتًا.

يُرجى العِلم أنّه باستخدام مصفوفة مكتوبة (أو مصفوفة عادية تحتوي على أرقام مزدوجة فقط)، يمكنك تجنُّب هذه المشكلة المحدّدة تمامًا لأنّ مساحة التخزين للرقم المزدوج الدقة يتم تخصيصها مرة واحدة فقط، ولا يتطلّب تغيير القيمة بشكل متكرر تخصيص مساحة تخزين جديدة.

المشتبه به رقم 4 هو احتمال.

الطب الشرعي

في هذه المرحلة، لدى المحققين مشتبه بهما محتملان: تخزين أرقام الكومة كخصائص كائن وحساب حسابي يحدث داخل وظائف غير محسّنة. حان الوقت للتوجه إلى المختبر وتحديد المشتبه به المذنب بشكل قاطع. ملاحظة: في هذا القسم، سأستخدم نسخة طبق الأصل من المشكلة الموجودة في رمز المصدر الفعلي لـ Oz. هذا الإصدار أصغر بكثير من الرمز الأصلي، وبالتالي يسهل فهمه.

التجربة رقم 1

التحقّق من المشتبه به رقم 3 (العمليات الحسابية داخل الدوال غير المحسّنة) يحتوي محرّك V8 JavaScript على نظام تسجيل مدمج يمكنه تقديم إحصاءات مفيدة حول ما يحدث في الخلفية.

ابدأ بمتصفّح Chrome غير مشغَّل على الإطلاق، ثم شغِّله باستخدام الميزات التجريبية التالية:

--no-sandbox --js-flags="--prof --noprof-lazy --log-timer-events"

ثم الخروج من Chrome بالكامل إلى إنشاء ملف السجلّ v8.log في الدليل الحالي.

لتفسير محتوى v8.log، عليك تنزيل الإصدار نفسه من v8 الذي يستخدمه Chrome (راجِع about:version)، وإنشاؤه.

بعد إنشاء v8 بنجاح، يمكنك معالجة السجلّ باستخدام معالج العلامات:

$ tools/linux-tick-processor /path/to/v8.log

(استبدِل mac أو windows بـ linux حسب النظام الأساسي الذي تستخدمه). (يجب تشغيل هذه الأداة من دليل ملفات المصدر ذي المستوى الأعلى في v8).

يعرض معالج العلامات جدولاً نصيًا لدوال JavaScript التي حصلت على أكبر عدد من العلامات:

[JavaScript]:
ticks  total  nonlib   name
167   61.2%   61.2%  LazyCompile: *opt demo.js:12
 40   14.7%   14.7%  LazyCompile: unopt demo.js:20
 15    5.5%    5.5%  Stub: KeyedLoadElementStub
 13    4.8%    4.8%  Stub: BinaryOpStub_MUL_Alloc_Number+Smi
  6    2.2%    2.2%  Stub: BinaryOpStub_ADD_OverwriteRight_Number+Number
  4    1.5%    1.5%  Stub: KeyedStoreElementStub
  4    1.5%    1.5%  KeyedLoadIC:  {12}
  2    0.7%    0.7%  KeyedStoreIC:  {13}
  1    0.4%    0.4%  LazyCompile: ~main demo.js:30

يمكنك ملاحظة أنّ demo.js يتضمّن ثلاث دوال: opt وunopt وmain. تظهر علامة النجمة (*) بجانب أسماء الدوال المحسّنة. لاحظ أن الدالة opt محسّنة وأن unopt غير محسّنة.

من الأدوات المهمة الأخرى في حقيبة أدوات محقق V8 هي plot-timer-event. ويمكن تنفيذ ذلك على النحو التالي:

$ tools/plot-timer-event /path/to/v8.log

بعد التشغيل، يظهر ملف png باسم timer-events.png في الدليل الحالي. عند فتحه، من المفترض أن يظهر لك شيء يشبه هذا:

أحداث المؤقِّت

بخلاف الرسم البياني في الأسفل، يتم عرض البيانات في صفوف. يمثّل المحور السيني الوقت (بالملي ثانية). يتضمّن الجانب الأيمن تصنيفات لكل صف:

المحور "ص" لأحداث المؤقِّت

يحتوي صف V8.Execute على خط عمودي أسود مرسوم عليه عند كل علامة في الملف الشخصي حيث كان V8 ينفّذ رمز JavaScript. يحتوي V8.GCScavenger على خط عمودي أزرق مرسوم عليه عند كل علامة ملف شخصي حيث كان V8 ينفّذ مجموعة جديدة من الجيل. وينطبق الأمر نفسه على بقية حالات V8.

أحد أهم الصفوف هو "نوع الرمز الذي يتم تنفيذه". سيضيء باللون الأخضر عند تنفيذ رمز محسَّن، وباللونَين الأحمر والأزرق عند تنفيذ رمز غير محسَّن. تعرض لقطة الشاشة التالية الانتقال من الرمز المحسَّن إلى الرمز غير المحسَّن ثم العودة إلى الرمز المحسَّن:

نوع الرمز الذي يتم تنفيذه

من الناحية المثالية، ولكن ليس على الفور، سيكون هذا الخط أخضر ثابتًا. وهذا يعني أنّ برنامجك قد انتقل إلى حالة مستقرة ومحسّنة. سيتم دائمًا تشغيل الرمز غير المحسّن بشكل أبطأ من الرمز المحسّن.

إذا وصلت إلى هذا الحد، تجدر الإشارة إلى أنه يمكنك العمل بشكل أسرع من خلال إعادة بناء تطبيقك بحيث يمكن تشغيله في واجهة تصحيح الأخطاء v8: d8. يمنحك استخدام d8 أوقات تكرار أسرع باستخدام أدوات tick-processor وplot-timer-event. ومن الآثار الجانبية الأخرى لاستخدام d8 أنّه يصبح من الأسهل عزل المشكلة الفعلية، ما يقلّل من مقدار التشويش في البيانات.

عند النظر إلى مخطّط أحداث الموقّت من الرمز المصدر لـ Oz، تبيّن حدوث انتقال من الرمز المحسَّن إلى الرمز غير المحسَّن، وأثناء تنفيذ الرمز غير المحسَّن، تم تشغيل العديد من مجموعات الجيل الجديد، على غرار لقطة الشاشة التالية (يُرجى ملاحظة أنّه تمت إزالة الوقت في المنتصف):

مخطط أحداث الموقّت

إذا نظرت عن كثب، يمكنك أن ترى أن الخطوط السوداء التي تشير إلى وقت تنفيذ V8 لرمز JavaScript مفقودة في أوقات علامة الملف الشخصي نفسها تمامًا مثل مجموعات الجيل الجديد (الخطوط الزرقاء). يوضّح هذا المثال بوضوح أنّه يتم إيقاف النص البرمجي مؤقتًا أثناء جمع المهملات.

بالنظر إلى مخرجات معالج العلامات من الرمز المصدر لـ Oz، لم يتم تحسين الدالة العليا (updateSprites). بعبارة أخرى، لم يتم تحسين الدالة التي استغرق البرنامج فيها معظم الوقت. يشير هذا بقوة إلى أن المشتبه به رقم 3 هو الجاني. احتوى مصدر updateSprites على حلقات تشبه هذه:

function updateSprites(dt) {
    for (var sprite in sprites) {
        sprite.position.x += 0.5 * dt;
        // 20 more lines of arithmetic computation.
    }
}

بفضل معرفتهم العميقة بمحرّك V8، أدركوا على الفور أنّ بنية حلقة for-i-in لا يتم تحسينها أحيانًا بواسطة V8. بعبارة أخرى، إذا كانت الدالة تحتوي على بنية حلقة for-i-in، فقد لا يتم تحسينها. هذه حالة خاصة اليوم، ومن المرجّح أن تتغيّر في المستقبل، أي أنّ محرّك V8 قد يحسّن بنية التكرار هذه في يوم من الأيام. بما أنّنا لسنا محقّقين في V8 ولا نعرف V8 عن ظهر قلب، كيف يمكننا تحديد سبب عدم تحسين updateSprites؟

التجربة رقم 2

تشغيل Chrome باستخدام هذه العلامة:

--js-flags="--trace-deopt --trace-opt-verbose"

سجلّاً مطوّلاً لبيانات التحسين وإلغاء التحسين. بالبحث في البيانات عن updateSprites نجد:

[تم إيقاف تحسين updateSprites، السبب: ForInStatement ليس حالة سريعة]

تمامًا كما افترض المحققون، كان السبب هو بنية حلقة for-i-in.

تم إغلاق الطلب

بعد اكتشاف سبب عدم تحسين updateSprites، كان الإصلاح بسيطًا، ما عليك سوى نقل العملية الحسابية إلى الدالة الخاصة بها، أي:

function updateSprite(sprite, dt) {
    sprite.position.x += 0.5 * dt;
    // 20 more lines of arithmetic computation.
}

function updateSprites(dt) {
    for (var sprite in sprites) {
        updateSprite(sprite, dt);
    }
}

سيتم تحسين updateSprite، ما يؤدي إلى تقليل عدد عناصر HeapNumber بشكل كبير، وبالتالي تقليل تكرار توقُّف جمع المهملات. من المفترض أن يكون من السهل عليك تأكيد ذلك من خلال إجراء التجارب نفسها باستخدام رمز جديد. سيلاحظ القارئ الدقيق أن الأرقام المزدوجة لا تزال مخزنة كخصائص. إذا كان التحليل يشير إلى أنّ الأمر يستحق، فإنّ تغيير الموضع ليكون مصفوفة من القيم العشرية المزدوجة أو مصفوفة بيانات مكتوبة من شأنه أن يقلّل من عدد العناصر التي يتم إنشاؤها.

الخاتمة

لم يتوقف مطوّرو Oz عند هذا الحد. وبفضل الأدوات والأساليب التي شاركها معهم محققو V8، تمكّنوا من العثور على بعض الدوال الأخرى التي كانت عالقة في جحيم إلغاء التحسين، وقسّموا رمز الحوسبة إلى دوال فرعية تم تحسينها، ما أدى إلى تحسين الأداء بشكل أكبر.

انطلِق وابدأ في حلّ بعض المشاكل المتعلّقة بالأداء.