OptimizationTips (Writing High-Performance Swift Code)

고라니·2024년 3월 25일
0

TIL

목록 보기
65/67

최근에 wwdc Understand Swift Performance를 보았는데 이 영상을 보고 apple/Swift의 OptimaizationTips를 보면 좋다고 하여 정리해보았다.

링크: https://github.com/apple/swift/blob/main/docs/OptimizationTips.rst#writing-high-performance-swift-code

Writing High-Performance Swift Code

해당 문서는 높은 성능의 Swift코드를 작성하기 위한 다양한 팁과 요령을 설명하는 문서다.
이러한 노력은 품질을 개선하고 오류 발생률을 낮추고 코드를 더 읽기 쉽게 만들어준다.
BUT 팁 중 일부는 원칙에 맞지 않거나 특정 문제를 임시적으로 해결하기 위한 것도 있으니 주의 하라고 한다.

Enabling Optimzation (최적화)

Swift는 세 가지의 최적화 수준을 제공한다.

  • Onene: 보통 개발을 위한 것. 최소한의 최적화를 수행하며 모든 디버그 정보를 보존한다.
  • Osize: 대부분의 프로덕션 코드를 위한 것. 컴파일러는 최대의 최적화를 수행하여 생성된 코드의 유형과 양을 극적으로 변경할 수 있다. 디버그 정보는 생성되지만 손실이 발생할 수 있다.
  • O: 이는 코드 크기보다 성능을 우선시 하는 특별한 최적화 모드이다.

Xcode UI에서 아래의 경로를 통해 현재 최적화 수준을 설정 할 수 있다.

Project -> TARGET -> Build Settings -> 검색: "optimiazation Level"

설명을 들어보면 뭔가 의미 있는 설정인듯 하면서도 굉장히 생소하면서 애매~하다.


Whole Module Optimizations(WMO) (전체 모듈 최적화)

Swift는 기본적으로 각 파일들을 개별적으로 컴파일 하는데 이는 여러 파일을 동시에 병렬적으로 컴파일 하여 빠르게 빌드할 수 있다. 하지만 파일들은 상호 의존적인 관계를 가지로 있어 이 방식은 최적화를 어렵게 만들 수 있다고 한다.

이 때 Whole Module Optimizaions(WMO)를 활성화 해주면 파일들을 하나의 큰 가상 파일로 간주하여 최적화를 수행하고 파일간의 상호 의존성을 고려하여 코드를 최적화 하게 해준다.

프로젝트 > TARGETS > Build Settings > swift compiler검색 > Swift Compiler - Code Generation부분
출처: https://zeddios.tistory.com/593 [ZeddiOS:티스토리]

Project -> TARGET -> Build Settings -> 검색: "Swfit compiler" -> Code -> Generation

활성화 하면 컴파일은 오래걸리지만 빠르게 실행된다고 하는데;; 아마 컴파일 시간이 증가하는 대신 최적화가 더 잘되고 성능이 좋아진다는 의미라고 이해하겠다.


Reducing Dynamic Dispatch (동적 디스패치 감소)

이 내용은 최근에 배워서 잘 알고 있다. Dynamic Dispatch 대신 Static Dispatch가 성능이 좋다!

Swift와 Objective-C는 매우 동적인 언어다. Swift프로그래머가 동적성을 제거하거나 줄임으로써 성능을 향상시킬 수 있다.

class A {
  var aProperty: [Int]
  func doSomething() { ... }
  dynamic doSomethingElse() { ... }
}

class B: A {
  override var aProperty {
    get { ... }
    set { ... }
  }

  override func doSomething() { ... }
}

func usingAnA(_ a: A) {
  a.doSomething()
  a.aProperty = ...
}

위의 코드를 보면 B 클래스 A클래스를 상속받고 있다. 그리고 상속 받은 프로퍼티와 메서드들을 오버라이딩 하고 있다. Swfit에서는 클래스의 메서드가 호출되면 해당 메서드의 실제 구현체를 찾기 위해 VTable을 통해 간접적으로 호출한다. 이 방식은 Dynamic Dispatch이고 컴파일 시점에 최적화가 불가하다.

그러면 당연히 Dynamic Dispatch를 줄인다면 성능이 향상될 수 있다. 어떻게 감소시킬까?

Advice: final 키워드를 사용하여 오버라이딩일 필요하지 않음을 명시해라.

final 키워드는 오버라이딩 될 수 없음을 의미하고 컴파일러가 Static Dispatch 방식으로 호출할 수 있게 한다.

final class C {
  // No declarations in class 'C' can be overridden.
  var array1: [Int]
  func doSomething() { ... }
}

