Cara kami mempercepat pelacakan tumpukan Chrome DevTools sebesar 10x

Benedikt Meurer
Benedikt Meurer

Developer web sudah terbiasa dengan dampak performa yang kecil atau tidak ada sama sekali saat men-debug kode mereka. Namun, ekspektasi ini tidak berlaku universal. Developer C++ tidak akan pernah mengharapkan build debug aplikasi mereka untuk mencapai performa produksi, dan pada tahun-tahun awal Chrome, hanya membuka DevTools secara signifikan memengaruhi performa halaman.

Fakta bahwa degradasi performa ini tidak lagi dirasakan adalah hasil dari investasi selama bertahun-tahun dalam kemampuan proses debug DevTools dan V8. Namun, kita tidak akan pernah dapat mengurangi overhead performa DevTools menjadi nol. Menetapkan titik henti sementara, menelusuri kode, mengumpulkan pelacakan tumpukan, merekam rekaman aktivitas performa, dll. semuanya memengaruhi kecepatan eksekusi dalam tingkat yang bervariasi. Lagi pula, mengamati sesuatu akan mengubahnya.

Namun, tentu saja overhead DevTools - seperti debugger lainnya - harus wajar. Baru-baru ini, kami melihat peningkatan jumlah laporan yang signifikan bahwa dalam kasus tertentu, DevTools akan memperlambat aplikasi hingga tidak dapat digunakan lagi. Di bawah ini, Anda dapat melihat perbandingan berdampingan dari laporan chromium:1069425, yang menggambarkan overhead performa hanya dengan membuka DevTools.

Seperti yang dapat Anda lihat dari video, perlambatan terjadi sekitar 5-10x, yang jelas tidak dapat diterima. Langkah pertama adalah memahami apa yang menyebabkan penurunan performa yang signifikan ini saat DevTools terbuka. Penggunaan Linux perf pada proses Perender Chrome mengungkapkan distribusi waktu eksekusi perender secara keseluruhan berikut:

Waktu eksekusi Chrome Renderer

Meskipun sebelumnya kami memperkirakan akan melihat sesuatu yang terkait dengan pengumpulan stack trace, kami tidak menyangka bahwa sekitar 90% dari keseluruhan waktu eksekusi akan menjadi simbol frame stack. Simbolisasi di sini mengacu pada tindakan me-resolve nama fungsi dan posisi sumber konkret - nomor baris dan kolom dalam skrip - dari frame stack mentah.

Inferensi nama metode

Yang lebih mengejutkan adalah fakta bahwa hampir sepanjang waktu, fungsi JSStackFrame::GetMethodName() di V8 digunakan - meskipun kita tahu dari penyelidikan sebelumnya bahwa JSStackFrame::GetMethodName() bukanlah hal yang asing dalam masalah performa. Fungsi ini mencoba menghitung nama metode untuk frame yang dianggap sebagai pemanggilan metode (frame yang mewakili pemanggilan fungsi dalam bentuk obj.func(), bukan func()). Sekilas melihat kode mengungkapkan bahwa kode tersebut berfungsi dengan melakukan traversal penuh objek dan rantai prototipenya serta mencari

  1. properti data yang value-nya adalah penutupan func, atau
  2. properti pengakses dengan get atau set sama dengan penutupan func.

Meskipun hal ini tidak terdengar murah, hal ini juga tidak terdengar seperti yang akan menjelaskan perlambatan yang mengerikan ini. Jadi, kami mulai mempelajari contoh yang dilaporkan di chromium:1069425, dan kami menemukan bahwa pelacakan tumpukan dikumpulkan untuk tugas asinkron serta untuk pesan log yang berasal dari classes.js - file JavaScript 10 MiB. Jika dilihat lebih lanjut, akan terlihat bahwa pada dasarnya ini adalah runtime Java plus kode aplikasi yang dikompilasi ke JavaScript. Stack trace berisi beberapa frame dengan metode yang dipanggil pada objek A sehingga kami berpikir bahwa sebaiknya kita memahami jenis objek yang kita tangani.

