https://www.youtube.com/watch?v=nb3bRQa0iGQ&t=3s
1. Swift에서 말하는 성능이란?
WWDC 2024에서 Apple의 John McCall이 발표한 "Explore Swift performance" 세션을 보면서 정말 많은 것을 배웠습니다.
성능이라는 게 단순히 "빠르다/느리다"로 나눌 수 있는 게 아니더라고요.
성능은 다차원적이고 상황적입니다. 여러 측면에서 성능을 평가할 수 있어요:
- 지연 시간: 작업이 시작되고 완료될 때까지 걸리는 시간
- 에너지 소모: 앱이 배터리를 얼마나 효율적으로 사용하는지
- 메모리 사용: 앱이 얼마나 많은 메모리를 점유하고 어떻게 관리하는지
보통 성능 문제를 조사할 때는 거시적인 관점에서 시작합니다. Instruments 같은 도구로 측정하고, 대부분은 알고리즘 개선으로 해결되죠. 하지만 때로는 정말 로우 레벨까지 파고들어야 할 때가 있어요.
저도 처음엔 "그냥 빠르게만 돌아가면 되는 거 아닌가?" 싶었는데, 실제로는 훨씬 복잡하더라고요 😅
2. 로우 레벨 성능의 4가지 핵심 원칙
Swift의 로우 레벨 성능은 4가지 주요 요소로 결정됩니다:
함수 호출 비용
함수 호출에는 4가지 비용이 있어요. 이 중 3가지는 우리가 직접 하는 것들이에요:
1. 먼저 호출에 의한 인수를 설정(arguments)
- 가장 낮은 수준에서 함수 호출 시 인수를 적절한 위치에 배치해야 합니다.
- 최신 프로세서에서는 레지스터 이름 변경을 통해 이 비용을 최소화합니다.
- 실제로는 함수의 소유권 규칙을 충족하기 위해 값 복사가 발생할 수 있습니다.
2. 함수 주소 확인 - Static vs Dynamic Dispatch
- 컴파일 타임에 어떤 함수를 호출하는지 알 수 있으면 정적 디스패치(Static Dispatch)를 사용합니다.
- 알 수 없다면 동적 디스패치(Dynamic Dispatch)를 사용하며, 이는 성능에 영향을 줍니다.
- 정적 디스패치는 컴파일러가 인라인화, 제네릭 구체화 등 다양한 최적화를 적용할 수 있습니다.
Static Dispatch (정적 디스패치):
- 컴파일 타임에 어떤 함수를 호출하는지 알 수 있는 경우
- 컴파일러가 인라인화, 제네릭 구체화 등 다양한 최적화 적용 가능
- 성능이 훨씬 좋음
Dynamic Dispatch (동적 디스패치):
- 런타임에 함수 주소를 결정해야 하는 경우
- 성능에 영향을 주지만 다형성(polymorphism) 구현 가능
Swift에서 동적 디스패치가 사용되는 경우:
- 불투명한 함수를 호출할 때
- 오버라이딩 가능한 클래스 메서드를 호출할 때
- 프로토콜 요구사항을 호출할 때
- Objective-C나 C++ 함수를 호출할 때
프로토콜에서 메서드 위치에 따른 Dispatch 🤔
이 부분에서 진짜 헷갈렸어요. 같은 프로토콜인데 메서드가 선언된 위치에 따라 dispatch 방식이 달라진다니!
프로토콜 본문에서 선언된 경우: 프로토콜 요구사항이 되어 Dynamic Dispatch 사용
프로토콜 확장에서만 선언된 경우: Static Dispatch 사용
왜냐하면 확장에서 추가된 메서드는 서브 클래스에서 오버라이딩이 불가능하기 때문이에요!
protocol IsStaticOrDynamic {
}
extension IsStaticOrDynamic {
func isStatic() {
print("static dispatch")
}
}
이렇게 프로토콜에 정의되어 있지 않은 것들을 extension에서 추가한 경우 오버라이딩이 불가능하기에 static이지만
protocol IsStaticOrDynamic {
func isDynamic()
}
extension IsStaticOrDynamic {
func isStatic() {
print("static dispatch")
}
func isDynamic() {
print("DYnmaic DIspatch")
}
}
이렇게 protocol에 선언되어있는것을 extension을 통해 default메서드 구현해놓은 경우에는 어떤게 불려야할지 모르기에 dynamic Dispatch이다.
3. 함수의 로컬 상태를 위한 공간 할당 (CallFrame)
함수 실행을 위해 로컬 변수와 임시 상태를 저장할 공간이 필요합니다. 동기 함수는 이 메모리를 C 스택에 할당해요.
함수 실행 시:
1. 스택 포인터에서 필요한 바이트 수만큼 빼기 (예: 208바이트)
2. 이 공간을 CallFrame이라 부름
3. 함수 실행 완료 후 스택 포인터에 다시 같은 크기 더하기
CallFrame은 C 구조체와 유사한 레이아웃을 가지며, 이상적으로는 함수의 모든 로컬 상태가 여기에 포함됩니다.
4. 이건 우리가 하지않는것인데 함수 호출자와 호출하는곳에서 최적화를 방해할 수 있다. Optimization restrictions
컴파일러가 최적화를 방해할 수 있는 요소들이에요. 3번과 4번 모두 컴파일 시간에 호출하는 함수를 정확히 알 수 있는지와 관련있어요.
Swift는 강력한 optimizer가 있어서 많은 비용들을 제거해주지만, 한계가 있어요.
메모리 할당 종류
메모리 할당 패턴
메모리는 총 3가지 종류가 있습니다:
Global Memory
- 프로그램 로드 시 할당되고 초기화
- 거의 할당 비용이 없음
- 단점: 고정된 크기, 프로그램 실행 내내 메모리 유지
- 사용 예: 글로벌 변수, 정적 멤버 변수
Stack Memory
- CallFrame 예시처럼 할당 비용이 적음
- 메모리의 범위가 제한적 (함수 내부 등)
- 사용 예: 일반적인 로컬 변수
Heap Memory
- 매우 유연하며 언제든지 할당/해제 가능
- 할당 및 해제 비용이 다른 종류보다 훨씬 높음
- 사용 예: 클래스 인스턴스, 크기가 동적인 데이터
- Swift는 레퍼런스 카운팅으로 힙 메모리 수명 관리
이 모든 것은 RAM에 있지만 다른 패턴으로 각각을 할당하고 사용해요.
메모리 레이아웃과 Representation(값이 메모리에 어떻게 보이는지)
array 변수는 버퍼 객체에 대한 참조를 유지하는 메모리의 이름입니다.
"inline representation"은 포인터를 따라가지 않는 representation이라고 할 수 있어요.
위의 경우 inline representation은 단일 버퍼 참조이며, 실제 측정해보면 64비트(8바이트)입니다.
CallFrame에는 로컬 범위에 포함된 배열 값이 있고, 이는 CallFrame의 inline 표현에 배치됩니다.
실제 해당 포인터를 저장하는 거죠.
2.4 값 복사와 소유권
Swift의 소유권 시스템은 메모리 안전성의 핵심입니다. 값을 사용할 때 세 가지 방식으로 소유권과 상호작용합니다:
- 소모(Consume):
- 표현의 소유권을 한 곳에서 다른 곳으로 이전합니다.
- 예: 변수 초기화, 함수에 값 전달
- consume 연산자를 사용해 명시적으로 소모를 표현할 수 있습니다.
- 변경(Mutate):
- 변경 가능한 변수에 저장된 값의 소유권을 일시적으로 가져옵니다.
- 메서드가 완료되면 새 값의 소유권을 다시 변수에 넘깁니다.
- 예: 배열에 요소 추가, 구조체 프로퍼티 수정
- 대여(Borrow):
- 값을 읽기만 하고 변경하지 않을 때 사용합니다.
- 예: 값을 출력하거나 함수에 읽기 전용으로 전달할 때
- 때로는 Swift가 방어적으로 값을 복사해야 할 수도 있습니다.
값을 복사하는 비용은 타입의 인라인 표현에 따라 달라집니다:
- 클래스 값 복사: 객체에 대한 참조만 복사하므로 비용이 적습니다.
- 구조체 값 복사: 모든 저장 속성을 재귀적으로 복사하므로 큰 구조체는 비용이 많이 들 수 있습니다.
array2에 넣어주면 값은 배열이므로 복사한다는 것은 해당 버퍼를 유지한다는 뜻. 만약 컴파일러가 여기서 array변수가 마지막으로 쓰이고 안쓰인다는 것을 알아? 복사본없이 여기에 값을 전송할 수 있어야 함.
var array2 = consume array라고 쓰면됨.
mutating: 이후에도 값의 소유권을 갖는다.
Borrowing: print와 같이 컴파일러가 저거에 대해 건드리지 않을것을 알고 빌려준다.
좀 더 높은 레벨에선, 함수의 컨벤션에 맞게 컴파일러가 값의 복사본을 추가해야할 수 있다.'
inline stoarge:
- heap할당을 피한다
- 작은 타입에 해야함. 전부 복사하기에 성능저하가 일어날 수 있다.
3. Swift의 고급 기능과 성능 영향
Swift 타입의 크기는 두 가지 경우에 런타임에 결정됩니다:
- SDK의 많은 타입(예: Foundation의 URL)은 향후 OS 업데이트에서 변경될 수 있습니다.
- 제네릭 타입의 매개변수는 다양한 크기의 타입으로 대체될 수 있습니다.
Swift는 이러한 동적 크기 타입을 처리하기 위해:
- 컴파일러가 타입의 정적 부분 레이아웃만 파악하고 나머지는 런타임에 결정합니다.
- 필요한 경우 별도의 메모리 할당을 사용합니다(글로벌 변수, 로컬 변수 등).
이럴때 첫번째 속성에 갈때까지는 레이아웃을 알고 있으나 이후에부터는 런타임에 의해 동적으로 채워진다. Cstack에서도 마찬가지로 96만큼만 가지고 있다가 런타임에 정해지면 다시 변수크기만큼빼서 stackPointer를 내려준다.
Async함수에서는 어떻게 작동할까?
비동기 함수는 일반 동기 함수와 다르게 작동합니다:
- 로컬 상태를 C 스택이 아닌 별도의 비동기 스택에 저장합니다.
- 실행 시 여러 "부분 함수"로 나뉘어 실행됩니다(await 지점마다 분리).
비동기 함수는 메모리 슬랩(memory slab)을 사용해 효율적으로 메모리를 관리합니다:
- 작업이 슬랩에서 필요한 메모리를 할당합니다.
- 슬랩이 가득 차면 새로운 슬랩을 할당합니다.
- 이 할당자는 일반 malloc보다 빠르지만, 함수 호출에 약간의 오버헤드가 추가됩니다.
여기서 로컬함수들은 정지지점을 cross하는데 쓰이므로 C스택에 저장할 수 없다.
마지막 제너릭
여기 두 updateAll의 차이가 뭘까?
첫번째꺼는 동일한 모델이 array에 들어간다. 타입에 대한 정보는 함수에게 한번만 전달된다.
두번ㅉ꺼는 뭐 다른 DataModel에 맞는 프로토콜이 전부들어올수있다. 당연히 최적화도 훨 어렵다. 컴파일러가 일일이 다 측정해야하기에..
사실 뒤로갈수록 너무 어려웠다...그래서 댓글에 각자 생각을 알려주시면 참고하도록 하겟슴다..
'면접준비' 카테고리의 다른 글
SilentPush&RichPush (2) | 2024.11.08 |
---|---|
test Code (with TCA) (0) | 2024.11.04 |
Uniform type Identifiers (1) | 2024.04.18 |
매크로(Macros) (3) | 2024.03.07 |
초기화(initialization.. 편의? 지정?) (0) | 2024.03.05 |