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 chuyện 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 có thể gây nghiệ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ể gây mất tập trung khỏi các vấn đề thực sự. Bạn cần có cái nhìn toàn diện về hiệu suất của ứng dụng web của mình – trước khi tập trung vào các mẹo về hiệu suất này, có thể bạn nên phân tích mã của mình bằng các công cụ như PageSpeed để tăng điểm. Đ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 để có được hiệu suất tốt trong các ứng dụng web là:
- Có sự chuẩn bị trước khi bạn 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
Để thực hiện 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ỉ ghi lại một số điểm quan trọng nhất trong thiết kế công cụ V8.
Bây giờ, hãy tiếp tục với các mẹo V8!
Lớp bị ẩn
JavaScript có thông tin giới hạn về loại thời gian biên dịch: các loại có thể thay đổi trong thời gian chạy, vì vậy, việc giải thích về loại JS tại thời điểm biên dịch là điều bình thường. Đ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 phiên bản đối tượng p2 có thêm thành viên ".z", p1 và p2 nội bộ có cùng một lớp ẩn - vì vậy V8 có thể tạo ra một phiên bản duy nhất của tập hợp được tối ưu hóa cho mã JavaScript thao tác p1 hoặc p2. Bạn càng tránh làm các lớp ẩn phân kỳ, thì hiệu suất của bạn 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 loại có thể thay đổi. V8 suy luận từ các giá trị mà bạn sử dụng để biết loại số mà bạn đang xử lý. 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 bạn sẽ phải trả giá khi thay đổi các thẻ loại này. 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ó 2 kiểu lưu trữ mảng nội bộ:
- Phần tử nhanh: bộ nhớ tuyến tính cho các tập hợp khoá nhỏ gọn
- Phần tử từ điển: nếu không 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 đôi nhanh hơn – các loại phần tử trong lớp ẩn của mảng theo dõi và các mảng chỉ chứa đối tượng đôi không được mở hộp (gây ra sự thay đổi lớp ẩn). Tuy nhiên, việc thao tác bất cẩn đối với Mảng có thể gây ra thêm nhiều thao tác do việc đấm bốc và mở 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 nhỏ có kích thước cố đị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 hầu 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 Đầ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 kiểu trong khi chương trình chạy, giúp cải thiện hiệu quả nhanh chóng.
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 toán tử chấp nhận nhiều loại sẽ có hiệu suất thấp hơn.
Do đó
- Toán tử đơn hình được ưu tiên sử dụng hơn toán tử đ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 dữ liệu để giúp mã được biên dịch nhanh hơn – trên thực tế, nó sử dụng các kiểu được lấy từ các IC mà chúng ta vừa nói đến!
Trong trình biên dịch tối ưu hoá, các thao tác đượ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
Ngừng 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, 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 những thay đổi trong các lớp biến ẩn sau khi các hàm được tối ưu hoá sẽ dẫn đến việc huỷ tối ưu hoá này.
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ụ khác trong V8
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 cần xác định và hiểu rõ cách công cụ V8 hoạt động với mã nguồn của bạn để chuẩn bị xây dựng JavaScript hiệu quả. 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!