Menggunakan pekerjaan forensik dan detektif untuk memecahkan misteri performa JavaScript

John McCutchan
John McCutchan

Pengantar

Dalam beberapa tahun terakhir, aplikasi web telah dipercepat secara signifikan. Banyak aplikasi sekarang berjalan cukup cepat sehingga saya mendengar beberapa developer bertanya-tanya "apakah web cukup cepat?". Untuk beberapa aplikasi, mungkin demikian, tetapi bagi developer yang mengerjakan aplikasi berperforma tinggi, kami tahu bahwa kecepatannya tidak cukup. Meskipun teknologi virtual machine JavaScript telah berkembang pesat, studi terbaru menunjukkan bahwa aplikasi Google menghabiskan antara 50% hingga 70% waktunya di dalam V8. Aplikasi Anda memiliki jumlah waktu yang terbatas, sehingga 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 - jank. Baca terus untuk mempelajari cara mengoptimalkan JavaScript dan membuat profil aplikasi JavaScript, dalam kisah dari medan pertempuran tentang 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. Lihat video di bawah:

Mengapa performa penting?

Siklus CPU adalah game dengan jumlah nol. Dengan membuat satu bagian sistem menggunakan lebih sedikit, Anda dapat menggunakan lebih banyak di bagian lain atau berjalan lebih lancar secara keseluruhan. Berjalan lebih cepat dan melakukan lebih banyak hal sering kali merupakan sasaran yang bersaing. Pengguna menuntut fitur baru sekaligus mengharapkan aplikasi Anda berjalan lebih lancar. Mesin virtual JavaScript terus menjadi lebih cepat, tetapi hal itu bukan alasan untuk mengabaikan masalah performa yang dapat Anda perbaiki sekarang, seperti yang sudah diketahui oleh banyak developer yang menangani masalah performa di aplikasi web mereka. Dalam aplikasi dengan kecepatan frame tinggi dan real-time, tekanan untuk bebas jank sangat penting. Insomniac Games membuat studi yang menunjukkan bahwa kecepatan frame yang solid dan berkelanjutan penting untuk kesuksesan game: "Kecepatan frame yang solid masih menjadi tanda produk profesional yang dibuat dengan baik." Developer web, catat.

Memecahkan Masalah Performa

Memecahkan masalah performa seperti memecahkan kasus kriminal. Anda perlu memeriksa bukti dengan cermat, memeriksa penyebab yang dicurigai, dan bereksperimen dengan berbagai solusi. Selama prosesnya, Anda harus mendokumentasikan pengukuran sehingga dapat memastikan bahwa Anda benar-benar telah memperbaiki masalah. Ada sedikit perbedaan antara metode ini dan cara detektif kriminal memecahkan kasus. Detektif memeriksa bukti, menginterogasi tersangka, dan menjalankan eksperimen dengan harapan menemukan bukti kuat.

V8 CSI: Oz

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

Linimasa devtools

Detektif V8, Jakob dan Yang, menangani kasus ini. Yang terjadi adalah diskusi panjang antara Jakob dan Yang dari tim V8 dan tim Oz. Saya telah meringkas percakapan ini menjadi peristiwa penting yang membantu melacak masalah ini.

Bukti

Langkah pertama adalah mengumpulkan dan mempelajari bukti awal.

Apa jenis aplikasi yang kita lihat?

Demo Oz adalah aplikasi 3D interaktif. Oleh karena itu, proses ini sangat sensitif terhadap jeda yang disebabkan oleh pembersihan sampah. Ingat, aplikasi interaktif yang berjalan pada 60 fps memiliki waktu 16 md untuk melakukan semua tugas JavaScript dan harus menyisakan sebagian 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.

Jenis masalah performa apa yang kita lihat?

Kita melihat jeda alias penurunan frame alias jank. Jeda ini berkorelasi dengan pengumpulan sampah yang berjalan.

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 dan menghasilkan kode JavaScript melalui compiler CoffeeScript. Hal ini membuat beberapa investigasi menjadi lebih rumit karena adanya perbedaan 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 pengumpulan sampah berjalan?

Memori di JavaScript dikelola secara otomatis untuk developer oleh VM. V8 menggunakan sistem pengumpulan sampah umum yang membagi memori menjadi dua (atau lebih) generasi. Generasi muda menyimpan objek yang baru saja dialokasikan. Jika objek bertahan cukup lama, objek tersebut akan dipindahkan ke generasi lama.

