Menggunakan pekerjaan forensik dan detektif untuk memecahkan misteri performa JavaScript

John McCutchan
John McCutchan

Pengantar

Dalam beberapa tahun terakhir, aplikasi web telah berkembang pesat. Banyak aplikasi yang sekarang berjalan cukup cepat sehingga saya mendengar beberapa pengembang bertanya-tanya "apakah web cukup cepat?". Mungkin demikian untuk beberapa aplikasi, tetapi bagi developer yang mengerjakan aplikasi berperforma tinggi, kami tahu itu tidak cukup cepat. Terlepas dari kemajuan yang luar biasa dalam teknologi virtual machine JavaScript, studi terbaru menunjukkan bahwa aplikasi Google menghabiskan antara 50% dan 70% waktu mereka di dalam V8. Aplikasi Anda memiliki waktu yang terbatas, mengurangi siklus dari satu sistem berarti sistem lain dapat melakukan lebih banyak hal. Ingat, aplikasi yang berjalan pada 60 fps hanya memiliki 16 md per frame atau yang lainnya - jank. Lanjutkan membaca untuk mempelajari cara mengoptimalkan JavaScript dan aplikasi membuat profil, dari kisah terhebat para detektif performa di tim V8 yang melacak masalah performa yang tidak jelas di Find Your Way to Oz.

Sesi Google I/O 2013

Saya mempresentasikan materi ini di Google I/O 2013. Tonton video di bawah ini:

Mengapa performa itu penting?

Siklus CPU adalah permainan {i>zero sum<i}. Mengurangi penggunaan satu bagian sistem memungkinkan Anda menggunakan lebih banyak bagian di bagian lain atau berjalan lebih lancar secara keseluruhan. Berjalan lebih cepat dan melakukan lebih banyak hal sering kali merupakan tujuan yang bersaing. Pengguna menuntut fitur baru sekaligus berharap aplikasi Anda berjalan lebih lancar. Mesin virtual JavaScript terus bertambah cepat tetapi itu bukan alasan untuk mengabaikan masalah kinerja yang dapat Anda perbaiki saat ini, seperti yang sudah diketahui oleh banyak pengembang yang menangani masalah kinerja dalam aplikasi web mereka. Dalam kecepatan frame tinggi secara real-time, tekanan untuk menghindari jank adalah hal yang sangat penting pada aplikasi. Insomniac Games menghasilkan studi yang menunjukkan bahwa kecepatan frame yang solid dan berkelanjutan penting untuk keberhasilan game: "Kecepatan frame yang solid masih menjadi tanda produk yang profesional dan dibuat dengan baik." Developer web mencatatnya.

Menyelesaikan Masalah Performa

Menyelesaikan masalah kinerja sama seperti menyelesaikan kejahatan. Anda perlu memeriksa bukti dengan cermat, memeriksa dugaan penyebab, dan bereksperimen dengan berbagai solusi. Selama ini, Anda harus mendokumentasikan pengukuran sehingga Anda yakin telah benar-benar mengatasi masalahnya. Ada sedikit perbedaan antara metode ini dan cara detektif kriminal memecahkan kasus. Detektif memeriksa barang bukti, menginterogasi tersangka, dan menjalankan eksperimen untuk menemukan pistol merokok.

V8 CSI: Oz

Para penyihir luar biasa yang membangun Find Your Way to Oz mendekati tim V8 dengan masalah performa yang tidak dapat mereka pecahkan sendiri. Terkadang Oz akan berhenti, menyebabkan jank. Developer Oz telah melakukan beberapa investigasi awal menggunakan Panel Linimasa di Chrome DevTools. Saat mengamati penggunaan memori, mereka menemukan grafik gigi gergaji yang menakutkan. Sekali per detik, pembersih sampah memori mengumpulkan 10 MB sampah dan jeda pembersihan sampah memori terkait dengan jank. Mirip dengan screenshot dari Linimasa berikut di Chrome Devtools:

Linimasa Devtools

Detektif V8, Jakob dan Yang, yang mengangkat kasus ini. Apa yang terjadi adalah persilangan panjang antara Jakob dan Yang dari tim V8 dan tim Oz. Saya telah menyaring percakapan ini ke peristiwa penting yang membantu melacak masalah ini.

