python - interpreting, 실행되기 까지와 메모리 관리, memory optimization (1)

정현우·2022년 11월 1일
6
post-thumbnail

Python 코드 실행과 메모리에 올라가기 까지

python은 "인터프리터" 언어다. 당신이 어떤 파일로든 .py 파일만 만들고, python의 인터프리터가 설치되어 있다면 [ compiler -> byte code -> pvm -> running ] 까지 다 해준다. excel 파일을 열기위해 MSoffice 를 설치하는것 처럼, 설치하는 python은 인터프리터 S/W 이다.

🔥 본 글은 python 3.6 이후 버전 중심 설명입니다. 🔥

1. python 실행 되기까지

없는 디자인 센스,, 피그마로 영끌을 해보았다,,

  • 우리가 만든 .py 파일은 python interpreter에 의해 compile 되고, bytecode로 바뀐뒤에 PVM 위에서 러닝된다.

  • 여기서 이 "Python Interpreter" 의 종류는 cpython, pypy, ironpython 등이 있는 것이다. 가장 많이 사용하는 것은 기본 제공되는 cpython 이다.

1) Byte-code가 되어 실행 되기까지 과정

byte code로 컴파일 되기까지도 역시 많은 과정을 거친다. 아래와 같은 과정은 절대적이지 않다. 당연히 인터프리터의 종류에 따라 상이하다.

(1) Lexing : 전달된 code를 가지고 line을 쪼개 코드를 Token으로 generate한다.

(2) Parsing

(3) Compiling

  • 위와 같은 과정을 가치면 Byte code 라는 .pyc 형태의 intermediate language code를 생성된다.

  • .pyc 파일은 파이썬 3.2 버전 이전에는 .py 파일과 같은 경로 에 생성되고, 3.2 이후 버전에서는 __pycache__ 디렉터리 아래에 생성 된다.

  • 근데 무조건적으로 .pyc 를 생성하는 것이 아니라, .py 파일이 다른 스크립트에 의해 import 되었을 경우에만 생성 된다.

  • import 문이 호출되었을 때는 아래와 같은 확인을 한다.

    • a. 파이썬은 import 되는 스크립트의 컴파일 된 파일이 존재 하는지 확인 한다. 없다면, .pyc 파일을 생성하고 불러 온다.
    • b. 있다면, 내부 timestamp 에서 .py 파일 보다 .pyc 파일이 더 오래 되었는지 확인한다. (한 마디로, 소스 코드가 변경 되면 자동으로 새 .pyc 로 갱신)
    • c. 대화형 프롬프트(REPL) 환경에서 입력한 코드에 대해서는 .pyc 파일을 생성하지 않는다.
  • 컴파일 언어인 자바와 차이나는 부분은 어딜까? 자바코드는 interpreter(해석기)에게 소스가 전달되기 전에 컴파일러에 의해 컴파일된다. 즉, compiler가 interpreter 밖에 존재한다.

(4) P.V.M (Python Virtual Machine)

  • .pyc가 전달되어져서 python 코드가 실행 되며 파이썬의 런타임 엔진 이다. 파이썬 시스템의 일부이기 때문에 별도의 설치가 필요하지 않고, "존재" 한다.

  • 위 "1)~3)" 과정은 PVM에서 일어난다. 즉 실제 interpreting 작업 (Row 단위로 해석하며 프로그램을 구동하는 방식)을 담당하며, byte code를 machine code로 변환하여 실행되는 곳이다.

  • 추가로, "프로즌 바이너리"는 작성한 파이썬 프로그램을 실행 파일로(.exe) 변환 하는 것 또한 가능하다. PyInstaller, py2exe, py2app 등을 통해서 "실행 가능한 바이너리"로 만들수 있다.

  • 프로즌 바이너리를 만들 때에는 바이트 코드와, 실행 환경(PVM), 그리고 의존성 모듈을 단일 패키지로 만들고 결과물은 실행 가능한 형태 (.exe 등) 가 된다.

2) 잠깐, 실행되는 "python process" 에 대해

  • PVM에서 "python process"가 돌아간다.

  • 너무 깊이 들어가면, OS에 대한 얘기가 되어 버린다. 잠깐 "python의 메모리공간" 을 생각하기 전에 어떻게 memory를 할당받는지 다시 생각해보자.

(1). process 는 OS(linux 기반)관점에서 "작업의 Group" 이다.

(2). process 에게 사용가능한 메모리 공간을 (Virtual Memory) 할당해주는 주체도 OS(커널)이다.

  • 우린 해당 메모리 공간을 code(text), data, heap, stack 영역 이라고 나눈다.

(3). process 는 하나 이상의 Thread를 가진다. Thread의 실제 작업 연산 주체라고 생각해보자.


2. python 메모리 관리

파이썬 메모리 공식문서, 모든 파이썬 객체와 데이터 구조를 포함하는 비공개 힙(private heap)은 python memory manager가 비공개적으로 알아서 관리한다.

  • 파이썬은 메모리를 관리하기위해 숨겨진 힙 스페이스(heap space)를 사용한다. 모든 객체와 자료 구조들이 이 곳에 저장된다. 인터프리터가 스페이스를 관리하기 때문에 심지어 프로그래머 조차도 이 공간에 대해 접근하지 못한다. 더 나아가, 파이썬은 사용되지 않은 메모리를 재활용하고 메모리를 지워 힙 스페이스에서 사용 가능케 하는 빌트인 가비지 컬렉터(Garbage Collector)를 소유하고 있다.

  • 메모리 관리가 코어에서 어떻게 되는지 알아야 쓸모 없는 메모리 낭비 코드를 줄일수 있다. 즉 파이썬의 메모리 관리가 어떻게 구현되는지 알아야 더 나아가 좋은 코드를 작성하려는 시도와 노력을 할수 있다.

