Chrome DevTools yığın izlemeleri nasıl 10 kat hızlandırdık?

Benedikt Meurer
Benedikt Meurer

Web geliştiricileri kodlarında hata ayıklarken performanslarının çok az etkilenmesini beklemez veya hiç etkilemez. Ancak bu beklenti her zaman geçerli değildir. C++ geliştiricileri, uygulamalarının hata ayıklama derlemesinin üretim performansına ulaşmasını asla beklemez. Chrome'un ilk yıllarında, DevTools'un açılması bile sayfanın performansını önemli ölçüde etkiliyordu.

Bu performans düşüşü, DevTools ve V8'in hata ayıklama özelliklerine yıllarca yatırım yapılmasından kaynaklanmaktadır. Yine de DevTools'un performans yükü hiçbir zaman sıfıra indirilemez. Kesme noktaları ayarlama, kodda adımlama, yığın izlemeleri toplama, performans izlemesi yakalama vb. tüm işlemler yürütme hızını farklı derecelerde etkiler. Sonuçta, bir şeyi gözlemlemek onu değiştirir.

Elbette tüm hata ayıklayıcıları gibi Geliştirici Araçları eklerinin de makul olması gerekir. Son zamanlarda, DevTools'un belirli durumlarda uygulamayı artık kullanılamayacak kadar yavaşlattığına dair raporların sayısında önemli bir artış gözlemledik. Aşağıda, chromium:1069425 raporundan alınan ve yalnızca DevTools'un açık olmasının performans üzerindeki etkisini gösteren bir yan yana karşılaştırma görebilirsiniz.

Videoda da görebileceğiniz gibi, yavaşlama 5-10 kat düzeyinde. Bu kabul edilemez bir durum. İlk adım, tüm zamanın nereye gittiğini ve DevTools açıkken bu büyük yavaşlamaya neyin neden olduğunu anlamaktı. Chrome Oluşturucu işleminde Linux perf kullanıldığında, genel oluşturucu yürütme süresinin dağılımı şu şekilde ortaya çıkar:

Chrome Oluşturucu yürütme süresi

Yığın izleme toplamayla ilgili bir şey görmeyi biraz etsek de toplam yürütme süresinin %90'ının yığın çerçevelerini simgeleştirmeye ayrılacağını beklemiyorduk. Buradaki sembolleştirme, işlev adlarını ve ham yığın çerçevelerinden somut kaynak konumlarını (komut dosyalarındaki satır ve sütun numaraları) çözme işlemini ifade eder.

Yöntem adı çıkarım

Daha da şaşırtıcı olan, neredeyse tüm zamanların V8'deki JSStackFrame::GetMethodName() işlevine gitmesiydi. Ancak önceki incelemelerden JSStackFrame::GetMethodName()'ün performans sorunlarının dünyasında yabancı olmadığını biliyorduk. Bu işlev, yöntem çağrısı olarak kabul edilen çerçeveler (func() yerine obj.func() biçimindeki işlev çağrılarını temsil eden çerçeveler) için yöntemin adını hesaplamaya çalışır. Kodu kısaca incelediğimizde, işlevin nesnenin ve prototip zincirinin tam bir tarama işlemi gerçekleştirerek ve

  1. value değeri func kapanış olan veri mülkleri veya
  2. get veya set'nin func kapatma değerine eşit olduğu erişim özelliklerini belirtir.

Bu durum, tek başına çok ucuz bir durum gibi görünmese de bu korkunç yavaşlamayı açıklamıyor. Bu nedenle, chromium:1069425 konumunda bildirilen örneği ayrıntılı olarak araştırmaya başladık ve yığın izlemelerin, eş zamansız görevler ve 10 MiB'lık bir JavaScript dosyası olan classes.js kaynaklı günlük mesajları için toplandığını tespit ettik. Daha yakından bakıldığında, bunun temelde bir Java çalışma zamanı artı JavaScript'e derlenmiş bir uygulama kodu olduğu ortaya çıktı. Yığın izlemeleri, A nesnesinde çağrılan yöntemlerin bulunduğu birkaç çerçeve içeriyordu. Bu nedenle, ne tür bir nesneyle uğraştığımızı anlamanın faydalı olabileceğini düşündük.

