Why python is Slow:Looking Under the Hood

파이썬은 왜 느릴까요? 내부 원리를 살펴 봅시다.

올해 초 막 초등학교에 들어간 조카와 가끔 누나 몰래 햄버거를 먹으면서 하는 질문이있습니다. 아빠가 좋아 엄마가 좋아? 조카는 잠깐의 머뭇거림 없이 대답합니다. ‘삼촌~ 둘 다는 안돼? 난 그때그때 다른데…’ 어린놈이 참으로 현명하다는 생각이 들더군요. ☺
그럼 여러분들은 어떠신가요? 컴파일 언어가 좋나요 인터프린팅 언어가 좋나요? 네. 둘 다 좋습니다. 어떤일에 어떤 용도로 사용하느냐에 따라 취사 선택해서 사용하시면 됩니다. 많은 분들이 컴파일언어는 많이 접하지만 인터프린팅언어는 그렇지 못한것 같습니다. 모두를 알아야 적재적소에 잘 사용할 수가 있겠죠?
이 글에서는 많은 언어들 중에 느리다는 얘기가 들리는 파이썬에대해서 알아보고자 합니다. 파이썬은 왜 느릴까요? 저도 잘은 모릅니다. ^^;; 하지만 한 외국 블로거가 쓴 글을 읽고 여러분과 함께 나누어 봅니다. 조금의 도움이라도 드릴려구요. 늘 하는 생각이지만 때로는 원문이 더 이해가 잘가는 경우가 있습니다. 참고 바랍니다.☺

누구나 한번쯤은 ‘파이썬은 느리다’는 말을 들어본 적이 있을겁니다.

과학컴퓨팅을 위한 파이썬 과정을 가르칠 때, 이것을 교육과정 초기에 언급하고, 학생들에게 왜 그런지를 이야기 해 주었습니다. 파이썬의 핵심은 동적인 타이핑과 인터프리트 언어라는점 입니다. 그것의 변수값들은 밀집된 버퍼에 저장되는것이 아니라 널널하게 흩어져있는 개체에 저장됩니다. 그리고 작업 백터화에 NumPy, SciPy 및 관련된 툴들을 사용, 컴파일된 코드를 호출하여 이러한 문제를 처리하고, 변수들이 어떻게 이동하는지에 대한 이야기를 합니다.

하지만 최근에 무엇인가를 깨달았습니다. 위 문장에서 말한 상대적인 정확도에도 불구하고, “dynamically-typed-interpreted-buffers-vectorization-compiled”라는 말은 대부분의 인트로 프로그래밍 세미나에 참석하는 사람들에게는 거의 의미가 없다는 사실을요. 이 전문용어는 실제로 무슨일이 일어나는지 “내부 원리”에 대해 말하고자 합니다.

그래서 저는 이 포스트를 쓰고, 평소에 그냥 얼버무렸던 세부적인것을 알아보기로 결심했습니다. 그 과정에서 CPython의 내부 동작을 조사하기위해 파이썬의 표준 라이브러리를 사용하여 살펴보겠습니다. 프로그래머로써의 경험에 관계 없이 이 글을 통해 여러분들이 많은 것들을 배우시기를 바랍니다.

왜 파이썬은 느린가? (Why Python is Slow)

파이썬은 포트란과 C보다 느립니다. 여기에 그 몇가지 이유가 있습니다.

1.파이썬은 정적이 아닌 동적 타입입니다.

이것은 프로그램 실행 시, 인터프리터는 정의된 변수의 유형을 알고 있지 않다는것을 의미합니다. C변수와 파이썬 변수의 차이는 아래 도표로 요약됩니다.(컴파일 된 언어의 표준으로 C언어를 사용합니다.)

C 언어 변수의 경우, 컴파일러는 단지 그 정의만으로도 변수의 유형을 알고 있습니다. 파이썬 변수의 경우는 모두 아시겠지만 프로그램 실행시의 변수는 파이썬 개체의 일부 종류라는 것입니다.

그래서 만약 여러분이 C에서 다음을 작성하는 경우:

/* C code */ 
int a = 1; 
int b = 2; 
int c = a + b;

