เคล็ดลับประสิทธิภาพสำหรับ JavaScript ใน V8

Chris Wilson
Chris Wilson

บทนำ

Daniel Clifford ได้พูดคุยอย่างยอดเยี่ยมที่ Google I/O เกี่ยวกับเคล็ดลับในการปรับปรุงประสิทธิภาพ JavaScript ใน V8 Daniel สนับสนุนให้เรา "เรียกร้องให้เร็วขึ้น" ซึ่งก็คือให้วิเคราะห์ความแตกต่างด้านประสิทธิภาพระหว่าง C++ กับ JavaScript อย่างละเอียด และเขียนโค้ดโดยคำนึงถึงวิธีการทํางานของ JavaScript สรุปประเด็นสําคัญที่สุดของการบรรยายของ Daniel มีอยู่ในบทความนี้ และเราจะอัปเดตบทความนี้อยู่เสมอเมื่อมีการเปลี่ยนแปลงคําแนะนําด้านประสิทธิภาพ

คำแนะนำที่สำคัญที่สุด

สิ่งสำคัญคือต้องพิจารณาคำแนะนำด้านประสิทธิภาพในบริบท คำแนะนำด้านประสิทธิภาพเป็นสิ่งที่น่าดึงดูดใจ และบางครั้งการมุ่งเน้นที่คำแนะนำเชิงลึกก่อนอาจทำให้เสียสมาธิจากปัญหาจริง คุณต้องพิจารณาประสิทธิภาพของเว็บแอปพลิเคชันแบบองค์รวม ก่อนที่จะมุ่งเน้นที่เคล็ดลับด้านประสิทธิภาพเหล่านี้ คุณควรวิเคราะห์โค้ดด้วยเครื่องมืออย่าง PageSpeed เพื่อยกระดับคะแนน ซึ่งจะช่วยหลีกเลี่ยงการเพิ่มประสิทธิภาพก่อนเวลาอันควร

คำแนะนำเบื้องต้นที่ดีที่สุดเพื่อให้ได้ประสิทธิภาพที่ดีในเว็บแอปพลิเคชันคือ

  • เตรียมพร้อมก่อนเกิด (หรือสังเกตเห็น) ปัญหา
  • จากนั้นให้ระบุและทำความเข้าใจประเด็นสำคัญของปัญหา
  • สุดท้าย ให้แก้ไขสิ่งที่สำคัญ

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

มาเริ่มดูเคล็ดลับเกี่ยวกับ V8 กัน

ชั้นเรียนที่ซ่อนอยู่

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

เช่น

function Point(x, y) {
  this.x = x;
  this.y = y;
}

var p1 = new Point(11, 22);
var p2 = new Point(33, 44);
// At this point, p1 and p2 have a shared hidden class
p2.z = 55;
// warning! p1 and p2 now have different hidden classes!```

จนกว่าอินสแตนซ์ออบเจ็กต์ p2 จะมีการเพิ่มสมาชิก ".z" เพิ่มเติม p1 และ p2 จะมีคลาสที่ซ่อนอยู่เหมือนกันภายใน V8 จึงสามารถสร้างแอสเซมบลีที่ได้รับการเพิ่มประสิทธิภาพเวอร์ชันเดียวสําหรับโค้ด JavaScript ที่จัดการ p1 หรือ p2 ยิ่งคุณหลีกเลี่ยงไม่ให้คลาสที่ซ่อนอยู่แตกต่างกันมากเท่าใด ประสิทธิภาพก็จะยิ่งดีขึ้นเท่านั้น

ดังนั้น

  • เริ่มต้นสมาชิกออบเจ็กต์ทั้งหมดในฟังก์ชันคอนสตรัคเตอร์ (เพื่อให้อินสแตนซ์ไม่เปลี่ยนประเภทในภายหลัง)
  • เริ่มต้นสมาชิกออบเจ็กต์ตามลําดับเดิมเสมอ

Numbers

