1 điểm bởi GN⁺ 4 giờ trước | 1 bình luận | Chia sẻ qua WhatsApp
  • pslang bắt đầu từ mối quan tâm đến khả năng mod của các game quy mô lớn và phần assembly do trình biên dịch C++ tạo ra, hiện đã hoạt động đủ để viết một Monte-Carlo path tracer khoảng 1.000 LOC
  • Ngôn ngữ mod cần có khả năng tương tác với C, xử lý mảng và con trỏ mức thấp, dễ sandbox, trình biên dịch nhỏ gọn và biên dịch nhanh; Lua và chế độ native C++ mỗi bên đều có giới hạn về hiệu năng kết nối, sandbox và triển khai
  • pslang là ngôn ngữ mức thấp theo kiểu mệnh lệnh, đánh giá tức thì, gọi theo giá trị, cung cấp hệ thống kiểu tĩnh, nghiêm ngặt, danh nghĩa, phạm vi dựa trên thụt lề, mảng tích hợp, kiểu hàm, con trỏ và bố cục bộ nhớ được đảm bảo
  • Trình biên dịch được chia thành parser dựa trên Bison, kiểm tra kiểu AST, IR, interpreter và JIT; hiện chỉ hỗ trợ Aarch64 Mac, và sau khi đưa vào IR thì chất lượng mã sinh ra vẫn còn thấp do chưa có bộ cấp phát thanh ghi
  • Bản triển khai hiện tại có khoảng 10.000 dòng mã C++, và đang xem xét các tính năng như bộ cấp phát thanh ghi, tối ưu hóa IR, IR interpreter, tạo tệp thực thi, thông tin gỡ lỗi, đa hình, module và thư viện chuẩn

Bối cảnh dẫn đến việc tạo ra pslang

  • Sau khoảng 17 năm lập trình, tác giả ngày càng muốn tự tạo một ngôn ngữ không chỉ là đồ chơi mà có tính đến mức độ sử dụng thực tế nhất định
  • Trước đây tác giả từng làm interpreter cho các ngôn ngữ kỳ lạ như FALSE và nhiều interpreter lambda calculus, nhưng điều đó không thỏa mãn mong muốn tạo ra một ngôn ngữ “thật sự”
  • game quy mô lớn đang phát triển có cấu trúc phù hợp để mod, trong lúc cân nhắc cách làm mod thì ngôn ngữ lập trình tùy biến nổi lên như một trong những lời giải đơn giản
  • Vào tháng 12 năm 2025, khi theo dõi Advent of Compiler Optimisations của Matt Godbolt, tác giả bắt đầu lần theo phần assembly mà trình biên dịch C++ tạo ra và lại muốn làm việc với assembly
  • Dù ngôn ngữ hiện còn rất xa chất lượng production, nó đã được triển khai đến mức có thể viết một Monte-Carlo path tracer đang chạy được với quy mô khoảng 1.000 LOC

Yêu cầu cho mod và giới hạn của các lựa chọn hiện có

  • Game mô phỏng hàng trăm nghìn entity bằng custom ECS engine, nên tác giả muốn ngôn ngữ mod có thể nhận bó con trỏ component và duyệt chúng như vòng lặp for của C
  • Vì mod khó kiểm soát, cần phải dễ sandbox để bảo vệ người chơi; lý tưởng nhất là có thể vô hiệu hóa mọi IO và chức năng tương tự chỉ bằng một công tắc
  • Việc mod phải đủ đơn giản để chỉ cần đặt script vào một thư mục nhất định là có thể dùng ngay như mod
  • Lua và các ngôn ngữ script có JIT

    • Lua là lựa chọn tiêu chuẩn, nhưng có vẻ cần sandbox kiểu gắn thêm mã tiền xử lý xóa các hàm liên quan đến IO trong thư viện chuẩn trước khi đưa mã không đáng tin cậy vào, và điều đó không tạo cảm giác là lời giải ổn định
    • Lua là ngôn ngữ động mức cao nên không hiểu trực tiếp con trỏ C; vì vậy để nối việc duyệt entity trong ECS thì hoặc sẽ phát sinh chuyển đổi native ↔ Lua ↔ native cho từng entity, hoặc phải biến entity native thành mảng Lua rồi lại tháo ra
    • Lua chuẩn và LuaJIT đã tách hướng từ vài phiên bản trước, điều này có thể gây nhầm lẫn cho cả modder lẫn người triển khai
  • C++ và mod native

    • Nếu làm mod bằng C++ thì bài toán duyệt entity biến mất, nhưng phân phối nhị phân sẽ đòi hỏi môi trường phát triển cho mọi nền tảng cùng kho lưu trữ artifact nhị phân
    • Nếu phân phối dưới dạng mã nguồn thì phải kèm trình biên dịch C++ trong game, trong khi một bản cài LLVM mặc định đã chiếm dung lượng đĩa lớn hơn kích thước game hiện tại khoảng 10~20 lần
    • Nếu DLL native khai báo và dùng int open(); thì gần như không thể ngăn truy cập hệ thống tệp hay mạng, nên không thể sandbox
    • Các ngôn ngữ native khác như Rust cũng gặp vấn đề tương tự
    • Mod là một trong các mục tiêu, nhưng vẫn chưa chắc ngôn ngữ này có thực sự được dùng cho mod game hay không, và tác giả không muốn chuyên biệt hóa quá mức cho một trường hợp sử dụng cụ thể