Bukti

Langkah pertama adalah mengumpulkan dan mempelajari bukti-bukti awal.

Jenis aplikasi apa yang kita lihat?

Demo Oz adalah aplikasi 3D interaktif. Oleh karena itu, sangat sensitif terhadap jeda yang disebabkan oleh pembersihan sampah memori. Ingat, aplikasi interaktif yang berjalan pada 60 fps memiliki waktu 16 md untuk melakukan semua pekerjaan JavaScript dan harus menyisakan beberapa waktu tersebut agar Chrome dapat memproses panggilan grafis dan menggambar layar.

Oz melakukan banyak komputasi aritmetika pada nilai ganda dan sering melakukan panggilan ke WebAudio dan WebGL.

Masalah performa seperti apa yang kami lihat?

Kami melihat jeda alias penurunan frame atau jank. Jeda ini berkorelasi dengan proses pembersihan sampah memori.

Apakah developer mengikuti praktik terbaik?

Ya, developer Oz sangat memahami performa dan teknik pengoptimalan VM JavaScript. Perlu diperhatikan bahwa developer Oz menggunakan CoffeeScript sebagai bahasa sumber mereka dan menghasilkan kode JavaScript melalui compiler CoffeeScript. Hal ini membuat beberapa penyelidikan menjadi lebih rumit karena terputusnya hubungan antara kode yang ditulis oleh developer Oz dan kode yang digunakan oleh V8. Chrome DevTools kini mendukung peta sumber yang akan mempermudah hal ini.

Mengapa pembersih sampah berjalan?

Memori di JavaScript otomatis dikelola untuk developer oleh VM. V8 menggunakan sistem pembersihan sampah memori umum dengan memori dibagi menjadi dua (atau lebih) generations. Generasi muda memegang objek yang baru saja dialokasikan. Jika sebuah objek dapat bertahan cukup lama, objek tersebut akan dipindahkan ke generasi lama.

Frekuensi pengumpulan gambar generasi muda jauh lebih tinggi daripada pengumpulan data generasi tua. Hal ini sesuai desain, karena koleksi generasi muda jauh lebih murah. Sering kali aman untuk mengasumsikan bahwa jeda GC yang sering disebabkan oleh pengumpulan gambar generasi muda.

Di V8, ruang memori muda dibagi menjadi dua blok memori yang berdekatan dan berukuran sama. Hanya satu dari dua blok memori ini yang digunakan pada waktu tertentu dan disebut {i>to space<i}. Meskipun ada sisa memori di dalam ruang, pengalokasian objek baru tidaklah mahal. Kursor di ruang ke digerakkan ke depan sesuai jumlah byte yang diperlukan untuk objek baru. Hal ini berlanjut hingga ruang penyimpanan habis. Pada tahap ini, program dihentikan dan pengumpulan dimulai.

Kenangan usia muda V8

Pada tahap ini, dari spasi dan ke ruang akan ditukar. Apa yang dulu menjadi ruang dan sekarang dari ruang angkasa, dipindai dari awal hingga akhir, dan objek apa pun yang masih hidup disalin ke ruang atau dipindahkan ke heap generasi lama. Jika ingin mengetahui detailnya, sebaiknya baca Algoritma Cheney.

Secara intuitif, Anda harus memahami bahwa setiap kali objek dialokasikan baik secara implisit maupun eksplisit (melalui panggilan ke yang baru, [], atau {}) aplikasi Anda akan semakin dekat dengan pembersihan sampah memori dan aplikasi yang ditakuti akan dijeda.

Apakah sampah sebesar 10 MB/dtk diharapkan untuk aplikasi ini?

Singkatnya, tidak. Developer tidak melakukan apa pun yang mengharapkan sampah sebesar 10 MB/dtk.

Dugaan

Fase penyelidikan berikutnya adalah menentukan calon tersangka dan kemudian menguranginya.

Terduga #1

