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

Chris Wilson
Chris Wilson

Giới thiệu

Daniel Castleord đã có một bài nói chuyện rất hay tại Google I/O về các mẹo và thủ thuật giúp cải thiện hiệu suất của JavaScript trong phiên bản 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ã theo cách lưu tâm đến cách JavaScript hoạt động. Bài viết này sẽ tóm tắt những điểm quan trọng nhất trong buổi trò 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

Bạn cần phải đưa mọi lời khuyên về hiệu suất vào bối cảnh. Lời khuyên về hiệu suất 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ể khiến bạn mất tập trung vào các vấn đề thực tế. Bạn cần phải 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 mẹo hiệu suất này, có thể bạn nên phân tích mã bằng công cụ như PageSpeed và tăng điểm số. Điều này sẽ giúp bạn tránh được tình trạng tối ưu hoá quá 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à:

  • Chuẩn bị trước khi bạn gặp (hoặc nhận thấy) vấn đề
  • Sau đó, xác định và nắm bắt điểm mấu chốt 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, nhờ đó, bạn có thể viết mã dựa trên thiết kế của thời gian chạy JS. Ngoài ra, bạn cũng nên tìm hiểu về các công cụ có sẵn 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ụ cho nhà phát triển trong buổi nói chuyện của mình; tài liệu này chỉ nêu ra một số điểm quan trọng nhất trong thiết kế động cơ V8.

Tiếp theo, hãy chuyển sang các mẹo V8!

Các lớp ẩn

JavaScript có thông tin hạn chế về loại trong thời gian biên dịch: có thể thay đổi loạ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 thắc mắc về cách hiệu suất JavaScript có thể đạt được ở bất kỳ vị trí nào gần với C++. Tuy nhiên, V8 có các kiểu ẩ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 hóa.

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ê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 một phiên bản assembly được tối ưu hóa cho mã JavaScript thao tác p1 hoặc p2. Bạn càng tránh được tình trạng các lớp ẩn bị chuyển hướng, thì bạn càng đạt được hiệu suất cao hơn.

Do đó

  • Khởi động tất cả thành phần đố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 đố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ố bạn đang xử lý. Sau khi đưa ra suy luận, V8 sẽ sử dụng phương pháp gắn thẻ để biểu thị các giá trị một cách hiệu quả, vì các kiểu này có thể thay đổi linh hoạt. Tuy nhiên, đôi khi sẽ tốn chi phí để thay đổi các thẻ loại này, vì vậy tốt nhất nên sử dụng kiểu số một cách nhất quán và nói 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 31 bit có dấu.

Mảng

Để xử lý các mảng lớn và thưa, có 2 cách lưu trữ mảng nội bộ:

  • Phần tử nhanh: bộ nhớ tuyến tính cho các bộ khoá nhỏ gọn
  • Phần tử từ điển: bộ nhớ bảng băm nếu không

Tốt nhất là bạn không nên làm cho bộ nhớ mảng chuyển 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ử) đến kích thước tối đa, thay vào đó hãy tăng lên khi bạn di chuyển
  • Không được 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 đã 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 kép sẽ hoạt động nhanh hơn – lớp ẩn của mảng theo dõi các loại phần tử của lớp, còn các mảng chỉ chứa dữ liệu kép thì không được mở hộp (dẫn đến sự thay đổi lớp ẩn). Tuy nhiên, thao tác bất cẩn của Mảng có thể dẫn đến việc phải thực hiện thêm thao tác do phải đóng hộp hay 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ẻ lần lượt được thực hiện và việc gán a[2] khiến Mảng đó được chuyển đổi thành một Mảng nhân đôi không có hộp, nhưng sau đó việc gán a[3] 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 loại 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 trước.

  • Khởi động bằng giá trị cố định 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 là số (đối tượng) trong mảng số
  • Cẩn thận để không gây ra 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à việc triển khai ban đầu của ngôn ngữ này chỉ là thông dịch viên, 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. V8 (JavaScript của Chrome) có hai trình biên dịch Đúng thời điểm (JIT) khác nhau:

  • Trình biên dịch "Full" (Đầy đủ) có thể tạo mã hiệu quả cho mọi JavaScript
  • Trình biên dịch Tối ưu hoá, tạo mã tuyệt vời 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ã ngay khi có thể, nhanh chóng tạo ra đoạn mã tốt nhưng không hiệu quả. Trình biên dịch này hầu như không giả định gì về các kiểu tại thời điểm biên dịch – trình biên dịch kỳ vọng 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 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ạy chương trình, nhờ đó nhanh chóng cải thiện hiệu quả.