V8 ใช้การติดแท็กเพื่อแสดงค่าอย่างมีประสิทธิภาพเมื่อประเภทสามารถเปลี่ยนแปลงได้ V8 จะอนุมานจากค่าที่คุณใช้ว่ากำลังจัดการกับตัวเลขประเภทใด เมื่อ V8 สร้างการอนุมานนี้ ก็จะใช้การติดแท็กเพื่อแสดงค่าอย่างมีประสิทธิภาพ เนื่องจากประเภทเหล่านี้อาจเปลี่ยนแปลงแบบไดนามิกได้ อย่างไรก็ตาม บางครั้งการเปลี่ยนแท็กประเภทเหล่านี้อาจทำให้เกิดค่าใช้จ่าย ดังนั้นจึงควรใช้ประเภทตัวเลขอย่างสอดคล้องกัน และโดยทั่วไปแล้ว การใช้จำนวนเต็มแบบมีเครื่องหมาย 31 บิตตามความเหมาะสมจะเหมาะสมที่สุด

เช่น

var i = 42;  // this is a 31-bit signed integer
var j = 4.2;  // this is a double-precision floating point number```

ดังนั้น

  • โปรดใช้ค่าตัวเลขที่แสดงเป็นจำนวนเต็มแบบมีเครื่องหมาย 31 บิต

อาร์เรย์

พื้นที่เก็บข้อมูลอาร์เรย์ภายในมี 2 ประเภทเพื่อจัดการอาร์เรย์ขนาดใหญ่และแบบกะทัดรัด

  • Fast Elements: พื้นที่เก็บข้อมูลเชิงเส้นสำหรับชุดคีย์ที่กะทัดรัด
  • องค์ประกอบของพจนานุกรม: พื้นที่เก็บข้อมูลตารางแฮช

คุณไม่ควรทําให้พื้นที่เก็บข้อมูลอาร์เรย์เปลี่ยนจากประเภทหนึ่งเป็นอีกประเภทหนึ่ง

ดังนั้น

  • ใช้คีย์ต่อเนื่องที่เริ่มต้นที่ 0 สำหรับอาร์เรย์
  • อย่าจัดสรรอาร์เรย์ขนาดใหญ่ (เช่น องค์ประกอบมากกว่า 64, 000 รายการ) ล่วงหน้าเป็นขนาดสูงสุด แต่ให้เพิ่มขนาดไปเรื่อยๆ
  • อย่าลบองค์ประกอบในอาร์เรย์ โดยเฉพาะอาร์เรย์ตัวเลข
  • ห้ามโหลดองค์ประกอบที่ไม่ได้เริ่มต้นหรือถูกลบ
for (var b = 0; b < 10; b++) {
  a[0] |= b;  // Oh no!
}
//vs.
a = new Array();
a[0] = 0;
for (var b = 0; b < 10; b++) {
  a[0] |= b;  // Much better! 2x faster.
}

นอกจากนี้ อาร์เรย์ของเลขทศนิยมยังทำงานได้เร็วกว่าด้วย เนื่องจากคลาสที่ซ่อนอยู่ของอาร์เรย์จะติดตามประเภทองค์ประกอบ และอาร์เรย์ที่มีเฉพาะเลขทศนิยมจะยกเลิกการบรรจุ (ซึ่งทําให้คลาสที่ซ่อนอยู่มีการเปลี่ยนแปลง) อย่างไรก็ตาม การจัดการอาร์เรย์อย่างไม่ระมัดระวังอาจทําให้ต้องทํางานเพิ่มเนื่องจากการบรรจุและการยกเลิกการบรรจุ เช่น

var a = new Array();
a[0] = 77;   // Allocates
a[1] = 88;
a[2] = 0.5;   // Allocates, converts
a[3] = true; // Allocates, converts```

มีประสิทธิภาพน้อยกว่า

var a = [77, 88, 0.5, true];

