-
[WWDC24] Swift의 성능 살펴보기 (3/3)IOS 2025. 2. 9. 13:54
이번 글은 이전글에 이어서 작성하는 글로 Swift의 고수준 기능에 대한 글입니다.
[WWDC24] Swift의 성능 살펴보기 (2 / 3)
2024.12.07 - [IOS/WWDC24] - [WWDC24] Swift의 성능 살펴보기 (1 / 3)에 이어서 작성하는 글로 WWDC기반 영상을 분석하고 풀어낸 글입니다. 이번에는 메모리 할당 관련해서 설명하려 합니다. https://developer.app
jjunbbang.tistory.com
[WWDC24] Swift의 성능 살펴보기 (1 / 3)
https://developer.apple.com/wwdc24/10217 Explore Swift performance - WWDC24 - Videos - Apple DeveloperDiscover how Swift balances abstraction and performance. Learn what elements of performance to consider and how the Swift optimizer...developer.apple.com
jjunbbang.tistory.com
1. Dynamically-sized types
C 구조체의 크기는 고정적이지만, Swift 구조체는 런타임에 결정될 수 있습니다.
Fundation URL의 경우 Struct이지만 내부적으로 NSURL(클래스) 이므로 힙에 할당될 가능성이 있음 따라서 런타임 때 크기가 동적으로 결정됩니다.
재네릭의 경우 어떤 유형으로든 대체될 수 있어야 하므로 마찬가지로 메모리 레이아웃이 결정되지 않지만, 제네릭 타입이 AnyObject
일 경우엔 포인터 주소를 가르키는 타입으로 한정 지을 수 있어 이럴 땐 예외사항이 됩니다.
위 그림에선 8Bytes가 할당되었는데 CPU 아키텍처에 따라 다릅니다.(64비트 시스템의 경우 8바이트 할당)
1.1 동적 할당 메커니즘
위 예제에 connection 예제를 다시 들면, Connection 구조체의 레이아웃은 address 크기가 정해질 때까지 나머지 레이아웃(username, options)을 파악할 수 있습니다.
나머지 레이아웃은 런타임 시점에 필요하게 될 때 동적으로 채우게 됩니다.
정적 레이아웃과 동일하게 구성되지만, 컴파일러는 필요한 크기와 오프셋을 런타임에 결정해 메모리에 배치하게 됩니다.(페이징 기법)
하지만 로드 시점에 전역 변수와 같이 고정 크기로 잡혀 있어야 하는데, URL일 경우엔 이렇게 동작하기 힘들거 같습니다. Swift에선 이를 해결하기 위해 전역영역에 포인터를 두고 실제 값은 Heap에 별도로 할당하게 됩니다.
lazy 할당도 마찬가지로 이런 지연 할당 메커니즘을 이용한 예가 됩니다.
또한 CallFrame은 상수이기 때문에, 로컬 변수 경우에도 비슷하게 동작됩니다.
workWithAddress 함수 호출시 address를 참조하는 포인터만 CallFrame에 저장하게 됩니다. 로컬 변수는 범위를 가지기 CallFrame에 할당되고 해당 범위를 벗어나면 해제합니다.
할당과 해제 2. Async functions
일반적인 동기 함수는 호출시 C스택에 할당되고 그 범위 내 유효하게 되지만, 로컬 함수나 클로저처럼 함수가 반환 이후에도 호출될 가능성이 있어 단순히 C 스택 메모리에 할당할 수 없습니다.
비동기 함수는 로컬 상태를 C스택과 다른 별도의 스택에서 로컬 상태를 유지합니다.
- 동기함수의 C스택 할당 해제 메커니즘과 동일하지만, 연속적인 스택 공간을 할당하지 않고 비동기 스택(힙기반 메모리 영역공간)에서 비동기 작업이 하나 이상의 메모리 슬랩(메모리 블록, 메모리 청크)을 관리합니다.
- 비동기 함수가 비동기 스택에 메모리를 할당할 때 Task가 관리하는 메모리 슬랩에 메모리를 요청합니다.
+) Task가 관리하는 정보는 로컬 변수, 중단점, 함수의 진행 정보입니다.
- 비동기 스택이 할당 가능하다면, Task가 슬랩을 사용 중으로 표시하고 비동기 작업을 호출한 함수에 제공합니다.
- Task가 관리하는 슬랩 대부분이 점유된 상태라면 Task가 자체적으로 새 슬립을 할당합니다.
- 할당 해제시에는 메모리에 작업을 다시 돌려주고 비어 있는 것으로 표시합니다.
- 이러한 메커니즘은 스택 메커니즘 원칙을 사용해서 일반 적인 malloc(힙 공간 할당)보다 속도가 빠다고 안내가 되어있습니다.
다음은 중단점이 있는 작업들이 C Stack에 어떻게 메모리 할당받게 되는지 심화 과정을 알아보겠습니다.
실제로 비동기 함수가 실행되는 메커니즘은 부분함수로 관리됩니다.
- 먼저 awaitAll 함수 진입시 중단지점(await) 전 상태들(result, tasks 배열)이 C 스택에 할당됩니다.
- for문 내 중단점(await task.value)을 만나면 현재 함수 진행상태를 Task가 관리하는 비동기 스택 영역에 저장 후 C스택을 해제합니다.
- 이때 비동기 함수는 일시 중단 상태가 되고 스레드는 다른 작업을 수행할 수 있도록 제어권을 이관합니다.(task.value가 완료될 때까지)
- task.value가 준비되면 task가 관리하는 메모리 슬랩을 활용해 중단점, 로컬 상태 등 C Stack에 다시 복구하게 됩니다.
- 따라서 result 로컬 변수에 작업의 결과를 추가하고 for문 루프 내 반복 여부를 체크합니다.
- 남은 Task가 있다면 마찬가지로 C Stack을 할당 해제하고 Task 비동기 메모리에 저장 후 작업이 완료될 때까지 런타임 대기, 완료 후 새로운 C 프레임을 생성하는 루프를 가지게 됩니다.
중요한 점은 C 스택에 최대 1개 부분 함수(호출자)만 존재한다는 것입니다.
3. Closures
지금까지 func로 선언한 함수에 대해 알아보았고 이제 클로저의 작동원리와 로컬 할당에 대해 알아보겠습니다.
3 - 1. Non Escaping Closure
C에서 표현되는 Swift 함수 시그니처 - 클로저는 함수의 유형을 전달하는 데 사용되고, 위 함수는 탈출 불가 함수를 인수로 받습니다.
- Swift에서 함숫값은 항상 함수 포인터와, 콘텍스트 포인터 쌍으로 구성됩니다.
- Swift에서 함수 호출하면 단순히 함수 포인터를 호출하게 되고, 묵시적으로 컨텍스트 포인터를 추가 인수로 전달합니다.
- 해당 코드에선 함수가 탈출 불가 함수로 호출 완료된 후에 함숫값이 사용되지 않습니다. 따라서 메모리 관리를 할 필요가 없습니다.
- 컨텍스트는 캡처한 값이 담긴 간단한 구조체가 됩니다.
- 컨텍스트를 스택에 할당할 수 있고 그 주소를 sumTwice에 전달합니다.
- puzzle 클로저 함수에선 짝을 이루는 컨텍스트의 유형을 알고 있으므로 puzzle 컨텍스트에서 필요한 데이터를 가져오기만 하면 됩니다.
3 - 2. Escaping Closure
- 탈출 클로저의 경우 클로저가 외부에 사용될 수 있으므로 컨텍스트 객체를 힙에 할당하고 소유권 획득/해제를 통해 관리합니다.
- 컨텍스트는 기본적으로 익명 Swift 클래스 인스턴스처럼 작동합니다.
3 - 3 Closure 로컬 변수 참조
- Swift 클로저에서 로컬 변수를 참조할 때 변수의 레퍼런스를 사용합니다. (그림에서 addend 변수가 이에 해당합니다.)
- 이에 따라 변수를 수정할 수 있으며, 기존 범위/ 현재 범위 모두 변경사항이 반영됩니다.
- 탈출 불가 클로저의 경우 변수를 가져올 때 변수의 수명은 변하지 않습니다. 그저 클로저가 변수의 레퍼런스 포인터만 가져와서 처리합니다.
3 - 4 Escape Closure 로컬 변수 참조
- 탈출 클로저에서 참조한 변수들은 힙 메모리에 할당되고 이 변수들은 클로저의 수명과 동일하게 될 수도 있습니다.
- 클로저 컨텍스트는 해당 객체에 대한 레퍼런스의 소유권을 획득해야 합니다.
4. 제네릭
재네릭의 경우 메모리 레이아웃이 정적으로 결정되지 않고 런타임에 결정되는 컨테이너에 따라 결정된다 이전에 말씀드렸습니다. 이번 주제는 프로토콜의 경우 어떻게 실행되는지 알아보겠습니다.
- 프로토콜은 런타임에 함수 포인터 테이블로 표현되고 이를 protocol witness table이라 칭합니다.
- 프로토콜의 각 요구사항당 이런 테이블이 존재합니다.
- 프로토콜의 제약 사항 같은 것들이 이러한 테이블에 포인터로 전달됩니다.
- 이 테이블은 런타임 시점에 해당 타입의 실구현체를 빠르게 조회하고 동적 디스패치를 가능하게 합니다.
따라서 재네릭 함수에선 타입의 유형, 함수 포인터 테이블이 숨겨진 매개변수로 추가됩니다.
4-1 성능저하가 있을 수 있는 dataModel 프로토콜
위와 같이 models의 배열엔 서로 다른 DataModel이 될 수 있습니다.(any로 선언)
- AnyDataModel의 경우 인라인 표현을 C로 나타내면 이렇습니다.
- 값을 저장할 공간이 있고
- 값의 유형, 준수 사항을 저장할 필드들이 있습니다.
이걸 왜 말하냐면, any 배열로 선언하게 된다면, DataModel 고정된 크기를 가져야 하는 조건이 있고 값을 저장할 공간에 맞지 않는 데이터 모델 유형이 있을 수 있습니다. swift에선 이를 임시 버퍼 3개를 사용해 해결합니다.
1. 버퍼에 값을 저장할 수 있는 크기의 DataModel이 들어오면 인라인 표현으로 값을 할당합니다.
2. 그 외의 것은 힙에 할당하고 이에 대한 레퍼런스 주소를 버퍼에 저장합니다.
따라서 제네릭과 any로 선언한 시그니처들은 매우 비슷해 보이지만 실제로 다른 특성을 가지고 있습니다.
첫 번째 updateAll함수의 경우 제네릭으로 특정 유형의 같은 아이템들이 배열로 받습니다. 이 배열에 같은 유형의 크기들로 효율적으로 채워지며 파라미터로 함수 유형정보가 한 번에 전달됩니다.
이 경우 어떤 유형인지 정확히 알기 때문에, 옵티마이저가 손쉽게 정적 디스패치를 통해 인라인 처리를 하게 됨으로써 추상화 비용이 제거됩니다.
두 번째 any 배열 파라미터를 받는 updateAll 메서드의 경우 동적 디스패치 메커니즘을 따릅니다. 각자 배열 요소들이 서로 다른 유형을 가짐으로써 추상화 비용이 발생하게 되고 이는 컴파일러가 추론하지 못하게 됩니다. 하지만 유연하다는 장점을 가질 수 있습니다.
컴파일러가 추론을 못할 뿐이지 성능이 정말 저하되는 게 아니기 때문에 이러한 유연성이 필요하다면(설계에 따라) 이런 비용을 지불할 수 있다는 것을 마지막으로 강조하고 있습니다.