November 13, 2018 (6y ago)

Cách Javascript hoạt động P2: Bên trong engine V8 & 5 mẹo để tối ưu hóa code

Hôm trước chúng ta đã có bài bắt đầu một chuỗi series đào sâu nghiên cứu về Javascript và cách thức nó hoạt động: Chúng tôi nghĩ rằng, bằng cách hiểu được những thành phần của JS và cách chúng tương tác với nhau thì chúng ta có thể viết code tốt hơn và ngon hơn

Bài đầu tiên của series đã cho chúng ta có một cái nhìn tổng quan về engine, runtime và callstack. Trong bài thứ 2 này, chúng ta sẽ đục khoét vào những thành phần bên trong của bộ engine V8 của Google. Tác giả cũng sẽ cung cấp một số mẹo vặt để có thể viết code Javascript tốt hơn - những thứ tốt nhất (best practices) mà team SessionStack đã và đang thực hiện để xây dựng những sản phẩm của họ.

Tổng quan

Javascript engine là một trình thông dịch thực thi mã Javascript. Một JS engine có thể được triển khai thực hiện như là 1 trình thông dịch độc lập, hoặc là một trình biên dịch tức thời (Just-In-Time Compiler) mà nó sẽ biên dịch code Javascript thành bytecode (chưa phải mã máy, nhưng gần như là mã máy).

Dưới đây là danh sách các dự án phổ biến đã triển khai cho Javascript engine:

  • V8 - Nguồn mở, phát triển bởi Google trên nền C++
  • Rhino - Được quản lý bởi quỹ Mozilla Foundation, nguồn mở, phát triển hoàn toàn bằng Java
  • SpiderMonkey - Bộ JS engine đầu tiên, ngày xưa được hỗ trợ bởi Netscape Navigator, ngày nay là Firefox
  • JavaScriptCore - nguồn mở, còn được gọi là Nitro, được phát triển bởi Apple cho Safari
  • KJS - Engine của KDE, phát triển bởi Harri Porten cho dự án trình duyệt Konqueror của KDE
  • Chakra (JScript9) - Internet Explorer
  • Chakra (JavaScript) -  Microsoft Edge
  • Nashorn - Nguồn mở và là 1 phần của OpenJDK, viết bằng Java bởi Oracle và Tool Group
  • JerryScript - là 1 bộ engine nhẹ dành cho Vạn vật kết nối (Internet of Things)

Tại sao V8 được tạo ra?

Hỏi vớ vẩn

V8 engine được xây dựng bởi Google bằng C++ và nó là phần mềm nguồn mở. Bộ engine này được dùng trong trình duyệt Google Chrome. Không giống như các engine khác, V8 còn được sử dụng trong môi trường runtime của Node.js

V8 đầu tiên được thiết kế nhằm gia tăng hiệu suất của tiến trình thực thi JavaScript bên trong trình duyệt. Để có thể đạt được tốc độ tốt, V8 dịch mã Javascript thành mã máy thay vì sử dụng trình thông dịch. Nó biên dịch mã JS thành mã máy ngay khi thực thi bằng bộ JIT compiler (Just-In-Time compiler) giống như đa số các JS engine hiện đại khác như SpiderMoney hay Rhino. Điểm khác biệt chính đó là V8 không sinh ra bytecode hay mã trung gian.

V8 sử dụng 2 trình biên dịch

Trước khi phát hành phiên bản 5.9 (đầu năm 2017), engine V8 sử dụng 2 trình biên dịch:

  • full-codegen - một trình biên dịch vừa đơn giản vừa cực nhanh, sinh mã máy đơn giản và tương đối chậm
  • Crankshaft - một trình biên dịch tối ưu có hơi phức tạp (Just-In-Time) sinh mã đã được tối ưu tốt nhất.

V8 engine còn sử dụng nhiều tiến trình nội bộ khác:

  • Tiến trình chính thực hiện những gì bạn thường thấy: lấy code, biên dịch và thực thi nó.
  • Có một tiến trình riêng khác cho việc biên dịch, bằng cách này thì trong khi tiến trình chính biên dịch, tiến trình phụ tối ưu (optimize) code.
  • Một tiến trình Profiler (không biết dịch) sẽ thông báo cho runtime những phương thức nào đang chiếm dụng nhiều thời gian xử lý để cho Crankshaft có thể tối ưu chúng.
  • Một vài tiến trình khác để xử lý dọn rác (Garbage Collector)

