Nội dung trên trang web này đã được dịch bằng trí tuệ nhân tạo (AI) hoặc công nghệ dịch máy và có thể có lỗi.

Skip to content

Tối ưu hóa khả năng tương tác giữa Lua và C++

Giới thiệu

Công cụ Roblox được viết bằng sự kết hợp giữa C++ và Lua, với mã thực hiện các hoạt động tính toán nặng được viết bằng C++ tối ưu hóa, trong khi logic trò chơi và tập lệnh được viết bằng Lua để dễ phát triển. Để mô hình này hiệu quả, việc chuyển đổi giữa Lua và C++ phải diễn ra nhanh nhất có thể, vì bất kỳ thời gian nào dành cho vùng đất hoang này về cơ bản chỉ là những mili giây bị lãng phí.

Trong vài tháng qua, chúng tôi đã triển khai nhiều cải tiến khác nhau cho phần này của hệ thống. Một phần cụ thể — việc gọi các phương thức C++ từ Lua — đặc biệt thú vị, vì nó dẫn đến những cải thiện đáng kể về tốc độ và đòi hỏi phải đào sâu vào bên trong Lua để hiểu cách mọi thứ hoạt động bên trong.

Cuối cùng, chúng tôi đã sửa đổi chính Lua VM, nhưng trước khi đi vào vấn đề đó, chúng ta cần phải đặt ra một số nền tảng cơ bản.

Trình biên dịch, VM và mã byte

Khi mã nguồn Lua được biên dịch, nó sẽ được chuyển đổi thành mã byte Lua, mà máy ảo Lua sau đó sẽ thực thi. Mã byte Lua có khoảng 35 lệnh tổng cộng, bao gồm các thao tác như đọc/ghi bảng, gọi hàm, thực hiện các phép toán nhị phân, nhảy và điều kiện, v.v. Lua VM dựa trên thanh ghi, trái ngược với việc dựa trên ngăn xếp như nhiều VM khác, vì vậy một phần công việc của trình biên dịch khi tạo mã byte là xác định mỗi lệnh nên sử dụng thanh ghi nào.

Mỗi lệnh có dạng “OP_CODE A B” hoặc “OP_CODE A B C”, trong đó “OP_CODE” là mã lệnh (ví dụ: CALL để gọi hàm) và A/B/C là các đối số của mã lệnh. Các đối số (hoặc thanh ghi) không phải là các giá trị thực tế. Thay vào đó, chúng là các chỉ số trỏ đến một trong hai bảng: bảng hằng số (viết là Kst(..)) hoặc bảng thanh ghi (viết là R(..)).

Để biết mô tả chi tiết về mã byte Lua, hãy xem “Giới thiệu cơ bản về các lệnh Lua 5.1 VM.” Nó thú vị hơn nhiều so với những gì bạn tưởng tượng; tôi hứa đấy!

Để giúp bạn cảm nhận được mã byte Lua trông như thế nào, trước tiên chúng ta sẽ xem xét một số chương trình đơn giản, sau đó chuyển sang một số ví dụ phù hợp hơn.

Sử dụng tiện ích Chunkspy, chúng ta có thể giải mã mã byte Lua thành mã lắp ráp Lua và lấy danh sách mã, cũng như bảng hằng số, để từ đó có thể xem mã byte nào được tạo ra cho bất kỳ mã nguồn Lua nào.

Ví dụ mã byte cơ bản

Một chương trình đơn giản như “x = 10” được biên dịch thành:

.const "x";  0

.const 10;  1

[1]  loadk       0   1       ;   10

[2]  setglobal   0   0       ;   x 

Hai dòng đầu tiên hiển thị bảng hằng số (với giá trị chuỗi “x” ở vị trí 0 và giá trị số nguyên 10 ở vị trí 1), và hai dòng tiếp theo là các mã lệnh đã được giải mã.

[Dòng 1] Khi tra cứu mã lệnh LOADK trong “No Frills”, chúng ta thấy rằng nó có dạng “LOADK A Bx --- R(A) := Kst(Bx)”. Vậy, LOADK có hai tham số (đăng ký A và B) và hoạt động của nó là gán giá trị tìm thấy trong bảng hằng số tại vị trí do đăng ký thứ hai chỉ định, Kst(Bx), vào đăng ký A tại vị trí do tham số đầu tiên chỉ định, R(A). “Bx” chỉ có nghĩa là vì mã lệnh chỉ có hai tham số, nên đăng ký B được mở rộng và gán thêm bit.

