루아 프로그래밍 최적화의 기본
Rule #1: Don’t do it.
Rule #2: Don’t do it yet. (for experts only)
Roberto Ierusalimschy는 루아 프로그래밍의 가장 기본적인 최적화 방법으로 처리량 줄이기를 강조하고 있다. 스크립트 언어의 특성상 코드의 양, 수행되는 처리량이 비대해 질수록 속도는 떨어질 수밖에 없다.
현재 개발 중인 프로젝트에서 사용되는 루아의 느린 퍼포먼스에 고민하다가 Lua Programming Gems를 발견하였다. 루아의 기본적인 테크닉부터 개발 방법론적인 내용까지 루아의 기본 문법에서 벗어나 조금 더 유연한 작업방식을 알려준다. Gems라는 이름에서 알 수 있듯이 자신의 필요한 부부만 골라서 확인하면 된다.
# 내용 요약
스크립트 언어인 루아를 트리거 시스템이나, 데이터 관리 및 가공, 이벤트 처리 등에 사용할 때 최적화는 반드시 필요하다. 특히 루아 스크립트를 사용하는 코드의 양이 비대해 질수록 최적화 여부에 따라 많게는 3~40% 까지 성능이 향상되지 않을까 한다.
최적화의 첫 번째로 지역변수를 최대한 활용하는 것이 있겠다. 루아는 함수를 호출할 때 매번 레지스터에 할당하여 사용되어서 부하가 발생할 수 있다. 속도를 높이기 위해서는 지역변수 local에 우선 함수를 할당하여 사용하면 할당횟수가 줄어들게 되어 처리속도가 증가하게 된다. for 처럼 루프 문 안에서 함수를 호출할 때에는 미리 local 변수로 해당 함수를 연결하여 사용하자.
아래는 Roberto Ierusalimschy의 Lua Performance Tip 중 Basic facts 를 번역한 내용이다. 실제 원문은 Lua Programming Gems(http://www.lua.org/gems/sample.pdf)에 수록되어 있다. Basic facts는 루아의 가장 흔한 사실 중 하나인 지역변수를 최대한 활용하여 최적화를 수행하는 방법에 대해서 설명한다.
Lua Performance Tip - 기본 사실
특정 코드를 수행하기 전, 루아는 소스 코드를 내부 포맷에 알맞게 해석(프리컴파일)한다. 이 포맷은 CPU에서 처리할 수 있도록 만들어진 기계어와 비슷한 가상기계(virtual machine)에 맞게 계속해서 명령을 내리게 된다. 실제로 루아 내부에서는 거대한 스위치 문을 반복하여 돌면서 각각의 명령어를 하나씩 C 코드로 해석한다.
루아 5.0 버전 이후부터는 레지스터 기반의 가상기계를 사용한다고 이미 어디에선가 읽어 봤을 것이다. 이 가상기계의 "레지스터"는 실제 CPU의 레지스터와 일치하지는 않는다. 왜냐하면, 루아의 레지스터와 CPU의 레지스터는 서로 호환되지 않으며 사용 가능한 레지스터의 개수도 상당히 제한적이기 때문이다. 또한, 루아에서는 레지스터를 제공하기 위해 스택(여러 개의 인덱스로 묶은 배열)을 사용한다. 루아에서 사용되는 함수들은 각각의 활성화된 레코드를 갖는다. 스택 조각처럼 함수를 레지스터에 저장하고 함수들은 각각 자신의 레지스터1를 갖게 된다. 함수들은 대략 250개 이상의 레지스터를 사용한다. 왜냐하면, 각각의 명령문은 레지스터를 참조하기 위해 8비트씩을 갖기 때문이다.
어마어마한 수의 레지스터를 제공함으로써 루아의 프리컴파일러는 모든 지역변수를 레지스터에 저장하게 된다. 그 결과 루아에서 지역 변수로의 접근은 상당히 빠르다. 예를 들어 만약 a와 b라는 지역변수가 있다고 가정하면, 루아에서 a를 정의할 때 a = a + b 가 하나의 단일 명령어로 해석된다.
ADD 0 0 1 (a와 b를 레지스터 안에서 각각 0 과 1로 가정했을 때).
반대로, 만약 a 와 b 둘 다 전부 전역변수라고 가정하면 추가적인 코드가 발생하게 된다.
GETGLOBAL 0 0 ; a
GETGLOBAL 1 1 ; b
ADD 0 0 1
SETGLOBAL 0 0 ; a
GETGLOBAL 1 1 ; b
ADD 0 0 1
SETGLOBAL 0 0 ; a
결론적으로 루아 프로그램의 성능향상을 위한 가장 중요한 규칙 중 하나를 정의하자: 지역 선언을 사용하자!
만약 프로그램의 현재 성능을 뛰어넘어 퍼포먼스를 높일 필요가 있다면 명확한 한 개(역자주: 전역변수)를 사용하는 것보다 지역변수를 몇몇 장소에서 사용하도록 한다. 만약, 긴 반복문 안에서 특정 함수를 호출하려 한다면 해당 함수를 지역변수에 할당하자.
예를들면 아래와 같다.
for i = 1, 1000000 do local x = math.sin(i) end
아래 코드는 위의 코드보다 30% 정도 빠르다.
local sin = math.sin for i = 1, 1000000 do local x = sin(i) end
함수 밖의 지역변수 접근은 함수 내부의 지역변수 접근만큼 빠르지는 않지만, 전역변수에 접근하는 것보다는 여전히 빠르다. 다음 구문을 고려해 보자.
function foo (x) for i = 1, 1000000 do x = x + math.sin(i) end return x end print(foo(10))
foo 함수 밖에 sin 변수를 하나 선언하여 최적화를 할 수 있다.
local sin = math.sin function foo (x) for i = 1, 1000000 do x = x + sin(i) end return x end print(foo(10))
이 두 번째 코드는 첫 번째 코드보다 30% 정도 빠르게 돌아간다.
루아의 컴파일러는 다른 언어의 컴파일러와 비교해서 꽤 효과적인 반면, 결과물은 무겁게 작동하게 된다. 될 수 있으면 반드시 프로그램에서의 코드 컴파일은 피해야 한다.(예를 들어 loadstring 함수, 역자주: C 함수를 호출하는 바인딩 함수 등) 실제로 동적인 코드를 꼭 실행해야 할 때를 제외하고는 최종 사용자에 의해 돌아가게 되는 코드는 동적인 코드가 컴파일되도록 하지 말아야 한다.
예를 들어, 1부터 100,000까지의 상수 값을 반환하는 함수와 테이블을 생성하는 코드를 살펴보자.
local lim = 10000
local a = {}
for i = 1, lim do
a[i] = loadstring(string.format("return %d", i))
end
print(a[10]()) --> 10
이 코드를 수행하는 데 1.4초가 걸린다.
마지막에서 동적인 컴파일에서 벗어나서 아래 코드는 위와 같이 100,000 개의 함수를 생성한다. 기존보다 1/10초(0.14초)를 단축하게 된다.
function fk (k)
return function () return k end
end
local lim = 100000
local a = {}
for i = 1, lim do a[i] = fk(i) end
print(a[10]()) --> 10