C 컴파일러는 시작할 때부터 a와 b는 정수형이라는 것을 알고 있지만, 단순한 어떤것도 할 수 없습니다. 정수형을 아는것으로는 메모리 상의 단순한 값에 두 개의 정수를 더하고, 이를 다른 정수로 반환하는 루틴을 호출할 수 있습니다. 개략적인 도식으로 나타내면 이벤트의 순서는 다음과 같습니다.

C 덧셈

  1. <int> 1을 a에 할당
  2. <int> 2을 b에 할당
  3. binary_add<int, int>(a,b) 호출
  4. 결과를 c에 할당

파이썬에서의 같은 역할의 코드는 아래와 같습니다.

# python code
a = 1
b = 2
c = a + b

인터프리터는 1과 2는 개체라는 것만을 알지, 그것들의 타입은 알지 못합니다. 그래서 인터프리터는 타입 정보를 찾기 위해 각 변수의 PyObject_HEAD을 검사 한 후, 두 타입의 적절한 덧셈 루틴을 호출해야 합니다. 마지막으로 반환 값을 보관 유지하는 새로운 Python 개체를 만들고 초기화해야합니다. 이벤트의 순서는 대략 다음과 같습니다.

파이썬 덧셈

  1. a에 1을 할당

1a. a->PyObject_HEAD->typecode 정수 설정

1b. a->val = 1 설정

2. b에 2를 할당

2a. b->PyObject_HEAD->typecode 정수 설정

2b. b->val = 2 설정

3. binary_add(a,b) 호출

3a. a->PyObject_HEAD 에서 typecode 찾기

3b. a는 정수형; 값 a->val

3c. b->PyObject_HEAD 에서 typecode 찾기

3d. b는 정수형; 값 b->val

3e. binary_add<int, int>(a->val, b->val) 호출

3f. 정수형 결과값 result

4. 파이썬 개체 c 생성

4a. c->PyObject_HEAD->typecode 정수 설정

4b. c->val에 result 설정

동적 타이핑은 모든 작업에 더 많은 단계가 있다는 포함되어있다는것을 의미합니다. 이것이 숫자데이터에 관한 연산에서 C언어와 비교했을때 파이썬이 느린 가장 큰이유입니다.

2. 파이썬은 컴파일 형식이 아닌 인터프리터 형식입니다.

위에서 인터프리터와 컴파일된 코드 사이의 차이점을 보았습니다. 스마트한 컴파일러는 결과의 속도를 높일 수 있도록, 반복되거나 불필요한 연산을 미리 내다보고 최적화 할 수 있습니다. 컴파일러 최적화는 컴파일러만의 특성이기에 개인적으로 그것에 대해 많이 이야기를 할 수 없으니, 이 이야기는 이쯤 해 두겠습니다. 이에대한 몇가지 예제들은 Numba와 Cython에 관해 작성한 이전 게시물에서 확인 할 수 있습니다.

3. 파이썬의 개체모델은 비효율적인 메모리 액세스가 발생할 수 있습니다.

우리는 앞 단락에서, C정수에서 파이썬 정수로 이동할 때 타입정보의 추가 단계를 보았습니다. 이제 여러분들은 이러한 많은 정수들을 가지고 있고, 그것들을 가지고 어떤 종류의 일괄작업을 한다고 상상해 봅시다. C에서는 어떤 종류의 버퍼 기반의 배열을 사용하는 동안, 파이썬에서는 표준 List개체를 사용할 수 있습니다.

가장 간단한 형태의 Numpy배열은 C의 배열과 유사한 파이썬 개체입니다. 이 배열은 값들의 연속되는 데이터 버퍼를 위한 포인터를 가지고 있습니다. 달리 말하자면, 파이썬 리스트는 포인터의 연속되는 버퍼를 위한 포인터를 가지고 있습니다. 각각의 포인터들은 그것들의 데이터(지금의 경우는 정수) 주소를 가지고 있고 그것들을 가르키고 있습니다. 이 두가지를 도식화 하면 아래와 같이 나타낼 수 있습니다.

