본문 바로가기

Python/고성능파이썬

[고성능파이썬] 고성능 파이썬을 어떻게 실현시킬 수 있을까?

반응형

🔊 해당 포스팅은 고성능 파이썬 2판 책 서적을 읽고 개인적인 학습 목적 하에 작성된 글입니다. 포스팅에서 사용되는 자료들은 책의 내용을 참고하되 본인이 직접 재구성한 자료임을 알립니다.

 

출처 : 한빛출판네트워크

 

파이썬이라는 언어를 시작한지 어느덧 4년이 넘어가는 시기에 들어섰다. 중간에 다른 언어를 잠깐 찍먹해본 적은 있었지만 파이썬은 줄곧 손에서 놓지 않았었다. 나름대로 파이썬 실력이 많이 쌓여왔다고 생각했지만 이는 매우 건방진(?) 생각이었음을 최근에 깨닫고 있다. 그래서 파이썬에 대한 실력을 한 번 점프하는 시간을 가져야 겠다고 생각했다. 그러던 중 평소에 봐왔던 책들 중 고성능 파이썬이라는 책을 선택해서 깊게 파보기로 결정했다. 책의 목차를 보니 프로파일링하는 법, 비동기 처리, 파이썬을 컴파일하는 CPython, Cython 등 평소에 내가 잘 모르는 내용들로 꽉 차있었다. 이는 곧 나의 약점이라고 생각되어서 빈 지식을 채워놓아보려고 한다. 주제랑 벗어나는 이야기지만 어느 순간부터는 개발 지식을 강의가 아닌 서적으로 공부하는 것이 더 자연스러워졌다. 물론 내가 이미 익숙한 언어라서 그럴 수 있다. 매번 느끼지만 책으로 공부하는 것이 시간은 오래 걸리지만 그 깊이는 말로 표현할 수 없을 만큼 얻어가는 내용이 많은 듯 하다. 그럼 지금부터 하나씩 시작해보자.


컴퓨터 프로그래밍이란, 어떠한 방법으로 데이터를 가공해서 서버 간에 데이터를 주고받으며 어떤 결과를 얻는 과정이다. 하지만 이런 과정에서는 시간이라는 비용이 든다. 고성능 프로그래밍이라는 것은 방금 언급한 과정 중에 발생하는 부가 비용 즉, 데이터를 가공하고 주고받으면서 발생하는 '시간' 비용을 줄이는 행위를 의미한다. 이런 고성능 프로그래밍을 하기 위해서는 기본적으로 데이터를 주고 받는 하드웨어에 대한 이해가 필수적이다. 데이터를 처리하는 주체에 대해서 모르는 상태에서 고성능 프로그래밍을 한다는 것이 말이 되지 않을 수 있다.

 

하지만 파이썬은 C 계열의 언어들과는 다르게 매우 고수준 언어이다. 고수준 언어라 함은 파이썬이 동작할 때 하드웨어와 상호작용하는 과정을 추상화시켜놓았기 때문에 프로그래머 입장에서는 하드웨어와 상호작용하는 과정이 피부에 와닿지 않는다. 하지만 그래도 하드웨어로 데이터를 옮기는 최적화 방법과 파이썬이 추상화해서 데이터를 옮기는 방법을 이해한다면 파이썬으로 고성능 프로그래밍을 하는 데 도움이 된다.

1. 기본적인 컴퓨터 시스템

컴퓨터를 구성하는 요소는 크게 3가지로 구분할 수 있다. 연산 장치, 메모리 장치, 이 연산 장치와 메모리 장치를 이어주는 연결 장치가 있다. 각 장치마다 갖는 특성이 존재하는데, 연산 장치는 말 그대로 초당 얼마나 많이 계산할 수 있는지를 특성으로 갖는다. 메모리 장치는 데이터를 얼마나 많이 저장할 수 있고 데이터를 얼마나 빠르게 읽고 쓸 수 있는지를 특성으로 갖는다. 마지막으로 연결 장치는 (연산, 메모리 장치와 같은) 서로 다른 장치들 간에 데이터를 얼마나 빠르게 옮길 수 있는지를 특성으로 갖는다.

 

