Swift

[Swift] Swift Performance (2) | Generic을 활용한 성능개선

하이D:) 2024. 2. 7. 14:54

이전 글에서는 Swift에서 성능일 미치는 세 가지 요소와 구조체가 protocol을 채택했을 때 어떻게 성능이 달라지는지 알아보았다.

 

[Swift] Swift Performance (1) | swift에서 성능을 좌우하는 3가지 요소와 구조체의 성능 변화

 

 

struct + protocol을 사용하면 단순하게 struct를 사용하는 것에 비해 성능이 안 좋아진다고 했는데 이 인스턴스를 argument로 전달한다고 가정했을 때 함수 내 로컬 변수로 사용되는 상황해서 어떻게 하면 성능을 좋게 할 수 있을까?

 

 

정답은 제네릭 함수를 사용하는 것이다!

이번 글도 WWDC 2016 Understanding Swift Performance 세션과 연관되는 글이다.

 

우선 제네릭 함수를 사용했을 때와 사용하지 않았을 때의 코드예시를 보자면,

 

 

 

제네릭 함수 사용하지 않았을 때

protocol Drawable {
 func draw()
}

struct Point: Drawable {
    let x, y: Double
    
    func draw() {
      print("Point 구조체의 메서드 실행 => \(x), \(y)")
    }
}

struct Line: Drawable {
    let x1, y1: Double
    let x2, y2: Double
    
    func draw() {
      print("Line 구조체의 메서드 실행 => \(x1), \(y1) - \(x2), \(y2)")
    }
}

func drawACopy(local : Drawable) {
 local.draw()
}

let line = Line()
drawACopy(line)

// ...

let point = Point()
drawACopy(point)

 

 

제네릭 함수 사용했을 때

// Drawing a copy using a generic method

protocol Drawable {
 func draw()
}

struct Point: Drawable {
    let x, y: Double
    
    func draw() {
      print("Point 구조체의 메서드 실행 => \(x), \(y)")
    }
}

struct Line: Drawable {
    let x1, y1: Double
    let x2, y2: Double
    
    func draw() {
      print("Line 구조체의 메서드 실행 => \(x1), \(y1) - \(x2), \(y2)")
    }
}

func drawACopy<T: Drawable>(local : T) {
 local.draw()
}

let line = Line()
drawACopy(line)

// ...

let point = Point()
drawACopy(point)

 

drawACopy 함수를 제네릭 함수로 정의했는가 아니면, 그냥 함수로 정의하고 프로토콜 타입으로 파라미터를 받았냐의 차이이다.

 

프로토콜 타입으로 파라미터를 받았을 때 VS 제네릭 함수로 정의했을 때

이 부분을 중점적으로 비교해서 제네릭 함수를 사용했을 때 대체 왜 성능이 좋아지는지 알아보자!

 

 

 

Static form of polymorphism

Generic code는 더욱 정적인 형태의 polymorphism(다형성)을 구현할 수 있도록 도와준다고 합니다. (static form of polymorphism, 이것을 parametric polymorphism이라고도 부른다)


protocol을 이용했을 때에는 struct를 사용하더라도 PWT를 사용하는 등 메서드가 동적(Dynamic Dispatch)으로 결정되는 부분이 있다고 말했었는데, generic에서는 그렇게 동작하지 않는다는 뜻이죠!

 

그럼  struct + protocol이 어떻게 Static으로 동작하는 게 가능할까?


제네릭 함수는 호출하는 시점에 파라미터(로컬 변수)의 타입이 바인딩된다.

아래의 예시를 보면

foo, bar function의 예시

func foo<T: Drawable>(local : T) {
	bar(local)
}

func bar<T: Drawable>(local: T) { … }

let point = Point()
foo(point)

// foo<T = Point>(point)
	 // bar<T = Point>(local)

 

위의 코드에서 foo를 호출한 시점에 local 변수의 타입이 Point로 바인딩된다(특정된다).
→ foo내부에서 bar을 호출하는데, 이 시점에 bar 함수의 generic type인 T를 Point로 바인딩한다.
이런 방식으로 타입은 call chain으로 대체되어 내려간다. (Type substituted down the call chain)

 

 

