본문 바로가기
iOS/WWDC

WWDC 2024 힙메모리 분석하기

by HaningYa 2024. 7. 9.
728x90

00:00 - Introduction

두사람 사이 좋아 보인다.

  • 힙 메모리는 App에 의해 직간접 적으로 사용되는 메모리 이다
  • 개발자가 제어하고 최적화 할 수 있다.
  • 앱의 레퍼런스 타입이 저장된다.
  • 앱 메모리 제한에 포함되있어서 관리하는게 중요하다

이 세션에서는 힙 메모리 측정 및 감소에 대해서 알아본다.

우리는 5가지 주제에 대해서 알아본다

  • 힙 메모리 측정
  • 힙 메모리 일시적인 증가
  • 힙 메모리 지속적인 증가
  • 메모리 누수 잡기
  • 런타임 성능 개선

01:05 - Heap memory overview

힙메모리는 뭐고 어떤 툴을 사용하여 힙 메모리를 측정할 수 있을까?

힙을 이해하려면 앱의 전체 가상 메모리 내에서 컨텍스트에 맞는 위치를 확인해야 한다

앱이 시작되면 가상 메모리의 빈 주소 공간을 얻는다.

앱이 시작되면 read only 메모리 공간에 위와 같이 할당된다

앱이 구동 중일 땐 Stack 영역을 통해 각 쓰레드의 로컬 변수나 임시 변수를 저장하고

동적이고 긴 수명을 가진 메모리는 힙 영역에 저장된다

위와 같이 힙 메모리를 자세히 들여다 보면 다수의 virtual memory region으로 또 나뉘어진다

하나의 region 은 또 개별 힙 할당(individual heap allocation) 으로 구분할 수 있다

내부적으로 각 region들은 운영체제의 16KB 메모리 페이지로 구성되지만 각 할당(Allocation)은 이보다 더 크거나 작을 수 있다

메모리는 3가지 상태를 가진다

  • Clean page: 기록되지 않은 메모리(할당 되었지만 사용되지 않은 공간, 디스크에서 읽기 전용으로 매핑된 파일을 나타내는 페이지) 
    시스템이 언제든지 삭제할 수 있기때문에 Cheap 하다
  • Dirty page: 앱이 최근에 쓴 메모리, 사용되지 않았어도 바로 버릴수가 없다, 메모리가 부족하면 압축하거나 디스크에 기록한다
    그래서 필요할때 다시 압축 해제되거나 디스크에서 읽어온다
  • Swapped page:

Dirty page와 Swapped page만 앱 메모리 공간에 포함된다

대부분 경우에서 힙이 해당 공간 대부분을 차지한다

Heap 메모리는 malloc, calloc, realloc과 같은 유사한 할당 생성자를 사용하여 생성된 메모리 이다

대부분의 경우 이런 함수를 직접 호출하진 않지만

컴파일러와 런타임에서는 많이 호출된다.

예를들어 Swift 나 Objective-C 클래스 인스턴스를 생성할 때 사용된다

Malloc을 사용하면 수명이 긴 메모리를 동적으로 할당할 수 있다

메모리 할당은 명시적으로 해제될 때 까지 계속 유지된다 (생성한 코드의 범위를 넘어서 지속될 수 있다)

이러한 할당 함수에는 몇가지 룰이 있는데
1. 최소 할당 크기 및 정렬은 16바이드 이다 (4바이트를 요청해도 16바이트로 요청이 반올림 된다)
2. 보안기능의 일종으로 대부분 작은 할당은 0이 할당된다

* 작은 크기의 메모리는 해제될때 메모리 블록이 0으로 초기화 되어서 메모리에서 이전 데이터를 제거하여 다른 프로세스나 할당이 이를 읽지 못하게 한다. 큰 크기의 메모리 할당의 경우 이러한 초기화가 생략된다. 왜냐하면 


03:45 - Tools for inspecting heap memory issues

Malloc 은 debugging feature 를 제공한다

MallocStackLogging을 통해 각 할당의 call stack과 time stamp 를 확인할 수 있다.
그래서 메모리가 언제 어디서 할당되었는지 볼 수 있다

Xcode 여기서 설정 가능

 

