swift
WWDC 16 Understanding Swift Performance (1/2)
Beom
2024. 6. 19. 11:01
서론
- swift 코드를 작성할 때 내가 만든 인스턴스가 스택에 할당될 건지?
- 아니면 힙에 할당되는 것인지?
- 이 인스턴스를 전달할 때 참조 계산 오버헤드가 얼마나 되는가?
- 동적 할당인가? 정적 할당인가? 알고 있어야한다.
- 그동안 문제가 생기면 생각을 해도 코드를 작성하기 전에, 스스로 생각하면서 코드를 작성하지는 않는 듯하다.
Allocation
Stack
스택이란??
후입선출의 데이터 구조로 가장 나중에 들어간 데이터가 가장 먼저 나오게 된다.
- 함수를 호출할 때 공간을 만들기 위해 스택 포인터를 간단하게 줄이는 것으로 필요한 메모리를 할당할 수 있다.
- 함수의 실행이 끝나면 스택 포인터를 함수를 호출하기 전의 위치로 다시 증가 시키는 것만으로 간단하게 해당 메모리 할당을 해제할 수 있다.
- 스택은 힙에 비해 상대적으로 빠른 처리 속도를 가진다.
- 컴파일 당시에 크기가 결정이 된다.

Heap
힙이란?
선입선출의 데이터 구조로 먼저 들어간 데이터가 먼저 나오게 된다.
- 스택보다 더 동적이지만 효율성이 떨어진다.
- 스택이 동적 수명으로 메모리를 할당할 수 없는 작업을 수행한다
- 동적? 프로그램의 실행 중에 필요에 따라 크기가 결정되는 것
- 힙에 메모리를 할당하려면 힙 데이터 구조를 검색하여 적절한 크기의 사용하지 않는 블록을 찾아야 한다.
- 작업이 끝나면 할당 해제 하기 위해 적절한 위치에 해당 메모리를 다시 넣어줘야 한다
- 여러 스레드가 동시에 힙에 접근할 수도 있기 때문에 힙은 여러 메커니즘을 통해 무결성을 갖추고 있어야 한다.
- 이게 큰 비용이 든다

- 프로그램을 만들 때 힙에 할당하는 시기와 위치에 주의를 기울이면 퍼포먼스가 향상된다.
코드의 실행과정 (Stack)
struct Point {
var x, y: Double
func draw() { ... }
}
let point1 = Point(x:0, y:0)
var point2 = point1
point2.x = 5
//use 'point1'
//use 'point2'
- 다음과 같은 코드를 실행해보면 각 point의 x를 출력하면 다음과 같이 나온다.
- point1.x = 0.0
- point2.x = 5.0
- 그러면 다음과 같은 코드를 실행하기 위해서 어떤 일이 일어나는지 살펴보자

- 첫번째로 코드가 실행되기전 컴파일의 단계에서 point1과 point2를 위한 스택 공간이 할당이 된다.
- 각각 초기화를 진행이 된다.
- point1과 point2를 실행하면 함수를 입력했을 때의 위치까지 스택 포인터를 다시 증가 시키는 것으로 point1과 point2의 메모리 할당이 해제 된다.
- 해당 코드를 struct를 class로 변경하여 처리하면 다음과 같은 일이 발생한다.
- point1과 point2가 스택에 할당이 된다. 하지만 point들의 프로퍼티들이 저장되는 것이 아닌 참조를 위해 메모리를 할당하게 된다.
- let point1 = Point(x: 0, y: 0) 구문에서 heap의 데이터 구조에서 적절한 크기의 사용되지 않은 메모리 블록을 검색한다.

- 클래스 포인터에 4단어의 저장 공간을 할당했다. 왜?????
- point가 class이고 x와 y말고 관리할 정보들이 있기 때문이다.

- struct일 때 처럼 point2가 point1을 복사하지 않는다.
- 동일한 Point를 참조하게 되는 것이다.
- 따라서 point2.x에 5의 값을 할당하면 다음과 같이 된다.
- point1.x → 5.0
- point2.x → 5.0
- point를 사용하면 swift에서 사용자를 대신하여 메모리를 할당 해제하고 사용하지 않는 블럭을 적절한 위치로 보낼 것이다.

결론
- 클래스는 힙 할당이 필요하기 때문에 구조체보다 생성하는데 비용이 더 많이 든다.
- 클래스는 참조 타입의 특성을 가진다.
- 참조와 관련된 특성이 필요 없다면 구조체를 사용하는 것이 더 좋다.
적용하여 코드의 성능을 향상 시켜보자
- 간단한 메시지 앱이다.

