Sebuah kisah tentang V8, React dan penurunan kinerja. Bagian 1

Materi, bagian pertama dari terjemahan yang kami terbitkan hari ini, akan membahas bagaimana mesin JavaScript V8 memilih cara terbaik untuk mewakili berbagai nilai JS dalam memori, dan bagaimana hal ini memengaruhi mekanisme internal V8 terkait bekerja dengan apa yang disebut formulir benda (Bentuk). Semua ini akan membantu kami memilah esensi masalah kinerja Bereaksi baru-baru ini.



Tipe data JavaScript


Setiap nilai JavaScript hanya dapat memiliki satu dari delapan tipe data yang ada: Number , String , Symbol , BigInt , Boolean , Undefined , Null dan Object .


Tipe data JavaScript

Jenis nilai dapat ditentukan menggunakan operator typeof , tetapi ada satu pengecualian penting:

 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' 

Seperti yang Anda lihat, perintah typeof null mengembalikan 'object' , bukan 'null' , meskipun fakta bahwa null memiliki tipe sendiri - Null . Untuk memahami alasan typeof perilaku ini, kami memperhitungkan fakta bahwa rangkaian semua tipe JavaScript dapat dibagi menjadi dua grup:

  • Objek (mis., Ketik Object ).
  • Nilai-nilai primitif (yaitu, nilai-nilai non-objektif).

Dalam terang pengetahuan ini, ternyata null berarti "tidak ada nilai objek", sedangkan undefined berarti "tidak ada nilai".


Nilai-nilai primitif, objek, null dan tidak terdefinisi

Mengikuti refleksi-refleksi ini dalam semangat Java, Brendan Eich mendesain JavaScript sehingga typeof operator akan mengembalikan 'object' untuk nilai-nilai tipe-tipe yang terletak pada gambar sebelumnya di sebelah kanan. Semua nilai objek dan null sampai di sini. Itulah sebabnya ekspresi typeof null === 'object' benar, walaupun ada tipe terpisah Null dalam spesifikasi bahasa.


Ekspresi typeof v === 'objek' benar

Representasi Nilai


Mesin JavaScript harus dapat mewakili nilai JavaScript apa pun dalam memori. Namun, penting untuk dicatat bahwa tipe nilai dalam JavaScript terpisah dari bagaimana mesin JS merepresentasikannya dalam memori.

Misalnya, nilai 42 dalam JavaScript adalah number jenis.

 typeof 42; // 'number' 

Ada beberapa cara untuk merepresentasikan bilangan bulat seperti 42 di memori:
Kiriman
Bits
8 bit, selain dua
0010 1010
32 bit, dengan penambahan hingga dua
0000 0000 0000 0000 0000 0010 1010
Packed binary-coded decimal (BCD)
0100 0010
32 bit, nomor floating point IEEE-754
0 100 0010 0010 1000 0000 0000 0000 0000
64 bit, nomor floating point IEEE-754
0100 0000 0100 0101 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000

Menurut standar ECMAScript, angka adalah nilai floating point 64-bit, yang dikenal sebagai angka floating-point presisi ganda (Float64). Namun, ini tidak berarti bahwa mesin JavaScript selalu menyimpan angka dalam tampilan Float64. Itu akan sangat, sangat tidak efisien! Engine dapat menggunakan representasi internal lainnya dari angka - selama perilaku nilai tersebut sama persis dengan bagaimana perilaku angka Float64.

Sebagian besar angka dalam aplikasi JS nyata, ternyata, adalah indeks array ECMAScript yang valid. Yaitu - bilangan bulat dalam rentang dari 0 hingga 2 32 -2.

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

Mesin JavaScript dapat memilih format optimal untuk mewakili nilai-nilai tersebut dalam memori. Ini dilakukan untuk mengoptimalkan kode yang berfungsi dengan elemen array menggunakan indeks. Prosesor yang melakukan operasi akses memori memerlukan indeks array tersedia sebagai angka yang disimpan dalam tampilan dengan tambahan dua . Jika sebaliknya kami mewakili indeks array dalam bentuk nilai Float64, ini akan berarti pemborosan sumber daya sistem, karena mesin kemudian perlu mengkonversi angka Float64 ke format dengan penambahan dua dan sebaliknya setiap kali seseorang mengakses elemen array.