[Dòng 2] SETGLOBAL có dạng “SETGLOBAL A Bx --- Gbl[Kst(Bx)] := R(A).” Nó gán một giá trị cho bảng toàn cục bằng cách sử dụng khóa được xác định bởi bảng hằng số tại vị trí của đối số thứ hai. Vì đối số thứ hai là 0 và giá trị của bảng hằng số tại vị trí 0 là “x”, nên nó ghi một giá trị vào bảng toàn cục bằng khóa “x”. Bất kỳ giá trị nào trong bảng đăng ký tại vị trí được chỉ định bởi đối số đầu tiên sẽ được ghi vào, và lệnh trước đó đã tải giá trị 10 vào vị trí đó.

Hãy xem một ví dụ phức tạp hơn một chút, “x = 10; y = x.” Tôi sẽ để việc thực thi thủ công đoạn mã này làm bài tập cho người đọc. :)

.const "x";   0

.const 10;    1

.const"y";    2

[1]  loadk       0   1       ;   10

[2]  setglobal   0   0       ;   x

[3]  getglobal   0   0       ;   x

[4]  setglobal   0   2       ;   y

 

Mã byte cho các lệnh gọi hàm

Hãy xem mã được tạo cho “foo(10):”

.const "foo"; 0

.const 10;    1

[1]  getglobal  0    0  ;  foo   //  R(A)  :=  Gbl[Kst(Bx)]

[2]  loadk      1     1 ;  10   //  R(A)  :=  Kst(Bx)

[3]  call       0     2      1

 
Để thực thi các lệnh gọi hàm, hàm phải được nạp vào thanh ghi đầu tiên và các đối số vào các thanh ghi tiếp theo. Sémantique của “CALL A B C” được định nghĩa như sau: A chứa tên hàm, B là số lượng tham số (thực tế là số lượng tham số cộng 1, do cách thực hiện của “...” ), và C là số lượng giá trị trả về (lại là số lượng giá trị trả về cộng 1, để xử lý trường hợp có nhiều giá trị trả về).

Chúng ta đã quen thuộc với hai dòng đầu tiên; chúng nạp một giá trị vào vị trí 0 của bảng đăng ký và giá trị 10 vào vị trí 1 của bảng đăng ký. Dòng thứ ba là dòng thực hiện cuộc gọi hàm, sử dụng giá trị trong thanh ghi A (khe 0 của bảng thanh ghi, đã được nạp với “foo”), với B chỉ định số lượng tham số, và C là số lượng giá trị trả về (nhớ rằng cả B và C đều phải cộng thêm 1 vào giá trị của chúng). Trước khi hàm có thể được gọi, máy ảo (VM) cũng kiểm tra xem giá trị trong R(A) thực sự có thể gọi được hay không.