- makeBallon함수는 사용자 스크롤 중에 자주 호출하기 때문에 정말 빨라야 한다.

- 그래서 다음과 같이 캐시 레이어를 추가했다.
- 두 번이상 풍선 이미지를 그릴 필요가 없다.
- 하지만 문자열로 키를 만들었기 때문에 안전하지 못하다.
- string은 힙에 간접적으로 내용을 저장한다.
- 무조건 힙저장은 아니라고 한다. (아..)
- https://github.com/apple/swift/blob/0d4a5853bf665eb860ad19a16048664899c6cce3/stdlib/public/core/StringObject.swift
- form에 따라서 다르다고 한다.

- 따라서 makeBallon함수를 호출할 때마다 캐시를 사용한다고 해도 힙 할당이 발생
- 구조체를 사용하여 해당 말풍선의 색, 꼬리, 방향등을 나타낼 수 있다.
- struct는 1급 객체이기 때문에 dictionary에서 키로 사용될 수 있다.
- Hashable 준수
- string보다 훨씬 안전한 방법
- makeBallon 함수를 사용하여 캐시 사용이 일어나도 구조체를 구성하는 데 힙을 사용하지 않기 때문에 할당 오버헤드가 없다.
Reference Counting
- swift는 참조를 추가하거나 제거하면 해당 참조 개수가 증가하거나 감소한다.
- 0에 도달하면 아무도 인스턴스를 참조 안한다고 판단하여 해당 메모리를 할당 해제 한다
- reference counting은 매우 빈번하게 일어나는 작업이다.

- 첫째, 증가 및 감소를 실행하기 위한 몇 가지 수준의 간접 참조가 존재한다.
- 더 중요한 것은 힙 할당과 마찬가지로 여러 스레드의 모든 힙 인스턴스에 동시에 추가하거나 제거할 수 있기 때문에 고려해야 할 thread safety가 있다.
RC를 계산하는 방법

- point1이라는 인스턴스가 생성이 될 때 RC 1 증가

- point 2에 point1을 할당하면 2개의 참조가 생기기 때문에 RC를 1 증가 시켜준다.
- 이후, release를 하는 경우 RC가 1씩 감소해 최종적으로 0이 된다.

- 이제 참조하는 것이 없기 때문에 swift는 메모리 해제를 해도 된다는 것을 알게 된다.
- heap을 lock하고, 해당 메모리 블록을 반환하게 된다.
struct
- 구조체는 어떻게 될까?
- 구조체는 힙 할당을 안하기 때문에 참조 계산 오버헤드가 없다.
- 더 복잡한 구조체는???
- 힙 할당을 하는 string과 class인 UIFont를 사용하는 구조체는 ??

- string과 UIFont는 힙에 할당하기 때문에 RC계산이 필요하다.
- 메모리 표현을 보면 기본적으로 Label에는 2개의 참조가 존재한다.
- string
- font

- 복사본을 만드는 경우에는 2개의 참조를 추가하게 된다.

- swift가 이러한 힙 할당을 추적하는 방법은 retain, release를 통해서 하는 것이다.
- label은 클래스가 가지는 참조 카운팅의 2배의 오버헤드를 발생 시키는 것을 알 수 있다.

- 클래스는 힙에 할당되기 때문에 swift는 힙 할당의 lifetime을 관리해야 한다.


- 구조체에 참조를 하는 값이 존재하면 참조 계산 오버헤드를 지불한다.
- 그렇기 때문에 참조 값이 많다면 struct보다는 class를 사용하는 것이 좋다.
- 참조가 2개 이상이면 클래스보다 참조 계산 오버헤드가 더 많아 진다.
메시지 앱에 적용해보기
- 상황
- 사용자들이 문자말고 이미지도 보내고 싶다.

- 기본적으로 3가지의 프로퍼티가 모두 참조 카운팅 오버헤드를 발생시킨다.
- 기존의 UUID는 String이였지만 2016년에 foundation에 UUID가 추가 되었기 때문에 UUID를 통해 기존의 String으로 아무런 값을 넣을 수 있던 상황을 UUID만 넣도록 변경했다.


- mimeType은 extension을 통해 구현 했다.

- enum 타입으로 변경을 해서 적절한 값을 매핑해준다.
- 안전성을 가지게 되고 힙에 저장할 필요가 없기 때문에 성능도 향상되었다.

- 동일한 코드지만 작성하기 편한 코드

- 기존에 uuid와 mimeType이 string일 때와 다르게 각각의 타입으로 구현해서 참조 카운트와 힙 할당될 필요가 없기 때문에 참조 카운트 오버헤드를 지불하지 않아도 된다.
Method Dispatch

