이 글에선 수학 함수의 사용에 대한 주의점을 다루려고 한다. pow 함수를 통해 실제 문제가 되는 사례를 중심으로 수학 함수를 다룰 때 함수가 정의하는 영역(정의역/공역)을 다루는 것이 중요하다는 것을 알아본다.

참고로 이 내용은 GPU 기준으로 시작한다. CPU도 근본적으로는 비슷한 문제가 있지만 라이브러리나 컴파일러가 좀 더 똑똑하게 처리하기 때문에 걱정을 덜 해도 된다. 좀 더 정확히 말하자면 단순히 GPU/CPU의 차이라기보다는 프로그래밍 언어와 컴파일러의 환경에서 나오는 차이고 CPU에서는 똑똑하게 처리할 여지가 더 크다.

pow(x, 2)와 x의 제곱

pow(x, y)은 x의 y 승을 나타내는 수학 함수다. 가령 x 와 y가 2라면 pow(2, 2)는 4를 나타내고, $2^2$도 4를 나타내니 이 둘은 같다고 할 수 있다. 하지만 pow 함수는 그렇게 호락호락한 함수가 아니라서 주의를 필요로 한다. 예를 들어 -2의 제곱을 계산하려고 한다면 어떨까?

$(-2)^2$은 4고 직관적으로라면 컴퓨터는 이 정도의 계산은 무리 없이 해야 한다. 하지만 그렇지 않다.

수학적으로 x의 2승은 $x\times x$과 같다. 만약 pow(-2, 2)를 수행하는 컴파일러가 2승이 정수라는 것을 눈치채고 똑똑하게도 $(-2)\times (-2)$ 로 바꿔준다면 아무런 문제가 없다. 하지만 컴파일러가 그렇지 못하다면(이러한 동작 방식은 컴파일러마다 다르다) 이 계산은 조금 복잡해지고 결과적으로 NaN(Not a Number)이 된다. 알다시피 NaN이 나오면 그 계산은 오염되어 그 이후의 계산이 모두 망가진다.

그럼 왜 pow(-2, 2)가 NaN이 되는 것일까? 결론부터 말하자면 pow의 x는 음수를 입력으로 받지 않는다는 것이고 음수를 입력으로 받지 않는 건 pow 함수의 계산 방식 때문에 그렇다.

한계

당연한 말이지만 컴퓨터는 덧셈 하나도 마음껏 하지 못한다. 일반적으로 어느 이상 숫자가 올라가 해당 자료형이 담을 수 있는 숫자를 넘기면 오버플로우(overflow)가 발생한다. 뺄셈도 마찬가지고, 곱셈, 나눗셈도 각자의 여러 난항이 있다.

그래도 사칙 연산 정도는 CPU/GPU의 어셈블리 단에서 명령어 처리가 가능하다. 그 명령어 처리가 어떻게 가능한지는 흔히 말하는 논리 회로의 영역이다. 하지만 현실적으로 논리 회로로 모든 수학 함수를 다 나타낼 순 없다. 그중에서도 pow 함수는 의외로 다루는 영역(정의역/공역)이 넓은 편인데다 허수가 나오기도 하여 그렇게 쉽게 계산 할 수 있는 것은 아니다.

pow 함수는 일반적으로는 아래의 공식을 따른다.

\[x^y = 2^{y\log_2 x}\]

더 복잡한 수식이 나와버린 듯 하지만 컴퓨터가 발전하면서 $2^x$ 를 구하는 함수, $log_2 x$ 를 구하는 함수 정도는 어셈블리 명령어로 만들어 두었기 때문에 곱하기, $2^x$, $log_2 x$ 를 이용하면 일반적인 pow 함수를 계산할 수 있다1.

제약

\[x^y = 2^{y\log_2 x}\]

이 공식에는 원래 의도와는 다른 제약이 들어간다. $2^x $ 의 경우 모든 실수를 입력으로 받지만 log 함수의 x의 정의역은 실수 전체가 아니라 0보다 큰 실수이다. x가 0이거나 0보다 작은 경우는 정의되지 않아서 넣으면 안된다2. 그러한 입력에는 NaN이 출력 되는 것이다.

하지만 x가 0이고 y 가 0보다 큰 경우의 $x^y$는 수학적으로 0이라서 NaN으로 두기 아깝다. 이런 경우는 분기문을 이용하면 어렵지 않게 처리할 수 있다. 실제로 pow 함수 내부에서는 분기 처리로 이러한 문제를 우회한다. 이러한 상황은 HLSL(High Level Shader Language)의 pow 함수의 스펙을 살펴보면 좀 더 명확해진다(참고 1).