Lua có một cơ chế cho phép người dùng mở rộng chức năng của các bảng bằng cách liên kết một metatable với một bảng hiện có. Metatable chứa các phương thức dự phòng được gọi nếu một phương thức hoặc thao tác cụ thể không thể thực hiện trên bảng chính (xem https://www.lua.org/pil/13.html để có mô tả chi tiết).

Đối với mục đích của chúng ta, các mục quan trọng nhất trong metatable là các trường “__index” và “__call”. __index được sử dụng khi tra cứu một phần tử trong bảng, vì vậy đoạn mã “local x = my_table[10]” sẽ trước tiên gọi phương thức __index trên my_table. Nếu việc đó thất bại, nó sẽ thay vào đó cố gắng gọi __index trên metatable của my_table. __call cũng được sử dụng tương tự khi bạn cố gắng coi một thứ gì đó như một hàm và gọi nó, ví dụ như “local x = foo(42),”

Để Lua và C++ có thể tương tác với nhau, chúng cần một cách nào đó để có thể chia sẻ các hàm và dữ liệu. Lua hỗ trợ việc này bằng cách cung cấp một kiểu dữ liệu gọi là UserData. Các đối tượng UserData có thể được tạo ra trong môi trường C++, và vì chúng là các kiểu dữ liệu gốc của Lua, chúng có thể được trang bị các metatable cho phép mã Lua tương tác với chúng như thể chúng chỉ là các đối tượng Lua thông thường.

Gọi hàm thành viên

Được rồi, quay lại xem xét một số mã byte! Ví dụ tiếp theo này thú vị hơn một chút vì nó cho thấy điều gì xảy ra khi bạn có mã như “foo:bar(10),” – tức là gọi phương thức bar trên đối tượng foo (một thực thể của lớp Foo).

foo:bar(10)

.const "foo";   0

.const "bar";   1

.const  10;     2

[1]  getglobal  0    0         ;  foo

[2]  self       0     0    257 ;  "bar"

[3]  loadk      2     2        ;  10

[4]  call       0     3      1

 
Điều mới ở đây là lệnh self [dòng 2], mà chúng ta chưa từng thấy trước đây. Lệnh self có cú pháp “SELF A B C --- R(A) := R(B)[RK(C)]; R(A+1) := R(B)”, vậy hãy phân tích chi tiết. Trong bảng đăng ký tại ô R(A), nó sẽ đặt kết quả tra cứu bảng tại ô R(B) bằng khóa trong ô RK(C). Nó cũng sẽ sao chép giá trị trong ô R(B) vào ô R(A+1), nhưng chúng ta sẽ nói chi tiết hơn về điều này sau. Bạn có thể nhận thấy giá trị của đăng ký C là 257. Điều này là hợp lệ vì Lua sử dụng RK(C) để tra cứu giá trị, và RK sẽ sử dụng bảng đăng ký hoặc bảng hằng số, tùy thuộc vào giá trị của bit thứ 9. Nếu bit này là 1 (như trong trường hợp này), thì bảng hằng số sẽ được sử dụng; ngược lại, việc tra cứu sẽ chuyển sang bảng đăng ký (sau khi loại bỏ bit cao nhất).

Dòng 3 đặt 10 vào vị trí 2, và cuối cùng dòng 4 sẽ thực thi lệnh gọi hàm.

Lệnh SELF có hai mục đích. Thứ nhất, nó tìm kiếm phương thức “bar” trên lớp Foo và đặt nó vào R(A). Thứ hai, vì foo là phương thức thực thể và chúng ta cần thực thể của lớp mà chúng ta đang gọi phương thức khi thực hiện cuộc gọi, nó đặt thực thể này vào R(A+1). Nếu bạn quen thuộc với các lớp trong Python, bạn có thể nhận ra khái niệm này: các phương thức thường được viết dưới dạng “def my_method(self, arg1, arg2..),” trong đó self là thực thể của lớp.

Chúng ta cần đi sâu hơn một chút và xem xét điều gì xảy ra khi đối tượng foo là một đối tượng C++, được biểu diễn trong Lua dưới dạng đối tượng UserData.

Gọi SELF có thể được xem như một thao tác tra cứu bảng, tức là Foo[“bar”] (Foo viết hoa đại diện cho lớp Foo, khác với foo là đối tượng), và chúng ta biết rằng các thao tác tra cứu sẽ sử dụng phương thức __index. Khi đối tượng foo được tạo ra trong môi trường C++, một metatable đã được liên kết với đối tượng đó, và trường __index của metatable được đặt thành một đoạn mã C++ sẽ được gọi khi __index được kích hoạt.

Khi C/C++ được gọi từ Lua, dữ liệu duy nhất được truyền là một đối tượng lua_State. Đối tượng này chứa mọi thứ liên quan đến luồng Lua đang chạy. Thông tin quan trọng nhất trong đối tượng trạng thái là ngăn xếp Lua, chứa các đối số hàm (truy cập qua họ hàm lua_tointeger/tostring, v.v.) và cũng được sử dụng để trả về các giá trị cho Lua.

Trong pseudo-C++, hàm __index của chúng ta trông giống như sau:

int  metaIndex(lua_State*  L)

{

           //  first  argument  is  the  userdata  object

           UserData*  userdata  =  lua_touserdata(L,  1);


           //  get  some  kind  of  descriptor,  that  contains  information

           //  about  what  methods the  class  exposes

           ClassDescriptor*  desc =  getDescriptorForUserData(userdata);


           //  See  if  the  class  has  the  requested  method

           const  char*  methodName  =  lua_tostring(L,  2);

           MemberFunctionPtr  method  = desc->hasMethod(methodName);

           if  (method)

           {

                   //  Upvalues  are  values  that  are  available  when  a  C

                   //  function  is  invoked.

                   lua_pushupvalue(L,  method);

                   lua_pushcfunction(L,  methodInvoker);

                   return  1;

           }

           else

           {

                   lua_pushnil(L);

                   return  0;

           }

}

 
Nhiều chi tiết nội bộ đã được lược qua, nhưng đây là ý chính. Dựa trên đối tượng UserData được truyền làm tham số đầu tiên trên ngăn xếp Lua, chúng ta có thể tìm thấy một mô tả (descriptor) mô tả lớp C++ thực tế, và thông qua mô tả này, chúng ta có thể kiểm tra xem lớp này có phương thức nào với tên đã cho hay không. Nếu có, một con trỏ hàm đến trình gọi phương thức sẽ được đẩy lên ngăn xếp Lua, và chúng ta trả về thành công.

Sau cuộc gọi này, Lua VM sẽ đặt các đối số còn lại vào bảng đăng ký, sau đó gọi hàm mà chúng ta đã trả về từ phương thức metaIndex, hàm này sẽ lại gọi vào C++ và đến hàm trình gọi:

int  methodInvoker(lua_State*  L)

{ &nbsp;  <br>  &nbsp; &nbsp;    &nbsp;//  Get  the  userdata  and  the  class  descriptor

           UserData*  userdata  =  lua_touserdata(L,  1);

           ClassDescriptor*  desc  =  getDescriptorForUserData(userdata);

           Class*  instance  =  (Class*)userdata;


           //  Using  Lua's  upvalue  mechanism,  get  the  'method'

           //  that  was  stored  in  metaIndex.

           MemberFunctionPtr  method  =  lua_getupvalue(L,  1);


           //  This  is  hand-wavey,  but  we  have  some  mechanism  of  being

           //  able  to  invoke  a  member  function  via  the  class  descriptor,

           //  and  also  pop  arguments  from  the  Lua  stack,  and  push  return  values

           return  desc-&gt;invokeFunction(instance,  method,  L);
}

 
methodInvoker cũng sử dụng ClassDescriptor, nhưng lần này nó có thể gọi hàm thành viên và lấy các đối số chính xác ra khỏi ngăn xếp.

Chặng cuối cùng!

Giờ đây, khi chúng ta đã thấy rõ hai lần đi lại từ Lua sang C++, chúng ta có thể thử tìm cách tối ưu hóa quá trình này.

Mục tiêu cuối cùng của chúng ta là thực hiện một cuộc gọi hàm duy nhất từ Lua sang C++ và có tất cả các thành phần cần thiết trên ngăn xếp Lua để có thể thực hiện tra cứu và gọi phương thức cùng một lúc. Vấn đề dường như là chúng ta thiếu một thanh ghi. Khi gọi hàm kết hợp tra cứu/gọi phương thức, chúng ta muốn ngăn xếp Lua trông như [self, tên phương thức, arg1, arg2, ...], nhưng khi xem xét SELF, chúng ta thấy nó sử dụng ô đầu tiên để lưu kết quả tra cứu hàm phương thức và ô thứ hai để lưu đối tượng.

Một nhận thức quan trọng đến khi chúng ta xem xét cách hoạt động của metamethod __call. Nếu một đối tượng có metamethod __call, thì trước khi hàm _call được gọi, chính đối tượng đó sẽ được đẩy lên stack và tất cả các tham số sẽ được dịch chuyển lên trên. Bằng cách tận dụng chức năng này, chúng ta có thể đưa “self” lên stack mà không cần phải lưu trữ nó một cách rõ ràng trong một thanh ghi.

Phần thứ hai liên quan đến việc đưa tên phương thức lên stack. Để làm điều này, chúng tôi phải “lách luật” và thay đổi cách hoạt động của mã lệnh SELF.

Hãy nhớ rằng trong trường hợp mặc định, SELF sẽ cố gắng tìm hàm thành viên và lưu trữ nó trong R(A) cùng với đối tượng trong R(A+1). Chúng ta đã bỏ qua quá trình tìm kiếm hoàn toàn và lưu trữ đối tượng thực tế trong R(A) và tên phương thức trong R(A+1).

Nếu bây giờ chúng ta đảm bảo rằng đối tượng trong R(A) có phương thức siêu __call, thì chúng ta cũng sẽ đẩy self lên stack. Vì vậy, stack sẽ trông như [self, tên phương thức, các tham số…] và chỉ thực hiện một lần gọi vào C++. Hoàn hảo! À, gần như vậy. :)

