قصة عن V8 ، React وانخفاض في الأداء. الجزء 1

ستناقش المادة ، الجزء الأول من الترجمة التي ننشرها اليوم ، كيف يختار محرك V8 JavaScript أفضل الطرق لتمثيل قيم JS المختلفة في الذاكرة ، وكيف يؤثر ذلك على الآليات الداخلية لـ V8 فيما يتعلق بالعمل مع ما يسمى النماذج كائنات (الشكل). كل هذا سيساعدنا في تحديد جوهر مشكلة أداء React الأخيرة.



أنواع بيانات JavaScript


يمكن أن تحتوي كل قيمة JavaScript على نوع واحد فقط من أنواع البيانات الثمانية الموجودة: Number ، String ، Symbol ، BigInt ، Boolean ، Undefined ، Null Object .


أنواع بيانات JavaScript

يمكن تحديد نوع القيمة باستخدام معامل typeof ، ولكن هناك استثناء واحد مهم:

 typeof 42; // 'number' typeof 'foo'; // 'string' typeof Symbol('bar'); // 'symbol' typeof 42n; // 'bigint' typeof true; // 'boolean' typeof undefined; // 'undefined' typeof null; // 'object' -   ,     typeof { x: 42 }; // 'object' 

كما ترى ، فإن الأمر typeof null يُرجع 'object' ، وليس 'null' ، على الرغم من حقيقة أن null له نوعه الخاص - Null . لفهم سبب هذا النوع من السلوك ، نأخذ في الاعتبار حقيقة أن مجموعة جميع أنواع JavaScript يمكن تقسيمها إلى مجموعتين:

  • كائنات (أي ، اكتب Object ).
  • القيم البدائية (أي ، أي قيم غير موضوعية).

في ضوء هذه المعرفة ، اتضح أن القيمة null "لا قيمة لها" ، في حين أن " undefined " يعني "لا قيمة".


القيم البدائية ، الكائنات ، لاغية وغير محددة

باتباع هذه الانعكاسات بروح Java ، قام Brendan Eich بتصميم JavaScript بحيث يقوم مشغل typeof بإرجاع 'object' لقيم تلك الأنواع الموجودة في الشكل السابق إلى اليمين. جميع القيم وجوه و null get هنا. هذا هو السبب في أن التعبير typeof null === 'object' صحيح ، على الرغم من وجود نوع منفصل Null في مواصفات اللغة.


التعبير typeof v === 'object' صحيح

تمثيل القيم


يجب أن تكون محركات JavaScript قادرة على تمثيل أي قيم JavaScript في الذاكرة. ومع ذلك ، من المهم ملاحظة أن أنواع القيم في JavaScript منفصلة عن الطريقة التي تمثل بها محركات JS في الذاكرة.

على سبيل المثال ، قيمة 42 في JavaScript هي من النوع type.

 typeof 42; // 'number' 

هناك عدة طرق لتمثيل أعداد صحيحة مثل 42 في الذاكرة:
فكرة
بت
8 بت ، بالإضافة إلى اثنين
0010 1010
32 بت ، مع إضافة ما يصل إلى اثنين
0000 0000 0000 0000 0000 0000 0010 1010
عشري مرمز ثنائي (BCD)
0100 0010
32 بت ، IEEE-754 رقم النقطة العائمة
0 100 0010 0010 1000 0000 0000 0000 0000
64 بت ، رقم النقطة العائمة IEEE-754
0100 0000 0100 0101 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000

وفقًا لمعيار ECMAScript ، فإن الأرقام هي قيم الفاصلة العائمة 64 بت ، والمعروفة باسم أرقام الفاصلة العائمة المزدوجة الدقة (Float64). ومع ذلك ، هذا لا يعني أن محركات JavaScript تقوم دائمًا بتخزين الأرقام في طريقة عرض Float64. سيكون ذلك غير فعال للغاية! يمكن للمحركات استخدام تمثيلات داخلية أخرى للأرقام - طالما يتطابق سلوك القيم تمامًا مع سلوك أرقام Float64.