만약 여러분이 연속되는 데이터로 여러 단계를 통하여 어떠한 작업을 하고 있다면, Numpy 레이아웃은 저장과 액세스 측면 모두에서 파이썬의 레이아웃보다 훨씬 더 효율적입니다.

그렇다면 왜 파이썬을 사용할까요?

이렇게 본질부터 비효율적임에도 불구하고, 왜 우리는 파이썬을 사용하려고 생각할까요? 글쎄요, 아래와 같은 이유들 때문이 아닐까요? 동적인 타이핑은 파이썬을 C보다 사용하기 쉽게 해 줍니다. 파이썬은 매우 유연하고 관대합니다. 이 유연함은 개발시간의 효율적인 사용을 이끌어내고, C나 포트란의 최적화가 절실히 필요할 경우에도 파이썬을 통해 쉽게 컴파일된 라이브러리에 접근할 수 있습니다. 이것이 많은 과학 커뮤니티 내에서 파이썬의 사용이 지속적으로 성정하고 있는 이유입니다. 모두가 함께 노력한다면, 파이썬은 코드와 함께 과학의 전반적인 업무처리를 위한 매우 효율적인 언어가 될 수 있습니다.

파이썬 더 깊게 알아보기: 너무 맹신하진 마세요 (Python meta-hacking: Don’t take my word for it)

위 글에서 파이썬 내부구조의 동작에 대해 일부 언급했지만, 거기서 멈추지는 않겠습니다. 요약을 작성하는 동안, 파이썬 언어의 내부동작을 심도있게 알아보고 발견하는 과정 그 자체가 매우 교육적이라는것을 발견했기 때문입니다.

다음 절에서는, 파이썬 자체를 이용하여 파이썬 개체를 노출하는 몇가지 방법을 통해 위에서 말한 정보가 정확하다는것을 여러분에게 증명하겠습니다. 아래에서 사용된 모든 코드들은 파이썬 3.4버전임에 유의하시기 바랍니다. 파이썬의 이전 버전은 약간 다른 내부 개체구조를 가지고 있고, 이후 버전도 미세한 차이가 있을 수 있습니다. 정확한 버전을 사용하고 있는지 확인하세요. 또, 아래의 코드 대부분은 64비트를 전제로 하고 있습니다. 만약 여러분이 32비트 플랫폼이라면, C 타입의 중 일부는 이러한 차이를 고려하기위해 조정되어야 할 것입니다.

파이썬 정수형 살펴보기

파이썬의 정수형은 생성하기와 사용하기가 아래와 같이 쉽습니다.

하지만, 이 인터페이스의 단순함은 내부적으로 일어나고 있는 복잡성과는 모순됩니다. 앞 단락에서 파이썬 정수형의 메모리 레이아웃에 대해서 간단히 이야기 했었습니다. 여기에서는 파이썬 인터프리터 자체에서 파이썬의 정수형의 내부를 조사하기위해 파이썬의 내장 ctypes모듈을 사용합니다. 하지만 먼저, 우리는 파이썬의 정수가 어떻게 생겼는지 C의 API 수준과 같이 정확히 알아야 합니다.

CPython의 실제 x 변수는 Include/longintrepr.h 내부에 있는 CPython 소스코드안에 정의되어있는 구조체에 저장됩니다.

PyObject_VAR_HEAD는 Include/object.h에 정의된, 다음과 같은 구조를 가진 개체를 시작하는 매크로 입니다.

그리고, PyObject 요소 또한 Include/object.h의 정의에 포함되어있습니다.

여기 _PyObject_HEAD_EXTRA는 일반적으로 파이썬 빌드에서 사용되지 않는 매크로입니다.

이 모든것과 정의와 매크로를 정확히 합치면, 정수 개체는 아래의 구조와 같이 동작합니다.

ob_refcnt변수는 개체의 참조 수, ob_type변수는 개체의 모든 형식 및 메서드의 정의가 들어있는 구조체에 대한 포인터, 그리고 ob_digit변수는 실제 수치를 가지고 있습니다.

이 정보를 바탕으로, 실제 개체 구조와 위의 정보의 일부를 확인하는데 ctypes 모듈을 사용할 것입니다.

