مقدمة
قدّم "دانيال كليفورد" كلمة ممتازة في مؤتمر Google I/O حول النصائح والحيل لتحسين أداء JavaScript في V8. شجّعنا "دانيال" على "طلب أداء أسرع"، أي تحليل الاختلافات في الأداء بين C++ وJavaScript بعناية، وكتابة الرموز البرمجية مع مراعاة طريقة عمل JavaScript. يتضمّن هذا المقال ملخّصًا لأهم النقاط التي تناولها "دانيال" في حديثه، وسنحرص على تعديل هذا المقال باستمرار ليعكس أي تغييرات في الإرشادات المتعلّقة بالأداء.
أهم نصيحة
من المهم وضع أي نصيحة بشأن الأداء في سياقها. إنّ نصائح الأداء تثير الاهتمام، وأحيانًا قد يؤدي التركيز على النصائح المعقّدة أولاً إلى تشتيت الانتباه عن المشاكل الحقيقية. يجب أن تأخذ نظرة شاملة على أداء تطبيق الويب الخاص بك - قبل التركيز على نصيحة الأداء هذه، يجب عليك على الأرجح تحليل الكود الخاص بك باستخدام أدوات مثل PageSpeed والحصول على درجاتك. سيساعدك هذا في تجنب التحسين المبكر.
أفضل نصيحة أساسية للحصول على أداء جيد في تطبيقات الويب هي:
- الاستعداد قبل حدوث (أو ملاحظة) مشكلة
- بعد ذلك، حدِّدوا جوهر المشكلة وافهموها
- أخيرًا، حلّ المشاكل المهمة
لإكمال هذه الخطوات، من المهم فهم كيفية تحسين محرّك V8 لبيانات JS، لكي تتمكّن من كتابة رمز برمجي يراعي تصميم وقت تشغيل JS. من المهم أيضًا التعرّف على الأدوات المتاحة وكيفية الاستفادة منها. يشرح "دانيال" بمزيد من التفصيل كيفية استخدام أدوات المطوّرين في حديثه، ويلخّص هذا المستند بعض أهم النقاط في تصميم محرّك V8.
والآن، لننتقل إلى نصائح V8.
الصفوف المخفية
تتضمّن لغة JavaScript معلومات محدودة عن أنواع وقت التجميع: يمكن تغيير الأنواع في وقت التشغيل، لذا من الطبيعي توقُّع أنّ الاستدلال على أنواع JS في وقت التجميع أمر مكلف. قد يدفعك ذلك إلى التساؤل عن كيفية اقتراب أداء 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: linear storage for compact key sets
- عناصر القاموس: تخزين جدول التجزئة بخلاف ذلك
من الأفضل عدم التبديل بين أنواع تخزين المصفوفات.
لذلك
- استخدام مفاتيح متجاورة تبدأ من 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، ولكنه يستغرق وقتًا أطول في التجميع.
برنامج التجميع الكامل
في V8، يعمل برنامج التجميع الكامل على جميع الرموز، ويبدأ في تنفيذ الرموز في أقرب وقت ممكن، مما يؤدي إلى إنشاء رموز جيدة ولكن ليست رائعة بسرعة. لا يفترض برنامج التجميع هذا أي شيء تقريبًا حول الأنواع في وقت التجميع، بل يتوقع أن أنواع المتغيرات يمكن أن تتغير وستتغير في وقت التشغيل. يستخدم الرمز الذي تم إنشاؤه بواسطة برنامج التجميع الكامل ذاكرات التخزين المؤقت المضمّنة (ICs) لتحسين المعرفة بالأنواع أثناء تشغيل البرنامج، ما يحسّن الكفاءة أثناء التنفيذ.
الهدف من "ذاكرة التخزين المؤقت المضمّنة" هو التعامل مع الأنواع بكفاءة، وذلك من خلال تخزين الرموز البرمجية المعتمدة على الأنواع مؤقتًا للعمليات. وعند تشغيل الرمز، سيتم التحقّق من صحة افتراضات الأنواع أولاً، ثم استخدام ذاكرة التخزين المؤقت المضمّنة لاختصار العملية. ومع ذلك، يعني هذا أنّ العمليات التي تقبل أنواعًا متعددة ستكون أقل كفاءة.
لذلك
- يُفضَّل استخدام العمليات الأحادية الشكل على العمليات المتعددة الأشكال
تكون العمليات أحادية الشكل إذا كانت الفئات المخفية للمدخلات هي نفسها دائمًا، وإلا فإنها تكون متعددة الأشكال، مما يعني أن بعض الوسيطات يمكن أن تغير نوعها عبر استدعاءات مختلفة للعملية. على سبيل المثال، يؤدي استدعاء add() الثاني في هذا المثال إلى تعدّد الأشكال:
function add(x, y) {
return x + y;
}
add(1, 2); // + in add is monomorphic
add("a", "b"); // + in add becomes polymorphic```
برنامج التجميع المحسِّن
بالتوازي مع برنامج التجميع الكامل، يعيد محرّك V8 تجميع الدوال "النشطة" (أي الدوال التي يتم تشغيلها عدة مرات) باستخدام برنامج تجميع محسِّن. يستخدم برنامج التجميع هذا الملاحظات حول الأنواع لجعل الرمز البرمجي المجمَّع أسرع، فهو في الواقع يستخدم الأنواع المأخوذة من ICs التي تحدثنا عنها للتو.
في برنامج التجميع المحسِّن، يتم تضمين العمليات بشكل تخميني (يتم وضعها مباشرةً في مكان استدعائها). يؤدي ذلك إلى تسريع التنفيذ (على حساب استهلاك الذاكرة)، ولكنه يتيح أيضًا تحسينات أخرى. يمكن تضمين الدوال والدوال الإنشائية الأحادية الشكل بالكامل (وهذا سبب آخر يجعل أحادية الشكل فكرة جيدة في 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 بشكل أفضل، ولكن احرص على التركيز على تحسين الخوارزميات الخاصة بك أيضًا.