이처럼 제네릭 함수를 호출하면 호출하는 시점에 타입이 바인딩되고

그로 인해 내부에서는 특정된 타입이 사용되면서 dynamic(동적)이 아닌 static(정적)으로 동작할 수 있는 것이다!

 

 

 

Swift가 내부적으로 static polymorphism을 구현하는 방법

근데 위에서 말한 것처럼 제네릭 함수 호출 시 타입을 특정하고 그에 맞게 static polymorphism이 가능하게 하기 위해서는 필요한 조건이 있다고 한다.

 

제네릭 함수 한 번의 호출에 하나의 타입만 전달(One type per call context)하는 경우에만!! 해당한다고..

일단 한 번의 호출에 하나의 타입만 전달(One type per call context)!!

이 조건에 집중해서 제네릭 함수에서 static polymorphism을 구현하는 방법에 대해 알아보면,

 

 

이 경우에는 파라미터로 전달된 프로토콜 타입의 구조체 인스턴스를 저장하기 위해  existential container를 사용하지 않는다고 한다..

(existential container 개념을 모른다면 이전 글 보고오기)

Swift 메서드 실행 방법 (3) | 프로토콜 채택한 구조체 Existential Container / ValueBuffer / VWT / PWT

 

 

뭐야.. 그럼 이 때는 existential containe 없이 어떻게 메모리를 할당하고 내부 메서드를 실행하는가?

PWT / VWT는 추가 argument로 전달

 

VWT가 추가 argument로 전달된다고 한다!

즉, 파라미터로서 전달받은 것을 로컬 변수로 만들고 값을 저장시키려면

추가 argument로 전달된 VWT에서 allocate함수를 통해 statck에 저장프로퍼티 등 값을 할당한다.

 

 

추가적인 인자로 PWT도 함께 전달하기 때문에 local.draw()를 호출할 때에도 전달된 PWT를 참조하여 호출한다.

 

 

WWDC 발표 자료에서는 프로퍼티 저장하는 데 이 사진을 썼는데..

 

 

existential container가 없다고 했고 valueBuffer도 없을 텐데 왜 이렇게 표현했는지 모르겠고, 세션 뒤에서 나오는 그림에서는  아래처럼 값의 크기에 상관없이 다 stack 내부에 저장하는데 왜 저런 자료를 보여줬는지 잘 모르겠다..?!

 

 

근데 이제 여기서 의문이 들 수 있는게

 

근데 그래서 제네릭 함수를 안 쓴 것보다 좋은게 확실해?? 빠른 게 확실해?? 

Is this any better? Is this any faster?

 

 

제네릭의 specialization

static polymorphism은 제네릭의 specialization이라고 불리는 컴파일러 최적화를 가능하게 한다.

 

그래서 specialization가 뭔데? 어떻게 하는 건데?

 

제네릭 함수 호출 시점에 특정 타입으로 바인딩되고(static polymorphism), 그 타입을 위한 특정 버전의 메서드를 생성하는 것(Specialization)! 이렇게 static polymorphism와 specialization의 개념을 완전히 구분지어서 이야기 해도 괜찮은 것인지 잘 모르겠지만..! 일단 이렇게 해야 이해하기 쉬웠어서 이렇게 정리해본다!

 

글로 이해하면 어렵고 아래 그림을 보면 이해가 갈 것이다.

 

 

원래는 이랬던 제네릭함수가

func drawACopy<T : Drawable>(local : T) { 
	local.draw() 
}

 

 

함수 call-site에서 전달된 argument로 타입이 구체화되면서 각각 전달된 타입에 맞는 함수(drawACopyOfAPoint, drawACopyOfALine)가 새로 생긴 것이다(Specialization).

func drawACopyOfAPoint(local : Point) {
 local.draw()
}
func drawACopyOfALine(local : Line) {
 local.draw()
}

drawACopyOfAPoint(Point(…))
drawACopyOfALine(Line(…))

 

 

 

