가비지 컬렉터 문서 및 설정

Incminimark

PyPy의 기본 가비지 컬렉터는 incminimark라고 합니다. 이것은 점진적(incremental)이고 세대별(generational)로 동작하는 이동형(moving) 컬렉터입니다. 여기서는 이 컬렉터가 어떻게 동작하는지, 그리고 작업 부하에 맞게 어떻게 조정할 수 있는지 간략히 설명하고자 합니다.

Incminimark는 먼저 이른바 nursery에 객체를 할당합니다 - nursery는 젊은 객체를 위한 공간으로, 할당이 포인터를 증가시키는 것에 불과하므로 비용이 매우 저렴합니다. nursery 크기는 매우 중요한 변수입니다 - 작업 부하(프로세스가 하나인지 여럿인지)와 캐시 크기에 따라 PYPY_GC_NURSERY 환경 변수로 이 값을 바꿔 가며 실험해 볼 수 있습니다. nursery가 가득 차면 마이너 컬렉션(minor collection)이 수행됩니다. 해제된 객체는 더 이상 참조할 수 없으며, 단지 더 이상 참조되지 않는 것만으로 그대로 소멸합니다. 반면 아직 살아 있는 것으로 확인된 객체는 살아남아야 하므로 nursery에서 구세대(old generation)로 복사됩니다. 이때 객체는 같은 크기의 객체들을 모아 둔 아레나(arena)로 복사되거나, 더 큰 경우에는 malloc으로 직접 할당됩니다. (세 번째 범주인 매우 큰 객체는 처음부터 nursery 바깥에 할당되며 절대 이동하지 않습니다.)

Incminimark는 점진적(incremental) GC이므로 메이저 컬렉션(major collection)도 점진적으로 수행됩니다. 목표는 1ms보다 긴 일시 정지가 전혀 없도록 하는 것이지만, 실제로는 힙의 크기와 특성에 따라 달라집니다. 때때로 10-100ms 사이의 일시 정지가 발생할 수 있습니다.

반수동 GC 관리

프로그램에서 낮은 지연 시간이 중요한 부분이 있다면, 예기치 않은 멈춤을 피하기 위해 GC가 실행되는 시점을 정밀하게 제어하는 것이 좋을 수 있습니다. 이는 메이저 컬렉션(major collection)에만 영향을 미치며, 마이너 컬렉션(minor collection)은 평소처럼 계속 동작한다는 점에 유의하십시오.

앞에서 설명했듯이, 완전한 메이저 컬렉션은 N개의 단계로 이루어지며, 여기서 N은 힙의 크기에 따라 달라집니다. 일반적으로 컬렉션을 완료하는 데 몇 단계가 필요할지는 예측할 수 없습니다.

gc.enable()와 gc.disable()은 GC가 컬렉션 단계를 자동으로 실행할지 여부를 제어합니다. GC가 비활성화되어 있으면 gc.collect()와 gc.collect_step()을 수동으로 호출하지 않는 한 메모리 사용량이 끝없이 늘어납니다.

gc.collect()는 전체 메이저 컬렉션을 실행합니다.

gc.collect_step()는 컬렉션 단계를 한 번 실행합니다. 이 함수는 GcCollectStepStats 타입의 객체를 반환하며, 이는 해당 GC Hooks에 전달되는 것과 같은 타입입니다. 다음 코드는 gc.collect()와 대략 같습니다.:

while True:
    if gc.collect_step().major_is_done:
        break

이 API의 실제 사용 예는 서드파티 모듈 pypytools.gc.custom에서 볼 수 있습니다. 이 모듈은 GC가 금지된 구간을 표시하는 with customgc.nogc() 컨텍스트 매니저도 제공합니다.

단편화

“단편화(fragmentation)” 문제를 논의하기 전에 용어를 조금 더 정확히 정리할 필요가 있습니다. 서로 관련되어 있지만 뚜렷이 구별되는 두 종류의 문제가 있습니다:

  • 프로그램이 메모리를 많이 할당한 다음 그 메모리에 대한 참조를 모두 제거하여 전부 해제하면 RSS가 줄어들 것이라고 예상할 수 있습니다. (RSS = Linux에서 “top”으로 볼 수 있는 Resident Set Size로, OS 관점에서 본 실제 메모리 사용량의 근삿값입니다.) 그러나 그렇게 되지 않을 수도 있습니다. RSS가 최고치에 그대로 머무를 수 있습니다. 더 정확히 말하면, 이 문제는 프로세스가 “free” 메모리를 OS에 반환하지 않기 때문에 발생합니다. 이러한 경우를 “반환되지 않은 메모리(unreturned memory)”라고 부릅니다.
  • 위 작업을 수행한 후에도 RSS가 줄어들지 않았다면, 적어도 이후의 할당 때문에 RSS가 더 커져서는 안 됩니다. 즉, 프로세스는 반환되지 않은 메모리가 남아 있는 한 그 메모리를 재사용해야 합니다. 그렇게 되지 않으면 RSS는 더욱 커지며, 이는 실제 단편화 문제가 있다는 뜻입니다.