pelacakan tumpukan objek

Tampaknya compiler Java ke JavaScript menghasilkan satu objek dengan 82.203 fungsi yang luar biasa - ini jelas mulai menjadi menarik. Selanjutnya, kita kembali ke JSStackFrame::GetMethodName() V8 untuk memahami apakah ada buah mudah yang bisa kita pilih di sana.

  1. Fungsi ini berfungsi dengan terlebih dahulu mencari "name" fungsi sebagai properti pada objek dan jika ditemukan, memeriksa apakah nilai properti cocok dengan fungsi.
  2. Jika fungsi tidak memiliki nama atau objek tidak memiliki properti yang cocok, fungsi tersebut akan melakukan pencarian terbalik dengan menelusuri semua properti objek dan prototipenya.

Dalam contoh kita, semua fungsi bersifat anonim dan memiliki properti "name" kosong.

A.SDV = function() {
   // ...
};

Temuan pertama adalah bahwa pencarian balik dibagi menjadi dua langkah (dilakukan untuk objek itu sendiri dan setiap objek dalam rantai prototipenya):

  1. Ekstrak nama semua properti yang dapat dienumerasi, dan
  2. Lakukan pencarian properti umum untuk setiap nama, dengan menguji apakah nilai properti yang dihasilkan cocok dengan penutupan yang kita cari.

