วิธีที่เราเร่งสแต็กเทรซ Chrome DevTools ได้มากถึง 10 เท่า

Benedikt Meurer
Benedikt Meurer

นักพัฒนาเว็บคาดหวังว่าการแก้ไขข้อบกพร่องโค้ดจะไม่ส่งผลกระทบต่อประสิทธิภาพมากนักหรือไม่ส่งผลกระทบเลย อย่างไรก็ตาม ก็ไม่ได้หมายความว่าทุกคนจะคาดหวังเช่นนั้น นักพัฒนาซอฟต์แวร์ C++ คงคาดไม่ถึงว่าบิลด์แก้ไขข้อบกพร่องของแอปพลิเคชันจะมีประสิทธิภาพเทียบเท่าเวอร์ชันที่ใช้งานจริง และในช่วงปีแรกๆ ของ Chrome การเปิดเครื่องมือสำหรับนักพัฒนาเว็บเพียงอย่างเดียวก็ส่งผลต่อประสิทธิภาพของหน้าเว็บอย่างมาก

การที่ผู้ใช้ไม่รู้สึกถึงประสิทธิภาพที่ลดลงอีกต่อไปเป็นผลมาจากการลงทุนในความสามารถด้านการแก้ไขข้อบกพร่องของ DevTools และ V8 เป็นเวลาหลายปี อย่างไรก็ตาม เราไม่สามารถลดค่าใช้จ่ายเพิ่มเติมด้านประสิทธิภาพของ DevTools ให้เป็น 0 ได้ การตั้งจุดหยุดพัก การสเต็ปผ่านโค้ด การเก็บรวบรวมสแต็กเทรซ การบันทึกการติดตามประสิทธิภาพ ฯลฯ ล้วนส่งผลต่อความเร็วในการดำเนินการในระดับที่แตกต่างกัน ท้ายที่สุดแล้ว การสังเกตสิ่งหนึ่งๆ จะทําให้สิ่งนั้นเปลี่ยนแปลง

แต่แน่นอนว่าค่าใช้จ่ายเพิ่มเติมของ DevTools ควรอยู่ในระดับที่เหมาะสม เช่นเดียวกับเครื่องมือแก้ไขข้อบกพร่องอื่นๆ เมื่อเร็วๆ นี้ เราพบว่ามีรายงานจำนวนเพิ่มขึ้นอย่างมากว่าในบางกรณี DevTools จะทําให้แอปพลิเคชันช้าลงจนใช้งานไม่ได้ ด้านล่างนี้คุณจะเห็นการเปรียบเทียบแบบเคียงข้างกันจากรายงาน chromium:1069425 ซึ่งแสดงถึงค่าใช้จ่ายด้านประสิทธิภาพของการแค่เปิดเครื่องมือสำหรับนักพัฒนาเว็บไว้

ดังที่คุณเห็นจากวิดีโอ วิดีโอช้าลงประมาณ 5-10 เท่า ซึ่งยอมรับไม่ได้ ขั้นตอนแรกคือทำความเข้าใจว่าเวลาหายไปไหนและสาเหตุที่ทำให้เกิดความล่าช้าอย่างมากเมื่อเปิด DevTools การใช้ Linux perf ในกระบวนการแสดงผลของ Chrome แสดงให้เห็นการแจกแจงเวลาในการเรียกใช้โปรแกรมแสดงผลโดยรวมดังต่อไปนี้

เวลาดำเนินการของโปรแกรมแสดงผลของ Chrome

แม้ว่าเราคาดหวังที่จะเห็นข้อมูลบางอย่างที่เกี่ยวข้องกับการเก็บรวบรวมสแต็กเทรซ แต่ก็ไม่ได้คาดคิดว่าเวลาในการดำเนินการโดยรวมประมาณ 90% จะใช้ไปกับการแสดงสัญลักษณ์เฟรมสแต็ก การแทนที่สัญลักษณ์ในที่นี้หมายถึงการแก้ไขชื่อฟังก์ชันและตำแหน่งแหล่งที่มาที่เฉพาะเจาะจง เช่น หมายเลขบรรทัดและคอลัมน์ในสคริปต์ จากสแต็กเฟรมดิบ

