WWDC/Swift

[Swift] WWDC16 - Understanding Swift Performance (1)

유정주 2022. 9. 7. 17:25
반응형

WWDC 16의 Understanding Swift Performance 세션을 이제서야 보았습니다.

2편에 걸쳐 나눠서 정리해보려고 합니다.

(해당 포스팅의 사진은 https://developer.apple.com/wwdc16/416 의 프레젠테이션 슬라이드에서 가져왔습니다.)

 

참고로 해당 포스팅에서는 메서드와 함수를 구분하여 표현하고 있습니다.

발표도 Method와 Function을 명확하게 구분해서 말하고 있더라고요.

두 개의 차이는 https://jeong9216.tistory.com/472 를 참고해 주세요.

 

Dimensions of Performance

추상화를 만들고 추상화 메커니즘을 선택할 때 세 가지를 고려해야 합니다.

  1. 인스턴스가 스택에 할당되는지 힙에 할당되는지
  2. 인스턴스를 전달할 때 참조 카운트의 오버헤드가 얼마나 발생하는지
  3. 인스턴스에서 메서드를 호출하면 Static Dispatch로 동작하는지 Method Dispatch로 동작하는지

빠른 Swift 코드를 위해서라면 우리가 이용하지 않는 dynamism과 runtime에 대한 비용을 줄여야 합니다.

더 나은 성능을 위해 어떻게 바꿀 수 있는지 알아야 합니다.

 

Allocation

Swift는 자동으로 메모리를 할당하고 해제합니다.

 

Stack

스택은 매우 간단한 자료구조입니다.

스택은 스택의 끝에서만 값을 push 하거나 pop 할 수 있고, 스택의 끝에 스택 포인터를 유지함으로써 이를 구현할 수 있습니다.

함수를 호출할 때는 스택 포인터를 약간 감소시켜 메모리 할당을 할 수 있고, 함수 실행이 끝나면 스택 포인터를 함수 호출 이전 위치로 증가시켜 메모리 할당 해제를 할 수 있습니다.

이 과정을 정수 할당 속도만큼 빠르게 이루어 집니다.

 

Heap

힙은 스택보다 동적이지만 효율은 떨어집니다.

힙을 사용하면 메모리를 동적 lifecycle로 관리할 수 있지만 스택은 불가능합니다.

하지만 그러기 위해서는 더 발전된 고급 자료구조가 필요합니다.

 

힙은 적절한 크기의 미사용 블록을 찾아 메모리를 할당하고, 작업이 끝나면 해당 메모리를 적절한 위치에 다시 삽입해야 합니다.

힙에는 스택보다 더 많은 작업이 연관되어 있습니다.

 

여러 스레드가 동시에 힙 할당을 할 수 있습니다.

동기화 메커니즘을 사용해 무결성 보호를 위한 작업도 필요한데, 이건 꽤 큰 비용이 소모됩니다.

 

코드를 짤 때 언제 어떤 인스턴스가 힙에 저장되는지 신경쓰면 성능을 획기적으로 향상시킬 수 있습니다. 

 

예제 1 - 구조체

Point 구조체는 x, y 저장 프로퍼티와 draw 메서드를 가지고 있습니다.

point1은 (0, 0)으로 생성하고 point2는 point1을 복사해서 할당합니다.

그리고 point2의 x를 5로 변경합니다.

 

이 흐름을 그림과 함께 알아봅시다.

함수가 실행되고 코드 실행을 시작하기 전에 point1 인스턴스와 point2 인스턴스를 위한 스택 메모리를 할당합니다.

스택 메모리 할당은 스택 포인터를 움직이는 것만으로 가능합니다.

Point는 구조체이기 때문에 저장 프로퍼티인 x와 y는 스택 라인에 저장됩니다.

 

point1 인스턴스를 생성할 때는 이미 스택에 메모리가 할당되있기 때문에 값을 초기화만 하면 됩니다.

 

point2에 point1을 할당할 때는 point1의 복사본을 만들고,

이전에 할당했던 스택 메모리에 point2 값을 초기화합니다.

 

그림과 같이 point1과 point2는 독립적인 인스턴스입니다.

이는 point2의 x를 변경할 때 point1에 영향을 주지 않는다는 것을 의미합니다.

이를 value sementic이라고 합니다.

 

함수가 종료되면 감소시켰던 스택 포인터를 함수가 실행하기 전 위치로 감소시켜(이동시켜) 메모리 할당 해제를 합니다.

 

예제 1 - 클래스

Point 구조체와 동일한 코드를 가진 Point 클래스입니다.

클래스일 때는 메모리 할당이 어떻게 바뀌는지 그림으로 알아봅시다.

 

함수를 실행하면 구조체와 마찬가지로 스택 메모리를 할당합니다.

다만, point 프로퍼티 값을 저장하는 대신 point1과 point2에 대한 참조 메모리를 할당합니다.

 

point1 인스턴스를 생성하면 힙에 메모리를 할당해야 합니다.

Swift는 (0, 0)을 생성할 때 힙을 lock 하고 적절한 크기의 사용되지 않은 메모리 블록을 찾습니다.

 

그 뒤 x = 0, y = 0인 메모리를 초기화할 수 있으며, point1 스택 메모리 공간을 힙의 메모리 주소로 초기화할 수 있습니다.

참고로 클래스이기 때문에 구조체와 달리 힙에 4 word의 공간을 할당하는데,

x, y 공간 2 word와 Swift가 우리를 대신해서 관리하기 위한 2 word 입니다.

(1칸은 스택에서 참조 당하는 용도, 다른 1칸은 RC를 관리하는 용도)

 

point2에 point1을 할당할 때 구조체와 달리 point1 내용을 복사하지 않습니다.

대신 참조를 복사하여 힙의 동일한 point 인스턴스를 참조합니다.

 

둘은 완전히 동일한 인스턴스를 참조하기 때문에 point2의 x를 5로 바꾸면 point1에도 영향을 줍니다.

이를 reference semetic이라고 하며, 의도하지 않은 상태 공유로 이어질 수 있습니다.

 

함수가 종료되면 Swift가 힙을 lock한 뒤 더이상 사용하지 않는 블록을 적절한 위치로 돌려놓으면서 힙 메모리 할당을 해제합니다.

 

마지막으로 스택 포인터를 감소시켜 스택에 할당한 메모리도 할당 해제합니다.

 

예제1에서 알 수 있는 것

클래스는 힙 할당이 필요하기 때문에 클래스가 구조체보다 더 많은 Allocation 비용이 듭니다.

클래스는 reference semetic 체계를 가지기 때문에 Identity(Identifier)나 간접 저장같은 유용한 특성이 있습니다.

이런 특성이 불필요하다면 구조체를 이용하는 것이 좋습니다.

 

구조체는 클래스보다 Allocation 비용이 적고, 의도하지 않은 상태 공유에 취약하지 않습니다.

 

Allocation을 최적화 하는 방법

지금까지 알아본 내용을 적용하여 Allocation을 최적화 해보겠습니다.

(Heap Allocation을 Stack Allocation으로 변경하는 부분에 집중하시면 좋습니다.)

 

메시징 앱 예시

메시징 앱을 만든다고 생각해 봅시다.

makeBalloon( )은 채팅방 말풍선을 생성하는 함수입니다.

말풍선의 색, 방향, 꼬리 종류를 enum으로 선언되어 있습니다.

makeBalloon 함수는 allocation launch 및 사용자 스크롤 중에 빈번하게 호출되기 때문에 굉장히 빨라야 합니다.

 

그렇기 때문에 캐시(cache)를 도입했습니다.

주어진 configuration에 대해 이 말풍선 이미지를 두 번 이상 생성할 필요가 없습니다.

한 번 생성하면 캐시에서 꺼내기만 하면 됩니다.

 

캐시의 키는 color, orientation, tail을 문자열로 이은 String 타입으로 설정했습니다.

여기에는 몇 가지 개선할 수 있는 사항이 존재합니다.

 

첫 번째로 String은 키로서 강력한 타입이 아닙니다.

지금은 configuration space를 나타내기 위해 사용하고 있지만 강아지 이름도 넣는 것이 가능하기도 하기 때문에 안전하지 않습니다.

또한 String은 힙에 해당 문자의 내용을 저장합니다. 

따라서 makeBalloon 함수를 호출할 때마다 캐시 hit가 있더라도 키 생성 과정에서 힙 할당이 발생합니다.

(캐시에 데이터가 존재하는 것을 캐시 hit라고 합니다.)

 

개선하기

구조체를 이용하면 캐시 hit이 될 때 힙 할당이 발생하는 문제는 개선할 수 있습니다.

 

구조체를 사용하면 color, orientation, tail을 구성하면서 나타낼 수 있습니다.

이것은 String 보다 훨씬 안전한 방법입니다.

구조체는 일급 객체이기 때문에 Hashable 프로토콜을 채택하면 키로서 사용할 수도 있습니다.

(구조체는 모든 프로퍼티가 Hashable 하면 Hashable 프로토콜을 채택하는 것만으로도 Hashable하게 사용할 수 있습니다.)

 

Attribute 구조체를 사용한 makeBalloon 함수는 캐시 hit이 발생하면 힙 할당이 필요하지 않기 때문에 할당 오버헤드가 발생하지 않습니다.

키를 스택에 할당할 수 있기 때문입니다.

이것은 String 키보다 훨씬 안전하고 훨씬 빠른 방법입니다.

 

Reference Counting

Swift에서 힙의 메모리 할당 해제는 언제 하는 것이 안전할까요?

Swift는 힙의 모든 인스턴스에 대해 Reference count(이하 RC)를 구해서 인스턴스에 보관합니다.

인스턴스에 참조를 추가하거나 제거할 때 RC를 증가시키거나 감소시킵니다.

RC가 0에 도달하면 Swift는 더이상 해당 인스턴스를 참조하는 객체가 없다는 것을 알고 해당 메모리를 할당 해제합니다.

이 순간이 가장 안전한 메모리 할당 해제 타이밍입니다.

 

참조 카운팅은 매우 빈번한 작업이고 실제로 정수를 증가 또는 감소시키는 것보다 많은 작업이 내재되어 있습니다.

 

첫 번째로, 증가 및 감소를 실행하기 위한 몇 가지 수준의 간접 참고가 존재합니다.

 

두 번째로, Thread safety를 위한 오버헤드가 발생합니다.

힙 할당과 마찬가지로 여러 스레드가 동시에 힙 인스턴스를 참조하거나 제거할 수 있기 때문에

thread safety를 고려하여 참조 카운팅은 원자적으로 이루어져야 합니다.

(원자적 : 어떠한 작업이 실행될 때 언제나 완전하게 진행되어 종료되거나, 그럴 수 없는 경우 실행을 하지 않는 경우를 말함)

 

참조 카운트는 빈번한 작업이므로 이 비용이 커질 수 있습니다.

 

왼쪽은 실제 코드이고 오른쪽은 Swift가 실제로 수행하는 의사 코드입니다.

Point 클래스는 Int형 refCount 변수를 가지게 되고 몇 개의 retain과 release 호출이 생겼습니다.

retain은 참조 카운트를 원자적으로 증가시킬 것이고, release는 참조 카운트를 원자적으로 감소시킬 것입니다.

이런 식으로 Swift는 힙의 Point에 얼마나 많은 참조가 살아있는지 추적할 수 있습니다.

 

이 흐름을 그림으로 알아 보겠습니다.

Point 인스턴스를 생성하기 전까지는 위에서 알아본 것과 동일합니다.

 

point1 인스턴스가 생성되며 refCount가 1 증가합니다.

point2에 point1을 할당하면 point2가 동일한 인스턴스를 참조합니다.

참조 직후 retain이 호출되어 refCount가 2가 됩니다.

 

point1이 더이상 사용되지 않으면 참조가 끊기면서 point1에 대한 release가 수행되고 refCount가 1 감소합니다.

 

같은 원리로 point2에 대한 release가 수행되면 Point 인스턴스의 refCount는 0이 됩니다.

 

더이상 Point 인스턴스의 참조가 남아있지 않으므로 Swift는 힙을 lock하고 메모리 블록을 반환합니다.

 

함수가 완전히 종료되면 스택 포인터가 감소하면서 스택 메모리까지 완전히 할당 해제됩니다.

 

구조체의 Reference Counting

구조체도 클래스처럼 RC가 존재할까요?

Point 구조체를 알아볼 때 힙 할당을 하지 않았습니다.

복사를 할 때도 힙 할당이 발생하지 않았고요.

따라서 Point 구조체는 참조 카운팅 오버헤드가 존재하지 않습니다.

 

그렇다면 조금 더 복잡한 구조체는 어떨까요?

Label 구조체는 String과 UIFont 타입 저장 프로퍼티와 draw 메서드를 가지고 있습니다.

String은 힙에 저장되기 때문에 RC가 필요하고 UIFont도 클래스이기 때문에 RC가 필요합니다.

 

메모리를 그림으로 표현하면,

label1이 생성될 때 두 개의 참조가 생기고 

label2에 label1이 할당될 때 두 개의 참조가 더 생깁니다.

 

retain과 release 호출을 삽입하여 살펴보면 두 개씩 쌍을 이뤄 retain, release 되는 것을 볼 수 있습니다.

이를 통해 Label 구조체는 Point 클래스가 가질 수 있는 참조 카운팅 오버헤드의 두 배를 발생시킨다는 것을 알 수 있습니다.

 

요약

요약하면 클래스가 힙에 할당되기 때문에 Swift는 해당 힙 할당의 수명을 관리해야 하고,

이를 위해 참조 카운팅을 사용합니다.

참조 카운팅 작업은 매우 빈번하고 원자성때문에 무시할 수 있는 작업이 아닙니다.

 

순수한 구조체는 힙 할당이 존재하지 않기 때문에 참조 카운팅도 필요하지 않습니다.

따라서 Allocation과 RC 모두 효율적입니다.

 

그러나

구조체 프로퍼티로 참조 객체가 들어가면 RC 오버헤드도 발생합니다.

 

구조체가 포함하고 있는 참조 수에 비례하여 RC 오버헤드가 발생합니다. (위 예시에서는 2개)

따라서 참조가 두 개 이상인 경우에는 클래스보다 RC 오버헤드가 더 많이 발생합니다.

 

RC 최적화를 메시징 앱에 적용하기

사용자들은 메시지 앱으로 문자 메시지 뿐만 아니라 이미지와 같은 첨부파일도 보내고 싶어합니다.

이럴 때 Attachment 구조체를 사용할 수 있습니다.

Attachment는 첨부 파일에 대한 디스크 내 데이터 경로인 fileURL 프로퍼티가 있고,

클라이언트와 서버가 파일의 고유함을 체크할 uuid,

첨부 파일의 데이터 타입을 저장하는 mimeType을 가집니다.

Attachment init은 mimeType이 유효하지 않은 경우 실패할 수 있습니다.

 

Attachment 구조체는 모든 프로퍼티가 참조 카운팅 오버헤드를 발생 시킵니다.

우리는 이를 개선할 수 있습니다.

 

먼저 UUID는 128비트 무작위 식별자로 명확하게 정의된 개념입니다.

2016년 Foundation은 uuid에 대해 새로운 value 타입을 추가했습니다.

이는 해당 128비트를 구조체에 직접 저장하기 때문에 매우 유용합니다.

String으로 선언한 uuid를 value 타입인 UUID로 변경하면 참조 카운팅 오버헤드를 줄일 수 있을 뿐더러

UUID만 저장할 수 있기 때문에 더 엄격한 안정성이 확보되었습니다.

 

다음은 mimeType 입니다.

현재 mimeType은 아래처럼 구현되었습니다.

String을 확장하여 switch문으로 자기 자신의 문자열을 체크합니다.

 

Swift의 훌륭한 추상화 메커니즘이면서 value 타입인 열거형을 이용하면 이를 개선할 수 있습니다.

enum의 init을 이용해 적절한 경우에 열거형의 case로 매핑할 수 있습니다.

enum을 적용한 결과, 적절한 타입만 저장할 수 있게 되어 타입 안정성도 증가했고,

힙에 간접 저장을 하지 않아도 되기 때문에 성능도 향상되었습니다.

 

위의 enum 코드는

이렇게 줄일 수도 있습니다.

enum의 raw 값을 String으로 설정하여 훨씬 명확하고 편리하면서 완전히 동일한 동작을 합니다.

 

최종적인 Attachment 구조체를 보면 많은 참조 카운팅 오버헤드가 줄어들었습니다.

 

Method Dispatch

런타임에 메서드를 호출할 때 Swift는 올바른 구현부를 실행해야 합니다.

 

Static Dispatch

컴파일 타임에 실행할 구현부를 결정할 수 있는 경우 이를 정적 디스패치라고 합니다.

컴파일 타임에 이미 결정했기 때문에 런타임에는 즉시 구현부로 이동할 수 있습니다.

컴파일러가 실제로 어떤 구현이 실행될지 visibility을 가질 수 있다는 점에서 굉장히 좋습니다.

정적 디스패치는 인라인같은 것을 이용해 코드를 최적화할 수 있습니다.

 

Dynamic Dispatch

동적 디스패치는 컴파일 타임에 어떤 구현부로 이동할지 결정할 수 없습니다.

따라서 런타임에 구현부의 위치를 찾은 다음 실행합니다.

동적 디스패치 자체는 한 가지의 간접 참조만 가지면 되기 때문에 정적 디스패치와 비교했을 때 비용이 많이 크진 않습니다.

참조 카운팅 및 힙 할당에서처럼 Thread 동기화 오버헤드는 존재하지 않습니다.

하지만 컴파일러가 visibility을 가지고 있지 않기 때문에 최적화를 할 수 없습니다.

 

inlining

그렇다면 inlining이란 뭘까요?

Point 구조체를 이용해 알아봅시다.

Point 구조체에는 x, y, draw 메서드가 있습니다.

추가로 drawAPoint 함수도 추가했고 이 함수는 Point를 전달 받아 draw를 호출합니다.

 

위 코드에서는 drawAPoint에 Point 인스턴스를 전달하고 있습니다.

 

drawAPoint와 point.draw() 모두 정적 디스패치로 동작합니다.

이것이 의미하는 바는 컴파일러가 어떤 구현이 실행될지 정확히 알고 있으므로 실제 drawAPoint의 구현으로 코드를 대치한다는 것입니다.

 

drawAPoint(point)가 실제 구현 코드인 point.draw()로 대치된 모습입니다.

 

point.draw()도 정적 디스패치이므로 구현 코드 대치될 수 있습니다.

정적 디스패치 메서드는 컴파일 타임에 구현 코드로 대치되기 때문에 런타임에는 구현 코드를 실행만 하면 됩니다.

이는 정적 디스패치가 동적 디스패치보다 속도가 빠른 결정적인 이유입니다.

정적 디스패치와 단일 동적 디스패치와 비교했을 때는 큰 차이가 없지만, 전체 정적 디스패치 체인은 컴파일러가 해당 전체 체인을 통해 visibility를 갖게 됩니다.

하지만 단일 동적 디스패치 체인은 더 높은 레벨의 모든 추론 단계에서 차단될 것입니다.

따라서 컴파일러는 call stack 오버헤드가 없는 구현 코드로 정적 디스패치 체인을 축소할 수 있습니다.

 

동적 디스패치는 왜 있을까

동적 디스패치는 다형성을 지원하기 위해 존재합니다.

Drawable 클래스는 Point와 Line에 상속되어 슈퍼 클래스로 동작됩니다.

 

Drawable 타입 drawables 배열을 생성함으로써 다형성을 지원합니다.

Point와 Line을 모두 저장할 수 있고 저장된 아이템들은 각각의 draw 메서드를 호출할 수 있습니다.

 

V-Table(Virtual Method Table)

이 동작은 어떤 과정을 통해 이루어질까요?

Line과 Point는 모두 Drawable을 상속하는 클래스이기 때문에 배열에 넣을 수 있고

배열에는 참조로 저장되기 때문에 크기가 모두 같습니다. (d[0], d[1]의 크기가 같다는 의미임)

그리고 각각의 draw 메서드를 호출할 것입니다.

 

여기서 컴파일러가 컴파일 타임에 실행할 구현을 결정하지 못하는 이유를 알 수 있습니다.

그림에 표시된 d.draw()는 Point가 될 수도 있고, Line이 될 수도 있습니다.

아이템들은 서로 다른 위치를 가집니다.

 

이 문제를 해결하기 위해 V-Table을 사용합니다.

컴파일러는 클래스 타입 정보에 대한 포인터를 정적 메모리에 저장하고, (d.type)

실제로 draw를 호출할 때 컴파일러가 실제로 생성하는 것을 타입 및 정적 메모리의 v-table을 조회합니다. (d.type.vtable)

그리고 실행하기에 적합한 draw 메서드를 찾고 파라미터로 실제 인스턴스를 self 매개변수로 전달합니다. (d.type.vtable.draw(d))

 

클래스는 기본적으로 동적 디스패치로 동작합니다.

이것은 성능상 큰 차이가 있진 않아도 메서드 체인 및 인라인과 같은 최적화를 불가하게 합니다.

그러나 모든 클래스에서 동적 디스패치가 필요한 것은 아닙니다.

 

클래스를 상속시킬 의도가 없다면(클래스를 상속하는 클래스가 없다면) final로 표시하여 타인에게 의도를 전달할 수 있습니다.

컴파일러는 해당 메서드를 정적으로 디스패치할 것입니다.

이렇게 컴파일러가 앱에서 클래스를 서브클래싱하지 않을 것임을 추론하고 증명할 수 있다면 기회에 따라 동적 디스패치를 정적 디스패치로 전환합니다.

(WWDC 2015 - Optimizing Swift Performance도 보면 좋다고 합니다 ㅎ,ㅎ)

 

구조체는 여기에서도 성능이 좋습니다.

따라서 구조체로 해결이 가능한 것은 구조체로 작성하는 것이 좋습니다.

 

2편에서 이어집니다.

2편 바로가기 : Understanding Swift Performance (2)

 

참고

https://developer.apple.com/wwdc16/416


아직은 초보 개발자입니다.

더 효율적인 코드 훈수 환영합니다!

공감 댓글 부탁드립니다.

 

 

반응형