bir nesnenin yığın izlemeleri (stack trace)

Java'dan JavaScript'e derleyici, 82.203 işlev içeren tek bir nesne oluşturmuş. Bu durum ilginç olmaya başlamıştı. Ardından, kolayca ulaşabileceğimiz bazı kolay hedefler olup olmadığını anlamak için V8'in JSStackFrame::GetMethodName() bölümüne geri döndük.

  1. Öncelikle nesnenin bir özelliği olarak işlevin "name" değerini arar ve bulunursa özellik değerinin işlevle eşleşip eşleşmediğini kontrol eder.
  2. İşlevin adı yoksa veya nesnenin eşleşen bir özelliği yoksa, nesnenin ve prototiplerinin tüm özellikleri arasında gezinerek ters aramaya geri döner.

Örneğimizde tüm işlevler anonimdir ve boş "name" mülklerine sahiptir.

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

İlk bulgu, ters aramanın iki adıma bölünmüş olduğuydu (nesnenin kendisi ve prototip zincirindeki her nesne için gerçekleştirilir):

  1. Tüm listelenebilir mülklerin adlarını ayıklayın ve
  2. Her ad için genel mülk araması gerçekleştirerek, elde edilen mülk değerinin aradığımız kapatma ile eşleşip eşleşmediğini test edin.

Adları ayıklamak için zaten tüm mülkleri incelemek gerektiğinden bu, kolayca elde edilebilecek bir sonuç gibi görünüyordu. Ad ayıklama için O(N) ve testler için O(N log(N)) olmak üzere iki geçişi yapmak yerine her şeyi tek bir geçişte yapabilir ve özellik değerlerini doğrudan kontrol edebilirdik. Bu, tüm işlevi yaklaşık 2-10 kat hızlandırdı.

İkinci bulgu daha da ilgi çekiciydi. İşlevler teknik olarak anonim işlevlerdi, ancak V8 motoru yine de kendileri için öngörülen adı verdiğimiz adı kaydetmişti. obj.foo = function() {...} biçimindeki atamaların sağ tarafında görünen işlev sabitleri için V8 ayrıştırıcısı, "obj.foo" değerini işlev sabiti için tahmine dayalı ad olarak saklar. Bu durumda, arama yapabileceğimiz doğru ada sahip olmasak da yeterince yakın bir isme sahiptik: Yukarıdaki A.SDV = function() {...} örneğinde, "A.SDV" tahmini ad olarak kullanılıyordu. Son noktayı bulup ardından nesnede "SDV" mülkünü arayarak tahmini addan mülk adını türetebiliriz. Bu, neredeyse tüm durumlarda işe yaradı ve pahalı bir tam geçişi tek bir mülk aramasıyla değiştirdi. Bu iki iyileştirme, bu CL kapsamında kullanıma sunuldu ve chromium:1069425'te bildirilen örnekte yavaşlamayı önemli ölçüde azalttı.

Error.stack

Buradan ayrılabilirdik. Ancak DevTools, yığın çerçeveleri için yöntem adını hiçbir zaman kullanmadığından, burada bir sorun olduğu anlaşılıyordu. Hatta C++ API'deki v8::StackFrame sınıfı, yöntem adına ulaşmanın bir yolunu bile göstermez. Bu nedenle, ilk başta JSStackFrame::GetMethodName() ile iletişime geçmemiz yanlış bir karardı. Bunun yerine, yöntem adını kullandığımız (ve gösterdiğimiz) tek yer JavaScript yığın izleme API'si içindedir. Bu kullanımı anlamak için aşağıdaki basit örneği error-methodname.js inceleyebilirsiniz:

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

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

Burada, object üzerinde "bar" adı altında yüklü bir foo işlevi var. Bu snippet'i Chromium'da çalıştırmak aşağıdaki çıkışı verir:

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

Burada, oynatmada yöntem adı aramasını görüyoruz: En üstteki yığın çerçevesi, bar adlı yöntemle Object örneğinde foo işlevini çağırmak için gösterilmektedir. Bu nedenle, standart olmayan error.stack özelliği JSStackFrame::GetMethodName() yoğun bir şekilde kullanmaktadır. Aslında, performans testlerimiz aynı zamanda değişikliklerimizin işleri önemli ölçüde hızlandırdığını göstermektedir.

