Mẹo về hiệu suất cho JavaScript trong V8

Chris Wilson
Chris Wilson

Giới thiệu

Daniel Olivia đã có một bài nói chuyện xuất sắc 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 "nhu 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ã lưu ý về cách hoạt động của JavaScript. Bài viết này trình bày bản 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 hướng dẫn về hiệu suất thay đổi.

Lời khuyên quan trọng nhất

Bạn cần đưa bất kỳ lời khuyên nào 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 đó, 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 điểm 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ã dành riêng cho 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. Trong buổi nói chuyệ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; Tài liệu này chỉ đề cập đến một số điểm quan trọng nhất về thiết kế động cơ V8.

Bây giờ, hãy tiếp tục với các mẹo V8!

Lớp ẩ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 ra câu hỏi làm thế nào hiệu suất JavaScript có thể đạt được gần với 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; 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 có thành phần bổ sung ".z" đã thêm vào, 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 assembly được tối ưu hóa cho mã JavaScript thao tác p1 hoặc p2. Bạn càng tránh được việc 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 các 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 ra từ các giá trị mà bạn sử dụng loại số đang xử lý. Sau khi đưa ra suy luận này, V8 sẽ sử dụng tính năng gắn thẻ để biểu diễn các giá trị một cách hiệu quả, vì các kiểu này có thể thay đổi linh động. Tuy nhiên, đôi khi việc thay đổi các thẻ kiểu này sẽ mất phí, vì vậy, tốt nhất là bạn nên sử dụng các loại số một cách nhất quán và nhìn chung, tốt nhất là sử dụng số nguyên 31 bit đã ký 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 diễn 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 làm cho bộ nhớ mảng thay đổ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
  • Đừng phân bổ trước các Mảng lớn (ví dụ: > 64K phần tử) về kích thước tối đa của chúng, thay vào đó, hãy tăng dần khi bạn sử dụng
  • Không xoá các phần tử trong mảng, đặc biệt là các mảng số
  • Không tải các phần tử chưa khởi chạy hoặc đã bị 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ể làm tăng thêm khối lượng công việc do phải phân bố 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 phép gán riêng lẻ được thực hiện lần lượt và việc gán a[2] khiến Mảng đó được chuyển đổi thành một Mảng gồm các cặp số không được mở hộp, nhưng sau đó việc gán a[3] sẽ khiến Mảng đó được chuyển đổi lại thành 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 kiểu của tất cả các phần tử trong giá trị cố định và lớp ẩn có thể được xác định từ trước.

  • Khởi động 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 lại 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 lượt chuyển đổi lại các mảng nhỏ nếu bạn khởi tạo 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 động và những cách triển khai ban đầu của ngôn ngữ này chỉ là thông dịch, nhưng các công cụ thời gian chạy JavaScript hiện đại lại 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 Đúng thời điểm (JIT) khác nhau:

  • Phiên bản "Full" trình biên dịch có thể tạo mã tốt cho mọi JavaScript
  • Trình biên dịch Tối ưu hoá. Trình biên dịch này tạo ra mã hiệu quả cho hầu hết JavaScript, nhưng mất nhiều thời gian biên dịch hơn.

Trình biên dịch đầy đủ

Trong V8, trình biên dịch Đầy đủ chạy trên tất cả các mã và bắt đầu thực thi mã càng sớm càng tốt, nhanh chóng tạo ra mã tốt nhưng không tốt. Trình biên dịch này giả định hầu như không có gì về các kiểu tại thời điểm biên dịch – trình biên dịch 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ã, mã sẽ xác thực các giả định về kiểu trước tiên, sau đó sử dụng bộ nhớ đệm cùng dòng để tắt 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ẽ kém hiệu quả 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 phép toán 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ì chúng có tính đa hình, nghĩa là một số đối số có thể thay đổi loại qua các lệnh gọi khác nhau đến hoạt động đó. 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 "nóng" các hàm (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 sẽ được cùng dòng theo suy đoán (được đặt trực tiếp tại vị trí chúng được gọi). Điều này giúp đẩy nhanh tốc độ thực thi (có hao tổn bộ nhớ), nhưng đồng thời cũng hỗ trợ các hoạt động tối ưu hoá khác. Các hàm và hàm khởi tạo đơn hình có thể được thêm vào cùng dòng hoàn toàn (đó là một lý do khác khiến đơn hình là một ý tưởng hay trong V8).

Bạn có thể ghi lại những nội dung được tối ưu hoá bằng tính năng "d8" độc lập phiên bản của động cơ V8:

d8 --trace-opt primes.js

(mục này sẽ ghi lại 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 tối ưu hoá. Tuy nhiên, 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 ("một dịch vụ trợ giúp"). Cụ thể, trình biên dịch tối ưu hoá hiện bảo vệ các hàm bằng cách dùng thử {} phát hiện {} khối!

Do đó

  • Đặt mã nhạy cảm với hiệu suất vào hàm lồng nhau nếu bạn đã thử {} bắt {} khối: ```js function perf_sensitive() { // Thực hiện công việc nhạy cảm về hiệu suất tại đây }

thử { perf_sensitive() } catch (e) { // Xử lý ngoại lệ tại đây } ```

Hướng dẫn này có thể sẽ 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á loại bỏ các hàm bằng cách sử dụng "--trace-opt" với d8 như trên, cung cấp cho bạn thêm thông tin về những chức năng đã được bảo vệ:

d8 --trace-opt primes.js

Ngừng tối ưu hoá

Cuối cùng, việc tối ưu hoá do trình biên dịch này thực hiện chỉ mang tính suy đoán - đôi khi nó không hiệu quả và chúng tôi dừng lại. Quy trình "huỷ tối ưu hoá" loại bỏ mã được tối ưu hoá và tiếp tục thực thi ở đúng vị trí ở trạng thái "đầy đủ" mã trình biên dịch. Tính năng 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 các thay đổi về lớp bị ẩn trong các hàm sau khi các hàm này được tối ưu hoá

Tương tự như các phương thức tối ưu hoá khác, bạn có thể nhận 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ể chuyển các tuỳ chọn theo dõi V8 đến 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 tính năng phân tích công cụ 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

Tính năng này sử dụng trình phân tích mẫu tích hợp sẵn, lấy mẫu mỗi mili giây và ghi v8.log.

Trong phần tóm tắt

Điều quan trọng là bạn cần xác định và nắm được cách công cụ V8 hoạt động với mã nguồn của bạn để chuẩn bị tạo ra JavaScript hiệu quả. Một lần nữa, lời khuyên cơ bản là:

  • Có sự chuẩn bị trước khi bạn gặp (hoặc nhận thấy) vấn đề
  • Sau đó, 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 điểm quan trọng

Điều này có nghĩa là trước tiên bạn nên đảm bảo sự cố nằm trong JavaScript của mình bằng cách sử dụng các công cụ khác như PageSpeed; có thể giảm xuống còn JavaScript thuần tuý (không có DOM) trước khi thu thập chỉ số và sau đó sử dụng các chỉ số đó để xác định điểm tắc nghẽn và loại bỏ các điểm tắc nghẽn quan trọng. Hy vọ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 hóa các thuật toán của riêng bạn!

Tài liệu tham khảo