메모리 추적 툴중 하나는 Xcode Memory Report 이다

Xcode Memory Report 는 Application footprint 를 알려준다

Application footprint에는 힙 영역 뿐만 아니라 다른 여러가지 내용들이 포함되어 있다

왜 메모리가 상승하는지 이유를 알려주지는 않는다

메모리 그래프 디버거는 메모리 그래프를 캡쳐할 수 있다

모든 할당과 참조에 대한 스냅샷을 제공한다

mallocStackLogging이 활성화 되어 있으면 각각 할당에 대해서 Backtrace를 제공해준다

특정 할당에 집중해서 봐야한다면 유용한 툴이다.

Xcode는 메모리 분석 CLI 툴을 통해 맥이나 시뮬레이터의 프로세스를 직접 분석하거나
이미 캡쳐된 메모리 그래프 내에서 사용이 가능하다

Allocation instrument 는 시간 경과에 따른 모든 할당와 해제 이벤트를 기록하고 

통계 및 call tree를 집계해서 코드로 추적하는데 도움을 준다

Leak instrument는 앱 메모리를 주기적으로 스냅샷을 찍어서 메모리 누수를 감지한다

Allocation instrument에 들어가면 두가지 템플릿이 있다. (Allocation, VMTracker)
Allocation의 경우 힙과 VM 이벤트를 실시간으로 기록하여 실시간 활동을 볼 수 있따
VMTracker의 경우 주기적으로 스냅샷을 찍어 모든 가상 메모리를 측정할 수 있다

메모리 누수로 의심되는 동작시 allocation에서 점진적으로 증가하는 걸 볼 수 있다

해당 trace를 저장하여 분석해보자

07:40 - Transient memory growth overview

앱의 메모리 급증은 일시적인 메모리 증가의 한 유형이며 3가지 이유 때문에 좋지않다

  • 메모리 급증으로 인해 메모리 부족이 발생하고 시스템이 이에 반응한다 
    • dirty memory의 교환 및 압축
    • 읽기 전용 메모리 삭제
    • 백그라운드 작업 종료
    • 결국 앱의 종료
  • 장기적인 영향도 좋지 않다
    • 힙 메모리 영역에 조각화(segmentation)이나 구멍이 발생하기 때문이다\

일시적인 힙 증가를 찾으려면 위 두가지 스코프로 찾아볼 수 있다.

Statistics에서 Total Bytes나 Persistent Bytes 기준으로 정렬해서 어떤게 문제인지 볼 수 있다.

다른 방법으로는 Object가 create, destroy 되는 넓은 스코프에서 보는것이다.

콜트리 뷰에서 확인한다
비정상적으로 메모리가 큰 코드 찾기
더블클릭하면 코드로 바로 이동한다.

10:34 - Managing autorelease pool growth in Swift

ARC 기능이 있는 Swift를 사용하지만 Autorelease pool 은 일시적인 메모리 증가의 일반적인 원인이다.

Objective-C는 이러한 풀을 통해 함수의 반환값에 대한 개체 수명을 연장한다

auto release pool은 release를 나중에 하여 이러한 반환값을 유지한다

이 말은 Swift가 Objective-C API를 사용하거나 노출하는 프레임워크를 호출 할 때 auto released된 객체를 생성할 수 있다는 뜻이다

예를들어 Date.now를 출력하면 auto-release-string도 생성하게 된다

이 string은 auto-release-scope가 끝날때 까지 힙에 유지되며 이는 다소 시간이 걸린다

쓰레드에서는 일반적으로 최상위 auto-release-pool 이 있지만 자주 정리되지는 않는다.

그래서 반복문을 통해 pool 에 object가 채워질때 문제가 발생할 수 있는데 

모든 반복 개체는 동일한 풀로 auto-release되고 필요 이상으로 오래 지속될 수 있다

위의 경우 루프가 모두 끝날 때 까지 release 되지 않는다

내부적으로는 auto-release-pool은 객체를 참조하기 위해 Content page를 할당한다

이 content-page들은 allocation instrument에 표시되므로 이러한 종류의 문제를 알 수 있는 좋은 방법이다

나중에 auto-release-pool이 부족하게 되면 pool은 지연된 release를 하고 많은 객체가 한번에 release될 수 있다

