TypeScript trên LLVM

Cách Perry hạ cấp một ngôn ngữ được thiết kế cho engine JIT xuống LLVM IR — monomorphization, NaN-boxing, inline lowering — và lý do nó rời bỏ Cranelift.

Tại sao chọn LLVM cho TypeScript?

Một trình biên dịch ahead-of-time sống trong một chế độ khác hẳn so với JIT. JIT biên dịch trong lúc người dùng chờ đợi, nên độ trễ biên dịch là ràng buộc chính. Một trình biên dịch AOT như Perry chỉ biên dịch một lần — trên máy của nhà phát triển hoặc trong CI — và binary sau đó được thực thi hàng triệu lần. Sự bất đối xứng đó chính là nơi một trình tối ưu hóa hạng nặng tự trả được giá của mình.

LLVM mang đến hai thập kỷ công việc middle-end: vector hóa vòng lặp, loop-invariant code motion, global value numbering, sparse conditional constant propagation, inlining mạnh mẽ, alias analysis. Nhiệm vụ của Perry là đưa cho cỗ máy đó một IR mà nó thực sự có thể tối ưu hóa — và đây chính là lúc thông tin kiểu của TypeScript phát huy tác dụng.

Pipeline hạ cấp

Mã nguồn được parse bằng SWC, sau đó hạ cấp thành một IR bậc cao có kiểu (HIR) — nơi các quyết định thú vị diễn ra trước khi LLVM nhìn thấy mã:

  • Monomorphization. Hàm và lớp generic được chuyên biệt hóa theo từng lần khởi tạo cụ thể, cùng chiến lược mà Rust và C++ sử dụng. Stack<number> Stack<string> trở thành hai hàm độc lập, có kiểu đầy đủ — nên trình tối ưu hóa làm việc với kiểu cụ thể thay vì một khối dispatch generic, và generic không tốn chi phí gì lúc runtime.
  • Static dispatch. Khi kiểu của receiver đã biết tại thời điểm biên dịch, lệnh gọi phương thức được biên dịch thành lệnh gọi trực tiếp mà LLVM có thể inline, không phải tra cứu hash-table.
  • Truy cập trường trực tiếp. Trường đối tượng được giải quyết thành chỉ số tại thời điểm biên dịch, nên việc đọc một thuộc tính là một phép load với offset cố định — không phải tra cứu dictionary.

NaN-boxing và inline lowering

Ở những nơi giá trị mang tính động, Perry dùng NaN-boxing: mỗi giá trị là một từ 64-bit. Số double được lưu trực tiếp; đối tượng, chuỗi, boolean, null, và undefined được mã hóa vào các mẫu bit chưa dùng của một quiet NaN theo chuẩn IEEE 754. Số là zero-cost — không boxing, không cấp phát cho phép toán số học.

Vấn đề là các phép toán trên giá trị không phải số cần chuỗi thao tác unpack-operate-repack trên bit. Nếu những chuỗi thao tác đó tồn tại dưới dạng lệnh gọi vào một runtime được biên dịch riêng, LLVM sẽ thấy chúng như những hộp đen mờ đục và không thể tối ưu hóa xuyên qua chúng. Vì vậy Perry phát ra các thao tác nóng — đọc thuộc tính, dispatch phương thức, cấp phát đối tượng — dưới dạng LLVM IR inline mà trình tối ưu hóa có thể gộp và đơn giản hóa. Ví dụ, việc cấp phát đối tượng được biên dịch thành một phép bump allocation thread-local dạng inline:

LLVM IR — inline bump allocation
%off_ptr = getelementptr i8, ptr %state, i64 8
%offset  = load i64, ptr %off_ptr        ; current bump offset
%new_off = add i64 %offset, 96           ; headers + 8 fields
%sz_ptr  = getelementptr i8, ptr %state, i64 16
%size    = load i64, ptr %sz_ptr         ; block capacity
%fits    = icmp ule i64 %new_off, %size
br i1 %fits, label %fast, label %slow

Tại sao không phải Cranelift?

Backend đầu tiên của Perry là Cranelift — bộ sinh mã đứng sau wasmtime, được xây dựng cho việc biên dịch nhanh và có thể dự đoán được. Đó là điểm khởi đầu đúng đắn, và nó vẫn là một lựa chọn xuất sắc cho JIT và runtime sandbox. Hai điều đã buộc phải chuyển đổi:

  • Giới hạn của trình tối ưu hóa. Cranelift cố tình là một trình biên dịch một tầng, nhanh: “mã tạm ổn, nhanh chóng,” đây là sự đánh đổi đúng cho JIT nhưng sai cho một trình biên dịch AOT có điểm bán hàng là hiệu năng gốc đỉnh cao.
  • arm64_32. Apple Watch sử dụng một ABI (lệnh 64-bit, con trỏ 32-bit) mà Cranelift không hỗ trợ. Để watchOS tồn tại như một mục tiêu biên dịch, LLVM là bắt buộc — và duy trì hai backend nghĩa là hai bộ lỗi, hai bộ kiểm thử và hai đường cơ sở hiệu năng.

Việc chuyển đổi không miễn phí: bản phát hành chỉ dùng LLVM đầu tiên khiến một số benchmark chậm đi tới 70 lần vì các thao tác nóng ban đầu phải đi qua các lệnh gọi helper runtime mờ đục. Quá trình khắc phục — inline lowering, bộ cấp phát bump ở trên, ranh giới inlining tốt hơn — đã đưa backend vượt qua các con số của Cranelift, và đến khi ổn định, Perry đã đánh bại Node.js trên mọi benchmark trong bộ của mình, nhanh hơn từ 1,7 lần đến 24,6 lần với hai lần hòa (tháng 4/2026). Bài phân tích hậu kỳ đầy đủ rất đáng đọc: Từ Cranelift đến LLVM.

Tìm hiểu sâu hơn

Trang cấu trúc bên trong trình biên dịch trình bày chi tiết hơn về NaN-boxing, monomorphization và static dispatch. Trên blog, Tối ưu hóa mọi thứ đi qua công việc tối ưu hóa theo từng bản phát hành, và GC theo thế hệ, JSON lười và benchmark chịu được soi xét giải thích cách phương pháp benchmark hoạt động (RUNS=11, trung vị + p95). Để có bức tranh toàn cảnh hơn, hãy bắt đầu với tổng quan trình biên dịch TypeScript gốc.

Tự mình xem đầu ra

perry compile main.ts — mã máy gốc, không có engine nào đi kèm.