เนื่องจากในตัวอย่างนี้ แต่ละการกําหนดค่าจะทํางานทีละรายการ และการกําหนดค่า a[2] ทําให้ระบบแปลงอาร์เรย์เป็นอาร์เรย์ของเลขทศนิยมแบบไม่แปลงค่า แต่การกําหนดค่า a[3] ทําให้ระบบแปลงกลับเป็นอาร์เรย์ที่อาจมีค่าใดก็ได้ (ตัวเลขหรือออบเจ็กต์) ในกรณีที่ 2 คอมไพเลอร์จะทราบประเภทขององค์ประกอบทั้งหมดในลิเทอรัล และสามารถกำหนดคลาสที่ซ่อนไว้ล่วงหน้าได้

  • เริ่มต้นโดยใช้นิพจน์อาร์เรย์สำหรับอาร์เรย์ขนาดเล็กที่มีขนาดคงที่
  • จัดสรรอาร์เรย์ขนาดเล็กไว้ล่วงหน้า (<64k) เพื่อแก้ไขขนาดก่อนใช้งาน
  • อย่าจัดเก็บค่าที่ไม่ใช่ตัวเลข (ออบเจ็กต์) ในอาร์เรย์ตัวเลข
  • โปรดระวังอย่าทำให้เกิดการแปลงอาร์เรย์ขนาดเล็กซ้ำ ถ้าคุณเริ่มต้นโดยไม่มีลิเทอรัล

การคอมไพล์ JavaScript

แม้ว่า JavaScript จะเป็นภาษาที่มีการเปลี่ยนแปลงได้อย่างมาก และการใช้งานแบบดั้งเดิมของ JavaScript เป็นล่าม แต่เครื่องมือรันไทม์ของ JavaScript สมัยใหม่ก็ใช้การคอมไพล์ V8 (JavaScript ของ Chrome) มีคอมไพเลอร์ Just-In-Time (JIT) 2 แบบด้วยกัน ได้แก่

  • คอมไพเลอร์ "แบบสมบูรณ์" ซึ่งสามารถสร้างโค้ดที่มีประสิทธิภาพสำหรับ JavaScript ใดก็ได้
  • คอมไพเลอร์เพิ่มประสิทธิภาพ ซึ่งจะสร้างโค้ดที่ยอดเยี่ยมสำหรับ JavaScript ส่วนใหญ่ แต่ใช้เวลาในการคอมไพล์นานกว่า

คอมไพเลอร์แบบสมบูรณ์

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

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

ดังนั้น

  • แนะนำให้ใช้การดำเนินการแบบโมโนโมฟิกมากกว่าการดำเนินการแบบโพลีมอร์ฟิก

การดำเนินการจะเป็นแบบโมโนโมร์ฟิคหากคลาสที่ซ่อนของอินพุตเหมือนกันเสมอ ไม่เช่นนั้นจะเป็นแบบโพลีโมร์ฟิค ซึ่งหมายความว่าอาร์กิวเมนต์บางรายการอาจเปลี่ยนประเภทในการเรียกใช้การดำเนินการที่แตกต่างกัน ตัวอย่างเช่น การเรียก add() ครั้งที่ 2 ในตัวอย่างนี้ก่อให้เกิดพฤติกรรมโพลีมอร์ฟิส

function add(x, y) {
  return x + y;
}

add(1, 2);      // + in add is monomorphic
add("a", "b");  // + in add becomes polymorphic```

คอมไพเลอร์เพิ่มประสิทธิภาพ

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

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

คุณสามารถบันทึกสิ่งที่ได้รับการเพิ่มประสิทธิภาพโดยใช้เครื่องมือ V8 เวอร์ชัน "d8" แบบสแตนด์อโลนได้ ดังนี้

d8 --trace-opt primes.js

(การดำเนินการนี้จะบันทึกชื่อของฟังก์ชันที่ได้รับการเพิ่มประสิทธิภาพไปยัง stdout)

อย่างไรก็ตาม บางฟังก์ชันอาจเพิ่มประสิทธิภาพไม่ได้ ฟีเจอร์บางอย่างทำให้คอมไพเลอร์การเพิ่มประสิทธิภาพไม่ทำงานในฟังก์ชันที่กำหนด ("การประกันตัว") โดยเฉพาะอย่างยิ่ง คอมไพเลอร์การเพิ่มประสิทธิภาพจะหยุดทำงานในฟังก์ชันที่มีบล็อก try {} catch {}

