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 kini berjalan cukup cepat sehingga saya mendengar beberapa developer bertanya-tanya "apakah web sudah cukup cepat?". Untuk beberapa aplikasi, mungkin cukup, tetapi, bagi developer yang mengerjakan aplikasi berperforma tinggi, kami tahu bahwa ini tidak cukup cepat. Meskipun ada kemajuan luar biasa dalam teknologi virtual machine JavaScript, studi terbaru menunjukkan bahwa aplikasi Google menghabiskan antara 50% dan 70% waktunya di dalam V8. Aplikasi Anda memiliki jumlah 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 - jank. Baca terus untuk mempelajari cara mengoptimalkan JavaScript dan membuat profil aplikasi JavaScript, dalam kisah nyata dari 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:

Mengapa performa itu penting?

Siklus CPU adalah game zero-sum. Dengan membuat satu bagian sistem menggunakan lebih sedikit resource, Anda dapat menggunakan lebih banyak resource di bagian lain atau menjalankan sistem dengan lebih lancar secara keseluruhan. Berjalan lebih cepat dan melakukan lebih banyak hal sering kali merupakan sasaran yang saling bersaing. Pengguna menginginkan fitur baru sekaligus mengharapkan aplikasi Anda berjalan lebih lancar. Mesin virtual JavaScript terus menjadi lebih cepat, tetapi 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 real-time dengan kecepatan frame tinggi, tekanan untuk bebas dari jank sangatlah 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 yang profesional dan dibuat dengan baik." Developer web, perhatikan.

Menyelesaikan masalah performa

Memecahkan masalah performa seperti memecahkan kejahatan. Anda perlu memeriksa bukti dengan cermat, memeriksa penyebab yang dicurigai, dan bereksperimen dengan berbagai solusi. Selama proses ini, Anda harus mendokumentasikan pengukuran agar dapat memastikan bahwa Anda benar-benar telah memperbaiki masalahnya. Hampir tidak ada perbedaan antara metode ini dan cara detektif kriminal memecahkan kasus. Detektif memeriksa bukti, menginterogasi tersangka, dan menjalankan eksperimen dengan harapan menemukan bukti yang tak terbantahkan.

V8 CSI: Oz

Para developer hebat yang membangun Find Your Way to Oz menghubungi tim V8 terkait masalah performa yang tidak dapat mereka selesaikan sendiri. Terkadang Oz akan berhenti berfungsi, sehingga menyebabkan jank. Developer Oz telah melakukan beberapa penyelidikan awal menggunakan Panel Linimasa di Chrome DevTools. Saat melihat penggunaan memori, mereka menemukan grafik gigi gergaji yang menakutkan. Sekali per detik, pembersih sampah memori mengumpulkan 10 MB sampah dan jeda pembersihan sampah memori 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 serta tim Oz. Kami telah merangkum percakapan ini menjadi peristiwa penting yang membantu melacak masalah ini.

Bukti

Langkah pertama adalah mengumpulkan dan mempelajari bukti awal.

Jenis aplikasi apa yang sedang kita lihat?

Demo Oz adalah aplikasi 3D interaktif. Oleh karena itu, aplikasi ini 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 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.

Masalah performa apa yang kita lihat?

Kita melihat jeda alias penurunan frame alias jank. Jeda ini berkorelasi dengan operasi pembersihan sampah memori.

Apakah developer mengikuti praktik terbaik?

Ya, developer Oz sangat memahami performa VM JavaScript dan teknik pengoptimalan. Perlu diperhatikan bahwa developer Oz menggunakan CoffeeScript sebagai bahasa sumber dan menghasilkan kode JavaScript melalui compiler CoffeeScript. Hal ini membuat beberapa penyelidikan 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 proses ini.

Mengapa pembersih sampah memori berjalan?

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

Generasi muda dikumpulkan pada frekuensi yang jauh lebih tinggi daripada generasi tua. Hal ini memang disengaja, karena pembersihan sampah memori generasi muda jauh lebih murah. Sering kali aman untuk berasumsi bahwa jeda GC yang sering disebabkan oleh pengumpulan generasi muda.

Di V8, ruang memori muda 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 sebagai ruang tujuan. Selama masih ada memori yang tersisa di ruang tujuan, mengalokasikan objek baru tidak memerlukan banyak biaya. Kursor di ruang tujuan dipindahkan ke depan sejumlah byte yang diperlukan untuk objek baru. Proses ini berlanjut hingga ruang tujuan habis. Pada tahap ini, program dihentikan dan pengumpulan dimulai.

Memori muda V8

