Trong bài viết trước, chúng ta đã biết rằng WebAssembly hay JavaScript không phải là lựa chọn một mất một còn. Sẽ không có quá nhiều lập trình viên phải viết toàn bộ code bằng WebAssembly.

Lập trình viên không cần thiết phải lựa chọn giữa WebAssembly hay Javascript cho ứng dụng của mình. Tuy nhiên họ vẫn được kì vọng sẽ thay thế một phần code Javascript trong trang web bằng WebAssembly.

Ví dụ, team phát triển react có thể nghĩ đến việc thay thế cây DOM ảo (aka the virtual DOM) bằng WebAssembly. Về phía sử dụng, người dùng React sẽ không phải thay đổi gì… ứng dụng sẽ chạy hoàn toàn giống như trước, trừ việc chúng sẽ nhận đầy đủ lợi ích của WebAssembly – chạy nhanh hơn.

Team React có thể làm như vậy vì WebAssembly thực thi nhanh hơn Javascript rất nhiều. Nhưng vì sao nó lại nhanh hơn?

Hiệu năng của JavaScript hiện nay như thế nào ?

Trước khi bàn về sự khác biệt hiệu năng giữa Javascript và WebAssembly, chúng ta cần hiểu những công đoạn mà JS engine đang thực hiện.

Biểu đồ sau thể hiện một cái nhìn khái quát về hiệu năng khi khởi động của một ứng dụng Javascript.

Thời gian mà JS engine dành để thực hiện các công đoạn trong biểu đồ hoàn toàn phụ thuộc vào cách mà trang web sử dụng Javascript. Biểu đồ này không nhằm mục đích đưa ra con số chính xác. Thay vào đó nó đưa ra một cái nhìn tổng quan về sự khác biệt hiệu năng của cùng một functions giữa JS và WebAssembly

Diagram showing 5 categories of work in current JS engines

Mỗi thanh màu tương ứng với thời gian thực hiện một task cụ thể

  • Parsing—là thời gian để dịch source code sang một dạng nào đó mà trình phiên dịch có thể hiểu
  • Compiling + optimizing—Thời gian cần để trình dịch cơ sở và trình dịch tối ưu hoạt động. Một số tác vụ trình dịch tối ưu không diễn ra trong luồng chính nên không được nhắc đến ở đây.
  • Re-optimizing—Thời gian JIT dành để điều chỉnh lại code, khi dự đoán ban đầu của nó không đúng, bao gồm cả giai đoạn tối ưu và phản tối ưu.
  • Execution—thời gian cần để code thực thi.
  • Garbage collection—thời gian để dọn dẹp bộ nhớ.

Một điều quan trong cần nhớ: các tác vụ này không diễn ra theo một giai đoạn riêng biệt hay theo một trình tự cụ thể. Thay vào đó nó nhảy qua nhảy lại giữa các quá trình. Thực hiện một chút parsing, thực thi một chút code, rồi một chút biên dịch, một chút parsing nữa, rồi thực thi một đoạn code…

Quá trình chia nhỏ này cũng mang lại cho Javascript hiệu năng đáng kể so với những ngày đầu của nó, mà biểu đồ khi đó có dạng như thế này:

Diagram showing 3 categories of work in past JS engines (parse, execute, and garbage collection) with times being much longer than previous diagram

Trong giai đoạn đầu, khi Javascript chỉ chạy bằng trình phiên dịch, quá trình thực thi diễn ra khá chậm. Khi JITs được áp dụng, nó cải thiện một cách đáng kể thời gian thực thi.

Điều phải đánh đổi là gánh nặng điều khiển và biên dịch code. Nếu lập trình viên vẫn dùng Javascript như cách học vẫn dùng, sẽ chẳng tốn nhiều thời gian để đáp ứng. Tuy nhiên cùng với việc hiệu năng cải thiện đã khiến mọi người viết nhiều ứng dụng lớn hơn bằng Javascript.

Điều đó có nghĩa là cần phải tối ưu nhiều hơn nữa.

WebAssembly thì sao?

Đây là so sánh một cách đại khái thời gian cần thiết giữa WebAssembly và Javascript thông thường.

