Instruments를 사용하여 CPU 성능 최적화하기 (Optimize CPU performance with Instruments) - WWDC25
Instruments를 사용해 Apple 실리콘 CPU에 맞게 최적화하는 방법을 알아보자.
CPU 리소스를 효율적으로 사용하면 앱에서 대량으로 데이터를 처리하거나 사용자의 인터랙션에 빠르게 응답해야 할 때 눈에 띄는 대기시간을 피할 수 있음.
목차
- Performance midset
- CPU에 맞게 코드를 최적화하는 방법에 대해서 알아볼 것.
- 우선, 데이터를 기반으로 성능 조서에 접근하는 방법을 검토하여 가장 큰 잡재적 속도 향상에 초점을 맞추고자 함.
- Profilers
- 코드에서 과도한 CPU 사용량을 식별하는 데 좋은 첫 단계인 프로파일링 접근 방식을 살펴볼 것
- Processor Trace
- 더욱 심층적으로 살펴보고 프로파일링의 공백을 메우기 위해 모든 명령어를 기록하고 SW 추상화 비용을 측정
- Bottleneck analysis
- 마지막으로 개선된 CPU 카운터 도구를 사용해 CPU 병목 현상을 분석하고 알고리즘을 미세하게 최적화하는 방법을 알아볼 예정.
SW 성능을 예측하는건 두가지 이유로 어려움.

첫번째 이유는 Swift 소스 코드와 실제 실행되는 코드 사이의 추상화 계층
- 앱에 작성한 코드는 최종적으로 CPU에서 실행되는 기계어 명령어로 컴파일 됨.
- 하지만, 코드는 격리되어 실행되지 않고, Swift Runtime, System Framework, Kernel system call을 호출할 수 있음.
- 이로 인하여 코드에 의존하는 소프트웨어 추상화의 비용을 실제로 아는건 어려움.

두번째 이유는 CPU가 주어진 명령을 어떻게 수행하는가에 달려 있음.
- 하나의 CPU 내에서는 기능적으로 병렬로 동작하여 효율적으로 명령을 실행
- 이를 지원하기 위해 명령어는 순서 없이 실행되므로 순서대로 실행되는 것처럼 보일 뿐.
- CPU는 데이터 대한 빠른 액세스를 보장하는 여러 계층의 메모리 캐시를 활용
특히 "CPU는 데이터 대한 빠른 액세스를 보장하는 여러 계층의 메모리 캐시를 활용"하는 특성은
메모리를 선형적으로 스캔하거나 특정 조건에 대한 방어적으로 검사하는 로직을 작성하는 코딩 패턴을 성능 최적화함.
/// 방어적 로직
guard let data else { return }
/// 선형
array.forEach { _ in }
하지만 특정한 데이터 구조, 알고리즘 또는 구현 방식은 신중한 최적화나 상당한 구조의 조정 없이는 CPU가 효율적으로 실행하기 어려움.
/// 랜덤접근
if Bool.random() {
engage()
}
Performance midset
성능 조사에 접근하기 위해 올바른 mindset에 대해서 알아볼 것

첫번째, 열린 마음을 갖기.
- 성능 둔화의 원인은 예상치 못한 것에서 비롯될 수 있음.
- 가설을 테스트하고, 코드가 실행되는 방법에 대한 모델이 정확한지 데이터를 수집함.

예를 들어 단일 스레드의 CPU 성능 외 속도 저하의 다른 원인이 있는지 생각해보기
- CPU에서 실행되는거 말고도 스레드 작업은 파일이나 공유하는 변경 가능한 상태에 접근과 같은 리소스를 기다리며 Blocking 시간을 보낼 수 있음.
- 스레드가 차단되지 않은 상태이 있는 경우에는 코드에 의해 Misuse(오용)하고 있거나, 암묵적으로 너무 많은 스레드를 생성하는 등 API가 잘못 사용되고 있을 가능성도 존재함.
- 효율성이 문제라면 알고리즘과 관련된 데이터 구조를 변경하거나, 구현을 변경해야 함.
- Insturments를 사용해 해당 그래프에서 어떤 포인트에 가장 집중해야 하는지 결정해야함.

