런타임 성능 최적화

Hyeongseok Yun·2024년 2월 6일

Swift에서 성능 최적화를 한다고하면 2가지 척도가 있다.

  • 런타임 성능
  • 컴파일 속도 향상

런타임 성능을 향상시키면 컴파일 속도가 감소되고, 컴파일 속도를 향상시키면 런타임 성능이 감소된다.

이 2가지 모두를 가져갈 수는 없으므로, 적절한 타협으로 균형을 이루는 것이 좋을 것 같다.

먼저 전체적인 런타임 성능 최적화 방법들을 알아보자.

1. WMO(Whole Module Optimizations) 활용

2. 동적 디스패치(Dynamic Dispatch) 줄이고 정적 디스패치(Static Dispatch) 늘리기

3. 컨테이너 (Array, Set 등) 내부에 값 타입 사용하기

4. inout 매개변수 활용하기

5. 프로토콜(protocol)이 클래스(Class)에서만 채택된다면 프로토콜에 AnyObject 채택하기

6. 힙(Heap) 영역 사용 줄이기

7. 프로토콜(protocol)을 사용할땐 제네릭(Generic)과 함께 사용하기

8. 탈출 클로저(Escaping Closure) 내부에서 변수(var) 대신 상수(let) 사용하기




1. WMO(Whole Module Optimizations) 활용

모든 코드를 하나의 파일처럼 컴파일을 진행하는 것이다.


스위프트 컴파일러는 기본적으로 파일을 개별적으로 컴파일한다.

여러개의 파일들을 병렬적으로 빠르게 컴파일하는데, 각각의 파일을 컴파일 하다보면 성능 최적화가 어렵다.

WMO를 활용하면 함수를 인라인(Inline)처리 할 수 있으므로 런타임 최적화가 일어난다.

인라인(Inline) 함수란 ?

함수가 호출될 때 일반적인 함수의 호출 과정을 거치지 않고, 함수의 모든 코드를 호출된 자리에 바로 삽입하는 방식

즉, 아래 일반적인 함수의 호출 과정을 생략하고 함수를
바로 실행 할 수 있다. 해당 과정이 생략되었으므로 런타임 성능이 향상된다.


일반적인 함수의 호출 과정

  • 함수 호출 -> 매개변수, 함수 호출이 끝난 뒤 돌아갈 반환 주소값 스택에 저장 -> 함수 내부에서 사용되는 지역변수 스택에 저장 -> 함수 실행 -> 함수 종료 후 반환 값 넘겨줌 -> 스택에 저장된 돌아갈 반환 주소값으로 이동 -> 스택에 저장된 함수 호출 정보 제거

대체적으로 Debug 환경에서는 WMO를 사용하지 않고, Release 환경에서 WMO를 사용한다.

Xcode - Target - Build Settings - Compilation Mode에서 설정 가능



2. 동적 디스패치(Dynamic Dispatch) 줄이고 정적 디스패치(Static Dispatch) 늘리기

디스패치(Dispatch)란 ?

어떤 메소드를 호출할 것인지 결정하여 실행하는 것

정적 디스패치(Static Dispatch) 늘리는 법

  • 상속이 되지 않는 클래스일때 final 키워드를 붙인다.

  • 접근이 파일 외부에서 일어나지 않을때, 은닉화(private, file private) 한다.


디스패치(Dispatch)의 종류에는 동적 디스패치(Dynamic Dispatch), 정적 디스패치(Static Dispatch)가 있다.

동적 디스패치는 런타임 시점, 정적 디스패치는 컴파일 시점에 어떤 메소드를 호출할 것인지 알 수 있다.

그러므로, 런타임 성능을 향상시키기 위해서는 런타임 시점에서 일어나는 동적 디스패치를 줄이고 정적 디스패치를 늘려야한다.



3. 컨테이너 (Array, Set 등) 내부에 값 타입 사용하기

컨테이너 내부에 참조 타입을 사용하면 Objective-C 호환을 위해 NSArray와 브릿징을 준비한다.

브릿징을 준비하는 과정에서 비용이 발생하므로 런타임 속도가 느려진다.

참조 타입이 아닌 값 타입을 사용한다면, NSArray와 브릿징이 불가하므로 해당 과정이 생략되므로 런타임 속도가 향상된다.


만약 컨테이너 내부에 참조 타입을 사용하는데 Objective-C 호환이 필요하지 않으면,

