Lua/C++ Arasındaki İşbirliğini Optimize Etme

Giriş
Roblox motoru, C++ ve Lua'nın bir kombinasyonu ile yazılmıştır; hesaplama yoğunluğu yüksek işlemleri gerçekleştiren kod, optimize edilmiş C++ ile yazılırken, oyun mantığı ve komut dosyaları, geliştirme kolaylığı açısından Lua ile yazılmıştır. Bu modelin etkili olabilmesi için, Lua ve C++ arasındaki geçişlerin mümkün olduğunca hızlı olması gerekir; çünkü bu ara bölgede geçirilen her saniye, esasen boşa harcanan milisaniyelerdir.
Geçtiğimiz birkaç ay boyunca, sistemin bu kısmında çeşitli iyileştirmeler yaptık. Bunlardan biri, Lua'dan C++ yöntemlerinin fiilen çağrılmasıydı. Bu, hızda önemli iyileştirmeler sağladığı ve işlerin arka planda nasıl yürüdüğünü anlamak için Lua'nın iç yapısını derinlemesine incelememizi gerektirdiği için özellikle ilginçti.
Sonunda Lua VM'yi kendimiz değiştirdik, ancak buna geçmeden önce bazı temel bilgileri vermemiz gerekiyor.
Derleyiciler, VM ve bayt kodu
Lua kaynak kodu derlendiğinde, Lua VM'nin daha sonra çalıştıracağı Lua bayt koduna dönüştürülür. Lua bayt kodu, tabloları okuma/yazma, işlevleri çağırma, ikili işlemleri gerçekleştirme, atlamalar ve koşullu ifadeler gibi işlemler için toplamda yaklaşık 35 komut içerir. Lua VM, diğer birçok VM gibi yığın tabanlı değil, kayıt tabanlıdır; bu nedenle derleyicinin bayt kodu oluştururken yaptığı işlerden biri, her komutun hangi kayıtları kullanması gerektiğini belirlemektir.
Her komut, "OP_CODE A B" veya "OP_CODE A B C" biçimindedir; burada "OP_CODE", opkoddur (örneğin, bir fonksiyonu çağırmak için CALL) ve A/B/C, opkod argümanlarıdır. Argümanlar (veya kayıtlar) gerçek değerler değildir. Bunun yerine, iki tablodan birine işaret eden indekslerdir: sabit tablosu (Kst(..) olarak yazılır) veya kayıt tablosu (R(..) olarak yazılır).
Lua bayt kodunun ayrıntılı açıklaması için “Lua 5.1 VM Komutlarına Basit Bir Giriş” başlıklı makaleye bakın. Kulağa geldiğinden çok daha heyecan verici, söz veriyorum!
Lua bayt kodunun neye benzediğini anlamanız için, önce bazı basit programları inceleyeceğiz, ardından daha alakalı örneklere geçeceğiz.
Chunkspy yardımcı programını kullanarak, Lua bayt kodunu Lua derleme koduna ayrıştırabilir ve kodun yanı sıra sabit tablosunun bir listesini de alabiliriz, böylece herhangi bir Lua kaynak kodu için hangi bayt kodunun üretildiğini görebiliriz.
Temel bayt kodu örnekleri
"x = 10" gibi basit bir program şu şekilde derlenir:
.const "x"; 0
.const 10; 1
[1] loadk 0 1 ; 10
[2] setglobal 0 0 ; x İlk iki satır sabit tablosunu gösterir (0. yuvada “x” dizesi değeri ve 1. yuvada 10 tamsayı değeri bulunur) ve sonraki iki satır ise ayrıştırılmış opkodlardır.
[Satır 1] “No Frills”de LOADK opkoduna baktığımızda, bunun “LOADK A Bx --- R(A) := Kst(Bx)” biçiminde olduğunu görürüz. Dolayısıyla, LOADK'ın iki argümanı vardır (A ve B kayıtları) ve işlevi, sabit tablosunda ikinci kayıt tarafından verilen yuvada bulunan değeri, Kst(Bx), birinci argüman tarafından verilen yuvadaki kayıt tablosuna atamaktır, R(A). “Bx” sadece, opkodun sadece iki argümanı olduğu için B kaydının genişletildiğini ve daha fazla bit atandığını ifade eder.
[Satır 2] SETGLOBAL, “SETGLOBAL A Bx --- Gbl[Kst(Bx)] := R(A)” biçimindedir. Bu komut, ikinci argümanın yuvasındaki sabit tablosunda verilen anahtarı kullanarak global tabloya bir değer atar. İkinci argüman 0 olduğundan ve 0 konumundaki sabit tablosunun değeri “x” olduğundan, “x” anahtarını kullanarak global tabloya bir şey yazar. İlk argümanın belirttiği yuvadaki kayıt tablosunda ne varsa o yazılır; önceki komut bu yuvaya 10 değerini yüklemişti.
Biraz daha karmaşık bir örneğe bakalım: “x = 10; y = x.” Kodun manuel olarak çalıştırılmasını okuyucuya bir alıştırma olarak bırakıyorum. :)
.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
İşlev çağrıları için bayt kodu
"foo(10):" için üretilen koda bakalım
.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 İşlev çağrılarını
yürütmek için, işlev ilk kayıt alanına, argümanlar ise sonraki kayıt alanlarına yüklenmelidir. "CALL A B C" ifadesinin anlamı şöyledir: A işlevi tutar, B argüman sayısıdır (aslında, "..." ifadesinin uygulanma şekli nedeniyle argüman sayısı +1'dir) ve C dönüş değeri sayısıdır (yine, birden fazla dönüş değerini işlemek için dönüş değeri sayısı +1'dir).
İlk iki satırı biliyoruz; bunlar, kayıt tablosu yuvası 0'a bir değer ve kayıt tablosu yuvası 1'e 10 değerini yükler. Üçüncü satır, A kayıtındaki değeri ( “foo” ile yüklenen kayıt tablosu yuvası 0) kullanarak işlev çağrısını gerçekleştirir; burada B argüman sayısını, C ise dönüş değerlerinin sayısını belirtir (hatırlayın, hem B hem de C değerlerine 1 eklenmelidir). İşlev çağrılmadan önce, VM ayrıca R(A)’daki değerin gerçekten çağrılabilir olup olmadığını da doğrular.
Lua, kullanıcıların mevcut bir tabloya bir metatablo ilişkilendirerek tabloların işlevselliğini genişletmelerine olanak tanıyan bir mekanizmaya sahiptir. Metatablo, ana tabloda belirli bir yöntem veya işlem gerçekleştirilemediğinde çağrılan yedek yöntemler içerir (ayrıntılı açıklama için bkz. https://www.lua.org/pil/13.html).
Amacımız açısından, metatablodaki en ilgili girdiler “__index” ve “__call” alanlarıdır. __index, bir tablodaki bir öğeyi ararken kullanılır; bu nedenle “local x = my_table[10]” kodu önce my_table üzerinde __index yöntemini çağırır. Bu başarısız olursa, bunun yerine my_table’ın metatablosunda __index’i çağırmaya çalışır. __call da benzer şekilde, bir şeyi işlev olarak ele alıp onu çağırmaya çalıştığınızda kullanılır; örneğin “local x = foo(42),” gibi
Lua ve C++'ın birbiriyle çalışabilmesi için, işlevleri ve verileri paylaşabilmenin bir yoluna ihtiyaçları vardır. Lua, UserData adlı bir veri türü sağlayarak bunu kolaylaştırır. UserData nesneleri C++ ortamında oluşturulabilir ve bunlar yerel Lua veri türleri oldukları için, Lua kodunun onlarla sanki normal Lua nesneleriymiş gibi etkileşime girmesine izin veren metatablolarla donatılabilirler.
Üye işlev çağrıları
Tamam, biraz bytecode'a geri dönelim! Bir sonraki örnek biraz daha ilginç çünkü “foo:bar(10),” gibi bir kodunuz olduğunda ne olacağını gösteriyor; bu kod, foo örneğinde (Foo sınıfının bir örneği) bar yöntemini çağırıyor.
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 Buradaki
yeni şey, daha önce görmediğimiz self komutudur [satır 2]. Self'in sözdizimi “SELF A B C --- R(A) := R(B)[RK(C)]; R(A+1) := R(B)” şeklindedir, öyleyse bunu parçalara ayıralım. Kayıt tablosunda R(A) yuvasında, RK(C) yuvasındaki anahtarı kullanarak R(B) yuvasındaki tabloyu aramanın sonucunu yerleştirecektir. Ayrıca R(B) yuvasındaki her ne varsa R(A+1) yuvasına kopyalayacaktır, ancak bununla ilgili daha sonra daha fazla bilgi vereceğiz. C kaydının değerinin 257 olduğunu fark edebilirsiniz. Bu geçerlidir çünkü Lua, değeri aramak için RK(C) kullanır ve RK, 9. bitin değerine bağlı olarak ya kayıt tablosunu ya da sabit tablosunu kullanır. Eğer 1 ise, ki bu durumda öyledir, o zaman sabit tablosu kullanılır; aksi takdirde, arama kayıt tablosuna gider (en yüksek bit maskelenerek).
3. satır, 2. yuvaya 10 değerini yerleştirir ve son olarak 4. satır işlev çağrısını yürütür.
SELF komutu iki amaca hizmet eder. İlk olarak, Foo sınıfında "bar" yöntemini arar ve bunu R(A)'ya yerleştirir. İkinci olarak, foo bir örnek yöntemi olduğundan ve çağrı yaparken yöntemi çağırdığımız sınıfın örneğine ihtiyacımız olduğundan, bu örneği R(A+1)'e yerleştirir. Python'daki sınıflara aşina iseniz, bu kavramı tanıyabilirsiniz: yöntemler genellikle “def my_method(self, arg1, arg2..)” şeklinde yazılır; burada self, sınıf örneğidir.
Bunu biraz daha derinlemesine incelememiz ve foo örneğinin Lua'da UserData nesnesi olarak temsil edilen bir C++ nesnesi olduğu durumda ne olduğunu görmemiz gerekecek.
SELF çağrısı bir tablo araması olarak görülebilir, yani Foo[“bar”] (büyük harfli Foo, foo nesnesinin aksine Foo sınıfını temsil eder) ve aramaların __index yöntemini kullanacağını biliyoruz. foo örneği C++ ortamında oluşturulduğunda, örnekle bir meta tablo ilişkilendirildi ve meta tablonun __index alanı, __index çağrıldığında çağrılacak bir C++ kod parçasına ayarlandı.
C/C++, Lua'dan çağrıldığında, aktarılan tek veri bir lua_State nesnesidir. Bu nesne, o anda çalışan Lua iş parçacığıyla ilgili her şeyi içerir. Durum nesnesindeki en önemli bilgi, işlev argümanlarını içeren (lua_tointeger/tostring vb. işlev ailesi aracılığıyla erişilen) ve aynı zamanda Lua'ya değerleri geri döndürmek için de kullanılan Lua yığınıdır.
Pseudo-C++'da, __index işlevimiz şuna benzer:
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;
}
}
Birçok iç detay atlanmıştır, ancak işin özü şudur. Lua yığınında ilk argüman olarak geçirilen UserData nesnesi göz önüne alındığında, gerçek C++ sınıfını tanımlayan bir tanımlayıcı bulabiliriz ve bu tanımlayıcı aracılığıyla bu sınıfın verilen ada sahip bir yöntemi olup olmadığını görebiliriz. Eğer varsa, bir yöntem çağırıcıya işaret eden bir işlev işaretçisi Lua yığınına eklenir ve başarı döndürürüz.
Bu çağrıdan sonra, Lua VM kalan argümanları kayıt tablosuna yerleştirir ve ardından metaIndex yönteminden döndürdüğümüz işlevi çağırır; bu da tekrar C++'ı çağırır ve çağrı işlevine ulaşır:
int methodInvoker(lua_State* L)
{ <br> // 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->invokeFunction(instance, method, L);
}
methodInvoker da ClassDescriptor'ı kullanır, ancak bu sefer üye işlevini çağırabilir ve yığından doğru argümanları çıkarabilir.
Son düzlük!
Artık Lua'dan C++'a yapılan iki gidiş-dönüşü net bir şekilde görebildiğimize göre, bunu nasıl optimize edebileceğimizi bulmaya çalışabiliriz.
Nihai hedefimiz, Lua'dan C++'a tek bir işlev çağrısı yapmak ve yöntem araması ile çağrısını aynı anda gerçekleştirebilmek için ihtiyacımız olan tüm parçaları Lua yığınında bulundurmaktır. Sorun, bir kayıt eksikliğimiz olması gibi görünüyor. Birleştirilmiş arama/çağırıcı işlevimizi çağırdığımızda, Lua yığınının [self, yöntem adı, arg1, arg2, ...] gibi görünmesini istiyoruz, ancak SELF'e baktığımızda, ilk yuvasını yöntem işlevini aramanın sonucu için, ikinci yuvasını ise örneği depolamak için kullandığını görüyoruz.
__call metametodunun çalışma şeklini incelediğimizde önemli bir farkındalığa ulaştık. Bir nesne __call metametoduna sahipse, _call fonksiyonu çağrılmadan önce nesnenin kendisi yığına itilir ve tüm argümanlar yukarı kaydırılır. Bu işlevselliği kullanarak, “self”i bir kayıtta açıkça depolamaya gerek kalmadan yığına almanın bir yolu vardı.
İkinci kısım, yöntem adını da yığına almayı içeriyordu. Bunun için kurnaz davranıp SELF opkodunun işleyişini değiştirmemiz gerekiyordu.
Varsayılan durumda, SELF'in üye işlevi bulmaya çalışacağını ve bunu R(A) içindeki örnekle birlikte R(A+1) içinde depolayacağını hatırlayın. Sonunda aramayı tamamen atladık ve gerçek nesneyi R(A) içinde, yöntem adını ise R(A+1) içinde depoladık.
Şimdi R(A) içindeki nesnenin bir __call metametodu olduğundan emin olursak, sonunda self'i de yığına itmiş oluruz. Böylece, [self, yöntem adı, argümanlar…] gibi görünen bir yığına sahip olur ve C++'a tek bir çağrı yaparız. Mükemmel! Şey, neredeyse. :)
Bunu tamamlanmış saymadan önce, üzerinde son rötuşlar yapmak istedik. __call metayönteminin anlamını aşırı yüklemek istemedik, bu yüzden bunun yerine bu tür çağrılar için UserData nesnelerinde kullanılabilen __namecall adlı özel bir metayöntem ekledik. Ayrıca SELF opkodunu da, nesnenin bir __namecall metayöntemi varsa yeni anlamı kullanacak şekilde değiştirdik.
Yaptığımız ikinci şey, yeni yol ile eski yolun kodları kolayca paylaşabilmesini sağlamaktı. Yöntem adını ikinci argüman olarak kullanmak yerine, onu son argümana taşıdık. Böylece, yöntem işaretçisini aramak için kullanıldıktan sonra, yığından kolayca çıkarılabilirdi ve yığın, işlev eski yol üzerinden çağrılmış gibi görünürdü.
Sonuç
Bu optimizasyonun etkisi ne kadar? Programlamadaki çoğu şeyde olduğu gibi, cevap "duruma bağlı"dır. Ağır ve sıkça çağırmadığınız işlevler için çok fazla bir iyileşme görmeyeceksiniz. Ancak sıkça çağırdığınız daha küçük işlevler için tasarruf önemli ölçüde olabilir.
Geliştirici Forumu'ndaki kullanıcılar bu garip, yeni metayöntemin ortaya çıkışını hemen fark ettiler ve __namecall'ın hızını hem eski örnek yöntem çağırma yöntemiyle hem de geliştiricilerin yöntem çağırmayı optimize etmek için kullandıkları bir geçici çözümle karşılaştıran bir tablo sunuldu:
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)
> namecall 0.49229717254639
> index+call 0.78510332107544
> call 0.49960780143738
İlk döngü yeni __namecall kod yolunu kullanır, ancak tüm sihir arka planda gerçekleştiği için, geliştiricilerin optimizasyondan yararlanmak için mevcut kodda herhangi bir değişiklik yapmasına gerek yoktur.
İkinci döngü, örnek yöntem çağrısını yapmanın eski yolunu taklit eder; önce yöntemi bulmak için bir arama yapar, ardından onu çağırır.
Son olarak, üçüncü döngü, geliştiricilerin yaptığı yaygın bir optimizasyonu gösterir; burada yöntem önce aranır, yerel bir değişkende saklanır ve ardından değişken çağrılır.
Buradaki güzel yanı, __namecall optimizasyonu ile artık örnek işlevleri açıkça önbelleğe almanın gerekli olmadığını göstermesidir; çünkü bu, önbelleğe alınmış optimizasyon kadar hızlıdır, dolayısıyla en basit kod aynı zamanda en yüksek performansa sahip olacaktır.
Artık __namecall kullanıma sunuldu ve gördüğümüz sonuçlardan memnunuz. Şimdi dikkatimizi bellek kullanımına çevirip, bu alanda istemciyi iyileştirmek için neler yapabileceğimize bakmanın zamanı geldi!