Representasi angka 32-bit dengan penambahan hingga dua bermanfaat tidak hanya untuk mengoptimalkan kerja dengan array. Secara umum, dapat dicatat bahwa prosesor melakukan operasi integer jauh lebih cepat daripada operasi yang menggunakan nilai floating point. Itulah sebabnya dalam contoh berikut, siklus pertama tanpa masalah dua kali lebih cepat dibandingkan dengan siklus kedua.

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

Hal yang sama berlaku untuk perhitungan menggunakan operator matematika.

Misalnya, kinerja operator untuk mengambil sisa pembagian dari fragmen kode berikutnya tergantung pada angka apa yang terlibat dalam perhitungan.

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

Jika kedua operan diwakili oleh bilangan bulat, maka prosesor dapat menghitung hasilnya dengan sangat efisien. Ada optimasi tambahan dalam V8 untuk kasus di mana operan divisor diwakili oleh angka yang merupakan kekuatan dua. Untuk nilai yang direpresentasikan sebagai angka floating point, perhitungannya jauh lebih rumit dan membutuhkan waktu lebih lama.

Karena operasi integer biasanya dilakukan jauh lebih cepat daripada operasi pada nilai floating-point, mungkin terlihat bahwa engine dapat selalu menyimpan semua integer dan semua hasil operasi integer dalam format dengan tambahan dua. Sayangnya, pendekatan seperti itu akan melanggar spesifikasi naskah ECMAS. Seperti yang telah disebutkan, standar menyediakan representasi angka dalam format Float64, dan beberapa operasi dengan bilangan bulat dapat menyebabkan tampilan hasil dalam bentuk angka floating-point. Adalah penting bahwa dalam situasi seperti itu, mesin JS menghasilkan hasil yang benar.

 //  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; 

Meskipun dalam contoh sebelumnya semua angka di sisi kiri ekspresi adalah bilangan bulat, semua angka di sisi kanan ekspresi adalah nilai floating point. Itulah sebabnya mengapa tidak ada operasi sebelumnya yang dapat dilakukan dengan benar menggunakan format 32-bit dengan tambahan hingga dua. Mesin JavaScript harus memberi perhatian khusus untuk memastikan bahwa ketika melakukan operasi integer Anda mendapatkan yang benar (meskipun mampu terlihat tidak biasa - seperti dalam contoh sebelumnya) hasil Float64.

Dalam kasus bilangan bulat kecil yang berada dalam kisaran representasi 31-bit bilangan bulat yang ditandatangani, V8 menggunakan representasi khusus yang disebut Smi . Segala sesuatu yang bukan nilai Smi direpresentasikan sebagai nilai HeapObject , yang merupakan alamat beberapa entitas dalam memori. Untuk nomor yang tidak termasuk dalam rentang Smi , kami memiliki jenis HeapObject - yang disebut 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 

Seperti yang Anda lihat dari contoh sebelumnya, beberapa nomor JS direpresentasikan sebagai Smi , dan beberapa sebagai HeapNumber . Mesin V8 dioptimalkan dalam hal memproses angka Smi . Faktanya adalah integer kecil sangat umum dalam program JS nyata. Ketika bekerja dengan nilai-nilai Smi , tidak perlu mengalokasikan memori untuk masing-masing entitas. Selain itu, penggunaannya memungkinkan Anda untuk melakukan operasi cepat dengan bilangan bulat.

Perbandingan Smi, HeapNumber dan MutableHeapNumber


Mari kita bicara tentang seperti apa struktur internal dari mekanisme ini. Misalkan kita memiliki objek berikut:

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

Nilai 42 dari properti objek x dikodekan sebagai Smi . Ini berarti dapat disimpan di dalam objek itu sendiri. Untuk menyimpan nilai 4.2, di sisi lain, Anda harus membuat entitas terpisah. Di objek, akan ada tautan ke entitas ini.


Penyimpanan berbagai nilai

Misalkan kita menjalankan kode JavaScript berikut:

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

Dalam hal ini, nilai properti x dapat diperbarui di lokasi penyimpanannya. Faktanya adalah bahwa nilai baru x adalah 52, dan angka ini berada dalam kisaran Smi .


