WWDC/Swift

[Swift] WWDC15 - Optimizing Swift Performance

유정주 2022. 9. 17. 21:42
반응형

저번 Understanding Swift ... 도 그렇고 이번 Optimizing Swift Performance도 그렇고

Swift에 관한 WWDC 내용은 정말 어렵네요 ㅠㅠ 여러 번 돌려봐야겠습니다...

 

Optimizing Swift Performance

Swift에는 클로저, 프로토콜, 제네릭, ARC처럼 좋은 기능이 많이 있습니다.

이런 고차원의 기능이 많으면 프로그램이 할 일이 많아지기 때문에 속도는 느려지기 마련입니다.

하지만 Swift는 고도로 최적화되어 native 코드를 굉장히 빠르게 컴파일해줍니다.

Swift는 어떻게 두 마리 토끼를 다 잡았을까요?

 

애플은 위 사진의 모든 고급 기능들을 최적화하였습니다.

 

Array Bounds Checks Optimizations

모두 알아볼 수 없으니 Safe의 bounds checks elimination을 예시로 봅시다.

아래 코드는 매 반복마다 i가 배열의 범위에 속하는지 체크합니다.

이 코드는 범위 체크를 반복문 바깥에서 하는 것으로 코드를 최적화할 수 있습니다.

어차피 i는 0~n-1까지 반복하므로 한 번만 체크해도 유효하며, n번의 체크에서 1회 체크로 최적화가 되었습니다.

 

Performance Improvements Since 1.0

Swift는 많은 프로그램을 최적화하였습니다.

5개 프로그램 "릴리즈 버전"의 최적화 수치입니다.

그리고 아래는 5개 프로그램의 "디버그 버전"으로 최적화되지 않았습니다.

개발자는 디버그 버전의 성능도 빠르게 실행하기를 원했습니다.

왜냐하면 작성한 코드를 디버깅하고, 테스트하는 시간도 많기 때문입니다.

 

그래서 Swift는 최적화되지 않은 코드를 두 가지 방법으로 더 빨리 실행되도록 했습니다.

첫 번째로 Swift의 런타임 구성 요소를 개선했습니다.

런타임은 메모리를 할당하고 메타데이터에 접근하는 등의 역할을 합니다.

그래서 런타임 구성 요소를 개선하면 더 빨리 실행할 수 있습니다.

 

두 번째로 Swift Standard Library를 최적화하였습니다.

Standard Library는 Array, Dictionary, Set을 구현하는 구성 요소입니다.

최적화 되지 않은 코드에도 이러한 구성요소들이 포함될 것이므로, Standard Library를 개선해서 실행 속도를 개선했습니다.

 

Objective-C와 비교해보면 Swift가 얼마나 빨라졌는지 알 수 있습니다.

DeltaBlue와 Richards는 "객체 지향" 프로그램입니다.

Swift는 DeltaBlue에서는 2.67배,. Richards에서는 4.29배 더 빨랐습니다.

(객체 지향을 강조한 이유는 저~ 아래에서 알 수 있습니다. 하나씩 하자구요. )

 

Xcode 컴파일 방식

Swift는 Whole Module Optimization라는 방식을 통해 더 빠르게 동작이 가능합니다.

(글쓴이: Whole Module Optimization가 이 때 처음 생겼군요.)

 

Whole Module Optimization를 언급하기 전에 Xcode가 파일을 컴파일하는 방식을 먼저 알아보겠습니다.

Xcode는 파일을 개별적으로 컴파일합니다.

이 방법은 컴퓨터의 여러 코어를 이용해 병렬적으로 컴파일하므로 좋은 아이디어였습니다.

또한, 개별적으로 진행되므로 원하는 파일만 컴파일할 수도 있습니다.

 

하지만 optimizer가 하나의 파일의 범위로 제한된다는 문제가 있습니다.

 

Whole Module Optimization

Whole Module Optimization는 전체 모듈을 한 번에 컴파일하는데, 모든 내용을 분석해서 공격적인 최적화가 가능합니다.

물론 WMO의 빌드는 시간이 더 오래 걸립니다.

