Trình biên dịch Javascript JIT (Just-in-time)

Javascript khi mới ra mắt thì khá là chậm chạp, nhưng sau đó đã trở nên nhanh hơn nhờ vào một cái gì đó gọi là JIT. Vậy JIT là gì và làm thế nào để JIT làm việc? Chúng ta sẽ cùng đi tìm hiểu trong bài này.

Làm thế nào Javascript được chạy trong trình duyệt

Khi bạn thêm Javascript vào trang web của mình, bạn có một mục tiêu cần đạt được và một vấn đề cần giải quyết. Mục tiêu: bạn muốn nói với máy tính rằng nó phải làm gì. Vấn đề: bạn và máy tính nói các ngôn ngữ khác nhau. Bạn nói ngôn ngữ loài người, và máy tính thì sử dụng ngôn ngữ máy. Thậm chí nếu bạn không nghĩ về JavaScript hoặc ngôn ngữ lập trình cấp cao khác như ngôn ngữ của con người, thì cũng vậy mà thôi. Các ngôn ngữ đó đã được thiết kế cho nhận thức của con người, chứ không phải cho máy. Vì vậy, công việc của JavaScript engine là biến ngôn ngữ con người của bạn thành một cái gì đó máy tính có thể hiểu được. Tình huống này cũng tương tự như việc con người và người ngoài hành tinh đang cố gắng nói chuyện với nhau. Con người và người ngoài hành tinh không chỉ dịch word-for-word. Hai nhóm có cách khác nhau để suy nghĩ về thế giới. Và đó cũng là sự thật của con người và máy móc.

Vậy thì việc compile xảy ra như thế nào? Trong lập trình, nói chung có hai cách dịch từ ngôn ngữ lập trình bất kỳ nào đó sang ngôn ngữ máy. Đó là interpreter và compiler. Với interpreter, việc dịch được thực hiện theo kiểu line-by-line, khi đang chaỵ. Compiler thì lại không thực hiện việc translate khi chương trình đang chạy. Nó hoạt động trước đó và ghi lại bản dịch. Mỗi cách thì đều có ưu và nhược điểm riêng.

Ưu và nhược điểm Interpreter

Interpreter thì nhanh chóng khởi động và thực thi code. Bạn không cần phải biên soạn toàn bộ trước khi có thể bắt đầu chạy code. Bạn chỉ cần bắt đầu dịch từ dòng đầu tiên và chạy nó. Vì điều này, Interpreter có vẻ như phù hợp cho một cái gì đó như JavaScript. Điều quan trọng với một nhà phát triển web là code của họ có thể khởi động và chạy một cách nhanh chóng. Và đó là lý do tại sao các trình duyệt lại sử dụng interpreters để dịch code JavaScript trong thời kì đầu. Tuy nhiên, khuyết điểm của việc sử dụng Interpreter xảy đến khi bạn chạy cùng một mã nhiều hơn một lần. Ví dụ, nếu bạn đang ở trong một vòng lặp. Khi đó, bạn phải dịch cùng một đoạn code lặp đi lặp lại nhiều lần.

Ưu và nhược điểm của compiler

Compiler có điều thỏa hiệp ngược lại. Phải mất nhiều hơn một chút thời gian để khởi động code bởi vì nó phải đi qua bước biên dịch lúc đầu. Nhưng sau đó code trong vòng lặp chạy nhanh hơn, bởi vì không cần phải lặp lại việc dịch khi đi qua từng vòng lặp. Khác biệt nữa là compiler có nhiều thời gian để xem xét code và thực hiện việc chỉnh sửa code để nó chạy nhanh hơn. Việc chỉnh sửa này được gọi là tối ưu hóa. Interpreter thực hiện công việc của mình trong thời gian code chạy, vì vậy nó không thể giành nhiều thời gian trong giai đoạn dịch để tìm ra những chỗ có thể tối ưu hóa.

Just-in-time compilers: the best of both worlds

Như một cách để loại bỏ sự không hiệu quả (việc interpreter phải retranslating code mỗi khi nó đi qua các vòng lặp), trình duyệt bắt đầu kết hợp các compiler trong đó. Trình duyệt khác nhau thực hiện điều này theo những cách hơi khác nhau, nhưng ý tưởng cơ bản là như nhau. Chúng sẽ thêm một phần mới cho Javascript engine, được gọi là monitor (hay còn gọi là một profiler). Monitor canh chừng các đoạn code đang chạy, và tạo một ghi chú về việc nó được chạy bao nhiêu lần và các kiểu dữ liệu được sử dụng. Lúc đầu, monitor chỉ chạy tất cả mọi thứ thông qua interpreter. Nếu các dòng code giống nhau được chạy một vài lần, đoạn code đó được gọi là warm. Nếu nó chạy rất nhiều, nó sẽ được gọi là hot.