Khi lần đầu thực thi mã Javascript, V8 engine sẽ gọi full-codegen để dịch trực tiếp những đoạn code JS đã được phân tích thành mã máy mà không thông qua bước chuyển đổi (transformation). Điều này cho phép thực thi mã máy rất nhanh. Để ý rằng V8 không sử dụng bytecode trung gian, cho thấy rằng cách làm này loại bỏ sự không cần thiết của 1 trình thông dịch.

Khi code đã chạy được một thời gian thì tiến trình profiler đã thu thập đủ dữ liệu để có thể xác định phương thức nào cần được tối ưu hóa.

Ở bước tiếp theo, Crankshaft sẽ bắt đầu 1 tiến trình khác. Nó dịch cây cú pháp trừu tượng (Abstract Syntax Tree - AST, sẽ có 1 bài riêng để nói về cái này nhé) của Javascript thành một dạng cấp độ cao của static single assignment (SSA - vốn ngôn ngữ có hạn nên không biết dịch), còn được gọi là Hydrogen, và cố gắng tối ưu đồ thị Hydrogen đó. Đa số sự tối ưu hóa được hoàn thành là giai đoạn này.

Inlining (Nội tuyến)

Bước tối ưu hóa đầu tiên là triển khai nội tuyến (Inlining) nhiều code nhất có thể. Inlining là tiến trình thay thế một vị trí gọi (call site - dòng code nơi hàm được gọi) với thân (body) của hàm được gọi. Bước thực hiện đơn giản này cho phép những tối ưu hóa sau này được ý nghĩa hơn.

Lớp ẩn (Hidden class)

Javascript là ngôn ngữ dựa trên các nguyên mẫu (prototype-based): không có các lớp (class) hay đối tượng (object) nào được tạo ra bằng tiến trình nhân bản (cloning process). Javascript cũng là ngôn ngữ động (dynamic), nghĩa là những thuộc tính (property) có thể được dễ dàng thêm vào hoặc xóa đi từ object sau khi object đó được khởi tạo.

Đa số các trình thông dịch JS sử dụng cấu trúc dạng như từ điển (hash function based) để lưu trữ vị trí của những giá trị của thuộc tính trong object trên bộ nhớ. Cấu trúc này làm cho quá trình lấy giá trị (get) trong JS trở nên tốn kém hơn nhiều so với những ngôn ngữ lập trình non-dynamic khác như Java hay C#. Trong Java, tất cả các thuộc tính của object được xác định bởi cấu trúc object cố định trước khi biên dịch và không thể thêm hay bớt thuộc tính tại thời điểm thực thi (C# cũng có kiểu dynamic, nhưng cái đó hơi lạc đề rồi nên mình không đề cập). Kết quả là, giá trị của thuộc tính (hay con trỏ đến thuộc tính đó) được lưu dưới dạng bộ đệm liên tục (continuous buffer) trên vùng nhớ với một offset cố định giữa các vùng nhớ. Độ dài của 1 offset có thể dễ dàng xác định dựa trên loại thuộc tính, trong khi đó điều này là bất khả thi đối với Javascript khi mà loại thuộc tính có thể bị thay đổi trong quá trình thực thi.

Bởi vì sử dụng cấu trúc từ điển để tìm vị trí của thuộc tính của object trong vùng nhớ là không hiệu quả nên V8 sử dụng một phương pháp khác: lớp ẩn (hidden class). Lớp ẩn hoạt động tương tự như một cấu trúc object (class) cố định trong Java, ngoại trừ việc nó được tạo ra ở quá trình thực thi (runtime). Giờ thì xem ví dụ bên dưới nhé:

function Point(x, y) {
  this.x = x;
  this.y = y;
}
var p1 = new Point(1, 2);

Khi câu lệnh new Point(1, 2) được gọi, V8 sẽ tạo ra 1 lớp ẩn, tạm gọi là C0

Không có thuộc tính nào được định nghĩa trong Point, vì thế tạm thời C0 đang rỗng.

