Lua/C++ 상호 운용성 최적화

소개
Roblox 엔진은 C++과 Lua의 조합으로 작성되었으며, 연산 집약적인 작업을 수행하는 코드는 최적화된 C++로 작성된 반면, 게임 로직과 스크립트는 개발 편의성을 위해 Lua로 작성되었습니다. 이 모델이 효과적으로 작동하려면 Lua와 C++ 간의 전환이 가능한 한 빨라야 합니다. 이 '중간 지대'에서 소요되는 시간은 본질적으로 낭비되는 밀리초이기 때문입니다.
지난 몇 달간, 우리는 시스템의 이 부분에 대한 다양한 개선 사항을 적용해 왔습니다. 그중에서도 특히 Lua에서 C++ 메서드를 실제로 호출하는 부분은 상당한 속도 향상을 가져왔고, 내부 작동 원리를 이해하기 위해 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 바이트코드에 대한 자세한 설명은 “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 처음 두 줄은 상수 테이블을 보여주며(슬롯 0에는 문자열 값 “x”, 슬롯 1에는 정수 값 10이 들어 있음), 그 다음 두 줄은 디스어셈블된 오퍼코드입니다.
[1행] “No Frills”에서 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는 인자 개수(실제로는 “...”의 구현 방식 때문에 인자 개수 +1입니다), C는 반환 값 개수(여러 반환 값을 처리하기 위해 반환 값 개수 +1입니다)를 나타냅니다.
우리는 처음 두 줄에 익숙합니다. 이 줄들은 레지스터 테이블 슬롯 0에 값을, 레지스터 테이블 슬롯 1에 값 10을 로드합니다. 세 번째 줄은 레지스터 A(‘foo’가 로드된 레지스터 테이블 슬롯 0)의 값을 사용하여 함수 호출을 수행하는 부분입니다. 여기서 B는 인자 수를, C는 반환 값의 수를 지정합니다(B와 C 값 모두 1을 더해야 한다는 점을 기억하세요). 함수를 호출하기 전에 VM은 R(A)의 값이 실제로 호출 가능한지 확인합니다.
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라는 데이터 유형을 제공하여 이를 용이하게 합니다. 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이라는 것을 눈치채셨을 것입니다. 이는 Lua가 RK(C)를 사용하여 값을 조회하고, RK는 9번째 비트의 값에 따라 레지스터 테이블이나 상수 테이블 중 하나를 사용하기 때문에 유효합니다. 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는 인스턴스인 foo와 달리 Foo 클래스를 나타냅니다). 그리고 조회는 __index 메서드를 사용한다는 것을 알고 있습니다. C++ 환경에서 foo 인스턴스가 생성될 때, 메타테이블이 해당 인스턴스와 연결되었으며, 이 메타테이블의 __index 필드는 __index가 호출될 때 실행될 C++ 코드로 설정되어 있었습니다.
Lua에서 C/C++이 호출될 때 전달되는 유일한 데이터는 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;
}
}
많은 내부 구현은 생략했지만, 핵심은 다음과 같습니다. Lua 스택의 첫 번째 인수로 전달된 UserData 객체를 통해 실제 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를 사용하지만, 이번에는 멤버 함수를 호출하고 스택에서 올바른 인수를 팝(pop)할 수 있습니다.
이제 막바지입니다!
이제 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 메타메서드의 의미를 과부하시키고 싶지 않았기 때문에, 대신 UserData 객체에서만 사용할 수 있는 이 유형의 호출을 위한 특정 메타메서드(__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이 적용되었고, 그 결과에 만족하고 있으므로, 이제 메모리 사용량에 초점을 맞춰 해당 영역에서 클라이언트를 개선할 수 있는 방법을 모색해 볼 때입니다!