class D {
  final var array1: [Int] // 'array1' cannot be overridden by a computed property.
  var array2: [Int]      // 'array2' *can* be overridden by a computed property.
}

func usingC(_ c: C) {
  c.array1[i] = ... // Can directly access C.array without going through dynamic dispatch.
  c.doSomething()   // Can directly call C.doSomething without going through virtual dispatch.
}

func usingD(_ d: D) {
  d.array1[i] = ... // Can directly access D.array1 without going through dynamic dispatch.
  d.array2[i] = ... // Will access D.array2 through dynamic dispatch.
}

Advice: 파일 외부에서 엑게스될 필요 없는 선언은 private 및 fileprivate를 사용해라.

private 및 fileprivate로 선언하면 소스 파일 내에서만 접근할 수 있도록 제한된다.
이런 경우 final 키워드를 사용한것과 동일하게 해당 선언을 최종 선언으로 취급하여 간접 호출을 제거한다.

private class E {
  func doSomething() { ... }
}

class F {
  fileprivate var myPrivateVar: Int
}

func usingE(_ e: E) {
  e.doSomething() // There is no sub class in the file that declares this class.
                  // The compiler can remove virtual calls to doSomething()
                  // and directly call E's doSomething method.
}

func usingF(_ f: F) -> Int {
  return f.myPrivateVar
}

Advice: WMO가 활성화되어 있다면, 모듈 외부에서 접근할 필요가 없는 선언에는 internal을 사용해라.

WMO는 여러 파일을 하나의 가상 파일로 취급하여 최적화 하기 때문에 위의 두 조언과 비슷하게 컴파일러가 해당 선언이 모듈 외부에서 접근할 수 없다는 것을 명시하면 WMO를 통한 최적화에 도움이 되는것 같다.
사실 internal은 기본설정이라 왜 internal을 사용하라고 설명하는지 잘 모르겠다.
아마 당연하게 생각하지 말고 원리를 이해하라는 취지이지 않을까?


Using Conatiner Type Effciently (Container Type 효율적으로 사용하기)

Swift 표준 라이브러리에서 제공하는 Array와 Dictionary를 통해 성능을 향상시키자.

Advice: Array에서 값 타입 사용하기.

값타입은 NSArray 내부에 포함될 수 없다 -> NSArray 가능성을 처리하는데 필요한 오버헤드를 제거할 수 있다.
즉 Array는 NSArray와의 호환성을 위한 비용이 발생하는데 값타입은 NSArray에 포함될 수 없어서 값타입을 사용하면 해당 비용을 제거할 수 있다.

또한 값타입은 참조카운팅이 필요 없기 때문에 오버헤드를 최소화할 수 있다.

Advice: 객체 재할당 대신 인플레이스 변형 사용하기.

인플레이스 변형?
: 데이터를 변경할 때 새로운 데이터를 생성하는 대신, 기존의 데이터를 직접 수정하는 것

Swift의 배열 및 컨테이너들은 값타입처럼 COW(Copy-on-write)방식을 사용하여 복사를 최적화 한다.
이는 필요할 때만 복사가 이루어지게 하여 성능을 향상시킨다.

var c: [Int] = [ ... ]
var d = c        // 복사가 발생하지 않는다.
d.append(2)      // 복사가 발행한다..

COW의 장점을 살려 불필요한 복사가 발생하지 않게 하는것이 좋은데, 함수 내에서 배열을 변경할 때 새로운 배열을 생성하는 대신 inout매개변수를 사용하여 원본 배열을 직접 수정하면 함수 호출이 끝나고 배열이 업데이트 되면서 불필요한 복사가 발생하지 않게 된다.

// 복사 발생 o
func append_one(_ a: [Int]) -> [Int] {
  var a = a
  a.append(1)
  return a
}

var a = [1, 2, 3]
a = append_one(a)


// 복사 발생 x
func append_one_in_place(a: inout [Int]) {
  a.append(1)
}

var a = [1, 2, 3]
append_one_in_place(&a)

Wrapping operations (연산 래핑)

Swift는 기본적으로 산술 연산에서 오버플로우를 방지하기 위해 안전성을 제공하는데 이러한 안전성 확인은 성능에 영향을 줄 수 있다.
(1. 성능이 중요, 2. 오버플로우가 발생하지 않음) 이 둘이 보장되는 상황이라면 안전성 확인을 제외하여 연산을 사용할 수 있다.

Advice: 오버플로우가 발생하지 않음이 보장되는 경우 wrapping intiger arthmetic을 사용해라

a: [Int]
b: [Int]
c: [Int]