연산 장치로는 우리가 흔히 아는 CPU가 있다. 메모리 장치에는 접근 속도가 상대적으로 빠른 RAM이 있고, 상대적으로 느린 HDD(하드디스크 드라이브), SSD가 존재한다. 물론 CPU에도 L1, L2 (때에 따라서는 L3, L4까지)와 같은 캐시 메모리가 존재한다. CPU의 캐시 메모리는 십여 메가 바이트정도까지만 매우 소량으로 데이터를 저장할 수 있지만 RAM보다 훨씬 빠른 속도로 동작한다. CPU로 전달되는 데이터는 항상 이 캐시 메모리를 거쳐 간다. 연결 장치로는 FSB(프론트사이드 버스), UPI(울트라 패스 인터커넥스)가 있고 상대적으로 매우 느린 연결 장치인 네트워크 연결도 연결장치에 속한다.

 

이제 방금 언급했던 3가지 장치들에 대해 하나씩 좀 자세히 알아보자.

1-1. CPU 연산 장치

연산 장치는 입력된 비트(bit)를 다른 비트로 변환하거나 프로세스의 상태를 변경하는 기능을 제공한다. 요즘은 머신러닝, 딥러닝 분야가 핫해지면서 병렬 처리에 특화된 GPU라는 연산장치도 존재한다. 어쨌든 연산 장치의 주요 속성은 IPC(Intrustructions Per Cycle)로 측정을 한다. IPC란, 한 사이클에 처리할 수 있는 연산의 개수를 의미한다. 그리고 또 다른 연산 장치의 주요 속성으로는 클럭 속도가 있다. 클럭이란, 1초에 처리할 수 있는 사이클의 횟수를 의미한다. 클럭 속도가 빠를수록 1초에 처리할 수 있는 사이클의 횟수가 많다는 것을 의미한다.

 

클럭 속도를 빠르게 하면(높이면) 1초에 처리할 수 있는 사이클의 횟수가 많다는 것을 의미하고 이는 곧 초당 연산량이 증가한다는 것을 의미한다. 그래서 클럭 속도가 빨라지면 그 연산 장치를 사용하는 모든 프로그램의 속도가 빨라진다. 마찬가지로, IPC 값이 높아지게 되면 한 사이클에 처리할 수 있는 연산의 개수가 많아짐을 의미하고 이는 벡터화 수준이 증가해 연산 처리 성능이 매우 올라간다. 여기서 '벡터화'란, CPU가 여러 데이터를 입력받아 한 번에 처리하는 것을 의미한다. 데이터 분야에서 자주 사용하는 numpy 라이브러리를 사용하다보면 Verctorization 연산을 한다고 하는데, 이것이 바로 벡터화와 동일한 의미이다. 이렇게 여러 데이터를 입력받아 한 번에 처리하는 종류의 CPU 명령을 SIMD(Single Instruction, Multiple data)라고 한다. 

 

이렇게 클럭 속도를 계속 빠르게 하고 IPC 값을 계속 높게 함으로써 연산 장치의 끝없는 성능 향상을 이루려고 했지만, 이렇게 하기 위해서는 트렌지스터를 더 작게 만들어야 한다는 물리적인 제약 때문에 실현될 수 없었다. 그래서 연산 장치를 제조하는 회사들은 더 빠른 속도를 얻으려고 여러 쓰레드를 병렬로 동시에 실행하는 멀티쓰레딩(=다중 쓰레딩), 비순차적 명령어 처리(out-of-order execution), 멀티 코어 아키텍처 같은 다른 방법들을 모색하게 되었다.

 

멀티쓰레딩은 여러 쓰레드가 동시에 실행되는 것을 의미한다. 그리고 하이퍼쓰레딩은 운영체제에 가상의 두번째 CPU 장치를 인식시킨 후, 하나의 물리적인 프로세서 코어에서 두 개 이상의 쓰레드를 번갈아 가며 실행하도록 하는 기법이다.

 