อนุมานชื่อเมธอด

สิ่งที่น่าประหลาดใจยิ่งกว่านั้นคือเวลาเกือบทั้งหมดหมดไปกับฟังก์ชัน JSStackFrame::GetMethodName() ใน V8 แม้ว่าเราจะทราบจากการตรวจสอบก่อนหน้านี้ว่า JSStackFrame::GetMethodName() ไม่ใช่คนแปลกหน้าในดินแดนแห่งปัญหาด้านประสิทธิภาพ ฟังก์ชันนี้จะพยายามคํานวณชื่อของเมธอดสําหรับเฟรมที่ถือว่าเป็นการเรียกใช้เมธอด (เฟรมที่แสดงการเรียกใช้ฟังก์ชันในรูปแบบ obj.func() ไม่ใช่ func()) การตรวจสอบโค้ดอย่างรวดเร็วพบว่าฟังก์ชันทํางานโดยการเรียกใช้การเรียกใช้ออบเจ็กต์และเชนโปรโตไทป์ทั้งหมด และมองหา

  1. พร็อพเพอร์ตี้ข้อมูลที่มี value เป็น func ปิด หรือ
  2. พร็อพเพอร์ตี้ตัวรับค่าที่ get หรือ set เท่ากับการปิด func

แม้ว่าการอัปเดตนี้จะไม่ทำให้อุปกรณ์ช้าลง แต่ก็ไม่ได้อธิบายความช้าอย่างรุนแรงนี้ เราจึงเริ่มตรวจสอบตัวอย่างที่รายงานใน chromium:1069425 และพบว่ามีการรวบรวมสแต็กเทรซสําหรับงานแบบแอสซิงค์ รวมถึงสําหรับข้อความบันทึกที่มาจาก classes.js ซึ่งเป็นไฟล์ JavaScript ขนาด 10 MiB เมื่อตรวจสอบอย่างละเอียดแล้ว พบว่านี่เป็นรันไทม์ Java บวกกับโค้ดแอปพลิเคชันที่คอมไพล์เป็น JavaScript บันทึกสแต็กมีเฟรมหลายเฟรมที่มีการเรียกใช้เมธอดในออบเจ็กต์ A เราจึงคิดว่าควรทำความเข้าใจว่ากำลังจัดการกับออบเจ็กต์ประเภทใด

สแต็กเทรซของออบเจ็กต์

ดูเหมือนว่าคอมไพเลอร์ Java เป็น JavaScript จะสร้างออบเจ็กต์เดียวที่มีฟังก์ชัน 82,203 รายการ ซึ่งนี่เป็นเรื่องที่น่าสนมาก จากนั้นเรากลับไปที่ JSStackFrame::GetMethodName() ของ V8 เพื่อดูว่ามีอะไรที่เราทำได้ง่ายๆ ไหม

  1. โดยวิธีทํางานคือจะค้นหา "name" ของฟังก์ชันเป็นพร็อพเพอร์ตี้ในออบเจ็กต์ก่อน หากพบ ก็จะตรวจสอบว่าค่าพร็อพเพอร์ตี้ตรงกับฟังก์ชันหรือไม่
  2. หากฟังก์ชันไม่มีชื่อหรือออบเจ็กต์ไม่มีพร็อพเพอร์ตี้ที่ตรงกัน ระบบจะเปลี่ยนไปใช้การค้นหาย้อนกลับโดยเรียกใช้พร็อพเพอร์ตี้ทั้งหมดของออบเจ็กต์และโปรโตไทป์

ในตัวอย่างนี้ ฟังก์ชันทั้งหมดเป็นแบบไม่ระบุชื่อและมีพร็อพเพอร์ตี้ "name" ว่าง

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

ผลการค้นหาแรกคือ การค้นหาย้อนกลับแบ่งออกเป็น 2 ขั้นตอน (ดำเนินการสำหรับออบเจ็กต์เองและแต่ละออบเจ็กต์ในเชนโปรโตไทป์) ดังนี้

  1. ดึงข้อมูลชื่อของพร็อพเพอร์ตี้ที่นับทั้งหมดได้ทั้งหมด และ
  2. ทำการค้นหาพร็อพเพอร์ตี้ทั่วไปสำหรับชื่อแต่ละรายการ โดยทดสอบว่าค่าพร็อพเพอร์ตี้ที่ได้ตรงกับคำปิดที่เราต้องการหรือไม่