Khi câu lệnh this.x = x được thực thi (bên trong hàm Point), V8 sẽ tạo ra 1 lớp ẩn thứ hai dựa trên C0, ta tạm gọi là C1. C1 mô tả lại vị trí trên vùng nhớ (tương tự như con trỏ) nơi mà thuộc tính x được lưu. Trong trường hợp này, x sẽ nằm ở offset 0, nghĩa là khi chúng ta view một object Point trên vùng nhớ dưới dạng bộ đệm liên tục (continuous buffer) thì offset đầu tiên sẽ tương ứng với thuộc tính x. V8 cũng sẽ cập nhật C0 với một sự chuyển đổi lớp (class transition) và tuyên bố rằng thuộc tính x được thêm vào trong object Point, lớp ẩn lúc này sẽ chuyển từ C0 sang C1. Lớp ẩn cho object Point ở hình dưới lúc này là C1

Mỗi khi có thuộc tính mới được thêm vào object, lớp ẩn cũ được cập nhật và sẽ chuyển tiếp (transition) sang lớp ẩn mới. Sự chuyển tiếp lớp ẩn là rất quan trọng bởi vì chúng cho phép những object được tạo ra theo những cách giống nhau có thể cùng chia sẻ các lớp ẩn. Nếu 2 object cùng dùng chung 1 lớp ẩn và 1 thuộc tính được thêm vào cả 2 object đó, sự chuyển tiếp sẽ đảm bảo rằng cả 2 object đều nhận được cùng 1 lớp ẩn mới và sẽ được tối ưu cùng nhau.

Quá trình này được lặp lại khi câu lệnh tiếp theo this.y = y được thực thi (bên trong hàm Point, ngay sau dòng this.x = x).

Một lớp ẩn mới, tạm gọi là C2 được tạo ra, một lớp chuyển tiếp (class transition) được thêm vào C1 biểu thị rằng nếu thuộc tính y được thêm vào object Point (object đã chứa thuộc tính x) thì lớp ẩn sẽ được thay đổi sang C2, và lớp ẩn của object Point sẽ được cập nhật thành C2

Sự chuyển tiếp lớp ẩn phụ thuộc vào thứ tự mà những thuộc tính được thêm vào 1 object. Ví dụ:

function Point(x, y) {
  this.x = x;
  this.y = y;
}

var p1 = new Point(1, 2);
p1.a = 5;
p1.b = 6;

var p2 = new Point(3, 4);
p2.b = 7;
p2.a = 8;

Nhìn vào đoạn code trên, bạn sẽ nghĩ rằng cả p1 và p2 sẽ dùng chung lớp ẩn và sự chuyển tiếp giữa các lớp. Nhưng sự thật không phải như vậy. Đối với p1, thuộc tính thứ nhất là a sẽ được thêm vào và sau đó đến b. Với p2 thì ngược lại, b trước rồi mới đến a. Vì vậy, p1 và p2 sẽ sinh ra 2 lớp ẩn khác nhau và có sự chuyển tiếp lớp ẩn cũng khác nhau. Trong trường hợp này, tốt nhất là nên giữ đúng thứ tự thuộc tính mỗi khi khởi tạo dữ liệu để lớp ẩn có thể được dùng lại:

var p1 = new Point(1, 2);
p1.a = 5;
p1.b = 6;

var p2 = new Point(3, 4);
p2.a = 8;
p2.b = 7;

Phần diễn giải về hidden class này khá là rối và khó hiểu, để hiểu rõ hơn thì mời bạn tham khảo thêm bài viết chi tiết của tác giả thefullsnack

Inline caching (Bộ đệm nội tuyến)

V8 có một điểm mạnh trong kỹ thuật tối ưu hóa dành cho các ngôn ngữ với kiểu dữ liệu động, gọi là Inline caching (bộ đệm nội tuyến). Inline caching dựa trên sự quan sát những lời gọi được lặp lại nhiều lần đến cùng 1 phương thức có xu hướng xảy ra trên cùng 1 loại object. Diễn giải chi tiết hơn về inline caching có thể xem ở đây

Giờ chúng ta sẽ duyệt sơ qua những khái niệm chung chung của inline caching (nếu như bạn không có thời gian đọc hết bài chi tiết ở trên)

