Konten di situs ini telah diterjemahkan menggunakan kecerdasan buatan (AI) atau teknologi penerjemahan mesin, dan mungkin terdapat kesalahan.

Skip to content

Mengoptimalkan Interoperabilitas Lua/C++

Pengantar

Mesin Roblox ditulis dalam kombinasi C++ dan Lua, dengan kode yang melakukan operasi komputasi intensif ditulis dalam C++ yang dioptimalkan, sementara logika permainan dan skrip ditulis dalam Lua, untuk kemudahan pengembangan. Agar model ini efektif, transisi antara Lua dan C++ harus secepat mungkin, karena setiap waktu yang dihabiskan di wilayah abu-abu ini pada dasarnya hanya membuang-buang milidetik.

Selama beberapa bulan terakhir, kami telah meluncurkan berbagai peningkatan pada bagian sistem ini. Satu bagian secara khusus—panggilan metode C++ dari Lua—sangat menarik, karena menghasilkan peningkatan kecepatan yang signifikan dan memerlukan penyelidikan mendalam ke dalam inti Lua untuk memahami bagaimana hal-hal bekerja di balik layar.

Kami akhirnya memodifikasi Lua VM itu sendiri, tetapi sebelum membahas hal tersebut, kami perlu meletakkan dasar-dasarnya terlebih dahulu.

Kompiler, VM, dan bytecode

Ketika kode sumber Lua dikompilasi, kode tersebut diubah menjadi bytecode Lua, yang kemudian dijalankan oleh Lua VM. Bytecode Lua memiliki sekitar 35 instruksi secara total, untuk hal-hal seperti membaca/menulis tabel, memanggil fungsi, melakukan operasi biner, lompatan, dan kondisi, dan sebagainya. Lua VM berbasis register, berbeda dengan VM lain yang umumnya berbasis tumpukan, sehingga bagian dari tugas kompiler saat menghasilkan bytecode adalah menentukan register mana yang harus digunakan oleh setiap instruksi.

Setiap instruksi memiliki bentuk “OP_CODE A B,” atau “OP_CODE A B C,” di mana “OP_CODE” adalah kode operasi (misalnya, CALL untuk memanggil fungsi) dan A/B/C adalah argumen kode operasi. Argumen (atau register) tersebut bukanlah nilai sebenarnya. Sebaliknya, mereka adalah indeks yang mengacu ke salah satu dari dua tabel: tabel konstanta (ditulis Kst(..)) atau tabel register (ditulis R(..)).

Untuk penjelasan terperinci mengenai bytecode Lua, lihat “Pengantar Sederhana tentang Instruksi VM Lua 5.1.” Ini jauh lebih menarik daripada kedengarannya; saya jamin!

Untuk memberi gambaran tentang seperti apa bytecode Lua, kita akan membahas beberapa program sederhana terlebih dahulu, lalu beralih ke contoh-contoh yang lebih relevan.

Dengan menggunakan utilitas Chunkspy, kita dapat membongkar bytecode Lua menjadi assembly Lua dan mendapatkan daftar kode, serta tabel konstanta, sehingga pada dasarnya kita dapat melihat bytecode apa yang dihasilkan untuk kode sumber Lua tertentu.

Contoh bytecode dasar

Program sederhana seperti “x = 10” dikompilasi menjadi:

.const "x";  0

.const 10;  1

[1]  loadk       0   1       ;   10

[2]  setglobal   0   0       ;   x 

Dua baris pertama menunjukkan tabel konstanta (dengan nilai string “x” di slot 0 dan nilai integer 10 di slot 1), dan dua baris berikutnya adalah opcode yang telah didisassemble.

[Baris 1] Mencari opcode LOADK di “No Frills,” kita melihat bahwa bentuknya adalah “LOADK A Bx --- R(A) := Kst(Bx).” Jadi, LOADK memiliki dua argumen (register A dan B) dan operasinya adalah menginisialisasi nilai yang ditemukan di tabel konstanta pada slot yang ditentukan oleh register kedua, Kst(Bx), ke register yang ditentukan oleh argumen pertama, R(A). “Bx” hanya berarti bahwa karena opcode hanya memiliki dua argumen, register B diperluas dan diberi bit tambahan.