ดูเหมือนว่าจะเป็นวิธีที่ง่ายที่สุดแล้ว เนื่องจากการดึงข้อมูลชื่อต้องเรียกใช้พร็อพเพอร์ตี้ทั้งหมดอยู่แล้ว แทนที่จะทำ 2 รอบ - O(N) สำหรับการดึงข้อมูลชื่อและ O(N log(N)) สำหรับการทดสอบ - เราทําทุกอย่างได้ในการผ่านเพียงครั้งเดียวและตรวจสอบค่าพร็อพเพอร์ตี้โดยตรง ซึ่งทำให้ฟังก์ชันทั้งหมดทำงานได้เร็วขึ้นประมาณ 2-10 เท่า

ข้อมูลการค้นพบที่ 2 น่าสนใจยิ่งขึ้น แม้ว่าในทางเทคนิคแล้วฟังก์ชันเหล่านี้จะเป็นฟังก์ชันนิรนาม แต่เครื่องยนต์ V8 ก็ได้บันทึกสิ่งที่เราเรียกว่าชื่อที่อิงตามข้อมูลที่มีอยู่ไว้ให้ฟังก์ชันเหล่านั้น สําหรับลิเทอรัลฟังก์ชันที่ปรากฏทางด้านขวามือของการกำหนดในรูปแบบ obj.foo = function() {...} โปรแกรมแยกวิเคราะห์ V8 จะจดจํา "obj.foo" เป็นชื่อที่อิงตามบริบทสําหรับลิเทอรัลฟังก์ชัน ในกรณีของเรา หมายความว่าแม้ว่าจะไม่มีชื่อที่เหมาะสมที่จะค้นหาได้ แต่เราก็มีชื่อที่ใกล้เคียงพอ ในตัวอย่างนี้ A.SDV = function() {...} เรามี "A.SDV" เป็นชื่อที่อนุมาน และสามารถดึงชื่อพร็อพเพอร์ตี้จากชื่อที่อนุมานได้โดยมองหาจุดสุดท้าย จากนั้นค้นหาพร็อพเพอร์ตี้ "SDV" ในออบเจ็กต์ วิธีนี้ได้ผลเกือบทุกกรณี โดยแทนที่การเรียกดูทั้งหมดที่สิ้นเปลืองทรัพยากรด้วยการค้นหาพร็อพเพอร์ตี้รายการเดียว การปรับปรุง 2 รายการนี้เป็นส่วนหนึ่งของ CL นี้ และช่วยลดการทํางานช้าลงอย่างมากสําหรับตัวอย่างที่รายงานใน chromium:1069425

Error.stack

เราอาจจบการแชทกันตรงนี้ แต่มีความผิดปกติเกิดขึ้นเนื่องจากเครื่องมือสำหรับนักพัฒนาเว็บไม่เคยใช้ชื่อเมธอดสำหรับเฟรมสแต็ก อันที่จริงแล้ว คลาส v8::StackFrame ใน C++ API ไม่ได้แสดงวิธีเข้าถึงชื่อเมธอดด้วยซ้ำ ดังนั้นการโทรหา JSStackFrame::GetMethodName() ในตอนแรกจึงดูไม่ถูกต้อง แต่เราจะใช้ (และแสดง) ชื่อเมธอดใน JavaScript Stack Trace API เท่านั้น ลองดูตัวอย่างง่ายๆ ต่อไปนี้ error-methodname.js เพื่อทําความเข้าใจการใช้งานนี้

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

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

ที่นี่เรามีฟังก์ชัน foo ที่ติดตั้งภายใต้ชื่อ "bar" ใน object การแสดงผลของข้อมูลโค้ดนี้ใน Chromium จะแสดงผลลัพธ์ต่อไปนี้

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