// Precondition: for all a[i], b[i]: a[i] + b[i] either does not overflow,
// or the result of wrapping is desired.
for i in 0 ... n {
  c[i] = a[i] &+ b[i]
}

&+, &-, &* 처럼 &를 사용하면 안전성 검사 없이 연산을 해주나보다


Generic (제네릭)

제네릭을 사용하여 함수나 타입을 정의할 수 있다. 제네릭은 코드를 유연하게 작성할 수 있도록 해주고, 컴파일러가 제네릭 타입에 할당되는 실제 타입에 대한 동작을 할 수 있게 해준다.

class MySwiftFunc<T> { ... }

MySwiftFunc<Int> X    // Int 형식에 대해 작동하는 코드를 생성
MySwiftFunc<String> Y // String 형식에 대해 작동하는 코드를 생성

컴파일러는 이러한 코드의 호출을 확인하여 호출에 사용된 구체적인 타입을 확인한다. 제네릭 함수의 정의가 optimizer에 의해 확인되면 컴파일러는 해당 타입에 대한 버전을 생성한다. 이를 specialization이라고 부르고 이 프로세스를 통해 제네릭과 관련된 오버헤드를 제거할 수 있다.

Advice: 제네릭 선언을 해당 선언들과 동일한 모듈 안에 넣어라

optimizer는 현재 모듈에서 generic 선언의 정의를 볼 수 있을 때에만 specialization를 수행할 수 있다.
당연히 컴파일중 generic이 어떤 타입으로 호출될 지 알아야 가능하겠지~

주의: 표준 라이브러리의 경우 모든 모듈에서 보이며 specialization이 가능한다.

위에서 whole-module-optimization는 각각의 파일들을 하나의 가상 파일로 취급하여 최적화 한다고 했는데 이 기능을 활성화 하면 당연히 다른 모듈에 있어서 specialization를 수행할 수 없는 상황을 극복할 수 있다.


The cost of large Swift values (대규모 값의 비용)

Swift에서 값은 데이터의 고유한 복사본을 유지한다. 값 형식을 사용하는 것에는 여러 장점이 있는데 값이 독립적인 상태를 가지도록 보장하고, 값이 복사될 때 값의 새로운 복사본을 생성한다. 이러한 방식은 효율적이지만 대규모 데이터의 경우 성능 저하가 발생할 수 있다.

protocol P {}
struct Node: P {
  var left, right: P?
}

struct Tree {
  var node: P?
  init() { ... }
}

위의 예시코드에서 트리가 복사 될 때 전체 트리를 복사해야 한다. 이 경우 많은 malloc/free호출과 많은 참조 카운팅 오버헤드가 필요하다.

위의 코드에서 트리를 복사할 때 전체 트리가 복사 되어야 하고 이 경우 많은 malloc/free 호출과 많은 참조 카운팅 오버헤드가 필요하다.

Advice: 대규모 값에 대해 copy-on-write를 사용하기

큰 값을 복사하는 비용을 제거하기 위해 COW를 활용할 수 있다. COW를 쉽게 활용하는 방법은 Array를 통해 데이터를 구성하는 것이다.

struct Tree: P {
  var node: [P?]
  init() {
    node = [thing]
  }
}

위의 예시에서는 트리 데이터를 배열에 래핑하여 복사 비용을 감소시켰다. 간단한 방법으로 성능 향상이 가능했다.

그러나 이 경우에도 몇 가지 단점이 존재한다.

    1. 배열은 값 래퍼의 컨텍스트에서 의미가 없는 "append"나 "count"같은 메서드를 노출한다.
    1. 배열이 프로그램 안전성 및 Objective-c와의 상호 작용을 보장하기 위한코드가 포함되어 있다.

첫 번째 문제를 해결하기 위해 사용하지 않는 API를 숨길 래퍼 구조체를 만들 수 있지만 두 번째 문제를 해결할 순없다. Swift는 인덱싱된 엑세스가 배열 범위 내에 있는지 확인하고 값 저장 시 배열 저장공간이 확정되어야 하는지 확인해야 한다. 이러한 과정은 성능을 저하시킬 수 있다.

두 번째 문제까지 해결하기 위해 배열을 사용하는 대신 전용 데이터 구조를 구현하여 배열을 값 래퍼로 대체하는 방법도 있다.

final class Ref<T> {
  var val: T
  init(_ v: T) { val = v }
}

struct Box<T> {
  var ref: Ref<T>
  init(_ x: T) { ref = Ref(x) }

  var value: T {
    get { return ref.val }
    set {
      if !isKnownUniquelyReferenced(&ref) {
        ref = Ref(newValue)
        return
      }
      ref.val = newValue
    }
  }
}