하지만 한 번 생성된 binary 파일은 더 빨리 실행됩니다.

 

Swift 2에서는 WMO를 이용해 크게 두 가지를 개선했습니다.

먼저 WMO를 이용한 새로운 최적화를 추가하여 프로그램이 더 빨리 실행될 수 있습니다.

그리고 컴파일 파이프라인의 일부를 병렬화하였습니다.

따라서 WMO 모드에서 프로젝트를 컴파일하는 데 걸리는 시간이 줄어듭니다.

 

이러한 개선을 통해 일반 Swift2보다 FFt는 4.7배, NBody는 3.6배 더 빨라졌습니다.

 

Xcode 7에서는 Optimization Level 메뉴에 WMO를 선택할 수 있습니다.

(글쓴이: 2022년 9월 기준 Xcode 14가 나왔고... WMO이 기본값으로 설정되어 있습니다.)

 

Writing High Performance Swift Code

Swift 프로그래밍 언어의 세 가지 기능과 그 특성에 대해 알아보겠습니다.

각각에 대해 앱의 성능을 향상시킬 수 있는 구체적인 기술을 알아볼 예정입니다.

 

Reference Couning

일반적으로 컴파일러는 어떠한 도움 없이도 대부분의 Reference Count(이하 RC) 오버헤드를 제거할 수 있습니다.

그러나 가끔씩 RC 오버헤드로 인해 코드가 느려지는 것을 발견했습니다.

이러한 오버헤드를 줄이거나 제거할 수 있는 두 가지 기술을 설명하겠습니다.

 

먼저 RC와 클래스가 어떻게 동작하는지 살펴보며 RC의 기본적인 내용을 알아보겠습니다.

C 클래스의 인스턴스를 생성하고 변수 x에 할다합니다.

클래스 인스턴스의 맨 위에 숫자 1이 적혀있는 상자가 있는데, 이는 클래스 인스턴스의 Reference Count를 나타냅니다.

지금은 클래스 인스턴스에 대한 참조가 1개 뿐이니 1이고, 2개면 2, 3개면 3입니다.

 

다음 라인에서 x를 y에 할당합니다.

이러면 x와 y가 클래스 인스턴스를 참조하기 때문에 RC는 2가 됩니다.

 

그리고 foo 함수의 인자로 C 클래스 인스턴스를 전달합니다.

foo에는 클래스 인스턴스 자체를 전달하는 것이 아니라, 임시 객체가 c에 할당됩니다.

그럼 클래스 인스턴스의 RC는 3이 되겠죠.

 

이제 foo 함수가 종료되었다고 생각해 봅시다.

함수가 끝나면 c의 참조가 사라지니 RC는 2로 감소됩니다.

 

그리고 y와 x에 nil을 할당하여 클래스 인스턴스의 RC를 0으로 만든 다음

할당이 해제됩니다.

 

지금까지는 할당을 할 때마다 클래스 인스턴스의 RC를 유지하기 위해 RC 작업을 수행해야 했습니다.

이것은 항상 memory safety를 지켜야 했기 때문에 중요한 작업입니다.

 

Objective-C의 retatin와 release를 이용한 증가, 감소는 일어나지 않습니다.

지금부터 말할 것은 Struct가 RC와 어떻게 상호작용하는가에 대한 것입니다.

 

참조가 없는 클래스

먼저 참조가 없는 클래스를 알아보겠습니다.

Point 클래스는 Float형 x, y 프로퍼티만 가지고 있고 어떠한 참조도 포함되지 않습니다.

Point 객체를 배열에 저장한다고 합시다.

 

클래스이기 때문에 객체를 직접 배열에 직접 저장하지 않고 객체에 대한 참조를 저장합니다.

따라서 배열에서 반복할 때마다 loop 변수인 p에 새로운 참조를 생성하고 있습니다.

즉, RC 증가를 수행해야 합니다.

그리고 반복이 끝날 때 p는 사라지므로 RC를 감소시켜야 합니다.

 

Objective-C에서는 NSRA같은 Foundation의 자료구조를 사용하기 위해 Point처럼 구조가 단순해도 클래스로 만들어야 하는 경우가 많습니다.