Memanggil baru selama frame. Ingatlah bahwa setiap objek yang dialokasikan akan membuat Anda semakin dekat dengan jeda GC. Aplikasi yang berjalan pada kecepatan frame tinggi secara khusus harus berusaha untuk nol alokasi per frame. Biasanya hal ini memerlukan sistem daur ulang objek khusus yang dipikirkan secara cermat dan spesifik. Detektif V8 menghubungi tim Oz dan mereka tidak menghubungi lagi. Bahkan, tim Oz sudah sangat memahami persyaratan ini dan berkata "Itu akan memalukan". Hapus yang ini dari daftar.

Terduga #2

Memodifikasi "bentuk" objek di luar konstruktor. Hal ini terjadi setiap kali properti baru ditambahkan ke objek di luar konstruktor. Tindakan ini akan membuat class tersembunyi baru untuk objek tersebut. Saat kode yang dioptimalkan melihat class tersembunyi baru ini, deopt akan dipicu, kode yang tidak dioptimalkan akan dieksekusi hingga kode diklasifikasikan sebagai hot dan dioptimalkan lagi. Churn de-pengoptimalan dan pengoptimalan ulang ini akan mengakibatkan jank,tetapi tidak benar-benar berkorelasi dengan pembuatan sampah yang berlebihan. Setelah memeriksa kode dengan cermat, dipastikan bahwa bentuk objek bersifat statis, sehingga dugaan #2 dikesampingkan.

Terduga #3

Aritmetika dalam kode yang tidak dioptimalkan. Dalam kode yang tidak dioptimalkan, semua komputasi menghasilkan objek aktual yang dialokasikan. Misalnya, cuplikan ini:

var a = p * d;
var b = c + 3;
var c = 3.3 * dt;
point.x = a * b * c;

Hasil dalam 5 objek HeapNumber yang dibuat. Tiga yang pertama adalah untuk variabel, a, b, dan c. Yang ke-4 adalah untuk nilai anonim (a &ast; b) dan yang ke-5 adalah dari #4 &ast; c; Yang ke-5 akhirnya ditugaskan ke poin.x.

Oz melakukan ribuan operasi ini per frame. Jika salah satu komputasi ini terjadi dalam fungsi yang tidak pernah dioptimalkan, hal tersebut dapat menjadi penyebab sampah. Karena komputasi yang tidak dioptimalkan mengalokasikan memori bahkan untuk hasil sementara.

Terduga #4

Menyimpan angka presisi ganda ke properti. Objek HeapNumber harus dibuat untuk menyimpan nomor dan properti yang diubah agar mengarah pada objek baru ini. Mengubah properti agar menunjuk ke HeapNumber tidak akan menghasilkan sampah. Akan tetapi, mungkin saja ada banyak angka presisi ganda yang disimpan sebagai properti objek. Kode tersebut penuh dengan pernyataan seperti berikut:

sprite.position.x += 0.5 * (dt);

Dalam kode yang dioptimalkan, setiap kali x diberi nilai yang baru dihitung, pernyataan yang tampaknya tidak berbahaya, objek HeapNumber baru dialokasikan secara implisit, yang membawa kita lebih dekat ke jeda pembersihan sampah memori.

Perhatikan bahwa dengan menggunakan array berjenis (atau array reguler yang hanya memiliki nilai ganda), Anda dapat menghindari masalah khusus ini sepenuhnya karena penyimpanan untuk angka presisi ganda hanya dialokasikan sekali, dan berulang kali mengubah nilai tidak memerlukan penyimpanan baru untuk dialokasikan.

Dugaan no. 4 adalah sebuah kemungkinan.

Forensik

Pada tahap ini, detektif memiliki dua kemungkinan dugaan: menyimpan jumlah heap sebagai properti objek dan komputasi aritmetika terjadi di dalam fungsi yang tidak dioptimalkan. Sudah waktunya menuju lab dan menentukan dengan pasti tersangka mana yang bersalah. CATATAN: Di bagian ini, saya akan menggunakan reproduksi masalah yang ditemukan di kode sumber Oz yang sebenarnya. Reproduksi ini urutan yang besarnya lebih kecil dari kode asli, sehingga lebih mudah untuk dipikirkan.

Eksperimen #1

Memeriksa tersangka #3 (komputasi aritmetika di dalam fungsi yang tidak dioptimalkan). Mesin JavaScript V8 memiliki sistem {i>logging<i} bawaan yang dapat memberikan wawasan hebat tentang apa yang terjadi di balik layar.

