Cara kami mempercepat pelacakan tumpukan Chrome DevTools sebesar 10x

Benedikt Meurer
Benedikt Meurer

Developer web mengharapkan sedikit atau tidak ada dampak terhadap performa saat men-debug kode mereka. Namun, ekspektasi ini sama sekali tidak universal. Developer C++ tidak akan mengharapkan build debug aplikasi mereka mencapai performa produksi, dan di tahun-tahun awal Chrome, hanya membuka DevTools secara signifikan memengaruhi performa halaman.

Fakta bahwa penurunan performa ini tidak lagi terasa adalah hasil dari investasi selama bertahun-tahun dalam kemampuan proses debug DevTools dan V8. Meskipun demikian, kita tidak akan pernah bisa mengurangi overhead performa DevTools ke nol. Menyetel titik henti sementara, menelusuri kode, mengumpulkan pelacakan tumpukan, merekam pelacakan performa, dll. semuanya memengaruhi kecepatan eksekusi hingga tingkat yang berbeda-beda. Lagi pula, mengamati sesuatu akan mengubahnya.

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

Seperti yang Anda lihat dari video, pelambatannya berkisar antara 5-10x, yang jelas tidak dapat diterima. Langkah pertama adalah memahami ke mana arah waktu perjalanan berjalan dan apa yang menyebabkan perlambatan besar ini ketika DevTools terbuka. Penggunaan Linux perf pada proses Chrome Renderer mengungkapkan distribusi waktu eksekusi perender keseluruhan berikut:

Waktu eksekusi Chrome Renderer

Meskipun sepertinya kami melihat sesuatu yang terkait dengan pengumpulan pelacakan tumpukan, kami tidak akan memperkirakan bahwa sekitar 90% dari keseluruhan waktu eksekusi akan digunakan untuk melambangkan 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 beralih ke fungsi JSStackFrame::GetMethodName() di V8 - meskipun kita tahu dari penyelidikan sebelumnya bahwa JSStackFrame::GetMethodName() sudah tidak asing lagi di bidang masalah performa. Fungsi ini mencoba menghitung nama metode untuk frame yang dianggap panggilan metode (frame yang mewakili pemanggilan fungsi formulir obj.func(), bukan func()). Dengan melihat sekilas, kode mengungkapkan bahwa kode 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 dengan sendirinya ini terdengar tidak murah, kedengarannya juga tidak dapat menjelaskan perlambatan yang mengerikan ini. Jadi, kami mulai mempelajari contoh yang dilaporkan di chromium:1069425, dan kami mendapati bahwa pelacakan tumpukan dikumpulkan untuk tugas asinkron serta untuk pesan log yang berasal dari classes.js - file JavaScript 10 MiB. Setelah dilihat lebih dekat, terbukti bahwa ini pada dasarnya adalah runtime Java ditambah kode aplikasi yang dikompilasi ke JavaScript. Pelacakan tumpukan berisi beberapa frame dengan metode yang dipanggil pada objek A sehingga kami merasa perlu memahami jenis objek yang sedang kita tangani.

pelacakan tumpukan objek

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

  1. Metode ini berfungsi dengan mencari "name" fungsi sebagai properti pada objek, dan jika ditemukan, periksa apakah nilai properti cocok dengan fungsi.
  2. Jika fungsi tidak memiliki nama atau objek tidak memiliki properti yang cocok, fungsi tersebut akan kembali ke pencarian terbalik dengan melintasi semua properti objek dan prototipenya.

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

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

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

  1. Mengekstrak nama semua properti yang dapat dihitung, dan
  2. Lakukan pencarian properti generik untuk setiap nama, menguji apakah nilai properti yang dihasilkan cocok dengan penutupan yang kami cari.

Sepertinya itu buah yang mudah tergantung, karena untuk mengekstrak nama-nama itu, Anda perlu menelusuri semua sifat-sifatnya. Alih-alih melakukan dua penerusan - O(N) untuk ekstraksi nama dan O(N log(N)) untuk pengujian - kita dapat melakukan semuanya dalam satu penerusan dan langsung memeriksa nilai properti. Cara ini membuat seluruh fungsi menjadi sekitar 2-10x lebih cepat.

Temuan kedua bahkan lebih menarik. Meskipun fungsi tersebut secara teknis merupakan fungsi anonim, mesin V8 telah merekam apa yang kami sebut nama yang disimpulkan untuk fungsi tersebut. Untuk literal fungsi yang muncul di sisi kanan tugas dalam bentuk obj.foo = function() {...}, parser V8 akan mengingat "obj.foo" sebagai nama yang disimpulkan untuk literal fungsi. Jadi, dalam kasus ini, meskipun kita tidak memiliki nama yang tepat yang bisa dicari, kita sudah memiliki nama yang cukup mirip: Untuk contoh A.SDV = function() {...} di atas, kita memiliki "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. Cara tersebut melakukan trik di hampir semua kasus, mengganti traversal penuh yang mahal dengan satu pencarian properti. Kedua peningkatan ini merupakan bagian dari CL ini, dan secara signifikan mengurangi pelambatan untuk contoh yang dilaporkan di chromium:1069425.

Error.stack

Kita bisa saja menyebutnya sehari. Tapi ada sesuatu yang mencurigakan, karena DevTools tidak pernah menggunakan nama metode untuk bingkai tumpukan. Bahkan, class v8::StackFrame di C++ API bahkan tidak mengekspos cara untuk mendapatkan nama metode. Jadi sepertinya salah bahwa kita pada akhirnya memanggil JSStackFrame::GetMethodName(). Sebagai gantinya, satu-satunya tempat di mana kita menggunakan (dan mengekspos) nama metode adalah di API pelacakan tumpukan JavaScript. Untuk memahami penggunaan ini, pertimbangkan contoh sederhana berikut error-methodname.js:

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

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