그리고 자료구조를 사용할 때마다 이런 RC 오버헤드가 발생합니다.

 

참조가 없는 구조체

Swift에서는 구조체를 사용할 수 있고, 클래스 대신 구조체를 사용해 이 문제를 해결할 수 있습니다.

Point를 구조체로 만들어 봅시다.

구조체는 배열에 직접 저장할 수 있고 RC가 필요하지 않기 때문에 increment와 decrement를 제거할 수 있습니다.

 

참조가 1개인 구조체

이제 내부에 참조가 있는 구조체를 생각해 봅시다.

앞에서 본 Point의 프로퍼티는 모두 참조가 필요 없기 때문에 효율적으로 복사를 할 수 있습니다.

그래서 UIColor라는 클래스를 추가해서 참조를 추가해줬습니다.

 

이것은 이 구조체를 할당할 때마다 UIColor에 대한 클래스 인스턴스가 생성된다는 의미이고,

이는 곧 RC가 필요하다는 것을 의미합니다.

 

참조가 많은 구조체

하나의 RC가 추가된 구조체는 그리 비싸지 않습니다.

하지만 아래에서 살펴볼 많은 참조를 가진 구조체는 어떨까요?

User 인스턴스는 세 개의 문자열과 관련된 데이터가 있습니다. (이름, 성, 주소)

그리고 앱의 데이터를 저장하는 Dictionary와 Array가 있습니다.

Array, Dictionary, String은 모두 구조체지만 내부 데이터를 관리할 때 참조를 사용합니다.

따라서 이 구조체를 할당할 때마다 각각 5번의 RC 수정을 해야합니다.

 

Wrapper Class 사용

이 문제는 wrapper class를 이용해 해결할 수 있습니다.

 

이제 User 구조체는 Wrapper 클래스의 프로퍼티로 존재합니다.

Wrapper 클래스를 참조하여 User를 다룰 수 있고 이 참조를 함수에 전달하거나 변수에 할당할 때 RC 수정을 1번만 해도 됩니다.

 

이 변화에서 중요한 점은 semantic이 바뀌었다는 것입니다.

구조체를 사용했을 때는 value semactic이었지만 Wrapper 클래스로 감싸면 reference semactic으로 동작합니다.

이는 예상하지 못하는 데이터 공유가 발생하여 이상한 결과가 나올 수 있습니다.

그러나 value semantic을 reference semantic으로 바꿔 최적화 하는 방법이 있다는 것을 배웠습니다.

(이에 대해 더 자세히 알고 싶다면 WWDC15 - Building Better APps with Value Types in Swift 세션을 보세요.)

 

Generics

이제 제네릭에 대해 알아보겠습니다.

min 함수 Comparable 프로토콜을 채택한 T 타입을 받고 있고, 단 세 줄이어서 그리 복잡해보이지 않습니다.

하지만 생각보다 훨씬 많은 일이 벌어집니다.

 

예를 들어 컴파일러는 위 코드를

이렇게 바꿔서 처리합니다.

 

우선 컴파일러는 x와 y를 FunctionTable을 통해 간접적으로 비교하는 것을 볼 수 있습니다.

왜냐하면 min 함수에 정수가 올 수도 있고, float, String 등 Comparable을 채택한 어떠한 타입도 올 수 있는데 컴파일러는 모든 경우에도 정확하게 처리해야 하기 때문입니다.

또한, T가 RC를 수정해야 하는지 알 수 없기 때문에 수정해야 하는 타입과 수정 안 해도 되는 타입을 모두 계산하는 코드로 처리해야 합니다.

이 두 경우 모두 컴파일러가 처리할 수 있어야 하기 때문에 보수적인 코드로 바꿔서 수행합니다.

 

Generic Specialization

Swift는 컴파일러 최적화를 통해 오버헤드를 제거할 수 있습니다.

이 컴파일러 최적화를 Generic Specialization이라고 합니다.

 

foo 함수를 봅시다.

foo 함수는 Generic min-T 함수에 두 개의 정수를 전달합니다.

컴파일러가 Generic Specialization을 수행할 때 min과 foo에 대한 호출을 보고 두 개의 정수가 전달되고 있음을 알게 됩니다.