Diagram showing 3 categories of work in WebAssembly (decode, compile + optimize, and execute) with times being much shorter than either of the previous diagrams

Có sự khác biệt nhỏ giữa các trình duyệt trong cách chúng xử lý từng giai đoạn, bài viết này sẽ sử dụng mô hình của SpiderMonkey trên trình duyệt FireFox.

Fetching

Giai đoạn này không thể hiện trong biểu đồ, nhưng một trong những bước tốn thời gian chính là việc tải file từ server xuống trình duyệt.

Vì WebAssembly ngắn gọn hơn Javascript, nó sẽ tải nhanh hơn. Mặc dù thuật toán nén như gzip đã giảm đáng kể dung lượng của file Javascript, nhưng cách biểu diễn nhị phân của WebAssembly sẽ giúp dung lượng file nhỏ hơn nữa.

Điều này giúp rút ngắn thời gian tải file từ server đến client. Nó sẽ rất có ý nghĩa khi đường truyền internet không tốt.

Parsing

Khi tải được xuống trình duyệt JavaScript sẽ được parse thành cây cú pháp – Abstract Syntax Tree(AST).

Trình duyệt thực hiện điều này một cách khá tằn tiện, nó sẽ chỉ parse những gì cần để chạy, những hàm chưa được gọi sẽ bị bỏ lại.

Từ đây AST sẽ được chuyển thành dạng biểu diễn trung gian(gọi là bytecode) đặc trưng cho từng JS engine.

Ngược lại, WebAssembly không cần trải qua các bước phức tạp này, vì bản thân nó đã là dạng biểu diễn trung gian rồi. Nó chỉ cần được giải mã và kiểm tra lỗi cú pháp.

Diagram comparing parsing in current JS engine with decoding in WebAssembly, which is shorter

Compiling + optimizing

Như bạn đã biết trong bài viết về JIT, Javascript sẽ được biên dịch lúc thực thi dựa vào kiểu biến mà nó sử dụng lúc đó, có thể sẽ có nhiều bản dịch của cùng một đoạn code.

Trình duyệt khác nhau sẽ biên dịch WebAssembly theo cách khác nhau. Một số thực hiện bằng trình dịch cơ sở, một số khác dùng JIT.

Nhưng bằng cách nào đi nữa, kết quả của WebAssembly sẽ ở level gần hơn với ngôn ngữ máy. Ví dụ kiểu biến trong WebAssembly là cố định và là một phần của chương trình. Nó sẽ khiến chương trình chạy nhanh hơn vì:

  1. Trình dịch không cần phải chạy thử code trước khi trình điều khiển có thể quan sát và xác định được kiểu biến mà nó sử dụng để đưa đi tối ưu.
  2. Trình dịch không cần phải dịch một vài phiên bản của code dựa vào kiểu biến mà nó quan sát được.
  3. Có nhiều tối ưu đã được thực hiện trước đó thông qua LLVM. Thế nên không cần nhiều công sức để tổ chức và tối ưu lại code.

Diagram comparing compiling + optimizing, with WebAssembly being shorter

Reoptimizing

Thình thoảng JIT phải xoả bỏ phiên bản code đã tối ưu và thực hiện lại.

Điều này xảy ra khi phán đoán của JIT dựa vào code trong quá khứ hoá ra không chính xác. Ví dụ, phản tối ưu diễn ra khi kiểu của một biến trong vòng lặp này không giống trong các vòng lặp trước đó, hay khi có một hàm mới được thêm vào prototype.

Phản tối ưu sẽ tốn hai bước. Thứ nhất nó phải xoá bỏ code đã tối ưu và thực hiện lại phiên bản của trình dịch cơ sở. Thứ hai, nếu hàm đó vẫn được gọi liên tục, nó sẽ lại cần được tối ưu một lần nữa.

Trong WebAssembly, kiểu biến là rõ ràng và cố định, nên JIT không cần phải phán đoán về kiểu biến. Do đó nó không hề có phản tối ưu.

Diagram showing that reoptimization happens in JS, but is not required for WebAssembly

Executing