Generasi muda dikumpulkan dengan frekuensi yang jauh lebih tinggi daripada generasi lama. Hal ini memang disengaja, karena pengumpulan generasi muda jauh lebih murah. Sering kali aman untuk mengasumsikan bahwa jeda GC yang sering disebabkan oleh pengumpulan generasi muda.

Di V8, ruang memori baru dibagi menjadi dua blok memori yang berdekatan dengan ukuran yang sama. Hanya salah satu dari dua blok memori ini yang digunakan pada waktu tertentu dan disebut ruang tujuan. Meskipun ada memori yang tersisa di ruang tujuan, mengalokasikan objek baru tidak akan membebani memori. Kursor di ruang tujuan dipindahkan ke depan sesuai jumlah byte yang diperlukan untuk objek baru. Tindakan ini berlanjut hingga ruang ke habis. Pada tahap ini, program dihentikan dan pengumpulan dimulai.

Memori muda V8

Pada tahap ini, ruang dari dan ruang ke akan ditukar. Yang dulunya adalah ruang ke dan sekarang menjadi ruang dari, akan dipindai dari awal hingga akhir dan setiap objek yang masih aktif akan disalin ke ruang ke atau dipromosikan ke heap generasi lama. Jika Anda menginginkan detailnya, sebaiknya baca Algoritma Cheney.

Secara intuitif, Anda harus memahami bahwa setiap kali objek dialokasikan secara implisit atau eksplisit (melalui panggilan ke new, [], atau {}), aplikasi Anda semakin mendekati pengumpulan sampah dan jeda aplikasi yang mengerikan.

Apakah sampah 10 MB/dtk diharapkan untuk aplikasi ini?

Singkatnya, tidak. Developer tidak melakukan apa pun untuk mengharapkan sampah sebesar 10 MB/detik.

Tersangka

Fase investigasi berikutnya adalah menentukan calon tersangka, lalu mempersempitnya.

Tersangka #1

Memanggil baru selama frame. Ingat bahwa setiap objek yang dialokasikan akan semakin mendekatkan Anda ke jeda GC. Aplikasi yang berjalan pada kecepatan frame tinggi, khususnya, harus berusaha untuk tidak memiliki alokasi per frame. Biasanya, hal ini memerlukan sistem daur ulang objek khusus aplikasi yang dipikirkan dengan cermat. Detektif V8 memeriksa dengan tim Oz dan mereka tidak memanggil baru. Faktanya, tim Oz sudah mengetahui persyaratan ini dan mengatakan "Itu akan memalukan". Hapus item ini dari daftar.

Tersangka #2

Mengubah "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. Saat kode yang dioptimalkan melihat class tersembunyi baru ini, deopt akan dipicu, kode yang tidak dioptimalkan akan dijalankan hingga kode diklasifikasikan sebagai hot dan dioptimalkan lagi. Penghentian pengoptimalan, pengoptimalan ulang ini akan menyebabkan jank, tetapi tidak sepenuhnya berkorelasi dengan pembuatan sampah yang berlebihan. Setelah audit kode yang cermat, dikonfirmasi bahwa bentuk objek bersifat statis, sehingga tersangka #2 dikecualikan.

Tersangka #3

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

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

Menghasilkan 5 objek HeapNumber yang dibuat. Tiga yang pertama adalah untuk variabel, a, b, dan c. Yang ke-4 adalah untuk nilai anonim (a * b) dan yang ke-5 adalah dari #4 * c; Yang ke-5 pada akhirnya ditetapkan ke point.x.

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

Tersangka #4

Menyimpan bilangan presisi ganda ke properti. Objek HeapNumber harus dibuat untuk menyimpan angka dan properti yang diubah untuk mengarah ke objek baru ini. Mengubah properti agar mengarah ke HeapNumber tidak akan menghasilkan sampah. Namun, mungkin ada banyak angka presisi ganda yang disimpan sebagai properti objek. Kode ini 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, sehingga kita lebih dekat ke jeda pembersihan sampah.

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

Tersangka #4 adalah kemungkinan.

Forensik

Pada tahap ini, detektif memiliki dua kemungkinan tersangka: menyimpan angka heap sebagai properti objek dan komputasi aritmetika yang terjadi di dalam fungsi yang tidak dioptimalkan. Saatnya menuju lab dan menentukan dengan pasti tersangka mana yang bersalah. CATATAN: Di bagian ini, saya akan menggunakan reproduksi masalah yang ditemukan dalam kode sumber Oz yang sebenarnya. Reproduksi ini jauh lebih kecil daripada kode aslinya, sehingga lebih mudah dipahami.

Eksperimen #1