비순차적 명령어 처리는 프로그램 실행 과정에서 이전 작업의 결과에 영향을 받지 않는 부분을 찾아내서 두 작업을 순서와 관계없이 실행하거나 동시에 실행하는 기법이다. 예를 들어서, A, B, C 라는 작업이 있고 C 작업은 반드시 A 작업이 수행된 후에야 수행이 된다고 해보자. 이 때, B 작업은 A 작업이 수행이 완료되는 것과 상관 없이 동시에 실행될 수 있다는 것이다. 이러한 비순차적 명령어 기법은 한 명령이 메모리에서 데이터를 가져오는 등의 이유로 대기하는 동안 다른 명령을 실행함으로써 사용 가능한 자원을 최대한 활용할 수 있게 한다.

 

다음으로는 멀티 코어 아키텍처가 있다. 이는 실행 유닛 하나에 CPU를 여러 개 두어 전체적인 처리량이 단일 CPU의 처리량 보다 능가하도록 한다. 하지만 멀티 코어 아키텍처를 고려해서 코드를 제대로 작성하기는 어렵다. 단순히 CPU 코어를 더 넣는다고 해서 프로그램 실행 시간이 무조건 단축되지는 않는다. 암달의 법칙이라는 것 때문인데, 암달의 법칙이란 멀티 코어에서 작동하도록 설계된 프로그램일지라도 하나의 코어에서 실행해야만 되는 루틴이 존재하고, 이러한 루틴이 존재하는 상황에서 코어를 더 투입할 수록 기대할 수 있는 최대 성능 향상치의 병목으로 작용한다는 법칙이다. 

 

예를 하나 들어보자. 100명의 고객을 대상으로 한 고객 마다 1분이 소요되는 구두 설문조사를 한다고 가정해보자. 처음에는 조사원이 1명 밖에 존재하지 않아서 100명의 고객을 설문조사를 한다고 하면 첫 번째 참여자에게 설문조사를 한 뒤 두번째 참여자에게 간다. 그리고 두 번째 참여자에게 설문조사를 끝낸 뒤 세번째 참여자에게 간다. 이를 계속 반복하여 100번째 참여자에게 까지 간다. 이는 곧 한 번에 1명의 참여자에게만 설문조사를 수행할 수 밖에 없고, 이는 순차 프로세스라고 할 수 있다. 즉, 한 번에 하나의 작업을 수행하며 다른 작업은 이전 작업이 끝날 때 까지 대기하는 것이다.

 

순차 프로세스

 

위처럼 순차 프로세스로 진행을 한다면 모든 100명의 고객들이 설문조사를 끝내는 데는 100분이 걸릴 것이다. 하지만 만약 조사원의 추가 인력 1명이 더 투입되어 2명의 조사원이 병렬로 설문조사를 수행한다면 50분 밖에 걸리지 않는다. 

 

2명의 조사원이 병렬적으로 수행

 

위 같은 병렬적인 작업이 가능한 이유는 각 조사원이 수행하는 설문조사는 조사원들 서로에 대한 의존성이 없기 때문이다. 즉, 조사원 1이 설문조사를 진행하고 있다고 하더라도 동시에 조사원 2가 설문조사를 진행하는 게 가능하기 때문이다.

 

이렇게 조사원을 하나씩 더 늘려서 고객의 수만큼 100명의 조사원까지 늘린다면 전체 설문조사는 단 1분 만에 끝낼 수 있다. 아래처럼 말이다.

 

조사원을 전체 고객수만큼 100명으로 늘리자

 

그런데 위 상태에서 전체 설문 소요 시간을 더 줄일 수 없을까? 만약 조사원을 100명에서 101명, 102명,.. 으로 더 늘리면 가능할까? 아니다. 전체 고객이 100명 밖에 되지 않으므로 조사원을 더 늘린다고 하여도 전체 설문 소요 시간을 더 단축할 수 는 없다. 이제 우리가 더 줄일 수 있는 방법은 '1명의 고객이 설문조사에 소요되는 시간(1분)'을 줄이는 것이다. 

 