Trước khi coi việc này là hoàn tất, chúng tôi muốn hoàn thiện nó thêm một chút. Chúng tôi không muốn làm quá tải ngữ nghĩa của phương thức siêu __call, vì vậy thay vào đó, chúng tôi đã thêm một phương thức siêu cụ thể cho loại gọi này — được gọi là __namecall — chỉ có sẵn trên các đối tượng UserData. Chúng tôi cũng đã sửa đổi mã lệnh SELF để nó chỉ sử dụng ngữ nghĩa mới nếu đối tượng có phương thức siêu __namecall.

Điều thứ hai chúng tôi làm chủ yếu là giúp đường dẫn mới và đường dẫn cũ có thể chia sẻ mã dễ dàng. Thay vì lấy tên phương thức làm đối số thứ hai, chúng tôi đã đẩy nó xuống làm đối số cuối cùng. Vì vậy, sau khi nó được sử dụng để tra cứu con trỏ phương thức, nó có thể dễ dàng được lấy ra khỏi ngăn xếp và ngăn xếp trông giống như khi hàm được gọi qua đường dẫn cũ.

Kết luận

Tối ưu hóa này có tác động như thế nào? Chà, giống như hầu hết mọi thứ trong lập trình, câu trả lời là “tùy thuộc vào trường hợp”. Đối với các hàm nặng nề — và bạn không gọi chúng thường xuyên — bạn sẽ không thấy nhiều cải thiện. Nhưng đối với các hàm nhỏ mà bạn gọi thường xuyên, tiết kiệm được có thể đáng kể.