CPU Gauges: Xcode에 기본적으로 제공되어서 사용자의 상호작용으로 인한 CPU 분석에 활용
System Trace: 스레드간 차단 동작을 분석하고 어떤 스레드가 궁극적으로 차단을 해제할지 알아볼 때 사용
Hangs: UI나 앱의 메인 스레드에 영향을 미치는 문제의 경우 사용

Instruments를 따르더라도 구현하는 유형에는 주의해야 함.
너무 많은 미세한 최적화로 인해 코드를 확장하고 추론하기가 더 어려워질 수 있음.
방해가 되는 Micro-optimization을 수행하기 전에 느린 작업을 아예 피할 수 있는 대안을 찾아보자.
즉, 느린 코드에 근본적으로 실행되는 이유에 대해 고민해볼 것.

코드를 아예 삭제하는 것으로 그 지점에 문제가 있는 가설을 확인할 수 있는 방법 중 하나임.
아니면 결과가 누군가에게 표시될 때만 작업을 수행할 수도 있음.
혹은 같은 맥락에서 미리 계산된 값은 작업을 완료하는게 걸리는 시간을 숨길 수도 있음.
동일한 입력을 사용하여 작업을 반복하는 경우에는 캐싱을 통해 안정적으로 제공할 수도 있음.
물론, 캐싱은 메모리 사용량 증가, 캐시 무효화 등의 어려운 문제가 존재하긴 하지만, 성능이 눈에 띄게 나타나는 작업을 CPU가 여러번 반복하는 것을 피하기 위해 고려될 수 있음.

즉, 사용자 경험에 가장 큰 영향을 미치는 코드를 위주로 최적화의 노력을 우선적으로 기울여야 함.

점진적으로 최적화를 하고 각 단계에서 잘 진행되고 있는지 확인하자.
즉, 변경할 때마다 크게 개선되어야 한다고만 생각하지 말것.
특히, 일부 최적화의 경우에는 정량화하기 어려울 수 있지만, 이러한 작은 개선사항은 시간이 지나면서 점점 더 누적될 것임.

정확한 정보들은 필요하지 않고, 단순하게 성능에 대한 추정치만 알고 싶을 뿐.
- ContinuousColck은 Date보다 오버헤드가 낮으며, 뒤로 돌아갈 수도 없음.

특정 이름을 통해 1초동안 검색을 실행하는 테스트 코드.
해당 코드는 내부의 바이너리 서치를 활용하여 해당 부분에 시간 소모 비용이 상각함.

해당 코드를 CPU 이진 탐색의 CPU 성능을 분석하기 위해 Insturments 프로파일러에서 해당 테스트를 실행해 봄.
CPU 중심의 프로파일러는 Time Profiler와 CPU Profiler 두 가지가 존재함.

Time Profiler: 클래식한 도구로 시스템의 CPU에서 실행중인 작업을 주기적으로 샘플링
해당 프로파일러는 주기적으로 계측하며, 샘플을 Call Tree 등에서 Flame Graph로 시각화하여 제공해 CPU 성능을 ㅍ최적화하는 데 중요한 코드가 무엇인지 대략적으로 파악할 수 있음.
이는 작업이 시간에 따라 어떻게 분포되는지 또는 어떤 스레드가 동시에 활성화 되어 있는지 유용

하지만, 타이머를 사용하여 호출 스택을 샘플링하면 이라는 Aliasing이라는 문제 발생
- Aliasing
- 앨리어싱은 시스템에서 일부 주기적으로 발생하는 작업이 샘플링과 동일한 주기로 발생하는 경우
즉, 샘플링 주기가 우리가 사용하는 특정 코드의 주기와 겹친다면 결과에서는 주황색 부분이 비대하게 발생하는 것처럼 나타남.

이 문제를 피하려면 CPU Profiler로 전환할 수 있음.
각 CPU의 Clock 주파수를 기준으로 CPU를 독립적으로 샘플링함.
CPU 최적화를 위해서는 Time Profiler보다 CPU Profiler를 선호하는 것이 좋은데, CPU 리소스를 더 정확하게 계산하게 공정하게 가중치를 부여하기 때문.