이에 대한 수정은 일반적으로 수명을 줄이기 위해 nested-local-auto-release-pool 범위를 지정하는 것이다

이 예시에서 자동 해제된 개체는 내부 루프별 풀에 의해 유지되며 각 반복마다 해제된다

이는 더 적은 개체가 축적되고 참조를 추적하는데 필요한 content-page가 적다는 걸 의미한다

기존코드

위 코드에서 문제가 발생하는걸 알았으니 이제 고쳐보자.

반복문에 auto-release-pool 범위를 추가하여 매 반복 이후 개체를 release 해보자

수정 후 코드

* 힙 분석의 경우 시뮬레이터 환경이 동작 면에서 훨씬 유사하므로 메모리 프로파일링에 사용하는 것이 좋다

 

이전 최대 803MB
이후 최대 182MB

이전 이후를 비교해보면 그래프상 차이는 없지만 메모리가 기가바이트 단위로 튀진 않았다.

새로운 문제점은 메모리가 매번 계단식으로 올라간다는 점이다.

13:57 - Persistent memory growth overview

persistent memory는 할당이 취소되지 않은 메모리다.

지속적인 메모리 증가 패턴

 위 그래프는 여러 할당으로 나타내지는데 Allocation-instrument 의 기능 중 세대별 할당기능을 통해 시간에 따라 메모리 증가를 분석해 볼 수 있다. 

allocation-instrument에서 mark-generation 버튼을 누르게 되면 instrument는 group of allocation을 만든다 (GenerationA)

이 그룹은 해당 시점 이전에 만들어진 모든 할당을 수집한다.

그 뒤에 시간을 선택하고 Mark-generation을 선택하면 Generation B가 만들어진다

Generation B는 이전 세대 이후에 만들어진 할당을 수집한다.

이렇게 Xcode에서 나만의 메모리 그래프를 생성하고 instrument로 가져와서 분석한다

instruments는 leak, VM tracker, allocation 데이터를 표시한다

Generation marking 기능을 통해 정상적인 영구 할당을 빼고 그래프를 분석해보자

 

Generation B와 Generation C는 특정 행동에 따른 메모리 증가로 볼 수 있다.

Generation C를 펼치고 Growth 기준으로 정렬을 떄리면 어떤 것 때문에 메모리가 증가했는지 찾을 수 있다.

DataStorage가 원인인것 같다. 펼쳐서 각각 개별로 확인해 보면

우측에 썸네일 코드 관련 storage 라고 나온다. 뭐가 데이터를 계속 들고있는 건가

이 중 하나의 주소를 가지고 메모리 그래프 디버거에 넣어서 어떤 레퍼런스인지 찾아볼 수 있다

 

메모리 그래프 디버거가 어떤걸 말해주는지 더 잘 이해하기 위해 

메모리 그래프 디버거가 어떻게 동작하는지 먼저 알아보자

16:00 - How the Xcode memory graph debugger works

메모리 그래프를 보는 이유는 왜 이 객체가 아직 할당되있는지에 대한 질문에 답하기 위해 본다.

먼저 4가지 레퍼런스 타입 정보에 대해서 알아본다

tool 이 heap 을 스캔할 때 각 allocation에서 사용할 수 있는 최상의 타입 유형 정보를 사용한다

예를들어 위 처음 두 필드는 표준이며 참조 검색에 중요한 항목을 포함하지 않는다

coconut은 힙 할당에 대한 포인터를 보유하며 코코넛 객체에 대한 strong reference 이다

Swift및 objective-C에 대한 type 정보는 훌륭하지만 C나 C++에는 참조 소유권 정보가 없으므로 보수적인 참조만 표시된다

Tool 이 할 수 있는 최선의 방법은 가상 베서드를 사용하여 C++ 유형의 이름을 찾는것이다.

위 클래스의 인스턴스는 코코넛으로 표시된다

 

가상 메서드가 없거나 기타 할당이 없는 형식의 경우 stack trace가 이름을 제공하는데 도움이 될 수 있다

MallocStackLogging 데이터를 이용하면 PalmTree:growCoconut()에서 이 클래스의 인스턴스에 malloc이라는 라벨이 붙을 수 있는데 이는 이것이 무엇인지에 대한 매우 좋은 힌트이다