이처럼 CPU도 동일하다. CPU도 특정 코어가 작업을 끝내는 데 걸리는 시간이 병목이 되는 지점 즉, 위 상황에서 비유하자면 1명의 고객이 설문조사에 소요되는 시간을 줄이는 것만이 시간을 더 단축하는 방법만일 때까지는 코어 수를 늘릴수록 성능을 끌어올릴 수 있다. 결국, 병렬 계산에서의 병목 지점은 병렬적으로 각각 순차적으로 실행되어야 하는 작은 작업들이 된다.

 

특정 작업에 걸리는 시간을 단축하는 것이 병렬 작업의 병목 지점을 해결하는 것!

 

게다가 파이썬에서는 GIL이라고 불리는 글로벌 인터프리터 락 때문에 코어를 여러개 활용하기가 쉽지 않다. GIL은 현재 사용 중인 코어가 몇 개든, 한 번에 하나의 명령만 실행하도록 강제한다. 즉, 파이썬에서 동시에 여러 개의 코어에 접근하더라도 한 번에 파이썬 명령 하나만 실행된다. 이 뜻을 위 상황에 비유하자면, 100명의 조사원이 있더라도 한 번에 한사람만 설문 조사를 할 수 있다는 뜻이다. 즉, 100명의 조사원이 동시에 100명의 고객을 설문조사할 수가 없고, 조사원 2가 고객 2를 설문조사하려고 한다면 조사원 1이 고객 1을 설문조사하는 작업이 끝나야만 할 수 있다는 것이다. 이렇게 되면 다수 코어를 사용하는 장점이 사라진다.

 

물론 파이썬에서 위 단점을 개선하지 못하고 그대로 방치하지는 않는다. 추후에 배울 표준 라이브러리인 multiprocessing 모듈을 사용하거나 numpy, numexpr, Cython 과 같은 기술을 이용하거나 분산 컴퓨팅 모델을 사용하는 방법으로 해결할 수 있다. 

 

참고로 필자도 이번 공부를 진행하면서 CPU 동작 원리를 정말 잘 설명하는 유튜브를 알게되었다. 반도체에서부터 CPU 동작원리까지 정말 이해하기 쉽게 설명해주니 꼭 시청해보자.

1-2. 메모리 장치

컴퓨터에서 메모리 장치는 비트(bit)라는 데이터 단위를 저장한다. 메모리 장치에는 메인보드의 레지스터, RAM, HDD(하드 디스크 드라이브), SSD도 포함된다. 이러한 여러가지 메모리 장치들 간의 가장 큰 차이점은 바로 데이터를 읽고 쓰는 속도이다. 그런데 이 데이터를 읽고 쓰는 속도는 그 메모리 장치가 어떻게 데이터를 읽어들이는지에 따라 달라진다는 점이다.

 

기본적으로 메모리 장치는 대부분 데이터를 조금씩 자주 읽을 때 보다 한번에 많이 읽을 때 훨씬 빠르게 동작한다. 데이터를 조금씩 자주 읽는 것을 임의 접근(Random Access) 이라고 하고, 한 번에 많이 읽는 것을 순차 접근(Sequential Access)이라고 한다.

 

또한 메모리 장치에는 레이턴시라는 특징이 있다. 메모리에서의 레이턴시는 메모리 장치가 데이터를 찾기까지 걸리는 시간을 의미한다. 단적인 예로, HDD는 특정 데이터를 찾기 위해서 물리적으로 헤드를 직접 움직여야 하기 때문에 데이터를 찾기까지 시간이 오래 걸리며 이를 레이턴시가 길다고 표현한다. 반면 RAM은 모든 데이터를 전자적으로 읽어들이기 때문에 레이턴시가 짧다. 읽기/쓰기 속도에 따라 일반적인 메모리 장치의 순위를 나열해 보면 빠른 순서대로 [CPU의 L1,L2 캐시 ➡️ RAM ➡️ SSD ➡️ HDD]이다.

 

