컴파일러의 목적은 한 언어를 다른 언어로 변환하는 것이다. 그리고 어떤 컴파일러는 시스템에서 바로 실행할 수 있는 저수준 기계어로 컴파일하지만, 어떤 컴파일러는 가상 머신에서 실행하기 위한 중간 언어로 컴파일한다.

  • 닷넷 CLR, 자바 등은 여러 시스템 아키텍쳐에서 사용할 수 있는 중간 언어로 컴파일한다.
  • C, Go, C++, 파스칼 등은 바이너리 실행 파일로 컴파일한다. 바이너리는 컴파일한 플랫폼과 동일한 플랫폼에서만 사용할 수 있다.

파이썬 애플리케이션은 보통 소스코드 형태로 배포한다. 파이썬 인터프리터는 소스 코드를 변환 후 한 줄씩 실행한다.

CPython 런타임이 첫 번째 실행될 때 코드를 컴파일 하지만, 이 단계는 사용자에게 노출되지 않는다.

 

python interpreter 내부 동작 과정

 

파이썬 코드는 기계어 대신 `바이트코드` 라는 저수준 중간 언어로 컴파일된다.

바이트코드는 .pyc 파일에 저장되고, 실행을 위해 캐싱된다.

코드를 변경하지 않고 같은 파이썬 애플리케이션을 다시 실행하면 매번 다시 컴파일하지 않고 컴파일 된 바이트코드를 불러와서 빠르게 실행할 수 있다.

 

 

 

CPython 컴파일러는 왜 C로 작성되는가?

윈도우와 리눅스 커널 api는 모두 C로 작성되었기 때문에, 여러 표준 라이브러리모듈이 저수준 OS api에 접근하기 위해 C로 작성되었다. 

 

컴파일러는 크게 두 가지 유형이 있는데,

  1. 셀프 호스팅 컴파일러는 Go 컴파일러처럼 자기 자신으로 작성한 컴파일러다. 셀프 호스팅 컴파일러는 부스트래핑이라는 단계를 통해 만들어진다.
  2. Source to Source 컴파일러는 컴파일러를 이미 가지고 있는 다른 언어로 작성한 컴파일러이다.

c.f. PyPy라는 파이썬으로 작성된 파이썬 컴파일러도 있다.

 

 

 

메모리 관리

먼저 C에서의 메모리 할당에 대해 알아보자면, 

  • 정적 메모리 할당: 필요한 메모리는 컴파일 시간에 계산되고, 실행 파일이 실행될 때 할당된다.
    • C에서는 타입 크기가 고정되어 있으며, 컴파일러는 모든 정적/전역 변수에 필요한 메모리 계산 후 필요한 메모리 양을 애플리케이션에 컴파일해 넣는다. 메모리 할당 시에는 시스템 콜을 이용한다. 
  • 자동 메모리 할당: 스코프에 필요한 메모리는 프레임에 진입 시 콜스택 내 할당되고, 프레임이 끝나면 해제된다.
    • 마찬가지로 컴파일 시간에 필요한 메모리를 계산한다.
#include <stdio.h>
static const double five_ninths = 5.0/9.0; // const: 정적 할당

double celsius(double fahrenheit){
	double c = (fahrenheit - 32) * five_ninths; // 함수 내 변수는 자동 할당
    return c;
}

int main(){
	double f = 100; // 함수 내 변수는 자동 할당
    printf("%f F is %f C\n", f, celsius(f)); // celsius(f) 반환값은 자동 할당
    return 0;
}
  • 동적 메모리 할당: 메모리 할당 api를 호출하여 런타임에 메모리를 동적으로 요청하고 할당한다
    • 사용자 입력에 따라 필요한 메모리가 결정되는 경우
    • OS는 프로세스가 메모리를 동적 할당 할 수 있도록 시스템 메모리의 일부를 예약해 두는데, 이 공간을 heap 이라 한다.
    • 동적 할당 메모리는 제대로 반환하지 않으면 메모리 누수가 발생한다.

 

 