Di sini kita memiliki fungsi foo yang diinstal dengan nama "bar" pada 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 berlangsung: 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 faktanya, pengujian performa kami juga menunjukkan bahwa perubahan kami membuat berbagai hal menjadi 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 histori yang membantu kita: Biasanya V8 memiliki dua mekanisme berbeda untuk mengumpulkan dan merepresentasikan pelacakan tumpukan untuk dua API berbeda yang dijelaskan di atas (C++ v8::StackFrame API dan JavaScript stack trace API). Memiliki dua cara berbeda untuk melakukan (kira-kira) hal yang sama rentan terhadap error dan sering menyebabkan inkonsistensi dan bug, sehingga pada akhir 2018 kami memulai project untuk menyelesaikan satu bottleneck untuk pengambilan pelacakan tumpukan.

Project tersebut sukses besar 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 dengan lambat dan hanya jika benar-benar diperlukan. Namun, 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 hal ini ternyata agak bertentangan dengan cara penggunaan objek C++ API ini di Chromium dan DevTools. Secara khusus, karena kami telah memperkenalkan class v8::internal::StackFrameInfo baru, yang menyimpan semua informasi tentang frame stack yang ditampilkan melalui v8::StackFrame atau error.stack, kita akan selalu menghitung superset informasi yang disediakan oleh kedua API, yang berarti bahwa untuk penggunaan v8::StackFrame (dan khususnya untuk DevTools) kita juga akan menghitung nama metode, segera setelah informasi apa pun tentang frame stack diminta. Ternyata DevTools selalu segera meminta informasi sumber dan skrip.

Berdasarkan realisasi tersebut, kami dapat memfaktorkan ulang dan menyederhanakan representasi frame stack secara drastis dan 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 besar-besaran 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 pintu 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 di mana V8 menghabiskan waktu ketika (mengumpulkan dan) melambangkan frame stack untuk konsumsi di DevTools.

Biaya simbolisasi

Hal pertama yang terlihat jelas adalah biaya kumulatif untuk menghitung nomor baris dan kolom. Bagian yang mahal di sini sebenarnya menghitung offset karakter dalam skrip (berdasarkan offset bytecode yang kami dapatkan dari V8), dan ternyata karena pemfaktoran ulang kami di atas, kami melakukan itu dua kali, sekali saat menghitung nomor baris dan di lain waktu saat menghitung nomor kolom. Menyimpan posisi sumber dalam cache di instance v8::internal::StackFrameInfo membantu menyelesaikan masalah ini dengan cepat dan menghapus v8::internal::StackFrameInfo::GetColumnNumber dari profil mana pun.

Temuan yang lebih menarik bagi kami adalah bahwa v8::StackFrame::GetFunctionName ternyata sangat tinggi di semua profil yang kami lihat. Menggali lebih dalam di sini kami menyadari bahwa itu tidak perlu mahal untuk menghitung nama yang akan kami tunjukkan untuk fungsi di {i>stack frame <i}di DevTools,

  1. cari properti "displayName" non-standar terlebih dahulu. Jika properti tersebut menghasilkan properti data dengan nilai string, kita harus menggunakannya,
  2. jika tidak, kembali mencari properti "name" standar dan sekali lagi memeriksa apakah properti tersebut menghasilkan properti data yang nilainya adalah string,
  3. dan akhirnya kembali ke nama debug internal yang ditentukan oleh parser V8 dan disimpan di literal fungsi.

Properti "displayName" ditambahkan sebagai solusi untuk properti "name" pada instance Function yang bersifat hanya baca dan tidak dapat dikonfigurasi di JavaScript, tetapi tidak pernah distandardisasi dan tidak melihat penggunaan yang luas, karena alat developer browser menambahkan inferensi nama fungsi yang melakukan tugas di 99,9% kasus. Selain itu, ES2015 membuat properti "name" pada instance Function dapat dikonfigurasi, sehingga sepenuhnya meniadakan kebutuhan akan 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).

Setelah pencarian negatif "displayName" selesai, separuh dari biaya v8::StackFrame::GetFunctionName telah dihapus. Setengah lainnya ditujukan ke pencarian properti "name" generik. Untungnya, kita telah menerapkan beberapa logika untuk menghindari pencarian properti "name" yang mahal pada instance Function (tidak tersentuh), yang telah kita perkenalkan di V8 beberapa waktu lalu untuk membuat Function.prototype.bind() itu sendiri lebih cepat. Kami telah mentransfer pemeriksaan yang diperlukan sehingga memungkinkan kami melewati pencarian umum yang mahal, sehingga v8::StackFrame::GetFunctionName tidak muncul di profil mana pun yang telah kami pertimbangkan lagi.

Kesimpulan

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

Kami tahu masih ada berbagai kemungkinan peningkatan - misalnya overhead saat menggunakan MutationObserver masih terlihat, seperti yang dilaporkan dalam chromium:1077657 - tetapi untuk saat ini, kami telah mengatasi masalah utamanya, dan kami mungkin kembali pada 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, menguji API platform web tercanggih, dan menemukan masalah di situs Anda sebelum pengguna melakukannya.

Menghubungi tim Chrome DevTools

Gunakan opsi berikut untuk membahas fitur dan perubahan baru di postingan ini, atau hal lain yang terkait dengan DevTools.

  • Kirim saran atau masukan kepada kami melalui crbug.com.
  • Laporkan masalah DevTools menggunakan Opsi lainnya   Lainnya   > Bantuan > Laporkan masalah DevTools di DevTools.
  • Tweet di @ChromeDevTools.
  • Tuliskan komentar di video YouTube Yang baru di DevTools atau video YouTube di DevTools.