Baseline compiler

Khi một chức năng bắt đầu được gán nhãn warm, JIT sẽ gửi nó đi để biên dịch. Sau đó, nó sẽ lưu giữ biên soạn đó. Mỗi dòng của function được biên soạn thành một stub”. Các stub được lập index bởi line number và variable type. Nếu monitor thấy các đoạn code được thực hiện một lần nữa với các variable type tương tự, nó sẽ chỉ kéo ra phiên bản đã biên dịch của nó thay vì phải dịch lại. Điều đó giúp tốc độ được cải thiện. Nhưng như tôi đã nói, compiler có thể thực hiện nhiều thứ hơn. Nó có thể mất một thời gian để tìm ra cách hiệu quả nhất để làm việc, để tối ưu hóa chương trình. Baseline compiler sẽ làm một số việc để tối ưu hóa code. Tuy nhiên, nó không muốn mất quá nhiều thời gian, bởi vì nó không muốn giữ việc thực hiện quá lâu.

Tuy vậy, nếu code đang thực sự hot (nếu nó đang được chạy một thời gian dài), thì baseline compiler sẽ giành thêm thời gian để thực hiện việc tối ưu.

Optimizing compiler

Khi một phần của code là rất hot, monitor sẽ gửi nó đi để trình biên dịch tối ưu hóa. Điều này sẽ tạo ra phiên bản khác, thậm chí nhanh hơn, và phiên bản đó của function cũng sẽ được lưu trữ để sử dụng ở lần sau. Để thực hiện một phiên bản nhanh hơn của code, trình tối ưu hóa biên dịch tạo một số giả định. Ví dụ, nếu nó có thể giả định rằng tất cả các đối tượng được tạo ra bởi một constructor riêng có cùng hình dạng (nghĩa là, chúng luôn có tên thuộc tính giống nhau trong mỗi phiên gọi) thì nó có thể cắt giảm một số corners dựa vào điều này. Trình tối ưu hóa biên dịch sử dụng thông tin monitor đã thu thập bằng cách quan sát thực thi code để đưa ra những phán quyết. Nếu một cái gì đó đã đúng cho tất cả lần vượt qua vòng lặp trước đó, nó sẽ giả định rằng cái đó sẽ tiếp tục là đúng. Nhưng tất nhiên với JavaScript, không bao giờ có chuyện mọi thứ được đảm bảo. Bạn có thể có 99 objects mà tất cả đều có hình dạng tương tự, nhưng sau đó lần thứ 100 có thể bị mất một thuộc tính. Vì vậy, các đoạn code đã biên dịch cần phải được kiểm tra trước khi nó chạy để xem liệu các giả định là hợp lệ hay không. Nếu đúng thì code đã biên dịch được chạy. Nhưng nếu không, JIT giả sử rằng nó đã tạo ra các giả định sai lầm và vứt bỏ code đã tối ưu. Sau đó, quay về thực thi phiên bản đã interpreter hoặc baseline compile. Quá trình này được gọi là deoptimization (hoặc bailing out). Thông thường các trình tối ưu hóa biên dịch làm cho code nhanh hơn, nhưng đôi khi chúng có thể gây ra vấn đề hiệu suất ngoài mong muốn. Nếu bạn có code đã được tối ưu hóa và sau đó lại thực hiện deoptimization, thì nó kết thúc chậm hơn so với việc chỉ thực thi các phiên bản đã biên dịch baseline. Hầu hết các trình duyệt đã thêm giới hạn để thoát khỏi chu kỳ optimization/deoptimization khi chúng xảy ra.

Kết luận

JIT làm cho việc thực thi code JavaScript nhanh hơn bằng cách theo dõi các đoạn code đang chạy và gửi code hot để thực hiện tối ưu hóa chúng. Điều này dẫn đến cải tiến hiệu suất nhiều lần đối với hầu hết các ứng dụng JavaScript. Tuy vậy, ngay cả với những cải tiến này, hiệu suất của JavaScript không thể đoán trước được. Và để làm cho mọi việc nhanh hơn, thì JIT đã làm tốn thêm một số overhead trong thời gian chạy, bao gồm: optimization và deoptimization bộ nhớ sử dụng cho tính toán và thông tin phục hồi của monitor khi có vấn đề xảy ra bộ nhớ sử dụng để lưu trữ phiên bản baseline và optimized của một function Ở đây có thể cải thiện: overhead có thể được gỡ bỏ, làm cho hiệu suất có khả năng dự đoán hơn. Và đó là một trong những điều mà WebAssembly làm. Trong phần tiếp theo , chúng ta sẽ tìm hiểu về assembly và cách các trình biên dịch làm việc với nó.

Tham khảo: https://hacks.mozilla.org/2017/02/a-crash-course-in-just-in-time-jit-compilers/