การเพิ่มประสิทธิภาพการทำงานร่วมกันระหว่าง 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" คือรหัสปฏิบัติการ (opcode) (เช่น CALL สำหรับการเรียกฟังก์ชัน) และ A/B/C คืออาร์กิวเมนต์ของรหัสปฏิบัติการ อาร์กิวเมนต์ (หรือรีจิสเตอร์) ไม่ใช่ค่าที่แท้จริง แต่เป็นดัชนีที่ชี้ไปยังหนึ่งในสองตาราง: ตารางค่าคงที่ (เขียนเป็น Kst(..)) หรือตารางรีจิสเตอร์ (เขียนเป็น R(..))
สำหรับคำอธิบายโดยละเอียดเกี่ยวกับไบต์โค้ดของ Lua โปรดดูที่ "A No-Frills introduction to Lua 5.1 VM Instructions" มันน่าตื่นเต้นกว่าที่ฟังมาก ผมรับรอง!
เพื่อให้คุณรู้สึกถึงลักษณะของโค้ดไบต์ของ 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 ให้กับค่าของพวกมัน) ก่อนที่ฟังก์ชันจะถูกเรียก VM ยังตรวจสอบด้วยว่าค่าใน 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 จึงสามารถตกแต่งด้วยเมตาเทเบิล (metatable) ซึ่งช่วยให้โค้ด 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
สิ่งใหม่ที่นี่คือการสอนตัวเอง [บรรทัดที่ 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 จะใช้ตารางรีจิสเตอร์หรือตารางค่าคงที่ ขึ้นอยู่กับค่าของบิตที่ 9:th หากเป็น 1 ซึ่งในกรณีนี้คือ 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 stack ซึ่งประกอบด้วยอาร์กิวเมนต์ของฟังก์ชัน (เข้าถึงผ่านฟังก์ชันตระกูล 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 เราสามารถค้นหาตัวอธิบาย (descriptor) ที่อธิบายคลาส C++ จริงได้ และผ่านตัวอธิบายนี้ เราสามารถตรวจสอบได้ว่าคลาสนี้มีเมธอดที่มีชื่อตามที่กำหนดหรือไม่ หากมี ฟังก์ชันพอยน์เตอร์ที่ชี้ไปยังตัวเรียกเมธอดจะถูกผลักลงบนสแต็กของ Lua และเราจะส่งค่า success กลับไป
หลังจากการเรียกนี้ Lua VM จะวางอาร์กิวเมนต์ที่เหลือในตารางรีจิสเตอร์ จากนั้นจะเรียกฟังก์ชันที่เราส่งคืนจากเมธอด metaIndex ซึ่งจะเรียกเข้าสู่ C++ อีกครั้ง และเข้าสู่ฟังก์ชัน invoker:
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);
}
เมธอดอินวอกเกอร์ยังใช้คลาสเดสคริปเตอร์ด้วย แต่คราวนี้มันสามารถเรียกใช้เมธอดของสมาชิกได้ และดึงอาร์กิวเมนต์ที่ถูกต้องออกจากสแต็ก
ใกล้ถึงเส้นชัยแล้ว!
ตอนนี้ที่เราสามารถเห็นการเดินทางไปกลับสองครั้งจาก Lua ไปยัง C++ ได้อย่างชัดเจนแล้ว เราสามารถลองคิดหาวิธีที่จะปรับปรุงประสิทธิภาพนี้ได้
เป้าหมายสุดท้ายของเราคือการทำการเรียกฟังก์ชันครั้งเดียวจาก Lua ไปยัง C++ และให้ทุกชิ้นส่วนที่เราต้องการอยู่บนสแต็กของ Lua เพื่อให้สามารถทำการค้นหาและเรียกใช้เมธอดได้ในครั้งเดียว ปัญหาดูเหมือนว่าเรามีรีจิสเตอร์น้อยไปหนึ่งตัว เมื่อเราเรียกฟังก์ชันค้นหา/เรียกใช้งานที่รวมกัน เราต้องการให้สแต็กของ Lua มีลักษณะเป็น [self, ชื่อเมธอด, arg1, arg2, ...] แต่เมื่อดูที่ SELF เราจะเห็นว่ามันใช้ช่องแรกสำหรับผลลัพธ์จากการค้นหาฟังก์ชันเมธอด และช่องที่สองสำหรับเก็บอินสแตนซ์
การตระหนักรู้ที่สำคัญเกิดขึ้นเมื่อเราดูวิธีการทำงานของ __call metamethod หากวัตถุมี __call metamethod ก่อนที่ฟังก์ชัน _call จะถูกเรียกใช้ วัตถุนั้นจะถูกผลักลงบนสแตกและอาร์กิวเมนต์ทั้งหมดจะถูกเลื่อนขึ้น ด้วยการอาศัยฟังก์ชันนี้ มีวิธีที่จะนำ "self" ลงบนสแตกโดยไม่ต้องเก็บไว้ในรีจิสเตอร์อย่างชัดเจน
ส่วนที่สองเกี่ยวข้องกับการนำชื่อเมธอดขึ้นไปอยู่บนสแตกด้วย สำหรับเรื่องนี้ เราจำเป็นต้องใช้กลเม็ดเล็กน้อยและปรับเปลี่ยนการทำงานของอ็อพโค้ด SELF
โปรดจำไว้ว่าในกรณีเริ่มต้น SELF จะพยายามค้นหาฟังก์ชันสมาชิกและเก็บไว้ใน R(A) พร้อมกับอินสแตนซ์ใน R(A+1) เราได้ข้ามการค้นหาไปเลยและเก็บวัตถุจริงไว้ใน R(A) และเก็บชื่อเมธอดไว้ใน R(A+1)
หากเราทำให้แน่ใจว่าวัตถุใน R(A) มี __call metamethod เราก็จะจบลงด้วยการใส่ self ลงในสแต็กด้วย ดังนั้นเราจะมีสแต็กที่ดูเหมือน [self, ชื่อเมธอด, อาร์กิวเมนต์…] และทำการเรียกไปยัง C++ เพียงครั้งเดียว สมบูรณ์แบบ! เกือบจะนะ :)
ก่อนที่จะถือว่าเสร็จสมบูรณ์ เราต้องการปรับแต่งเพิ่มเติมเล็กน้อย เราไม่ต้องการให้ semantics ของ __call metamethod มากเกินไป ดังนั้นเราจึงเพิ่ม metamethod เฉพาะสำหรับการเรียกใช้งานประเภทนี้—เรียกว่า __namecall—ซึ่งจะใช้ได้เฉพาะกับวัตถุ UserData เท่านั้น นอกจากนี้เรายังได้ปรับเปลี่ยน SELF opcode เพื่อให้ใช้ semantics ใหม่เฉพาะเมื่อวัตถุมี __namecall metamethod เท่านั้น
สิ่งที่สองที่เราทำคือทำให้เส้นทางใหม่และเส้นทางเก่าสามารถแชร์โค้ดได้อย่างง่ายดาย แทนที่จะใช้ชื่อเมธอดเป็นอาร์กิวเมนต์ตัวที่สอง เราดันมันไปเป็นอาร์กิวเมนต์ตัวสุดท้าย ดังนั้น หลังจากที่ใช้เพื่อค้นหาตัวชี้เมธอดแล้ว มันสามารถถูกดึงออกจากสแต็กได้อย่างง่ายดาย และสแต็กจะดูเหมือนกับว่าฟังก์ชันถูกเรียกผ่านเส้นทางเก่า
สรุป
การปรับให้เหมาะสมนี้มีผลกระทบมากน้อยเพียงใด? อย่างที่มักจะเป็นกับสิ่งต่าง ๆ ในการเขียนโปรแกรม คำตอบคือ "ขึ้นอยู่กับ" สำหรับฟังก์ชันที่มีน้ำหนักมาก—และคุณไม่ได้เรียกใช้บ่อย—คุณจะไม่เห็นการปรับปรุงที่ชัดเจนมากนัก แต่สำหรับฟังก์ชันขนาดเล็กที่คุณเรียกใช้บ่อย การประหยัดอาจมีความสำคัญอย่างมาก
ผู้คนในฟอรัมสำหรับนักพัฒนาได้สังเกตเห็นการปรากฏตัวของเมตาเมธอดแปลกใหม่นี้อย่างรวดเร็ว และมีการนำเสนอตารางเปรียบเทียบความเร็วของ __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 ได้ถูกนำไปใช้งานแล้ว และเราพอใจกับผลลัพธ์ที่เห็น ถึงเวลาที่จะหันมาให้ความสำคัญกับการใช้หน่วยความจำ และดูว่าเราสามารถปรับปรุงไคลเอนต์ในด้านนี้ได้อย่างไร!