C 구조체로 Python의 표현을 정의면서 시작해 봅시다.

이제 어떤 수 42에 대한 내부 표현을 살펴 봅시다. CPython에서 id 함수의 개체의 메모리 위치 제공기능을 사용하겠습니다.

ob_digit 속성은 메모리의 정확한 위치를 가르킵니다!

하지만 refcount는 어떤가요? 정확히 하나의 값을 생성했지만, 왜 참조 수가 하나 이상의 값을 가질까요?

파이썬이 작은 정수를 많이 사용하는 것은 아주 잘 알려져 있습니다. 이러한 정수의 각각을 위해 새로운 PyObject가 생성된 경우라면, 많은 양의 메모리를 사용하게 됩니다. 이 때문에 파이썬에서는 일반적인 정수값을 싱글톤으로 구현합니다. 즉, 이 숫자 중 하나의 복사본만 메모리에 존재합니다. 달리말하자면, 이 범위 내에서 새로운 파이썬 정수를 만들때마다 단순히 그 값을 가지는 싱글톤에 대한 참조가 만들어집니다.

두 변수는 같은 메모리 주소를 가리키는 단순한 포인터입니다. 만약 훨씬 더 큰 정수 (파이썬 3.4버전에서 255보다 큰 경우)라면, 이것은 더 이상 참이지는 않습니다.

파이썬 인터프리터를 시작하면 많은 정수 개체들이 만들어집니다. 각각에 얼마나 많은 참조들이 있는지를 살펴보면, 흥미로울 것입니다.

우리는 ‘0’이 몇 천 번을 참조되는것을 확인하고, 여러분이 예상하는것처럼 참조 주파수는 일반적으로 정수의 값이 증가함에 따라 감소하는것을 볼 수 있습니다.

더 나아가 우리의 예상대로 결과가 나타나는지 확인하기 위해, ob_digit필드가 올바른 값을 가지고 있는지 확인해 봅시다.

여러분이 이것을 조금 더 깊이 생각해 본다면, 256보다 큰 번호를 가지지 않는다는것을 알아차릴 수 있을껍니다. Objects/longobject.c에서 수행되는 몇가지 비트-쉬프트 연산은 이러한 큰 정수가 메모리에 표시되는 방식을 변경합니다.

정확하게 그것이 왜 일어나고 있는지 완전히 이해하고 말 할 수는 없습니다만, 여기서 볼 수 있듯이 효율적으로 long int 데이터 타입의 오버플로 한계를 넘어 정수처리를 하는 파이썬의 능력과 관계있다고 생각합니다.

저 숫자는 long 타입으로 표현하기에 너무 깁니다. long타입은 64비트로 표현 가능한 값들만 가질수 있습니다. (64비트 표현은 2^64)

파이썬 리스트형 살펴보기

좀 더 복잡한 형식으로 위의 아이디어를 적용해 파이썬 리스트를 알아봅시다. 정수형과 유사하게, Include/listobject.h 안에 있는 개체리스트에서 그 정의를 찾을 수 있습니다.

또한, 구조체를 효과적으로 따라가는것을 보기위해 매크로와 애매한 타입들을 확장할 수 있습니다.

여기에서 PyObject **ob_item은 리스트의 내용물을 가르키고, ob_size값은 리스트안에 얼마나 많은 아이템들이 있는지 말해줍니다.

한 번 적용해 봅시다.

우리가 제대로 일을 했는지 확인하기 위해 몇가지 추가 참조 리스트를 만들고, 참조횟수에 어떻게 영향을 미치는지 알아봅시다.

이제 리스트에서 실제 요소들을 찾는것에 대해 살펴 봅시다.

위에서 본 것처럼, 요소들은 PyObject포인터들의 연속적인 배열속에 저장되어있습니다. ctypes를 사용해, 우리는 실제로 IntStruct개체로 구성된 복합 구조를 만들 수 있습니다.

이제 각 항목의 값들을 살펴 보겠습니다.