유형 정보와 참조에 대해서 얘기는 다했고 다시 데이터 메모리가 영원히 지속되는지 이유를 찾아보자

메모리 그래프 디버거에서 선택한 할당이 __DataStorage 객체에 의해 보유되고 있음을 확인 가능하다

__DataStorage는 PhotoTHumbnail에 의해 보유되고 photoThumbnail은 dictionary 형태로 보관된다

이렇게 끝까지 가보면 static ThumbnailLoader.globalImageCache가 나온다

MallocStackLogging을 활성화 한 상태라서 오른쪽 Inspector에서 allocation backtrace를 볼 수 있다

데이터를 들고있는 PhotoThumbnail을 보면 내 코드의 클로저중 하나가 이를 할당하고 있다

해당 코드로 들어가 보면 creationDate 에 따라 캐시 키가 만들어져야 하는데 timestamp 가 현재 시간에 따라 설정되어 캐싱이 제대로 동작하지 않고 있다. 그래서 매번 새롭게 캐싱해서 메모리가 증가되었다.

수정 후 코드
여러번 해도 메모리 증가가 없다

다시 메모리 그래프를 보니 leak 이 발생중이다 (옆에 노란색 삼각형 아이콘)

 

20:15 - Reachability and ensuring memory is deallocated appropriately

누수된 메모리를 이해하고 수정하려면 먼저 연결 가능성(Reachability)에 대해서 알아야 한다.

프로그램의 모든 메모리는 나중에 사용될 어딘가에서 약하지 않은 참조를 통해 접근해야 한다

세가지 메모리 종류가 힙에 존재한다.

  1. Useful memory: 프로그램이 접근 가능하고 나중에 사용될 유용한 메모리
  2. Abandoned memory: 접근 가능하고 사용할 수 있지만 실제로 다시는 사용되지 않는 버려진 메모리
    이 메모리는 앱의 공간에 포함되어 낭비되고 너무 공격적으로 캐싱하거나 expensive data를 싱글톤에 보관하는 작업 등에서 발생
  3. Leaked memory: 다시 사용할 수 없는 도달 불가능한 메모리
    수동으로 관리되는 allocation이나 객체의 순환참조로 인해 마지막 포인터가 손실될 때 발생

대부분 leak에 대한 목표는 한 주기에서 참조를 찾아 수정하는 것이다

이는 실수로 발생한 참조를 제거하거나 소유권 한정자를 strong->weak or unowned로 변경하는 일이다.

이러한 누출을 더 쉽게 조사하기 위해 삼각형 아이콘이 있는 필터 표시줄에서 누출된 할당만 표시하는 필터를 걸 수 있다

탐색기에는 앱의 다양한 바이너리로 그룹화된 유형이 표시된다

나의 코드가 시스템 바이너리에서 유형을 누출 할 수 있지만 일반적으로 leak은 프로젝트 문제로 인해 직접 발생된다.

그래서 내 프로젝트 유형만 필터링 하도록 적용해서 보면 

ThumbnailLoader에 3개의 누수와 ThumbnailRenderer에 3개의 누수가 있다.

이 중 하나를 보면

ThumbnailRenderer와 ThumbnailLoader, closure context 간의 순환 참조가 나타난다.

21:54 - Resolving leaks of Swift closure contexts

여기서 클로져 컨텍스트에 대해서 알아보자

Swift closure가 값을 캡쳐해야 하는 경우 힙에 메모리를 할당한다.

메모리 그래프 디버거는 이러한 할당을 "클로저 컨텍스트"로 표시한다

힙의 각 클로저 컨텍스트는 라이브 클로저와 1:1 대응된다 

클로저는 기본적으로 참조를 강력하게 캡처하므로 참조 순환을 생성할 수 있다

대신에 weak or unowned 캡쳐를 사용하여 이러한 사이클을 깰 수 있다.

Swallow 예시의 경우 메모리 그래프 디버거에서는 해당 참조를 강력한 캡쳐로 표시하지만 클로져 메타 데이터에는 변수 이름이 포함되지 않는다 -> 클로저 컨텍스트의 모든 참조에는 단순히 "capture"라는 레이블이 지정된다