Mục tiêu của Bộ nhớ đệm nội tuyến là xử lý các kiểu một cách hiệu quả bằng cách lưu mã phụ thuộc vào kiểu vào bộ nhớ đệm cho thao tác; khi mã chạy, mã sẽ xác thực các giả định về kiểu trước, sau đó sử dụng bộ nhớ đệm cùng dòng để tắt thao tác này. 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 đó

  • Việc sử dụng phép toán đơn hình được ưu tiên hơn phép toán đa hình

Toán tử là đơn hình nếu các lớp đầu vào bị ẩn luôn giống nhau – nếu không thì chúng là đa hình, nghĩa là một số đối số có thể thay đổi loại qua các lệnh gọi hoạt động khác nhau. 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 chạy nhiều lần) bằng một 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 sử dụng các kiểu lấy từ 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ùng dòng theo suy đoán (được đặt trực tiếp tại nơi chúng được gọi). Điều này giúp tăng tốc quá trình thực thi (theo chi phí của bộ nhớ), nhưng cũng hỗ trợ các hoạt động tối ưu hoá khác. Các hàm đơn hình và hàm khởi tạo có thể cùng dòng hoàn toàn (đó là một lý do khác tại sao tính đơn hình là một ý tưởng hay trong V8).

Bạn có thể ghi nhật ký những gì đượ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

(ghi nhật ký tên 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á – 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 ("bail-out"). 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 thử {} bắt {} khối!

Do đó

  • Đặt mã có khả năng nhạy cảm vào một hàm lồng nhau nếu bạn đã thử {} bắt {} các khối: ```js hàm 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 trường hợp ngoại lệ tại đây } ```

Hướng dẫn này có thể sẽ thay đổi trong tương lai, khi chúng ta 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á đang xử lý 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 cung cấp thêm thông tin về hàm nào đã được loại bỏ:

d8 --trace-opt primes.js

Tắt tính năng tối ưu hoá

Cuối cùng, hoạt động tối ưu hoá mà trình biên dịch này thực hiện mang tính suy đoán, đôi khi không hoạt động và chúng tôi sẽ dừng lại. Quá trình "huỷ tối ưu hoá" sẽ huỷ 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 đủ". Tính năng tối ưu hoá lại có thể được kích hoạt lại sau đó, nhưng trong thời gian ngắn, quá trình thực thi sẽ chậm lại. Cụ thể, việc thay đổi 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á xảy ra.

Do đó

  • Tránh các thay đổi về lớp ẩn trong các hàm sau khi các hàm đó được tối ưu hoá

Tương tự như với các hoạt động tối ưu hoá khác, bạn có thể lấy 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ể chuyển các tuỳ chọn theo dõi V8 sang 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 tài nguyên của 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

Việc này sử dụng trình phân tích lấy 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 phải xác định và hiểu cách công cụ V8 làm việc với mã nguồn của bạn để chuẩn bị xây dựng JavaScript có hiệu suất cao. Một lần nữa, lời khuyên cơ bản là:

  • Chuẩn bị trước khi bạn gặp (hoặc nhận thấy) vấn đề
  • Sau đó, xác định và nắm bắt điểm mấu chốt 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 của mình, 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 thành JavaScript thuần túy (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 quan trọng. Hy vọng buổi trò 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!

Tài liệu tham khảo