Swift로 메모리 사용량 및 성능 개선하기 (Improve memory usage and performance with Swift) - WWDC25
Swift 6.2에서 새로 추가된 기능을 활용해 코드 성능을 이해하고 개선하는 방법을 알아보자


새로운 InlineArray 및 Span 타입을 사용해 제네릭을 알아보고, 탈출할 수 없는 타입에 대해서 알아보고 이러한 타입들을 사용해 보유 및 해제 독점성 및 고유성 검사 기타 추가 작업을 제거할 것.

Binary Parsing을 빠르고 안전하게 작성할 수 있는 새로운 오픈소스 라이브러리(Swift Binary Parsing)도 알아볼 예정.
이는 속도에 중점을 두고 여러가지 종류의 안전을 관리하는데 필요한 도구를 제공함
이번 세션에서는 코드가 시간을 어디에 사용하는지 알아내는 연습을 하고 여러 종류의 성능 최적화를 시도해 볼 것

- 올바른 알고리즘 선택
- 불필요한 할당 제거
- 독점성 검사 제거
- 힙에서 스택 할당으로 이동
- 참조 계산 줄이기

QOI Parser에서 로드 속도가 느린 동일한 새 이미지를 파싱하는 테스트를 작성함.
로드 속도가 느린 이미지를 파싱하는 테스트 작성
이렇게 테스트를 작성하면 정확성을 확인하는 것은 물론 Instruments에서 테스트의 성능도 확인할 수 있음.
테스트 프로파일링을 할 경우 프로파일링 시 코드의 특정 부분에 집중할 수 있음.

어떻게 접근했는지랑 관계없이 우선 가장 자주 캡처된 call들을 보고 싶으니까 Invert Call Tree를 클릭해서 반전시킴

이후 상단의 그래프를 클릭하면 그래프로 표시되도록 변경되며
각 Flame 그래프는 프로파일링 중 call의 캡처된 시간의 비율을 보여줌
1번의 _platform_memmove는 시스템이 데이터를 복사하기 위한 시스템 콜임.
즉, 해당 상황에서는 Parser가 데이터를 읽는 데 시간을 들이지 않고, 데이터를 복사하는데 대부분의 시간을 소비하고 있음을 나타냄.
이런 상황이 좋지는 않으며, 코드의 어느 부분이 복사를 일으키는지 확인해보고자 함.

이 상태에서 스택 추적에서 모든 프레임을 확인하고자 하면 우측 상단에 버튼을 클릭.
specialized method 으로 표현된 것들을 본 적이 있을탠데, 이는 Swift Compiler가 생성함.
이를 따라가면서 마지막으로 우리가 작성한 코드를 확인
(예제에서는 readByte)
해당 코드가 문제에 가장 가까운 코드이므로 시작하기에 좋음
Instruments를 사용하면 라이브러리에서 속도 저하의 잠재적인 원인으로 작용하는 모든 memmove 호출을 식별해
데이터 복사를 유발하는 특정 코드 라인으로 바로 이동할 수 있음.


개발자가 이 코드를 작성할 때는 Data에서 하나의 값을 제외하여 배열을 줄인다고 예상
하지만 실제로는 데이터를 하나 제거하고 새로운 배열에 전부 다시 할당하고 복사함.

따라서 요 부분은 이렇게 개선할 수 있음.

코드를 개선할 경우 memmove가 사라진 것을 확인할 수 있음.


벤치마크 결과 이정도로 개선되었다고 함.
이로 인해서 이 부분만 개선된 것은 아님
기존 작성된 이미지 알고리즘이 이미지 크기에 비례해서 늘어나는 구조라서
개선 후 전체적인 파싱 타임도 줄어듦.

이번에는 일반적으로 성능에 문제 지점인 Allocation에 대해서 살펴보고자 함.
Swift 배열을 할당하고 해제하는 메서드에 많은 트래픽이 발생하고 있음을 보여줌.
메모리를 할당하고 해제하는 데는 비용이 많이 들 수 있음.
이러한 할당이 어디에서 발생하는지 알아내고 이를 제거한다면 해당 코드가 더 빨라질 것.

할당이 어디에 이루어지는지 보려면 Allocation 도구를 확인하면 됨.
내 코드가 불필요하게 할당을 하고 있다는 몇가지 지표가 존재하는데
- 첫째로, 엄청난 숫자로 하나의 이미지의 구문을 분석하는데 100만이 넘는 KiB가 필요하고 있음.
- 두번째로, 거의 모든 할당이 일시적으로 발생한 할당임
- 이는 Instruments에서 Transient(일시적)으로 표시됨

문제의 근원을 찾기 위해서, 세부 정보를 확인하기 위해서 Call Tree로 전환.
콜 스택에서 반전하지 않았을 경우 가장 아래부터 찾아봐야 함!

해당 코드가 문제를 발생시킴.
호출마다 최소 3개의 Element에 대해서 공간을 할당한다는 의미.

위 코드는 짧게 작성되었지만, 짧다고 해서 더 빠른 것은 아님.
때때로 컴파일러가 이러한 추가할당을 없앨수도 있지만, 그렇지 않을수도 있고, 해당 부분에서는 컴파일러가 없애지 못함
flatmap은 새 배열에 할당하면서 데이터를 flat하게 만듦