[Baris 2] SETGLOBAL memiliki bentuk “SETGLOBAL A Bx --- Gbl[Kst(Bx)] := R(A).” Ini menetapkan nilai ke tabel global menggunakan kunci yang ditentukan oleh tabel konstanta pada slot argumen kedua. Karena argumen kedua adalah 0 dan nilai tabel konstanta pada 0 adalah “x,” maka ia menulis sesuatu ke tabel global menggunakan kunci “x.” Apa pun yang ada di tabel register pada slot yang ditentukan oleh argumen pertama adalah yang ditulis, yang sebelumnya dimuat dengan nilai 10 oleh instruksi sebelumnya.

Mari kita lihat contoh yang sedikit lebih rumit, “x = 10; y = x.” Saya akan membiarkan eksekusi manual kode ini sebagai latihan bagi pembaca. :)

.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

 

Bytecode untuk panggilan fungsi

Mari kita lihat kode yang dihasilkan untuk “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

 
Untuk menjalankan panggilan fungsi, fungsi tersebut harus dimuat ke register pertama dan argumen ke register berikutnya. Semantik untuk “CALL A B C” adalah sebagai berikut: A menyimpan fungsi, B adalah jumlah argumen (sebenarnya, ini adalah jumlah argumen +1, karena cara implementasi “...” ), dan C adalah jumlah nilai kembalian (sekali lagi, ini adalah jumlah nilai kembalian +1, untuk menangani nilai kembalian ganda).

Kita sudah familiar dengan dua baris pertama; keduanya memuat nilai ke slot 0 tabel register dan nilai 10 ke slot 1 tabel register. Baris ketiga adalah yang melakukan panggilan fungsi, menggunakan nilai di register A (slot tabel register 0, yang dimuat dengan “foo”), dengan B menentukan jumlah argumen, dan C jumlah nilai kembalian (ingat, baik B maupun C harus ditambah 1 pada nilainya). Sebelum fungsi dapat dipanggil, VM juga memverifikasi bahwa nilai di R(A) memang dapat dipanggil.

