Tập 30: Sau binary là câu hỏi lớn hơn

Compiler đã tạo dựng thành công file thực thi nhị phân đầu tiên. Phản xạ tự nhiên tiếp theo của một kỹ sư viết compiler là khao khát làm thế nào để compiler tạo ra các file nhị phân chạy nhanh và tối ưu hơn nữa.

Và đây cũng chính là nơi lập trình viên rất dễ làm hỏng một chương trình vốn đang chạy đúng đắn chỉ bằng một ý tưởng tối ưu hóa nghe có vẻ vô cùng hợp lý trên giấy.

Sprint 12 đưa bộ tối ưu hóa sơ khai V0 (optimizer V0) hoạt động trên mã nguồn DUY IR: thực hiện thu gọn hằng số (constant folding), xóa mã chết đơn giản (dead-code elimination) và dọn dẹp các block logic không thể chạm tới (unreachable blocks). Ví dụ biểu tượng vẫn xuất hiện từ phép tính của file hello:

let x: i32 = 40 + 2

Nếu cả hai toán hạng đều là hằng số và phép toán cộng có ngữ nghĩa hoàn toàn rõ ràng từ compile-time, biểu diễn trung gian IR không cần thiết phải mang phép toán cộng vật lý ấy hạ xuống backend làm gì. Nó hoàn toàn có thể tự tính toán và lưu giữ trực tiếp con số 42 ngay từ thời điểm biên dịch.

Constant folding là một pass tối ưu hóa cực kỳ dễ giải thích ngữ nghĩa. Nhưng để viết code triển khai nó một cách an toàn lại buộc ta phải trả lời hàng loạt câu hỏi logic hiểm hóc: hiện tượng tràn số nguyên (integer overflow) xử lý theo luật nào, phép chia cho số không (division by zero) phát hiện khi nào, phép toán trên số có dấu và không dấu khác biệt ra sao, các phép toán trên số thực (floating-point) có được tự ý thay đổi thứ tự thực hiện hay không? Chỉ cần lập trình viên lười biếng sử dụng luôn ngữ nghĩa toán học của ngôn ngữ host (như JavaScript của Node.js) để thực hiện tính toán hộ cho DUY, một kết quả tối ưu hóa có thể chạy rất đúng trên các số nhỏ nhưng lại sinh ra kết quả sai lệch tai hại khi chạm đến các giá trị biên của phần cứng vật lý.

Dead-code elimination (DCE) còn nhạy cảm và nguy hiểm hơn nhiều. Đoạn mã nằm phía sau câu lệnh return chắc chắn không bao giờ được thực thi (unreachable) và hoàn toàn có thể bị compiler xóa bỏ một cách an toàn để giảm dung lượng file. Một nhánh rẽ điều khiển có điều kiện kiểm tra luôn luôn là hằng số (ví dụ if (true)) có thể được thu gọn về một nhánh duy nhất. Nhưng một biểu thức trông có vẻ không sử dụng đến kết quả trả về vẫn hoàn toàn có thể đang chứa các hiệu ứng phụ nhạy cảm bên trong như thực hiện ghi file I/O, đột biến trạng thái toàn cục, hoặc chứa vùng nguy hiểm unsafe. Nếu thông tin hiệu ứng phụ (effects) bị thất lạc hoặc bị compiler bỏ qua trong quá trình tối ưu hóa, bộ optimizer sẽ âm thầm “tối ưu hóa” xóa bỏ luôn cả hành vi nghiệp vụ quan trọng mà lập trình viên đang trông đợi.

fn main() -> i32 {
  return 0
  print(99)
}

Ở ví dụ tối giản này, lệnh print(99) thực sự là unreachable. Xóa bỏ nó đi là quyết định hoàn toàn đúng đắn. Nhưng việc tự ý xóa bỏ một lời gọi hàm chỉ vì kết quả trả về của hàm đó không được sử dụng ở các dòng dưới lại là một câu chuyện hoàn toàn khác. Tính đúng đắn (correctness) của một pass tối ưu hóa nằm ở các điều kiện ràng buộc áp dụng (preconditions), tuyệt đối không nằm ở cái tên gọi kêu tai của pass đó.

Đó là lý do vì sao Sprint 12 đặt ra yêu cầu nghiêm ngặt: mọi tối ưu hóa bắt buộc phải đi kèm các ca kiểm thử đối chiếu IR trước và sau khi optimize (before/after tests). Chúng tôi cần nhìn thấy rõ ràng mã nguồn IR đầu vào, mã nguồn IR sau tối ưu và xác nhận hành vi runtime của tệp nhị phân sinh ra vẫn hoàn toàn tương đương ngữ nghĩa. Việc so khớp snapshot giúp phát hiện ngay lập tức nếu bộ optimizer tự ý thay đổi cấu trúc đồ thị logic một cách bất ngờ. Việc chạy test thực thi (execution test) giúp phát hiện trường hợp snapshot trông có vẻ rất đẹp mắt nhưng ý nghĩa ngữ nghĩa thực tế đã bị dịch sai lệch.