많은 시스템은 보통 메모리를 단계별로 운용하는데, 먼저 HDD, SSD와 같은 곳에 전체 데이터를 저장하고 일부를 RAM으로 옮긴다. 여기서 일부라고 한다면 애플리케이션 코드나 사용중인 변수 같은 데이터들을 의미한다. 그리고 이 중 매우 작은 부분을 CPU의 L1, L2 캐시로 옮기는 식이다. 프로그램 메모리의 사용 패턴을 최적화하려면 어떤 데이터가 어디에 저장될 것인지, 그리고 어떻게 저장될 것인지, 몇 번이나 데이터를 옮길 것인지를 최적화하면 된다. 또한 비동기 I/O나 선점형 캐시를 사용하면 데이터가 필요할 때 바로 읽을 수 있다. 이러한 모든 과정을 다른 계산을 수행 중일 때도 독립적으로 일어나게 된다.

1-3. 연결 장치

마지막으로 연결 장치인데, 연결 장치는 앞에서 살펴본 연산 장치와 메모리 장치같은 구성 요소들이 서로 어떻게 통신하는지에 대한 것이다. 여러 통신 방법이 있지만 모두 버스의 변형이다. 

 

기본적으로 탑재되어 있는 버스 중 하나로 FSB(Front Side Bus)가 있다. FSB는 RAM과 CPU의 L1,L2 캐시를 연결한다. FSB는 처리할 준비가 된 데이터를 옮겨서 프로세서가 계산할 수 있도록 해주고, 프로세서의 계산이 완료된 데이터를 다시 돌려준다.

 

FSB 버스와 CPU 내부 버스

 

물론 CPU와 시스템 메모리가 외부 하드웨어와의 통신을 하기 위해서는 외부 버스를 이용하기도 한다. 하지만 외부 버스는 보통 FSB보다 통신 속도가 느리다는 점이 있다. 사실 L1, L2 캐시 메모리의 빠른 속도의 이유는 CPU 내부 버스라는 것이 매우 빠른 버스이기 때문이다. CPU 내부 버스에 비해 상대적으로 FSB 버스가 느리다. 그래서 계산에 필요한 데이터를 대략으로 L1, L2 캐시에 올려두면 CPU의 실질적인 연산을 담당하는 ALU가 데이터를 매우 빠르게 갖고와서 계산을 많이 할 수 있게 된다. 

 

이렇게 어떤 버스를 이용하느냐에 따라 속도가 매우 달라지는데, 요즘 인공지능 분야에서 핫한 GPU 장치가 이러한 버스 때문에 단점이 존재한다. GPU는 주변 장치에 속하므로 FSB 버스보다는 훨씬 느린 PCI(Peripheral Component Interconnect Bus) 버스로 연결된다. 따라서 GPU에 데이터를 보내고 받는 작업이 큰 부담을 준다. 물론 이기종 컴퓨팅이나 CPU와 GPU를 FSB로 연결한 시스템이 있다면 GPU에 데이터를 보내는 전송 비용을 많이 줄일 수 있다.

 

위와 같이 컴퓨터 안에서의 데이터 통신만 있는 것이 아니라 네트워크를 통한 데이터 전송도 있다. 클라우드를 활용한 컴퓨팅이 요즘 많이 활성화 되는 시대에 네트워크를 통한 데이터 통신은 필수적이다. 하지만 일반적으로 네트워크 통신은 지금까지 살펴본 FSB, PCI 버스 등 다른 통신과는 비교할 수 없을 정도로 느리다. 예로, FSB로 초당 십여 기가비트를 전송할 수 있지만 네트워크 통신은 수십 메가비트 밖에 전송하지 못한다.

 

버스의 핵심 속성인 속도는 주어진 시간에 얼마나 많은 데이터를 전송할 수 있는지이다. 이렇게 한 번에 전송할 수 있는 데이터의 양을 버스 폭(width) 이른바, '대역폭'을 의미하고, 초당 데이터를 몇 번 전송할 수 있는지를 나타내는 버스 주파수(Frequency)가 있다. 참고로 한 번의 전송으로 데이터를 옮기는 과정은 순차적으로 이루어진다. 즉, 메모리에서 한 덩어리의 데이터를 읽은 후에 다른 장소로 옮기는 순서로 이루어진다. 그래서 버스 폭이 넓으면 필요한 데이터를 한 번에 옮길 수 있으므로 코드를 벡터화할 수 있게 된다.(벡터화란 용어는 [1-1. CPU 연산 장치] 목차에서 언급했다) 

 