Dimulai dengan Chrome yang tidak berjalan sama sekali, meluncurkan Chrome dengan tanda:

--no-sandbox --js-flags="--prof --noprof-lazy --log-timer-events"

dan kemudian sepenuhnya keluar dari Chrome akan menghasilkan file v8.log di direktori saat ini.

Untuk menafsirkan konten v8.log, Anda harus mendownload versi v8 yang sama dengan yang digunakan Chrome (periksa tentang:versi), dan membangunnya.

Setelah berhasil membangun v8, Anda dapat memproses log menggunakan prosesor tick:

$ tools/linux-tick-processor /path/to/v8.log

(Ganti mac atau windows dengan linux bergantung pada platform Anda.) (Alat ini harus dijalankan dari direktori sumber tingkat atas di v8.)

Pemroses tick menampilkan tabel fungsi JavaScript berbasis teks yang memiliki tick terbanyak:

[JavaScript]:
ticks  total  nonlib   name
167   61.2%   61.2%  LazyCompile: *opt demo.js:12
 40   14.7%   14.7%  LazyCompile: unopt demo.js:20
 15    5.5%    5.5%  Stub: KeyedLoadElementStub
 13    4.8%    4.8%  Stub: BinaryOpStub_MUL_Alloc_Number+Smi
  6    2.2%    2.2%  Stub: BinaryOpStub_ADD_OverwriteRight_Number+Number
  4    1.5%    1.5%  Stub: KeyedStoreElementStub
  4    1.5%    1.5%  KeyedLoadIC:  {12}
  2    0.7%    0.7%  KeyedStoreIC:  {13}
  1    0.4%    0.4%  LazyCompile: ~main demo.js:30

Anda dapat melihat demo.js memiliki tiga fungsi: opt, unopt, dan main. Fungsi yang dioptimalkan memiliki tanda bintang (*) di samping namanya. Perhatikan bahwa pengoptimalan fungsi dioptimalkan dan unopt tidak dioptimalkan.

Alat penting lainnya dalam {i>tool bag<i} detektif V8 adalah {i>plot-timer-event<i}. Hal ini dapat dieksekusi seperti berikut:

$ tools/plot-timer-event /path/to/v8.log

Setelah dijalankan, file png bernama timer-events.png akan berada di direktori saat ini. Saat membukanya, Anda akan melihat sesuatu yang terlihat seperti ini:

Peristiwa timer

Selain grafik di sepanjang bagian bawah, data ditampilkan dalam bentuk baris. Sumbu X adalah waktu (md). Sisi kiri menyertakan label untuk setiap baris:

Peristiwa timer sumbu Y

Baris V8.Execute memiliki garis vertikal hitam yang digambar di atasnya pada setiap centang profil di mana V8 mengeksekusi kode JavaScript. V8.GCScavenger memiliki garis vertikal biru yang digambar di atasnya pada setiap centang profil di mana V8 menjalankan koleksi generasi baru. Demikian pula untuk status V8 lainnya.

Salah satu baris terpenting adalah "jenis kode yang dieksekusi". Ikon ini akan berubah menjadi hijau setiap kali kode yang dioptimalkan dieksekusi, dan kombinasi warna merah dan biru akan berubah saat kode yang tidak dioptimalkan sedang dieksekusi. Screenshot berikut menunjukkan transisi dari dioptimalkan ke tidak dioptimalkan, lalu kembali ke kode yang dioptimalkan:

Jenis kode yang sedang dijalankan

Idealnya, tetapi tidak segera, garis ini akan berwarna hijau solid. Artinya program Anda telah beralih ke kondisi stabil yang dioptimalkan. Kode yang tidak dioptimalkan akan selalu berjalan lebih lambat daripada kode yang dioptimalkan.

Jika sudah mencapai durasi ini, perlu diperhatikan bahwa Anda bisa bekerja lebih cepat dengan memfaktorkan ulang aplikasi sehingga bisa berjalan di shell debug v8: d8. Penggunaan d8 memberi Anda waktu iterasi yang lebih cepat dengan alat tick-processor dan plot-timer-event. Efek samping lain dari penggunaan d8 adalah menjadi lebih mudah untuk mengisolasi masalah yang sebenarnya, sehingga mengurangi jumlah noise yang ada dalam data.