다시 순환참조로 돌아가서 보면 capture의 유형이 strong이라고 나와있다.

이 순환 참조를 깨기 위해서는 클로저를 생성한 코드를 찾아야 한다

closure context의 stack trace에서 해당 코드로 바로 이동해서 보면 

CompletionHandler 클로져가 ThumbnailRenderer를 강력하게 캡쳐하고 있고 이로인해 순환참조가 발생한다.

위와 같이 수정후에 메모리 그래프 디버거를 보면 Leak 이 없어진 걸 볼 수 있다

24:13 - Leaks FAQ

leak 검사가 모든 Leak 을 검출 할 수 없는 이유는?

tool에서 type 정보가 없는 메모리가 많고 C와 같은 언어는 관리되지 않는 포인터를 사용한다. 이는 도구가 포인터 인것처럼 보이지만 그렇지 않을 수도 있다는 것을 허용해야 한다는 뜻이다. 

tool 이 보수적으로 leak을 검색할 때 포인터를 바이트 단위로 찾고 참조로 보이는 값을 찾은다음 allocation 목록과 비교하여 확인한다 
값이 일치하면 tool은 블록에 대해 불확실하고 보수적인 참조를 기록한다

그러나 해당 값은 숫자값, 플래그 또는 유효한 포인터 처럼 보이는 임의의 바이트 일 수도 있다.

따라서 보수적인 참조로 인해 실제 leak을 놓칠 수 있다 

하지만 실제 앱에서는 일반적으로 leak code가 여러번 실행되므로 tool 이 leak 전부를 찾을 수 없더라도 버그를 잡아 낼 수 있다

어떻게 시간이 지나면서 leak의 수가 줄어들수 있는지?

버그는 시간이 지남에 따라 더 많은 Leak 으로 이어지지만 heap 은 noisy하고 random 하다
이 noise로 인해 보수적인 참조가 비결정적으로 나타나거나 사라질 수 있다 
따라서 프로그램이 시작시 5개의 객체가 leak 되더라도 tool은 처음 5개를 찾은 뒤 나중에는 4개만 찾을 수 있다 

return 되지 않는 함수에 leak 이 발생하는 이유는?

noreturn 속성이 있는 C 함수이거나 Never 타입을 반환하는 swift 함수 일 수 있다 
이러한 함수는 절대 return 되지 않기 떄문에 컴파일러는 생성된 로컬 할당 또는 참조 해제를 포함하여 일반적으로 수행해야 하는 정리 작업을 최적화 할 수 있다 
이러한 종류의 함수가 fatal assertion에 사용되면 어쨋건 프로그램은 크래시 날것이니까 걱정안해도 된다 

그러나 때로는 쓰레드를 영원히 보관하는데 사용되기도 한다 

이 예제의 Server 객체와 같은 noreturn 함수 호출에서 로컬 상태가 유출된 것으로 보고되는 경우 
이를 명시적으로 전역에 저장하는 방법으로 해결 할 수 있다 
외부에 객체를 저장하면 찾고는 도구가 볼 수 있는 위치에 있게 된다 
그리고 도구가 이를 볼 수 있기 떄문에 로컬 변수가 컴파일러에 의해 보존되지 않더라도 객체는 leak 대신 reachable 한것으로 간주된다

26:51 - Comparing performance of weak and unowned

메모리를 줄이면 앱 성능이 크게 향상될 수 있으며 이를 더욱 향상시킬 수 있도록 염두에 두어야 하는 몇가지 런타임 세부사항이 있다.

weak reference 와 unowned reference 는 순환 참조를 피하기 위해 swift에서 사용되는 두가지 일반적인 도구이다.

Weak reference의 경우 항상 optional 값이며 대상이 deinit된 이후에는 nil이 된다.
소스 및 대상 수명에 관계없이 항상 약한 참조를 사용할 수 있다

코코넛과 제비를 예로 들자면 코코넛은 제비에 의해 운반될 수 있지만 제비를 소유하진 않는다 
코코넛이 제비를 참조하도록 하려면 약한 참조를 사용해야 하는데 여기에는 오버헤드가 발생한다