해당 이미지에서 종은 CPU 사이클 카운터가 실행중인 호출 스택을 샘플링할 때를 나타냄.
Apple 실리콘 CPU는 비대칭적이며, 그 중 일부는 다른 CPU보다 느리지만, 전력 효율이 더 높은 클록 주파수로 실행됨.
개별 CPU의 주파수를 높이면 더 자주 샘플링되며,

오버헤드를 낮추기 위해 Recoder 세팅에서 Deferred로 설정

Test Runner 영역을 확인하면 해당 부분에서 CPU Profile을 확인할 수 있음.
즉, 내가 수행한 특정 코드의 성능을 체크할 때 유용

옵션을 눌러 확장할 때, 샘플수가 크게 달라지는 지점까지 열림
각 함수는 샘플 수에 각 샘플 간 사이클 수를 곱해서 가중치가 부여됨

1. protocol witness는 전체 샘플에 1/4을 차지함
2. Objective-C 유형에는 할당과 Array 검사 기능이 있는데, 우리가 검색하는 데이터 종류에 맞는 컨테이너로 변환하면 Array와 Generic 오버헤드를 피할 수 있음.

Element가 메모리에 연속적으로 저장되어 있는 경우 Collection 대신 Span을 사용할 수 있으며 이는 많은 종류의 데이터 구조에서 흔한 일임.
이는 사실상 Base 포인터와 Element의 수임.
Span을 이용하면 함수 외부에서 메모리 참조가 이스케이프되거나 누수되는 것도 방지할 수 있음.

Span을 채택하려면 hayStack과 같은 반환타입에 대해서만 Span으로 변경하면 되고, 알고리즘 자체는 변경되지 않음.
Span을 사용함으로써 4배이상 빨라짐 (애플이 그렇다고 함.)

하지만 이 버전의 바이너리 서치는 여전히 앱에 영향을 미쳐서 Span의 bounds checking 오버헤드에 어떤 영향을 미치는지 검사하고자 함.
이를 더 자세히 알아보기 위해서 Processor Trace(Xcode 16.3에 새로 등장)를 사용
앱 프로세스가 사용자 공간에서 실행하는 모든 명령의 전체 추적을 수집할 수 있음.

이는 소프트웨어 성능을 측정하는 데 있어 근본적인 변화임.
샘플링 편향이 없고, 앱 성능에 미치는 영향이 1% 미만에 불과함
프로세서를 추적하기 위해서는 M4가 탑재된 Mac 혹은 iPad Pro나 A18칩 이상의 iPhone에서만 사용할 수 있는 CPU 기능이 필요함.
가장 효과적으로 사용하기 위해서는 추적시간을 몇 초로 제한.
CPU Profiler를 사용하여 샘플링하는 것과 달리 작업을 일괄 처리할 필요가 없음.
최적화하려는 코드의 인스턴스가 하나뿐이라도 충분함.

프로세서 추적은 많은 양의 데이터를 처리해야 하므로 이를 캡처하고 분석하는데 시간이 걸릴 수 있음.
프로세서 추적은 CPU가 모든 분기 결정을 기록하도록 설정.
사이클 수와 현재 시간도 기록되어 CPU가 각 기능에 소요되는 시간을 추적
그런 다음 Instuments는 앱과 시스템 프레임워크의 Excutable binaries를 사용하여 실행 경로를 구성하고 경과된 사이클과 시간 시간을 함수 호출에 주석으로 표시함.
추적에 소요되는 시간을 제한하는 이유는 CPU가 가능한 적은 정보를 기록하더라도 멀티스레드 애플리케이션의 경우 초당 기가바이트 규모의 데이터가 기록될 수 있기 때문에임.

flame graph는 함수 비용과 관계를 그래픽으로 표현한 것.
각 색상은 해당 막대가 속한 바이너리의 종류를 나타냄.
갈색은 시스템 프레임워크
보라색은 Swift Runtime과 표준 라이브러리
파란색은 앱의 바이너리나 사용자 정의 프레임워크에 컴파일 된 코드

프로세서 분석의 힘은 단 수백 나노초 동안만 실행되는 단일 함수에서 이루어진 모든 호출을 볼 수 있음.
타임라인 아래에 있는 함수 호출 Summary를 사용해볼 것.
bounds checking이 속도 저하를 초래한다는 초기 가설은 틀렸음.
바이너리 서치를 구현하더라도 여전히 프로토콜 메타데이터 오버헤드를 처리해야 하며, 숫자 비교를 인라인으로 처리할 수 없음.
결국 이는 검색의 총 사이클 수에서 상당한 비율을 차지하게 됨.
이는 일반적인 Comparable 매개변수가 사용되는 Element Type에 맞게 특수화되지 않았기 때문임