Bộ optimizer V0 của DUY chưa phải là lời tuyên chiến về hiệu năng thực thi đối với thế giới bên ngoài. Trình biên dịch Clang và LLVM phía sau vốn dĩ đã sở hữu hàng nghìn pass tối ưu hóa cực kỳ mạnh mẽ và thông minh hơn nhiều. Giá trị thực tế của bộ optimizer V0 của DUY nằm ở chỗ khác: nó buộc ngôn ngữ của chúng tôi phải phát biểu tường minh ngữ nghĩa logic trên chính cấu trúc IR của mình. Nó đồng thời mở ra con đường tối ưu hóa dựa trên các thông tin ngữ nghĩa độc quyền mà chỉ có compiler của DUY hiểu được (như phân vùng region, hệ thống effects, ownership tĩnh) – những thông tin mà các backend chung của hệ điều hành có thể đã bị thất lạc hoàn toàn trong quá trình dịch hạ cấp.

Nhưng mỗi một pass tối ưu hóa mới được thêm vào compiler sẽ làm tăng diện tích bề mặt mã nguồn cần tin cậy (trusted surface) lên gấp bội. Bộ parser viết sai cú pháp thường chỉ dẫn đến việc compiler từ chối dịch hoặc hiểu sai lệch một chương trình cụ thể. Bộ optimizer viết sai thuật toán có thể âm thầm biến đổi một chương trình hoàn toàn đúng đắn của người dùng thành một tệp nhị phân thực thi sai ngữ nghĩa ở runtime mà không hề phát ra bất kỳ một cảnh báo lỗi nào. Đây chính là loại lỗi nguy hiểm nhất của công nghệ compiler – loại lỗi khiến bạn dù chạy test báo xanh rực trên mười ví dụ mẫu vẫn chưa thể yên tâm đi ngủ.

Tôi tự rút ra một quy tắc làm việc sắt đá: mọi pass tối ưu hóa mới bắt buộc phải tự kiếm được quyền tồn tại của mình bằng các bằng chứng kiểm thử đanh thép. Phải có đặc tả precondition rõ ràng. Phải có bộ before/after fixtures chi tiết. Phải có reject cases (các trường hợp bộ tối ưu hóa cấm không được phép tự ý chạy). Phải chứng minh được tính tương đương ngữ nghĩa ở runtime. Và nếu có bất kỳ một tuyên bố nào về việc tăng hiệu năng thực thi, tuyên bố đó bắt buộc phải đi kèm với một bộ benchmark tự động có khả năng tái lập kết quả bởi bất kỳ ai.

Trải qua mười tập phát triển của Chương 3, DUY đã đi được một hành trình thực tế từ việc khởi tạo thư mục dự án đến xây dựng CLI, từ việc gắn span vị trí lỗi đến hoàn thiện ngữ pháp, từ việc dựng cây AST đến viết checker, từ cấu trúc IR đến cầu tạm C, nhả ra file binary thực thi đầu tiên và viết bộ tối ưu hóa sơ khai. Tốc độ tiến triển nhanh chóng rất dễ mang lại cho đội ngũ một cảm giác tự mãn rằng ngôn ngữ đã hoàn thiện hình hài.

Thực tế kỹ thuật lạnh lùng chỉ ra rằng: phần việc khó khăn và thử thách nhất mới chỉ vừa bắt đầu. Memory model của DUY vẫn đang ở dạng dự thảo cú pháp. Hệ thống effects chưa thể bao phủ toàn bộ các chi phí ẩn của runtime. Ranh giới vùng nguy hiểm unsafe boundary chưa được đóng kín. Bộ verifier cho IR chưa trở thành cổng gác bắt buộc ở mọi lượt biên dịch. LLVM backend độc lập và freestanding runtime bare-metal vẫn còn nằm ở các sprint tương lai.

Tệp thực thi đầu tiên chạy thành công mới chỉ trả lời cho câu hỏi tò mò “Liệu DUY có thể chạy được trên máy tính không?”. Chương tiếp theo sẽ bắt buộc phải trả lời một câu hỏi đáng sợ hơn của một ngôn ngữ hệ thống chuyên nghiệp: "Khi DUY thực thi dưới phần cứng, trình biên dịch có thật sự đủ năng lực logic để ngăn chặn tuyệt đối các hành vi phá hủy bộ nhớ nguy hiểm không được phép xảy ra hay không?"


Chương 3 dựa trên các kế hoạch phát triển thực tế từ Sprint 01 đến Sprint 12 trong tài liệu lưu trữ plans/archive/SPRINT_01_45.md và mã nguồn sơ khai của compiler DUY. Lịch sử Git giai đoạn Genesis được commit theo các khối tính năng lớn, vì vậy chương truyện sử dụng sprint plan làm trục thời gian logic, còn mã nguồn ví dụ và hệ thống tests làm bằng chứng xác thực cho các hành vi kỹ thuật. Chương truyện không ngụ ý rằng mỗi sprint tương ứng chính xác với một commit Git đơn lẻ.

Điểm kết thúc của chương truyện đánh dấu compiler prototype của DUY đã hoàn thiện một lát cắt dọc end-to-end hoạt động được và tích hợp bộ optimizer V0. Toàn bộ các cam kết an toàn tĩnh về bộ nhớ tĩnh (static ownership), hệ thống effects nâng cao, ranh giới unsafe boundary và bộ xác minh typed IR verifier thuộc phạm vi nội dung của Chương 4, tuyệt đối không được kể lùi thời gian như thể đã hoàn thiện trọn vẹn từ thời điểm có tệp nhị phân hello đầu tiên.

Tập 30: Sau binary là câu hỏi lớn hơn

Bình luận (0)

Đang tải bình luận...