تحسين التوافق بين Lua و C++

مقدمة
تمت كتابة محرك Roblox بمزيج من لغتي C++ و Lua، حيث تمت كتابة الكود الذي يقوم بعمليات حسابية مكثفة بلغة C++ المُحسّنة، بينما تمت كتابة منطق اللعبة والنصوص البرمجية بلغة Lua، لتسهيل عملية التطوير. لكي يكون هذا النموذج فعالاً، يتطلب أن تكون الانتقالات بين Lua و C++ سريعة قدر الإمكان، حيث أن أي وقت يُقضى في هذه المنطقة المحايدة هو في الأساس مجرد ميلي ثوانٍ مهدرة.
على مدار الشهرين الماضيين، قمنا بطرح العديد من التحسينات على هذا الجزء من النظام. كان أحد الأجزاء على وجه التحديد — وهو الاستدعاء الفعلي لأساليب C++ من Lua — مثيرًا للاهتمام بشكل خاص، حيث أدى إلى تحسينات كبيرة في السرعة وتطلب البحث في أعماق Lua لفهم كيفية عمل الأمور في الخلفية.
انتهى بنا الأمر إلى تعديل Lua VM نفسها، ولكن قبل أن نصل إلى ذلك، نحتاج إلى إرساء بعض الأسس.
المترجمات، VM، والبايت كود
عندما يتم ترجمة كود Lua المصدري، يتم ترجمته إلى بايت كود Lua، والذي تقوم Lua VM بتشغيله بعد ذلك. يحتوي بايت كود Lua على حوالي 35 تعليمة إجمالاً، لأمور مثل قراءة/كتابة الجداول، واستدعاء الدوال، وإجراء العمليات الثنائية، والقفزات والشروط، وما إلى ذلك. يعتمد Lua VM على السجلات، على عكس العديد من أجهزة VM الأخرى التي تعتمد على المكدس، لذا فإن جزءًا مما يفعله المُترجم عند إنشاء البايت كود هو تحديد السجلات التي يجب أن تستخدمها كل تعليمة.
تتخذ كل تعليمة شكل "OP_CODE A B" أو "OP_CODE A B C"، حيث يمثل "OP_CODE" رمز العملية (على سبيل المثال، CALL لاستدعاء دالة) ويمثل A/B/C وسيطات رمز العملية. المعاملات (أو السجلات) ليست قيمًا فعلية. بل هي مؤشرات تشير إلى أحد جدولين: جدول الثوابت (يُكتب Kst(..)) أو جدول السجلات (يُكتب R(..)).
للحصول على وصف تفصيلي لبايت كود Lua، انظر "مقدمة بسيطة لتعليمات Lua 5.1 VM". الأمر أكثر إثارة مما يبدو؛ أعدك بذلك!
لإعطائك فكرة عن شكل بايت كود Lua، سنستعرض أولاً بعض البرامج البسيطة ثم ننتقل إلى بعض الأمثلة الأكثر صلة بالموضوع.
باستخدام الأداة المساعدة Chunkspy، يمكننا تفكيك بايت كود Lua إلى تجميع Lua والحصول على قائمة بالكود، بالإضافة إلى جدول الثوابت، حتى نتمكن بشكل أساسي من رؤية بايت الكود الذي يتم إنشاؤه لأي كود مصدر Lua معين.
أمثلة أساسية على البايت كود
يتم ترجمة برنامج بسيط مثل "x = 10" إلى:
.const "x"; 0
.const 10; 1
[1] loadk 0 1 ; 10
[2] setglobal 0 0 ; x يُظهر السطران الأولان جدول الثوابت (مع قيمة السلسلة "x" في الخانة 0 والقيمة الصحيحة 10 في الخانة 1)، والسطران التاليان هما رموز العمليات المفككة.
[السطر 1] عند البحث عن رمز التشغيل LOADK في "No Frills"، نرى أنه يأخذ الشكل "LOADK A Bx --- R(A) := Kst(Bx)". لذا، فإن LOADK له حجتان (السجلان A و B) وتتمثل عمليته في تعيين القيمة الموجودة في جدول الثوابت في الخانة المحددة بواسطة السجل الثاني، Kst(Bx)، إلى جدول السجلات في الخانة المحددة بواسطة الحجة الأولى، R(A). "Bx" يعني فقط أنه نظرًا لأن رمز العملية له حجتان فقط، يتم توسيع السجل B وتعيين المزيد من البتات له.
[السطر 2] SETGLOBAL له الشكل "SETGLOBAL A Bx --- Gbl[Kst(Bx)] := R(A)." وهي تخصص قيمة للجدول العام باستخدام المفتاح المحدد بواسطة جدول الثوابت في الخانة الخاصة بالحجة الثانية. ونظرًا لأن الحجة الثانية هي 0 وقيمة جدول الثوابت عند 0 هي “x”، فإنها تكتب شيئًا ما في الجدول العام باستخدام المفتاح “x.” وما يوجد في جدول السجلات في الخانة المحددة بالحجة الأولى هو ما يتم كتابته، وهو ما تم تحميله بالقيمة 10 في التعليمات السابقة.
لنلقِ نظرة على مثال أكثر تعقيدًا قليلاً، “x = 10; y = x.” سأترك تنفيذ الكود يدويًا كتمرين للقارئ. :)
.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
البايت كود لاستدعاءات الدالة
لنلقِ نظرة على الكود الذي تم إنشاؤه لـ "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
لتنفيذ استدعاءات الدالة، يجب تحميل الدالة في السجل الأول والحجج في السجلات التالية. دلالات "CALL A B C" هي أن A تحتوي على الدالة، و B هو عدد الحجج (في الواقع، هو عدد الحجج +1، بسبب طريقة تنفيذ "...")، و C هو عدد قيم الإرجاع (مرة أخرى، هو عدد قيم الإرجاع +1، للتعامل مع قيم الإرجاع المتعددة).
نحن على دراية بالسطرين الأولين؛ فهما يقومان بتحميل قيمة في الخانة 0 من جدول السجلات والقيمة 10 في الخانة 1 من جدول السجلات. السطر الثالث هو الذي يقوم باستدعاء الدالة، باستخدام القيمة الموجودة في السجل A (الخلية 0 من جدول السجلات، التي تم تحميلها بـ "foo")، مع تحديد B لعدد الوسيطات، و C لعدد قيم الإرجاع (تذكر أن كلاً من B و C يجب أن تضاف 1 إلى قيمتيهما). قبل أن يتم استدعاء الدالة، تتحقق الآلة الافتراضية أيضًا من أن القيمة الموجودة في R(A) قابلة للاستدعاء بالفعل.
تحتوي Lua على آلية تسمح للمستخدمين بتوسيع وظائف الجداول من خلال ربط جدول تعريف بجدول موجود. يحتوي جدول التعريف على طرق احتياطية يتم استدعاؤها إذا تعذر تنفيذ طريقة أو عملية معينة على الجدول الرئيسي (انظر https://www.lua.org/pil/13.html للحصول على وصف شامل).
لأغراضنا، فإن الإدخالات الأكثر صلة في الجدول الفوقي هي الحقول “__index” و “__call”. يتم استخدام __index عند البحث عن عنصر في جدول، لذا فإن الكود “local x = my_table[10]” سيستدعي أولاً طريقة __index على my_table. إذا فشل ذلك، فسيحاول بدلاً من ذلك استدعاء __index على الجدول الفوقي لـ my_table. يتم استخدام __call بشكل مشابه عند محاولة معاملة شيء ما كدالة واستدعائه "local x = foo(42)،" على سبيل المثال
لكي يتفاعل Lua و C++ معًا، يحتاجان إلى طريقة ما لتتمكن من مشاركة الدوال والبيانات. يسهل Lua ذلك من خلال توفير نوع بيانات يسمى UserData. يمكن إنشاء كائنات UserData في بيئة C++، ونظرًا لأنها أنواع بيانات أصلية في Lua، يمكن تزيينها بجداول تعريفية تسمح لرمز Lua بالتفاعل معها كما لو كانت مجرد كائنات Lua عادية.
استدعاءات وظائف الأعضاء
حسنًا، لنعد إلى النظر في بعض البايت كود! المثال التالي أكثر إثارة للاهتمام قليلاً لأنه يوضح ما يحدث عندما يكون لديك كود مثل "foo:bar(10)،" الذي يستدعي طريقة bar على المثيل foo (مثيل من فئة 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
الشيء الجديد هنا هو تعليمة self [السطر 2]، والتي لم نرها من قبل. تتميز self بصيغة "SELF A B C --- R(A) := R(B)[RK(C)]; R(A+1) := R(B)"، لذا دعونا نحللها. في جدول السجلات في الخانة R(A)، ستضع نتيجة البحث في الجدول في خانة السجل R(B) باستخدام المفتاح الموجود في الخانة RK(C). كما ستنسخ أي شيء كان موجودًا في الخانة R(B) إلى الخانة R(A+1)، ولكن سنناقش هذا لاحقًا. قد تلاحظ أن قيمة السجل C هي 257. هذا صحيح لأن Lua تستخدم RK(C) للبحث عن القيمة، وستستخدم RK إما جدول السجلات أو جدول الثوابت، اعتمادًا على قيمة البت التاسع. إذا كانت 1، كما هو الحال هنا، فسيتم استخدام جدول الثوابت؛ وإلا، فسيتم البحث في جدول السجلات (بعد إخفاء البت الأعلى).
يضع السطر 3 الرقم 10 في الخانة 2، وأخيرًا سيقوم السطر 4 بتنفيذ استدعاء الدالة.
تخدم تعليمات SELF غرضين. أولاً، تبحث عن طريقة "bar" في فئة Foo، وتضعها في R(A). ثانيًا، نظرًا لأن foo هي طريقة مثيل ونحتاج إلى مثيل الفئة التي نستدعي الطريقة عليها عند إجراء الاستدعاء، فإنها تضع هذا المثيل في R(A+1). إذا كنت على دراية بالفئات في Python، فقد تتعرف على المفهوم: عادةً ما تُكتب الطرق على النحو التالي: "def my_method(self, arg1, arg2..)،" حيث self هو مثيل الفئة.
سنحتاج إلى التعمق في هذا الأمر قليلاً وإلقاء نظرة على ما يحدث عندما تكون مثيل foo كائن C++، ممثلة في Lua ككائن UserData.
يمكن النظر إلى استدعاء SELF على أنه بحث في الجدول، أي Foo[“bar”] (حيث يمثل Foo بالخط العريض الفئة Foo، على عكس foo، المثيل)، ونحن نعلم أن عمليات البحث ستستخدم طريقة __index. عندما تم إنشاء مثيل foo في بيئة C++، تم ربط جدول تعريف بالمثيل، وتم تعيين حقل __index في جدول التعريف إلى جزء من كود C++ سيتم استدعاؤه عند استدعاء __index.
عندما يتم استدعاء C/C++ من Lua، فإن البيانات الوحيدة التي يتم تمريرها هي كائن lua_State. يحتوي هذا الكائن على كل ما يتعلق بخيط Lua قيد التشغيل حاليًا. أهم المعلومات في كائن الحالة هي مكدس Lua، الذي يحتوي على وسيطات الدالة (يتم الوصول إليها عبر مجموعة دوال lua_tointeger/tostring وما إلى ذلك) ويُستخدم أيضًا لإرجاع القيم إلى Lua.
في لغة C++ الزائفة، تبدو دالة __index الخاصة بنا كما يلي:
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;
}
} تم
تجاهل الكثير من التفاصيل الداخلية، ولكن هذا هو جوهر الأمر. بالنظر إلى أن كائن UserData تم تمريره كحجة أولى في مكدس Lua، يمكننا العثور على واصف يصف فئة C++ الفعلية، ومن خلال هذا الواصف يمكننا معرفة ما إذا كانت هذه الفئة تحتوي على طريقة بالاسم المحدد. إذا كان الأمر كذلك، يتم دفع مؤشر دالة إلى مستدعي الطريقة في مكدس Lua، ونقوم بإرجاع النجاح.
بعد هذا الاستدعاء، ستضع Lua VM بقية الحجج في جدول السجلات، ثم تستدعي الدالة التي أعدناها من طريقة metaIndex، والتي ستستدعي C++ مرة أخرى، وتصل إلى دالة المستدعي:
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 أيضًا ClassDescriptor، ولكن هذه المرة يمكنها استدعاء دالة العضو، وإخراج الحجج الصحيحة من المكدس.
المرحلة الأخيرة!
الآن بعد أن أصبح بإمكاننا رؤية الرحلتين ذهابًا وإيابًا من Lua إلى C++ بوضوح، يمكننا محاولة اكتشاف كيفية تحسين ذلك.
هدفنا النهائي هو إجراء استدعاء دالة واحدة من Lua إلى C++ والحصول على جميع العناصر التي نحتاجها في مكدس Lua لنتمكن من البحث عن الدالة واستدعائها في آن واحد. يبدو أن المشكلة تكمن في أننا نفتقر إلى سجل واحد. عندما نستدعي دالة البحث/الاستدعاء المدمجة، نريد أن تبدو مكدس Lua على النحو التالي: [self، اسم الدالة، arg1، arg2، ...]، ولكن بالنظر إلى SELF، نرى أنه يستخدم الخانة الأولى لنتيجة البحث عن دالة الطريقة، والخانة الثانية لتخزين المثيل.
توصلنا إلى إدراك مهم عندما نظرنا إلى طريقة عمل ميتا-الطريقة __call. إذا كان الكائن يحتوي على ميتا-الطريقة __call، فقبل استدعاء دالة _call، يتم دفع الكائن نفسه إلى المكدس ويتم إزاحة جميع الحجج لأعلى. من خلال الاستفادة من هذه الوظيفة، كانت هناك طريقة للحصول على "self" في المكدس دون الحاجة إلى تخزينها صراحةً في سجل.
تضمن الجزء الثاني وضع اسم الدالة في المكدس أيضًا. لهذا، كان علينا أن نكون ماكرين ونغير طريقة عمل رمز التشغيل SELF.
تذكر أنه في الحالة الافتراضية، سيحاول SELF العثور على دالة العضو وتخزينها في R(A) مع المثيل في R(A+1). انتهى بنا الأمر بتخطي عملية البحث تمامًا وتخزين الكائن الفعلي في R(A) واسم الدالة في R(A+1).
إذا تأكدنا الآن من أن الكائن في R(A) يحتوي على ميتا-طريقة __call، فسننتهي أيضًا بدفع self إلى المكدس. وبذلك، سيكون لدينا مكدس يبدو كالتالي [self، اسم الطريقة، الحجج...] وقمنا باستدعاء واحد فقط إلى C++. ممتاز! حسناً، تقريبًا. :)
قبل اعتبار هذا الأمر منتهياً، أردنا وضع بعض اللمسات النهائية عليه. لم نرغب في تحميل دلالات ميتا-طريقة __call، لذا أضفنا بدلاً من ذلك ميتا-طريقة محددة لهذا النوع من الاستدعاء — تسمى __namecall — كانت متاحة فقط على كائنات UserData. كما قمنا بتعديل رمز التشغيل SELF بحيث يستخدم الدلالات الجديدة فقط إذا كان الكائن يحتوي على ميتا-طريقة __namecall.
الشيء الثاني الذي قمنا به كان في الغالب جعل المسار الجديد والمسار القديم قادرين على مشاركة الكود بسهولة. بدلاً من جعل اسم الأسلوب هو الحجة الثانية، قمنا بدفعه إلى الحجة الأخيرة. لذلك، بعد استخدامه للبحث عن مؤشر الأسلوب، يمكن إزالته بسهولة من المكدس ويبدو المكدس كما لو أن الدالة قد تم استدعاؤها عبر المسار القديم.
الخلاصة
ما مدى تأثير هذا التحسين؟ حسنًا، مثل معظم الأمور في البرمجة، الإجابة هي "هذا يعتمد". بالنسبة للدوال الثقيلة — والتي لا تستدعيها كثيرًا — لن ترى تحسنًا كبيرًا. ولكن بالنسبة للدوال الأصغر التي تستدعيها كثيرًا، يمكن أن تكون الوفورات كبيرة.
لاحظ أعضاء منتدى المطورين بسرعة ظهور هذه الطريقة الفوقية الجديدة والغريبة، وتم تقديم جدول يقارن سرعة __namecall بكل من الطريقة القديمة لاستدعاء طرق المثيل والحل البديل الذي كان المطورون يستخدمونه لتحسين استدعاء الطرق:
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
تستخدم
الحلقة الأولى مسار كود __namecall الجديد، ولكن نظرًا لأن كل ما يحدث من سحر يتم خلف الكواليس، فلا داعي للمطورين لتغيير أي كود موجود للاستفادة من التحسين.
تحاكي الحلقة الثانية الطريقة القديمة لاستدعاء طريقة المثيل؛ حيث يتم أولاً البحث عن الطريقة ثم استدعاؤها.
وأخيرًا، تُظهر الحلقة الثالثة تحسينًا شائعًا كان المطورون يقومون به، حيث يتم البحث عن الطريقة أولاً، وتخزينها في متغير محلي، ثم استدعاء المتغير.
الشيء الجيد هنا هو أنه يوضح أنه مع تحسين __namecall، لم يعد من الضروري تخزين وظائف المثيل في ذاكرة التخزين المؤقت بشكل صريح، حيث إنها بنفس سرعة التحسين المخزن في ذاكرة التخزين المؤقت، لذا فإن الكود الأكثر بساطة سيكون أيضًا الأكثر أداءً.
الآن بعد أن تم نشر __namecall، ونحن راضون عن النتائج التي نراها، حان الوقت لتحويل تركيزنا إلى استخدام الذاكرة، ونرى ما يمكننا فعله لتحسين العميل في هذا المجال!


