Giới thiệu
Daniel Clifford đã có một bài nói tuyệt vời tại Google I/O về các mẹo và thủ thuật để cải thiện hiệu suất JavaScript trong V8. Daniel khuyến khích chúng tôi "yêu cầu nhanh hơn" – phân tích kỹ sự khác biệt về hiệu suất giữa C++ và JavaScript, đồng thời viết mã một cách cẩn thận về cách hoạt động của JavaScript. Bài viết này tóm tắt những điểm quan trọng nhất trong bài nói của Daniel. Chúng tôi cũng sẽ cập nhật bài viết này khi có thay đổi về hướng dẫn về hiệu suất.
Lời khuyên quan trọng nhất
Điều quan trọng là bạn phải đặt mọi lời khuyên về hiệu suất vào ngữ cảnh. Lời khuyên về hiệu suất rất hấp dẫn và đôi khi việc tập trung vào lời khuyên chuyên sâu trước tiên có thể khiến bạn mất tập trung vào các vấn đề thực sự. Bạn cần xem xét toàn diện hiệu suất của ứng dụng web – trước khi tập trung vào các mẹo về hiệu suất này, bạn nên phân tích mã bằng các công cụ như PageSpeed và cải thiện điểm số. Điều này sẽ giúp bạn tránh tối ưu hoá sớm.
Lời khuyên cơ bản tốt nhất để đạt được hiệu suất tốt trong các ứng dụng Web là:
- Chuẩn bị trước khi gặp (hoặc nhận thấy) vấn đề
- Sau đó, hãy xác định và tìm hiểu cốt lõi của vấn đề
- Cuối cùng, hãy khắc phục những vấn đề quan trọng
Để hoàn thành các bước này, bạn cần hiểu cách V8 tối ưu hoá JS để có thể viết mã có tính đến thiết kế thời gian chạy JS. Bạn cũng cần tìm hiểu về các công cụ hiện có và cách chúng có thể giúp bạn. Daniel giải thích thêm về cách sử dụng các công cụ dành cho nhà phát triển trong bài nói chuyện của mình; tài liệu này chỉ nêu một số điểm quan trọng nhất trong thiết kế công cụ V8.
Vậy, hãy chuyển sang các mẹo về V8!
Lớp bị ẩn
JavaScript có thông tin loại thời gian biên dịch hạn chế: các loại có thể thay đổi trong thời gian chạy, vì vậy, việc suy luận về các loại JS tại thời điểm biên dịch sẽ tốn kém. Điều này có thể khiến bạn đặt câu hỏi về cách hiệu suất JavaScript có thể đạt gần đến C++. Tuy nhiên, V8 có các loại ẩn được tạo nội bộ cho các đối tượng trong thời gian chạy; sau đó, các đối tượng có cùng lớp ẩn có thể sử dụng cùng một mã được tạo được tối ưu hoá.
Ví dụ:
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!```
Cho đến khi thực thể đối tượng p2 thêm thành phần ".z" bổ sung, p1 và p2 nội bộ có cùng một lớp ẩn – vì vậy, V8 có thể tạo một phiên bản duy nhất của tập hợp được tối ưu hoá cho mã JavaScript thao tác với p1 hoặc p2. Bạn càng tránh được việc khiến các lớp ẩn phân kỳ thì hiệu suất càng cao.
Do đó
- Khởi tạo tất cả thành phần của đối tượng trong hàm khởi tạo (để các thực thể không thay đổi loại sau này)
- Luôn khởi tạo các thành phần của đối tượng theo cùng một thứ tự
Numbers
V8 sử dụng tính năng gắn thẻ để biểu thị các giá trị một cách hiệu quả khi các loại có thể thay đổi. V8 suy luận từ các giá trị mà bạn sử dụng để biết bạn đang xử lý loại số nào. Sau khi suy luận, V8 sẽ sử dụng tính năng gắn thẻ để biểu thị các giá trị một cách hiệu quả, vì các loại này có thể thay đổi linh động. Tuy nhiên, đôi khi việc thay đổi các thẻ loại này sẽ gây ra tổn thất. Vì vậy, tốt nhất bạn nên sử dụng các loại số một cách nhất quán và nói chung, bạn nên sử dụng số nguyên có dấu 31 bit khi thích hợp.
Ví dụ:
var i = 42; // this is a 31-bit signed integer
var j = 4.2; // this is a double-precision floating point number```
Do đó
- Ưu tiên các giá trị số có thể được biểu thị dưới dạng số nguyên có dấu 31 bit.
Mảng
Để xử lý các mảng lớn và thưa thớt, có hai loại bộ nhớ mảng nội bộ:
- Fast Elements (Thành phần nhanh): bộ nhớ tuyến tính cho các tập hợp khoá nhỏ gọn
- Phần tử từ điển: lưu trữ bảng băm
Tốt nhất là bạn không nên khiến bộ nhớ mảng chuyển đổi từ loại này sang loại khác.
Do đó
- Sử dụng các khoá liền kề bắt đầu từ 0 cho Mảng
- Không phân bổ trước các Mảng lớn (ví dụ: > 64K phần tử) theo kích thước tối đa, thay vào đó, hãy tăng dần theo nhu cầu
- Không xoá các phần tử trong mảng, đặc biệt là mảng số
- Không tải các phần tử chưa khởi tạo hoặc đã xoá:
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.
}
Ngoài ra, Mảng của số thực đôi nhanh hơn – lớp ẩn của mảng theo dõi các loại phần tử và các mảng chỉ chứa số thực đôi được bỏ hộp (khiến lớp ẩn thay đổi). Tuy nhiên, việc thao tác không cẩn thận với Mảng có thể gây ra thêm công việc do việc đóng hộp và bỏ hộp – ví dụ:
var a = new Array();
a[0] = 77; // Allocates
a[1] = 88;
a[2] = 0.5; // Allocates, converts
a[3] = true; // Allocates, converts```
kém hiệu quả hơn:
var a = [77, 88, 0.5, true];
vì trong ví dụ đầu tiên, các lượt gán riêng lẻ được thực hiện lần lượt và việc gán a[2]
sẽ chuyển đổi Mảng thành một Mảng gồm các số thực không đóng hộp, nhưng sau đó việc gán a[3]
sẽ chuyển đổi lại Mảng đó thành một Mảng có thể chứa bất kỳ giá trị nào (Số hoặc đối tượng). Trong trường hợp thứ hai, trình biên dịch biết loại của tất cả các phần tử trong giá trị cố định và có thể xác định trước lớp ẩn.
- Khởi chạy bằng cách sử dụng giá trị cố định của mảng cho các mảng có kích thước cố định nhỏ
- Phân bổ trước các mảng nhỏ (<64k) để sửa kích thước trước khi sử dụng
- Không lưu trữ các giá trị không phải số (đối tượng) trong mảng số
- Hãy cẩn thận để không gây ra việc chuyển đổi lại các mảng nhỏ nếu bạn khởi chạy mà không có giá trị cố định.
Biên dịch JavaScript
Mặc dù JavaScript là một ngôn ngữ rất linh động và các phương thức triển khai ban đầu của ngôn ngữ này là trình thông dịch, nhưng các công cụ thời gian chạy JavaScript hiện đại sử dụng tính năng biên dịch. Trên thực tế, V8 (JavaScript của Chrome) có hai trình biên dịch Just-In-Time (JIT) khác nhau:
- Trình biên dịch "Full" (Đầy đủ) có thể tạo mã tốt cho mọi JavaScript
- Trình biên dịch Tối ưu hoá tạo ra mã tuyệt vời cho hầu hết các JavaScript, nhưng mất nhiều thời gian hơn để biên dịch.
Trình biên dịch đầy đủ
Trong V8, trình biên dịch Full chạy trên tất cả mã và bắt đầu thực thi mã sớm nhất có thể, nhanh chóng tạo ra mã tốt nhưng không phải là mã tuyệt vời. Trình biên dịch này gần như không giả định gì về các loại tại thời điểm biên dịch – trình biên dịch này dự kiến rằng các loại biến có thể và sẽ thay đổi trong thời gian chạy. Mã do trình biên dịch Full (Đầy đủ) tạo ra sử dụng Bộ nhớ đệm nội tuyến (IC) để tinh chỉnh kiến thức về các loại trong khi chương trình chạy, giúp cải thiện hiệu quả ngay lập tức.
Mục tiêu của Bộ nhớ đệm nội tuyến là xử lý các loại một cách hiệu quả, bằng cách lưu mã phụ thuộc vào loại vào bộ nhớ đệm cho các thao tác; khi chạy, mã sẽ xác thực các giả định về loại trước, sau đó sử dụng bộ nhớ đệm nội tuyến để rút ngắn thao tác. Tuy nhiên, điều này có nghĩa là các thao tác chấp nhận nhiều loại sẽ có hiệu suất thấp hơn.
Do đó
- Nên sử dụng các thao tác đơn hình hơn là các thao tác đa hình
Các toán tử là đơn hình nếu các lớp ẩn của dữ liệu đầu vào luôn giống nhau – nếu không thì các toán tử đó là đa hình, nghĩa là một số đối số có thể thay đổi loại trên các lệnh gọi khác nhau đến toán tử. Ví dụ: lệnh gọi add() thứ hai trong ví dụ này gây ra tính đa hình:
function add(x, y) {
return x + y;
}
add(1, 2); // + in add is monomorphic
add("a", "b"); // + in add becomes polymorphic```
Trình biên dịch tối ưu hoá
Song song với trình biên dịch đầy đủ, V8 biên dịch lại các hàm "nóng" (tức là các hàm được chạy nhiều lần) bằng trình biên dịch tối ưu hoá. Trình biên dịch này sử dụng phản hồi kiểu để giúp mã được biên dịch nhanh hơn – trên thực tế, trình biên dịch này sử dụng các kiểu lấy từ các IC mà chúng ta vừa thảo luận!
Trong trình biên dịch tối ưu hoá, các toán tử được đưa vào cùng dòng một cách dự đoán (được đặt trực tiếp tại vị trí được gọi). Điều này giúp tăng tốc độ thực thi (tốn chi phí về mức sử dụng bộ nhớ), nhưng cũng cho phép các hoạt động tối ưu hoá khác. Bạn có thể đưa các hàm và hàm khởi tạo đơn hình vào cùng dòng (đây là một lý do khác khiến bạn nên sử dụng tính đơn hình trong V8).
Bạn có thể ghi lại nội dung được tối ưu hoá bằng cách sử dụng phiên bản "d8" độc lập của công cụ V8:
d8 --trace-opt primes.js
(đây là nhật ký tên của các hàm được tối ưu hoá vào stdout.)
Tuy nhiên, không phải hàm nào cũng có thể được tối ưu hoá – một số tính năng ngăn trình biên dịch tối ưu hoá chạy trên một hàm nhất định ("thoát"). Cụ thể, trình biên dịch tối ưu hoá hiện thoát khỏi các hàm có khối try {} catch {}!
Do đó
- Đặt mã nhạy cảm về hiệu suất vào một hàm lồng nhau nếu bạn có các khối try {} catch {}: ```js function perf_sensitive() { // Thực hiện công việc nhạy cảm về hiệu suất tại đây }
try { perf_sensitive() } catch (e) { // Xử lý các ngoại lệ tại đây } ```
Hướng dẫn này có thể thay đổi trong tương lai, vì chúng tôi bật các khối try/catch trong trình biên dịch tối ưu hoá. Bạn có thể kiểm tra cách trình biên dịch tối ưu hoá thoát khỏi các hàm bằng cách sử dụng tuỳ chọn "--trace-opt" với d8 như trên. Tuỳ chọn này sẽ cung cấp cho bạn thêm thông tin về những hàm đã thoát:
d8 --trace-opt primes.js
Huỷ tối ưu hoá
Cuối cùng, quá trình tối ưu hoá do trình biên dịch này thực hiện là suy đoán – đôi khi không hiệu quả và chúng ta sẽ quay lại. Quá trình "huỷ tối ưu hoá" sẽ loại bỏ mã được tối ưu hoá và tiếp tục thực thi ở đúng vị trí trong mã trình biên dịch "đầy đủ". Quá trình tối ưu hoá lại có thể được kích hoạt lại sau này, nhưng trong ngắn hạn, quá trình thực thi sẽ bị chậm lại. Cụ thể, việc gây ra thay đổi trong các lớp biến ẩn sau khi các hàm đã được tối ưu hoá sẽ khiến quá trình huỷ tối ưu hoá này xảy ra.
Do đó
- Tránh thay đổi lớp ẩn trong các hàm sau khi tối ưu hoá
Giống như các hoạt động tối ưu hoá khác, bạn có thể xem nhật ký các hàm mà V8 phải huỷ tối ưu hoá bằng cờ ghi nhật ký:
d8 --trace-deopt primes.js
Các công cụ V8 khác
Nhân tiện, bạn cũng có thể truyền các tuỳ chọn theo dõi V8 cho Chrome khi khởi động:
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --js-flags="--trace-opt --trace-deopt"```
Ngoài việc sử dụng công cụ phân tích tài nguyên dành cho nhà phát triển, bạn cũng có thể sử dụng d8 để phân tích tài nguyên:
% out/ia32.release/d8 primes.js --prof
Thao tác này sử dụng trình phân tích tài nguyên lấy mẫu tích hợp, lấy một mẫu mỗi mili giây và ghi v8.log.
Tóm tắt
Điều quan trọng là bạn phải xác định và hiểu cách hoạt động của công cụ V8 với mã của mình để chuẩn bị xây dựng JavaScript hiệu suất cao. Xin nhắc lại, lời khuyên cơ bản là:
- Chuẩn bị trước khi gặp (hoặc nhận thấy) vấn đề
- Sau đó, hãy xác định và tìm hiểu cốt lõi của vấn đề
- Cuối cùng, hãy khắc phục những vấn đề quan trọng
Điều này có nghĩa là bạn nên đảm bảo vấn đề nằm trong JavaScript bằng cách sử dụng các công cụ khác như PageSpeed trước tiên; có thể giảm xuống JavaScript thuần tuý (không có DOM) trước khi thu thập chỉ số, sau đó sử dụng các chỉ số đó để xác định nút thắt cổ chai và loại bỏ các nút thắt cổ chai quan trọng. Hy vọng rằng bài nói chuyện của Daniel (và bài viết này) sẽ giúp bạn hiểu rõ hơn về cách V8 chạy JavaScript – nhưng hãy nhớ tập trung vào việc tối ưu hoá các thuật toán của riêng bạn!