مقدمة
قدّم "دانيال كليفورد" محادثة رائعة في Google I/O حول النصائح والخدع لتحسين أداء JavaScript في V8. شجّعنا دانيال على "المطالبة بأداء أسرع"، أي تحليل الاختلافات في الأداء بين C++ وJavaScript بعناية، وكتابة الرموز البرمجية مع الانتباه إلى آلية عمل JavaScript. تتضمّن هذه المقالة ملخّصًا لأهمّ نقاط محادثة "دانيال"، وسنحرص أيضًا على تعديل هذه المقالة عند تغيير إرشادات الأداء.
أهم النصائح
من المهم وضع أي نصائح حول الأداء في السياق. إنّ النصائح المتعلّقة بالأداء تُسبّب الإدمان، وفي بعض الأحيان قد يؤدي التركيز على النصائح المفصّلة أولاً إلى تشتيت الانتباه عن المشاكل الحقيقية. عليك الاطّلاع على نظرة شاملة على أداء تطبيق الويب. قبل التركيز على نصائح الأداء هذه، من المحتمل أن تحتاج إلى تحليل الرمز البرمجي باستخدام أدوات مثل PageSpeed وتحسين نتيجتك. سيساعدك ذلك في تجنُّب إجراء تحسينات مبكّرة.
في ما يلي أفضل النصائح الأساسية للحصول على أداء جيد في تطبيقات الويب:
- الاستعداد قبل حدوث مشكلة (أو ملاحظتها)
- بعد ذلك، حدِّد جوهر مشكلتك وافهمها.
- أخيرًا، حلّ المشاكل المهمة
لتنفيذ هذه الخطوات، قد يكون من المهم فهم كيفية تحسين V8 لـ JavaScript، حتى تتمكّن من كتابة رمز مع مراعاة تصميم وقت تشغيل JavaScript. من المهم أيضًا التعرّف على الأدوات المتاحة وكيفية مساعدتك. يقدّم "دانيال" المزيد من الشرح حول كيفية استخدام أدوات المطوّرين في محادثته، ويعرض هذا المستند بعضًا من أهم النقاط في تصميم محرّك 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]
يؤدّي بعد ذلك إلى إعادة تحويلها إلى مصفوفة يمكن أن تحتوي على أيّ قيم (أرقام أو عناصر). في الحالة الثانية، يعرف المُجمِّع أنواع جميع العناصر في القيمة الثابتة، ويمكن تحديد الفئة المخفية مسبقًا.
- الإعداد باستخدام القيم الثابتة للمصفوفات الصغيرة ذات الحجم الثابت
- تخصيص مصفوفات صغيرة مسبقًا (<64k) للحجم الصحيح قبل استخدامها
- عدم تخزين القيم غير الرقمية (العناصر) في مصفوفات رقمية
- احرِص على عدم إعادة تحويل المصفوفات الصغيرة في حال الإعداد بدون استخدام القيم الثابتة.
تجميع 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 بشكل أفضل، ولكن احرص على التركيز على تحسين خوارزميكاتك أيضًا.