이렇게 개선할 경우 반환해야 하는 데이터 외에는 할당이 없는 코드로 작성할 수 있음.

이렇게 변경한 후 프로파일링을 진행하면 실제로는 우리가 사용한 건 결과 이미지를 저장하는데 필요한 데이터 뿐임.

이렇게 할당을 개선함으로써 기존보다도 더 많은 시간 개선을 이뤄낼 수 있었음.

다음으로는 Swift Runtime에서 자동으로 메모리 관리 작업을 제거할 수 있도록 하는 몇가지 고급 기술을 알아볼 것.
Swift는 background에서 메모리를 관리함.
Array는 일반적으로 사용하기 좋으나, 동적인 사이즈와 여러가지 참조를 지원하기 위해 종종 힙에 별도로 할당 내용을 저장함.
Swift Runtime은 각 배열의 복사본의 수를 추적하고, 변경사항이 있으면 배열에서 고유성 검사를 수행해 요소를 복사해야 하는지 확인.
마지막으로 배열의 안전성을 위해 Swift Exclusivity를 적용하여 두 가지 다른 것이 동시에같은 데이터를 수정할 수 없음.
- 해당 규칙은 컴파일 타임에서 적용되지만 때로는 런타임에만 적용될 수도 있음.


기존 코드를 충분히 개선해 Instruments가 해당 메서드를 검사할 시간이 충분하지 않을 수 있어서 50번 정도 반복문을 수행하여 조사함.

배타성 테스트(Exclusivity tests)는 Trace에서 'swift_beginAccess' 및 'swiftendAccess' 기호로 표시됨.
'swift_beginAccess'로 필터를 걸어 확인해보니 Flame graph에서 swift_begiAcess가 몇번 나타나고, 바로 아래에 검사가 필요한 심볼이 존재함.
해당 심볼을 이전 픽셀과 픽셀 캐시에 대한 접근자로 파서의 State 클래스에 저장됨.


클래스 인스턴스를 수정하는 것은 Swift가 Runtime에 배타성을 확인해야 하는 상황중 하나이므로
기존 코드에서 State를 Class로 하고 있기에 'swift_beginAccess'으로 보고 있는 것으로 나타남.
이러한 속성을 클래스 외부로 옮겨서 해당 구조체에 직접 넣으므로써 배타성 검사를 제거할 수 있음.

이렇게 코드를 개선한 후 확인해보니 `swift_beginAccess`에 어떤 내용도 남아있지 않음!
즉, runtime exclusivity checking을 완전히 제거함.

이는 새로운 Swift 기능을 사용하여 힙 메모리에서 스택 메모리로 데이터를 이동하고, 해당 exclusivity checking가 다시 일어나지 못하도록 하는 좋은 방법임.

특정 배열의 사이즈가 변경되지 않는 경우 이 캐시는 InlineArray 타입을 사용하기에 좋은 영역임.
Swift 6.2에서 새로 나온 표준 라이브러리 타입임.
일반 배열과 마찬가지로 사용되지만, 몇가지 중요한 차이점이 존재함.
- 첫째, 인라인 배열은 컴파일 시에 설정하는 고정된 크기를 가짐.
- 추가하거나 제거할 수 있는 일반 배열과 달리 InlineArray는 new value generic feature를 사용하여 사이즈를 타입의 일부로 만듦
- 즉 값을 변경할 수는 있지만, 인라인 배열을 추가하거나 제거하거나 할당할 수는 없음.
- 두번째, 별도의 할당이 아닌 이상 항상 인라인으로 저장됨.
- 인라인 배열은 복사본 간에 저장소를 공유하지 않으며, COW를 사용하지 않음.
- 대신 사본을 만들 때마다 매번 복사됨.
- 이렇게 할 경우 일반 배열에 필요한 모든 reference count, uniqueness(고유성), exclusivitiy checks가 필요하지 않음.
- 하지만 이러한 복사는 양날의 검과도 같음.
- (주의)
- 두번째 특성으로 인해 복사본을 만들거나 달느 변수나 클래스 간에 참조를 공유해야 하는 경우에는 InlineArray는 적절한 선택이 아닐 수 있음.

최종적으로 최적화를 위해 표준 라이브러리의 새로운 span 타입을 사용해 대부분의 referecne count를 제거할 예정.

swift_retain은 샘플의 7%를 나타내는 분홍색 막대
swift_release는 또 다른 7%에 나타나는 분홍색 막대
앞서 말했던 swift_isUniquelyRefereced_nonNull_natvie도 샘플의 3%에서 나타남.
'swift_release' 샘플로 돌아가서 Stack Trace에서 가장 무거운 사용자 정의 메서드를 찾음.

