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

Benedikt Meurer
Benedikt Meurer

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

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

แต่แน่นอนว่าค่าใช้จ่ายสำหรับเครื่องมือสำหรับนักพัฒนาเว็บก็สมเหตุสมผลเช่นเดียวกับโปรแกรมแก้ไขข้อบกพร่องทั่วไป เมื่อเร็วๆ นี้ เราพบว่ามีรายงานจำนวนเพิ่มขึ้นอย่างมากว่าในบางกรณี 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 เราจะคำนวณชุดข้อมูลขั้นสูงจาก API ทั้ง 2 ชุดเสมอ ซึ่งหมายความว่าสำหรับการใช้งาน v8::StackFrame (โดยเฉพาะอย่างยิ่งสำหรับเครื่องมือสำหรับนักพัฒนาเว็บ) เราจะคำนวณชื่อเมธอดทันทีที่มีการขอข้อมูลเกี่ยวกับสแต็กเฟรม ปรากฏว่าเครื่องมือสำหรับนักพัฒนาเว็บจะขอข้อมูลแหล่งที่มาและสคริปต์ทันทีเสมอ

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

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

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

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

สิ่งแรกที่โดดเด่นคือต้นทุนสะสมสําหรับการคํานวณจํานวนแถวและคอลัมน์ ส่วนที่เป็นค่าใช้จ่ายสูงจริงๆ คือการคำนวณออฟเซตของตัวละครภายในสคริปต์ (อิงตามออฟเซตของไบต์โค้ดที่เราได้รับจาก 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 DevTools

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