Lua memiliki mekanisme yang memungkinkan pengguna memperluas fungsionalitas tabel dengan mengaitkan metatable ke tabel yang sudah ada. Metatable berisi metode cadangan yang dipanggil jika suatu metode atau operasi tidak dapat dilakukan pada tabel utama (lihat https://www.lua.org/pil/13.html untuk penjelasan mendetail).

Untuk keperluan kita, entri yang paling relevan dalam metatable adalah bidang “__index” dan “__call”. __index digunakan saat mencari elemen dalam sebuah tabel, sehingga kode “local x = my_table[10]” akan terlebih dahulu memanggil metode __index pada my_table. Jika itu gagal, ia akan mencoba memanggil __index pada metatabel my_table. __call digunakan dengan cara serupa saat Anda mencoba memperlakukan sesuatu sebagai fungsi dan memanggilnya, misalnya “local x = foo(42),”

Agar Lua dan C++ dapat berinteroperasi, keduanya memerlukan cara untuk berbagi fungsi dan data. Lua memfasilitasi hal ini dengan menyediakan tipe data yang disebut UserData. Objek UserData dapat dibuat di lingkungan C++, dan karena merupakan tipe data asli Lua, objek tersebut dapat dilengkapi dengan metatabel yang memungkinkan kode Lua berinteraksi dengannya seolah-olah objek tersebut adalah objek Lua biasa.

Panggilan fungsi anggota

Oke, kembali ke bytecode! Contoh berikutnya sedikit lebih menarik karena menunjukkan apa yang terjadi saat Anda memiliki kode seperti “foo:bar(10),” yang memanggil metode bar pada instance foo (sebuah instance dari kelas 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

 
Hal baru di sini adalah instruksi self [baris 2], yang belum kita lihat sebelumnya. Self memiliki sintaks “SELF A B C --- R(A) := R(B)[RK(C)]; R(A+1) := R(B),” jadi mari kita uraikan ini. Di tabel register pada slot R(A), akan ditempatkan hasil pencarian tabel di slot register R(B) menggunakan kunci di slot RK(C). Selain itu, isi slot R(B) akan disalin ke slot R(A+1), tetapi kita akan membahasnya nanti. Anda mungkin memperhatikan bahwa nilai register C adalah 257. Hal ini valid karena Lua menggunakan RK(C) untuk mencari nilai, dan RK akan menggunakan tabel register atau tabel konstanta, tergantung pada nilai bit ke-9. Jika nilainya 1, seperti dalam kasus ini, maka tabel konstanta digunakan; sebaliknya, pencarian akan dilakukan ke tabel register (setelah bit tertinggi diabaikan).

Baris 3 menempatkan 10 di slot 2, dan akhirnya baris 4 akan menjalankan panggilan fungsi.

Instruksi SELF memiliki dua tujuan. Pertama, ia mencari metode “bar” pada kelas Foo, dan menempatkannya di R(A). Kedua, karena foo adalah metode instance dan kita memerlukan instance kelas yang akan dipanggil saat melakukan panggilan, metode ini menempatkan instance tersebut di R(A+1). Jika Anda familiar dengan kelas di Python, Anda mungkin mengenali konsep ini: metode biasanya ditulis sebagai “def my_method(self, arg1, arg2..),” di mana self adalah instance kelas.

Kita perlu menggali hal ini lebih dalam dan melihat apa yang terjadi ketika instance foo adalah objek C++, yang direpresentasikan dalam Lua sebagai objek UserData.

Panggilan SELF dapat dipandang sebagai pencarian tabel, yaitu Foo[“bar”] (huruf besar Foo mewakili kelas Foo, bukan foo, instansnya), dan kita tahu bahwa pencarian akan menggunakan metode __index. Saat instance foo dibuat di lingkungan C++, sebuah metatable dihubungkan dengan instance tersebut, dan metatable tersebut memiliki bidang __index yang disetel ke potongan kode C++ yang akan dipanggil saat __index dipanggil.

Ketika C/C++ dipanggil dari Lua, satu-satunya data yang diteruskan adalah objek lua_State. Objek ini berisi segala sesuatu yang terkait dengan thread Lua yang sedang berjalan. Informasi terpenting dalam objek state adalah tumpukan Lua, yang berisi argumen fungsi (diakses melalui keluarga fungsi lua_tointeger/tostring, dll.) dan juga digunakan untuk mengembalikan nilai ke Lua.

Dalam pseudo-C++, fungsi __index kami terlihat seperti ini:

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;

           }

}

 
Banyak detail internal diabaikan, tetapi inilah intinya. Mengingat objek UserData yang diteruskan sebagai argumen pertama di tumpukan Lua, kita dapat menemukan deskriptor yang menggambarkan kelas C++ sebenarnya, dan melalui deskriptor tersebut kita dapat memeriksa apakah kelas ini memiliki metode dengan nama yang diberikan. Jika ada, penunjuk fungsi ke pemanggil metode ditambahkan ke tumpukan Lua, dan kita mengembalikan keberhasilan.

Setelah panggilan ini, Lua VM akan menempatkan argumen-argumen lainnya di tabel register, lalu memanggil fungsi yang kita kembalikan dari metode metaIndex, yang akan kembali memanggil C++, dan berakhir di fungsi pemanggil:

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 juga menggunakan ClassDescriptor, tetapi kali ini ia dapat memanggil fungsi anggota, dan mengeluarkan argumen yang benar dari tumpukan.

Tahap akhir!

Sekarang setelah kita dapat dengan jelas melihat dua kali perjalanan bolak-balik dari Lua ke C++, kita dapat mencoba mencari cara untuk mengoptimalkan ini.

Tujuan akhir kita adalah melakukan satu panggilan fungsi dari Lua ke C++ dan memiliki semua bagian yang kita butuhkan di tumpukan Lua agar dapat melakukan pencarian metode dan pemanggilan sekaligus. Masalahnya tampaknya kita kekurangan satu register. Saat memanggil fungsi pencarian/panggilan gabungan kita, kita ingin tumpukan Lua terlihat seperti [self, nama metode, arg1, arg2, ...], tetapi saat melihat SELF, kita melihat bahwa slot pertamanya digunakan untuk hasil pencarian fungsi metode dan slot kedua untuk menyimpan instance.