Người dùng trên Diễn đàn Nhà phát triển nhanh chóng nhận ra sự xuất hiện của phương thức siêu (metamethod) mới lạ này, và một bảng so sánh đã được trình bày để so sánh tốc độ của __namecall với cả phương pháp gọi phương thức thực thể cũ và với một giải pháp thay thế mà các nhà phát triển đã sử dụng để tối ưu hóa việc gọi phương thức:

local  part  =  workspace.Baseplate


local  count  =  1000000


local  start0  =  tick()

for  i=1,count  do

        part:IsA("BasePart")

end

local  end0  =  tick()



local  start1  =  tick()

for  i=1,count  do

       local  isa  =  part.IsA

       isa(part,  "BasePart")

end

local  end1  =  tick()



local  start2  =  tick()

local  isa  =  part.IsA

for  i=1,count  do

       isa(part,  "Basepart")

end

local  end2  =  tick()




print("namecall",  end0  -  start0)

print("index+call",  end1  -  start1)

print("call",  end2  -  start2)



&gt;  namecall  0.49229717254639

&gt;  index+call  0.78510332107544

&gt;  call  0.49960780143738

 
Vòng lặp đầu tiên sử dụng đường dẫn mã __namecall mới, nhưng vì tất cả các thao tác đều diễn ra ở chế độ ẩn, nên các nhà phát triển không cần thay đổi bất kỳ mã hiện có nào để tận dụng lợi thế của việc tối ưu hóa này.

Vòng lặp thứ hai mô phỏng cách gọi phương thức thể hiện cũ; đầu tiên là tìm kiếm phương thức và sau đó gọi nó.

Và cuối cùng, vòng lặp thứ ba cho thấy một cách tối ưu hóa phổ biến mà các nhà phát triển thường thực hiện, trong đó phương thức được tìm kiếm trước, lưu trữ trong một biến cục bộ, và sau đó biến đó được gọi.

Điều hay ở đây là nó cho thấy rằng với tối ưu hóa __namecall, không còn cần thiết phải lưu trữ bộ nhớ đệm cho các hàm thực thể một cách rõ ràng, vì nó nhanh ngang với tối ưu hóa có bộ nhớ đệm, do đó mã đơn giản nhất cũng sẽ là mã hiệu quả nhất.

Giờ đây, khi __namecall đã được triển khai và chúng tôi hài lòng với kết quả thu được, đã đến lúc chuyển sự tập trung sang việc sử dụng bộ nhớ và xem chúng tôi có thể làm gì để cải thiện ứng dụng khách trong lĩnh vực này!