ในที่นี้ เราจะเห็นการค้นหาชื่อเมธอด: เฟรมสแต็กด้านบนสุดแสดงขึ้นเพื่อเรียกใช้ฟังก์ชัน foo ในอินสแตนซ์ของ Object ผ่านเมธอดชื่อ bar ดังนั้นพร็อพเพอร์ตี้ error.stack ที่ไม่เป็นไปตามมาตรฐานจึงใช้ JSStackFrame::GetMethodName() เป็นจำนวนมาก และจากการทดสอบประสิทธิภาพพบว่าการเปลี่ยนแปลงของเราทําให้ทุกอย่างเร็วขึ้นอย่างมาก

เพิ่มความเร็วในการเปรียบเทียบข้อมูลไมโครสแต็กเทรซ

แต่กลับไปที่หัวข้อเครื่องมือสำหรับนักพัฒนาเว็บใน Chrome การที่ระบบประมวลผลชื่อเมธอดแม้ว่าจะไม่มีการใช้ error.stack นั้นดูไม่ถูกต้อง ประวัติบางส่วนที่เป็นประโยชน์มีดังนี้ เดิมที V8 มีกลไก 2 กลไกที่แตกต่างกันในการรวบรวมและแสดงสแต็กเทรซสําหรับ API 2 รายการที่อธิบายไว้ข้างต้น (v8::StackFrame API ของ C++ และ API สแต็กเทรซของ JavaScript) การมี 2 วิธีที่แตกต่างกันในการทํางานแบบเดียวกัน (โดยประมาณ) มีแนวโน้มที่จะเกิดข้อผิดพลาดและมักทําให้เกิดความไม่สอดคล้องและข้อบกพร่อง ดังนั้นในช่วงปลายปี 2018 เราได้เริ่มโปรเจ็กต์เพื่อหาทางออกเดียวสําหรับการบันทึกสแต็กเทรซ

โปรเจ็กต์ดังกล่าวประสบความสําเร็จอย่างมากและลดจํานวนปัญหาที่เกี่ยวข้องกับการเก็บรวบรวมสแต็กเทรซได้อย่างมาก ข้อมูลส่วนใหญ่ที่ระบุผ่านพร็อพเพอร์ตี้ error.stack ที่ไม่เป็นไปตามมาตรฐานได้รับการคํานวณแบบล่าช้าและเฉพาะเมื่อจําเป็นเท่านั้น แต่เราใช้เทคนิคเดียวกันกับออบเจ็กต์ v8::StackFrame ในการรีแฟกทอริงด้วย ระบบจะคํานวณข้อมูลทั้งหมดเกี่ยวกับเฟรมสแต็กเมื่อมีการเรียกใช้เมธอดใดก็ตามในเฟรมนั้นเป็นครั้งแรก

โดยทั่วไปแล้วการดำเนินการนี้จะปรับปรุงประสิทธิภาพ แต่น่าเสียดายที่การดำเนินการนี้กลับขัดแย้งกับวิธีใช้ออบเจ็กต์ C++ API เหล่านี้ใน Chromium และเครื่องมือสำหรับนักพัฒนาซอฟต์แวร์ โดยเฉพาะอย่างยิ่งเมื่อเราเปิดตัวคลาส v8::internal::StackFrameInfo ใหม่ซึ่งมีข้อมูลทั้งหมดเกี่ยวกับสแต็กเฟรมที่แสดงผ่าน v8::StackFrame หรือผ่าน error.stack เราจะคำนวณชุดข้อมูลย่อยของข้อมูลที่ได้จากทั้ง 2 API เสมอ ซึ่งหมายความว่าสำหรับการใช้งาน v8::StackFrame (และโดยเฉพาะอย่างยิ่งสำหรับ DevTools) เราจะคำนวณชื่อเมธอดด้วยทันทีที่มีคำขอข้อมูลเกี่ยวกับสแต็กเฟรม ปรากฏว่าเครื่องมือสำหรับนักพัฒนาเว็บจะขอข้อมูลแหล่งที่มาและสคริปต์ทันทีเสมอ