Bạn có thể viết một đoạn Javascript thực thi rất hiệu quả. Để làm điều đó bạn cần hiểu rõ những tối ưu mà JIT sử dụng. Ví dụ, bạn cần viết code sao cho trình dịch có thể phán đoán kiểu biến một cách hiệu quả.

Tuy nhiên hầu hết lập trình viên không có hiểu biết về cách JIT làm việc. Cho dù với những người biết rõ về JIT, cũng cần một số kỹ năng để sử dụng nó hiệu quả. Rất nhiều coding patterns đang được dùng để code dễ đọc hơn (ví dụ abstract một hàm để nó hoạt động với nhiều kiểu biến) đang khiến JIT khó khăn hơn trong việc tối ưu code.

Hơn nữa, mỗi trình duyệt lại sử dụng một kiểu tối ưu khác nhau. Một đoạn code có thể chạy rất tốt trên trình duyệt này, nhưng lại có thể chậm trên một trình duyệt khác.

Bởi vì thế, code WebAssembly thường thường thực thi hiệu quả hơn. Rất nhiều tối ưu mà JIT thực hiện với Javascript(Ví dụ như cố định kiểu biến) đã xuất hiện sẵn trong WebAssembly

Thêm vào đó, WebAssembly được thiết kế là dạng ngôn ngữ đích cho các trình dịch khác. Nói cách khác nó được thiết kế để được sinh tự động bằng máy, chứ không phải được viết thủ công bởi con người.

Bởi vì con người không cần phải làm việc với WebAssembly, nó có thể cung cấp nhiều lệnh thân thiện hơn với máy tính. Tuỳ thuộc vào nội dung mà bạn code, các tập lệnh thân thiện đó có thể thực thi nhanh hơn từ 10% đến 800%.

Diagram comparing execution, with WebAssembly being shorter

Garbage collection

Với Javascript, lập trình viên không cần để ý đến việc dọn dẹp bộ nhớ sau khi sử dụng. Thay vào đó JS engine sẽ tự động dọn dẹp đựa vào một thứ gọi là garbage collector.

Điều này có thể hơi vấn đề nếu bạn muốn hiệu năng ổn định. Bạn không thể điều khiển thời điểm garbage collector hoạt động, nên nó có thể khởi chạy vào lúc không thích hợp. Hầu hết trình duyệt thực hiên rất tốt việc lập kế hoạch, nhưng nó vẫn là một cái gì đó có thể ảnh hưởng đến code của bạn.

Ít nhất hiện tại, WebAssembly hoàn toàn không cung cấp garbage collector. Bộ nhớ cần quản lý thủ công (giống như trong C/C++). Mặc dù nó khiến bạn khó làm việc hơn, nhưng chắc chắn nó sẽ giúp chương trình của bạn có hiệu năng ổn định.

Diagram showing that garbage collection happens in JS, but is not required for WebAssembly

Kết luận

Có nhiều lý do để WebAssembly chạy nhanh hơn JavaScript như:

  • WebAssembly cần ít thời gian hơn để tải xuống so với JavaScript, ngay cả khi nó đã được nén .
  • Giải mã WebAssembly nhanh hơn so với parsing JavaScript.
  • biên dịch và tối ưu hoá nhanh hơn vì WebAssembly gần với mã máy hơn JavaScript và đã được tối ưu sẵn từ quá trình dịch trước đó.
  • Không cần tái tối ưu, vì kiểu biến của WebAssembly là cố định, nên JS engine không cần phải xử lý nó như với Javascript
  • thời gian thực thi thường nhanh hơn, vì bản thân lập trình viên không cần phải biết nhiều kỹ năng để tối ưu hoá, và tập lệnh của WebAssembly thích hợp hơn cho mã máy.
  • Không cần chạy garbage collection vì bộ nhớ được quản lý thủ công.

Đó là lý do mà trong rất nhiều trường hợp WebAssembly thường thực thi nhanh hơn Javascript với cùng một tác vụ.

Tuy nhiên vẫn còn một vài trường hợp WebAssembly không hiệu quả như kì vọng. Tuy nhiên ctrong tương lai gần điều này sẽ được cải thiện. Mình sẽ thảo luận nó trong bài viết tiếp theo.