Vậy thì nó hoạt động như thế nào? V8 duy trì bộ nhớ đệm về kiểu của các object được truyền qua dưới dạng tham số (parameter) trong những lần gọi method gần đây và sử dụng thông tin này để dự đoán về kiểu object sẽ được truyền trong tương lai. Nếu như V8 có thể dự đoán tương đối tốt về kiểu object sẽ được truyền vào thì nó có thể bỏ qua bước xử lý tìm hiểu về các thuộc tính của object đó, nó sẽ dùng những thông tin có sẵn đã được lưu từ lần trước và áp dụng cho lớp ẩn của object này.

Vậy thì lớp ẩn và inline caching liên quan với nhau như thế nào? Khi một phương thức được gọi trên một object cụ thể, V8 sẽ thực hiện tìm kiếm lớp ẩn của object đó để xác định offset nhằm phục vụ cho việc truy xuất thuộc tính. Sau 2 lần gọi thành công cùng 1 phương thức đến cùng 1 lớp ẩn, V8 sẽ bỏ qua việc tìm kiếm lớp ẩn đó và đơn giản là thêm offset của thuộc tính vào trong bản thân con trỏ của object. Về sau, cứ mỗi lần gọi phương thức, V8 sẽ giả định rằng lớp ẩn không thay đổi và nhảy trực tiếp vào trong địa chỉ vùng nhớ của thuộc tính và sử dụng offset đã được lưu từ lần tìm kiếm trước đó. Cách này có thể gia tăng đáng kể tốc độ thực thi.

Inline caching cũng là lý do quan trọng cho việc cùng kiểu object sẽ dùng chung lớp ẩn. Nếu bạn tạo ra 2 object có cùng 1 kiểu nhưng khác hidden class (như chúng ta đã làm ở trên), V8 sẽ không thể sử dụng inline caching bởi vì mặc dù 2 object có cùng kiểu nhưng lớp ẩn tương ứng của nó lại gán offset khác nhau cho mỗi thuộc tính.

2 object về cơ bản là giống nhau nhưng thuộc tính a và b lại có thứ tự khác nhau.

Biên dịch thành mã máy

Một khi đồ thị Hydrogen đã được tối ưu hóa, Crankshaft chuyển nó xuống một tầng thấp hơn gọi là Lithium. Hầu như toàn bộ các Lithium này là kiến trúc cụ thể (architecture-specific, mình cũng không hiểu nữa, kiến thức chuyên sâu quá). Đăng ký cấp phát xảy ra ở tầng này.

Ở bước cuối cùng, Lithium được biên dịch thành mã máy. Sau đó, quá trình thay đổi trên ngăn xếp (on-stack replacement: OSR) sẽ diễn ra. Trước khi chúng ta bắt đầu biên dịch và tối ưu hóa một phương thức ngốn nhiều thời gian, chúng ta thường chạy nó trước. V8 sẽ không vứt hết những đoạn code chậm và chạy lại từ đầu đoạn code đã tối ưu. Thay vì thế, V8 sẽ chuyển hóa toàn bộ ngữ cảnh hiện tại (ngăn xếp, các thanh ghi), do đó ta có thể chuyển đổi sang phiên bản đã được tối ưu ngay giữa quá trình thực thi. Đây là một thao tác cực kỳ phức tạp, hãy nhớ rằng V8 đã inline toàn bộ code giữa quá trình tối ưu hóa (Đoạn này viết khó hiểu quá, nhưng mà cũng không quan tọng lắm đâu). Ngoài ra thì V8 không phải là engine duy nhất có thể làm được điều này.

Có một giải pháp an toàn đó là đảo ngược quá trình tối ưu hóa (deoptimization - phức tạp hóa ?) để chuyển đổi toàn bộ code về với trạng thái chưa được tối ưu trong trường hợp engine gặp trục trặc.

Bộ dọn rác (Garbage collection - GC)

Đối với GC thì V8 sử dụng phương pháp thế hệ theo kiểu truyền thống là đánh-dấu-và-quét (mark-and-sweep) để dọn dẹp những thứ cũ. Giả sử quá tình đánh dấu có thể làm ngưng sự thực thi Javascript. Để có thể điều hành GC hiệu quả và thực hiện một cách ổn định, V8 sử dụng đánh dấu gia tăng (incremental marking), thay vì duyệt qua toàn bộ heap và cố đánh dấu nhiều object nhất có thể, nó sẽ đi qua từng heap, sau đó quay lại với những thực thi bình thường. Lần tiếp theo GC chạy sẽ tiếp tục từ vị trí heap mà trước đó nó đã dừng lại. Cách này cho phép một khoảng thời gian dừng rất ngắn giữa quá trình thực thi. Và như đã đề cập ở trước, quá tình quét dọn được xử lý bằng 1 tiến trình khác.

