본문 바로가기
WWDC

[WWDC15] - Optimizing Swift Performance

by 고고 2021. 12. 14.

안녕하세요 ◠‿◠ 고고입니다!

 

 

이 WWDC의 아젠다

1. swift 2.0 성능 업데이트

2. swift 성능 이해하기

3. swift 프로그래밍의 성능 통계하기 위해 Instruments 사용하기

 

 

개발자들은 아래의 high-level 기능들을 대상으로 하는 컴파일러 최적화를 구현하여 Swift를 빠르게 만들습니다.

그 중 한 가지 예를 보겠습니다.

 

Bound checks elimination

Swift는 당신이 배열의 밖을 읽거나 쓰지 않도록 보호합니다.

 

컴파일러는 조건을 위로 보내 한 번만 실행되도록하여 최적화했습니다.

 

 

 

 

Xcode는 파일을 개별적으로 컴파일합니다. 따라서 컴퓨터의 여러 코어에서 많은 파일들을 병렬로 컴파일할 수 있습니다.

하지만 문제는 옵티마이저의 범위가 하나의 파일로 제한된다는 것입니다.

 

 

Whole Module Optimization을 통해 컴파일러는 전체 모듈을 한 번에 최적화할 수 있습니다.

자연스레 빌드가 더 오래 걸리지만 생성된 바이너리는 더 빠르게 실행됩니다.

 

Swift 2에서는 Whole Module Optimization에 두 가지 주요 개선 사항을 적용했습니다.

1. Whole Module Optimization에 의존하는 새로운 최적화 추가

2. 컴파일 파이프라인의 일부 병렬화

 

 

Xcode 7에서는 Optimization Level을 설정할 수 있습니다.

 

 

앱의 성능을 개선하는 3가지 기술

1. Reference Counting

클래스 C의 인스턴스를 할당하고 그것을 변수 x에 배정합니다.

맨 위의 박스에는 인스턴스의 reference count를 나타냅니다. 

 

 

y는 인스턴스의 새 참조를 생성합니다. reference count가 1 증가하여 2가 되었습니다.

 

 

임시로 만든 c에도 y를 배정합니다.

 

 

foo 함수를 벗어나며 c는 사라지며 reference count가 줄어듭니다.

 

 

y와 x에 nil을 넣으면서 reference count는 0이 됩니다. C의 인스턴스는 할당이 해제됩니다.

 

 

- Classes That Do Not Contain References

Array<Point>를 만들어 for문을 돌면 인스턴스에 대한 새 참조를 만드는 것이다. 따라서 reference counting 오버헤드가 발생한다.

 

 

구조체는 reference counting을 할 필요가 없다. 따라서 루프로부터의 오버헤드가 없어진다.

 

 

구조체가 reference counting이 필요할 때에는 프로퍼티가 reference counting이 필요할 때이다.

아래 Point 구조체는 UIColor라는 class를 프로퍼티로 가지어 reference를 참조하게 된다.

 

 

 

많은 reference를 가진 구조체 예시

String, Array, Dictionary는 모두 value type이지만 내부적으로 내부 데이터의 수명을 관리하는 데 사용되는 클래스를 포함합니다. 따라서 구조체 중 하나를 할당할 때, 함수에 전달할 때마다 실제로 5번의 reference counting을 수정해야 합니다.

 

 

Wrapper class를 사용하면 reference counting을 하나로 줄일 수 있습니다. 하지만 이로 인해 예상치 못한 데이터 공유로 인한 결과나 일이 발생할 수 있습니다.

Value sementic을 갖고 optimization의 이점을 얻을 수 있는 방법은 Building Better Apps with Value Types in Swift를 보세요.

 

2. Generics

min이라는 함수에 Comparable을 준수하는 제네릭 T가 있습니다. 위의 min함수를 컴파일러가 내보낸 코드는 아래와 같습니다.

 

 

먼저 컴파일러가 x와 y를 모두 비교하기 위해 간접 참조를 사용하고 있음을 주목해주세요.

컴파일러는 T가 reference counting 변경을 요구하는지 모르기 때문에 추가적인 간접 참조를 넣어야 합니다.

 

 

컴파일러가 이를 최적화하는 방법은 Generic Specialization입니다.

컴파일러는 두 Int가 min함수로 전달되는 것을 보고 T를 Int로 대치하여 함수를 복사합니다.

 

 

그런 다음 이 함수가 Int에 최적화되고 이 함수와 관련된 모든 오버헤드가 제거되고, 불필요한 reference counting도 제거됩니다.

 

 

Visibility에 의해 제한된 경우가 있습니다.

다른 파일에서 제네릭을 추론해야 할 때입니다. 파일1과 파일2를 별도로 컴파일하기 때문에 파일2의 함수 정의는 컴파일러에서 볼 수 없습니다. 따라서 파일1에서는 전처럼 int에 특정된 함수가 아닌 min<T>함수를 부릅니다.

 

 