이렇게만 보면 제네릭 함수가 실행될 때 내부적으로 코드 사이즈가 증가하는 게 아닌가? 성능이 안 좋아지는 게 아닌가?라고 생각할 수 있지만!

 

정적으로 입력된 정보(전달된 타입별로 새롭게 생성된 메서드들)는 더 적극적인 최적화(aggressive optimization)를 가능하게 하고 결과적으로는 코드 사이즈를 줄인다고 합니다!

(because the static typing information that is now available enables aggressive compiler optimization, Swift can actually reduce the code size here.

 

 

 

그럼 또 최적화(Optimization)란 무엇인가?

 

Optimization은 위의 Specialization 과정을 거쳐서 코드를 이렇게까지 줄일 수 있는 것!

 

 

Point().draw() & Line().draw() 이런 식으로 최적화되면서 drawACopyOfAPoint, drawACopyOfALine과 같은 메서드들이 더 이상 참조되지 않으므로 컴파일러는 이것들을 삭제한다. 

 

this can be even further reduced to the implementation of draw

and drawACopyOfAPoint method is no longer referenced → compiler remove it

 

 

 

Specialization은 언제, 어느 조건에서 발생하는가?

Specialization은 아래 두 가지 조건을 만족해야 발생한다고 한다.

 

  • specialize 하기 위해선 call-site에서 타입을 추론할 수 있어야 한다. (infer the type at this call-site)
  • specialization 중에 사용될 타입과 제네릭 함수의 정의가 있어야한다. (Definition must be available)

 

우리가 이렇게 함수를 호출했을 때 Line의 인스턴스 혹은 Point의 인스턴스였기 때문에 타입 추론이 가능했죠?

그리고 사용해 줄 타입(Line, Point)의 정의도 존재하고 제네릭 함수의 정의도 존재하기 때문에 Specialization이 발생하는 조건은 모두 만족한다고 볼 수 있죠!

 

 

 

다만, struct 정의, 정의한 struct 타입에 대한 인스턴스를 저장한 로컬 변수, 그 변수를 인자로서 넘겨주는 함수의 실행이 모두 한 파일에 존재할 수도 있지만 다른 파일에 존재할 수도 있는데..

 

 

다른 파일에 존재한다면!

  • Whole Module Optmization(WMO)가 더욱 활발하게 일어날 수 있다고 하는데
  • 컴파일러가 두 파일을 따로 컴파일할 것이기 때문에 WMO(Whole Module Optimization)을 통해서 모든 파일을 하나의 단위로서 컴파일될 수 있고 최적화가 가능하다고 한다.
  • WMO는 Xcode 8부터 기본값으로 활성화되어있다고 한다.

 

 

Pair 구조체 initializer 관련 예시 코드

 

pair 구조체를 예시로 initializer가 제네릭 타입일 때와 제네릭 타입이 아닐 때의 차이에 대해 알아보자

 

 

Pair (제네릭 타입이 아닌 initializer)

protocol Drawable {
    func draw()
    
}

struct Point : Drawable {
    var x, y: Double
    func draw() {
    }
}

struct Line : Drawable {
    var x1, y1, x2, y2: Double
    func draw() {
    }
}


// Pairs in our program
struct Pair {
    init(_ f: Drawable, _ s: Drawable) {
        first = f ; second = s
    }
    var first: Drawable
    var second: Drawable
}

let pairOfLines = Pair(Line(), Line())
let pairOfPoint = Pair(Point(), Point())

 

  • Line 인스턴스 값을 저장하기 위해서는 valueBuffer의 용량을 넘어가기 때문에 heap에 저장되어야 할 것이다.
  • Pair(Line(), Line())가 실행되면서 Line 인스턴스 값을 저장하기 위해 두 번의 힙 할당이 일어날 것이다.

 

 

Pair(제네릭 타입인 initializer)

  • Pair이라는 struct의 이니셜라이저가 제네릭 타입으로 정의되어 있으며
  • 첫 번째 파라미터와 두 번째 파라미터의 타입을 똑같은 것으로 강제함으로써 같은 타입만 넣을 수 있도록 강제해 놨다.
  • ⇒ 우리가 위에서 살펴봤던 대로 제네릭 함수의 실행에 하나의 타입만 전달되는 경우(One type per call context)에 부합하는 것!
protocol Drawable {
    func draw()
    
}

struct Point : Drawable {
    var x, y: Double
    func draw() {
    }
}

struct Line : Drawable {
    var x1, y1, x2, y2: Double
    func draw() {
    }
}


// Pairs in our program using generic types
struct Pair<T : Drawable> {
    init(_ f: T, _ s: T) {    
    	first = f ; second = s
    }
    var first: T
    var second: T
}

let pairOfLines = Pair(Line(), Line())
let pairOfPoint = Pair(Point(), Point())

 

자 그럼 앞에서 우리가 봤던 제네릭 함수처럼 프로토콜을 준수하는 구조체 인스턴스를 제네릭 initializer에 넘겨주었을 때 무슨 일이 일어나는지 다시 한번 살펴보자

  • 하나의 타입만 받는 제네릭 함수는 static polymorphism, parametric polymorphism으로 동작하며
  • 프로토콜을 준수하는 구조체 인스턴스를 함수 내 로컬 변수로 사용해도 제네릭함수이기 때문에 Existential Container로 저장되지 않는다. 즉, 인스턴스 별로 valueBuffer/PWT참조/VWT 참조를 저장하는 stack영역에 저장된 컨테이너가 없는 것!
  • 대신에 함수 실행 시 argument와 함께 pwt, vwt를 추가 인자로 직접 전달하다고 했다?!
  • 그럼 Existential Container가 없으면 값은 어떻게 저장하냐? - 프로토콜 채택하지 않는 보통의 구조체처럼 stack에 저장한다.
  • 메서드 실행이 static(정적)하게 이루어진다. 이 말은! 제네릭함수가 실행될 때 specialization를 통해 전달된 타입 버전으로 함수를 만들기 때문에 굳이 해당 메서드가 누구 건지 저장할 pwt가 필요 없고 → static Disptatch 해 버리면 된다는 것!

 

 

성능 비교

지금까지 우리가 제네릭을 살펴본 이유는 성능이 얼마나 좋아지는지를 알아보기 위해서라는 걸 잊으면 안 된다.

 

 

그냥 struct 

 

 

 

프로토콜을 준수하는 구조체를 파라미터로 가지는 보통의 함수가 동작하는 방법 

앞의 글에서  본 것처럼 저장 프로퍼티의 용량에 따라 다르게 저장되기 때문에 아래 두 가지 상황에서 성능에 차이가 날 수밖에 없다.

 

 

 

struct + protocol을 파라미터로 가지는 제네릭함수가 동작하는 방법

이 때는 Small Value, Large Value가 상관없음(다르게 동작하지 않는다).

왜냐! 어차피 existential container 만들지 않고 프로퍼티는 모두 stack영역에 저장, 그렇기 때문에 RC도 하지않고 메서드는 static으로 동작하기 때문에

 

 

 

그렇다면 클래스를 파라미터로 받는 제네릭 함수는?

  • 원래 클래스의 값들은 힙에 저장하고 힙에 있는 값들을 참조, RC를 올린다.
  • 그리고.. 클래스는 구조체의 타입처럼 제네릭 타입으로 넘겨도 VTable 활용해서 동적으로 디스패치 하나보당..?

결과적으로는 class에서 generic을 사용했을 때 specialization이 된다고 하더라도 일반적으로 class를 사용했을 때와 다르지 않은 성능을 보여준다.

 

그렇담 class을 인자로 넘길 때는 제네릭 함수를 사용하는 게 struct+protocol을 제네릭 함수에 넘겨주는 것만큼 성능에 베네핏이 없네요??

 

 

 


 

 

 

(( 참고 : 제네릭 함수가 specialization된다는 것을 확인하고 싶다면 - SIL 코드))

 

우리가 제네릭의 specialization 수행 여부를 알고 싶다면!! 스위프트 중간 언어인 SIL(Swift Intermediate Language)  코드를 통해 확인해 볼 수 있다.

 

SIL이 뭔데?

 

  • Swift Intermediate Language : 스위프트 중간 언어
  • Swift 컴파일러가 Swift 코드를 통해 바이너리 코드를 생성하는데 중간과정에서 필요한 것 중 하나가 SIL

 

 

SIL 코드를 확인할 수 있는 방법 ((예시))

일단 예시로 아래 코드가 실행될 때 SIL은 어떻게 나오는지 확인해 보자

//Generics.swift

@inline(never)
func replaceNilValues<T>(from array: [T?], with element: T) -> [T] {
  return array.compactMap {
    $0 == nil ? element : $0
  }
}

let numbers: [Int?] = [32, 3, 24, nil, 4]
let filledNumbers = replaceNilValues(from: numbers, with: 0)
print(filledNumbers) // [ 32, 3, 24, 0 , 4 ]

let floatNumbers: [Float?] = [32.0, 3.0, 24.0, nil, 4.0]
let filedFloatNumbers = replaceNilValues(from: floatNumbers, with: 0)
print(filedFloatNumbers) // [ 32.0, 3.0, 24.0, 0.0 , 4.0 ]

 

이런 코드가 있다고 가정할 때 

터미널로 파일이 있는 디렉토리에 가서 아래 명령어를 입력해 주면

//터미널에
$ swiftc Generics.swift -O -emit-sil -o Generics.s

 

같은 디렉토리에 새로운 Generics.s 라는 파일이 새로  생겼을 것이다. 그 파일이 바로 컴파일러가 번역한 파일이고 제네릭함수인 replaceNilValues(from:with:)가 specialization 된 코드이다. 

// specialized replaceNilValues<A>(from:with:)
...
bb0(%0 : $Array<Optional<Int>>, %1 : $Int):
...

//// specialized replaceNilValues<A>(from:with:)
...
bb0(%0 : $Array<Optional<Float>>, %1 : $Float):
...

 

파일을 열어서 보면  제네릭 타입이 전달된 argument에 따라 구체적인 타입(Int or Float)으로 바뀐 것을 볼 수 있다. 

 

 

 

그럼 우리가 지금까지 본 drawACopy 제네릭 함수에 대한 specializaion을 확인해보고 싶다면?

 

 

 

아래 코드를 SIL 코드로 변환한다고 해보자

//GenericPratice.swift
import Foundation

protocol Drawable {
    func draw()
    
}

struct Point : Drawable {
 var x, y: Double
 func draw() {
     print("Point 인스턴스의 draw메서드")
 }
}

struct Line : Drawable {
 var x1, y1, x2, y2: Double
 func draw() {
     print("Line 인스턴스의 draw메서드")
 }
}

func drawACopy<T: Drawable>(local : T) {
 local.draw()
}

let point = Point(x: 1.0, y: 1.0)
drawACopy(local:point)

let line = Line(x1: 2.0, y1: 2.0, x2: 3.0, y2: 3.0)
drawACopy(local:line)

 

 

 

터미널에는 이렇게 작성해 주고

//터미널에
$ swiftc GenericPratice.swift -O -emit-sil -o GenericPratice.s

 

 

 

이렇게  해서 만들어진 GenericPratice.s 파일에 들어가서 확인해 보면 아까 예시코드와 다르게 여기는 Specialized라는 키워드는 없지만!

Point.draw(), Line.draw() 로 나눠져서 실행된 걸 보면 제네릭 타입이 각각 실행됐을 때 전달된 argument의 타입으로 구체화되어서 각각 따로 코드로 변하고 실행되는 것을 알 수 있다.

//Point.draw()
...

//Line.draw()
...

 


 

 

지금까지 정리한 것처럼 제네릭함수는 프로젝트의 성능에 꽤나 큰 영향을 끼칠 수 있다 그러니 프로토콜 타입의 구조체를 사용할 때나 동적으로 동작하는 타입을 정적으로  동작하게 만들고 싶을 때에는 제네릭 함수를 사용해서 성능을 최적화시키는 방법을 잘 고려해 보도록하자!!

 

성능 최적화 화팅...!!