컴파일러는 min-T의 정의를 볼 수 있기 때문에 min-T를 복제하고 Generic T를 Int로 대체하여 클론 함수를 Specialization할 수 있습니다.

그러면 특수 함수가 Int에 최적화되고 이 함수와 관련된 모든 오버헤드가 제거되므로

불필요한 RC 호출을 없애고 정수 두 개를 직접 비교할 수 있습니다.

 

마지막으로 Generic min-T 함수 호출을 Specialized min Int 함수 호출로 바꾸면서 추가적인 최적화를 합니다.

 

Specialization is Limited by Visibility

Generic Specialization은 강력한 최적화 방법이지만 한 가지 한계가 있습니다.

바로 visibility of the generic definition 입니다.

 

Generic min-T 함수를 호출하는 compute 함수가 있습니다.

이 경우 Generic Specialization을 수행할 수 있을까요?

compute 함수와 min-T 함수는 File1.swift와 File2.swift로 나눠져 있습니다.

컴파일러가 File1을 컴파일 할 때 File2는 보이지 않으므로 min-T의 정의를 볼 수 없어 Generic min-T 함수를 호출해야 합니다.

 

Whole Molue Optimization

그러나 Whole Molue Optimization를 쓰면 어떨까요?

WMO이 활성화된 경우 File1과 File2는 같이 컴파일 되기 때문에 compute가 File1에 있더라도 min-T의 정의를 볼 수 있습니다.

따라서 Generic min-T 함수를 min으로 specialization 하고 min-T 호출을 min Int로 대체할 수 있습니다.

 

컴파일러는 WMO로 인해 추가적인 정보를 얻을 수 있고, Generic specification을 할 수 있었습니다.

이는 WMO의 장점 중 하나입니다.

 

Dynamic Dispatch

마지막으로 Dynamic Dispatch를 알아보겠습니다. (이제 영상 절반임 ㅋㅋㅎ)

Pet 클래스가 있습니다.

Pet에는 noise 메서드, name 프로퍼티가 있으며 noiseImpl 메서드가 있습니다.

noiseImpl 메서드는 noise를 구현하는데 사용됩니다.

 

또한 Pet를 상속하는 Dog 클래스가 있습니다.

Dog 클래스는 noise 메서드를 오버라이드합니다.

 

makeNoise 함수는 Pet 타입을 매개변수로 받아 noise 메서드를 호출합니다.

makeNoise 함수의 코드는 매우 짧지만 실제로 컴파일러가 대체하는 코드는 이렇습니다.

여기서 getter와 noise를 간접적으로 호출하는 방식이 중요합니다.

컴파일러는 name 프로퍼티나 noise 메서드가 서브 클래스에서 오버라이드 되었는지 알 수 없기 때문에

이 명령들을 간접적으로 호출해야 합니다.

이 경우 컴파일러는 name이나 noise 메서드가 서브 클래스에서 오버라이드 안 되었다고 명확히 알 수 있을 때만 직접 호출을 할 수 있습니다.

 

noise를 오버라이드하는 것을 적절합니다.

강아지는 멍멍, 고양이는 야옹하고 울듯이 동물마다 울음소리가 다르기 때문입니다.

 

하지만 이름을 오버라이드하는 것을 적절하지 않습니다.

 

API의 클래스 계층(hierarchy)을 제한함으로서 이를 모델링할 수 있습니다.

아래에서 다룰 Swift의 기능은 API의 클래스 계층을 제한하는 데 사용할 수 있습니다.

첫 번째는 상속(Inheritance) 제한이고 두 번째는 접근 제어(Access Control)를 통한 접근 제한입니다.

 

 

Inheritance

final을 이용한 상속 제한을 먼저 알아보겠습니다.

API에 final 키워드를 넣어 선언할 경우, API는 이 선언이 서브 클래스에 의해 오버라이드되지 않음을 알 수 있습니다.

 

이 때, makeNoise 함수를 다시 봐봅시다.

컴파일러는 getter를 호출하여 간접적으로 name 프로퍼티를 사용했습니다.