신기한 점은 이런 버스의 속도를 결정하는 특징들을 컴퓨터 메인보드의 물리적인 구조를 변경함에 따라 바뀐다. 즉, 두 칩을 가깝게 배치하면 그 만큼 물리적인 선의 길이가 짧아지므로 버스 속도가 빨라지고, 연결하는 선의 수가 많아지면 버스 폭이 넓어진다. 

2. 기본 요소 조합하기

지금까지 컴퓨터의 기본적인 구성 요소를 살펴보았고, 이 구성 요소들을 어떻게 다루면 코드의 성능을 최적화할 수 있을지도 개념적으로 알아보았다. 이번 목차에서는 그러면 실제적으로 Python 코드를 통해서 어떻게 코드를 최적화하면서 속도를 끌어올릴 수 있는지 알아보자. 우선 아래와 같이 특정 수가 소수인지 아닌지 판별하는 에라토스테네스의 체를 구현한 코드가 있다고 해보자.

 

import math

def check_prime_number(number):
    sqrt_number = math.sqrt(number)
    for i in range(2, int(sqrt_number) + 1):
        if (number / i).is_integer():
            return False
    return True
    
print(check_prime_number(10000019))

 

위 코드의 동작 방식을 이해하기 위해 앞서서 배운 여러가지 컴퓨터 장치들과 연계해서 살펴보도록 하자.

 

먼저  10000019 라는 숫자를 number에 할당했기 때문에 RAM 장치에 number가 저장된다. 그리고 number의 제곱근을 계산(sqrt())하기 위해서 number에 할당된 값을 CPU로 보내야 한다. 그리고 CPU에서는 제곱근 계산을 수행할 것이다. 이 과정을 이상적인 컴퓨팅 관점에서 보면 순서가 아래처럼 될 것이다.

 

  1. number가 RAM에 저장된다
  2. RAM에서 number를 꺼내와서 CPU의 L1, L2캐시에 저장한 후 CPU로 전달한다
  3. CPU에서 제곱근 계산을 수행한다
  4. 제곱근을 계산한 값을 다시 RAM으로 전송한다

위 과정을 도식화했다

 

위 이상적인 과정의 핵심은 가장 시간이 오래걸리는 RAM에서 데이터를 읽어들이는 횟수를 최소화한 것이다. 그리고 CPU에서 제곱근 계산을 위해 RAM 보다 데이터를 읽는 속도가 훨씬 빠른 L1, L2 캐시 메모리로부터 데이터(number)를 읽어들였고, 위 과정 중에 FSB 버스라는 연결 장치 통신 속도가 가장 느린데, 이 FSB 버스를 통한 데이터 전송 횟수를 최소화하기도 했다. 따라서 이 과정을 이상적이라고 할 수 있겠다.

 

다음으로 생각해볼 수 있는 이상적인 개선점은 CPU의 벡터화 능력을 이용하는 것이다. 현재 위 코드에서는 for ~ loop 구문을 보면 element 하나씩 처리가 되는 것을 볼 수 있다. 이렇게 한 개씩 처리하지 않고 element 여러개를 한 번에 처리할 수 있도록 할 것이다. 이렇게 하면 CPU 벡터화를 이용하게 되어 동시에 여러 독립된 계산을 수행할 수 있게 된다. 이 과정의 AS-IS, TO-BE를 도식화면 아래와 같다.

참고로 아래 그림은 코드의 for ~ loop 구문 부분만 도식화한 것이다.

 

CPU 벡터화를 하게 되었을 때의 과정 도식화

 

만약 위 벡터화 과정을 코드로 개선해 표현한다면 아래와 같아진다.(물론 아래 코드는 정상 동작하지 않는 파이썬 코드이다)

 

def check_prime_number(number):
    sqrt_number = math.sqrt(number)
    numbers = range(2, int(sqrt_number) + 1)
    for i in range(0, len(numbers), 5):
        results = (number / numbers[i: i+5]).is_integer()
        if any(results):
            return False
    return True

 