약한 참조를 구현하기 위해 swift은 처음 약하게 참조될 때 대상 객체에 대한 Swift weak reference storage를 할당한다
이 할당은 Swallow와 들어오는 모든 약한 참조 사이에 위치한다.
Swallow가 사라진 후 약한 참조가 lazy 하게 제거될 수 있다 

약한 참조와 다르게 unowned reference는 대상을 직접 보유한다
이는 추가 메모리를 사용하지 않으며 약한 참조보다 엑세스 하는데 시간이 덜 걸린다는 걸 의미한다

그러나 unowned reference 를 사용하는 것이 항상 유효한건 아니다 

만약 holder를 unowned로 하고 우리가 참조하기 전에 Swallow가 사라지면 Swallow 는 deinit 되지만 할당이 취소되진 않는다
이것이 바로 unowned reference 를 안전하게 만드는 것이다

여튼 unowned reference 는 weak reference를 force unwrapping 하는것과 유사하다

결국 얼마나 오래 지속될 지 모른다면 weak reference의 작은 오버헤드가 그만한 가치가 있다

만약 메모리 그래프에 보고된 weak, unowned reference가 표시되지 않는 경우 xcode에서 프로젝트의 refrelection metadata level 빌드 설정을 확인해야 한다

가능하면 디폴드 설정인 All을 추천한다

더 구체적인 예시를 들자면 이 ByteProducer 클래스에는 defaultAction 메서드에 할당된 클로저와 같은 generator 속성이 있다. 

문제는 defaultAction 메소드가 암시적으로 self를 사용하고 있기 때문에 강력한 순환참조가 생성된다.
(메소드를 클로져로 사용할때 매우 주의해야 한다)

이 경우 weak reference도 해결책이고 unowned도 가능한 해결책이다

unowned가 가능한 이유는 generator closure가 참조 대상인 ByteProducer 인스턴스와 수명이 동일하기 떄문이다

클로저는 다른 코드에 제공되거나 비동기적으로 전달되지 않으므로 캡처된 자체보다 오래 지속될 수 있는 방법이 없다 

이러한 선택 사항간의 성능 차이가 합산되면 유의미한 성능 차이가 발생한다.

백만개의 ByteProducer를 할당 하고 메모리 그래프를 보면 힙 CLI 가 비용에 대한 빠른 요약을 제공해 준다

각 ByteProducer에는 하나의  weak reference storage allocation이 있으며 이는 ByteProducer 자체만큼의 메모리를 사용중이다..!

Unowned를 사용하면 이 메모리는 필요하지 않다

결론: weak reference 가 가장 좋은 기본 값이며 unowned reference 는 reference가 대상보다 오래 지속되지 않도록 보장할 수 있어 메모리와 시간을 절약할 수 있다.

CPU 오버헤드가 발생하는 영역을 찾으려면 "Swift_weakLoadString()"과 같은 런타임 함수에 대한 호출을 프로파일링 해봐라

 

30:44 - Reducing reference counting overhead

weak 나 unowned외에도 automatic retain and release call이 프로파일링 핫스팟으로 표시되는 경우가 있다

유혹적이긴 하지만 ARC를 피하지 마라.

관리되지 않는 포인터를 사용하거나 성능에 민감한 코드를 메모리에 안전하지 않은 언어로 이동하는것 보다 더 나은 방법이 있다.

-whole-module-optimization 이 활성화 되어 있는지 확인해라 (더 많은 Inline을 허용하여 오버헤드를 줄일 수 있다)

또는 가장 많이 복사된 구조체에 대해서 단순한 필드만 있는지 확인하는 것도 도움이 된다

이런 구조체의 경우 reference type, copy on write type, any 사용을 최소화 해야한다


32:06 - Cost of measurement

 

32:30 - Wrap up

 

728x90

'iOS > WWDC' 카테고리의 다른 글

WWDC 2014: session 502 AVAudioEngine in practice  (0) 2024.05.27
[WWDC 2021] What's new in SwiftUI  (0) 2021.06.09
WWDC 2021 Watch list  (2) 2021.06.09
[WWDC 2021] What‘s new in Swift  (0) 2021.06.09
Introducing PencilKit  (0) 2020.09.29

댓글