Mục tiêu thiết kế ngôn ngữ

  • Muốn cung cấp khả năng tương tác với C liền mạch để việc kết nối giữa mã game native và mã mod đơn giản như gọi hàm
  • Vì cần xử lý các mảng entity thô nên cần các tính năng mức thấp
  • Ngôn ngữ phải thực dụng và dễ dùng để modder có thể viết mã với mức tiện lợi hợp lý
  • Cần dễ sandbox và trình biên dịch cũng phải nhỏ gọn
  • Không muốn đưa trình biên dịch 1GB vào một game 50MB, nên muốn giảm footprint của trình biên dịch
  • Cần biên dịch nhanh để người chơi không phải chờ quá lâu khi biên dịch mod, dù một phần có thể giảm bớt nhờ cache rộng rãi
  • Tác giả muốn hỗ trợ đa nền tảng thực sự, nhưng chấp nhận một số giả định như vài nền tảng desktop phổ biến, 64-bit và hỗ trợ IEEE754
  • Chỉ cần nhanh ở mức hợp lý so với phần lớn ngôn ngữ động là đủ
  • Vì C++ đã là ngôn ngữ chính trong thời gian dài nên nó ảnh hưởng mạnh tới quan niệm về ngôn ngữ, nhưng tác giả cố tránh việc đơn thuần tạo lại C++

Mô hình ngôn ngữ hiện tại của pslang

  • Tên tạm là pslang, lấy từ game engine psemek; đây là ngôn ngữ mức thấp, mệnh lệnh, đánh giá tức thì và gọi theo giá trị
  • Hệ thống kiểu gồm kiểu tĩnh, nghiêm ngặt và danh nghĩa
  • Ví dụ cơ bản sử dụng đồng thời hàm, struct, kiểu hàm và trả về mảng
func min(x: i32, y: i32) -> i32:
    return if x < y then x else y

struct vec3i:
    x: i32
    y: i32
    z: i32

func apply(f: i32 -> i32, v: vec3i) -> vec3i:
    return vec3i(f(v.x), f(v.y), f(v.z))

func as_array(v: vec3i) -> i32[3]:
    return [v.x, v.y, v.z]

Phạm vi và các kiểu cơ bản

  • Sử dụng phạm vi dựa trên thụt lề để trông giống ngôn ngữ scripting hơn và tạo cảm giác thân thiện hơn với người mới bắt đầu
  • Hiện tại phần thụt lề dùng ký tự tab, nhưng sau này có thể chuyển sang dấu cách
  • Thân hàm, thân vòng lặp, thân if v.v. tạo phạm vi mới; hàm và struct có thể được định nghĩa trong bất kỳ phạm vi nào và chỉ hiển thị trong phạm vi đó
  • Hàm cục bộ không thể truy cập biến của phạm vi nơi nó được định nghĩa nên không phải closure; phạm vi chỉ ảnh hưởng đến phân giải tên
  • Phạm vi cấp cao nhất được xử lý như các phạm vi khác và chứa entry point được thực thi khi tệp được nạp hoặc khởi tạo
  • Có tổng cộng 13 kiểu cơ bản: bool, 4 kiểu số nguyên có dấu, 4 kiểu số nguyên không dấu, 3 kiểu số thực dấu phẩy động và unit
i8  i16  i32  i64
u8  u16  u32  u64
    f16  f32  f64
  • Không đưa f8 vào vì phần lớn CPU desktop không hỗ trợ, và cũng chưa có sự đồng thuận về ý nghĩa của số thực 8 bit
  • f16 ít hữu ích hơn với người dùng phổ thông, nhưng thường được dùng trong đồ họa như màu HDR hay thuộc tính đỉnh, và đa số CPU desktop hiện đại đều triển khai IEEE754 f16, nên được hỗ trợ sẵn
  • Mọi phép toán số nguyên đều theo dạng bù hai có tràn số, và không có hành vi không xác định
  • unit chỉ có một giá trị duy nhất là unit(), và là kiểu trả về chính thức của các hàm không có giá trị trả về
  • Hàm bỏ qua kiểu trả về sẽ tự động trả về unit, và nếu bỏ return ở cuối các hàm như vậy thì nó sẽ được chèn tự động
  • Nếu không phải hàm unit mà không trả về giá trị thì sẽ báo lỗi

Literal, mảng, kiểu hàm, con trỏ

  • Số 10 mặc định là i32, và có thể chỉ định kích thước bằng hậu tố như 10b, 10s, 10l
  • Literal không dấu dùng hậu tố u, viết như 10ub, 10us, 10u, 10ul
  • Literal số thực có dấu thập phân mặc định là f32, trong đó 10.0h là 16-bit và 10.0d là 64-bit
  • Không thể lược bỏ phần nguyên hoặc phần thập phân như 10. hay .5, mà phải viết đầy đủ như 10.0, 0.5
  • Mọi literal số đều có kiểu không mơ hồ
  • Mảng là kiểu hạng nhất được tích hợp sẵn, và khác với C/C++, có thể truyền cả mảng vào hàm, trả về hoặc gán giữa các mảng cho nhau
  • Kích thước mảng luôn được biết tại thời điểm biên dịch, và hoạt động như một struct có nhiều trường cùng kiểu
  • Kiểu mảng được viết như i32[5], literal mảng như [1, 2, 3, 4, 5]
  • Kiểu hàm gần với con trỏ hàm trong C, được viết theo dạng (a, b, c) -> d, và nếu chỉ có một đối số thì có thể bỏ ngoặc như a -> b
  • Về nội bộ, kiểu hàm là con trỏ hàm thông thường không truyền kèm dữ liệu, không phải closure
  • Kiểu con trỏ được viết như i32*, mặc định là con trỏ bất biến, còn con trỏ khả biến được khai báo là i32 mut*
  • Địa chỉ biến là &x, con trỏ khả biến là &mut x, giải tham chiếu là *p, và số học con trỏ dùng như *(p + 10)

Struct, bố cục bộ nhớ, kiểu rỗng

  • Struct được khai báo bằng từ khóa struct và danh sách trường