Pada tahap ini, ruang asal dan ruang tujuan ditukar. Ruang yang sebelumnya merupakan ruang tujuan dan sekarang menjadi ruang asal, dipindai dari awal hingga akhir dan objek apa pun yang masih aktif akan disalin ke ruang tujuan atau dipromosikan ke heap generasi lama. Jika Anda ingin mengetahui 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 akan semakin dekat dengan pembersihan sampah memori dan jeda aplikasi yang tidak diinginkan.

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

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

Tersangka

Fase investigasi berikutnya adalah menentukan tersangka potensial, lalu mempersempitnya.

Tersangka #1

Memanggil {i>new<i} selama {i>frame<i}. Ingat bahwa setiap objek yang dialokasikan akan makin mendekatkan Anda ke jeda GC. Aplikasi yang berjalan pada kecepatan frame tinggi khususnya harus mengupayakan nol alokasi per frame. Biasanya, hal ini memerlukan sistem daur ulang objek khusus aplikasi yang dipikirkan dengan cermat. Detektif V8 memeriksa tim Oz dan mereka tidak memanggil fungsi {i>new<i}. Bahkan, tim Oz sudah sangat menyadari persyaratan ini dan berkata, "Itu akan memalukan". Coret yang satu 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. Churn de-pengoptimalan dan pengoptimalan ulang ini akan menyebabkan jank,tetapi tidak berkorelasi secara ketat dengan pembuatan sampah yang berlebihan. Setelah mengaudit kode dengan cermat, dipastikan bahwa bentuk objek bersifat statis, sehingga tersangka #2 dikesampingkan.

Tersangka #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;

Menghasilkan 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 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 yang tidak dioptimalkan mengalokasikan memori bahkan untuk hasil sementara.

Tersangka #4

Menyimpan angka 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 makin dekat dengan jeda pembersihan sampah memori.

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

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 pergi ke lab dan menentukan secara 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 dengan Chrome yang tidak berjalan sama sekali, luncurkan Chrome dengan tanda:

--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 versi v8 yang sama dengan yang digunakan Chrome Anda (periksa about:version), dan mem-build-nya.

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

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

(Ganti mac atau windows untuk linux, bergantung pada platform Anda.) (Alat ini harus dijalankan dari direktori sumber level teratas di v8.)

Tick processor menampilkan tabel berbasis teks dari fungsi JavaScript 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 fungsi opt dioptimalkan dan unopt tidak dioptimalkan.

Alat penting lainnya dalam tas 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. Jika dibuka, Anda akan melihat tampilan seperti berikut:

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 profil tempat V8 mengeksekusi kode JavaScript. V8.GCScavenger memiliki garis vertikal biru yang digambar di setiap tick 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 hijau akan muncul setiap kali kode yang dioptimalkan dieksekusi, dan campuran warna merah dan biru akan muncul saat kode yang tidak dioptimalkan dieksekusi. Screenshot berikut menunjukkan transisi dari kode yang dioptimalkan ke kode yang tidak dioptimalkan, lalu kembali ke kode yang dioptimalkan:

Jenis kode yang dieksekusi

Idealnya, tetapi tidak pernah langsung, garis ini akan berwarna hijau tidak berkedip. Artinya, program Anda telah beralih 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 merefaktorisasi aplikasi sehingga 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 yang 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 bahwa waktu telah dihapus di tengah):

Plot peristiwa timer

Jika Anda perhatikan lebih dekat, Anda dapat melihat bahwa garis hitam yang menunjukkan kapan V8 mengeksekusi kode JavaScript tidak ada pada waktu tick profil yang sama persis dengan pembersihan sampah memori generasi baru (garis biru). Hal ini menunjukkan dengan jelas bahwa saat pembersihan sampah memori sedang berlangsung, skrip akan dijeda.

Melihat output pemroses tick dari kode sumber Oz, fungsi teratas (updateSprites) tidak dioptimalkan. Dengan kata lain, fungsi yang paling banyak menghabiskan waktu program juga tidak dioptimalkan. Hal ini sangat mengindikasikan 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 sangat memahami V8, 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 nanti akan mengoptimalkan konstruksi loop ini. Karena kita bukan detektif V8 dan tidak mengetahui V8 secara mendalam, 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. Saat menelusuri data untuk updateSprites, kita menemukan:

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

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

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 lebih jarang terjadi. Anda dapat mengonfirmasi hal ini dengan mudah dengan melakukan eksperimen yang sama menggunakan kode baru. Pembaca yang cermat akan melihat bahwa angka ganda masih disimpan sebagai properti. Jika pembuatan profil menunjukkan bahwa hal ini bermanfaat, mengubah posisi menjadi array ganda atau array data yang diketik akan semakin mengurangi jumlah objek yang dibuat.

Epilog

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

Keluarlah dan mulailah menyelesaikan beberapa kejahatan performa.