크기가 매우 큰 희소행렬(sparse matrix)에 대해 SciPy 패키지의 scipy.sparse.linalg.svds 함수를 사용하여 특이값 분해(SVD)를 계산하던 중 이상한 버그를 발견했다. 행렬의 크기가 $(65536, 65536)$이었는데, 함수는 입력된 행렬이 비어 있다며 예외를 발생시킨 것이다. 에러 메시지는 다음과 같다.

ValueError: `A` must not be empty.

처음에는 행렬의 크기가 너무 커서 발생한 문제인가 생각이 들었지만, 더 큰 행렬에 대해서는 잘 동작했다. 결국 패키지 내부의 코드를 분석한 결과 이 버그의 원인을 알아내었다.

svds 함수 내부에서는 _iv라는 함수를 호출하여 입력값의 유효성을 검사하는 것으로 보인다. 이 함수에서는 입력된 행렬이 비어있는지를 확인하는데, 그 부분의 코드는 다음과 같다.

if np.prod(A.shape) == 0:
    message = "`A` must not be empty."
    raise ValueError(message)

해당 부분 링크

A는 입력된 행렬이다. np.prod(A.shape)는 차원 성분의 곱이므로 행렬의 총 성분 개수와 같다. 즉 저 코드는 행렬의 성분 개수가 $0$인지를 확인하여 행렬이 비어 있는지 확인하는 것이다.

문제는 np.prod(A.shape)가 NumPy의 32비트 정수형 numpy.int32로 계산된다는 것이다. 그래서 총 성분 개수가 정수 범위인 $2^{31}-1$를 넘어가면 오버플로우가 발생한다. 사실 대부분의 경우에는 오버플로우가 발생하더라도 $0$이 되지는 않아 별 문제가 되지 않는다. 예를 들어, 크기가 $(65536, 32768)$인 경우 성분의 개수는 $2^{31}$이 되어 오버플로우가 발생하지만, 그 값은 $-2^{31}$이 되어 어찌 됐든 $0$은 아니므로 문제가 없다. 문제는 처음에 말한 것과 같이 크기가 $(65536, 65536)$인 경우로, 이때는 성분의 개수가 $2^{32}$가 되고, 오버플로우가 적용된 값은 $0$이다. 그래서 저 크기의 행렬은 빈 것으로 간주하여 예외를 발생시킨다.

다음은 오류를 발생시키는 코드의 예이다.

from scipy.sparse import csc_array
from scipy.sparse.linalg import svds

mat = csc_array((65536, 65536))
_, s, _ = svds(mat)
print(s[0])

버그를 알아낸 후 패키지 리포지토리에 제보가 있는지 찾아보았지만 발견하지 못해 내가 직접 제보하였다.

물론 저 버그를 고치는 것은 매우 간단하다. 그냥 차원의 각 성분을 검사해 $0$인 것이 있는지 확인하는 코드로 바꾸면 된다. 놀라운 사실은 저걸 알아내고 제보한 사람이 지금까지 없었다는 것이다. 지금까지 말한 대로 이 버그는 “행렬의 성분 개수가 32비트 정수형으로 표현되어 오버플로우가 발생했을 때 0이 되는 경우”에서만 발생하고, 이런 상황 자체가 굉장히 드물기 때문에 수많은 프로그래머의 눈을 피해 지금까지 살아남을 수 있었을 것이다. 이런 황당한 버그를 알아내 제보한 것도 나름의 영광이 아닌가 싶다.

Tags:

Categories:

Updated:

Leave a comment