리스트에서 PyObject정수를 얻어냈습니다! 여러분들은 위의 리스트 메모리 레이아웃의 도식화된 결과를 다시볼 수 있는 시간을 가지고 싶을 수 있습니다. 그리고 이 ctypes 작업들이 어떻게 다이어그램에 매핑되는지 확실히 이해 할 수 있습니다.

Numpy 배열 살펴보기

이제 비교를 위해 똑같은 방법으로 Numpy 배열을 살펴 보도록 하겠습니다. NumPy C-API 배열 정의의 상세한 단계별 안내는 건너뛸 수 있습니다. 만약 여러분들이 그것을 보기 원하신다면, numpy/core/include/numpy/ndarraytypes.h에서 찾을 수 있습니다.

여기에서는 NumPy 1.8버전을 사용하고 있습니다. 이것의 내부는 버전에 따라 변경될 수 있는데, 이런 경우에는 확실하지는 않을 수 있습니다.

NumPy배열 자체를 나타내는 구조체를 생성하는것으로 시작해 봅시다. 익숙한 시작으로…

또한 여기에서는 파이썬 버전에 접근하기위해 사용자 속성 shape과 stride을 추가 할 것입니다.

한 번 살펴봅시다.

우리는 정확한 shape정보를 끌어 온것을 알 수 있습니다. 그러면 참조 카운트가 올바른지 확인해 봅시다.

이제 우리는 데이터 버퍼를 뽑는 까다로운 부분을 할 수 있습니다. 단순화 하기 위해 strides를 무시하고, 연속적인 C 배열을 가정하겠습니다. 이것은 비트를 가지고 작업을 일반화 할 수 있습니다.

data변수는 이제 NumPy배열 안의 메모리를 정의하는 연속적인 블록을 나타냅니다. 이를 보여주기위해 배열속의 값을 바꾸어 보면…

…그리고 데이터 표현의 바뀌는것도 관찰하는것 뿐만 아니라, x와 data는 둘 다 메모리의 같은 연속적인 블록을 가르킵니다.

파이썬 리스트와 NumPy nd-배열(ndarray)의 내부를 비교하자면, NumPy 배열이 훨씬 더 확실하게 동일한 타입의 데이터 리스트를 나타내는데 보다 더 단순합니다. 이 사실은 핸들링 뿐만 아니라 더 효율적인 컴파일러를 만드는것과도 관계가 있습니다.

그냥 재미를 위해서입니다. 이렇게 사용하진 마세요. (Just for fun: a few “never use these” hacks)

파이썬 객체 뒤의 C-레벨 데이터를 래핑하여 ctypes를 사용하면, 꽤 재미있는 일을 할 수 있습니다. 내 친구 제임스 파월에게 몇가지 속성을 더한 글을 ‘진지하게 이 코드를 사용하지 마십시오.’에서 말했었습니다. 아래에 언급하는것들이 실제로는 (결코)사용되지 않지만, 아직까지 모든것에 흥미로움을 발견하곤 합니다.

정수의 값을 수정

이 레딧 포스트에 의해 영감을 받았는데, 우리는 실제로 정수 개체의 숫자 값을 수정할 수 있습니다! 만약 0과 1 같은 보통 숫자를 사용한다면, 파이썬 커널과 충돌할 가능성이 매우 높습니다. 하지만 덜 중요한 숫자와 함께 사용할 경우에는 적어도 잠시동안은 충돌로부터 멀어질 수 있습니다.

이것은 정말, 정말로 나쁜 생각입니다. 특히, 여러분이 IPython 노트북에서 이 작업을 실행한다면, IPython 커널의 능력을 충분히 발휘하는데 지장을 줄 수도 있습니다. (실행시간에 변수들을 꼬이게 만들기 때문입니다.) 그럼에도 불구하고, 행운을 빌면서 한 번 해 봅시다.

그러나 주의해야 할 것은, 간단한 방법으로 다시 그 값을 되돌릴 수 없다는 것입니다. 더이상 113이라는 값이 파이썬에서는 존재하지 않기 때문입니다!

복구 할 수 있는 한가지 방법은 직접 바이트를 조작하는것입니다. 우리는 113 = 7*16^1 + 1 *16^0 를 알고 있습니다. 파이썬 3.4를 실행하는 리틀 엔디안 64비트 시스템이기에, 우리는 아래와 작이 작업을 해야 합니다.