struct string_view:
    size: u64
    data: u8*
  • Struct được tạo bằng hàm dựng tích hợp sẵn theo kiểu hàm như string_view(10, data), và truy cập trường bằng dấu chấm như v.x
  • Với con trỏ struct cũng có thể truy cập trường bằng cùng cú pháp dấu chấm
  • Trường của struct không có bộ chỉ định tính khả biến riêng; trường của đối tượng khả biến là khả biến, còn trường của đối tượng bất biến là bất biến
  • Không có bộ chỉ định truy cập, và các trường luôn là public
  • Mọi đối tượng đều có bố cục bộ nhớ được bảo đảm; kiểu cơ bản có căn chỉnh bằng đúng kích thước của nó và bool là 1 byte
  • Kiểu con trỏ và kiểu hàm luôn là 64-bit và có cùng căn chỉnh
  • Mảng có căn chỉnh giống phần tử, còn struct có padding để đáp ứng yêu cầu căn chỉnh
  • Bảo đảm này chủ yếu nhằm đơn giản hóa khả năng tương tác với C và việc dùng cho lập trình GPU
  • unit và struct không có trường được xem là kiểu rỗng chỉ có một giá trị hợp lệ duy nhất, và kích thước thực tế là 0 byte
  • Truyền kiểu rỗng vào hàm, khai báo thành biến hoặc đặt làm trường đều không ảnh hưởng đến việc dùng bộ nhớ hay kích thước struct
  • Kiểu rỗng có thể dùng cho các mục đích như tag cấp kiểu tại thời điểm biên dịch
  • Việc đọc/ghi qua con trỏ tới kiểu rỗng vẫn chưa được quyết định, và hiện tại số học con trỏ với kiểu đó là bất hợp pháp
  • Ngôn ngữ này không theo quy tắc như C++ rằng mỗi đối tượng đều có địa chỉ bộ nhớ riêng

Biến, hàm, luồng điều khiển, hàm bên ngoài

  • Biến bất biến được khai báo như let x = 10, biến khả biến như mut x = 20
  • Không thể tạo con trỏ khả biến tới biến bất biến
  • Có thể ghi rõ kiểu như let x: i32 = 10, nhưng không bắt buộc vì ngôn ngữ được thiết kế để suy luận kiểu của mọi biểu thức một cách không mơ hồ
  • Mọi biến đều bắt buộc phải được khởi tạo
  • Hàm được viết theo dạng func foo(x: A, y: B) -> C: rồi đến phần thân, và nếu lược bỏ kiểu trả về thì sẽ là unit
  • Mọi hàm đều tuân theo native C ABI của nền tảng thực thi, nhằm phục vụ khả năng tương tác với C, callback, hệ thống ECS và việc truyền dưới dạng con trỏ hàm
  • Trong cùng một scope, thứ tự khai báo hàm và struct là tự do, nên có thể dùng trước một hàm hay struct được khai báo ở phía sau
  • Vì mọi đối số hàm và kiểu trả về đều phải được ghi đầy đủ, việc tự do hóa thứ tự khai báo không làm suy luận kiểu trở nên phức tạp
  • Có câu lệnh if/else if/else và vòng lặp while, còn vòng lặp for thì chưa có
  • if ở dạng biểu thức được dùng như if A then B else C
  • Hàm bên ngoài được khai báo như foreign func sin(x: f64) -> f64, và phần triển khai phải được liên kết ở nơi khác
  • Hiện tại trình thông dịch tìm các hàm như vậy bằng dlsym trong chính tệp thực thi của trình thông dịch
  • Hàm bên ngoài là cơ chế chính để tương tác với thư viện C và thư viện bên thứ ba; ví dụ raytracer dùng tính năng này để tính căn bậc hai, ghi tệp, đo thời gian và tạo luồng

Ép kiểu và toán tử

  • Hoàn toàn không có ép kiểu ngầm định; ép kiểu thủ công dùng toán tử as như (x as f32)
  • Mọi kiểu số đều có thể ép sang nhau, và mọi kiểu con trỏ cũng có thể ép sang nhau, trừ việc đổi con trỏ bất biến thành con trỏ khả biến
  • Kiểu con trỏ có thể ép sang u64, và u64 cũng có thể ép sang kiểu con trỏ
  • bool không thể ép sang hay từ bất kỳ kiểu nào
  • Tác giả đang cân nhắc thêm một phép ép kiểu ngầm định từ T mut* sang T*
  • Các toán tử tiêu chuẩn như số học, logic, so sánh nhìn chung đều được hỗ trợ
  • &, |, &&, || đều hoạt động với cả boolean lẫn số nguyên; &| luôn đánh giá cả hai toán hạng, còn &&|| thì đánh giá ngắn mạch
  • Phép toán số học và so sánh chỉ hoạt động trên các cặp cùng kiểu số, không có cơ chế nâng kiểu số
  • Dù hiện tại các tính năng ngôn ngữ trông không quá nhiều, nhưng đã có thể viết chương trình thực tế khá thoải mái

Cấu trúc compiler

  • Dự án được chia thành nhiều thư viện
    • types: định nghĩa hệ thống kiểu
    • ast: định nghĩa cây cú pháp trừu tượng và các tiện ích
    • parser: parser
    • ir: biểu diễn trung gian
    • interpreter: trình thông dịch
    • jit: JIT compiler
  • Dự định là giữ trình thông dịch và compiler thành các ứng dụng CLI đơn giản dùng các thư viện này; hiện tại chỉ có trình thông dịch ở chế độ JIT
  • Nếu muốn nhúng ngôn ngữ này, chỉ cần dùng thư viện parserjit

Parser và xử lý thụt lề

  • Dùng Bison làm trình sinh parser
  • Token được định nghĩa trong lexer grammar, còn ngữ pháp ngôn ngữ được định nghĩa trong parser grammar
  • Tệp là một danh sách câu lệnh; câu lệnh có thể là khai báo hàm, toán tử điều khiển luồng, khai báo biến, biểu thức, v.v.; còn biểu thức có thể là literal, biến, toán tử, lời gọi hàm, v.v.
  • Trong ngữ pháp đã phải sửa vài xung đột shift/reduce, và dùng cờ -Wcounterexamples của Bison để kiểm tra chính xác tình huống gây ra xung đột
  • Dùng skeleton Bison lalr1.cc để sinh lớp parser C++
  • Bison mặc định tạo parser C dùng biến toàn cục cho trạng thái parser, nhưng điều đó không phù hợp với các trường hợp như trình thông dịch hay chế độ game, nơi cần phân tích song song nhiều tệp
  • Việc chạy Bison được đưa vào bước build của CMake scripts
  • Đầu ra của parser là đối tượng C++ biểu diễn AST của tệp đã phân tích
  • Do thụt lề, ngữ pháp thực tế không phải là ngữ pháp phi ngữ cảnh, vì việc một câu lệnh có thuộc thân while hay không phụ thuộc vào số lượng token thụt lề phía trước
  • Giải pháp là trước tiên phân tích mỗi dòng thành một câu lệnh độc lập cùng mức thụt lề, sau đó xác định scope trong một lượt duyệt tuyến tính đơn giản dựa trên mức thụt lề
  • Cách này có phần chắp vá, nhưng hoạt động tốt và rất nhanh nên được chấp nhận
  • Trong cùng lượt duyệt đó, cũng kiểm tra để bảo đảm breakcontinue chỉ xuất hiện trong vòng lặp, return chỉ xuất hiện trong hàm, và định nghĩa trường chỉ xuất hiện trong struct