gc.get_stats

gc 모듈에는 get_stats(memory_pressure=False)라는 특별한 함수가 있습니다.

memory_pressure는 GC 외부에서 할당된 객체로 인한 메모리 압박을 보고할지 여부를 제어합니다. 이를 위해서는 힙 전체를 순회해야 하므로 비용 문제로 기본적으로 비활성화되어 있습니다. 원인을 알 수 없이 메모리가 사라지는 문제를 디버깅할 때 활성화하십시오.

호출 예시는 다음과 같습니다:

>>> gc.get_stats(True)
Total memory consumed:
GC used:            4.2MB (peak: 4.2MB)
   in arenas:            763.7kB
   rawmalloced:          383.1kB
   nursery:              3.1MB
raw assembler used: 0.0kB
memory pressure:    0.0kB
-----------------------------
Total:              4.2MB

Total memory allocated:
GC allocated:            4.5MB (peak: 4.5MB)
   in arenas:            763.7kB
   rawmalloced:          383.1kB
   nursery:              3.1MB
raw assembler allocated: 0.0kB
memory pressure:    0.0kB
-----------------------------
Total:                   4.5MB

이 경우는 시작 직후이므로 GC가 소비하는 메모리가 비교적 적고, 할당되었지만 사용되지 않는 메모리는 그보다도 더 적습니다. 반환되지 않은 메모리가 많거나 실제로 단편화가 일어난 경우에는 “allocated”가 “used”보다 훨씬 클 수 있습니다. 일반적으로 “peak”가 RSS로 보고되는 실제 메모리 사용량에 더 가깝습니다. 사실 메모리를 OS에 반환하는 것은 어려우며 아직 해결되지 않은 문제입니다. PyPy에서는 아레나(arena) 전체가 비어 있을 때만 반환이 일어납니다. 아레나는 각각 4 KB 또는 8 KB 크기인 페이지 64개로 이루어진 연속된 블록입니다. 적어도 흔히 쓰이는 시스템의 malloc() 구현에서는 “rawmalloced” 범주에서도 메모리가 반환되는 경우가 드뭅니다.

여러 필드의 세부 사항은 다음과 같습니다.

  • 아레나(arena) 내 GC - 아레나에 보관되는 작고 오래된 객체입니다. “allocated” 양이 “used” 양보다 훨씬 많다면, 반환되지 않은 메모리가 있는 것입니다. 여기서 내부 단편화가 발생할 수도 있지만, 그럴 가능성은 낮습니다. 그러나 이 반환되지 않은 메모리는 “rawmalloced” 섹션의 메모리를 포함하여 어떤 malloc()에도 재사용될 수 없습니다.
  • GC rawmalloced - malloc으로 할당된 큰 객체입니다. 이 항목은 malloc()으로 할당된 현재 메모리(첫 번째 텍스트 블록)와 최대 메모리(두 번째 텍스트 블록)를 보여 줍니다. malloc()이 반환하지 않은 메모리의 양이나 malloc()으로 인한 단편화는 쉽게 보고할 수 없습니다. 보통 RSS가 “GC allocated”로 보고된 전체 메모리보다 훨씬 크다면 그런 메모리가 어느 정도 있다고 짐작할 수 있습니다. 다만 이 합계에는 PyPy의 GC가 전혀 알지 못하는, malloc으로 할당된 메모리가 포함되지 않는다는 점을 유의하십시오. 그런 메모리가 있다고 짐작된다면 시스템 malloc 대신 jemalloc을 사용하는 것을 고려하십시오.
  • nursery - nursery에 할당되는 메모리 양으로, 시작할 때 고정되며 환경 변수로 제어됩니다
  • raw assembler allocated - JIT가 자신이 관리해야 한다고 여기는 어셈블러 메모리의 양
  • 요청 시 메모리 압박(memory pressure) - 외부 malloc(예: SSL 컨텍스트에서 인증서 저장소 로딩)을 통해 할당되었다고 추정되며, GC 객체에 의해 살아 있게 유지되지만 GC에서는 집계되지 않는 메모리의 양입니다.

GC 훅

GC 훅은 특정 GC 이벤트가 발생할 때마다 호출되는 사용자 정의 함수이며, GC 활동과 일시 정지를 모니터링하는 데 사용할 수 있습니다. 다음 속성을 설정하여 훅을 설치할 수 있습니다:

