เคล็ดลับประสิทธิภาพสำหรับ 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 บิตได้

อาร์เรย์

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

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

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

ดังนั้น

  • ใช้คีย์ที่อยู่ติดกันโดยเริ่มจาก 0 สำหรับอาร์เรย์
  • อย่าจัดสรรอาร์เรย์ขนาดใหญ่ไว้ล่วงหน้า (เช่น องค์ประกอบมากกว่า 64K) ให้มีขนาดสูงสุด แต่ให้เพิ่มขนาดตามความจำเป็น
  • อย่าลบองค์ประกอบในอาร์เรย์ โดยเฉพาะอาร์เรย์ตัวเลข
  • อย่าโหลดองค์ประกอบที่ไม่ได้เริ่มต้นหรือถูกลบ:
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.
}

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

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] ทำให้ระบบแปลงอาร์เรย์เป็นอาร์เรย์ของ Double ที่ไม่ได้อยู่ในออบเจ็กต์ แต่การกำหนดค่า a[3] ทำให้ระบบแปลงกลับเป็นอาร์เรย์ที่สามารถมีค่าใดก็ได้ (ตัวเลขหรือออบเจ็กต์) ในกรณีที่ 2 คอมไพเลอร์จะทราบประเภทขององค์ประกอบทั้งหมดในลิเทอรัล และสามารถกำหนดคลาสที่ซ่อนอยู่ล่วงหน้าได้

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

การคอมไพล์ 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 } ```

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

d8 --trace-opt primes.js

การลดประสิทธิภาพ

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

ดังนั้น

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

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

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 อย่างไร แต่อย่าลืมมุ่งเน้นที่การเพิ่มประสิทธิภาพอัลกอริทึมของคุณเองด้วย

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