이제 돌아왔습니다!

전에 이렇게 강조한적이 없었습니다. 하지만 이 작업을 절대 수행하지 마십시오.

리스트 내용의 수정

우리는 위에서 numpy배열 안의 값 수정에 대해 살펴보았습니다. numpy배열은 단순한 데이터 버퍼이기 때문에 쉬웠습니다. 하지만 리스트에서 같은 일을 할 수 있을까요? 값 그 자체가 아니라 값의 참조가 리스트에 저장되기 때문에 좀 더 다루기가 힘듦니다. 그리고 파이썬 자체가 충돌하지 않도록, 이 참조 수를 망치지 않도록 매우 주의해야 합니다. 여기에 그 방법이 있습니다.

말씀 드린것 처럼 절대로 이렇게 사용해서는 안됩니다. 그리고 솔직히 여러분들이 왜 이것을 원하는지에 대해 어떤 이유도 생각할 수 없습니다. 하지만 그것은 여러분에게 인터프리터가 리스트의 내용을 수정할 때 수행하는 작업의 유형에 대한 아이디어를 제공합니다. 위에서 말한 NumPy의 예와 이것을 비교해 보면, 무엇때문에 파이썬에서 리스트가 배열보다 더 많은 오버헤드를 가지는지를 알 수 있습니다.

메타에서 메타로 스스로 감싸는 파이썬 개체

위의 메소드들을 사용하여 모르는 것들을 시작 할 수 있습니다. ctypes 안의 Structure클래스는 파이썬 개체 자체로 되어있습니다. 이것은 Modules/_ctypes/ctypes.h에서 확인 할 수 있습니다. ints와 lists를 감싸는 것 처럼, 다음과 같이 구조체 자체를 감쌀 수 있습니다.

이제 자신을 감싸고 있는 구조체 만들기를 시도 할 것입니다. 새 구조체 메모리의 어느주소에 만들어지게 될 찌 모르기 때문에 이것을 직접 수행할 수는 없습니다. 하지만 첫번째를 감싸는 두번째 구조체를 만들고 그 위치에서 해당 내용을 수정 할 수는 있습니다.

임시 메타 구조체를 만들고 그것을 감싸보겠습니다.

세번째 구조체를 만들고, 현 위치에서 두번째의 메모리값을 조절하여 그것을 사용해 보겠습니다.

자기를 감싸는 파이썬 구조체가 완성되었습니다.

다시 이야기 하지만, 여러분들이 이렇게 하는것을 원하는 어떤 이유도 생각할 수 없습니다. 그리고 파이썬에서 자기참조 타입은 전혀 획기적인것이 없습니다. 동적인 타이핑 때문에 실제로 직접 메모리를 해킹하지 않고 이같은 일들을 간단히 할 수 있습니다.

결론 (Conclusion)

파이썬은 느립니다. 그리고 우리가 보았듯이 느린 큰 이유중의 하나는 내부적으로 간접적인 타입이기 때문입니다. 이 내부간접 타입은 개발자에게는 파이썬을 빠르고, 쉽고, 재미있게 만들어줍니다. 그리고 또 우리가 보았듯이 파이썬은 개체 자체를 해킹하는데 사용할 수 있는 도구를 제공합니다.

다양한 개체들의 차이점이 이 탐구를 통해 더 명확해졌기를 바랍니다. 그리고 약간의 자유스러움은 CPython 자체의 내부를 망가뜨리게 만들기도 합니다. 이 활동들은 나에게 매우 교육적이었습니다. 그리고 여러분들께도 그렇게 되기를 기대합니다…. 행복한 해킹이 되시길!

이 블로그 게시물은 IPython 노트북에서만 작성되었습니다. 전체 노트북은 여기에서 다운로드 가능하고, 고정된 보기는 여기에서 가능합니다.

ps. 참고로 IPython notebook은 http://ipython.org/notebook.html 에서 확인 하실 수 있습니다. :D

Show your support

Clapping shows how much you appreciated CookAtRice’s story.