ดังนั้น

  • ใส่โค้ดที่ละเอียดอ่อนต่อประสิทธิภาพไว้ในฟังก์ชันที่ฝังอยู่หากคุณมีบล็อก try {} catch {} ดังนี้ ```js function perf_sensitive() { // Do performance-sensitive work here }

try { perf_sensitive() } catch (e) { // Handle exceptions here } ```

คำแนะนำนี้อาจมีการเปลี่ยนแปลงในอนาคต เนื่องจากเราเปิดใช้บล็อกทดสอบ/ตรวจจับในคอมไพเลอร์ที่มีการเพิ่มประสิทธิภาพ คุณสามารถตรวจสอบวิธีที่คอมไพเลอร์การเพิ่มประสิทธิภาพยกเลิกการดำเนินการในฟังก์ชันต่างๆ โดยใช้ตัวเลือก "--trace-opt" กับ d8 ดังที่แสดงด้านบน ซึ่งจะให้ข้อมูลเพิ่มเติมเกี่ยวกับฟังก์ชันที่ถูกยกเลิกการดำเนินการ

d8 --trace-opt primes.js

การยกเลิกการเพิ่มประสิทธิภาพ

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

ดังนั้น

  • หลีกเลี่ยงการเปลี่ยนแปลงคลาสที่ซ่อนอยู่ในฟังก์ชันหลังจากเพิ่มประสิทธิภาพแล้ว

คุณสามารถดูบันทึกของฟังก์ชันที่ V8 ต้องยกเลิกการเพิ่มประสิทธิภาพด้วย Flag การบันทึก เช่นเดียวกับการเพิ่มประสิทธิภาพอื่นๆ ดังนี้

d8 --trace-deopt primes.js

เครื่องมืออื่นๆ ของ V8

นอกจากนี้ คุณยังส่งตัวเลือกการติดตาม V8 ไปยัง Chrome เมื่อเริ่มต้นได้ด้วย โดยทำดังนี้

"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --js-flags="--trace-opt --trace-deopt"```

นอกจากการใช้เครื่องมือสำหรับนักพัฒนาซอฟต์แวร์ในการสํารวจแล้ว คุณยังใช้ d8 ในการสํารวจได้ด้วย โดยทําดังนี้

% out/ia32.release/d8 primes.js --prof

ฟีเจอร์นี้ใช้เครื่องมือสร้างโปรไฟล์การสุ่มตัวอย่างในตัว ซึ่งจะสุ่มตัวอย่างทุกมิลลิวินาทีและเขียน v8.log

ข้อมูลสรุป

คุณควรระบุและทำความเข้าใจวิธีที่เครื่องมือ V8 ทำงานร่วมกับโค้ดของคุณเพื่อเตรียมสร้าง JavaScript ที่มีประสิทธิภาพ ขอย้ำคำแนะนำพื้นฐานอีกครั้ง

  • เตรียมพร้อมก่อนเกิด (หรือสังเกตเห็น) ปัญหา
  • จากนั้นให้ระบุและทำความเข้าใจประเด็นสำคัญของปัญหา
  • สุดท้าย ให้แก้ไขสิ่งที่สำคัญ

ซึ่งหมายความว่าคุณควรตรวจสอบว่าปัญหาอยู่ใน JavaScript โดยใช้เครื่องมืออื่นๆ เช่น PageSpeed ก่อน โดยอาจลดเป็น JavaScript ล้วนๆ (ไม่มี DOM) ก่อนรวบรวมเมตริก จากนั้นใช้เมตริกเหล่านั้นเพื่อหาจุดคอขวดและกำจัดจุดคอขวดที่สำคัญ หวังว่าการพูดคุยของ Daniel (และบทความนี้) จะช่วยให้คุณเข้าใจมากขึ้นว่า V8 เรียกใช้ JavaScript อย่างไร แต่โปรดมุ่งเน้นที่การเพิ่มประสิทธิภาพอัลกอริทึมของคุณเองด้วย

ข้อมูลอ้างอิง