เกริ่นนำ
Daniel Clifford จัดการพูดคุยที่ยอดเยี่ยมในงาน Google I/O เกี่ยวกับกลเม็ดเคล็ดลับในการปรับปรุงประสิทธิภาพของ JavaScript ในเวอร์ชัน 8 Daniel กระตุ้นให้เรา "เรียกใช้เร็วขึ้น" เพื่อวิเคราะห์ความแตกต่างของประสิทธิภาพระหว่าง C++ และ JavaScript อย่างรอบคอบ และเขียนโค้ดอย่างรอบคอบเกี่ยวกับวิธีการทำงานของ JavaScript เราได้สรุปประเด็นสำคัญที่สุดในการพูดคุยของภานุพงศ์ไว้ในบทความนี้ และเราจะอัปเดตบทความนี้อยู่เสมอเมื่อคำแนะนำด้านประสิทธิภาพเปลี่ยนแปลงไป
คำแนะนำที่สำคัญที่สุด
คุณควรใส่คำแนะนำด้านประสิทธิภาพในบริบท การให้คำแนะนำเรื่องประสิทธิภาพเป็นสิ่งที่สื่อไปไม่ได้ และบางครั้งการมุ่งเน้นไปที่คำแนะนำที่เจาะลึกก่อนก็อาจทำให้เสียสมาธิจากปัญหาจริงได้ คุณต้องดูประสิทธิภาพของเว็บแอปพลิเคชันแบบองค์รวม ก่อนที่จะให้ความสำคัญกับเคล็ดลับด้านประสิทธิภาพเหล่านี้ คุณควรวิเคราะห์โค้ดของคุณด้วยเครื่องมือ เช่น 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 ประเภท ได้แก่
- องค์ประกอบที่รวดเร็ว: พื้นที่เก็บข้อมูลเชิงเส้นสำหรับชุดคีย์แบบกะทัดรัด
- องค์ประกอบพจนานุกรม: พื้นที่เก็บข้อมูลตารางแฮช
คุณไม่ควรทำให้ที่เก็บอาร์เรย์พลิกจากประเภทหนึ่งไปเป็นอีกประเภทหนึ่ง
ดังนั้น
- ใช้คีย์ต่อเนื่องที่เริ่มต้นที่ 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.
}
นอกจากนี้ อาร์เรย์ของค่าคู่ยังทำงานเร็วกว่า ประเภทขององค์ประกอบแทร็กคลาสที่ซ่อนอยู่ของอาร์เรย์ และอาร์เรย์ที่มีเฉพาะเลขคู่จะถูกถอดกล่องออก (ซึ่งทำให้มีการเปลี่ยนแปลงคลาสที่ซ่อนอยู่) อย่างไรก็ตาม การบิดเบือนของอาร์เรย์อาจทำให้งานยากขึ้น เนื่องจากการต่อมวยและการแกะกล่อง เช่น
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]
ทำให้ระบบแปลงค่าดังกล่าวเป็นอาร์เรย์ซึ่งมีค่าใดก็ได้ (ตัวเลขหรือวัตถุ) ในกรณีที่สอง คอมไพเลอร์จะรู้ประเภทขององค์ประกอบทั้งหมดในตัวอักษรและสามารถระบุคลาสที่ซ่อนได้ตั้งแต่แรก
- เริ่มต้นโดยใช้ลิเทอรัลอาร์เรย์สำหรับอาร์เรย์ขนาดคงที่ขนาดเล็ก
- จัดสรรอาร์เรย์ขนาดเล็กไว้ล่วงหน้า (<64k) เพื่อแก้ไขขนาดก่อนใช้งาน
- ไม่จัดเก็บค่าที่ไม่ใช่ตัวเลข (วัตถุ) ในอาร์เรย์ตัวเลข
- โปรดระวังอย่าทำให้เกิดการแปลงอาร์เรย์ขนาดเล็กซ้ำหากคุณเริ่มต้นโดยไม่ใช้ลิเทอรัล
คอมไพล์ JavaScript
แม้ว่า JavaScript จะเป็นภาษาที่มีการเปลี่ยนแปลงอยู่ตลอดเวลา และการใช้งานดั้งเดิมก็เป็นล่าม แต่เครื่องมือรันไทม์ JavaScript สมัยใหม่จะใช้การคอมไพล์ V8 (JavaScript ของ Chrome) มีคอมไพเลอร์ Just-In-Time (JIT) ที่แตกต่างกัน 2 แบบ ดังนี้
- คอมไพเลอร์ "เต็มรูปแบบ" ซึ่งสามารถสร้างโค้ดที่ดีสำหรับ JavaScript
- คอมไพเลอร์ที่เพิ่มประสิทธิภาพ ซึ่งจะสร้างโค้ดที่ดีสำหรับ JavaScript ส่วนใหญ่ แต่ใช้เวลาคอมไพล์นานกว่า
คอมไพเลอร์แบบเต็ม
ใน V8 คอมไพเลอร์ Full จะทำงานกับโค้ดทั้งหมด และเริ่มเรียกใช้โค้ดโดยเร็วที่สุด โดยสร้างโค้ดที่ดีแต่ไม่ดีอย่างรวดเร็ว คอมไพเลอร์นี้แทบจะไม่ถือว่ามีประเภทใดๆ เมื่อทำการรวบรวม โดยคาดว่าตัวแปรประเภทต่างๆ จะมีการเปลี่ยนแปลงได้ขณะรันไทม์ โค้ดที่สร้างโดยคอมไพเลอร์ Full ใช้แคชแบบอินไลน์ (IC) ในการปรับแต่งความรู้เกี่ยวกับประเภทต่างๆ ขณะที่โปรแกรมทำงาน ซึ่งช่วยเพิ่มประสิทธิภาพได้ในทันที
เป้าหมายของแคชแบบอินไลน์คือการจัดการประเภทต่างๆ อย่างมีประสิทธิภาพ โดยการแคชโค้ดที่ขึ้นอยู่กับประเภทสำหรับการดำเนินการ เมื่อโค้ดทำงาน โค้ดจะตรวจสอบสมมติฐานประเภทก่อน จากนั้นจึงใช้แคชแบบอินไลน์เพื่อลัดการดำเนินการ อย่างไรก็ตาม นี่หมายความว่าการดำเนินการที่ยอมรับหลายประเภทจะมีประสิทธิภาพน้อยกว่า
ดังนั้น
- ควรใช้การดำเนินการแบบโมโนมอร์ฟิกมากกว่าการดําเนินการแบบโพลีมอร์ฟิก
การดำเนินการจะเป็นแบบโมโนมอร์ฟิก หากคลาสของอินพุตที่ซ่อนอยู่เหมือนเดิมเสมอ มิฉะนั้นจะเป็นโพลีมอร์ฟิก ซึ่งหมายความว่าอาร์กิวเมนต์บางส่วนสามารถเปลี่ยนประเภทการเรียกใช้การดำเนินการต่างๆ ได้ ตัวอย่างเช่น การเรียก add() ที่สองในตัวอย่างนี้ทำให้เกิดรูปหลายเหลี่ยม
function add(x, y) {
return x + y;
}
add(1, 2); // + in add is monomorphic
add("a", "b"); // + in add becomes polymorphic```
คอมไพเลอร์ที่เพิ่มประสิทธิภาพ
V8 คอมไพล์ฟังก์ชัน "hot" ขึ้นมาใหม่ (ซึ่งเป็นฟังก์ชันที่ทำงานหลายครั้ง) ด้วยคอมไพเลอร์ที่เพิ่มประสิทธิภาพ คอมไพเลอร์นี้ใช้ฟีดแบ็กประเภทช่วยทำให้โค้ดที่คอมไพล์ทำงานได้เร็วขึ้น โดยเทคโนโลยีนี้ใช้ประเภทที่ได้มาจาก IC ที่เราพูดถึงไปเมื่อไม่นานนี้
ในคอมไพเลอร์ที่เพิ่มประสิทธิภาพ การดำเนินการจะแทรกซ้อนกันแบบคาดเดา (วางในตำแหน่งที่มีการเรียกใช้โดยตรง) วิธีนี้จะช่วยเร่งการดำเนินการ (โดยใช้หน่วยความจำน้อยลง) แต่ก็ทำให้เพิ่มประสิทธิภาพในด้านอื่นๆ ได้ ฟังก์ชันโมโนมอร์ฟิกและตัวสร้างสามารถแทรกได้ทั้งหมด (นี่เป็นอีกเหตุผลหนึ่งว่าทำไมโหมดโมโนมอร์ฟิกจึงเป็นแนวคิดที่ดีในเวอร์ชัน 8)
คุณสามารถบันทึกสิ่งที่ได้รับการเพิ่มประสิทธิภาพโดยใช้เครื่องมือ V8 เวอร์ชัน "d8" แบบสแตนด์อโลน ดังนี้
d8 --trace-opt primes.js
(ซึ่งจะบันทึกชื่อของฟังก์ชันที่เพิ่มประสิทธิภาพไปยัง stdout)
อย่างไรก็ตาม บางฟังก์ชันไม่สามารถเพิ่มประสิทธิภาพได้ บางฟังก์ชันอาจทำให้คอมไพเลอร์ที่เพิ่มประสิทธิภาพไม่ทำงานในฟังก์ชันที่กำหนด ("การประกัน") โดยเฉพาะอย่างยิ่ง คอมไพเลอร์ที่เพิ่มประสิทธิภาพในปัจจุบันให้ผลด้านฟังก์ชันต่างๆ โดยใช้บล็อก {} catch {}!
ดังนั้น
- ใส่โค้ดที่คำนึงถึง Perf ลงในฟังก์ชันที่ฝังไว้หากคุณลองใช้บล็อก {} ดักจับ {}: ```js function perf_sensitive() { // ทำงานที่เกี่ยวข้องกับประสิทธิภาพที่นี่ }
ลอง { perf_sensitive() } catch (e) { // จัดการข้อยกเว้นที่นี่ } ```
คำแนะนำนี้อาจมีการเปลี่ยนแปลงในอนาคต เนื่องจากเราเปิดใช้บล็อกการลอง/จับในคอมไพเลอร์ที่เพิ่มประสิทธิภาพ คุณสามารถตรวจสอบว่าคอมไพเลอร์ที่เพิ่มประสิทธิภาพใช้ได้ผลกับฟังก์ชันต่างๆ อย่างไรโดยใช้ตัวเลือก "--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 (และบทความนี้) จะช่วยให้คุณเข้าใจวิธีเรียกใช้ JavaScript ของ V8 ได้ดีขึ้น แต่อย่าลืม เน้นไปที่การเพิ่มประสิทธิภาพอัลกอริทึมของคุณด้วย