gc.hooks.on_gc_minor
마이너 컬렉션이 발생할 때마다 호출됩니다. PYPYLOG 안의 gc-minor 섹션에 해당합니다.
gc.hooks.on_gc_collect_step
메이저 컬렉션(major collection)의 증분 단계가 일어날 때마다 호출됩니다. 이는 PYPYLOG 안의 gc-collect-step 섹션에 해당합니다.
gc.hooks.on_gc_collect
마지막 증분 단계 이후, 메이저 컬렉션이 완전히 끝났을 때 호출됩니다. 이는 PYPYLOG 안의 gc-collect-done 섹션에 해당합니다.

훅을 제거하려면 해당 속성을 None으로 설정하기만 하면 됩니다. 모든 훅을 한 번에 설치하려면 gc.hooks.set(obj)를 호출하면 됩니다. 이 함수는 obj에서 on_gc_* 메서드를 찾습니다. 모든 훅을 한 번에 제거하려면 gc.hooks.reset()을 호출하면 됩니다.

훅이 호출하는 함수는 stats 인자 하나를 받으며, 이 인자에는 해당 이벤트에 관한 다양한 통계가 담겨 있습니다.

PyPy는 GC 이벤트 직후에 바로 훅을 호출할 수 없으며, 인터프리터가 알려진 상태에 있어 사용자 정의 코드를 호출해도 무해한 지점에 도달할 때까지 기다려야 한다는 점에 유의하십시오. 훅이 호출되기 전에 여러 이벤트가 발생할 수도 있습니다. 이 경우 stats.count 값을 확인하면 훅이 마지막으로 호출된 이후 해당 이벤트가 몇 번 발생했는지 알 수 있습니다. 마찬가지로 stats.duration에는 훅이 마지막으로 호출된 이후 이 특정 이벤트에 GC가 소비한 총 시간이 담겨 있습니다.

반면에 stats 객체의 나머지 필드는 모두 일련의 이벤트 가운데 마지막 이벤트에만 해당합니다.

on_gc_minor 훅에서 GcMinorStats의 속성은 다음과 같습니다:

count
마지막 훅 호출 이후 발생한 마이너 컬렉션 횟수입니다.
duration
마지막 훅 호출 이후 마이너 컬렉션에 소요된 총 시간(초 단위)입니다.
duration_min
마지막 훅 호출 이후 가장 짧았던 마이너 컬렉션의 소요 시간입니다.
duration_max
마지막 훅 호출 이후 가장 느렸던 마이너 컬렉션의 소요 시간입니다.
total_memory_used
마이너 컬렉션이 끝난 시점에 사용 중인 메모리의 양이며, 바이트 단위입니다. 여기에는 아레나에서 사용하는 메모리(GC가 관리하는 메모리)와 raw-malloc으로 할당된 메모리(예: numpy 배열의 내용)가 포함됩니다.
pinned_objects
고정된(pinned) 객체의 수입니다.

on_gc_collect_step 훅에서 사용하는 GcCollectStepStats의 속성은 다음과 같습니다:

count, duration, duration_min, duration_max
위의 내용을 참조하십시오.
oldstate, newstate
해당 단계 전후의 GC 상태를 나타내는 정수입니다.
major_is_done
이번 단계가 메이저 컬렉션의 마지막 단계였는지를 나타내는 불리언 값입니다.

oldstate와 newstate의 값은 gc.GcCollectStepStats 안에 정의된 다음 상수 중 하나입니다: STATE_SCANNING, STATE_MARKING, STATE_SWEEPING, STATE_FINALIZING, STATE_USERDEL. GC_STATES 튜플을 인덱싱하면 이 값의 문자열 표현을 얻을 수 있습니다.

on_gc_collect 훅에서 GcCollectStats의 속성은 다음과 같습니다:

count
위 내용을 참조하십시오.
num_major_collects
시작 이후 지금까지 수행된 메이저 컬렉션의 총 횟수입니다. count와 달리, 이 값은 계속 증가하기만 하는 카운터이며 호출과 호출 사이에 초기화되지 않습니다.
arenas_count_before, arenas_count_after
메이저 컬렉션 전후에 사용된 아레나(arena)의 수입니다.
arenas_bytes
GC가 관리하는 객체들이 사용하는 전체 바이트 수입니다.
rawmalloc_bytes_before, rawmalloc_bytes_after
메이저 컬렉션 전후에 raw-malloc으로 할당된 객체가 사용하는 총 바이트 수입니다.
pinned_objects
고정된(pinned) 객체의 수입니다.

