Tái sử dụng code không chỉ để tiết kiệm dung lượng, nó giúp JIT Compiler kích hoạt Hot Path. Nhưng nếu không cẩn thận với kiểu dữ liệu, bạn sẽ sập bẫy Megamorphic.
Có một nguyên tắc lập trình mà ai cũng thuộc nằm lòng ngay từ những ngày đầu gõ phím: DRY (Don’t Repeat Yourself). Nguyên tắc này dạy chúng ta rằng: Nếu một đoạn logic lặp lại nhiều lần, hãy gom nó vào một hàm duy nhất và tái sử dụng.
Hầu hết chúng ta đều nghĩ mục đích của DRY là để con người dễ bảo trì code hơn, hoặc để giảm dung lượng file script. Điều đó đúng, nhưng chưa đủ sâu.
Trong thế giới của JavaScript hiện đại (và các ngôn ngữ chạy trên máy ảo nói chung), việc tái sử dụng code có một tác dụng phụ cực kỳ khủng khiếp: Nó ép Engine phải biên dịch hàm đó thành Machine Code siêu tốc.
Nhưng đồng thời, nó cũng giăng ra một cái bẫy chết người: Bẫy Megamorphic.
Như đã tìm hiểu ở các bài trước, JavaScript Engine (như V8 của Chrome/Node.js) sử dụng cơ chế Just-In-Time (JIT) Compiler.
Nếu bạn copy-paste một đoạn code cộng 2 số ở 10 file khác nhau, và mỗi file chỉ chạy đoạn code đó 10 lần. JIT Compiler sẽ nhìn vào và nghĩ: “Mấy đoạn code này chạy ít quá, không bõ công tối ưu, cứ để cho bộ thông dịch (Interpreter) chạy rề rề cũng được.”
Nhưng nếu bạn gộp chúng lại thành một hàm function add(a, b), và gọi nó từ 10 file đó. Lúc này, hàm add được gọi tổng cộng 100 lần.
Profiler (Người theo dõi) của V8 sẽ rú còi báo động: “Khoan đã! Hàm add này là một Hot Path (Đường dẫn nóng). Gọi JIT Compiler mau!”
Ngay lập tức, V8 bế hàm add của bạn ra, dịch thẳng nó thành Mã máy (Native Code) dành riêng cho CPU phần cứng. Kể từ đó, hàm add của bạn chạy với tốc độ ngang ngửa C++.
Sự thật là: Bạn không chỉ đang viết code cho con người — bạn đang “huấn luyện” JIT Compiler. Việc gọi một hàm hàng ngàn lần ở nhiều nơi chính là “chất xúc tác” để kích hoạt sức mạnh thực sự của nó.
Nghe có vẻ tuyệt vời đúng không? Chúng ta cứ việc tạo ra những hàm “vạn năng” có thể nhận bất cứ tham số nào, dùng ở mọi nơi!
Khoan đã. JIT Compiler biên dịch code dựa trên một phép “đánh cược” cực kỳ rủi ro: Nó ngầm định rằng hình thái dữ liệu (Shape) đầu vào của bạn sẽ không bao giờ thay đổi.
Hãy xem một ví dụ cực kỳ đơn giản này:
function getX(obj) {
return obj.x;
}
// V8 thích điều này: Gọi 1000 lần với cùng một cấu trúc -> Cực nhanh ⚡️
getX({ x: 10, y: 20 });
// Và thảm họa ập đến: Ai đó tái sử dụng hàm nhưng truyền cấu trúc khác
getX({ a: 5, x: 10 });
Chỉ vì thêm một field ở một chỗ khác, hàm này mất hoàn toàn mọi tối ưu hóa trước đó.
Bạn có thể tự mình thử nghiệm điều này. Hãy thử phá vỡ giả định (assumption) của JIT Compiler trong Simulator dưới đây để hiểu điều gì xảy ra khi bạn ném vào đó đủ thứ kiểu dữ liệu hỗn loạn.
Hãy phân tích từng giai đoạn trong Simulator trên:
Monomorphic (Đơn hình thái): Bạn truyền Số nguyên (Int) vào hàm. V8 thấy vậy, nó dịch ra Mã máy chỉ dành cho Số nguyên. Cấu trúc này gọi là Monomorphic, và nó có tốc độ nhanh nhất có thể.
Polymorphic (Đa hình thái): Sau đó, từ một chỗ nào đó trong source code, đồng nghiệp của bạn lại truyền một Chuỗi (String) vào chính hàm đó. V8 bị sốc! Đoạn Mã máy cũ bị lỗi (De-opt). V8 phải cắn răng biên dịch lại một đoạn Mã máy to hơn, phân nhánh IF-ELSE phức tạp hơn để hỗ trợ cả Số và Chuỗi. Đây là Polymorphic. Code vẫn nhanh, nhưng đã chậm đi một chút vì phải kiểm tra xem đầu vào là Số hay Chuỗi.
Megamorphic (Siêu đa hình thái): Nó quyết định: “Hàm này quá hỗn loạn! Xóa bỏ toàn bộ Machine Code!” Mọi tối ưu trước đó — biến mất hoàn toàn. Hàm của bạn bị ném về lại cho bộ thông dịch (Interpreter) chạy lết rề rề vĩnh viễn!
Và thế là, từ một hàm chạy nhanh như sấm sét, hàm tái sử dụng “tuyệt vời” của bạn giờ đây đã lết như một con rùa.
“JavaScript trông rất linh hoạt, nhưng JIT Compiler lại cực kỳ bảo thủ.”
Nguyên lý DRY vẫn luôn đúng, nhưng khi làm việc với các hệ thống hiệu năng cao (đặc biệt là V8/Node.js/Deno), hiệu năng không đến từ việc viết code “thông minh”, mà đến từ việc viết code “dễ đoán” (predictable).
JIT hates unpredictability.
Nếu bạn có một hàm processData(), hãy đảm bảo mọi nơi gọi nó đều truyền vào cùng một “hình hài” dữ liệu (cùng số lượng thuộc tính, cùng kiểu dữ liệu). Đây cũng là lý do vì sao TypeScript lại giúp tăng hiệu năng gián tiếp: Nó khóa chặt kiểu dữ liệu, giúp V8 tự tin tối ưu hóa mà không sợ bị “lật lọng” lúc runtime!
Lần tới khi bạn viết một hàm tiện ích (Utility), hãy tự hỏi: “Liệu mình có đang tạo ra một bẫy Megamorphic không?”