C 기반인 CPython은 C 메모리 할당 방식들의 제약조건을 따르기 쉽지 않다. 왜냐하면

  1. 파이썬은 동적 타입 언어로, 변수의 크기를 컴파일 시간에 계산할 수 없다.
  2. 코어 타입의 크기는 대부분 동적이다. 리스트 타입은 크기를 정할 수 없고, 딕셔너리 타입은 제한없이 키를 가질 수 있다.
  3. 값의 타입이 달라도 같은 변수명을 재사용할 수 있다.

따라서 C 메모리 할당 방식의 제약을 극복하기 위해 CPython은 동적 메모리 할당에 크게 의존하며 `참조 카운팅`과 `가비지 컬렉션`을 사용하여 메모리를 자동 해제하도록 했다. Python의 객체 메모리는 개발자가 직접 할당하는 대신, 하나의 통합 api를 통해 자동으로 할당된다.

CPython은 세가지 동적 메모리 할당자 도메인을 제공한다.

  • 저수준 도메인: 시스템 힙이나 대용량 메모리 또는 비객체 메모리 할당 시 사용된다.
  • 객체 도메인: 파이썬 객체와 관련된 메모리 할당 시 사용된다.
  • PyMem 도메인: PYMEM_DOMAIN_OBJ 와 동일하며, 레거시 api 용도로 제공된다.

또한 CPython은 두가지 메모리 할당자를 사용한다.

  • malloc: 저수준 메모리 도메인을 위한 OS 할당자
  • pymalloc: PyMem 도메인과 객체 메모리 도메인을 위한 CPython 할당자
    • 기본적으로 CPython에 같이 컴파일 된다. pyconfig.h에서 WITH_PYMALLOC=0으로 설정 후 파이썬을 컴파일하면 pymalloc을 제거할 수 있으며, 이 경우 PyMeM과 객체 메모리 도메인 api도 시스템 할당자를 사용한다.

 

CPython 메모리 할당자에 대해 알아보기 전에 먼저 Python 의 메모리 구조 레벨에 대해 알아보자.

  • Arenas
    • 힙 메모리에 할당 된 고정된 크기의 256KiB, 가장 큰 메모리 단위를 나타낸다.
      • 아레나는 익명 메모리 매핑을 지원하는 시스템에서는 mmap()으로 할당한다.
    • 파이썬 할당자 pymalloc에서 사용하는 메모리 매핑이다.
    • 시스템 페이지 크기에 맞추어 정렬하며, 시스템 페이지 경계는 고정 길이의 연속 메모리 청크다.
  • Pools
    • arena 당 pool 개수는 64개로 고정되어 있다.
    • pool의 크기는 OS의 default 메모리 페이지 사이즈와 일치해야 한다.
    • 고정된 크기의 4KB 크기를 가지며, 세 가지 상태를 가질 수 있다.
      • Empty; pool이 비어있으므로 할당할 수 있다.
      • Used; pool에 비어있지도 가득차지도 않게 개체가 포함되어 있다.
      • Full; pool이 가득차서 더 이상 할당할 수 없다.
    • pool은 요청이 들어오면 할당되며, 요청된 크기 인덱스에 맞는 사용 가능한 pool이 없는 경우 새로운 pool을 생성한다.
  • Blocks
    • 블록의 크기는 고정되어 있지 않으며, 블록 사이즈는 8 ~ 512 bytes 인 8의 배수이다.
    • 각 블록은 특정 크기의 파이썬 객체를 하나만 저장할 수 있으며, 세 가지 상태를 가질 수 있다.
      • Untouched; 할당되지 않음
      • Free; 할당 되었지만, 해제되어 할당에 사용할 수 있다.
      • Allocated; 할당됨
    • 아래 그림과 같이 사용되지 않은 블록은 풀 내부의 freeblock 단일 연결 리스트에 연결된다. 할당 해제된 블록은 freeblock 리스트의 맨 앞에 추가되며, 풀이 초기화 되면 첫 두 블록은 freeblock 리스트에 연결된다.

할당된 블록과 해제된 블록, 사용되지 않은 블록들을 포함한 사용중인 풀

python 디버그 api `debugmallocstats`를 사용하면 할당된 아레나의 개수와 사용된 블록의 총 개수를 알 수 있다.

크기 인덱스 테이블과 할당 상태를 비롯한 다양한 통계 정보가 출력된다.

