نصائح الأداء لـ JavaScript في الإصدار 8

Chris Wilson
Chris Wilson

مقدمة

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

أهم النصائح

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

في ما يلي أفضل النصائح الأساسية للحصول على أداء جيد في تطبيقات الويب:

  • الاستعداد قبل حدوث مشكلة (أو ملاحظتها)
  • بعد ذلك، حدد وافهم ركيزة مشكلتك
  • أخيرًا، حلّ المشاكل المهمة

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

لننتقل إلى نصائح V8.

الصفوف المخفية

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

على سبيل المثال:

function Point(x, y) {
  this.x = x;
  this.y = y;
}

var p1 = new Point(11, 22);
var p2 = new Point(33, 44);
// At this point, p1 and p2 have a shared hidden class
p2.z = 55;
// warning! p1 and p2 now have different hidden classes!```

إلى أن تتم إضافة العنصر الإضافي ".z" إلى مثيل العنصر p2، يكون لدى p1 وp2 داخليًا الفئة المخفية نفسها، ما يتيح لمحرك V8 إنشاء إصدار واحد من التجميع المحسَّن لرمز JavaScript الذي يتعامل مع p1 أو p2. وكلما تمكنت من تجنب التسبب في تباعد الفئات المخفية، أدى ذلك إلى تحقيق أداء أفضل.

لذلك

  • بدء جميع أعضاء الكائن في دوالّ الإنشاء (كي لا تتغيّر أنواع النماذج لاحقًا)
  • بدء عناصر الكائن دائمًا بالترتيب نفسه

Numbers

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

على سبيل المثال:

var i = 42;  // this is a 31-bit signed integer
var j = 4.2;  // this is a double-precision floating point number```

لذلك

  • استخدِم القيم الرقمية التي يمكن تمثيلها كأعداد صحيحة بعلامة 31 بت.

الصفائف

للتعامل مع الصفائف الكبيرة والمتفرقة، هناك نوعان من تخزين الصفائف داخليًا:

  • Fast Elements: مساحة تخزين خطية لمجموعات مفاتيح مدمجة
  • عناصر القاموس: تخزين جدول التجزئة بخلاف ذلك

ومن الأفضل عدم التسبب في تبديل تخزين الصفيف من نوع إلى آخر.

لذلك

  • استخدِم مفاتيح متسلسلة تبدأ من 0 للمصفوفات.
  • لا تخصص مسبقًا صفائف كبيرة (مثلاً أكثر من 64 ألف عنصر) إلى الحد الأقصى لحجمها، بل وسِّعها أثناء التنفيذ.
  • لا تحذف العناصر في المصفوفات، وخاصة المصفوفات الرقمية
  • لا تحمِّل العناصر التي لم يتمّ بدء تشغيلها أو حذفها:
for (var b = 0; b < 10; b++) {
  a[0] |= b;  // Oh no!
}
//vs.
a = new Array();
a[0] = 0;
for (var b = 0; b < 10; b++) {
  a[0] |= b;  // Much better! 2x faster.
}

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

var a = new Array();
a[0] = 77;   // Allocates
a[1] = 88;
a[2] = 0.5;   // Allocates, converts
a[3] = true; // Allocates, converts```

أقل كفاءة من:

var a = [77, 88, 0.5, true];

لأنّه في المثال الأول، يتمّ إجراء عمليات التحديد الفردية الواحد تلو الآخر، ويؤدّي تحديد a[2] إلى تحويل المصفوفة إلى مصفوفة من الأعداد المزدوجة غير المُعبّأة، ولكنّ تحديد a[3] يؤدّي بعد ذلك إلى إعادة تحويلها إلى مصفوفة يمكن أن تحتوي على أيّ قيم (أرقام أو عناصر). في الحالة الثانية، يعرف برنامج التحويل البرمجي أنواع جميع العناصر في الحرف الحرفي، ويمكن تحديد الفئة المخفية مقدمًا.

  • الإعداد باستخدام القيم الثابتة للمصفوفات الصغيرة ذات الحجم الثابت
  • تخصيص الصفائف الصغيرة مسبقًا (أقل من 64 ألف) لضبط حجمها قبل استخدامها
  • عدم تخزين القيم غير الرقمية (العناصر) في مصفوفات رقمية
  • احرِص على عدم إعادة تحويل المصفوفات الصغيرة في حال الإعداد بدون استخدام القيم الثابتة.

تجميع JavaScript

على الرغم من أنّ JavaScript هي لغة ديناميكية جدًا، وكانت عمليات التنفيذ الأصلية لها هي التفسير، إلا أنّ محرّكات وقت التشغيل الحديثة لـ JavaScript تستخدم عملية الترجمة. يحتوي V8 (JavaScript في Chrome) على برنامجَي تجميع مختلفَين أثناء التشغيل (JIT)، وهما:

  • المُحوِّل البرمجي "الكامل"، الذي يمكنه إنشاء رمز برمجي جيد لأي لغة JavaScript
  • المُحوِّل البرمجي المحسِّن، الذي يُنشئ رمزًا رائعًا لمعظم JavaScript، ولكنّه يستغرق وقتًا أطول في عملية التحويل البرمجي.

The Full Compiler

