लुआ/सी++ अंतरसंचालनीयता का अनुकूलन

परिचय
रॉब्लॉक्स इंजन C++ और Lua के संयोजन में लिखा गया है, जिसमें गणनात्मक रूप से गहन संचालन करने वाला कोड अनुकूलित C++ में लिखा गया है, जबकि गेम लॉजिक और स्क्रिप्ट्स विकास की आसानी के लिए Lua में लिखे गए हैं। इस मॉडल को प्रभावी बनाने के लिए, यह आवश्यक है कि Lua और C++ के बीच संक्रमण यथासंभव तेज़ हो, क्योंकि इस अनिश्चित क्षेत्र में बिताया गया कोई भी समय अनिवार्य रूप से बर्बाद हुई मिलीसेकंड ही है।
पिछले कुछ महीनों में, हम सिस्टम के इस हिस्से में विभिन्न सुधार लागू करते रहे हैं। एक हिस्सा विशेष रूप से—Lua से C++ मेथड्स का वास्तविक आह्वान—बहुत दिलचस्प था, क्योंकि इसने गति में काफी सुधार किया और यह समझने के लिए कि पर्दे के पीछे चीजें कैसे काम करती हैं, Lua के अंदरूनी हिस्सों में गहराई से देखने की आवश्यकता पड़ी।
अंततः हमने Lua VM में ही संशोधन किया, लेकिन उससे पहले हमें कुछ आधार तैयार करने की ज़रूरत है।
कंपाइलर, वीएम, और बाइटकोड
जब लुआ स्रोत कोड संकलित होता है, तो यह लुआ बाइटकोड में संकलित होता है, जिसे लुआ वीएम तब चलाता है। लुआ बाइटकोड में कुल मिलाकर लगभग 35 निर्देश होते हैं, जो तालिकाओं को पढ़ने/लिखने, फ़ंक्शन कॉल करने, द्विआधारी संचालन करने, जंप और कंडीशनल आदि जैसी चीज़ों के लिए होते हैं। Lua VM कई अन्य VMs की तरह स्टैक-आधारित होने के बजाय रजिस्टर-आधारित है, इसलिए जब कंपाइलर बाइटकोड उत्पन्न करता है तो वह यह निर्धारित करता है कि प्रत्येक निर्देश को किन रजिस्टरों का उपयोग करना चाहिए।
प्रत्येक निर्देश का रूप "OP_CODE A B," या "OP_CODE A B C," होता है, जहाँ "OP_CODE" ऑपकोड है (उदाहरण के लिए, किसी फ़ंक्शन को कॉल करने के लिए CALL) और A/B/C ऑपकोड के तर्क हैं। तर्क (या रजिस्टर) वास्तविक मान नहीं होते हैं। इसके बजाय, वे सूचकांक होते हैं जो दो तालिकाओं में से किसी एक की ओर इशारा करते हैं: स्थिर तालिका (Kst(..)) या रजिस्टर तालिका (R(..))।
Lua बाइटकोड का विस्तृत विवरण जानने के लिए, "A No-Frills introduction to Lua 5.1 VM Instructions" देखें। यह सुनने में जितना रोमांचक लगता है, उससे कहीं ज़्यादा रोमांचक है; मैं वादा करता हूँ!
आपको यह एहसास दिलाने के लिए कि लुआ बाइटकोड कैसा दिखता है, हम पहले कुछ सरल प्रोग्राम देखेंगे और फिर कुछ अधिक प्रासंगिक उदाहरणों की ओर बढ़ेंगे।
Chunkspy यूटिलिटी का उपयोग करके, हम Lua बाइटकोड को Lua असेंबली में डिसअसेंबल कर सकते हैं और कोड की एक लिस्टिंग, साथ ही कॉन्स्टेंट टेबल भी प्राप्त कर सकते हैं, ताकि हम मूल रूप से यह देख सकें कि किसी भी दिए गए Lua स्रोत कोड के लिए कौन सा बाइटकोड जेनरेट होता है।
बेसिक बाइटकोड उदाहरण
"x = 10" जैसा एक सरल प्रोग्राम संकलित होकर बनता है:
.const "x"; 0
.const 10; 1
[1] loadk 0 1 ; 10
[2] setglobal 0 0 ; x पहली दो पंक्तियाँ स्थिर तालिका (कॉन्स्टेंट टेबल) दिखाती हैं (स्लॉट 0 में स्ट्रिंग मान "x" और स्लॉट 1 में पूर्णांक मान 10 के साथ), और निम्नलिखित दो पंक्तियाँ डिसअसेंबल किए गए ऑपकोड हैं।
[लाइन 1] "नो फ्रिल्स" में LOADK ओपकोड को देखें तो, हम पाते हैं कि इसका रूप "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 तर्कों (arguments) की संख्या है (वास्तव में, यह तर्कों की संख्या +1 है, क्योंकि "..." को इस तरह लागू किया गया है), और C रिटर्न मानों (return values) की संख्या है (फिर से, यह रिटर्न मानों की संख्या +1 है, ताकि कई रिटर्न मानों को संभाला जा सके)।
हम पहले दो पंक्तियों से परिचित हैं; वे रजिस्टर टेबल स्लॉट 0 में एक मान और रजिस्टर टेबल स्लॉट 1 में 10 का मान लोड करती हैं। तीसरी पंक्ति ही फ़ंक्शन कॉल को निष्पादित करती है, जिसमें रजिस्टर A (रजिस्टर टेबल स्लॉट 0, जिसमें "foo" लोड किया गया था) में मौजूद मान का उपयोग किया जाता है, जहाँ B तर्कों (arguments) की संख्या को निर्दिष्ट करता है, और C रिटर्न मानों (return values) की संख्या को (फिर से, B और C दोनों के मानों में 1 जोड़ा जाना चाहिए)। फ़ंक्शन को कॉल करने से पहले, VM यह भी सत्यापित करता है कि R(A) में मौजूद मान वास्तव में कॉल करने योग्य (callable) है।
Lua में एक तंत्र है जो उपयोगकर्ताओं को किसी मौजूदा टेबल के साथ एक मेटाटेबल को जोड़कर टेबलों की कार्यक्षमता का विस्तार करने की अनुमति देता है। मेटाटेबल में फॉलबैक मेथड्स होते हैं जिन्हें तब कॉल किया जाता है जब मुख्य टेबल पर कोई निश्चित मेथड या ऑपरेशन नहीं किया जा सकता (एक विस्तृत विवरण के लिए https://www.lua.org/pil/13.html देखें)।
हमारे उद्देश्यों के लिए, मेटाटेबल में सबसे प्रासंगिक प्रविष्टियाँ "__index" और "__call" फ़ील्ड हैं। __index का उपयोग किसी तालिका में किसी तत्व को खोजने के लिए किया जाता है, इसलिए कोड "local x = my_table[10]" पहले my_table पर __index मेथड को कॉल करेगा। यदि वह विफल हो जाता है, तो इसके बजाय यह my_table की मेटाटेबल पर __index को कॉल करने का प्रयास करेगा। __call का उपयोग इसी तरह तब किया जाता है जब आप किसी चीज़ को एक फ़ंक्शन के रूप में मानने और उसे कॉल करने का प्रयास करते हैं, उदाहरण के लिए "local x = foo(42),"।
Lua और C++ को एक-दूसरे के साथ काम करने के लिए, उन्हें फ़ंक्शन और डेटा साझा करने का कोई तरीका चाहिए। Lua यूज़रडेटा (UserData) नामक एक डेटा प्रकार प्रदान करके इसे सुगम बनाता है। यूज़रडेटा ऑब्जेक्ट्स को C++ में बनाया जा सकता है, और चूंकि वे मूल Lua डेटा प्रकार हैं, उन्हें मेटाटेबल से सजाया जा सकता है जो Lua कोड को उनके साथ वैसे ही इंटरैक्ट करने की अनुमति देता है जैसे वे सामान्य Lua ऑब्जेक्ट्स हों।
सदस्य फ़ंक्शन कॉल
ठीक है, अब कुछ बाइटकोड देखने पर वापस चलते हैं! यह अगला उदाहरण थोड़ा और दिलचस्प है क्योंकि यह दिखाता है कि तब क्या होता है जब आपके पास "foo:bar(10)" जैसा कोड होता है, जो foo इंस्टेंस (क्लास Foo का एक इंस्टेंस) पर bar मेथड को कॉल कर रहा है।
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) में, यह स्लॉट RK(C) में मौजूद कुंजी का उपयोग करके टेबल में खोजने के परिणाम को रजिस्टर स्लॉट R(B) में रखेगा। यह स्लॉट R(B) में जो कुछ भी था उसे स्लॉट R(A+1) में भी कॉपी करेगा, लेकिन इस पर बाद में और बात करेंगे। आप ध्यान देंगे कि C रजिस्टर का मान 257 है। यह मान्य है क्योंकि लुआ मान को खोजने के लिए RK(C) का उपयोग कर रहा है, और RK 9वें बिट के मान के आधार पर या तो रजिस्टर तालिका या स्थिरांक तालिका का उपयोग करेगा। यदि यह 1 है, जो इस मामले में है, तो स्थिरांक तालिका का उपयोग किया जाता है; अन्यथा, खोज सबसे ऊँचे बिट को मास्क करने के बाद रजिस्टर तालिका में जाती है।
लाइन 3 स्लॉट 2 में 10 रखती है, और अंत में लाइन 4 फ़ंक्शन कॉल को निष्पादित करेगी।
SELF निर्देश के दो उद्देश्य हैं। पहला, यह Foo क्लास पर "bar" मेथड को खोजता है, और इसे R(A) में रखता है। दूसरा, क्योंकि foo एक इंस्टेंस मेथड है और हमें कॉल करते समय उस क्लास का इंस्टेंस चाहिए जिस पर हम मेथड को कॉल कर रहे हैं, यह इस इंस्टेंस को R(A+1) में रखता है। यदि आप पाइथन में क्लास से परिचित हैं, तो आप इस अवधारणा को पहचान सकते हैं: मेथड आमतौर पर "def my_method(self, arg1, arg2..)" के रूप में लिखे जाते हैं, जहाँ self क्लास का इंस्टेंस है।
हमें इस पर थोड़ा और गहराई से विचार करने की आवश्यकता होगी और यह देखने की ज़रूरत होगी कि क्या होता है जब foo इंस्टेंस एक C++ ऑब्जेक्ट होता है, जिसे Lua में एक UserData ऑब्जेक्ट के रूप में दर्शाया जाता है।
SELF कॉल को एक टेबल लुकअप के रूप में देखा जा सकता है, यानी Foo["bar"] (बड़ी Foo, instance foo के विपरीत, Foo क्लास का प्रतिनिधित्व करती है), और हम जानते हैं कि लुकअप __index मेथड का उपयोग करेंगे। जब C++ में foo इंस्टेंस बनाया गया था, तो एक मेटाटेबल को इंस्टेंस से जोड़ा गया था, और मेटाटेबल के __index फ़ील्ड को C++ कोड के एक टुकड़े पर सेट किया गया था जिसे __index को कॉल किए जाने पर कॉल किया जाएगा।
जब Lua से C/C++ को कॉल किया जाता है, तो केवल एक lua_State ऑब्जेक्ट ही पास किया जाता है। इस ऑब्जेक्ट में वर्तमान में चल रहे Lua थ्रेड से संबंधित सब कुछ होता है। state ऑब्जेक्ट में सबसे महत्वपूर्ण जानकारी 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;
}
}
कई आंतरिक बातें सरसरी तौर पर बताई गई हैं, लेकिन इसका सार कुछ ऐसा है। Lua स्टैक पर पहले तर्क के रूप में पास किए गए UserData ऑब्जेक्ट को देखते हुए, हम एक डिस्क्रिप्टर ढूंढ पाते हैं जो वास्तविक C++ क्लास का वर्णन करता है, और डिस्क्रिप्टर के माध्यम से हम देख सकते हैं कि क्या इस क्लास में दिए गए नाम का कोई मेथड है। यदि ऐसा है, तो मेथड इन्वोकर्स को एक फंक्शन पॉइंटर Lua स्टैक पर धकेल दिया जाता है, और हम सफलता लौटाते हैं।
इस कॉल के बाद, Lua VM शेष आर्गुमेंट्स को रजिस्टर टेबल में रखेगा, और फिर मेटाइंडेक्स मेथड से हमारे द्वारा लौटाए गए फ़ंक्शन को कॉल करेगा, जो फिर से 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++ तक दो राउंड ट्रिप को स्पष्ट रूप से देख सकते हैं, तो हम इसे अनुकूलित (optimize) करने का तरीका समझने की कोशिश कर सकते हैं।
हमारा अंतिम लक्ष्य Lua से C++ में एक ही फ़ंक्शन कॉल करना है और Lua स्टैक पर हमारे पास उन सभी चीज़ों का होना चाहिए जिनकी हमें एक साथ मेथड लुकअप और इनवोकेशन करने के लिए आवश्यकता है। समस्या यह लगती है कि हमारे पास एक रजिस्टर कम है। जब हम अपने संयुक्त लुकअप/इंवाकर फ़ंक्शन को कॉल करते हैं, तो हम चाहते हैं कि लुआ स्टैक [self, method name, arg1, arg2, ...] जैसा दिखे, लेकिन SELF को देखने पर, हम देखते हैं कि यह मेथड फ़ंक्शन को खोजने के परिणाम के लिए अपना पहला स्लॉट और इंस्टेंस को संग्रहीत करने के लिए दूसरा स्लॉट उपयोग करता है।
एक महत्वपूर्ण एहसास तब हुआ जब हमने __call मेटा-मेथड के काम करने के तरीके को देखा। यदि किसी ऑब्जेक्ट में __call मेटा-मेथड होता है, तो _call फ़ंक्शन के निष्पादित होने से पहले, ऑब्जेक्ट को स्वयं स्टैक पर धकेल दिया जाता है और सभी तर्क ऊपर सरका दिए जाते हैं। इस कार्यक्षमता का लाभ उठाकर, "self" को एक रजिस्टर में स्पष्ट रूप से संग्रहीत किए बिना स्टैक पर लाने का एक तरीका था।
दूसरे भाग में मेथड का नाम भी स्टैक पर लाना शामिल था। इसके लिए, हमें चतुराई से काम लेना पड़ा और SELF ऑपकोड के काम करने के तरीके को बदलना पड़ा।
याद रखें कि डिफ़ॉल्ट मामले में, SELF सदस्य फ़ंक्शन को खोजने की कोशिश करेगा और इसे R(A) में इंस्टेंस के साथ संग्रहीत करेगा। हमने लुकअप को पूरी तरह से छोड़ दिया और वास्तविक ऑब्जेक्ट को R(A) में और मेथड के नाम को R(A+1) में संग्रहीत किया।
यदि अब हम यह सुनिश्चित कर लें कि R(A) में मौजूद ऑब्जेक्ट में एक __call मेटा-मेथड है, तो हम स्टैक पर self को भी पुश कर देंगे। तो, हमारे पास एक ऐसा स्टैक होगा जो [self, मेथड का नाम, आर्ग्स…] जैसा दिखेगा और C++ में सिर्फ एक ही कॉल करेगा। एकदम सही! खैर, लगभग। :)
इसे पूरा मानने से पहले, हम इसमें कुछ अंतिम सुधार करना चाहते थे। हम __call मेटा-मेथड के सिंटैक्स को ओवरलोड नहीं करना चाहते थे, इसलिए इसके बजाय हमने इस प्रकार के इनवोकेशन के लिए एक विशिष्ट मेटा-मेथड जोड़ा—जिसे __namecall कहा जाता है—जो केवल यूज़रडाटा ऑब्जेक्ट्स पर ही उपलब्ध था। हमने 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 को लागू कर दिया गया है, और हम जो परिणाम देख रहे हैं उनसे हम खुश हैं, तो अब ध्यान मेमोरी उपयोग की ओर मोड़ने और यह देखने का समय है कि हम उस क्षेत्र में क्लाइंट को बेहतर बनाने के लिए क्या कर सकते हैं!


