2008년 7월 15일 화요일

SBCL 1.0.18 Win32 Binary Installer

SBCLCLISP과 함께 꽤 많은 분들이 사용하고 계시는 Open-source Common Lisp 구현체 중 하나입니다. 비교적 개발이 활발히 이루어지고 있고, 다양한 플랫폼용으로 포팅되어 있는 것이 특징입니다. 완벽하진 않지만 Windows용으로도 포팅이 되어 있습니다. 사용하는 데에 큰 지장이 없는 수준이죠.

한 가지 아쉬운 점이 있다면 새 버전의 소스가 발표된 이후 Windows용 binary가 가장 늦게 나온다는 것입니다. 그래서, SBCL Internals 페이지에 나와 있는 Windows binary build 방법을 보고 직접 Windows용 binary를 만들게 되었습니다.

제가 build 해둔 binary는 다음 링크에서 다운로드 하실 수 있습니다.

LINK: http://kaisyu.ohpy.com/99779/27
LINK: https://code.google.com/p/sbcl-for-windows/

참고.

1.0.12 버전 이후부터는 아래의 메모리 관련 오류에 대한 패치를 기본으로 반영하여 설치 패키지를 만들었습니다. 그러므로 별도로 패치를 해주실 필요가 없습니다.
- 2007.11.29

특정 시스템에서 다음과 같은 오류를 내며 제대로 실행되지 않는 경우가 있습니다.
VirtualAlloc: 0x1e7.
ensure_space: failed to validate 536870912 bytes at 0x09000000
(hint: Try "ulimit -a"; maybe you should increase memory limits.)
이 문제를 해결하기 위해 heap과 stack 크기를 수정한 패치 실행 파일도 다운로드 페이지에 함께 올려두었습니다. (패치 방법은 이곳에서 참고했습니다.) Installer를 사용해서 설치한 이후 제대로 실행이 안되고 위와 같은 오류가 뜨면 패치된 실행 파일을 받아서 SBCL이 설치된 곳에 덮어 쓰시면 됩니다. Installer 설치만으로 오류 없이 잘 실행이 된다면 굳이 패치된 실행 파일을 사용하실 필요가 없습니다. 패치가 설치 패키지 내에 기본으로 반영되었습니다.

공개되어 있는 소스를 그대로 build한 것이므로 저는 이 설치 binary에 대해 어떠한 권리도 가지고 있지 않습니다. 또한 이 설치 binary에 대해 어떠한 보장도 해드리지 않습니다.

2007년 8월 22일 수요일

Erlang 공부 - Tail Recursion으로 구현한 피보나치 수열

느리지만 지속적으로 Erlang 공부를 이어가고 있습니다. 이번에는 Tail Recursion(우리 말로는 '끝 재귀' 또는 '꼬리 재귀'라고 부르더군요.)에 대해 더욱 깊이 공부를 하면서 피보나치 수열(Fibonacci Sequence)을 tail recursion으로 구현해보았습니다.

우선, 피보나치 수열 구현으로 들어가기에 앞서 tail recursion이 무엇인지 간단하게 이해하고 넘어갈 필요가 있겠습니다. Tail recursion은 재귀 호출의 특수한 형태로서, 다음 재귀 호출이 끝난 이후 현재 함수 블럭 내에서 추가 연산을 할 필요가 없도록 구현하는 형태를 말합니다. 말이 좀 복잡한데, 예를 들어서 설명을 해보죠. (이해가 쉽도록 C 코드로 작성을 해봤습니다.)
int sum(int n)
{
if(n == 1)
return 1;
return n + sum(n-1);
}

이 코드에서 sum 함수는 1 부터 n까지의 합을 구하는 재귀 함수입니다. 'sum(3)'을 호출했다고 가정하고 그 수행 과정을 살펴보면 다음과 같습니다.
sum(3) =>
3 + sum(2) <-- (1)
3 + (2 + sum(1))
3 + (2 + (1))
3 + (3) <-- (2)
6
함수 호출의 깊이를 들여쓰기로 나타내었는데, (1) 에서 보이는 것처럼 'sum(2)'를 호출하기 전에 '3 + '라는 추가 연산의 여지를 남겨두고 있습니다. 그렇기 때문에 'sum(2)' 호출 전에 현재 함수 블럭의 상태를 stack에 저장해야 할 필요가 있고, 이렇게 저장된 상태는 다시 거슬러 올라오면서 (2)에서 복원되어 최종 연산으로 이어지게 됩니다.

똑같은 일을 하는 함수를 tail recursion으로 구현해봅시다.
int sum(int n, int acc)
{
if(n == 1)
return (acc+1);
return sum(n-1, acc+n);
}