StackTrace mikro karşılaştırmalarında hızlandırma

Ancak Chrome Geliştirici Araçları konusuna dönecek olursak, error.stack kullanılmadığı halde yöntem adının hesaplanmış olması doğru görünmüyor. Bu konuda geçmişte bize yardımcı olan bazı bilgiler var: Geleneksel olarak V8'de, yukarıda açıklanan iki farklı API (C++ v8::StackFrame API ve JavaScript yığın izleme API'si) için yığın izlemeyi toplayıp temsil etmek üzere iki ayrı mekanizma mevcuttu. Yaklaşık olarak aynı işlemi iki farklı şekilde yapmak hatalara yol açıyordu ve genellikle tutarsızlıklara ve hatalara neden oluyordu. Bu nedenle 2018'in sonlarında, yığın izleme yakalama için tek bir darboğaza karar vermek üzere bir proje başlattık.

Bu proje büyük bir başarı elde etti ve yığın izleme toplama ile ilgili sorunların sayısını önemli ölçüde azalttı. Standart olmayan error.stack mülkü aracılığıyla sağlanan bilgilerin çoğu da yalnızca gerçekten ihtiyaç duyulduğunda ve yavaşça hesaplanıyordu. Ancak yeniden yapılandırmanın bir parçası olarak aynı hileyi v8::StackFrame nesnelerine de uyguladık. Yığın çerçevesiyle ilgili tüm bilgiler, üzerinde herhangi bir yöntem ilk kez çağrıldığında hesaplanır.