Dengan melihat plot peristiwa timer dari kode sumber Oz, menunjukkan transisi dari kode yang dioptimalkan ke kode yang tidak dioptimalkan, dan saat mengeksekusi kode yang tidak dioptimalkan, banyak koleksi generasi baru yang dipicu, mirip dengan screenshot berikut (catatan waktu telah dihapus di tengah):

Plot peristiwa timer

Jika Anda memperhatikan, Anda dapat melihat bahwa garis hitam yang menunjukkan ketika V8 mengeksekusi kode JavaScript hilang pada waktu tick profil yang persis sama dengan koleksi generasi baru (garis biru). Hal ini menunjukkan dengan jelas bahwa saat sampah sedang dikumpulkan, skrip dijeda.

Melihat output prosesor tick dari kode sumber Oz, fungsi atas (updateSprites) tidak dioptimalkan. Dengan kata lain, fungsi yang paling banyak digunakan oleh program juga tidak dioptimalkan. Hal ini dengan jelas menunjukkan bahwa tersangka #3 adalah penyebabnya. Sumber untuk updateSprites berisi loop yang terlihat seperti ini:

function updateSprites(dt) {
    for (var sprite in sprites) {
        sprite.position.x += 0.5 * dt;
        // 20 more lines of arithmetic computation.
    }
}

Mengetahui V8 sebaik mereka, mereka segera menyadari bahwa konstruksi loop for-i-in kadang-kadang tidak dioptimalkan oleh V8. Dengan kata lain, jika suatu fungsi berisi konstruksi loop for-i-in, fungsi tersebut mungkin tidak akan dioptimalkan. Ini adalah kasus khusus saat ini, dan kemungkinan akan berubah di masa mendatang, yaitu, V8 mungkin suatu hari nanti akan mengoptimalkan konstruksi loop ini. Karena kita bukan detektif V8 dan tidak mengenal V8 seperti bagian belakang tangan kita, bagaimana kita bisa menentukan mengapa updateSprites tidak dioptimalkan?

Eksperimen #2

Menjalankan Chrome dengan tanda ini:

--js-flags="--trace-deopt --trace-opt-verbose"

menampilkan log panjang pengoptimalan dan data depengoptimalan. Saat menelusuri data untuk updateSprites, kami menemukan:

[pengoptimalan yang dinonaktifkan untuk updateSprites, reason: ForInStatement bukanlah kasus cepat]

Sama seperti yang dihipotesiskan oleh detektif, konstruksi loop for-i-in adalah alasannya.

Kasus Ditutup

Setelah menemukan alasan updateSprites tidak dioptimalkan, perbaikannya sederhana, cukup pindahkan komputasi ke fungsinya sendiri, yaitu:

function updateSprite(sprite, dt) {
    sprite.position.x += 0.5 * dt;
    // 20 more lines of arithmetic computation.
}

function updateSprites(dt) {
    for (var sprite in sprites) {
        updateSprite(sprite, dt);
    }
}

updateSprite akan dioptimalkan, sehingga menghasilkan objek HeapNumber yang jauh lebih sedikit, sehingga jeda GC yang lebih jarang terjadi. Anda seharusnya dapat mengonfirmasi hal ini dengan mudah dengan melakukan eksperimen yang sama dengan kode baru. Pembaca yang cermat akan melihat bahwa angka ganda masih disimpan sebagai properti. Jika pembuatan profil menunjukkan manfaatnya, mengubah posisi menjadi array ganda atau array data yang diketik akan mengurangi jumlah objek yang dibuat.

Epilog

Pengembang Oz tidak berhenti di situ. Berbekal alat dan teknik yang dibagikan kepada mereka oleh detektif V8, mereka dapat menemukan beberapa fungsi lain yang terjebak dalam depengoptimalan neraka dan memperhitungkan kode komputasi ke dalam fungsi daun yang dioptimalkan, sehingga menghasilkan kinerja yang lebih baik.

Keluarlah dan mulailah menyelesaikan beberapa kejahatan kinerja!