Kiểm tra kiểu và trình thông dịch

  • Sau khi phân tích cú pháp, lượt xử lý đầu tiên sẽ phân giải mọi định danh, nối trực tiếp các nút định danh với nút định nghĩa biến, hàm, cấu trúc tương ứng
  • Lượt xử lý cốt lõi tiếp theo là kiểm tra và suy luận mọi kiểu
  • Suy luận kiểu nhìn chung khá đơn giản, chủ yếu gồm các phép kiểm tra điều kiện theo từng loại nút AST cụ thể
  • Ví dụ, kiểu của biểu thức trong if hay while phải là bool, và hai toán hạng của phép cộng phải cùng là kiểu số hoặc một bên là số nguyên và bên kia là con trỏ
  • Trình thông dịch ban đầu là tree-walking interpreter, trực tiếp duyệt các nút AST để thực thi ngữ nghĩa C++
  • Các hàm chính là exec()eval(), trong đó exec() thực thi một câu lệnh đơn lẻ còn eval() tính toán và trả về giá trị của một biểu thức đơn lẻ
  • Vì C++ là ngôn ngữ kiểu tĩnh, eval() trả về một variant cho mọi kiểu giá trị có thể có trong ngôn ngữ
  • Cấu trúc được biểu diễn dưới dạng mảng cặp tên-giá-trị, mỗi trường một cặp, và cùng variant đó cũng được dùng để lưu giá trị biến
  • Mục đích của trình thông dịch là chạy mã ngôn ngữ theo cách đa nền tảng, đồng thời hỗ trợ gỡ lỗi cho phần triển khai và chương trình, chứ không nhằm mục tiêu tốc độ
  • Trình thông dịch hiện tại đang ở trạng thái rất hỏng, nên có kế hoạch viết lại hoàn toàn dựa trên IR
  • Trình thông dịch cũ không thể thực thi các hàm foreign
  • Hàm foreign phải được gọi theo quy ước gọi C, và vì không thể biết trước số lượng cũng như kiểu đối số nên có thể sẽ cần kỹ thuật vararg hoặc libffi
  • Trình thông dịch có thể dump trạng thái nội bộ, tức tên, kiểu và giá trị của các biến, ra stdout; đây từng là cách chính để gỡ lỗi parser và trình thông dịch trước khi có một compiler tử tế

Trình biên dịch JIT Aarch64 đầu tiên

  • Vào kỳ nghỉ đầu tháng 1 năm 2026, vì chỉ có chiếc M1 Mac nên kiến trúc đích đầu tiên của compiler là Aarch64 trên Mac
  • Hiện đây cũng là kiến trúc duy nhất được hỗ trợ
  • Compiler hoạt động theo kiểu JIT, và kết quả là một khối bộ nhớ được ánh xạ với bit thực thi cùng các con trỏ tới điểm bắt đầu của từng hàm
  • Cấu trúc cấp cao gần như là một compiler dựa trên stack truyền thống, nhưng kết quả biểu thức được đặt theo đúng cách mà một hàm có cùng kiểu trả về sẽ đặt giá trị trong AAPCS64, tức quy ước gọi C tiêu chuẩn của Aarch64 trên Mac
  • Số nguyên và con trỏ được trả về trong thanh ghi đa dụng x0, số thực dấu chấm động trong thanh ghi dấu chấm động v0, còn cấu trúc thì được trả về qua thanh ghi hoặc stack tùy theo kích thước
  • Cách làm này giúp giảm số lần truy cập bộ nhớ, khiến mã sinh ra nhanh hơn và lời gọi hàm cũng đơn giản hơn
  • Stack chủ yếu được dùng cho các kết quả trung gian như phép toán nhị phân
(eval A)         # the value of A is in x0
push x0          # the value of A is on stack top
(eval B)         # the value of B is in x0
pop x1           # the value of A is in x1
add x0, x0, x1   # the value of A+B is in x0
  • Các cấu trúc điều khiển luồng được chuyển thành nhảy có điều kiện, nhưng trong biên dịch một lượt thì chưa thể biết đích nhảy vì phần thân của if hay while vẫn chưa được biên dịch
  • Để giải quyết việc này, trước tiên nó phát ra lệnh nhảy với offset bằng 0, rồi khi đã biết offset đích thì chèn offset nhảy thực tế vào sau
  • Cùng cách đó cũng được áp dụng cho lời gọi hàm
  • Để sinh lệnh CPU đích, tác giả không dùng thư viện bên thứ ba mà tự triển khai nhằm giữ cho compiler gọn nhẹ
  • Cách triển khai là lục instruction manual rồi điền các bit cần thiết vào