위 코드를 보면 주목할만한 포인트는 numbers 라는 iterable한 변수에 슬라이싱을 이용해서 데이터를 한 번에 여러개를 갖고온 점이다. 그리고 any() 함수의 결과를 특정 변수에 할당해서 RAM에 되돌려주지 않고 바로 CPU에서 처리하도록 하였다. 즉, 아래처럼 만약 작성했다면 any() 함수의 결과를 RAM에 전달해주는 과정이 추가가 되었을 것이다.

 

def check_prime_number(number):
    sqrt_number = math.sqrt(number)
    numbers = range(2, int(sqrt_number) + 1)
    for i in range(0, len(numbers), 5):
        results = (number / numbers[i: i+5]).is_integer()
        tmp = any(results)
        if tmp:
            return False
    return True

 

벡터화의 구체적인 동작 방식은 추후에 더 자세히 배워볼 에정이다.

3. 파이썬의 가상머신

파이썬 인터프리터는 컴퓨터의 구성 요소(연산, 메모리, 연결 장치 등..)를 추상화해준다. 따라서 배열을 위한 메모리 할당, 메모리 정렬, CPU로 데이터를 보내는 순서 등 저수준의 동작 자체를 개발자가 고민하지 않아도 된다. 이러한 점은 파이썬의 입문 진입장벽을 낮추고 개발 자체에만 집중하도록 해주지만, 잘못 사용하게 되면 성능상의 비용이 엄청나게 발생한다.

 

사실 파이썬은 내부적으로 잘 최적화된 명령어 집합을 실행하긴 하지만, 명령어 집합을 올바른 순서로 실행하도록 하면 성능이 더 좋아진다. 예를 들어, 아래의 2개 함수는 모두 시간 복잡도 $O(n)$으로 동작하지만 search_fast 라는 함수는 불필요한 계산을 건너뛰어 루프를 더 빨리 끝낼 수 있으므로 search_slow 함수보다 더 빠르다.

 

def search_fast(box: list[str], target: str):
    for item in box:
        if item == target:
            return True
    return False


def search_slow(box: list[str], target: str):
    flag = False
    for item in box:
        if item == target:
            flag = True
    return flag

 

위와 같은 코드의 경우 native한 파이썬 코드로, 그것도 매우 간단하게 되어 있기 때문에 어떤 코드가 느리게 동작할지 감이 오지만 써드 파티 라이브러리나 파생된 타입과 같은 복잡한 코드들로 이루어졌을 경우, 어떤 코드가 더 빨리 동작하는지 파악하기가 매우 어렵다. 예를 들어, 아래 2가지 다른 함수를 비교해보자.

 

def search_f1(box: list[str], target: str):
    return any((item == target for item in box))


def search_f2(box: list[str], target: str):
    return any([item == target for item in box])

 

위 코드 2개의 성능을 직관적으로 비교하기 위해서라도 개발자는 파이썬의 list comprehension 과 generator의 동작 과정을 미리 알고 있어야 바로 한 번에 어떤 함수가 더 빠른지 판단할 수 있을 것이다. 그래서 이러한 해답을 찾기 위해 다음 포스팅에서 배울 프로파일링을 통해 코드의 느린 부분을 찾아내고 같은 계산을 더 효율적인 방법으로 처리하는 작업을 수행해서 코드의 성능을 끌어올릴 수 있다. 결과는 같더라도 프로파일링 전/후 코드 내부에서 동작하는 계산 횟수와 데이터 전송 횟수는 엄청나게 차이가 날 수 있다.

 

이러한 파이썬의 추상화 과정은 CPU가 다음 계산에 사용할 데이터를 (읽는 속도가 매우 빠른) L1, L2 캐시에 유지해야 하는 최적화 과정을 방해한다. 원인이 몇가지 있다.

 