@inlinable을 사용해서 클라이언트의 바이너리 실행 파일 내에서 특수 구현을 생성해야 함.
하지만 인라이닝을 사용하면 코드가 호출자랑 섞이기 때문에 코드를 분석하기가 더 어려워질 수 있음.
이 함수의 경우에는 앱에서 사용하는 Int 유형에 맞게 수동으로 특수화하고

이렇게 변경함으로써 코드는 제네릭을 통한 일반성을 많이 잃어버렸지만, 코드의 속도는 약 1.7배 빨라짐.
그럼에도 불구하고 바이너리 서치가 앱 속도 저하에 여전히 영향을 미치고 있으므로 최적화를 계속하고자 함.

CPU Counter 도구를 사용해 코드가 CPU에서 실행될 때 어떤 병목현상이 발생하는지 알아낼 수 있음.
Instruments를 사용하기 전에 CPU가 동작하는 방식에 대해서 리마인드 하자.
기본적으로 CPU는 명령어 목록을 따르고 레지스터와 메모리를 수정하고 주변 장치와 상호작용 함.
CPU가 실행될 때는 일련의 단계를 따름. 대체로 2가지 단계로 나뉨

CPU에 실행할 명령어가 있는지 확인하기 위한 명령어 전달를 전달하고 이를 처리함
이러한 과정을 연속적으로 실행해야 한다면 속도가 매우 느릴 것이므로 애플 실리콘 프로세서는 파이프라인 방식으로 구성됨

한 영역에서 작업이 끝나면 다음 작업으로 넘어가 각각이 다음 작업을 수행할 수 있음.

파이프라인을 실행하고 각각의 복사본을 만드는 작업은 명령어 수준에서 병렬 처리를 지원함

Swift Concurrency나 GCD에서 접근하는 프로세스 또는 스레드 수준의 병렬 처리와는 다름.
여기서는 여러 CPU가 서로 다른 운영체제 스레드를 실행함.
명령어 수준 병렬 처리를 실행하면, 단일 CPU가 유닛이 idle 상태일 수 있는 시간을 활용해 하드웨어 리소스를 효율적으로 사용하고, 파이프라인의 모든 부분을 busy하게 유지할 수 있음.

Swift 소스 코드는 이러한 병렬 처리를 직접 제어하진 않지만 대신 컴파일러가 수용 가능한 일련의 명령을 생성하는데 도움을 주어야 함.
하지만, CPU의 각 유닛간 상호 작용으로 직관적으로 이해할 수 없음.
모든 단위 사이의 모든 화살표는 파이프라인에서 작업이 중단될 수 있는 곳을 보여주며 이는 사용 가능한 병렬 처리를 제한함.
이를 병목현상이라고 부름

관련된 병목현상이 무엇인지 알아내기 위해서는 Apple Silicon은 실행되는 명령어의 다른 특성을 계산할 수 있음.
올해에는 이러한 카운터에 사전 설정 모드를 추가해 사용이 훨씬 편리해짐.
이를 활용해 명확한 함수 호출 오버헤드가 없음에도 불구하고 바이너리 서치가 왜 느린지 알아볼 것임.

CPU 병목 현상 모드는 CPU가 수행하는 작업을 CPU의 잠재적 성능을 모두 설명하는 네가지 범주로 나눔.
CPU 병목 현상 위에 마우스를 올리면 Discarded Bottleneck의 비율이 높다는 것을 보여줌.

해당 부분을 클릭하면 주석을 표시한 부분을 알려줌.
하지만 여전히 어떤 부분이 원인인지는 자세히 알기 어려움.
이미지의 [다른 모드]를 클릭하면 해당 모드는 CPU 병목현상과 조금 다른데, 여전히 카운터 데이터를 수집하고 있지만, 샘플링을 트리거하기 위해 카운터를 설정하고 있음.
샘플 데이터는 Discard 작업을 생성하는 명령어에만 제한됨.