Những phần rắc rối trên Aarch64

  • Mọi lệnh của Aarch64 đều dài 32 bit nên thoạt nhìn có vẻ dễ xử lý, nhưng để nạp một hằng số 32 bit vào thanh ghi thì cần cả bit chọn thanh ghi, bit lệnh và bit hằng số, nên không thể nhét hết vào một lệnh 32 bit duy nhất
  • Hằng số 64 bit còn là vấn đề lớn hơn
  • Hằng số phải được ghép từ các lệnh nạp từng mảnh 16 bit ở các vị trí offset 0, 16, 32, 48 bit, hoặc phải đặt vào bộ nhớ hằng rồi nạp từ đó
  • Với hằng số dấu chấm động, cách dùng là nạp từ bộ nhớ hằng
  • Khác với x86, không có lệnh push/pop; thay vào đó phải kết hợp các lệnh đọc/ghi giữa thanh ghi và địa chỉ bộ nhớ rồi điều chỉnh thanh ghi địa chỉ
  • Vì mọi lệnh đều đúng 32 bit nên luôn phải để ý offset là signed hay unsigned, có bị nhân trước với một hằng số cụ thể hay không, và liệu nó có sửa đổi thanh ghi địa chỉ hay không
  • Khi đọc và ghi stack theo thanh ghi SP, con trỏ stack luôn phải được căn chỉnh 16 byte
  • Các offset khả dụng bị giới hạn trong 12 bit, nên khi stack frame lớn hơn khoảng 16 KB sẽ cần mã đặc biệt, nhưng phần đó vẫn chưa được triển khai
  • Quy ước gọi có các trường hợp đặc biệt trong đó cấu trúc được truyền hoặc trả về qua tối đa 2 thanh ghi đa dụng, thanh ghi dấu chấm động hoặc con trỏ bộ nhớ, nên mã compiler phải xử lý các trường hợp này

Đưa vào IR và trình biên dịch thứ hai

  • Sau khi làm xong trình thông dịch và compiler cơ bản, tác giả đưa vào biểu diễn trung gian (IR) để tái sử dụng mã, đơn giản hóa việc viết compiler cho kiến trúc khác và phục vụ tối ưu hóa
  • IR khởi đầu giống SSA, nhưng vì có thể gán lại giá trị cho cùng một nút và không dùng phi node nên thực ra không phải SSA
  • IR là một chuỗi nodes, trong đó mỗi nút biểu diễn literal, phép toán có nút đầu vào, nhảy có điều kiện hoặc vô điều kiện, lời gọi hàm, v.v.
  • Các nút biểu diễn giá trị cũng lưu cả kiểu của giá trị đó
  • Vì cho phép gán lại nên có lệnh IR assign để gán lại giá trị của nút đã có
  • Nhảy có điều kiện được tách thành jump_if_zerojump_if_nonzero; điều này thường tương ứng với các lệnh CPU khác nhau và nhanh hơn so với việc phủ định giá trị rồi dùng lệnh ngược lại
  • Vì hỗ trợ con trỏ hàm, có lệnh riêng để gọi một nút IR đã biết và một lệnh khác để gọi một giá trị con trỏ chưa biết
  • Để việc loại bỏ hoặc chèn nút ở vị trí bất kỳ trong quá trình tối ưu hóa được dễ dàng, các nút được lưu trong std::list và tham chiếu dùng iterator của list
  • Không thể tạo literal cho giá trị cấu trúc, nên có nút alloc biểu diễn giá trị cấu trúc, thường được biên dịch thành việc cấp phát vùng cấu trúc chưa khởi tạo trên stack
  • Cấu trúc được xây dựng bằng cách gán cho từng trường riêng lẻ
  • Nếu biểu diễn đơn giản trường cấu trúc lồng nhau a.x.y, thì sẽ phải đọc a.x thành một nút mới rồi đọc y từ nút đó, gây lãng phí lớn
  • a.x.y = b cũng sẽ kém hiệu quả nếu được biểu diễn thành t = a.x, t.y = b, a.x = t, nên IR xử lý đặc biệt các trường lồng nhau
  • Nút copy có thể trích xuất bất kỳ trường lồng nhau nào từ cấu trúc, còn nút assign có thể gán vào bất kỳ trường lồng nhau nào của cấu trúc
  • Trường lồng nhau được biểu diễn bằng một mảng chỉ số kiểu như “lấy trường số 0, rồi trong đó lấy trường số 2, rồi trong đó lấy trường số 5”
  • Sau đó compiler Aarch64 được viết lại, tách thành compiler AST → IR và compiler IR → Aarch64
  • Phần AST → IR tương đối đơn giản, nhưng compiler IR → Aarch64 hiện ở trạng thái còn tệ hơn nhiều so với compiler cũ dựa trên stack
  • Khi bắt đầu hàm, nó cấp phát trên stack đủ không gian cho mọi nút IR cần dùng trong hàm đó, nên ngay cả đa số giá trị trung gian có vòng đời rất ngắn cũng chiếm chỗ trong stack frame
  • Một hàm trong raytracer đã phải tách làm đôi để stack frame nằm trong giới hạn 12 bit nói ở trên
  • Compiler này được xây dựng với giả định sẽ có bộ cấp phát thanh ghi, nên sau này mã sinh ra được kỳ vọng sẽ cải thiện ở mức vài bậc độ lớn

Kế hoạch cho compiler và interpreter

  • Bản triển khai hiện tại gồm khoảng 10.000 dòng mã C++, và tác giả hài lòng vì theo tiêu chuẩn hiện đại thì compiler này nhỏ nhưng thực sự hoạt động được
  • Bộ cấp phát thanh ghi

    • Trình biên dịch IR → Aarch64 hiện tại nhất định cần có bộ cấp phát thanh ghi
    • Dự định dùng bộ cấp phát tuyến tính kiểu chuẩn để cân bằng giữa tốc độ biên dịch và chất lượng mã
  • Tối ưu hóa IR

    • Muốn bổ sung truyền hằng, đơn giản hóa số học, loại bỏ mã chết, inlining và unrolling vòng lặp dựa trên IR
    • Mục tiêu không phải đánh bại GCC hay LLVM, nhưng muốn những hàm đơn giản như cộng vector 3D được biên dịch thành ít lệnh CPU nhất có thể
  • IR interpreter

    • Dự định viết lại interpreter theo hướng đánh giá trực tiếp IR, và như vậy interpreter có thể sẽ đơn giản hơn đáng kể
  • Tạo tệp thực thi

    • Compiler hiện tại chỉ tạo ra blob bộ nhớ JIT để chạy ngay
    • Tác giả cũng muốn tạo cả binary có thể thực thi theo định dạng riêng của từng nền tảng, nên sẽ phải đào sâu vào đặc tả các định dạng binary như ELF, Mach-O, PE
    • Một mục tiêu khác là thử tạo ra tệp thực thi nhỏ nhất có thể
  • Gỡ lỗi

    • Tác giả đã nhiều lần lần theo assembly do JIT tạo ra trong lldb, và muốn có thể gỡ lỗi ngôn ngữ này một cách đúng nghĩa
    • Để làm được vậy có khả năng cao sẽ cần hỗ trợ định dạng thông tin gỡ lỗi DWARF, nhưng hiện tại gần như chưa biết gì về nó