X Y 결과
<0 any NaN
>0 ==0 1
==0 >0 0
==0 <0 $\infty$
>0 <0 $1/x^{-y}$
==0 ==0 0 or NaN (GPU마다 다름)

GPU 어셈블리를 보면 분기 과정이 더 명확해지겠지만 그 부분은 생략하겠다.

컴파일러간 차이

C++의 CPU의 pow 함수는 훨씬 더 쓰기 편하다. pow(x, y)에서 y가 정수면(float 형이더라도) 알아서 문제가 없도록 처리해준다(gcc 9.2 기준). 예를 들어 pow(-2.0f, 2.0f) 같은 것도 알아서 $(-2)\times (-2)=4$를 계산해준다(참고 2).

이보다 조금은 나은 방법은 pow(-2, 2) 형태로 정수를 쓰는 것이다. 이 경우 컴파일러가 $(-2)\times (-2)$로 치환을 해주어 많은 어셈블리 명령어를 절약할 수도 있다(CPU에 해당하는 이야기다). 물론 가장 좋은건 함수에 넣기 전 abs(-x) 형태를 취하거나 처음부터 $(-2)\times (-2)$로 쓰는 것이다.

이처럼 GPU와 CPU는 근본적인 환경 차이 때문에 동작이 제법 많이 다르다. CPU는 물론이고 GPU에서도 쉐이더 언어나 컴파일러(혹은 GPU 및 드라이버)에 따라 동작이 다를 수 있다. 특정 입력에 대해 결과값이 항상 NaN으로 나오는 경우는 어쩌면 그나마 나은 상황이다. HLSL pow 함수에서 pow(0, 0) = 0 or NaN인 경우처럼 GPU마다 동작이 다르다면 이 부분에 버그가 있을 때 찾아내기 훨씬 힘들어질 것이다.

마무리

프로그래머가 수학 함수를 다룰 때는 그냥 수식 그대로를 옮기는 것으로 끝나지 말아야한다. 실제 수학 함수의 정의역과 치역을 알고 있어야 하며, 그것에 해당하는 프로그래밍 함수는 어떠한 제약이 있는지를 알고 있어야 한다. 초반에 말했듯이 컴퓨터는 덧셈 하나도 맘놓고 못하기 때문에 마음 놓고 쓸 수 있는 수학 함수란 사실상 없다.

log 함수를 다룰 때는 x가 0 이하가 되지 않도록 하듯이, 나눗셈을 할때는 반드시 0으로 나누지 않도록 조심해야하고 sqrt 의 경우는 음수를 계산하지 않도록 해야 한다. 이러한 부분들은 문제를 겪고 디버깅을 하면서 습관이 되기도 하지만 미리 조심하면 많은 버그를 사전에 방지할 수 있다.

성능 문제도 있다. Exponentiation by squaring을 보면 알 수 있듯이 정수의 pow 계산은 비용이 그렇게 싸진 않다. 실제 CPU 수학 라이브러리 안에서 어떤 계산을 사용하는지는 확인을 하지 못했는데 pow 함수 계산하는 것은 이처럼 비용이 싸진 않다는 것을 확인하기 좋다(부동소수점과는 달리 정수의 계산은 정확해야하기 때문에 $2^{y\,log_2 x}$ 형태를 사용하기에는 무리가 있다).

수학 함수를 다루는 팁은 별도의 글로 적으려 했었다. 일반적인 팁 위주로 나중에 다시 적도록 하겠다.

참고 문헌

  1. HLSL의 pow 함수
  2. C++의 std::pow 함수
  3. Exponentiation by squaring - pow(x, y)의 y가 양의 정수일 경우 계산하는 알고리즘이다.
  4. How to Prove That 1 = 2? - 0으로 나눴을 때 어떻게 수식 체계가 망가지는지를 알 수 있는 흔한 사례다.
  1. $2^x$, $log_2 x$ 명령어가 없는 CPU의 경우 계산 테이블로 미리 만들어두는 것도 흔히 쓰는 방식이다. 

  2. 수학적으로 정의되지 않음(undefined)은 그냥 무조건 피해야하는 것이라고 보면 된다. 정의 되지 않는 것을 사용할 경우 수학의 수식 체계가 망가진다. 이게 망가지면 흔한 유머처럼 1=2와 같은 잘못된 수식이 만들어진다. (예시, How to Prove That 1 = 2?