추가 정보가 없으면 name이 서브 클래스에 의해 오버라이드 되는지 알 수 없기 때문입니다.

 

하지만 final을 추가하여 name이 오버라이드 가능성이 없다는 것을 알 수 있게 되었습니다.

그래서 컴파일러는 Dynamic Dispatch를 삭제하고 name을 직접 호출할 수 있습니다.

 

Access Control

이제 접근 제어에 대해 알아보겠습니다.

Pet과 Dog는 다른 파일에 있지만 같은 모듈에 속합니다.

또다른 서브 클래스인 Cat은 다른 파일, 다른 모듈에 존재합니다.

 

여기까지 봤을 때, 과연 컴파일러는 noiseImpl 메서드를 직접 호출할 수 있을까요?

기본적으로 하지 못합니다.

왜냐하면 기본적으로 컴파일러는 noiseImpl 메서드가 Cat이나 Dog같은 서브클래스에 의해 재정의된다고 가정해야 하기 때문입니다.

 

하지만 우리는 noiseImpl()이 Pet의 private한 메서드라는 것을 알고 있습니다.

Cat이나 Dog에서는 사용하면 안 되죠.

private 키워드를 추가하여 컴파일러에게도 이 사실을 알려줄 수 있습니다.

noiseImpl 메서드에 private 키워드를 붙이면 noiseImpl 메서드는 더이상 Pet.swift 파일 밖에서 보이지 않습니다.

이것은 컴파일러가 Cat이나 Dog에서 noiseImpl을 오버라이드하지 못한다는 것을 즉시 알 수 있음을 의미합니다.

이제 컴파일러는 noiseImple을 직접 호출할 수 있습니다.

 

Whole Module Optimization

private 다음으로 Whole Module Optimization에 대해서 말해봅시다.

지금까지 Pet 클래스에 대한 얘기를 많이 했지만 Dog에 대해서는 알아보지 않았습니다.

Dog가 public 클래스인 Pet의 서브 클래스라는 것을 잊지 마세요.

만약 더 많은 정보 없이 Dog 클래스 인스턴스에서 noise 메서드를 호출하면, 컴파일러는 반드시 noise를 간접적으로 호출해야 합니다.

왜냐하면 Dog의 서브 클래스가 있는지 없는지 알 수 없기 때문입니다.

 

그러나 WMO가 활성화된다면 컴파일러는 모듈 전체의 visibility를 가집니다.

즉, Dog의 클래스 인스턴스에서 직접 noise 메서드를 호출할 수 있게 됩니다.

코드를 전혀 바꾸지 않아도 WMO를 키는 것만으로 최적화가 되었습니다. (짜란💃)

 

Swift vs. Objective-C

이제 위에서 본 Swift vs. Objective-C 비교 그래프를 다시 봐보겠습니다.

Swift가 이러한 객체 지향 벤치마크에서 Objective-C보다 훨씬 빠른 이유가 뭘까요?

그 이유는 Objective-C에서 컴파일러는 Objective-C의 메시지를 전달하기 위한 Dynamic Dispatch를 제거할 수 없기 때문입니다.

Objective-C는 인라인을 할 수도 없고 어떤 분석도 할 수가 없습니다.

컴파일러는 Objective-C 메시지를 보낼 때 다른쪽에 무엇이 있을지 고려해서 보수적으로 동작해야 합니다.

 

하지만 Swift는 컴파일러가 더 많은 정보를 가지고 있습니다.

다른 곳의 모든 정의를 볼 수 있고, 많은 상황에서 Dynamic Dispatch를 제거할 수 있습니다.

이럴 때 성능은 훨씬 향상되고 코드가 훨씬 빨라집니다.

따라서 Access Control와 final 키워드를 이용해 API에게 의도를 명확히 전달하세요.

그러면 컴파일러가 클래스 계층을 이해하는 데 도움이 되며, 이를 통해 추가적인 최적화가 가능합니다.

(이후 WMO를 활성화하라는 말을 했지만 지금은 기본으로 WMO가 활성화 되어 있으니 스킵합니다.)

 

감사합니다.


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

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

공감 댓글 부탁드립니다.

 

 

반응형