Các tính năng ngôn ngữ muốn bổ sung

  • Constructor cho struct

    • Hiện tại struct chỉ có thể hoặc gán mọi trường như vec3i(1, 2, 3), hoặc khởi tạo toàn bộ về 0 như vec3i()
    • Đang cân nhắc cách cho phép khai báo một hàm cùng tên với struct để nó hoạt động như constructor tùy ý
func vec3i(x: i32, y: i32) -> vec3i:
    return vec3i(x, y, 0)
  • Tuy vậy cũng chưa chắc chắn, vì có thể tốt hơn nếu đặt cho những hàm này một tên riêng
  • Biến toàn cục

    • Hiện tại chưa hỗ trợ biến toàn cục
    • Dự định dùng từ khóa global để tạo biến toàn cục; việc truy cập vẫn bị ràng buộc bởi quy tắc phạm vi, nên cũng có thể tạo biến toàn cục cục bộ theo hàm giống biến static trong C
    • Biến ở mức top-level, trừ khi dùng global, thực ra là biến cục bộ của hàm entry point của tệp chứ không phải biến toàn cục thực sự
    • Cấu trúc này có thể gây khó hiểu cho người dùng nên tác giả vẫn đang cân nhắc các lựa chọn khác
    • Trên Mac, không thể đồng thời cho phép ánh xạ bộ nhớ vừa ghi được vừa thực thi được, nên có thể phải cấp phát biến toàn cục tách khỏi mã và ánh xạ bằng cờ khác
    • Việc truy cập toàn cục cũng có thể phải dùng địa chỉ được phân giải lúc chạy thay vì offset đã biết tại thời điểm biên dịch
    • Tuy nhiên có vẻ có thể dùng mprotect() để thay đổi cờ của một phần ánh xạ, nên tác giả định thử cách đó trước
  • Cú pháp gọi method

    • Vì tính dễ đọc, tác giả muốn x.f(y) khi có thể sẽ mang nghĩa là f(&x, y) hoặc f(&mut x, y)
  • Đa hình

    • Đây được xem là tính năng tiềm năng quan trọng nhất
    • Lựa chọn sáng giá là overloading hàm kiểu C++ cùng template hàm và template struct không giới hạn, hoặc trait tường minh kiểu Haskell/Rust cùng generic function và generic struct có ràng buộc trait
    • Phong cách C++ mạnh hơn, trong trường hợp đơn giản thì dễ đọc hơn, và cũng dễ triển khai compiler hơn, nhưng thông báo lỗi có thể trở nên cực kỳ khó hiểu
    • Trait tường minh có thể dễ đọc hơn trong một số trường hợp và giải quyết vấn đề thông báo lỗi, nhưng lại cần một hệ thống mới gồm trait và trait bound nên khó triển khai compiler hơn
    • Dù chưa quyết định, tác giả đang nghiêng mạnh về lựa chọn đầu tiên dù ban đầu không hề muốn tạo lại C++
struct vec2<t: type>:
    x: t
    y: t

func min<t: type>(x: t, y: t) -> t:
    return if x < y then x else y
  • Tác giả cũng muốn có suy luận đối số hàm khi có thể
  • Overloading toán tử

    • Cần có đa hình dưới bất kỳ hình thức nào
    • a + b có thể sẽ là cách gọi một hàm overload như add(a, b) hoặc một method trait như Add::add
  • Vòng lặp for

    • Vì có thể bắt chước bằng while, nên for được dự định là vòng lặp dựa trên collection như range-based loop của C++ hoặc loop của Python
    • Để làm vậy sẽ cần giao diện range/iterator, và lại cần đến đa hình
  • Quản lý tài nguyên tự động

    • Tác giả cho rằng một ngôn ngữ thực dụng và dễ dùng cần có cách hỗ trợ giải phóng các tài nguyên như bộ nhớ, tệp, socket, mutex
    • Các ứng viên là RAII và move kiểu C++, defer kiểu Zig, hoặc linear type
    • RAII có nhược điểm là ngầm định, nên thêm các lệnh ẩn và luồng điều khiển ẩn
    • defer thì tường minh nhưng phải tự viết mỗi lần, không ngăn được việc quên thêm, và cũng bất tiện khi giải phóng các collection lồng nhau như mảng tệp
