Dđộ roll khi back up source code là gì năm 2024
Mình đang viết một ngôn ngữ lập trình scripting và rất chú trọng về hiệu năng, cũng như tốc độ xử lý của nó. Bài viết này là một phần kinh nghiệm đã trãi qua khi xử lý phần virtual machine cho ngôn ngữ. Trong bài viết này, mình sẽ đi chi tiết từ cách tạo một máy ảo (virtual machine) thực thi bytecode của một ngôn ngữ lập trình, cho đến từng cách tối ưu nó trên các trình biên dịch phổ biến (cross-compiler). Bài viết này có thể sẽ hữu ích đối với những ai đam mê nghiên cứu về viết ngôn ngữ lập trình hay sử dụng virtual machine cho mục đích nào đó, viết giả lập chẳng hạn. BytecodeBytecode, còn được gọi là portable code hoặc p-code, là cách thức lưu trữ dạng mã các chỉ thị (instructions - các opcode) trong lập trình máy tính, được thiết kế để phần mềm thông dịch thực hiện hiệu quả trên nền tảng máy ảo - theo Wiki Nói dễ hiểu hơn thì bytecode là một loạt chỉ thị được tạo sau khi biên dịch source code, sau đó đưa chúng vào máy ảo để thực thi. Hiện tại, thì opcode instruction thường được biểu diễn theo 2 dạng 32bitTheo kiểu nguyên mẫu của ISA, giống với các chỉ thị được sử dụng trên CPU thật. Gói gọn trong 32bit đó, sẽ chứa cả opcode, các tham số phụ như dấu, đối số khác, địa chỉ register... Từ Lua 5.0, người ta đã chuyển sang sử dụng register-based và 32bit instruction, có thể thấy nó trong The Implementation of Lua 5.0 và Lua 5.0 source code.
8bit (1 byte đơn)Phương pháp này tương đối ít gặp hơn, nhưng có ưu điểm là không tốn nhiều byte như ở trên, là sao?
Ta dễ dàng bắt gặp nó trong ebook .
elsetypedef union { } Inst; endif 0 chỉ cần 1 byte
1 thì cần thêm 1 byte để lấy vị trí đã lưu của constant (giới hạn là 256 constant, nếu muốn tăng thêm thì cần 1 byte hoặc hơn). Virtual machine(Phần này mình chỉ nói nhanh qua) Máy ảo ở đây là môi trường được xây dựng trên máy thật, về hình thức thì nó giống như mô phỏng CPU hay máy thật và được dùng để thực thi các chỉ thị đưa vào (bytecode). Trong ngôn ngữ lập trình có sử dụng máy ảo, nó thường được xây dựng theo 2 kiểu
Triển khai máy ảoMục đích của máy ảo trong post là tương tác với hai giá trị constant và value (đơn giản chỉ là số nguyên), thông qua các chỉ thị đã thiết lập sẵn, kết thúc chương trình sẽ trả về value. Phần này mình chỉ sử dụng ngôn ngữ C đơn giản vì 2 lý do:
C++ cũng không ngoại lệ, nhưng chỉ C là đủ rồi. Khai báo
Xây đựng mã chỉ thịMình sẽ cho các chỉ thị vào một
2 như sau
elsetypedef union { } Inst; endif 3 (halt), dừng chương trình và trả về value
4 (load constant), load một giá trị constant
elsetypedef union { } Inst; endif 5 (add), cộng value với constant
6 (subtract), trừ value cho constant
elsetypedef union { } Inst; endif
7 (less than or equal), nếu value bé hơn hoặc bằng constant thì jump đến một vị trí trong bytecode (nhằm mục đích tạo vòng lặp, kéo dài chương trình)
Tiếp theo là instruction, mình sẽ dùng macro để viết theo 2 cách biểu diễn bytecode đã nói ở trên.
ifdef OPCODE_BYTEtypedef u8 Inst;
elsetypedef union { } Inst;endif
ifdef OPCODE_BYTEdefine EMIT_HLT() (OP_HLT)define EMIT_LDK(k) (OP_LDK), ((k) & 0xff), (((k) >> 8) & 0xff)define EMIT_ADD() (OP_ADD)define EMIT_SUB() (OP_SUB)define EMIT_LEQ(n) (OP_LEQ), ((n) & 0xff), (((n) >> 8) & 0xff)...
8 và
9 đều có 1 tham số, và mình dùng số nguyên 16bit
Phần cốt lõi của máy ảoĐây là hàm để thực thi code
ifdef OPCODE_BYTEdefine EMIT_HLT() (OP_HLT)define EMIT_LDK(k) (OP_LDK), ((k) & 0xff), (((k) >> 8) & 0xff)define EMIT_ADD() (OP_ADD)define EMIT_SUB() (OP_SUB)define EMIT_LEQ(n) (OP_LEQ), ((n) & 0xff), (((n) >> 8) & 0xff) ...
0, bytecode hay các chỉ thị đầu vào
ifdef OPCODE_BYTEdefine EMIT_HLT() (OP_HLT)define EMIT_LDK(k) (OP_LDK), ((k) & 0xff), (((k) >> 8) & 0xff)define EMIT_ADD() (OP_ADD)define EMIT_SUB() (OP_SUB)define EMIT_LEQ(n) (OP_LEQ), ((n) & 0xff), (((n) >> 8) & 0xff) ...
1, mảng các giá trị, ở đầy mình dùng số nguyên 64bit
ifdef OPCODE_BYTEdefine EMIT_HLT() (OP_HLT)define EMIT_LDK(k) (OP_LDK), ((k) & 0xff), (((k) >> 8) & 0xff)define EMIT_ADD() (OP_ADD)define EMIT_SUB() (OP_SUB)define EMIT_LEQ(n) (OP_LEQ), ((n) & 0xff), (((n) >> 8) & 0xff) ...
2, program counter, trỏ đến chỉ thị hiện tại
ifdef OPCODE_BYTEdefine EMIT_HLT() (OP_HLT)define EMIT_LDK(k) (OP_LDK), ((k) & 0xff), (((k) >> 8) & 0xff)define EMIT_ADD() (OP_ADD)define EMIT_SUB() (OP_SUB)define EMIT_LEQ(n) (OP_LEQ), ((n) & 0xff), (((n) >> 8) & 0xff) ...
3 và
4 sẽ giống như hai register
Tiếp theo là khởi tạo giá trị
Phần đọc code của máy ảo sẽ là một vòng lặp, khi đọc đến các opcode sẽ rẻ nhánh. Mình sẽ dùng
8 và
9, nhưng để tiện cho các phần sau của bài viết, nên mình chuyển chúng sang macro
elsedefine EMIT_HLT() ((Inst) { .op = OP_HLT })define EMIT_LDK(k) ((Inst) { .op = OP_LDK, .arg = (k) })define EMIT_ADD() ((Inst) { .op = OP_ADD })define EMIT_SUB() ((Inst) { .op = OP_SUB })define EMIT_LEQ(n) ((Inst) { .op = OP_LEQ, .arg = (n) })endif 0 sẽ rẽ nhánh theo opcode
3 để định nghĩa nhánh, thông qua opcode nhập vào
elsedefine EMIT_HLT() ((Inst) { .op = OP_HLT })define EMIT_LDK(k) ((Inst) { .op = OP_LDK, .arg = (k) })define EMIT_ADD() ((Inst) { .op = OP_ADD })define EMIT_SUB() ((Inst) { .op = OP_SUB })define EMIT_LEQ(n) ((Inst) { .op = OP_LEQ, .arg = (n) })endif
4 để đọc tiếp
...
elsedefine EMIT_HLT() ((Inst) { .op = OP_HLT })define EMIT_LDK(k) ((Inst) { .op = OP_LDK, .arg = (k) })define EMIT_ADD() ((Inst) { .op = OP_ADD })define EMIT_SUB() ((Inst) { .op = OP_SUB })define EMIT_LEQ(n) ((Inst) { .op = OP_LEQ, .arg = (n) })endif
elsetypedef union { } Inst;endif
elsetypedef union { } Inst;endif
typedef enum { } OpCode;
4 để đi tiếp, phần
8 không có vì nó trả về kết quả luôntiếp theo nữa là
4
1
...và cuối cùng là
7
2
ifdef OPCODE_BYTEdefine EMIT_HLT() (OP_HLT)define EMIT_LDK(k) (OP_LDK), ((k) & 0xff), (((k) >> 8) & 0xff)define EMIT_ADD() (OP_ADD)define EMIT_SUB() (OP_SUB)define EMIT_LEQ(n) (OP_LEQ), ((n) & 0xff), (((n) >> 8) & 0xff) ...
2 ngược về
8 đơn vị (hay jump back
8 đơn vị)Xử lý nốt hai macro
0 và
4 nữa là có thể chạy được. Do hai instruction layout khác nhau nên phải chia làm 2 phần, đặt code dưới vào sau phần khởi tạo giá trị
3
ifdef OPCODE_BYTEdefine EMIT_HLT() (OP_HLT)define EMIT_LDK(k) (OP_LDK), ((k) & 0xff), (((k) >> 8) & 0xff)define EMIT_ADD() (OP_ADD)define EMIT_SUB() (OP_SUB)define EMIT_LEQ(n) (OP_LEQ), ((n) & 0xff), (((n) >> 8) & 0xff) ...
2 lên 1
4
ifdef OPCODE_BYTEdefine EMIT_HLT() (OP_HLT)define EMIT_LDK(k) (OP_LDK), ((k) & 0xff), (((k) >> 8) & 0xff)define EMIT_ADD() (OP_ADD)define EMIT_SUB() (OP_SUB)define EMIT_LEQ(n) (OP_LEQ), ((n) & 0xff), (((n) >> 8) & 0xff) ...
2 lên rồi (để ý chỗ
0 ở trên sẽ thấy, sau đó mới đến
4)Vậy là xong toàn bộ máy ảo, đến phần không thể thiếu của một chương trình C - main
5 Mà khoan, phải xác định rõ sẽ cho máy ảo thực thi cái gì trước đã. Mình nghĩ ra mẫu code đơn giản sau
6
define DISPATCH() for (;;) switch(GET_OP())define CODE(op) case opdefine NEXT() continue 1
2
define DISPATCH() for (;;) switch(GET_OP())define CODE(op) case opdefine NEXT() continue
3 đi ...
define DISPATCH() for (;;) switch(GET_OP())define CODE(op) case opdefine NEXT() continue 4
3 thêm
6
define DISPATCH() for (;;) switch(GET_OP())define CODE(op) case opdefine NEXT() continue
7 thì ...
define DISPATCH() for (;;) switch(GET_OP())define CODE(op) case opdefine NEXT() continue 8
Vậy là ta sẽ có 3 giá trị trong constant
typedef enum { } OpCode;
typedef enum { } OpCode;
9 nên cho gì vào? Phải tính toán thôi.Ta để ý chỗ
0 ở trên, sẽ có
1,
8 chính là
9 ta cần tìm. Trong mảng
0 ở trên, vị trí tương ứng của label
2 (trong code mẫu) chính là
6. Vậy ta chỉ cần tìm khoảng cách giữa
7 và
8. Có thể thấy rõ, một thằng ở vị trí
9 và một thằng ở vị trí
6, ở trên có
01 nên sẽ điền vào là
4 (tức
03). Nhưng đừng vội, hãy nhìn
8 và
9 (phần
06), xem là nó được định nghĩa như thế nào? Có đến 2 dấu phẩy, tức không phải 1 mà là 3 giá trị đấy! Quay trở lại, ban đầu chỉ là
4, nhưng nếu có define
06 thì sẽ là
09 (4 cộng thêm (2 nhân 3), vì có 3
4, tức
11), vậy mình thêm macro vào đây
9 Test thửOk, giờ có thể chạy để test 2 phương pháp bytecode. Mình test trên
12 Các case test đều sử dụng tùy chọn tối ưu hóa, kết quả theo format
13 opcode, tính theo giây.
0 platforms gcc -O3 msvc /O2 clang -O3 Linux x64 (WLS) 2.49s \ 2.60s _ 2.71s \ 1.85s Windows 32bit 2.90s \ 3.27s 2.36s \ 2.75s _ Windows 64bit 2.96s \ 2.95s 2.39s \ 2.15s 2.27s \ 2.37s Kết quả trên cho thấy rằng, 2 phương pháp bytecode không hơn kém nhau bao nhiêu, mà còn phụ thuộc khá nhiều vào compiler. Và tất nhiên là bài viết vẫn chưa kết thúc đâu, đến phần tiếp theo thôi nào! Tối ưu nhẹ phần runtimeKhi chuyển code trên sang assembly (thông qua Godbolt), ta sẽ thấy vài chỗ khác biệt nhỏ giữa GCC và MSVC, đây cũng là điểm khiến GCC chậm hơn trong code trên
1
ifdef OPCODE_BYTEdefine EMIT_HLT() (OP_HLT)define EMIT_LDK(k) (OP_LDK), ((k) & 0xff), (((k) >> 8) & 0xff)define EMIT_ADD() (OP_ADD)define EMIT_SUB() (OP_SUB)define EMIT_LEQ(n) (OP_LEQ), ((n) & 0xff), (((n) >> 8) & 0xff) ...
9 hoạt động gần giống như các
19 nối nhau, đồng nghĩa sẽ có rất nhiều
15 được sử dụng.Để hạn chế điều này, có một trick nhỏ đó là sử dụng computed goto (tạm dịch: bước đi có tính toán). Cách này chỉ có thể sử dụng trên được trên GCC và Clang compiler. Về cơ bản thì địa chỉ của các label sẽ được đưa vào một mảng, truy xuất mảng đó thông qua index để lấy địa chỉ và jump đến label tương ứng. Ta thực hiện với máy ảo trên như sau:
2
3
4
elsetypedef union { } Inst; endif
2
Vậy là xong, ta có thể test ngay, kết quả nhanh hơn trước rất nhiều.
ifdef OPCODE_BYTEtypedef u8 Inst;
ifdef OPCODE_BYTEtypedef u8 Inst;
` 6 Có một câu hỏi đặt ra, có thể áp dụng cách này trên MSVC được không?Về cách code thì không thể vì dính ngay syntax error, nhưng về cách thức hoạt động thì có thể code giống như vậy bằng assembly (x86 có thể dễ dàng sử dụng inline assembler, còn x64 thì không). |