GcCollectStats에는 duration 필드가 없다는 점에 유의하십시오. 이는 모든 GC 작업이 gc-collect-step 내부에서 수행되기 때문입니다. gc-collect-done은 추가 통계를 제공하는 데에만 사용될 뿐, 실제 작업은 전혀 수행하지 않습니다.

다음은 GC 훅을 사용하는 예시입니다.:

import sys
import gc

class MyHooks(object):
    done = False

    def on_gc_minor(self, stats):
        print 'gc-minor:        count = %02d, duration = %d' % (stats.count,
                                                                stats.duration)

    def on_gc_collect_step(self, stats):
        old = gc.GcCollectStepStats.GC_STATES[stats.oldstate]
        new = gc.GcCollectStepStats.GC_STATES[stats.newstate]
        print 'gc-collect-step: %s --> %s' % (old, new)
        print '                 count = %02d, duration = %d' % (stats.count,
                                                                stats.duration)

    def on_gc_collect(self, stats):
        print 'gc-collect-done: count = %02d' % stats.count
        self.done = True

hooks = MyHooks()
gc.hooks.set(hooks)

# simulate some GC activity
lst = []
while not hooks.done:
    lst = [lst, 1, 2, 3]

환경 변수

PyPy의 기본 incminimark 가비지 컬렉터는 여러 환경 변수를 통해 설정할 수 있습니다:

PYPY_GC_NURSERY
너서리(nursery) 크기입니다. 기본값은 최종 레벨 캐시의 1/2입니다. 캐시 크기를 알 수 없으면 4M이고, 최종 레벨 캐시가 너무 작은 경우에도 4M입니다. 작은 값(예: 1 또는 1KB)은 디버깅에 유용합니다.
PYPY_GC_NURSERY_DEBUG
0이 아닌 값으로 설정하면 디버깅에 도움이 되도록 너서리(nursery)를 가비지 값으로 채웁니다.
PYPY_GC_INCREMENT_STEP
마킹 단계에서 마킹되는 메모리의 크기입니다. 기본값은 너서리(nursery) 크기의 2배입니다. 너무 높게 설정하면 GC가 전혀 점진적(incremental)으로 동작하지 않습니다. 최솟값은 마이너 컬렉션에서 살아남는 크기의 1.5배로 설정되어 있으므로, 항상 무언가를 회수하게 됩니다.
PYPY_GC_MAJOR_COLLECT
메이저 컬렉션 메모리 계수입니다. 기본값은 1.82이며, 이는 소비된 메모리가 직전 메이저 컬렉션이 끝난 시점에 실제로 사용 중이던 메모리의 1.82배에 도달하면 메이저 컬렉션을 시작한다는 의미입니다.
PYPY_GC_GROWTH
메이저 컬렉션 임계값의 최대 증가율입니다. 기본값은 1.4입니다. 메모리 사용량이 일시적으로 급증하는 경우처럼 메모리가 갑자기 늘어날 때 평소보다 더 자주 컬렉션을 수행하게 하는 데 유용합니다.
PYPY_GC_MAX
최대 힙 크기입니다. 이 한도에 가까워지면 먼저 수집을 더 자주 수행하고, 그다음 RPython MemoryError를 발생시키며, 그것으로도 충분하지 않으면 치명적 오류와 함께 프로그램을 강제 종료합니다. 1.6GB와 같은 값을 시도해 보십시오.
PYPY_GC_MAX_DELTA
메이저 컬렉션 임계값은 컬렉션 후 실제로 사용 중인 양에 PYPY_GC_MAX_DELTA를 더한 값보다 크게 설정되지 않습니다. 기본값은 전체 RAM 크기의 1/8입니다(32비트 시스템에서는 최대 2/3/4GB로 제한됩니다). 200MB와 같은 값을 시도해 보십시오.
PYPY_GC_MIN
메모리 크기가 이 한도보다 작은 동안에는 컬렉션을 수행하지 않습니다. 아주 작은 프로그램에서 모든 시간을 GC에 소비하는 일을 피하는 데 유용합니다. 기본값은 너서리(nursery) 크기의 8배입니다.
PYPY_GC_DEBUG
컬렉션 전후에 수행하는 추가 검사를 활성화합니다. 이 검사는 일반적인 용도로 쓰기에는 너무 느립니다. 값은 0 (끔), 1 (메이저 컬렉션 시) 또는 2 (마이너 컬렉션 시에도)입니다.
PYPY_GC_MAX_PINNED
어느 시점에서든 고정(pinned)될 수 있는 객체의 최대 개수입니다. 기본값은 너서리(nursery) 크기와 너서리 안에 들어갈 수 있는 최대 객체 크기에 따라 정해지는 보수적인 값입니다. 0으로 설정하면 디버깅에 유용합니다.