defer free(array)
defer for file in array:
    close(file)
  • Linear type có vẻ hứa hẹn vì vẫn giữ được tính tường minh của việc gọi tay free hay close, đồng thời buộc đối tượng phải bị tiêu thụ bởi hàm giải phóng tài nguyên
  • Tuy nhiên vì khó kết hợp với các collection lồng nhau như mảng tệp động nên tác giả vẫn chưa quyết định
  • Literal đa hình

    • Mảng rỗng [] thì biết kích thước bằng 0 nhưng không thể suy ra kiểu phần tử
    • null có thể là bất kỳ kiểu con trỏ nào, còn literal inf mà tác giả muốn thêm có thể là bất kỳ kiểu số thực dấu chấm động nào
    • Ba hướng đang được cân nhắc là literal đa hình kiểu Haskell, kiểu dựng sẵn/thư viện đặc biệt cùng chuyển đổi ngầm như nullptr_t trong C++, hoặc literal đặc biệt trong AST kèm xử lý ad-hoc trong compiler
    • Hiện tại tác giả đang nghiêng về cách cuối: chỉ cho phép null ở những vị trí biết trước kiểu con trỏ kỳ vọng, như khởi tạo biến có kiểu tường minh hoặc truyền đối số hàm
    • Cách này đơn giản nhất nhưng không có tính mở rộng, nên không thể tạo kiểu tùy chỉnh từ null
  • Đánh giá tại thời điểm biên dịch

    • Tác giả muốn có thể khai báo biến thời điểm biên dịch bằng từ khóa const, và dùng chúng trong các biểu thức thời điểm biên dịch như kích thước mảng
    • Giá trị const không thể gán lại và cũng không thể lấy địa chỉ
    • Các hàm phù hợp có thể được gọi trong biểu thức thời điểm biên dịch nếu không truy cập biến toàn cục và không có tác dụng phụ
    • Phần thân hàm hoạt động như hàm bình thường, nhưng được thực thi trong lúc biên dịch và kết quả trở thành biểu thức thời điểm biên dịch
    • Sẽ cần có cách đánh dấu các hàm foreign an toàn để gọi trong lúc biên dịch, chẳng hạn như hàm toán học hay cấp phát bộ nhớ
  • Tính toán kiểu

    • Tác giả muốn hỗ trợ tính toán trên kiểu để phục vụ metaprogramming
    • Vì không muốn tạo mã hóa kiểu thời gian chạy trong một ngôn ngữ tĩnh kiểu, và kiểu thời gian chạy cũng có ích lợi hạn chế, nên tính năng này được dự định chỉ dành cho thời điểm biên dịch
    • Tác giả cũng cho rằng các tính năng tương tự C++ concepts có thể được triển khai bằng lời gọi tại thời điểm biên dịch mà không cần cú pháp riêng
func comparable(t: type) -> bool:
    // Implemented somehow...

func min<t: comparable type>(x: t, y: t) -> t:
    return if x < y then x else y
  • Coroutine

    • Việc bổ sung async/await kiểu Python hay JS hiện tại giống mong muốn hơn là một kế hoạch cụ thể

Kế hoạch cho thư viện và mô-đun

  • Mô-đun

    • Việc viết toàn bộ mã trong một tệp là không khả thi, nên cần có mô-đun
    • Dự kiến dùng câu lệnh đơn giản như import lib.sublib, có thể đặt ở bất kỳ đâu trong mã và cũng tuân theo quy tắc phạm vi
    • Phạm vi chỉ ảnh hưởng đến khả năng hiển thị, còn việc tải thực tế diễn ra tại thời điểm biên dịch, và entry point của mô-đun được import sẽ được chạy trước mô-đun hiện tại
    • Tên thư viện ánh xạ trực tiếp tới đường dẫn hệ thống tệp dựa trên đường dẫn gốc được chỉ định cho compiler hoặc interpreter
    • Nếu là một tệp nguồn đơn lẻ thì chỉ import tệp đó, còn nếu là thư mục thì import mọi tệp trong thư mục đó theo một thứ tự nào đó
    • Cần có cú pháp để chỉ tới các tệp trong cùng thư mục, và đang cân nhắc dạng như import .another
    • Các hàm và biến toàn cục được import có thể dùng mà không cần tiền tố, và khi mơ hồ thì có thể thêm tiền tố tên thư viện như io.print(x)
    • Entry point của mô-đun dự kiến sẽ chạy theo thứ tự xác định dựa trên thứ tự import và sắp xếp topo của import đệ quy, qua đó có thể giải quyết vấn đề thứ tự khởi tạo trong C hay C++
    • Cách bố trí bộ nhớ cho chương trình nhiều mô-đun vẫn chưa được quyết định
    • Có thể dùng một vùng nhớ riêng cho mỗi mô-đun và phân giải lời gọi hàm cùng truy cập biến toàn cục ở runtime, hoặc tạo một ánh xạ bộ nhớ lớn duy nhất rồi dùng offset tương đối
    • Một ánh xạ lớn duy nhất có thể nhanh hơn ở runtime, nhưng lại khiến việc biên dịch song song nhiều mô-đun trở nên khó khăn
  • Prelude

    • Khi có mô-đun, có thể đưa các tiện ích cơ bản vào mô-đun prelude được ngầm bao gồm trong mọi chương trình
    • Các ứng viên gồm hàm length() cho mảng tích hợp sẵn, giao diện iterator, kiểu string view, và numeric range giống range(n) của Python
  • Chuỗi ký tự literal

    • Hiện vẫn chưa có string literal, và vẫn chưa quyết định chúng nên mang ý nghĩa gì
    • Kế hoạch là đặt kiểu string_view bất biến trong prelude, đặt nội dung chuỗi ở đâu đó trong bộ nhớ thực thi được, rồi biến chính literal thành một string_view trỏ tới vùng nhớ đó
  • Thư viện chuẩn

    • Khi có mô-đun thì cũng cần có thư viện chuẩn
    • Phạm vi muốn bao gồm là thư viện toán học có vector và ma trận, quản lý bộ nhớ theo kiểu alloc/free được liên kết từ libc, mảng động, chuỗi động và formatting, hash table, console và file IO, helper cho filesystem, helper cho thời gian và đồng hồ, cùng networking

Ưu tiên hiện tại

  • Vẫn chưa quyết định sẽ triển khai các tính năng đã lên kế hoạch khi nào, hay liệu ngôn ngữ này có thực sự được dùng cho mod game hoặc mục đích khác hay không
  • Tác giả cho rằng không nên cùng lúc nghiêm túc theo đuổi nhiều dự án tham vọng, nên ưu tiên hiện tại vẫn là phát triển game
  • Vì phải có game trước thì mới có thể mod game, nên công việc làm ngôn ngữ hiện đang được tiến hành khi nào có hứng