จากการค้นพบนี้ เราจึงสามารถปรับโครงสร้างและลดความซับซ้อนของการแสดงสแต็กเฟรมได้อย่างมาก และทำให้ทำงานได้น้อยลงกว่าเดิม เพื่อให้การใช้งานใน V8 และ Chromium เสียค่าใช้จ่ายเฉพาะสำหรับการประมวลผลข้อมูลที่ขอเท่านั้น การดำเนินการนี้ช่วยเพิ่มประสิทธิภาพของเครื่องมือสำหรับนักพัฒนาซอฟต์แวร์และกรณีการใช้งานอื่นๆ ของ Chromium อย่างมาก ซึ่งต้องใช้ข้อมูลเพียงเศษเสี้ยวเกี่ยวกับเฟรมสแต็ก (โดยพื้นฐานแล้วคือชื่อสคริปต์และตำแหน่งแหล่งที่มาในรูปแบบออฟเซตบรรทัดและคอลัมน์) และเปิดโอกาสให้ปรับปรุงประสิทธิภาพได้มากขึ้น

ชื่อฟังก์ชัน

เมื่อการแยกส่วนโค้ดข้างต้นเสร็จสิ้นแล้ว ค่าใช้จ่ายเพิ่มเติมของการทำสัญลักษณ์ (เวลาที่ใช้ใน v8_inspector::V8Debugger::symbolize) ลดลงเหลือประมาณ 15% ของเวลาในการดำเนินการโดยรวม และเราเห็นได้ชัดเจนขึ้นว่า V8 ใช้เวลาไปกับอะไรบ้างเมื่อ (รวบรวมและ) ทำสัญลักษณ์เฟรมสแต็กเพื่อใช้งานใน DevTools

ค่าใช้จ่ายในการแปลง

สิ่งแรกที่โดดเด่นคือต้นทุนสะสมสําหรับการคํานวณจํานวนแถวและคอลัมน์ ส่วนที่เป็นค่าใช้จ่ายสูงจริงๆ คือการคำนวณออฟเซ็ตของตัวละครภายในสคริปต์ (อิงตามออฟเซ็ตของไบต์โค้ดที่เราได้รับจาก V8) และปรากฏว่าเนื่องจากการรีแฟกทอริงข้างต้น เราทําการคำนวณนี้ 2 ครั้ง 1 ครั้งเมื่อคํานวณหมายเลขบรรทัด และอีก 1 ครั้งเมื่อคํานวณหมายเลขคอลัมน์ การแคชตําแหน่งแหล่งที่มาในอินสแตนซ์ v8::internal::StackFrameInfo ช่วยแก้ปัญหานี้ได้อย่างรวดเร็วและนํา v8::internal::StackFrameInfo::GetColumnNumber ออกจากโปรไฟล์ทั้งหมด

สิ่งที่น่าสนใจกว่าคือ v8::StackFrame::GetFunctionName สูงอย่างน่าประหลาดใจในโปรไฟล์ทั้งหมดที่เราดู เมื่อเจาะลึกเรื่องนี้ เราพบว่าการคำนวณชื่อที่จะแสดงสำหรับฟังก์ชันในสแต็กเฟรมในเครื่องมือสำหรับนักพัฒนาซอฟต์แวร์นั้นไม่จำเป็น

  1. ก่อนอื่นให้มองหาพร็อพเพอร์ตี้ "displayName" ที่ไม่เป็นไปตามมาตรฐาน และหากพร็อพเพอร์ตี้ดังกล่าวให้พร็อพเพอร์ตี้ข้อมูลที่มีค่าสตริง เราจะใช้พร็อพเพอร์ตี้นั้น
  2. ไม่เช่นนั้นให้กลับไปค้นหาพร็อพเพอร์ตี้ "name" มาตรฐาน และตรวจสอบอีกครั้งว่าพร็อพเพอร์ตี้ดังกล่าวให้พร็อพเพอร์ตี้ข้อมูลที่มีค่าเป็นสตริงหรือไม่
  3. และสุดท้ายจะกลับไปใช้ชื่อการแก้ไขข้อบกพร่องภายในที่แยกแยะโดยโปรแกรมแยกวิเคราะห์ V8 และจัดเก็บไว้ในนิพจน์ฟังก์ชัน