하지만 Whole Module Optimization가 가능하다면 파일1과 파일2이 함께 컴파일됩니다. 파일2의 정의를 파일1에서 볼 수 있기에 min<T>가 아닌 min<Int>를 호출합니다.

 

3. Dynamic Dispatch

Pet클래스와 Pet을 상속하는 Dog클래스가 있습니다.

아래는 컴파일러가 생성한 코드입니다. 컴파일러는 현재 클래스 계층 구조가 주어지면 name 프로퍼티이나 nosie() 함수가 하위 클래스에 의해 override되는지 알 수 없기 때문에 간접 참조를 삽입해야 합니다.

컴파일러는 name이나 noise()의 하위클래스에 의해 가능한 override가 없음을 증명할 수 있는 경우에만 직접 호출을 할 수 있습니다. 

 

API의 클래스 계층 구조를 제한하는 두 가지 방법 - 상속과 접근제한자

 

 

 

API에 final 키워드가 붙어진 선언이 포함된 경우, API는 이 선언이 하위 클래스에 의해 override되지 않고 dynamic dispatch와 간접참조가 제거될 수 있다는 것을 전달합니다.

이름이 override되지 않을 것을 알기에 final var name으로 수정하면 컴파일러에서도 이렇게 수정됩니다.

 

 

 

컴파일러가 noiseImpl()를 직접 호출할 수 있을까요?

이는 기본적으로 컴파일러가 이 API가 Cat 및 Dog와 같은 하위 클래스에서 override되는 noiseimpl()을 위한 것이라고 가정해야 하기 때문에 불가능합니다.

 

 

private 키워드를 통해 pet.swift가 아닌 다른 파일에서 보여질 수가 없으니 컴파일러는 직접 호출이 가능해집니다.

 

 

컴파일러는 모듈A의 다른 파일에 Dog의 하위클래스가 있는지 알 수 없기 때문에 간접 참조를 해야 합니다.

 

 

하지만 Whole Module Optimization과 함께라면 모듈의 모든 파일을 볼 수 있습니다. 따라서 컴파일러는 Dog의 하위 클래스가 없음을 볼 수 있기에 직접 noise()를 호출할 수 있습니다.

 

 

 

 

그래서 왜 객체지향 밴치마크(?)에서 Swift가 Objective-C보다 훨씬 빠를까요?

그 이유는 Objective-C에서 컴파일러가 ObjC 메세지 전송을 통해 dynamic dispatch를 제거할 수 없기 때문입니다. 그것을 통해 inline과 분석을 수행할 수 없습니다. 컴파일러는 ObjC 메시지 전송의 다른 쪽에 무엇이든 있을 수 있다고 가정해야 합니다.

그러나 Swift에서는 컴파일러에 더 많은 정보가 있어 dynamic dispatch를 제거할 수 있습니다.

 

 

 

결론 - Relase에서 Whole Module Optimization을 사용해보세요!

 

 

 

후의 예제에서는 아티클이 나오자마자 CPU사용률이 100%로 증가하면서 프레임수가 60FPS -> 39FPS로 줄어들었습니다.

 

 

디테일한 call stack을 보면 NSApplicationMain에서 총 시간의 99.1%가 사용되었음을 알 수 있습니다.

그 아래를 계속 열어서 확인하는 것보다는 오른쪽의 상세보기를 추천합니다.

 

 

Heaviest Stack Trace의 왼쪽 부분을 보면 갑자기 9000에서 4000으로 낮아집니다. 이 부분을 클릭해서 보면 왼쪽 아래에 자세하게 들어가집니다.

 

 

화살표를 눌러서 들어가보면

 

 

release가 40%, retain이 35%정도를 차지하는 것을 볼 수 있다.

 

 

저렇게 release와 retain 부담이 큰 findNearestNeighbor 함수를 두번 클릭하여 소스코드를 봅니다.

 

 

오른쪽의 View Disassemble을 누르면 release와 retain을 반복하는 것을 볼 수 있습니다.

 

 

업데이트 타이머가 실행되면 해당 코드의 모든 싱글 파티클에 대해 findNearestNeighbor 함수를 호출한 다음 화면의 모든 단일 파티클에 대해 반복되는 이 루프가 있다는 것을 알았습니다. N제곱 알고리즘이네요. (화질 죄송합니다!! ㅠㅠ)

 

이 클래스를 최적화하겠습니다.

final 키워드를 붙이니 처음의 60프레임에서 38프레임으로 떨어졌던 현상이 이제는 60프레임을 유지합니다.

 

 

하지만 60프레임에서 38프레임으로 떨어지는 현상이 또 있어 그것을 비슷한 방식으로 해결합니다. 끝!

 

 

 

+) 애플 문서중에 OptimizationTips라는 문서도 있네요.

댓글