사실 제대로 이해가 안되는 부분이다.
대규모 데이터를 다룰 때 COW와 Array를 사용하는게 좋은데 이 때도 문제점이 있고 이 문제점을 해결하기 위해 전용 데이터 구조를 구현하여 사용하라는것 같다.


Unsage code (안전하지 않은 코드)

Swift는 클래는 항상 참조 카운팅을 사용한다.
예를 들어 클래스를 사용하여 구현된 연결 리시트를 스캔하는 문제를 생각해보면 참조를 한 노드에서 다음 노드로 이동해야 한다. 이 때 참조를 이동할 때마다 Swift는 다음 객체의 참조 카운트를 증가시키고 이전 객체의 참조 카운트를 감소시킨다. 이런 참조 카운트 작업은 비용이 많이 들며 클래스를 사용한다면 피할 수 없다.

final class Node {
  var next: Node?
  var data: Int
  ...
}

Advice: 참조 카운팅 오버헤드를 피하기 위해 비관리 참조를 사용해라

성능이 중요한 코드에서 참조 카운팅 오버헤드를 피하는 방법이 있기는 하다.
Unmanaged 구조체를 사용하면 특정 참조에 대해 자동 참조 카운팅을 비활성할 수 있다. 이를 통해 참조 카운팅 관련 오버헤드를 피할 수 있다.

하지만 Unmanaged._withUnsafeGuaranteedRef는 공개 API가 아니며 향후에 사라질 예정이라고...


Protocol (프로토콜)

Advice: 클래스에서만 충족되는 프로토콜을 클래스 프로토콜로 표시해라

프로토콜 채택을 클래스에서만 제한할 수 있다. 해당 프로토콜이 클래스만 준수할것을 시반으로 최적화 시키도 런타임 성능이 향상될 수 있다.

protocol Pingable: AnyObject { func ping() -> Int }

The Cost of let/var when Captured by Escaping Closures

일반적으로 let과 var는 성능보다는 의미적으로 구분하는 경우가 많은데 성능적으로 고려해야 하는 경우도 있다.
클로저에 대한 바인딩을 생성할 때마다 컴파일러는 escaping closure를 생성하도록 강제된다.

let f: () -> () = { ... } // Escaping closure
// Contrasted with:
({ ... })() // Non Escaping closure
x.map { ... } // Non Escaping closure

위와 같이 바인딩 하여 클로저를 생성하는 경우 escaping closure가 생성된다.
escaping closure에서 var가 캡처될 때 컴파일러가 변수를 저장하기 위한 힙 박스(heap box)를 할당해야 한다. 이 힙 박스를 통해 클로저 생성자 및 클로저가 변수의 값을 읽거나 쓸 수 있다. 반대로 let은 값으로 캡처한다. 따라서 컴파일러는 힙 박스를 사용자히 않고 값의 복사본을 직접 클로저의 저장 공간에 저장한다.
라고 하는데... 이해가 잘 안돼서 좀더 알아보았다.

클로저를 사용할 때 클로저 내부에서 외부의 값을 참조하는 경우가 있는데 이걸 캡처한다고 표현한다. 이 때 변수를 가져올때 let과 var의 차이가 있다.
만약 클로저 안에서 var로 가져온 변수가 있고 이 클로저가 다른곳에서 사용될 때 그 변수를 저장할 공간을 만들어야 한다. 이 저장하는 공간을 힙 상자(heap box)라고 한다. 왜냐하면 변수가 메모리의 힙(heap) 영역에 저장되어야 하기 때문이다.
반면 let으로 가져온 변수는 값 그 자체를 가져오기 때문에 힙 상자를 사용하지 않아도 된다. 대신 그대로 값을 복사해서 클로저의 저장 공간에 넣는다.

Advice: closure가 escaping되지 않는다면 var 대신 inout개매변수를 사용하는것이 좋다.

이 경우 힙 상자를 만들지 않아도 되기 때문에 성능이 개선될 수 있다.


마치면서

몰랐던 내용들이 많아서 유용하긴 했다. 그런데 뭔가 이번 문서는 특히 설명 자체가 이해하기 어렵게 설명하고 있다고 느껴졌다ㅠㅠ.
무튼~ 정보를 명시적으로 표현해주는게 가독성만을 위함이 아닌 컴파일러가 최적화를 수행하는데 도움이 된다는것을 알게되었다. 이런 부분을 유의해서 코드를 작성하면 성능향상에 도움이 될 수 있을것이다.

profile
🍎 무럭무럭

0개의 댓글