더보기

Python 3.10.4 (main, Jun 29 2022, 12:14:53) [GCC 11.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import sys
>>> sys._debugmallocstats()
Small block threshold = 512, in 32 size classes.

class   size   num pools   blocks in use  avail blocks
-----   ----   ---------   -------------  ------------
    0     16           1             128           893
    1     32           3            1143           387
    2     48          10            3147           253
    3     64          47           11794           191
    4     80          33            6697            35
    5     96           7            1102            88
    6    112           5             595           130
    7    128           3             370            11
    8    144          15            1641            54
    9    160           2             150            54
   10    176          27            2451            33
   11    192           2              96            74
   12    208           1              73             5
   13    224           3             206            10
   14    240           1              57            11
   15    256           1              52            11
   16    272           1              46            14
   17    288           1              34            22
   18    304           5             232            33
   19    320           1              31            20
   20    336           1              20            28
   21    352           1              18            28
   22    368           1              23            21
   23    384           1              17            25
   24    400           2              42            38
   25    416           2              71             7
   26    432           2              54            20
   27    448           2              48            24
   28    464           2              43            27
   29    480           2              34            34
   30    496           1              29             3
   31    512           2              48            14

# arenas allocated total           =                    3
# arenas reclaimed                 =                    0
# arenas highwater mark            =                    3
# arenas allocated current         =                    3
3 arenas * 1048576 bytes/arena     =            3,145,728

# bytes in allocated blocks        =            2,795,680
# bytes in available blocks        =              261,600
2 unused pools * 16384 bytes       =               32,768
# bytes lost to pool headers       =                9,024
# bytes lost to quantization       =               13,888
# bytes lost to arena alignment    =               32,768
Total                              =            3,145,728

arena map counts
# arena map mid nodes              =                    1
# arena map bot nodes              =                    1

# bytes lost to arena map root     =              262,144
# bytes lost to arena map mid      =              262,144
# bytes lost to arena map bot      =              131,072
Total                              =              655,360

            8 free PyDictObjects * 48 bytes each =                  384
           4 free PyFloatObjects * 24 bytes each =                   96
          6 free PyFrameObjects * 360 bytes each =                2,160
           21 free PyListObjects * 40 bytes each =                  840
   2 free 1-sized PyTupleObjects * 32 bytes each =                   64
  16 free 2-sized PyTupleObjects * 40 bytes each =                  640
   5 free 3-sized PyTupleObjects * 48 bytes each =                  240
   2 free 4-sized PyTupleObjects * 56 bytes each =                  112
   2 free 5-sized PyTupleObjects * 64 bytes each =                  128
   3 free 6-sized PyTupleObjects * 72 bytes each =                  216
   3 free 7-sized PyTupleObjects * 80 bytes each =                  240
   3 free 8-sized PyTupleObjects * 88 bytes each =                  264
   0 free 9-sized PyTupleObjects * 96 bytes each =                    0
 0 free 10-sized PyTupleObjects * 104 bytes each =                    0
 3 free 11-sized PyTupleObjects * 112 bytes each =                  336
 0 free 12-sized PyTupleObjects * 120 bytes each =                    0
 3 free 13-sized PyTupleObjects * 128 bytes each =                  384
 2 free 14-sized PyTupleObjects * 136 bytes each =                  272
 2 free 15-sized PyTupleObjects * 144 bytes each =                  288
 0 free 16-sized PyTupleObjects * 152 bytes each =                    0
 1 free 17-sized PyTupleObjects * 160 bytes each =                  160
 0 free 18-sized PyTupleObjects * 168 bytes each =                    0
 0 free 19-sized PyTupleObjects * 176 bytes each =                    0

 

또는 표준 라이브러리 `tracemalloc` 모듈로 객체 할당자의 메모리 할당 동작을 디버깅할 수도 있다. 이 모듈은 객체 할당 위치나 할당된 메모리 블록의 개수 등에 대한 정보를 제공한다. 메모리 추적을 활성화 하려면 파이썬 실행 시 -X tracemalloc=1 옵션을 추가하면 된다. 여기서 1은 추적할 프레임 수이며, 더 늘릴수도 있다.

 

 

CPython 할당자에 대해 다시 돌아가보자면, 시스템 메모리 할당자 위해 구축된 할당자로 독자적인 할당 알고리즘을 구현한다. 이 알고리즘은 시스템 할당자와 비슷하지만 CPython에 특화되어 있다.

참고💡 대부분의 메모리 할당 요청은 고정된 크기의 작은 메모리를 요구한다.
- PyObject는 16byte, PyASCIIObject는 42byte, PyCompactUnicodeObject는 72byte, PyLongObject는 32byte 크기
- pymalloc 할당자로는 메모리 블록을 최대 256kb까지 할당할 수 있고, 그보다 큰 할당 요청은 시스템 할당자로 처리한다.

그리고 중앙 레지스터가 블록 위치와 사용할 수 있는 블록 개수를 관리한다. 한 풀이 모두 사용되면 다음 풀이 사용된다.

이러한 방식은 CPython에서 주로 만들어지는 작고 수명이 짧은 객체를 할당할 때 적합하다. 그리고 힙 할당 대신 메모리 맵(mmap())을 사용하게 된다.

 

이렇게 CPython은 C의 동적 메모리 할당 시스템 위에 구축되었으며, 메모리 요구 사항은 런타임에 결정되고 PyMem api를 사용하여 메모리를 시스템에 할당한다고 했다. 파이썬은 메모리 관리를 단순화 하기 위해 `객체 메모리 관리 전략`을 도입했다. 여기에는 참조 카운팅과 가비지 컬렉션이 있다.

 

 

파이썬 프로세스 내부 구조

컴퓨터의 CPU는 프로세스를 실행할 때 다음과 같은 추가 데이터가 필요하다.

  • 실행 중인 명령이나 명령을 실행하는 데 필요한 다른 데이터를 보관하는 레지스터
  • 프로그램 시퀀스의 어떤 명령을 실행 중인지 저장하는 프로그램 카운터(명령 포인터)

CPython 프로세스는 컴파일된 CPython 인터프리터+모듈로 구성된다.

모듈을 런타임에 불러들여서 CPython 평가 루프를 통해 명령으로 변환한다.

프로그램 레지스터와 프로그램 카운터는 프로세스에 저장된 명령 중 한 명령만을 가리킨다.

즉, 한번에 하나의 명령만 실행할 수 있다. = CPython은 한 번에 하나의 파이썬 바이트 코드 명령만 실행할 수 있다.

c.f. 따라서 프로세스의 명령을 병렬로 실행하려면 다른 프로세스를 포크하거나 스레드를 스폰하는 방법을 사용한다.

 

 

 

 

후기

이 책에는 CPython 컴파일러가 어떻게 동작하는지, 파이썬 코드를 어떻게 파싱하는지에 관한 설명이 8장까지 매우매우 자세하게 나와있고,

9장 이후 파이썬에서 메모리 관리가 어떻게 구현되는지 나와있다. 9장 이후의 내용이 굉장히 많이 도움이 되었다.

 

이책을 읽다가 새롭게 알게된 사실은 python의 long과 c의 long이 다르다는 점이었다...!!

python의 long은 숫자 리스트로, 12378562834를 [1,2,3,7,8,5,6,2,8,3,4]로 표현한다고 한다.

Python 정수 표현 시 메모리 사용량 변화 - https://mortada.net/can-integer-operations-overflow-in-python.html

(그치만 numpy/pandas 같은 패키지를 사용할 때는 C 스타일이 유지되기 때문에 오버플로우 발생을 고려해야 한다.)

 

그리고 파이썬에서 가비지 컬렉터가 카운터=0일때 회수된다는것은 여러 곳에서 찾아볼 수 있었지만

어느 주기로 실행되는지는 잘 나와있지 않아 그동안 궁금했는데, 

가비지 컬렉션은 정해진 양만큼의 연산이 일어났을 때 주기적으로 실행된다는 것을 알게 되었다. 

 

이 책에는 CPython을 위해 설계된 다양한 도구를 이용하는 벤치마킹, 프로파일링, 실행 추적 등이 자세하게 나와있어서 쉽게 따라할 수 있었다.

 

 

참고:

https://scoutapm.com/blog/python-memory-management

+ Recent posts