일반 Array<>타입을 ContiguousArray<>타입으로 사용하면 브릿징 과정을 생략한다.



4. inout 매개변수 활용하기

컨테이너 타입(Array, Set 등)을 매개변수로 받아 해당 컨테이너 타입에 접근하는 경우,

매개변수를 특정 프로퍼티에 재할당 하는 것 대신에 내부값을 대치하는 방식(inout)을 사용하자.


매개변수로 받은 컨테이너 타입에 매우 많은 요소가 담겨져있으면 복사가 일어날 때 비용이 매우 크다.

var array: [Int] = Array(repeating: 1, count: 9999999)

func changeArray(array: [Int]) -> [Int] {
    var result: [Int] = array
    result.removeLast()
    
    return result
}

changeArray(array: array)

inout 매개변수를 사용하여 내부값을 대치하는 방식으로 수정

복사가 일어나지 않는다.

var array: [Int] = Array(repeating: 1, count: 9999999)

func changeArray(array: inout [Int]) -> [Int] {
    array.removeLast()
    
    return array
}

changeArray(array: &array)


5. 프로토콜(protocol)이 클래스(Class)에서만 채택된다면 프로토콜에 AnyObject 채택하기

프로토콜에 AnyObject를 채택한다면 해당 프로토콜은 클래스(Class)에서만 채택할 수 있다.

해당 프로토콜을 채택 했을때 클래스라는 것이 확실하므로, ARC는 별도의 확인 절차 없이 참조 카운팅을 할 수 있다.

AnyObject가 채택되지 않은 일반적인 프로토콜 일 때, 해당 프로토콜을 채택한 인스턴스가 구조체(Struct)인지 클래스

(Class)인 지 결정하는 것은 비용이 발생한다.



6. 힙(Heap) 영역 사용 줄이기

런타임에 동적으로 메모리를 할당 및 해제하여, 힙(Heap) 영역을 사용하는 것은 많은 비용이 든다.

힙 영역 사용에 들어가는 비용을 살펴보자.

  • 메모리 할당에 필요한 공간을 탐색한다.
  • 할당된 데이터가 현재도 참조되고 있는지, 지속적으로 확인하는 과정이 필요하다.
  • 어떤 인스턴스를 참조 및 참조 해제 할 때마다, 참조 횟수를 계산(Reference Counting)한다.
  • 더이상 참조되지 않을 때 메모리에서 해제시킨다.

그러므로 힙 영역을 최소화 한다면 런타임 성능 향상에 큰 도움이 될 것이다.



7. 프로토콜(protocol)을 사용할땐 제네릭(Generic)과 함께 사용하기

프로토콜(protocol)은 동적 디스패치(Dynamic Dispatch)이다.

프로토콜에 함수들이 정의 되어있고, 여러개의 타입들이 해당 프로토콜을 채택하였다고 가정해보자.

어떤 함수에서 프로토콜의 함수를 실행한다고 하면, 어떤 타입의 프로토콜 함수인지는 런타임에 알 수 있으므로

동적 디스패치이다.


반면에 프로토콜과 제네릭(Generic)을 함께 사용한다면 정적 디스패치(Static Dispatch)이다.

제네릭은 함수 호출 전에 '정확한 타입'을 받기 때문에, 컴파일 시점에서 어떤 타입의 함수를 실행할지 알 수 있으므로 정적 디스패치이다.



8. 탈출 클로저(Escaping Closure) 내부에서 변수(var) 대신 상수(let) 사용하기

클로저(Closure) 내부에서 상수(let)를 캡처(사용)했을때, 힙 메모리를 사용하지 않는다.


클래스 내부에 변수(var) 프로퍼티, 상수(let) 프로퍼티, 탈출 클로저(Clousre)가 포함된 함수가 있다고 가정해보자.

변수 프로퍼티가 탈출 클로저에서 캡처된다면, 해당 변수의 값 변경을 추적해야 하므로 힙 메모리에 변수의 공간이 할당된다.

반면에 상수 프로퍼티가 클로저에서 캡처된다면, 상수는 값을 변경 할 수 없으므로 힙 메모리에 할당되지 않고 클로저 내부에 저장된다.


만약 변수 프로퍼티를 캡처해야 한다면, 변수 프로퍼티를 매개변수로 받고 inout과 함께 사용하자.

이렇게 한다면 힙 메모리를 사용하지 않는다.

profile
운동과 개발을 좋아합니다. 💪 🧑🏻‍💻

0개의 댓글