Nilai baru properti x disimpan di tempat nilai sebelumnya disimpan.

Namun, nilai baru y , 5.2, tidak masuk ke dalam kisaran Smi , dan itu, di samping itu, berbeda dari nilai sebelumnya dari y - 4.2. Akibatnya, V8 harus mengalokasikan memori untuk entitas HeapNumber baru dan referensi itu dari objek yang sudah ada.


Entitas baru HeapNumber untuk menyimpan nilai y baru

Entitas HeapNumber tidak dapat diubah. Ini memungkinkan Anda untuk mengimplementasikan beberapa optimasi. Misalkan kita ingin mengatur properti objek x nilai properti y :

 ox = oy; // ox   5.2 

Saat melakukan operasi ini, kita bisa merujuk ke entitas HeapNumber sama, dan tidak mengalokasikan memori tambahan untuk menyimpan nilai yang sama.

Salah satu kelemahan imunitas entitas HeapNuber adalah bahwa pemutakhiran bidang yang sering dengan nilai di luar rentang Smi lambat. Ini ditunjukkan dalam contoh berikut:

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

Saat memproses baris pertama, sebuah instance dari HeapNumber dibuat, nilai awalnya adalah 0,1. Dalam inti siklus, nilai ini berubah menjadi 1.1, 2.1, 3.1, 4.1, dan akhirnya ke 5.1. Akibatnya, dalam proses mengeksekusi kode ini, 6 contoh HeapNumber , lima di antaranya akan dikenai operasi pengumpulan sampah setelah selesainya loop.


Entitas Banyak

Untuk menghindari masalah ini, V8 memiliki optimasi, yang merupakan mekanisme untuk memperbarui bidang numerik yang nilainya tidak sesuai dengan rentang Smi di tempat yang sama di mana mereka sudah disimpan. Jika bidang numerik menyimpan nilai yang entitas Smi tidak cocok untuk penyimpanan, maka V8, dalam bentuk objek, menandai bidang ini sebagai Double dan mengalokasikan memori untuk entitas MutableHeapNumber , yang menyimpan nilai nyata yang diwakili dalam format Float64.


Menggunakan Entitas MutableHeapNumber

Akibatnya, setelah nilai bidang berubah, V8 tidak perlu lagi mengalokasikan memori untuk entitas HeapNumber baru. Alih-alih, cukup tulis nilai baru ke entitas MutableHeapNumber ada.


Menulis nilai baru ke MutableHeapNumber

Namun, pendekatan ini memiliki kekurangan. Yaitu, karena nilai MutableHeapNumber dapat berubah, penting untuk memastikan bahwa sistem bekerja sedemikian rupa sehingga nilai-nilai ini berperilaku seperti yang disediakan dalam spesifikasi bahasa.


Kerugian MutableHeapNumber

Misalnya, jika Anda menetapkan nilai ox beberapa variabel lain y , maka Anda perlu memastikan bahwa nilai y tidak berubah dengan perubahan berikutnya pada ox . Itu akan menjadi pelanggaran terhadap spesifikasi JavaScript! Akibatnya, ketika mengakses ox , angka tersebut harus HeapNumber ulang ke nilai HeapNumber biasa sebelum diberikan y .

Dalam hal angka floating-point, V8 melakukan operasi pengemasan di atas menggunakan mekanisme internal. Tetapi dalam kasus bilangan bulat kecil, menggunakan MutableHeapNumber akan membuang-buang waktu karena Smi adalah cara yang lebih efisien untuk mewakili angka-angka tersebut.

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

Untuk menghindari penggunaan sumber daya sistem yang tidak efisien, yang perlu kita lakukan untuk bekerja dengan bilangan bulat kecil adalah menandai bidang terkait dalam bentuk objek sebagai Smi . Akibatnya, nilai-nilai bidang ini, asalkan sesuai dengan rentang Smi , dapat diperbarui langsung di dalam objek.


Bekerja dengan bilangan bulat yang nilainya berada dalam kisaran Smi

Dilanjutkan ...

Pembaca yang budiman! Pernahkah Anda mengalami masalah kinerja JavaScript yang disebabkan oleh fitur mesin JS?

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


All Articles