1) 파이썬의 메모리 구조

(1) 프로그래밍에서 정적 메모리

  • 프로그램 "컴파일"시 메모리가 할당된다. C/C++의 예로, 고정 크기로만 정적 배열을 선언한다. 메모리는 컴파일할 때 할당되며, 스택은 정적 할당을 구현하는 데 사용 되며, 이 경우 메모리를 재사용할 수 없다.

(2) 프로그래밍에서 동적 메모리

  • 프로그램 "런타임"시 메모리가 할당된다. C/C++의 예로, new() 연산자를 사용하여 배열을 선언한다. 메모리는 런타임에 할당되며, 힙은 동적 할당을 구현하는 데 사용 된다. 이 경우 필요하지 않은 메모리를 비우고 재사용할 수 있다.

(3) python에서 메모리에 할당 되는 것

  • python의 전체 메모리 할당 시스템을 아래와 같은 계층들의 순서, 집합으로 볼 수 있다.

  • cpython source code

  • 파이썬에서 메모리에 할당되는것은 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 단위의 풀로 나뉘며 고정 크기 블록으로 다시 세분화된다. 블록은 애플리케이션으로 반환된다.

2) 블록 < 풀 < 아레나

(1) Block(블록)

  • 블록은 하나의 객체를 저장할 수 있는 pymalloc최소 구조다.

  • 한 블럭은 8바이트에서 512바이트까지 8바이트 단위로 할당된다. 만약 7바이트 객체면 8블럭, 500바이트 객체면 512바이트 블록에 저장되는 형식이다.

    bytesallocated block sizesize class index
    1-880
    9-16161
    17-24242
    497-50450462
    505-51251263

(2) Pool(풀)

  • 동일한 단위의 블록의 모음을 풀이라고 한다. 풀의 크기는 메모리 페이지의 사이즈 크기와 동일하다. 일반적으로 x86 아키텍처의 "페이지 크기" 와 같은 4KB이며, 큰 단위의 블록을 가지는 풀일수록 할당 가능한 블록의 "갯수가 적다".
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가지 상태를 가진다.

    • used: partially used, neither empty nor full
    • full: all the pool's blocks are currently allocated
    • empty: all the pool's blocks are currently available for allocation
  • 그리고 동일한 사이즈 블럭은 위 코드와 같이 "이중 연결 리스트(doubly linked list)" 자료구조로 묶인다. (풀에 접근하기 위해 사실 head만 알면 된다)

  • usedpools 배열의 각 인덱스는 풀의 이중 연결 리스트에 연결되어 있기 때문에, pymalloc 할당자는 객체를 할당할 때 usedpools 배열에서 원하는 단위의 풀 리스트를 찾아 마지막 풀 (마지막 풀이 꽉 차있을 경우엔 새 풀을 할당해서 리스트에 추가) 의 빈 블럭에 객체를 할당하게 된다.

  • 풀과 블록은 메모리를 직접 할당하지 않으며, 대신 이미 할당된 영역 공간을 사용한다.

(3) Arena(아레나)

  • 아래나는, 64개 풀에 메모리를 제공하는 256KB 크기의 할당된 메모리 청크다.

  • 아래나의 structure 코드는 아래와 같다.
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)" 자료구조로 묶인다.

  • ntotalpoolsnfreepools 은 현재 사용가능한 풀을 알 수 있다. 그리고 freepools 변수는 사용가능한 풀의 연결 리스트 (linked list)의 pointer 다.

  • 아레나는 속한 Pool이 모두(전체) 해제가 되어야만 OS로 반환한다. 짧은 시간에 큰 임시 object를 할당하고 해제하는 경우가 대게 이렇다. 이 행위 때문에 오랜시간동안 러닝되는 python process는 사용하지 않는 메모리를 많이 할당된 채로 가지고 있을 수 있다.

  • pymalloc 할당자가 malloc등의 범용 메모리 할당자로 할당 또는 해제 할 수 있는 "최소 단위"로 256KB의 단위이고 페이지 사이즈에 정렬되는 만큼 mmap 또는 VirtualAlloc 등의 페이지 단위로 할당되는 시스템 콜로 할당이 먼저 시도된다.

  • https://github.com/python/cpython/blob/7d6ddb96b34b94c1cbdf95baa94492c48426404e/Objects/obmalloc.c

(4) 마무리

  • 사실 이러한 메모리 할당은 Garbage Collection와 Reference Counting 와 연관이 있고 또 GC와 RC는 다시 GIL과 관계가 있다.

  • 우선 위에서 나온 메모리 할당 얘기와 더불어, 정적 메모리 - 동적 메모리 (각 stack - heap) 에 object이 어떻게 할당되고, 어떻게 코드를 작성하는 것이 python memory optimization에 좋을까를 고민해보자.


출처

profile
도메인 중심의 개발, 깊이의 가치를 이해하고 “문제 해결” 에 몰두하는 개발자가 되고싶습니다. 그러기 위해 항상 새로운 것에 도전하고 노력하는 개발자가 되고 싶습니다!

0개의 댓글