معظم الأرقام في تطبيقات JS الحقيقية ، كما اتضح فيما بعد ، هي فهارس صفيف ECMAScript صالحة. هذا هو - أعداد صحيحة في النطاق من 0 إلى 2 32 -2.

 array[0]; //      . array[42]; array[2**32-2]; //      . 

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

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

 for (let i = 0; i < 1000; ++i) {  //  } for (let i = 0.1; i < 1000.1; ++i) {  //  } 

الأمر نفسه ينطبق على العمليات الحسابية باستخدام العوامل الرياضية.

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

 const remainder = value % divisor; //  -  `value`  `divisor`   , //    . 

إذا تم تمثيل كلتا المعاملتين بأعداد صحيحة ، فإن المعالج يمكن أن يحسب النتيجة بكفاءة عالية. يوجد تحسين إضافي في V8 للحالات التي يتم فيها تمثيل المعامل divisor بواسطة رقم يمثل قوة اثنين. بالنسبة للقيم الممثلة كأرقام الفاصلة العائمة ، تكون الحسابات أكثر تعقيدًا وتستغرق وقتًا أطول.

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

 //  Float64   53-  . //         . 2**53 === 2**53+1; // true // Float64   ,   -1 * 0   -0,  //           . -1*0 === -0; // true // Float64   Infinity,   , //     . 1/0 === Infinity; // true -1/0 === -Infinity; // true // Float64    NaN. 0/0 === NaN; 

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

في حالة الأعداد الصحيحة الصغيرة التي تقع في نطاق التمثيل 31 بت للأعداد الصحيحة الموقعة ، يستخدم V8 تمثيل خاص يسمى Smi . يتم تمثيل كل ما لا يمثل قيمة HeapObject كقيمة HeapObject ، وهو عنوان بعض الكيانات في الذاكرة. بالنسبة للأرقام التي لا تدخل في نطاق Smi ، لدينا نوع خاص من HeapObject - ما يسمى HeapNumber .

 -Infinity // HeapNumber -(2**30)-1 // HeapNumber  -(2**30) // Smi       -42 // Smi        -0 // HeapNumber         0 // Smi       4.2 // HeapNumber        42 // Smi   2**30-1 // Smi     2**30 // HeapNumber  Infinity // HeapNumber       NaN // HeapNumber 

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

مقارنة بين Smi و HeapNumber و MutableHeapNumber


دعنا نتحدث عن الشكل الداخلي لهذه الآليات. لنفترض أن لدينا الكائن التالي:

 const o = {  x: 42, // Smi  y: 4.2, // HeapNumber }; 

يتم تشفير القيمة 42 من خاصية الكائن x كـ Smi . هذا يعني أنه يمكن تخزينها داخل الكائن نفسه. لتخزين القيمة 4.2 ، من ناحية أخرى ، سوف تحتاج إلى إنشاء كيان منفصل. في الكائن ، سيكون هناك رابط لهذا الكيان.


تخزين القيم المختلفة

لنفترض أننا ننفذ الجزء التالي من شفرة JavaScript:

 ox += 10; // ox   52 oy += 1; // oy   5.2 

في هذه الحالة ، يمكن تحديث قيمة الخاصية x في موقع التخزين الخاص بها. الحقيقة هي أن القيمة الجديدة لـ x هي 52 ، وهذا الرقم يقع في نطاق Smi .


يتم تخزين القيمة الجديدة للخاصية x حيث تم تخزين القيمة السابقة.

ومع ذلك ، فإن القيمة الجديدة لـ y ، 5.2 ، لا تنسجم مع نطاق Smi ، وتختلف ، بالإضافة إلى ذلك ، عن القيمة السابقة لـ y - 4.2. نتيجةً لذلك ، يتعين على V8 تخصيص ذاكرة لكيان HeapNumber الجديد والإشارة إليها من الكائن بالفعل.


كيان جديد HeapNumber لتخزين قيمة y الجديدة

كيانات HeapNumber غير قابلة للتغيير. هذا يسمح لك بتنفيذ بعض التحسينات. افترض أننا نريد تعيين خاصية الكائن x قيمة الخاصية y :

 ox = oy; // ox   5.2 

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

أحد عيوب حصانة كيانات HeapNuber هو أن التحديث المتكرر للحقول ذات القيم خارج نطاق Smi يكون بطيئًا. هذا موضح في المثال التالي:

 //   `HeapNumber`. const o = { x: 0.1 }; for (let i = 0; i < 5; ++i) {  //    `HeapNumber`.  ox += 1; } 

عند معالجة السطر الأول ، يتم إنشاء مثيل HeapNumber ، تكون القيمة الأولية له هي 0.1. في نص الدورة ، تتغير هذه القيمة إلى 1.1 ، 2.1 ، 3.1 ، 4.1 ، وأخيراً إلى 5.1. نتيجة لذلك ، في عملية تنفيذ هذا الرمز ، HeapNumber 6 حالات من HeapNumber خمس منها لعمليات جمع القمامة بعد الانتهاء من الحلقة.


الكيانات كومة

لتجنب هذه المشكلة ، يتوفر V8 على التحسين ، وهي آلية لتحديث الحقول العددية التي لا تتناسب قيمها مع نطاق Smi في نفس الأماكن التي تم تخزينها فيها بالفعل. إذا قام حقل رقمي بتخزين القيم التي لا Smi كيان Smi مناسبًا للتخزين ، فإن V8 ، في شكل كائن ، يقوم MutableHeapNumber هذا الحقل على أنه Double ويخصص ذاكرة لكيان MutableHeapNumber ، الذي يخزن القيمة الحقيقية الممثلة بتنسيق Float64.


باستخدام MutableHeapNumber الكيانات

نتيجة لذلك ، بعد تغير قيمة الحقل ، لم تعد V8 بحاجة إلى تخصيص ذاكرة لكيان HeapNumber الجديد. بدلاً من ذلك ، فقط اكتب القيمة الجديدة في كيان MutableHeapNumber موجود.


كتابة قيمة جديدة إلى MutableHeapNumber

ومع ذلك ، فإن هذا النهج له عيوبه. أي ، بما أن قيم MutableHeapNumber يمكن أن تتغير ، فمن المهم التأكد من أن النظام يعمل بطريقة تتصرف بها هذه القيم على النحو المنصوص عليه في مواصفات اللغة.


عيوب MutableHeapNumber

على سبيل المثال ، إذا قمت بتعيين قيمة ox لبعض المتغيرات الأخرى y ، فأنت بحاجة إلى التأكد من أن قيمة y لا تتغير مع تغيير لاحق في ox . سيكون ذلك انتهاكًا لمواصفات JavaScript! نتيجة لذلك ، عند الوصول إلى ox ، يجب إعادة تحزيم الرقم إلى قيمة HeapNumber المعتادة قبل تعيين y .

في حالة أرقام الفاصلة العائمة ، تقوم V8 بعمليات التعبئة أعلاه باستخدام آلياتها الداخلية. ولكن في حالة الأعداد الصحيحة الصغيرة ، فإن استخدام MutableHeapNumber سيكون مضيعة للوقت لأن Smi طريقة أكثر فاعلية لتمثيل هذه الأرقام.

 const object = { x: 1 }; // ""  `x`    object.x += 1; //   `x`   

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


العمل مع الأعداد الصحيحة التي تقع قيمها في نطاق Smi

أن تستمر ...

أعزائي القراء! هل واجهت مشكلات في أداء جافا سكريبت بسبب ميزات محرك JS؟

Source: https://habr.com/ru/post/ar467247/


All Articles