Bu genellikle performansı artırır ancak ne yazık ki bu C++ API nesnelerinin Chromium ve Geliştirici Araçları'nda kullanılma şekliyle biraz çeliştiği ortaya çıktı. Özellikle, v8::StackFrame veya error.stack aracılığıyla sunulan bir yığın çerçevesiyle ilgili tüm bilgileri içeren yeni bir v8::internal::StackFrameInfo sınıfı kullanıma sunduğumuzdan, her zaman her iki API tarafından sağlanan bilgilerin süper kümesini hesaplardık. Bu da, v8::StackFrame'un kullanımlarında (ve özellikle DevTools'da) bir yığın çerçevesiyle ilgili herhangi bir bilgi istendiğinde yöntem adını da hesaplayacağımız anlamına geliyordu. Geliştirici Araçları'nın her zaman kaynak ve komut dosyası bilgilerini istediği anlaşılıyor.

Bu tespite dayanarak, stack kare gösterimini yeniden düzenleyip büyük ölçüde basitleştirdik ve bunu daha da geç hale getirmeyi başardık. Böylece, V8 ve Chromium genelinde kullanımlar artık yalnızca istedikleri bilgi hesaplama maliyetini ödüyor. Bu, yığın çerçeveleriyle ilgili bilgilerin yalnızca bir kısmına (temel olarak satır ve sütun ofseti biçiminde komut dosyası adı ve kaynak konumu) ihtiyaç duyan DevTools ve diğer Chromium kullanım alanları için büyük bir performans artışı sağladı ve daha fazla performans iyileştirmesine kapı açtı.

İşlev adları

Yukarıda belirtilen yeniden yapılanma işlemleri tamamlandıktan sonra, sembolleştirmenin ek maliyeti (v8_inspector::V8Debugger::symbolize içinde harcanan süre) toplam yürütme süresinin yaklaşık %15'ine düşürüldü ve V8'in DevTools'da kullanılmak üzere yığın çerçevelerini sembolize ederken (toplarken ve) nerede zaman harcadığını daha net görebildik.

Simgeleştirme maliyeti

Dikkate değer ilk şey, satır ve sütun numarası hesaplamanın kümülatif maliyetiydi. Buradaki pahalı kısım, komut dosyasındaki karakter ofsetini hesaplamaktır (V8'den aldığımız bayt kodu ofsetine göre). Yukarıdaki yeniden düzenleme işlemimiz nedeniyle bunu bir kez satır numarasını hesaplarken, bir kez de sütun numarasını hesaplarken yaptığımız ortaya çıktı. v8::internal::StackFrameInfo örneklerindeki kaynak konumunu önbelleğe alma, bu sorunun hızlı bir şekilde çözülmesine yardımcı oldu ve v8::internal::StackFrameInfo::GetColumnNumber'ı tüm profillerden tamamen ortadan kaldırdı.

Bizim için daha ilginç olan bulgu, incelediğimiz tüm profillerde v8::StackFrame::GetFunctionName şaşırtıcı derecede yüksek olduğuydu. Bu konuyu daha ayrıntılı bir şekilde incelediğimizde, DevTools'taki yığın çerçevesinde işlev için göstereceğimiz adı hesaplamanın gereksiz yere maliyetli olduğunu fark ettik.

  1. önce standart olmayan "displayName" özelliğini ararız. Bu şekilde dize değeri içeren bir veri özelliği çıkarsa şunu kullanırız:
  2. Aksi takdirde, standart "name" mülkünü aramaya geçer ve bu aramanın sonucunda değeri dize olan bir veri mülkünün bulunup bulunmadığını tekrar kontrol eder.
  3. ve sonunda V8 ayrıştırıcısı tarafından tahmin edilen ve işlev değişmezinde depolanan dahili bir hata ayıklama adına geri döner.

"displayName" özelliği, Function örneklerindeki "name" özelliğinin JavaScript'te salt okunur ve yapılandırılamaz olması nedeniyle geçici bir çözüm olarak eklendi ancak hiçbir zaman standartlaştırılmadı ve yaygın olarak kullanılmadı. Bunun nedeni, tarayıcı geliştirici araçlarının, işlevi %99,9 oranında yerine getiren işlev adı çıkarımının eklenmiş olmasıdır. Ayrıca ES2015, Function örneklerindeki "name" mülkünü yapılandırılabilir hale getirerek özel bir "displayName" mülküne olan ihtiyacı tamamen ortadan kaldırdı. "displayName" için negatif arama oldukça maliyetli ve gerçekten gerekli olmadığından (ES2015 beş yıldan uzun bir süre önce yayınlandı), V8'den (ve DevTools'dan) standart olmayan fn.displayName mülkü için desteği kaldırmaya karar verdik.

"displayName" için negatif arama işlemi kaldırıldığından v8::StackFrame::GetFunctionName maliyetinin yarısı kaldırıldı. Diğer yarısı ise genel "name" mülk aramasına gider. Neyse ki, (dokunulmamış) Function örneklerindeki "name" mülkünün maliyetli aramalarını önlemek için zaten bazı mantıklarımız vardı. Bu mantığı, Function.prototype.bind()'ı daha hızlı hale getirmek için bir süre önce V8'de kullanıma sunduk. Pahalı genel aramaları en baştan yapmamızı sağlayan gerekli kontrolleri aktardık. Sonuç olarak v8::StackFrame::GetFunctionName artık değerlendirdiğimiz profillerde görünmüyor.

Sonuç

Yukarıdaki iyileştirmelerle, yığın izlemeleri açısından DevTools'un ek yükünü önemli ölçüde azalttık.

Hâlâ çeşitli olası iyileştirmeler olduğunun farkındayız. Örneğin, chromium:1077657 sayfasında belirtildiği üzere MutationObserver kullanımındaki ek yük hâlâ fark edilebilir durumdadır. Ancak şimdilik, önemli sorunlar giderildi ve hata ayıklama performansını daha da kolaylaştırmak için gelecekte tekrar gelebiliriz.

Önizleme kanallarını indirme

Varsayılan geliştirme tarayıcınız olarak Chrome Canary, Yeni geliştirilenler veya Beta sürümünü kullanabilirsiniz. Bu önizleme kanalları, en son DevTools özelliklerine erişmenizi sağlar, en yeni web platformu API'lerini test etmenize olanak tanır ve sitenizdeki sorunları kullanıcılarınızdan önce bulmanıza yardımcı olur.

Chrome Geliştirici Araçları Ekibi ile iletişime geçme

Yeni özellikler, güncellemeler veya Geliştirici Araçları ile ilgili başka herhangi bir konu hakkında konuşmak için aşağıdaki seçenekleri kullanın.