python은 "인터프리터" 언어다. 당신이 어떤 파일로든
.py
파일만 만들고, python의 인터프리터가 설치되어 있다면 [ compiler -> byte code -> pvm -> running ] 까지 다 해준다. excel 파일을 열기위해 MSoffice 를 설치하는것 처럼, 설치하는 python은 인터프리터 S/W 이다.
🔥 본 글은 python 3.6 이후 버전 중심 설명입니다. 🔥
없는 디자인 센스,, 피그마로 영끌을 해보았다,,
우리가 만든 .py
파일은 python interpreter에 의해 compile 되고, bytecode로 바뀐뒤에 PVM 위에서 러닝된다.
여기서 이 "Python Interpreter" 의 종류는 cpython, pypy, ironpython 등이 있는 것이다. 가장 많이 사용하는 것은 기본 제공되는 cpython
이다.
byte code로 컴파일 되기까지도 역시 많은 과정을 거친다. 아래와 같은 과정은 절대적이지 않다. 당연히 인터프리터의 종류에 따라 상이하다.
위에서 Generated token이 parser에게 전달되어 파싱된다. token을 가지고 Abstract Syntax Tree(A.S.T)라는 token(code들)사이의 관계를 나타내는 트리 구조를 만든다. 해당 단계는 프로그래밍 언어를 구축하는데 있어서 중요개념 중 하나이다. 위키 트리에서 좋은 예시가 있다
AST를 제어 흐름 그래프(Control FlowGraph)로 변환한다. 해당 과정까지의 depth있는 설명은 여기 링크에서 제대로 확인 가능하다
위와 같은 과정을 가치면 Byte code 라는 .pyc
형태의 intermediate language code를 생성된다.
.pyc
파일은 파이썬 3.2 버전 이전에는 .py 파일과 같은 경로 에 생성되고, 3.2 이후 버전에서는 __pycache__
디렉터리 아래에 생성 된다.
근데 무조건적으로 .pyc
를 생성하는 것이 아니라, .py 파일이 다른 스크립트에 의해 import 되었을 경우에만 생성 된다.
import 문이 호출되었을 때는 아래와 같은 확인을 한다.
컴파일 언어인 자바와 차이나는 부분은 어딜까? 자바코드는 interpreter(해석기)에게 소스가 전달되기 전에 컴파일러에 의해 컴파일된다. 즉, compiler가 interpreter 밖에 존재한다.
.pyc
가 전달되어져서 python 코드가 실행 되며 파이썬의 런타임 엔진 이다. 파이썬 시스템의 일부이기 때문에 별도의 설치가 필요하지 않고, "존재" 한다.
위 "1)~3)" 과정은 PVM에서 일어난다. 즉 실제 interpreting 작업 (Row 단위로 해석하며 프로그램을 구동하는 방식)을 담당하며, byte code를 machine code로 변환하여 실행되는 곳이다.
추가로, "프로즌 바이너리"는 작성한 파이썬 프로그램을 실행 파일로(.exe) 변환 하는 것 또한 가능하다. PyInstaller, py2exe, py2app 등을 통해서 "실행 가능한 바이너리"로 만들수 있다.
프로즌 바이너리를 만들 때에는 바이트 코드와, 실행 환경(PVM), 그리고 의존성 모듈을 단일 패키지로 만들고 결과물은 실행 가능한 형태 (.exe 등) 가 된다.
PVM에서 "python process"가 돌아간다.
너무 깊이 들어가면, OS에 대한 얘기가 되어 버린다. 잠깐 "python의 메모리공간" 을 생각하기 전에 어떻게 memory를 할당받는지 다시 생각해보자.
파이썬 메모리 공식문서, 모든 파이썬 객체와 데이터 구조를 포함하는 비공개 힙(private heap)은 python memory manager가 비공개적으로 알아서 관리한다.
파이썬은 메모리를 관리하기위해 숨겨진 힙 스페이스(heap space)를 사용한다. 모든 객체와 자료 구조들이 이 곳에 저장된다. 인터프리터가 스페이스를 관리하기 때문에 심지어 프로그래머 조차도 이 공간에 대해 접근하지 못한다. 더 나아가, 파이썬은 사용되지 않은 메모리를 재활용하고 메모리를 지워 힙 스페이스에서 사용 가능케 하는 빌트인 가비지 컬렉터(Garbage Collector)를 소유하고 있다.
메모리 관리가 코어에서 어떻게 되는지 알아야 쓸모 없는 메모리 낭비 코드를 줄일수 있다. 즉 파이썬의 메모리 관리가 어떻게 구현되는지 알아야 더 나아가 좋은 코드를 작성하려는 시도와 노력을 할수 있다.
파이썬에서 메모리에 할당되는것은 AST 객체와 일반 데이터 객체, 크게 두가지로 나뉜다. 그 중 AST 객체는 (Python/pyarena.c)
의 PyArena에 저장되고 일반 데이터 객체는 (Python/obmalloc.c)
의 PyObject_*, PyMem_*
API 를 통해 메모리에 저장된다. (일반 데이터 객체를 중점으로 메모리를 살펴보자)
파이썬에서는 "Vladimir Marangozov" 이 작성한 pymalloc
이라는 custom memory 관리법이 있다. memory에 arena라는 이름의 블럭을 만들고, 그 안에 풀을 다시 만들어 관리한다.
파이썬에서는 객체를 PyObject_New 매크로 또는 직접 PyObject_Malloc, PyObject_Calloc
객체 할당자를 사용해 힙에 할당한다. 이러한 할당자들은 결국 최종적으로 malloc, mmap, VirtualAlloc
등의 범용 메모리 할당자들을 이용하게 되는데 파이썬은 메모리를 효율적으로 관리하기 위해 이러한 범용 할당자와 객체 할당자 사이에 pymalloc
메모리 할당자 계층이 추가되어 있다.
일종의 최적화로써 pymalloc
은 할당하고자 하는 객체의 크기에 따라 다른 전략 을 취한다. SMALL_REQUEST_THRESHOLD
전처리기 상수로 정의되 있는 512바이트보다 작은 객체는 pymalloc 할당자를 사용 하지만 512바이트를 초과하는 객체에 대해서는 pymalloc 할당자를 사용하지 않고 malloc등의 범용 메모리 할당자를 사용 하는 PyMem_RawMalloc
저수준 메모리 할당자를 사용하여 객체를 할당한다.
ps) SMALL_REQUEST_THRESHOLD
는 예전에는 이 값이 256이였지만 현재에는 512로 조정됨
// https://github.com/python/cpython/blob/main/Objects/obmalloc.c#L704
void *
PyObject_Malloc(size_t size)
{
/* see PyMem_RawMalloc() */
if (size > (size_t)PY_SSIZE_T_MAX)
return NULL;
OBJECT_STAT_INC_COND(allocations512, size < 512);
OBJECT_STAT_INC_COND(allocations4k, size >= 512 && size < 4094);
OBJECT_STAT_INC_COND(allocations_big, size >= 4094);
return _PyObject.malloc(_PyObject.ctx, size); // _PyObject_Malloc
}
void *
PyObject_Calloc(size_t nelem, size_t elsize)
{
/* see PyMem_RawMalloc() */
if (elsize != 0 && nelem > (size_t)PY_SSIZE_T_MAX / elsize)
return NULL;
OBJECT_STAT_INC_COND(allocations512, elsize < 512);
OBJECT_STAT_INC_COND(allocations4k, elsize >= 512 && elsize < 4094);
OBJECT_STAT_INC_COND(allocations_big, elsize >= 4094);
OBJECT_STAT_INC(allocations);
return _PyObject.calloc(_PyObject.ctx, nelem, elsize);
}
static void *
_PyObject_Malloc(void *ctx, size_t nbytes)
{
void* ptr = pymalloc_alloc(ctx, nbytes);
if (LIKELY(ptr != NULL)) {
return ptr;
}
ptr = PyMem_RawMalloc(nbytes);
if (ptr != NULL) {
raw_allocated_blocks++;
}
return ptr;
}
파이썬은 512바이트 미만 객체에 대해서는 수명이 짧고 작은 객체에 최적화된 pymalloc
할당자를 사용함으로써 적은 비용으로 더 많은 할당을 하려고 한다. 자주 생성되고 파괴되는 많은 object를 malloc()
, free()
를 호출하면 너무 오버헤드가 크기때문이다.
이를 피하기 위해 pymalloc
은 아레나(Arena) 라고 하는 256KB 청크로 메모리를 할당한다. 아레나는 4kB 단위의 풀로 나뉘며 고정 크기 블록으로 다시 세분화된다. 블록은 애플리케이션으로 반환된다.
블록은 하나의 객체를 저장할 수 있는 pymalloc
의 최소 구조다.
한 블럭은 8바이트에서 512바이트까지 8바이트 단위로 할당된다. 만약 7바이트 객체면 8블럭, 500바이트 객체면 512바이트 블록에 저장되는 형식이다.
bytes | allocated block size | size class index |
---|---|---|
1-8 | 8 | 0 |
9-16 | 16 | 1 |
17-24 | 24 | 2 |
… | … | … |
497-504 | 504 | 62 |
505-512 | 512 | 63 |
struct pool_header {
union { block *_padding;
uint count; } ref; /* number of allocated blocks */
block *freeblock; /* pool's free list head */
struct pool_header *nextpool; /* next pool of this size class */
struct pool_header *prevpool; /* previous pool "" */
uint arenaindex; /* index into arenas of base adr */
uint szidx; /* block size class index */
uint nextoffset; /* bytes to virgin block */
uint maxnextoffset; /* largest valid nextoffset */
};
typedef struct pool_header *poolp;
static poolp usedpools[64];
/*
usedpools
+-----+
| 0 | -> [8byte pool] <-> [8byte pool]
+-----+
| 1 | -> [16byte pool] <-> [16byte pool] <-> [16byte pool]
+-----+
| 2 | -> [24byte pool]
+-----+
| ... | -> [nbyte pool] <-> [nbyte pool] <-> [nbyte pool]
+-----+
| 63 | -> [512byte pool] <-> [512byte pool]
+-----+
*/
pymalloc이 사용하는 모든 풀은 usedpools
풀 배열에 의해 관리된다. 해당 풀의 자료구조는 "pool_header" structure 를 가진다.
각각의 풀은 아래 3가지 상태를 가진다.
그리고 동일한 사이즈 블럭은 위 코드와 같이 "이중 연결 리스트(doubly linked list)" 자료구조로 묶인다. (풀에 접근하기 위해 사실 head만 알면 된다)
usedpools
배열의 각 인덱스는 풀의 이중 연결 리스트에 연결되어 있기 때문에, pymalloc 할당자는 객체를 할당할 때 usedpools
배열에서 원하는 단위의 풀 리스트를 찾아 마지막 풀 (마지막 풀이 꽉 차있을 경우엔 새 풀을 할당해서 리스트에 추가) 의 빈 블럭에 객체를 할당하게 된다.
struct arena_object {
uintptr_t address;
block* pool_address;
uint nfreepools;
uint ntotalpools;
struct pool_header* freepools;
struct arena_object* nextarena;
struct arena_object* prevarena;
};
아래나도 "이중 연결 리스트(doubly linked list)" 자료구조로 묶인다.
ntotalpools
과 nfreepools
은 현재 사용가능한 풀을 알 수 있다. 그리고 freepools
변수는 사용가능한 풀의 연결 리스트 (linked list)의 pointer 다.
아레나는 속한 Pool이 모두(전체) 해제가 되어야만 OS로 반환한다. 짧은 시간에 큰 임시 object를 할당하고 해제하는 경우가 대게 이렇다. 이 행위 때문에 오랜시간동안 러닝되는 python process는 사용하지 않는 메모리를 많이 할당된 채로 가지고 있을 수 있다.
pymalloc 할당자가 malloc등의 범용 메모리 할당자로 할당 또는 해제 할 수 있는 "최소 단위"로 256KB의 단위이고 페이지 사이즈에 정렬되는 만큼 mmap 또는 VirtualAlloc 등의 페이지 단위로 할당되는 시스템 콜로 할당이 먼저 시도된다.
https://github.com/python/cpython/blob/7d6ddb96b34b94c1cbdf95baa94492c48426404e/Objects/obmalloc.c
사실 이러한 메모리 할당은 Garbage Collection와 Reference Counting 와 연관이 있고 또 GC와 RC는 다시 GIL과 관계가 있다.
우선 위에서 나온 메모리 할당 얘기와 더불어, 정적 메모리 - 동적 메모리 (각 stack - heap) 에 object이 어떻게 할당되고, 어떻게 코드를 작성하는 것이 python memory optimization에 좋을까를 고민해보자.