Ignition và TurboFan

Cùng với sự ra mắt của V8 bản 5.9 đầu năm 2017, có 2 pipeline thực thi mới được giới thiệu, mang lại khả năng cải thiện hiệu năng tốt hơn và tiết kiệm bộ nhớ đáng kể đối với những ứng dụng Javascript.

Bộ pipeline mới này được xây dựng trên trình thông dịch của V8 là Ignition và trình biên dịch tối ưu hóa mới nhất là TurboFan.

Bạn có thể xem thêm thông tin trong bài viết trên blog của V8 tại đây

Kể từ phiên bản 5.9 thì full-codegenCrankshaft (những công nghệ đã xuất hiện trong V8 từ năm 2010) đã không còn được sử dụng cho quá trình thực thi Javascript nữa khi mà nhóm phát triển V8 đã phải vất vả để có thể theo kịp với những tính năng mới và sự tối ưu hóa cần thiết cho những tính năng này.

Điều này nghĩa là xét một cách tổng quát thì V8 sẽ đơn giản hơn và có kiến trúc dễ bảo trì hơn.

Những cải tiến này chỉ là bước khởi đầu. Ignition và TurboFan sẽ lót đường cho những tối ưu hóa về sau, đẩy mạnh hiệu năng Javascript và tinh giản bớt V8 trong Chrome và Node.js trong những năm tới.

Cuối cùng, dưới đây là 1 số mẹo vặt để bạn có thể viết được bộ code Javascript một cách tối ưu. Bạn có thể dễ dàng rút ra được bài học từ những gì đã nêu ở trên, nhưng để dễ hiểu hơn thì mời bạn xẹm tổng kết:

Làm thế nào để tối ưu hóa Javascript

  1. Thứ tự các thuộc tính của object: luôn luôn khởi tạo các thuộc tính của object sao cho chúng có cùng thứ tự để lớp ẩn và những code tối ưu có thể chia sẻ dùng chung.
  2. Thuộc tính động (Dynamic properties): thêm thuộc tính vào một object sau khi khởi tạo sẽ ép lớp ẩn phải thay đổi và làm chậm những phương thức đã được tối ưu cho lớp ẩn trước đó. Thay vì thế, ta có thể gán tất cả các thuộc tính của object trong hàm khởi tạo constructor.
  3. Phương thức: code thực thi cùng 1 phương thức nhưng nhiều lần sẽ chạy nhanh hơn code thực thi nhiều phương thức khác nhau nhưng mỗi phương thức chạy một lần (xem phần inline caching)
  4. Mảng: Tránh sử dụng mảng thưa (sparse arrays) mà key không phải là số tăng liên tục. Mảng thưa mà không có phần tử nào thì lại là bảng băm (hash table). Mỗi phần tử trong mảng như vậy sẽ gây ra sự tốn kém mỗi lần truy xuất. Ngoài ra cần tránh trường hợp cấp phát bộ nhớ trước cho những mảng lớn. Cứ để cho mảng "nở ra" một cách tự nhiên. Cuối cũng thì cũng đừng xóa các phần tử trong mảng (toán tử delete()) vì nó chỉ xóa phần từ chứ không đánh index lại mảng.
  5. Tagged values: Trong V8 thì object và số (number) là 32bits. Nó sử dụng 1 bit để phân biệt object (flag = 1) và số integer (flag = 0) gọi là SMI (SMall Integer). Vì thế nếu 1 giá trị số lớn hơn 31 bit thì V8 sẽ đóng gói số đó, chuyển nó thành kiểu double và tạo 1 object mới để lưu số. Chỉ nên sử dụng số nguyên có dấu 31 bit để tránh quá trình chuyển đổi không đáng có (lại tốn công xử lý) số thành object.

Source: https://blog.sessionstack.com/how-javascript-works-inside-the-v8-engine-5-tips-on-how-to-write-optimized-code-ac089e62b12e