Hal ini tampak seperti buah yang cukup mudah didapat, karena mengekstrak nama memerlukan pemeriksaan semua properti. Alih-alih melakukan dua {i>pass - O(N) untuk ekstraksi nama dan O(N log(N)) untuk pengujian - kita dapat melakukan semuanya dalam satu penerusan dan langsung memeriksa nilai propertinya. Hal itu membuat seluruh fungsi sekitar 2-10x lebih cepat.

Temuan kedua bahkan lebih menarik. Meskipun secara teknis fungsi tersebut adalah fungsi anonim, mesin V8 tetap mencatat apa yang kita sebut nama yang disimpulkan untuk fungsi tersebut. Untuk literal fungsi yang muncul di sebelah kanan penetapan dalam bentuk obj.foo = function() {...}, parser V8 akan mengingat "obj.foo" sebagai nama yang disimpulkan untuk literal fungsi. Jadi, dalam kasus ini, artinya, meskipun tidak memiliki nama yang tepat yang bisa dicari, kita memang memiliki sesuatu yang cukup mirip: Untuk contoh A.SDV = function() {...} di atas, kita menggunakan "A.SDV" sebagai nama yang disimpulkan, dan kita dapat memperoleh nama properti dari nama yang disimpulkan dengan mencari titik terakhir, lalu mencari properti "SDV" pada objek. Hal ini berhasil dilakukan dalam hampir semua kasus, dengan mengganti traversal penuh yang mahal dengan pencarian properti tunggal. Kedua peningkatan ini diluncurkan sebagai bagian dari CL ini, dan secara signifikan mengurangi pelambatan untuk contoh yang dilaporkan di chromium:1069425.

Error.stack

Kita bisa berhenti di sini. Namun, ada sesuatu yang aneh, karena DevTools tidak pernah menggunakan nama metode untuk frame stack. Bahkan, class v8::StackFrame di C++ API bahkan tidak mengekspos cara untuk mendapatkan nama metode. Jadi agak salah jika kita pada akhirnya memanggil JSStackFrame::GetMethodName(). Sebagai gantinya, satu-satunya tempat kami menggunakan (dan mengekspos) nama metode adalah dalam API stack trace JavaScript. Untuk memahami penggunaan ini, pertimbangkan contoh sederhana error-methodname.js berikut:

function foo() {
    console.log((new Error).stack);
}

var object = {bar: foo};
object.bar();

Di sini kita memiliki fungsi foo yang diinstal dengan nama "bar" di object. Menjalankan cuplikan ini di Chromium akan menghasilkan output berikut:

Error
    at Object.foo [as bar] (error-methodname.js:2)
    at error-methodname.js:6

Di sini kita melihat pencarian nama metode yang sedang digunakan: Frame stack paling atas ditampilkan untuk memanggil fungsi foo pada instance Object melalui metode bernama bar. Jadi, properti error.stack non-standar banyak menggunakan JSStackFrame::GetMethodName() dan sebenarnya pengujian performa kami juga menunjukkan bahwa perubahan kami membuat semuanya jauh lebih cepat.

Mempercepat benchmark mikro StackTrace

Namun, kembali ke topik Chrome DevTools, fakta bahwa nama metode dihitung meskipun error.stack tidak digunakan bukanlah hal yang benar. Ada beberapa sejarah di sini yang membantu kita: Secara tradisional, V8 memiliki dua mekanisme berbeda yang diterapkan untuk mengumpulkan dan merepresentasikan pelacakan tumpukan untuk dua API berbeda yang dijelaskan di atas (v8::StackFrame API C++ dan API pelacakan tumpukan JavaScript). Menggunakan dua cara berbeda untuk melakukan (kira-kira) hal yang sama rentan terhadap error dan sering menyebabkan inkonsistensi dan bug, jadi pada akhir tahun 2018 kami memulai project untuk menyelesaikan satu bottleneck untuk perekaman stack trace.

Project tersebut sangat sukses dan secara drastis mengurangi jumlah masalah yang terkait dengan pengumpulan pelacakan tumpukan. Sebagian besar informasi yang diberikan melalui properti error.stack non-standar juga telah dikomputasi secara lambat dan hanya jika benar-benar diperlukan, tetapi sebagai bagian dari pemfaktoran ulang, kami menerapkan trik yang sama ke objek v8::StackFrame. Semua informasi tentang frame stack dihitung saat pertama kali metode dipanggil padanya.

Hal ini umumnya meningkatkan performa, tetapi sayangnya ternyata hal ini agak bertentangan dengan cara objek API C++ ini digunakan di Chromium dan DevTools. Secara khusus, karena kami telah memperkenalkan class v8::internal::StackFrameInfo baru, yang menyimpan semua informasi tentang frame stack yang diekspos melalui v8::StackFrame atau melalui error.stack, kami akan selalu menghitung superset informasi yang disediakan oleh kedua API, yang berarti bahwa untuk penggunaan v8::StackFrame (dan khususnya untuk DevTools), kami juga akan menghitung nama metode, segera setelah informasi tentang frame stack diminta. Ternyata, DevTools selalu langsung meminta informasi sumber dan skrip.

Berdasarkan realisasi tersebut, kami dapat memfaktorkan ulang dan menyederhanakan representasi frame stack secara drastis serta membuatnya lebih lambat, sehingga penggunaan di seluruh V8 dan Chromium kini hanya membayar biaya untuk menghitung informasi yang mereka minta. Hal ini memberikan peningkatan performa yang besar untuk DevTools dan kasus penggunaan Chromium lainnya, yang hanya memerlukan sebagian kecil informasi tentang frame stack (pada dasarnya hanya nama skrip dan lokasi sumber dalam bentuk offset baris dan kolom), dan membuka peluang untuk peningkatan performa lainnya.

Nama fungsi

Dengan pemfaktoran ulang yang disebutkan di atas, overhead simbolisasi (waktu yang dihabiskan di v8_inspector::V8Debugger::symbolize) dikurangi menjadi sekitar 15% dari keseluruhan waktu eksekusi, dan kita dapat melihat dengan lebih jelas tempat V8 menghabiskan waktu saat (mengumpulkan dan) menandai frame stack untuk digunakan di DevTools.

Biaya simbolisasi

Hal pertama yang menonjol adalah biaya kumulatif untuk nomor baris dan kolom komputasi. Bagian yang mahal di sini sebenarnya adalah menghitung offset karakter dalam skrip (berdasarkan offset bytecode yang kita dapatkan dari V8), dan ternyata karena pemfaktoran ulang di atas, kita melakukannya dua kali, sekali saat menghitung nomor baris dan satu lagi saat menghitung nomor kolom. Menyimpan posisi sumber dalam cache pada instance v8::internal::StackFrameInfo membantu menyelesaikan masalah ini dengan cepat dan sepenuhnya menghilangkan v8::internal::StackFrameInfo::GetColumnNumber dari profil mana pun.

Temuan yang lebih menarik bagi kami adalah v8::StackFrame::GetFunctionName ternyata tinggi di semua profil yang kami lihat. Setelah menggali lebih dalam di sini, kami menyadari bahwa menghitung nama yang akan ditampilkan untuk fungsi dalam bingkai stack di DevTools menjadi lebih mahal,

  1. pertama-tama mencari properti "displayName" non-standar dan jika properti tersebut menghasilkan properti data dengan nilai string, kita akan menggunakannya,
  2. jika tidak, kembali ke pencarian properti "name" standar dan sekali lagi memeriksa apakah metode tersebut menghasilkan properti data yang nilainya adalah string,
  3. dan akhirnya kembali ke nama debug internal yang disimpulkan oleh parser V8 dan disimpan di literal fungsi.

Properti "displayName" ditambahkan sebagai solusi untuk properti "name" pada instance Function yang hanya dapat dibaca dan tidak dapat dikonfigurasi di JavaScript, tetapi tidak pernah distandarisasi dan tidak digunakan secara luas, karena alat developer browser menambahkan inferensi nama fungsi yang melakukan tugas dalam 99,9% kasus. Selain itu, ES2015 membuat properti "name" pada instance Function dapat dikonfigurasi, sehingga Anda tidak perlu lagi properti "displayName" khusus. Karena pencarian negatif untuk "displayName" cukup mahal dan tidak terlalu diperlukan (ES2015 dirilis lebih dari lima tahun yang lalu), kami memutuskan untuk menghapus dukungan untuk properti fn.displayName non-standar dari V8 (dan DevTools).

Karena pencarian negatif "displayName" tidak dilakukan, separuh dari biaya v8::StackFrame::GetFunctionName dihapus. Bagian lainnya mengarah ke pencarian properti "name" generik. Untungnya, kami sudah memiliki beberapa logika untuk menghindari pencarian properti "name" yang mahal pada instance Function (yang tidak tersentuh), yang kami perkenalkan di V8 beberapa waktu lalu untuk membuat Function.prototype.bind() itu sendiri lebih cepat. Kami memindahkan pemeriksaan yang diperlukan yang memungkinkan kami melewati pencarian generik yang mahal sejak awal, sehingga v8::StackFrame::GetFunctionName tidak muncul lagi di profil yang telah kami pertimbangkan.

Kesimpulan

Dengan peningkatan di atas, kami telah mengurangi overhead DevTools secara signifikan dalam hal pelacakan tumpukan.

Kami tahu masih ada berbagai kemungkinan peningkatan - misalnya overhead saat menggunakan MutationObserver masih terlihat, seperti yang dilaporkan di chromium:1077657 - tetapi untuk saat ini, kami telah mengatasi poin masalah utama, dan kami mungkin akan kembali di masa mendatang untuk lebih menyederhanakan performa proses debug.

Mendownload saluran pratinjau

Pertimbangkan untuk menggunakan Chrome Canary, Dev, atau Beta sebagai browser pengembangan default Anda. Saluran pratinjau ini memberi Anda akses ke fitur DevTools terbaru, memungkinkan Anda menguji API platform web canggih, dan membantu Anda menemukan masalah di situs sebelum pengguna melakukannya.

Hubungi tim Chrome DevTools

Gunakan opsi berikut untuk membahas fitur baru, update, atau hal lain yang terkait dengan DevTools.