이렇게 세팅할 경우 이는 호출 스택이 아니라 문제를 일으키는 지점에 대한 정확한 명령어를 알려줌.
함수 이름 옆에 화살표를 클릭하면 소스 뷰어를 열 수 있고, CPU가 샘플링 된 소스 코드를 확인할 수 있음.
여기 예제에서는 needle과 middleValue를 비교하는 것이 잘못 예측되었음.
왜 소스코드의 해당 라인이 그토록 잘못된 예측을 초래하는지 이해하려면 CPU에 대해서 좀 더 알아야 함.

CPU는 교활하게 명령을 순서 없이 실행함.

명령어가 완료되면 추가적인 재정렬 단계가 있기 때문에 명령어가 순차적으로 실행되는 것처럼 보임.
즉, CPU는 미리 예측한 다음에 어떤 명령어가 실행될지를 예측함.
이를 담당하는 분기 예측기는 대개 정확하지만 분기를 수행할지 여부에 대한 이전 실행의 일관된 패턴이 없는 경우에 잘못된 경로를 취할수도 있음.

해당 알고리즘에는 두가지 분기가 존재함.
첫번째 루프 조건은 일반적으로 루프가 끝날 때까지 적용되므로 예측성이 우수하고 샘플링에 나타나지 않음.
하지만 needle을 확인하는 것은 사실상 무작위 지점이기 때문에 예측자가 어려움을 겪는것도 당연함.

제어 흐름에 영향을 미치는 예측하기 어려운 분기를 피하기 위해 루프 본문을 다시 작성함.
(파란색 지점)
if 문은 이제 조건에 따라서만 값을 할당함.
이를 통해 Swift 컴파일러는 조건부 이동 명령어를 생성하고 다른 명령어로 분기하는 것을 방지할 수 있음.
(초록색 지점)
함수에서 반환하거나 루프를 끊는 것은 분기를 사용하여 구현해야 하므로 조기 반환도 제거해야 했음.
프로그램을 종료시킬 수 있는 분기를 피하기 위해 검사되지 않은 산술을 사용하여 이는 마이크로 최적화가 취약하고 방해받기 쉬운 영역중 하나이며 안정성이 이해성이 떨어지기도 함.

이런 변경을 할 경우에는 CPU 병목현상 모드로 돌아가서 나머지 병목현상에 대해 어떤 영향을 미치는지도 확인해야 함.
분기가 없는 바이너리 서치의 흔적을 수집했는데 분기가 없는 버전보다 약 2배 빠름.
하지만, 명령어 처리 부분에서 완벽히 병목 현상이 발생함.

Instruments에서는 명령어 처리 모드로 워크로드를 다시 실행해야 한다고 표시함.
해당 모드에서는 L1D 캐시 미스 샘플링 모드를 실행할 것을 권장하는 설명이 포함되어 있음.
캐시 미스 샘플은 배열에서 메모리에 액세스하는 것이 CPU가 효율적으로 명령어를 실행할 수 없는 이유임을 보여줌.
이유를 알아보기 위해 CPU와 메모리에 대해서 자세히 알아볼 것임.

CPU는 캐시 계층을 통해 메모리에 액세스하는데, 이를 통해 동일한 주소에 대한 반복적인 액세스나 예측 가능한 접근 패턴도 훨씬 빠르게 처리할 수 있음.

이는 각 CPU 내부에 위치한 L1 캐시부터 시작. 캐시는 많이 데이터를 처리할 수 없지만 메모리에 가장 빠르게 접근할 수 있음.
L2 캐시는 CPU 외부에 위치해 훨씬 더 많은 여유 공간을 제공함.
마지막으로 두 캐시에 모두 미스하고 주 메모리에 접근해야 하는 요청은 가장 빠른 경우보다 50배 더 느려짐.
이러한 캐시는 메모리를 캐시 라인이라고 하는 64바이트 또는 128바이트 세그먼트로 그룹화함.
명령어가 4바이트만 요청하더라도 캐시는 후속 명령어가 근처의 다른 바이트에 액세스해야할 것으로 예상하고 더 많은 데이터를 가져옴

이것이 바이너리 서치에서 어떤 영향을 미치는지 생각해보고자 함.
파란색 선: 배열의 요소
회색캡슐: CPU 캐시가 작동하는 캐시라인