첫째로 파이썬 객체가 메모리에 최적화된 형태로 저장되지 않는다는 점이다. 왜냐하면 파이썬은 메모리를 자동으로 할당하고 해제하는 참조 카운트 기반의 GC(가비지 컬렉터)를 사용하기 때문이다. 이 GC로 인해서 CPU 캐시에 데이터를 전송하는 데 영향을 미치는 메모리 단편화(fragmentation)를 일으킨다. 메모리 단편화는 예전 CS 포스팅에서 다룬적이 있는데, 쉽게 말해서 가용 메모리 공간이 충분이 있음에도 불구하고 그 가용 메모리를 사용하지 못하는 것이다. 또한 파이썬은 메모리에 저장되는 자료구조를 직접적으로 변경할 수 없으므로 (한 번에 전송할 수 있는 데이터 양을 결정하는) 버스 대역폭이 넓더라도 한 번의 계산에 필요한 정보를 한 번에 전송할 수 없다.

 

두번째로는 파이썬이라는 언어의 본질적인 문제로, 기계가 알아서 데이터 타입을 추측하는 동적 타입을 사용하며 컴파일되지 않는다는 점이다. 자바나 C, Go 같은 컴파일 계열의 언어들은 정적인 코드를 컴파일 할때, 컴파일러가 코드의 많은 부분을 변경해서 최적화할 수가 있다. 하지만 파이썬은 컴파일 언어가 아니고 코드의 기능이 런타임에 변경되는 동적 타입 언어이기 때문에 최적화 알고리즘이 반영되기가 어렵다. 그래서 이를 런타임을 Cython으로 두어 해결하기도 한다. 이 Cython은 파이썬 코드를 컴파일하고 컴파일러에게 동적인 코드가 실제로 어떻게 동작하는지 일종의 힌트를 주어 코드를 최적화 할 수 있도록 해준다.

 

마지막으로는 앞서 배운 GIL(글로벌 인터프리터 락) 때문에 코드를 병렬로 실행하려고 할 때 성능을 낮추게 된다. GIL 때문에 코어가 여러개여도 동시에 사용할 수 있는 코어는 단 하나 뿐이다. 그래서 병럴적으로 실행되는 코드로 바꾸기 전/후가 동일하게 동작하게 된다. 이러한 문제는 multiprocessing 라이브러리를 사용하거나 Cython이나 외부 함수를 사용해서 해결할 수 있긴 하다.


지금까지 파이썬의 단점만 언급한 것 같아 파이썬을 왜 사용해야 하나 싶지만, 이런 단점에도 불구하고 파이썬은 빠른 개발과 점점 커지는 생태계같은 매우 큰 장점이 앞서 언급한 단점들을 많이 상쇄시킨다. 특히나 파이썬에서 나오는 라이브러리들은 타 언어로 작성된 도구를 감싸서 다른 시스템을 쉽게 호출하는데, 사이킷런의 경우 C로 작성된 LIBLINEAR와 LIBSVM을 사용하고, numpy는 BLAS, 다른 C, 포트란이라는 라이브러리를 포함하기도 한다. 이러한 라이브러리, 모듈들을 활용하는 파이썬 코드들은 C로 작성된 코드만큼이나 빠르게 작동한다.

 

지금까지 간단하게 파이썬을 고성능 프로그래밍 하기 위한 기초적인 지식 내용과 개념들에 대해 배웠다. 하지만 이러한 고성능 프로그래밍은 적절한 상황에 적용되어야 한다고 책에서는 이야기 한다. 성능을 높이는 것이 필요하지 않은 상황임에도 불구하고 성능을 쥐어짜내려면 정말 Cython을 도입한다거나 하는 등 액션을 취할 수 있지만 이렇게 하는 것에는 반드시 제대로 된 이해가 전체되어야 한다는 것이다. 그리고 어떻게든 Cython을 도입하여 성능을 끌어올렸을 지라도 그 뒤에 따라오는 코드 유지보수 비용과 같이 코드를 관리하는 팀원들의 학습 리소스 같은 것들도 매우 중요하게 고려해야 한다.

 

이제 그러면 다음 포스팅부터는 고성능 프로그래밍의 첫 걸음인 코드 프로파일링을 하는 방법에 대해 배워보도록 하자.

반응형