1 bình luận

 
Ý kiến trên Lobste.rs
  • Các bình luận ở đây có cảm giác khắc nghiệt hơn nhiều so với những gì tôi mong đợi từ cộng đồng này
    Có lẽ dùng một ngôn ngữ khác như Lua cũng đã đủ rồi. Cũng có thể tác giả đã sa vào một màn yak shaving khổng lồ
    Dù vậy, rõ ràng là tác giả rất giỏi và đang cực kỳ tận hưởng việc này, và trong bài cũng có nhiều nội dung kỹ thuật thú vị
    Nếu là bài viết của một đồng nghiệp mọt kỹ thuật đang thiết kế thêm một ngôn ngữ script cho game engine, tôi vẫn sẵn sàng đọc với sự thích thú. Nếu điều đó giúp tránh được thêm một bài tạp văn do AI tạo ra về đống SaaS rác làm bằng vibecoding nào đó được cho là sẽ cứu thế giới và làm tác giả giàu lên, thì tôi có thể đọc cả nghìn bài như thế mỗi ngày

  • Câu nói “Lua hoặc một ngôn ngữ script biên dịch JIT khác là lựa chọn tiêu chuẩn, nhưng sandboxing thì thật sự khó” là một nhận định rất khó hiểu
    Sandboxing của Lua rất dễ, và đó là một trong những ưu điểm lớn nhất của nó, không chỉ cho mod hay plugin mà còn ở nhiều mặt khác. Tôi chưa thấy ngôn ngữ nào khác đến gần được điểm này

    • Cả đoạn đó đọc lên như thể “Tôi có đọc qua về ngôn ngữ này, nhưng dù nó là lựa chọn tiêu chuẩn suốt 20 năm qua thì tôi cũng không định bỏ ra vài tiếng để tìm hiểu”
      Vấn đề phiên bản Lua thì có phần hợp lý, nhưng thực tế tôi không thấy nhiều người phẫn nộ vì chuyện đó. Trừ khi bạn đang dùng Lua “hiện đại” cho một mục đích nào đó rồi vì việc khác lại phải hạ xuống 5.1/5.2, còn không thì đa số có vẻ chỉ dùng một trong hai
    • Việc “khả năng phổ biến” ngay từ đầu chỉ có Lua và C++ cũng khá kỳ lạ. Ý là chỉ có đúng hai loại ngôn ngữ tồn tại thôi sao?
      Cảm giác như đang khảo cứu để hợp lý hóa cho việc “tôi muốn tự làm ngôn ngữ của mình”. Bản thân chuyện đó không sao, nhưng thành thật vẫn tốt hơn là đưa ra những nhận định hoàn toàn sai về các lựa chọn sẵn có
    • Một điểm nữa khiến tôi vướng bận trong bài này là: nếu bạn muốn học thiết kế ngôn ngữ, thì viết một compiler cho ngôn ngữ host nhắm tới một máy ảo hoặc runtime có sẵn sẽ tốt hơn rất nhiều so với đi từ con số không tới tận tầng thấp nhất
      Nếu bạn quan tâm đến thiết kế máy ảo hay các phần ở tầng thấp hơn thì cách làm được mô tả trong bài dĩ nhiên vẫn ổn. Nhưng nó khá xa so với cách tốt nhất để học thiết kế ngôn ngữ
    • Cũng đã có khá nhiều game do các lập trình viên giỏi làm ra từng dính sandbox escape của Lua. Factorio, Binding of Isaac, và nếu xem cloud programming như một trò chơi kỳ quặc mà ai cũng đang thua thì cả ~~Redis~~ nữa, nên tôi nghi là có vấn đề gì đó ở cách API được trình bày
      Ví dụ dễ nhất là bytecode escape. Nếu biết nó tồn tại thì bạn có thể vô hiệu hóa, nhưng việc chuyện này cứ lặp đi lặp lại cho thấy một vấn đề rộng hơn. Bạn phải hiểu cách các phần rời rạc trong đặc tả Lua tương tác với nhau để tự lắp ghép các quy tắc sandbox, chứ nó không có cấu trúc kiểu những khối nền tảng rõ ràng để bạn có thể ghép chương trình một cách an toàn mà biết chính xác những tương tác bổ sung nào sẽ được cho phép
      Một ví dụ gượng hơn là hiện tượng ô nhiễm prototype xảy ra giữa các môi trường khác nhau trong cùng một Lua VM. Trong Redis, người ta có thể làm ô nhiễm metatable của string, và khi đó có thể thực thi mã dưới quyền của người dùng cơ sở dữ liệu khác đang dùng tính năng Lua. Bề mặt ô nhiễm prototype của Lua nhỏ hơn JavaScript một cách phi lý, nhưng việc chỉ với khoảng 2 prototype toàn cục mà vẫn làm được đúng trò đó với một trong số chúng thì cũng khá buồn cười
      Dù vậy, Luau có một lời giải khá tốt cho vấn đề này, nên tôi không rõ vì sao tác giả lại ngầm cho rằng nếu tự làm một sandbox mới thì sẽ tự động tránh được tất cả các vấn đề tương tự
  • Đoạn “Game của tôi thiên rất mạnh về mô phỏng. Nó mô phỏng hàng trăm nghìn entity bằng một custom ECS engine. Lý tưởng nhất là ngôn ngữ mod có thể nhận nhiều con trỏ component và lặp qua chúng như vòng for trong C” đáng lẽ nên có một lý tưởng tốt hơn
    Đặc biệt nên so sánh cách các rendering engine như Unity, Unreal, Blender, Godot xử lý bài toán này. Vòng lặp bên ngoài có thể không đủ nhanh để nói về mức megapixel mỗi giây, và cũng có thể không phù hợp với hàng trăm nghìn entity mỗi giây. Ở đây cần nghĩ tới tính song song
    Các engine lớn đều thân thiện với GPU và thường dùng mô tả dataflow của các thuật toán không nhánh, dễ song song hóa đến mức gần như hiển nhiên. Tác giả có thể ghét trình biên tập trực quan, và suy nghĩ đó cũng phổ biến, nhưng điều đó không có nghĩa for loop là câu trả lời
    Nếu tác giả có nhắc rằng ECS về bản chất là một mô hình quan hệ, và ngôn ngữ mang nhiều gánh nặng lịch sử cần đem ra so sánh ở đây là SQL, thì có lẽ tôi đã dễ tính hơn một chút