배열은 캐시에서 시작되고, 첫번째 비교에서는 캐시라인과 여러 요소를 L1 데이터 캐시로 가져옴.
하지만 다음 비교에서는 캐시 미스가 발생함

그리고 이후 반복 잡업은 캐시에서 계속 누락됨.



즉, 캐시 라인의 크기의 영역으로 검색 범위가 좁혀질 때까지 계속 캐스가 미스되는 과정이 반복됨.
바이너리 서치의 문제는 CPU의 메모리 계층 구조에 대한 병적인 사례(pathlogical case)로 밝혀짐.
하지만 캐시에 더 친화적인 요소로 재정렬 할 수 있다면, 검색 지점을 동일한 캐시 라인에 배치할 수도 있음.
대신 이렇게 할 경우 순차 탐색 속도가 희생되어 검색 속도가 향상되고 해당 작업이 캐시에서 누락함
정렬된 배열을 Eytinger Layout으로 재정렬하는 방법을 보여주기 위해 바이너리 서치의 첫번째 예제로 돌아가 보자.

우선 바이너리 서치를 위한 데이터를 배열에서 트리로 모델링 함.


Eytinger Layout은 해당 트리의 BFS 방식으로 구성됨.
트리의 루트에 가까운 요소는 더 조밀하게 배열되며, 캐시 라인을 공유할 가능성이 더 높음

이 상태에서 5를 검색하면 처음 세 단계는 캐시라인에서 진행됨

리프 노드는 배열의 끝에 정렬되므로, 피할 수 없는 캐시 미스가 발생함.

이렇게 알고리즘을 바꾼 후 이진 탐색의 CPU 병목 현상을 추적 기록했는데 분기 없는 탐색보다 두배 더 빠르다는 것을 보여줌

하지만 이 예시는 흥미로운 점을 하나 더 보여주는데, 여전히 명령어 처리 측면에서 기술적으로 병목 현상이 발생함.
구현을 캐시 친화적으로 만들었지만 작업 부하는 여전히 본질적으로 메모리에 의존함.

앱의 다른 코드를 언제 중지하고 최적화해야 할지 알기 위해 성능을 모니터링 해야함.
왜냐하면 이제 검색이 중요 경로에 성능에 더 이상 영향을 미치지 않기 때문에임.

이 과정을 거치면서 검색 기능이 크게 향상된 것을 볼 수 있음.
- 우선 CPU Profiler를 사용하면 Collection에서 Span으로 전환할 때 상당한 속도 향상을 얻을 수 있었음.
- 다음 Processor Trace는 특수화되지 않은 제네릭 오버헤드를 보여줌.
- 마지막으로 CPU 병목현상 분석을 통해 일부 마이크로 최적화를 통해 성능을 크게 향상시킴.
전반적으로 Instruments를 사용하여 검색 기능을 약 25배 더 빠르게 만들었음.
이러한 속도 향상을 달성하기 위해서는 우리는 올바른 사고방식으로 접근해 추상화 비용에 대한 부분을 확인함.
(참고)
https://www.youtube.com/watch?v=li0TMRDzyU8
Analyze hangs with Instruments
https://developer.apple.com/videos/play/wwdc2023/10248/
Tuning your code’s performance for Apple siliconom
https://developer.apple.com/documentation/apple-silicon/tuning-your-code-s-performance-for-apple-silicon
Tuning your code’s performance for Apple silicon | Apple Developer Documentation
Improve your code to get the best performance from both Apple silicon and Intel-based Mac computers.
developer.apple.com
'apple > WWDC' 카테고리의 다른 글
| Swift 동시성 사용하기 (Embracing Swift Concurrency) - WWDC25 (0) | 2025.06.21 |
|---|---|
| Swift로 메모리 사용량 및 성능 개선하기 (Improve memory usage and performance with Swift) - WWD (1) | 2025.06.15 |
| 앱의 전력 사용량 프로파일링 및 최적화하기 (Profile and optimization power usage in your app) - WWDC25 (0) | 2025.06.11 |
| 힙 메모리 분석하기(Analyze heap memory) - WWDC24 (3) | 2025.06.03 |
| Modern Cell configuration - WWDC21 (0) | 2025.05.17 |