في الإصدار 8، يعمل المُجمِّع الكامل على كل الرموز البرمجية، ويبدأ تنفيذ الرمز البرمجي في أقرب وقت ممكن، ما يؤدي إلى إنشاء رمز برمجي جيد ولكن ليس رائعًا بسرعة. لا يفترض هذا المحول البرمجي عدم وجود أي شيء تقريبًا حول الأنواع في وقت التجميع، ويتوقع أن أنواع المتغيرات يمكن أن تتغير وتغير في وقت التشغيل. إنّ الرمز الذي ينشئه المُجمِّع الكامل يستخدم ذاكرة التخزين المؤقت المضمّنة (IC) لتحسين المعرفة حول الأنواع أثناء تشغيل البرنامج، ما يؤدي إلى تحسين الكفاءة على الفور.

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

لذلك

  • يُفضّل استخدام العمليات أحادية الشكل بدلاً من العمليات متعددة الأشكال.

تكون العمليات أحادية الشكل إذا كانت الفئات المخفية للمدخلات دائمًا متطابقة - وإلا فإنها تكون متعددة الأشكال، مما يعني أن بعض الوسيطات يمكن أن تغير النوع عبر استدعاءات مختلفة للعملية. على سبيل المثال، تؤدي الدعوة الثانية إلى add() في هذا المثال إلى تعدد الأشكال:

function add(x, y) {
  return x + y;
}

add(1, 2);      // + in add is monomorphic
add("a", "b");  // + in add becomes polymorphic```

برنامج الترجمة المُحسِّن

بالتوازي مع المُجمِّع الكامل، يُعيد V8 تجميع الدوالّ "الرائجة" (أي الدوالّ التي يتمّ تشغيلها عدّة مرّات) باستخدام مُجمِّع محسّن. يستخدم هذا المُجمِّع ملاحظات النوع لجعل الرمز المجمَّع أسرع، وفي الواقع، يستخدم الأنواع المستمَدة من الدوائر المتكاملة التي تحدثنا عنها للتو.

في المُجمِّع المحسِّن، يتم تضمين العمليات بشكل تخميني (يتم وضعها مباشرةً في المكان الذي يتم استدعاؤها فيه). يؤدي ذلك إلى تسريع التنفيذ (على حساب مساحة الذاكرة)، ولكنه يتيح أيضًا تحسينات أخرى. يمكن تضمين الدوالّ وعناصر الإنشاء أحادية الشكل بالكامل (هذا سبب آخر يجعل أحادية الشكل فكرة جيدة في V8).

يمكنك تسجيل ما يتم تحسينه باستخدام الإصدار المستقل "d8" من محرّك V8:

d8 --trace-opt primes.js

(يسجّل هذا أسماء الدوال المحسَّنة إلى stdout.)

ومع ذلك، لا يمكن تحسين جميع الدوال، حيث تمنع بعض الميزات تشغيل برنامج التحويل البرمجي المحسّن من خلال دالة معينة (عملية "تجاوز"). على وجه الخصوص، يتوقف المُجمِّع المحسِّن حاليًا عن تنفيذ الدوالّ التي تحتوي على كتل try {} catch {}.

لذلك

  • ضَع الرمز الحسّاس للأداء في دالة متداخلة إذا كانت لديك وحدات try {} catch {}: ```js function perf_sensitive() { // Do performance-sensitive work here }

try { perf_sensitive() } catch (e) { // Handle exceptions here } ```

من المحتمل أن تتغيّر هذه الإرشادات في المستقبل، لأنّنا سنفعّل وحدات try/catch في المُجمِّع المحسّن. يمكنك التعرف على كيفية قيام المحول البرمجي للتحسين بتوزيع الدوال باستخدام الخيار "--trace-opt" مع d8 على النحو الوارد أعلاه، والذي يمنحك مزيدًا من المعلومات حول الدوال التي تم إغفالها:

d8 --trace-opt primes.js

إيقاف التحسين

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

لذلك

  • تجنُّب تغييرات الفئات المخفية في الدوالّ بعد تحسينها

يمكنك، كما هو الحال مع عمليات التحسين الأخرى، الحصول على سجلّ للدوالّ التي كان على V8 إلغاء تحسينها باستخدام علامة تسجيل:

d8 --trace-deopt primes.js

أدوات V8 الأخرى

يمكنك أيضًا ضبط خيارات تتبُّع V8 في Chrome عند بدء التشغيل:

"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --js-flags="--trace-opt --trace-deopt"```

بالإضافة إلى استخدام أدوات المطوّرين لإنشاء الملفات الشخصية، يمكنك أيضًا استخدام d8 لإجراء التدقيق:

% out/ia32.release/d8 primes.js --prof

ويستخدم هذا الإجراء أداة تحليل الأداء المضمّنة المستندة إلى تحليل عيّنات الأداء، والتي تأخذ عيّنة كل مللي ثانية وتُسجّل ملف v8.log.

في الملخّص

من المهم تحديد وفهم آلية عمل محرّك V8 مع الرمز البرمجي للاستعداد لإنشاء رمز JavaScript عالي الأداء. مرة أخرى، النصيحة الأساسية هي:

  • الاستعداد قبل حدوث مشكلة (أو ملاحظتها)
  • بعد ذلك، حدد وافهم ركيزة مشكلتك
  • أخيرًا، حلّ المشاكل المهمة

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

المراجع