Sebuah pencerahan penting muncul saat kita melihat cara kerja metametode __call. Jika sebuah objek memiliki metametode __call, maka sebelum fungsi _call dipanggil, objek itu sendiri ditumpuk di tumpukan dan semua argumen digeser ke atas. Dengan memanfaatkan fungsi ini, ada cara untuk mendapatkan “self” di tumpukan tanpa harus menyimpannya secara eksplisit di register.

Bagian kedua melibatkan penempatan nama metode di tumpukan juga. Untuk ini, kami harus cerdik dan mengubah cara kerja opcode SELF.

Ingat bahwa dalam kasus default, SELF akan mencoba mencari fungsi anggota dan menyimpannya di R(A) bersama dengan instance di R(A+1). Kami akhirnya melewati proses pencarian tersebut sepenuhnya dan menyimpan objek sebenarnya di R(A) serta nama metode di R(A+1).

Jika kita memastikan bahwa objek di R(A) memiliki metametode __call, maka kita juga akan mendorong self ke tumpukan. Jadi, kita akan memiliki tumpukan yang terlihat seperti [self, nama metode, argumen…] dan melakukan panggilan tunggal ke C++. Sempurna! Ya, hampir. :)

Sebelum menganggap ini selesai, kami ingin memberikan sentuhan akhir. Kami tidak ingin membebani semantik metametode __call, jadi sebagai gantinya kami menambahkan metametode khusus untuk jenis panggilan ini—bernama __namecall—yang hanya tersedia pada objek UserData. Kami juga memodifikasi opcode SELF sehingga hanya menggunakan semantik baru jika objek memiliki metametode __namecall.

Hal kedua yang kami lakukan adalah memastikan jalur baru dan jalur lama dapat berbagi kode dengan mudah. Alih-alih menggunakan nama metode sebagai argumen kedua, kami memindahkannya ke argumen terakhir. Jadi, setelah digunakan untuk mencari penunjuk metode, argumen tersebut dapat dengan mudah dikeluarkan dari tumpukan, dan tumpukan terlihat seperti saat fungsi dipanggil melalui jalur lama.

Kesimpulan

Seberapa besar dampak optimasi ini? Nah, seperti halnya kebanyakan hal dalam pemrograman, jawabannya adalah “tergantung.” Untuk fungsi yang berat—dan Anda tidak memanggilnya sering—Anda tidak akan melihat banyak perbaikan. Namun, untuk fungsi kecil yang sering Anda panggil, penghematannya bisa signifikan.

Para pengguna di Forum Pengembang dengan cepat menyadari kemunculan metametode aneh dan baru ini, dan sebuah tabel disajikan yang membandingkan kecepatan __namecall dengan metode lama dalam memanggil metode instance serta dengan solusi alternatif yang telah digunakan pengembang untuk mengoptimalkan pemanggilan metode:

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

 
Loop pertama menggunakan jalur kode __namecall yang baru, tetapi karena semua prosesnya terjadi di balik layar, pengembang tidak perlu mengubah kode yang ada untuk mendapatkan manfaat dari pengoptimalan ini.

Loop kedua meniru cara lama dalam melakukan panggilan metode instance; pertama melakukan pencarian untuk menemukan metode tersebut, lalu memanggilnya.

Dan terakhir, loop ketiga menunjukkan optimasi umum yang dilakukan pengembang, di mana metode dicari terlebih dahulu, disimpan dalam variabel lokal, lalu variabel tersebut dipanggil.

Hal yang menarik di sini adalah bahwa optimasi __namecall menunjukkan bahwa tidak lagi perlu secara eksplisit menyimpan fungsi instance dalam cache, karena kinerjanya sama cepatnya dengan optimasi yang menggunakan cache, sehingga kode yang paling sederhana juga akan menjadi yang paling efisien.

Sekarang setelah __namecall telah diterapkan, dan kami puas dengan hasil yang kami lihat, saatnya untuk mengalihkan fokus ke penggunaan memori, dan melihat apa yang dapat kami lakukan untuk meningkatkan klien di bidang tersebut!