- 컴파일 타임에 구현을 결정할 수 있으면 정적 디스패치라고 한다.
- inline과 같은 것을 이용해서 최적화 하는 것이 가능하다.

- 동적 디스패치는 어디로 이동할지 컴파일 단계에서 결정이 불가능하다.
- 런타임 단계에서 가능해진다.
- 참조 카운팅 및 힙 할당과 같은 스레드 동기화 오버헤드는 없다.
- 하지만 동적 디스패치는 컴파일러의 가시성을 차단하기 때문에 최적화가 힘들다.
정적 디스패치?
- 인라인이란?

- 이 코드에서 drawPoint(point)를 실행하는 것과 point.draw()를 실행하는 것과 동일하다.
- 그렇기 때문에 drawPoint를 draw로 대체할 수 있다.

- 둘다 정적 디스패치 이므로, draw를 호출할 필요없이 그냥 draw() 구현부를 그냥 실행하는 것도 가능하다.
- 정적 디스패치의 오버헤드와 호출 스택 관련이 필요가 없다.
- 바로 stack에 Point를 생성하고 함수의 구현부를 실행하면 되기 때문에 빠르다.
- 이러한 이유들이 정적 디스패치가 동적 디스패치보다 빠른 이유다.
- 단일로 봤을 때는 단일 동적 디스패치와 단일 정적 디스패치가 별차이가 없지만 정적 디스패치는 전체 체인을 통해 가시성을 가지게 된다.
- 동적 디스패치 체인은 매 단계마다 추론을 하지 않고 상위 레벨에서 차단된다.
결론: 컴파일러는 호출 스택 오버헤드가 없이 단일 구현처럼 정적 메서드 디스패치 체인을 축소하는 것이 가능하다. 위의 drawPoint→draw→구현부 이것 처럼
동적 디스패치
- 동적 디스패치를 사용하는 이유

- 다형성을 가지게 한다
- Drawable이라는 상위 클래스를 바탕으로 하위 클래스를 가질 수 있다.

- Drawable타입의 배열을 만들어줬다.
- 이 배열에는 Point와 Line이 포함될 수 있다.
- 각 배열의 요소에 따라서 draw를 호출하는 것이 가능하다.

- Point와 LIne모두 클래스이고 배열에 참조를 저장하기 때문에 크기가 모두 동일하다.

- 이 d.draw는 point를 그리는 것일 수도 있고, line을 그리는 것일 수도 있다.
- 그렇다면 어떤코드를 불러올지 어떻게 결정할까?
- 컴파일러가 해당 클래스의 유형 정보에 대한 포인터를 클래스에 추가하고 이를 정적메모리에 저장한다.
- draw를 호출할 때 컴파일러가 실제로 생성하는 것은 정적 메모리에 있는 virtual method table이라는 타입을 조회하는 것이다.
- 일단 실행을 하면 컴파일러가 적합한 draw메소드를 찾고 이것을 파라미터로 실제 인스턴스를 전달한다.


- 기본적으로 클래스는 메서드를 동적으로 디스패치한다.
- 이것은 기본적으로 큰 차이를 만들지는 않지만 메소드 체인과 기타 사항들이 쌓인다면 최적화를 방해하는 요소로 남게 된다.
- 하지만 모든 클래스가 동적 디스패치가 필요한 것은 아니다.

- final 키워드를 사용하면 동적 디스패치에서 정적 디스패치로 변경이 가능하다.
- 상속을 안하기 때문에 그런듯
- 또한 컴파일러가 앱에서 클래스를 서브클래싱하지 않을 것임을 추론하고 증명할 수 있다면 동적에서 정적 디스패치로 전환한다.
정적 디스패치
- 장점
- 컴파일러가 최적화할 정보가 충분하므로, inline과 같은 최적화가 가능하기 때문에 성능상의 이점이 존재한다.
- 단점
- 유연성이 낮기 때문에 상속이나 오버라이딩을 통한 다형성 구현이 어렵다.
동적 디스패치
- 장점
- 유연하기 때문에 다향성을 구현하는 것이 가능하다.
- 같은 형태? 이름의 메소드를 호출할 때 다른 메소드를 실행하는 것이 가능하다.
- 단점
- 메소드를 호출할 때 어떤 메소드를 호출을 할지 결정해야 하기 때문에 소모되는 비용이 크다.
- 최적화가 제한적이다.
어떻게 수행되는지 알고 싶으면 optimizing swift performance WWDC를 봐야한다.