이번 구현에서는 위에서는 없었던 acc라는 변수가 하나 더 추가되었습니다. Tail recursion에서는 이것을 Accumulator라고 부르는데, 현재 함수 블럭에 추가 연산의 여지를 남겨두지 않기 위해서 저장해야 할 데이터를 보관하는 용도로 사용됩니다. 이 sum 함수에서는 각 호출 단계별 합을 저장하게 되죠. 실행되는 결과를 보면 좀더 쉽게 이해가 됩니다.
sum(3, 0) =>
sum(2, 3) <-- (3)
sum(1, 5)
6 <-- (4)
6
6
위 결과에서 (3)을 보면 다음 'sum(2, 3)'을 호출하기 전에 저장해야할 상태가 없습니다. 그래서 (4) 이후에는 계속 동일한 값을 반환하는 것으로 호출이 마무리 됩니다. 이게 무슨 의미가 있냐구요? Tail Recursion Optimization, 즉 tail recursion 형태에 대한 최적화가 적용될 경우 실제 실행 결과는 다음과 같아집니다.
sum(3, 0)
sum(2, 3)
sum(1, 5)
6

이 결과에서 보시는 바와 같이 다음 재귀 호출을 수행한 뒤 그 반환값으로 추가 연산을 하지 않기 때문에 stack에 현재 함수 블럭의 상태를 저장하지 않고 그 상태를 바로 바꾼 뒤에 함수 블럭 처음부터 다시 실행을 하는 것입니다. 즉, 함수 호출(call)이 아닌 분기(jump)가 사용된 것과 같다는 말이죠. 이것은 결국 for 또는 while과 같은 loop가 실행되는 구조와 유사하게 변한다는 말이 됩니다.

주변에서 흔히 사용하고 있는 주요 C/C++ compiler를 비롯해서 다른 많은 프로그래밍 언어에서 tail recursion optimization을 지원하고 있습니다. Erlang 역시 지원해주고 있지요.

최적화가 적용된 tail recursion 구현의 특징은 stack-overflow가 발생하지 않는다는 것과 실행 속도가 non-tail recursion(끝 재귀가 아닌 일반 재귀)에 비해 빠르다는 것입니다. 재귀 함수의 알고리즘 구조에 따라서 그 차이가 극명하게 나타날 수도 있습니다. 아래에서 보게 될 피보나치 수열이 바로 그런 예입니다.

자, 그럼 본론으로 들어갑시다.
피보나치 수열의 정의는 다음과 같습니다.
f(x) = f(x - 1) + f(x - 2)
단, f(0) = 0, f(1) = 1

수열을 구하는 함수의 정의 자체가 재귀로 되어 있습니다. 그 모양 그대로 Erlang에서 재귀 함수 형태로 구현할 수 있습니다. Tail recursion 방식을 사용하지 않고 구현해보면 다음과 같겠지요.
-module(fibo).
-export([fibo/1]).

%% Fibonacci sequence - Recursion version
fibo(0) ->
0;
fibo(1) ->
1;
fibo(N) when N > 1 ->
fibo(N - 2) + fibo(N - 1).

그런데, 이 구현은 원칙적인 측면에서는 매우 직관적이고 바람직한 형태라고 할 수 있겠지만, 효율 측면에서는 매우 그렇지 못합니다.(제가 사용하는 HP nc8000 노트북에서 'fibo:fibo(100)'을 실행했더니 stack-overflow는 발생하지 않는데, 아무리 기다려도 답이 나오지 않더군요.)

자, 그러면 tail recursion을 적용해서 다시 한번 구현해봅시다.
-module(fibo2).
-export([fibo/1]).

%% Fibonacci sequence - Tail-recursion version
fibo(0) ->
0;
fibo(1) ->
1;
fibo(N) when N > 1 ->
fibo(N, 1, 1, 1).

fibo(N, I, Acc1, Acc2) when (N > 1) and (I < N) ->
fibo(N, I + 1, Acc2, Acc1 + Acc2);
fibo(N, N, Acc1, Acc2) ->
Acc1.

처음 구현 형태에 비하면 모양이 꽤 복잡해보입니다. 피보나치 수열 특성 상 세 개의 accumulator가 사용되었습니다. 'I'는 index를, 'Acc1'과 'Acc2'는 각각 f(n-2)f(n-1)의 계산값 저장을 위해 사용합니다. 구현에 대한 더욱 구체적인 이해는 여러분들께 맡기겠습니다. 이 구현보다 더 깔끔한 구현이 있을 수도 있으니 직접 한번 도전해보시는 것도 좋지 않을까요? ;)

실행 결과는 다음과 같습니다.


[그림 1] fibo 실행 결과

정확한 시간을 측정해보지는 않았지만, 제 노트북(HP nc8000)에서 'fibo:fibo(40)'의 결과를 구하는 데 대략 3 ~ 4 초 가량이 소요됐으나, tail recursion으로 구현된 'fibo2:fibo(10000)'의 결과를 구하는 데에는 채 1 초도 걸리지 않더군요. Non-tail recursion 구현의 'fibo:fibo(100)'은 10 분 이상을 기다려도 답이 떨어지지 않아서 결국 중단하고 말았습니다. :-$

즐거운 Erlang 공부가 되시길...