เราได้เพิ่มพร็อพเพอร์ตี้ "displayName" ไว้เป็นวิธีแก้ปัญหาสำหรับพร็อพเพอร์ตี้ "name" ในอินสแตนซ์ Function ที่เป็นแบบอ่านอย่างเดียวและไม่สามารถกําหนดค่าได้ใน JavaScript แต่ไม่เคยมีการทำให้เป็นมาตรฐานและไม่ได้ใช้งานอย่างแพร่หลาย เนื่องจากเครื่องมือสําหรับนักพัฒนาซอฟต์แวร์เบราว์เซอร์ได้เพิ่มการอนุมานชื่อฟังก์ชันที่ทํางานได้ 99.9% ของกรณี นอกจากนี้ ES2015 ยังทำให้พร็อพเพอร์ตี้ "name" ในอินสแตนซ์ Function กำหนดค่าได้ จึงไม่จำเป็นต้องใช้พร็อพเพอร์ตี้ "displayName" พิเศษอีกต่อไป เนื่องจากการค้นหาเชิงลบสำหรับ "displayName" มีค่าใช้จ่ายค่อนข้างสูงและไม่จำเป็นจริงๆ (ES2015 เปิดตัวไปกว่า 5 ปีแล้ว) เราจึงตัดสินใจนำการรองรับพร็อพเพอร์ตี้ fn.displayName ที่ไม่เป็นไปตามมาตรฐานออกจาก V8 (และเครื่องมือสำหรับนักพัฒนาซอฟต์แวร์)

เมื่อการค้นหาเชิงลบของ "displayName" เสร็จสิ้นแล้ว ระบบได้นำต้นทุนของ v8::StackFrame::GetFunctionName ออกครึ่งหนึ่ง ส่วนที่เหลือจะนำไปที่การค้นหาพร็อพเพอร์ตี้ "name" ทั่วไป แต่โชคดีที่เรามีตรรกะบางอย่างอยู่แล้วเพื่อหลีกเลี่ยงการค้นหาพร็อพเพอร์ตี้ "name" ในอินสแตนซ์ Function (ที่ไม่ได้แตะต้อง) ซึ่งเราเปิดตัวใน V8 เมื่อสักครู่เพื่อทําให้ Function.prototype.bind() ทำงานได้เร็วขึ้น เราได้พอร์ตการตรวจสอบที่จําเป็น ซึ่งช่วยให้เราข้ามการค้นหาทั่วไปที่มีค่าใช้จ่ายสูงตั้งแต่แรกได้ ผลที่ได้คือ v8::StackFrame::GetFunctionName ไม่แสดงในโปรไฟล์ใดๆ ที่เราพิจารณาอีกต่อไป

บทสรุป

การปรับปรุงข้างต้นช่วยลดค่าใช้จ่ายเพิ่มเติมของ DevTools ในแง่ของสแต็กเทรซได้อย่างมาก

เราทราบดีว่ายังมีการปรับปรุงที่เป็นไปได้อีกมากมาย เช่น ค่าใช้จ่ายเพิ่มเติมเมื่อใช้ MutationObserver ยังคงสังเกตได้อยู่ตามที่รายงานใน chromium:1077657 แต่ตอนนี้เราได้แก้ไขปัญหาหลักๆ แล้ว และอาจกลับมาดำเนินการเพิ่มเติมในอนาคตเพื่อปรับปรุงประสิทธิภาพการแก้ไขข้อบกพร่องให้ดียิ่งขึ้น

ดาวน์โหลดแชแนลตัวอย่าง

ลองใช้ Chrome Canary, Dev หรือ เบต้า เป็นเบราว์เซอร์สำหรับนักพัฒนาซอฟต์แวร์เริ่มต้น ช่องทางเวอร์ชันตัวอย่างเหล่านี้จะช่วยให้คุณเข้าถึงฟีเจอร์ล่าสุดของ DevTools, ทดสอบ API ของแพลตฟอร์มเว็บที่ล้ำสมัย และช่วยคุณค้นหาปัญหาในเว็บไซต์ได้ก่อนที่ผู้ใช้จะพบ

ติดต่อทีมเครื่องมือสำหรับนักพัฒนาเว็บใน Chrome

ใช้ตัวเลือกต่อไปนี้เพื่อพูดคุยเกี่ยวกับฟีเจอร์ใหม่ การอัปเดต หรือสิ่งอื่นๆ ที่เกี่ยวข้องกับเครื่องมือสำหรับนักพัฒนาเว็บ