이번에는 알고리즘 문제가 아니라 데이터 자체의 사용에 대한 문제
Array와 마찬가지로 Data도 일반적으로 힙에 메모리를 저장하며, reference counting이 필요함.
이러한 reference counting(retian 및 release) 작업은 매우 효율적이지만, 해당 메서드와 마찬가지로 긴 루프에서 실행될 경우 상당한 시간이 소요될 수 있음.
`Data`나 `Array`와 같이 high-level Collection 타입에서 reference counting explosion을 야기하지 않는 걸로 전환해야 함.
Swift 6.2 이전까지는 `withUnsafeButterPointer`와 같은 메서드를 사용해 Collection의 underlying storage에 접근함.
이렇게 사용할 경우 메모리를 reference counting없이 수동으로 관리할 수 있지만 코드에 안전하지 않는 부분이 생김

Pointer가 안전하지 않은 이유는 Swift에서는 언어의 안정성 보장을 우회하기 때문에 안전하지 않다고 부름.
- 초기화된 메모리와 초기화되지 않은 메모리를 모두 가리킬 수 있고
- 타입 보장(type guarntees)를 삭제하고
- 컨텍스트에서 벗어날 수 있어 더 이상 할당되지 않은 메모리에 접근할 수 있음.

processUsingBuffer 메서드는 포인터를 안전하게 사용하지만
getPointerToByte 메서드는 위험성이 존재함.
getPointerToByte가 위험한 이유는 더이상 유효하지 않은 포인터를 반환함으로써 이동되거나 할당 해제된 메모리에 대한 위험성이 있는 leftover refernce를 생성함.
배열을 생성하고 withUnsafeBufferPointer를 호출하지만 포인터에 대한 사용을 제한하는 대신 클로저로 외부 scope로 포인터를 반환함.

Swift 6.2에서는 Span이라는 새로운 타입을 도입
'Span'이 중요한 점은 ~Escapable 언어 기능을 사용해 컴파일러는 `Span`의 수명을 `Span`을 제공하는 Collection에 연결할 수 있음.
`Span`이 접근할 수 있는 메모리는 `Span`이 있는 동안만 유지되며 reference가 남을 가능성은 없음.
컴파일러는 외부에서 `span`을 탈출하거나 반환하는 것을 방지함.

배열 요소에 대해서 `Span`을 얻으려면 `span` 속성을 사용하면 됨.
클로저를 사용하지 않고도 Unsafe Pointer와 마찬가지로 배열의 underlying storage에 접근할 수 있으며 위험한 지점도 존재하지 않음.

span은 탈출할 수 없으므로, 컴파일러는 `span`을 캡처하면 `span`이 빠져나갈 수 있다는 것을 인식하고 컴파일 오류를 발생시키고 span의 수명은 로컬 배열의 따라 달라짐을 알려줌.
해당 컴파일러의 검사에서 span이 범위를 벗어나지 않는다는 것을 의미하므로 retian과 release가 필요하지 않음.
즉, unsafe buffer를 사용하지 않으면서도 좋은 성능을 얻을 수 있음

Span은 해당 패밀리에서 여러가지 타입을 지원함.

Span의 API는 Data와 다르지만 동일한 기능을 구현함.
`unsafeLoadUnaligned`는 타입을 로드하는게 안전하지 않을 수 있어서 이렇게 네이밍했다고 함.
내장된 타입을 로드하는건 언제나 안전함.


Data의 RawSpan은 bytes로 Data 타입의 사용은 이렇게 한다고 함.


OutputSpan은 작성한 데이터의 크기를 측정해서 별도의 오프셋 변수대신 count 속성을 사용할 수 있음.
OutputSpan 타입은 append가 가능한데, 이런 타입의 사용을 위해 설계된 탈출 불가능한 타입으로 `Data` 인스턴스에 작성하는 것보다 더 간단하고 효율적이며 Unsafe Buffer Pointer보다 안전함

이렇게 변경한 후 확인해보니 `swift_retain`과 `swift_release`가 사라진 것을 확인할 수 있음
여기서 InlineArray와 RawSpan을 도입한 결과에 대해서 살펴보자.

이들을 도입한 것 만으로도 속도가 훨씬 더 빨라짐.

마지막으로 Swift Binary Parsing 라이브러리는 여기 영상에서 제공한 것과 동일한 기능을 기반으로 구축됨.
이 라이브러리는 개발자가 바이너리 포맷을 안전하고 효율적으로 파싱하며 안전하게 사용할 수 있도록 함.

애플은 이미 내부적으로 사용하고 있어서 오늘부너 외부에서 사용 가능하다고 함.
(참고)
https://www.youtube.com/watch?v=LzBZjwEY9as
'apple > WWDC' 카테고리의 다른 글
| Meet Swift Testing - WWDC24 (0) | 2025.06.29 |
|---|---|
| Swift 동시성 사용하기 (Embracing Swift Concurrency) - WWDC25 (0) | 2025.06.21 |
| Instruments를 사용하여 CPU 성능 최적화하기 (Optimize CPU performance with Instruments) - WWD (0) | 2025.06.14 |
| 앱의 전력 사용량 프로파일링 및 최적화하기 (Profile and optimization power usage in your app) - WWDC25 (0) | 2025.06.11 |
| 힙 메모리 분석하기(Analyze heap memory) - WWDC24 (3) | 2025.06.03 |