Tối ưu hóa trình thông dịch ngôn ngữ động: Hành trình đưa Zef từ chậm chạp sang cạnh tranh với Lua và Python
Bài viết này chia sẻ hành trình tối ưu hóa một trình thông dịch ngôn ngữ động đơn giản tên là Zef, giúp tăng tốc độ lên tới 16 lần so với bản gốc và tiệm cận với các ngôn ngữ như Lua, QuickJS và CPython mà không cần dùng JIT compiler hay Garbage Collector phức tạp.
Tối ưu hóa trình thông dịch ngôn ngữ động: Hành trình đưa Zef từ chậm chạp sang cạnh tranh với Lua và Python
Bài viết này khám phá quá trình tối ưu hóa một trình thông dịch ngôn ngữ động cực kỳ đơn giản dựa trên AST (Abstract Syntax Tree - Cây cú pháp trừu tượng) cho một ngôn ngữ gọi là Zef. Mục tiêu là đưa trình thông dịch này từ trạng thái thô sơ lên mức độ có thể cạnh tranh với các "ông lớn" như Lua, QuickJS và CPython.
Tại sao lại tập trung vào tối ưu hóa cơ bản?
Hầu hết các tài liệu về việc tạo ra trình triển khai ngôn ngữ nhanh thường tập trung vào các công việc khi bạn đã có một nền tảng ổn định, chẳng hạn như viết thêm một trình biên dịch JIT (Just-In-Time) khác hoặc tinh chỉnh bộ thu gom rác (Garbage Collector). Tuy nhiên, bài viết này đi theo hướng khác: nó dành cho trường hợp bạn bắt đầu từ con số không, chưa sẵn sàng viết JIT và GC chưa phải là vấn đề lớn nhất.
Các kỹ thuật được trình bày ở đây rất dễ hiểu — không có SSA, không có GC phức tạp, không có bytecode hay mã máy — nhưng chúng mang lại sự cải thiện hiệu suất khổng lồ: tăng tốc 16 lần (67 lần nếu bao gồm bản port chưa hoàn thiện sang Yolo-C++). Điều này giúp trình thông dịch nhỏ bé của tác giả tiệm cận với hiệu suất của QuickJS, CPython và Lua.
Các kỹ thuật trọng tâm bao gồm:
- Biểu diễn giá trị (Value representation).
- Bộ nhớ đệm nội tuyến (Inline caching).
- Mô hình đối tượng (Object model).
- Watchpoints.
- Tối ưu hóa dựa trên tư duy thông thường.
Phương pháp đánh giá
Để đo lường tiến bộ, một bộ benchmark gọi là ScriptBench1 đã được tạo ra, bao gồm các bài kiểm tra cổ điển:
- Richards (lịch trình hệ điều hành).
- DeltaBlue (bộ giải ràng buộc).
- N-Body (mô phỏng vật lý).
- Splay (kiểm tra cây nhị phân).
Tất cả các thử nghiệm đều chạy trên Ubuntu 22.04.5 với chip Intel Core Ultra 5 135U và 32GB RAM. Zef được biên dịch với Fil-C++ phiên bản 0.677, trong khi các ngôn ngữ so sánh khác sử dụng các trình biên dịch C++ chuẩn (GCC, v.v.).
Trình thông dịch Zef ban đầu
Phiên bản đầu tiên của Zef được viết mà gần như không quan tâm đến hiệu suất. Nó sử dụng các lựa chọn kém hiệu quả như:
- Đệ quy AST walking: Trình thông dịch được triển khai dưới dạng phương thức ảo
Node::evaluate. - Sử dụng String khắp nơi: Các nút AST giữ
std::stringđể mô tả tên biến, dẫn đến việc băm và so sánh chuỗi liên tục. - Hashtable khắp nơi: Sử dụng
std::unordered_mapcho mọi thứ. - Chuỗi gọi đệ quy: Để duyệt qua chuỗi phạm vi (scope chain).
Kết quả là, Zef ban đầu chậm hơn CPython 3.10 tới 35 lần, chậm hơn Lua 5.4.7 tới 80 lần và chậm hơn QuickJS 23 lần.
Hành trình tối ưu hóa 21 bước
Tác giả đã thực hiện 21 bước tối ưu hóa từng chút một. Dưới đây là những bước ngoặt quan trọng nhất:
1. Gọi trực tiếp toán tử (Direct Operators)
Thay vì phân tách a + b thành DotCall(a, "add") yêu cầu tra cứu chuỗi, trình phân tích cú pháp (parser) giờ tạo ra các nút AST riêng biệt cho từng toán tử. Điều này giúp gọi trực tiếp vào các đường dẫn nhanh (fast paths) của giá trị, mang lại tăng tốc 17,5%.
4. Sử dụng Symbols (Biểu tượng)
Thay thế std::string bằng các con trỏ đến đối tượng Symbol được hash-consing. Điều này cho phép so sánh các biểu tượng chỉ bằng con trỏ thay vì so sánh chuỗi, mang lại lợi ích 18%.
6. Mô hình đối tượng, Inline Caches và Watchpoints (Bước ngoặt lớn nhất) Đây là sự thay đổi lớn nhất, kết hợp ba kỹ thuật:
- Mô hình đối tượng mới: Thay vì dùng hashtable cho mọi đối tượng và phạm vi, giờ đây sử dụng
StoragevàOffsetsđược tính toán trước. - Inline Caches: Một kỹ thuật cổ điển trong các ngôn ngữ động hiện đại. Tại một vị trí trong mã thực hiện
expr.name, trình thông dịch sẽ "nhớ" kiểu dữ liệu cuối cùng củaexprvà offset mànamephân giải tới. Nếu lần sau kiểu dữ liệu không đổi, nó sẽ truy cập trực tiếp vào bộ nhớ mà không cần tra cứu hashtable. - Watchpoints: Xử lý các trường hợp đặc biệt khi một thuộc tính có thể bị ghi đè bởi lớp con.
Sự thay đổi này một mình đã giúp Zef nhanh hơn 4,55 lần, rút ngắn khoảng cách với các ngôn ngữ khác xuống mức tương đương chi phí overhead của Fil-C++.
7. Tối ưu hóa Arguments (Đối số)
Thay vì truyền std::optional<std::vector> (vốn gây cấp phát bộ nhớ), tác giả tạo ra kiểu Arguments chuyên dụng, giúp người gọi cấp phát trực tiếp các đối số, giảm một nửa số lần cấp phát cho mỗi lệnh gọi hàm.
11. Hashtable toàn cục Thay vì duyệt qua phân cấp lớp (class hierarchy) để tìm phương thức — một quy trình O(chiều sâu phân cấp) — một bảng băm toàn cục được sử dụng để ánh xạ trực tiếp từ (lớp nhận, biểu tượng) đến phương thức được gọi.
Kết quả cuối cùng
Sau 21 bước tối ưu hóa với Fil-C++, Zef đã nhanh hơn 16,6 lần so với bản gốc. Nó chỉ chậm hơn CPython khoảng 2,1 lần và chậm hơn QuickJS khoảng 1,35 lần.
Khi chuyển sang biên dịch với Yolo-C++ (một trình biên dịch C++ "thả ga" không an toàn nhưng nhanh, thay thế GC bằng calloc), tốc độ đã tăng vọt lên 67 lần so với bản gốc, thậm chí nhanh hơn cả QuickJS và chỉ chậm hơn Lua một chút.
Bài học rút ra
Quá trình này cho thấy rằng với các kỹ thuật tối ưu hóa nền tảng đúng đắn — biểu diễn giá trị hợp lý, inline caching, và giảm thiểu cấp phát bộ nhớ — một trình thông dịch đơn giản có thể đạt được hiệu suất đáng nể mà không cần đến những công nghệ phức tạp như JIT compiler ngay lập tức.