Memeriksa tersangka #3 (komputasi aritmetika di dalam fungsi yang tidak dioptimalkan). Mesin JavaScript V8 memiliki sistem logging bawaan yang dapat memberikan insight yang bagus tentang apa yang terjadi di balik layar.

Mulai dari Chrome yang tidak berjalan sama sekali, luncurkan Chrome dengan flag:

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

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

Untuk menafsirkan konten v8.log, Anda harus mendownload v8 versi yang sama dengan yang digunakan Chrome (periksa about:version), dan mem-build-nya.

Setelah berhasil mem-build 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 level teratas di v8.)

Pemroses tanda centang menampilkan tabel berbasis teks fungsi JavaScript yang memiliki tanda centang 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 fungsi opt dioptimalkan dan unopt tidak dioptimalkan.

Alat penting lainnya dalam kotak alat detektif V8 adalah plot-timer-event. Perintah ini dapat dijalankan seperti ini:

$ 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 tampilan seperti ini:

Peristiwa timer

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

Sumbu Y peristiwa timer

Baris V8.Execute memiliki garis vertikal hitam yang digambar di setiap tanda centang profil tempat V8 mengeksekusi kode JavaScript. V8.GCScavenger memiliki garis vertikal biru yang digambar di setiap tanda profil tempat V8 melakukan pengumpulan generasi baru. Demikian pula untuk status V8 lainnya.

Salah satu baris yang paling penting adalah "jenis kode yang dieksekusi". Warna ini akan menjadi hijau setiap kali kode yang dioptimalkan dieksekusi dan campuran merah dan biru saat kode yang tidak dioptimalkan sedang dieksekusi. Screenshot berikut menunjukkan transisi dari kode yang dioptimalkan menjadi tidak dioptimalkan, lalu kembali ke kode yang dioptimalkan:

Jenis kode yang sedang dieksekusi

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

Jika Anda telah melakukan hal ini, perlu diperhatikan bahwa Anda dapat bekerja jauh lebih cepat dengan memfaktorkan ulang aplikasi agar dapat berjalan di shell debug v8: d8. Menggunakan d8 memberi Anda waktu iterasi yang lebih cepat dengan alat tick-processor dan plot-timer-event. Efek samping lain dari penggunaan d8 adalah lebih mudah untuk mengisolasi masalah sebenarnya, sehingga mengurangi jumlah derau yang ada dalam data.

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

Plot peristiwa timer

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

Melihat output pemroses tick dari kode sumber Oz, fungsi teratas (updateSprites) tidak dioptimalkan. Dengan kata lain, fungsi tempat program menghabiskan sebagian besar waktu juga tidak dioptimalkan. Hal ini sangat menunjukkan bahwa tersangka #3 adalah pelakunya. 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.
    }
}

Karena sudah memahami V8 dengan baik, mereka langsung menyadari bahwa konstruksi loop for-i-in terkadang tidak dioptimalkan oleh V8. Dengan kata lain, jika fungsi berisi konstruksi loop for-i-in, fungsi tersebut mungkin tidak dioptimalkan. Ini adalah kasus khusus saat ini, dan kemungkinan akan berubah di masa mendatang, yaitu, V8 mungkin suatu hari akan mengoptimalkan konstruksi loop ini. Karena kita bukan detektif V8 dan tidak tahu V8 seperti telapak tangan kita, bagaimana kita dapat menentukan alasan updateSprites tidak dioptimalkan?

Eksperimen #2

Menjalankan Chrome dengan flag ini:

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

menampilkan log panjang data pengoptimalan dan de-pengoptimalan. Dengan menelusuri data untuk updateSprites, kita menemukan:

[disabled optimization for updateSprites, reason: ForInStatement is not fast case]

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

Kasus Ditutup

Setelah menemukan alasan updateSprites tidak dioptimalkan, perbaikannya cukup mudah, 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 menjadi lebih jarang. Anda dapat dengan mudah mengonfirmasinya dengan melakukan eksperimen yang sama dengan kode baru. Pembaca yang cermat akan melihat bahwa angka ganda masih disimpan sebagai properti. Jika pembuatan profil menunjukkan bahwa hal ini sepadan, mengubah posisi menjadi array ganda atau array data berjenis akan lebih mengurangi jumlah objek yang dibuat.

Epilog

Developer Oz tidak berhenti di situ. Dengan alat dan teknik yang dibagikan oleh detektif V8, mereka dapat menemukan beberapa fungsi lain yang terjebak dalam de-pengoptimalan dan memasukkan kode komputasi ke dalam fungsi leaf yang dioptimalkan, sehingga menghasilkan performa yang lebih baik.

Keluarlah dan mulai pecahkan beberapa kejahatan performa.