
-
Instruments를 사용하여 CPU 성능 최적화하기
Instruments에서 두 개의 새로운 하드웨어 지원 도구로 사용해 앱을 Apple Silicon에 맞게 최적화하는 방법을 알아보세요. 먼저 앱을 프로파일링하는 방법을 소개한 다음, Processor Trace로 호출되는 모든 기능을 자세히 살펴봅니다. 또한 CPU Counter 모드를 사용하여 코드의 CPU 병목 현상을 분석하는 방법을 논의합니다.
챕터
- 0:00 - 서론 및 어젠다
- 2:28 - 성능 개선을 위한 자세
- 8:50 - 프로파일러
- 13:20 - Span
- 14:05 - Processor Trace
- 19:51 - 병목 분석
- 31:33 - 요약
- 32:13 - 다음 단계
리소스
- Analyzing CPU usage with the Processor Trace instrument
- Apple Silicon CPU Optimization Guide Version 4
- Performance and metrics
- Tuning your code’s performance for Apple silicon
관련 비디오
WWDC25
WWDC24
WWDC23
WWDC22
-
비디오 검색…
안녕하세요, 저는 OS 커널 엔지니어 Matt입니다 Instruments로 Apple Silicon CPU용 코드 최적화를 설명합니다 CPU 리소스를 효율적으로 사용하면 앱이 대량의 데이터를 처리하거나 상호작용에 신속하게 응답해야 할 때 눈에 띄는 대기 시간을 없앨 수 있습니다 하지만 소프트웨어 성능 예측은 두 가지 이유로 어렵습니다 첫째는 Swift 소스 코드 사이의 추상화 계층과 결국 실제로 실행되는 항목입니다 앱에 대해 작성한 소스 코드는 최종적으로 CPU에서 실행되는 기계 명령어로 컴파일됩니다 하지만 코드는 격리되어 실행되지 않고 컴파일러에서 생성된 지원 코드, Swift 런타임, 기타 시스템 프레임워크를 통해 증강되므로 앱을 대신해 권한이 있는 연산을 처리하기 위한 커널 시스템 호출을 유발할 수 있습니다
코드가 의존하는 소프트웨어 추상화의 비용을 알기 어려워지죠 코드 성능을 예측하기 어려운 두 번째 이유는 CPU가 주어진 명령어를 수행하는 방식이기 때문입니다 단일 CPU 내에서 기능 단위는 병렬로 작동해 명령어를 효율적으로 실행합니다 이를 지원하기 위해 명령어는 순서 없이 실행되며 그저 순서대로 실행되는 것처럼 보일 뿐입니다 CPU는 또한 빠른 데이터 접근을 실현하는 여러 메모리 캐시 계층의 이점을 누립니다 이러한 특성은 메모리를 통한 선형 스캔이나 드문 상황에서 조기 종료 등의 방어적 검사와 같은 일반적인 코딩 패턴을 극적으로 가속합니다 하지만 일부 데이터 구조 알고리즘 또는 구현 접근 방식은 신중한 최적화나 대대적인 재구조화 없이는 CPU가 효율적으로 실행하기 어렵습니다 CPU에 맞게 코드를 최적화하는 올바른 방법을 알아보겠습니다 우선 데이터를 기반으로 가장 큰 속도 향상 가능성에 먼저 집중하는 성과 조사 접근 방법을 검토합니다 그런 다음 코드에서 과도한 CPU 사용량을 식별하는 데 매우 좋은 첫 단계인 기존 프로파일링 접근 방식을 봅니다 더 깊이 파고들어 프로파일링의 빈틈을 메우고자 Processor Trace를 사용해 모든 명령어를 기록 후 소프트웨어 추상화의 비용을 측정합니다 마지막으로 개선된 CPU Counters instrument를 사용하여 CPU 병목 현상을 분석하고 알고리즘 미세 최적화 방법을 이해합니다 먼저 성과 조사에 접근하는 올바른 사고방식을 장착해 볼까요 첫 번째 단계는 열린 마음가짐이죠 속도 저하의 원인은 예상치 못한 것일 수 있습니다 가정을 테스트하기 위해 데이터를 수집하고 코드 실행 방식에 대한 심리적 모델이 정확한지 검증합니다
예를 들어 단일 스레드 CPU 성능 외에도 속도 저하의 다른 원인을 고려합니다 CPU에서 실행하는 것 외에 스레드와 작업은 파일과 같은 리소스를 대기하거나 공유된 가변 상태에 접근하면서 차단된 상태로 시간을 보낼 수 있습니다 “Swift 동시성 시각화 및 최적화하기” 세션에서 작업이 CPU를 차지하지 않는 이유를 이해하는 도구를 다룹니다
스레드가 차단 해제되고 CPU에 있을 때 API가 잘못 사용되고 있을 가능성이 있습니다 코드에 잘못된 서비스 품질 클래스를 적용하거나 너무 많은 스레드를 묵시적 생성하는 거죠 자세한 내용은 코드 성능 튜닝하기 문서를 읽어 보세요 하지만 효율성이 문제라면 알고리즘과 관련 데이터 구조 또는 구현을 변경해야 합니다 구현이란 알고리즘이 프로그래밍 언어로 표현되는 방식입니다 도구를 사용해 이 트리에서 먼저 집중할 분기를 판단합니다 Xcode의 내장 CPU Gauge를 사용해 앱과 상호작용할 때 CPU가 많이 사용되는지 확인합니다 스레드 간의 차단 동작을 분석하고 궁극적으로 이를 차단 해제하는 스레드를 분석하려면 System Trace instrument를 사용합니다
UI 또는 앱의 메인 스레드에 영향을 미치는 문제의 경우 특수 Hangs instrument를 사용합니다 Instruments로 행 분석하기 세션에 앱 CPU 사용량 최적화가 필요한지 확인하는 방법에 대한 자세한 내용이 있습니다 하지만 도구의 도움을 받더라도 구현의 최적화 유형에 대해 주의해야 합니다 무작정 미세하게 최적화하면 코드를 확장하고 추론하기가 더 어려워질 수 있습니다 이때 흔히 의존하게 되는 방식인 자동 벡터화, 참조 횟수 생략 등의 컴파일러 최적화는 취약성이 있을 수 있습니다 침습적인 미세 최적화를 수행하기 전에 느린 연산 자체를 피할 수 있는 대안을 찾으세요 애초에 코드가 실행되는 이유를 고려하세요 작업 자체가 필요 없어 코드를 삭제하면 끝일 수도 있죠 그럼 이것으로 세션을 마무리... 농담이고요 진지하게 말씀드리면 이렇게 하기는 보통 불가능하지만 작업 결과의 중요성에 대한 가정을 확인하기에는 좋습니다
나중에 중요 경로 외부에서 또는 결과가 다른 사람에게 표시될 때만 작업을 시도해 볼 수도 있습니다 같은 맥락에서 값을 사전 계산하면 작업을 완료하는 데 소요되는 시간을 숨길 수도 있고요 여기에는 빌드 시간에 값을 베이킹하는 것이 포함될 수도 있죠 하지만 이러한 접근 방식은 불필요하게 전력을 소모하거나 앱의 다운로드 크기를 늘릴 수 있습니다 같은 입력으로 동일한 연산을 반복한다면 캐싱도 솔루션이지만 캐시 무효화, 메모리 사용량 증가 대처 등 캐싱만의 까다로운 문제가 흔히 따릅니다 작업 성능이 눈에 띄는 수준이고 이 작업을 피하려는 시도가 효과가 없다면 CPU의 작업 속도를 높여야 합니다 오늘 이 부분에 집중할 것입니다 사용자 경험에 가장 큰 영향을 미치는 코드에 최적화 노력을 우선 집중하는 거죠 이러한 코드는 일반적으로 사용자와 앱의 상호작용에 중요한 경로와 관련 있어 성능 문제가 눈에 띄는 코드지만 전력을 소모하는 장시간 실행 연산일 수도 있습니다 이 세션에서는 제 앱의 중요 경로인 준비된 정수 목록을 통해 검색하는 데 집중하겠습니다
제 앱은 이미 이진 검색을 사용 중인데 정렬된 배열을 사용해 연속으로 검색 공간을 반으로 줄여 요소를 찾는 클래식 알고리즘이죠 이 예에서는 배열에 요소 16개가 있고 숫자 5가 있는 요소를 찾고 있습니다 5는 배열 중간의 요소 20보다 작으므로 요소가 전반부에 있을 것입니다 5는 또한 전반부 중간의 요소 9보다 작으므로 배열의 첫 1/4 안에 있을 것입니다 단 4단계 만에 3과 비교하여 일치하는 요소로 범위를 좁힙니다 이것이 제 앱에서 사용하는 프레임워크의 이진 검색 구현인데 독립형 함수로서 건초 더미에서 바늘을 찾는다는 은유를 사용해 매개변수의 이름을 지정합니다 건초 더미 Collection에서 Comparable 바늘을 검색할 수 있죠 이 알고리즘은 두 가지 변수를 추적합니다 start에서 현재 검색 영역의 시작과 length에서 검색해야 할 남은 요소의 수입니다 검색할 요소가 남아 있으면 검색 공간의 중간 값을 확인합니다 바늘이 값보다 작으면 검색 공간을 반으로 줄이되 start는 그대로 둡니다 바늘이 equal이면 요소를 찾은 것이며 중간 인덱스가 반환됩니다 그렇지 않으면 시작 위치를 중간 위치 직후로 조정해야 하며 검색 공간이 반으로 줄어듭니다
이 알고리즘을 점진적으로 최적화할 준비를 하겠습니다 각 단계에서 검색 처리량 또는 알고리즘이 매초 완료할 수 있는 검색 수를 비교해 진전을 있음을 확인하는 거죠 변경 사항을 적용할 때마다 큰 도약이 필수라고 생각지 마세요 정량화가 어려운 최적화도 있고 작은 개선이 모이면 효과가 큽니다
지속적인 최적화를 위해 검색 처리량을 측정하는 자동화 테스트를 작성했습니다 특별히 철저한 설정은 필요하지 않고 성능 추정치만 얻으면 됩니다 이 repeat-while 루프는 지정된 기간이 경과할 때까지 검색 클로저를 호출합니다 검색 클로저에 대한 호출 주위에 OS 표지 간격을 사용해 도구가 최적화 대상 테스트 부분에만 집중하도록 합니다 Instruments에 기본적으로 포함된 관심 지점 카테고리를 선택했고요 타이밍 자체는 Date와 달리 ContinuousClock을 사용하는데 뒤로 돌아갈 수 없고 오버헤드가 낮죠 알고리즘 성능에 대한 대략적인 데이터를 수집하는 데 간단하지만 효과적인 접근 방식입니다 이 searchCollection 테스트는 앱의 이진 검색 사용 방식을 시뮬레이션하는데 설명식 이름으로 1초 표지 검색을 실행하겠습니다 단일 기록에서 여러 테스트를 실행할 때를 대비해서요 클로저 내의 루프가 이진 검색 함수를 호출해 시간 확인의 비용을 분할 상환합니다 Instruments 프로파일러에서 이 테스트를 실행해 이진 검색의 CPU 성능을 분석하겠습니다 선택 가능한 CPU 중심 프로파일러는 두 가지인데 Time Profiler와 CPU Profiler입니다 클래식 Time Profiler instrument는 타이머에 기반해 시스템의 CPU에서 실행 중인 내용을 주기적으로 샘플링합니다 이 예에서는 CPU 두 개에서 몇몇 작업이 진행됩니다 각 샘플 지점에서 Time Profiler는 CPU에서 실행되는 각 스레드의 사용자 공간 호출 스택을 캡처하죠
Instruments는 이러한 샘플을 세부 정보 보기에 호출 트리 또는 Flame 그래프로 시각화해 CPU 성능 최적화에 중요한 코드를 근사치로 나타냅니다 시간 경과에 따라 작업이 배포되는 방식 또는 동시에 활성 상태인 스레드를 분석하는 데 유용합니다 하지만 타이머로 호출 스택을 샘플링하면 문제가 발생합니다 바로 앨리어싱인데 시스템에서 일부 주기적 작업이 샘플링 타이머와 동일한 주기로 발생하는 것입니다 여기서 파란색 영역은 대부분의 CPU 시간에 해당하지만 주황색 함수는 샘플러가 호출 스택을 수집할 때마다 실행됩니다 때문에 Instruments 호출 트리에서 주황색이 부당하게 과대 표현되죠
이 문제를 피하려면 CPU Profiler로 전환하면 됩니다 각 CPU의 클럭 주파수를 기준으로 CPU를 독립적으로 샘플링하는데요 CPU 최적화에는 Time Profiler보다 CPU Profiler를 우선해야 합니다 더 정확하고 CPU 리소스 소모 소프트웨어 가중치가 더 공정하죠
종은 CPU 사이클 카운터가 실행 중 호출 스택의 샘플링 시점입니다 Apple Silicon CPU는 비대칭이며 일부는 더 느리지만 전력 효율적인 클럭 주파수로 실행됩니다 주파수를 확장하는 개별 CPU는 더 빠르게 실행되는 CPU에 대한 Time Profiler의 편향 없이 더 자주 샘플링됩니다 CPU Profiler를 사용해 이진 검색 함수에서 CPU 사이클을 가장 많이 소모하는 부분을 확인하겠습니다 Xcode의 테스트 탐색기에서 단위 테스트의 Instruments를 빠르게 실행할 수 있습니다 테스트 이름을 보조 클릭하고 Profile 항목을 선택하면 됩니다 여기서는 Profile searchCollection을 선택합니다
그러면 Instruments가 열리고 템플릿 선택기가 나타납니다 CPU Profiler을 선택하고요 기록기 설정에서 낮은 오버헤드를 위해 지연 모드로 전환하고 기록을 시작합니다 프로파일러의 기본 즉시 모드는 앱과의 상호작용이 캡처되는지 확인하는 데 유용할 수 있습니다 하지만 Instruments와 동일한 컴퓨터의 자동화 테스트에서는 기록이 중지될 때까지 기다렸다가 분석하여 도구에 의해 추가될 수 있는 오버헤드를 최소화하고 싶습니다 Instruments의 새로운 문서는 흔히 까다로운데요 윈도우는 두 부분으로 나뉩니다 맨 위에는 타임라인의 활동을 보여 주는 트랙이 있습니다 트랙별로 수준이나 지역을 나타낸 차트가 딸린 행 여러 개가
있을 수 있고 타임라인 아래에는 조사 대상인 타임라인 범위에 대한 요약 정보가 포함된 세부 정보 보기가 있습니다 확장된 세부 정보는 오른쪽에 표시됩니다 방향을 잡기 위해 Points of Interest 트랙에서 검색이 발생하는 지역을 찾아보겠습니다 지역을 보조 클릭하면 검사 범위를 설정할 수 있어 표지 간격에서 수집된 데이터로 아래 세부 정보 보기가 제한됩니다 테스트 실행기 과정 트랙을 클릭하면 타임라인 아래 세부 정보 보기에 CPU 프로필이 표시됩니다 이 보기는 테스트의 함수 중 각 CPU의 사이클 카운터에서 샘플링한 함수의 호출 트리를 보여 줍니다 옵션을 누른 채로 나열된 첫 번째 함수 옆에 있는 V자형 기호를 클릭하면 샘플 수가 크게 달라지는 첫 번째 지점까지 트리가 확장됩니다 이진 검색 함수와 가까운 곳이죠 이진 검색 함수에 집중하고자 이름 옆의 화살표를 클릭하고 Focus on Subtree 항목을 선택합니다 각 함수에는 샘플 수에 각 샘플 간 사이클 수를 곱한 가중치가 부여됩니다 이 호출 트리는 이진 검색이 Collection 유형에 대응하고자 호출한 함수에서 얻은 다수의 샘플을 보여 줍니다 이 프로토콜 증인은 샘플의 약 1/4 지점에 나타납니다 할당이 있고 Objective-C 유형에 대한 Array의 검사도 있습니다 Array의 오버헤드와 제네릭을 피하려면 컨테이너 유형으로 전환하면 됩니다 우리가 검색 중인 데이터 종류와 더 잘 맞겠죠 새로운 Span 유형을 시도해 보겠습니다 요소가 메모리에 연속으로 저장되면 Collection 대신 Span을 사용할 수 있는데 다양한 데이터 구조에서 흔합니다 사실상 기본 주소와 개수이고요 하지만 탈출이나 누출과 사용되는 함수 외부의 메모리 참조도 방지합니다 Span에 대한 자세한 내용은 “Swift로 메모리 사용량 및 성능 개선하기” 세션을 시청하세요 Span 채택 시 건초 더미 및 반환 유형을 Span으로 변경하면 됩니다 알고리즘 자체는 변경되지 않죠
이 작은 변화로 검색 속도가 4배 빨라졌습니다 하지만 이 이진 검색 버전도 아직 앱에 영향을 미치고 있어 Span의 경계 확인이 오버헤드에 기여하고 있는지 조사하고 싶습니다 더 깊이 알아보고자 Processor Trace라는 새로운 도구로 전환하죠 Instruments 16.3부터 Processor Trace는 앱 프로세스가 사용자 공간에서 실행하는 모든 명령어의 완전한 추적을 수집 가능합니다 소프트웨어 성능 측정 방법에서 획기적인 변화에 해당합니다 표본 편향이 없고 앱 성능에 미치는 영향은 1%로 미미합니다 Processor Trace에는 M4 탑재 Mac 및 iPad Pro와 A18 탑재 iPhone에서만 사용 가능한 특수 CPU 기능이 필요합니다 시작하기 전에 프로세서 추적을 위해 기기를 설정해야 합니다 Mac에서는 Privacy & Security Developer Tools에서 설정을 켜고 iPhone 또는 iPad에서는 설정이 Developer 섹션에 있습니다 최상의 Processor Trace 경험을 위해 추적 시간을 몇 초로 제한하도록 하세요 CPU Profiler로 샘플링할 때와 달리 작업을 묶을 필요가 없습니다 최적화하려는 코드의 인스턴스가 하나만 있어도 충분합니다 Span 버전 이진 검색에서 Processor Trace를 실행해 보죠 이제 테스트에서 몇 번만 반복하면 됩니다 이 테스트를 프로파일링하기 위해 줄 번호 여백에 있는 테스트 아이콘을 보조 클릭하면 앞서 사용한 메뉴가 표시됩니다 탐색기 전환보다 편리하죠 Processor Trace 템플릿을
선택하고 기록을 시작합니다
Processor Trace에서 많은 데이터를 처리해야 하므로 캡처 및 분석에 시간이 다소 걸릴 수 있습니다 Processor Trace는 CPU가 모든 분기 결정을 기록하도록 설정하죠 사이클 수와 현재 시간도 기록해 CPU가 각 함수에서 소요하는 시간을 추적합니다 그런 다음 Instruments는 앱 및 시스템 프레임워크의 실행 가능한 바이너리를 사용해 실행 경로를 재구성하고 함수 호출에 경과 주기와 지속 시간을 주석 처리합니다 추적 소요 시간을 제한합니다 그 이유는 CPU가 최소한의 정보를 기록하더라도 다중 스레드 앱이면 초당 데이터가 기가바이트 단위일 수 있기 때문입니다 이제 문서가 준비되었으므로 확대해서 이진 검색 함수 호출을 검사하겠습니다 이제 검색이 전체 기록에서 아주 작은 부분만 차지하므로 타임라인 아래 세부 정보 보기의 Regions of Interest List에서 검색을 찾고 행을 보조 클릭 후 Set Inspection Range and Zoom을 선택합니다 이진 검색을 실행 중인 스레드를 찾고자 Start Thread 셀을 보조 클릭하고 Pin Thread in Timeline을 선택합니다
Processor Trace가 각 스레드 트랙에 새 함수 호출 Flame 그래프를 추가하는데 핀의 구분선을 끌어올려 공간을 만들죠
Processor Trace는 실행을 Flame 그래프로 시각적으로 보여 줍니다 Flame 그래프는 함수 비용과 관계를 그래픽으로 표현한 것이며 막대의 너비는 함수 실행에 걸린 시간을 나타내고 행은 중첩된 호출 스택을 나타내죠 하지만 대부분의 Flame 그래프는 샘플링에서 얻은 데이터를 표시하며 비용은 샘플 수를 기반으로 한 추정치입니다 Processor Trace의 타임라인 Flame 그래프는 좀 다릅니다 시간 경과에 따른 호출을 CPU에서 실행되었을 때와 똑같이 보여 줍니다 각 막대의 색상은 막대가 속한 바이너리의 종류를 나타냅니다 갈색은 시스템 프레임워크 자주색은 Swift 런타임 및 표준 라이브러리 파란색은 앱의 바이너리나 맞춤형 프레임워크에 컴파일된 코드입니다 이 추적의 첫 번째 부분은 표지를 발생시키는 오버헤드를 보여 주죠 범위의 끝부분에 있는 이진 검색 코드를 더 확대하겠습니다 확대하려면 옵션을 누른 채 클릭하고 타임라인을 드래그합니다
반복 10개 중 이진 검색 함수 호출을 선택하고 검사 범위를 설정한 후 보조 클릭으로 확대할 수 있습니다 Processor Trace의 유용한 점이죠 단 몇백 나노초 동안 실행되는 단일 함수에 의한 호출을 모두 볼 수 있습니다 더 확대할 수도 있지만 타임라인 아래의 Function Calls 요약을 사용해 보겠습니다 여기에는 타임라인과 동일한 정보가 표로 정리되며 짧은 시간 동안 호출되는 함수의 전체 이름이 표시됩니다 이 표를 사이클별로 정렬하죠
경계 확인이 속도 저하를 유발한다는 초기 가정이 틀렸네요 이진 검색 구현이 여전히 프로토콜 메타데이터 오버헤드를 처리하고 있으며 숫자 비교를 인라인 처리하지 못해 검색의 총 사이클 수에서 상당 부분을 차지하고 있습니다 제네릭 Comparable 매개변수가 사용 중인 요소의 유형에 대해 특수화되지 않았기 때문입니다
앱에 연결된 프레임워크에 코드가 있으므로 Swift 컴파일러가 호출자가 전달한 유형용 특수 이진 검색 버전을 생성할 수 없습니다
이로 인해 프레임워크의 코드에 오버헤드가 발생한다면 프레임워크의 함수에 inlinable 주석을 추가해 프레임워크 클라이언트의 이진 실행 파일 안에 특수 구현을 생성해야 합니다
하지만 인라인 처리하면 코드가 모든 호출자와 섞여 분석하기 어려워질 수 있습니다 테스트 하네스에서 인라인 처리를 피하고 싶으므로 이 함수의 경우 앱에서 사용하는 Int 유형에 대해 수동으로 특수화하고 새 함수 이름으로 테스트하죠 코드가 일반성을 크게 잃었지만 약 1.7배 더 빨라졌습니다 아직 이진 검색이 앱 속도 저하에 기여하고 있기 때문에 계속 최적화해야 합니다 함수 하나의 최적화에 이렇게 많은 시간을 보내다니 이상하죠 더 많은 데이터를 주기적으로 재평가 및 수집하다 보면 코드에서 효율 저하의 원인이 되는 다른 부분이 눈에 들어옵니다 Processor Trace에서도 특수 Span 이진 검색의 예상치 못한 함수 호출이 표시되지 않고 있으니 추가 진전을 위해서는 CPU에서 코드가 어떻게 실행되고 있는지 이해해야 합니다 CPU Counters instrument를 사용하면 CPU에서 실행할 때 코드가 직면하는 병목 현상을 확인할 수 있습니다 다시 Instruments를 사용하기 전에 CPU 작동 방식의 심리적 모델을 구축해야 합니다 기본 수준에서 CPU는 명령어 목록을 따르고 레지스터와 메모리를 수정하며 주변 기기와 상호작용합니다
CPU는 실행 중에 일련의 단계를 따라야 하며 이는 두 단계로 분류됩니다 첫 번째는 CPU에 실행할 명령어를 확보해 주는 Instruction Delivery 두 번째는 명령어를 실행하는 Instruction Processing입니다 Instruction Delivery에서는 명령어를 가져와 미세 연산으로 디코딩해 CPU가 실행하기 쉽도록 합니다 대부분의 명령어는 단일 미세 연산으로 디코딩되나 일부 명령은 메모리 요청 발행, 인덱스 값 증가 등 여러 작업을 수행합니다 미세 연산은 처리를 위해 지도 및 일정 단위로 전송되며 이 단위가 연산을 라우팅하고 디스패치합니다 이후 연산은 실행 단위에 할당되며 연산이 메모리에 접근해야 하면 로드-저장 단위에 할당됩니다
CPU가 이러한 단계를 연속적으로 실행한 후 다시 가져온다면 꽤 느릴 것이므로 Apple Silicon 프로세서는 파이프라인화되어 있죠 한 단위의 작업이 완료되면 다음 연산으로 넘어가는 방식으로 모든 단위가 계속 작동합니다
실행 단위의 파이프라인화 및 추가 사본 생성이 명령어 수준 병렬 처리를 지원합니다
Swift Concurrency나 Grand Central Dispatch로 이용 가능한 프로세스 또는 스레드 수준 병렬 처리와는 다릅니다 이때는 여러 CPU가 다양한 운영 체제 스레드를 실행하죠 명령어 수준 병렬 처리를 통해 단일 CPU로 단위가 유휴 상태일 때의 시간을 활용하고 하드웨어 리소스를 효율적으로 사용해 파이프라인 전체가 계속 작동하도록 할 수 있습니다 Swift 소스 코드는 이 병렬 처리를 직접 제어하지 않지만 컴파일러가 수용 가능한 일련의 명령어를 생성하도록 도와야 하죠
병렬 처리 가능 명령어 시퀀스는 단위와 CPU 간의 상호작용 때문에 즉시 직관적이지는 않습니다 단위 간 화살표는 파이프라인에서 연산이 중단될 수 있는 위치를 나타내는데 이 때문에 사용 가능한 병렬 처리가 제한됩니다 이것이 병목 현상입니다
워크로드와 관련된 병목 현상을 확인하려면 Apple Silicon CPU는 각 단위의 흥미로운 이벤트와 실행 중인 명령어의 기타 특성을 계산할 수 있습니다 CPU Counters instrument는 이러한 카운터를 읽어 상위 수준 지표를 구축합니다 올해 이 카운터에 프리셋 모드가 추가되어 사용하기 더 편리해졌죠 Instruments는 이를 안내형 반복적 방법론에서 사용하여 코드의 성능을 분석하는데 이것을 병목 현상 분석이라고 합니다 이를 사용해 눈에 띄는 함수 호출 오버헤드가 없는데도 이진 검색이 아직 느린 이유를 알아보죠 CPU Counters instrument는 워크로드 샘플링에 의존하므로 CPU Profiler에서 사용한 하네스로 처리량을 다시 측정해야 합니다
Instruments로 특수 Span 구현 테스트를 프로파일링하겠습니다
CPU Counters 템플릿을 선택하고요
측정할 엄선 모드를 갖춘 안내형 구성이 준비되었습니다
각 모드의 기능이 궁금하다면 모드 선택 옆의 정보 아이콘을 클릭해 문서를 확인하세요 계수를 시작할까요
이 초기 CPU Bottlenecks 모드는 CPU가 수행하는 작업을 CPU의 잠재적 성능 전체에 해당하는 광범위 카테고리 네 개로 분할하죠 Instruments는 이러한 범주를 세부 정보 보기에서 색상이 있는 적층형 막대 차트와 요약 표로 표시합니다 기록 중에 Instruments는 테스트에 사용된 스레드의 CPU 카운터 데이터를 수집하고 병목 현상 백분율로 변환합니다 아까처럼 Points of Interest를 사용해 방향을 잡고 확대해 검색을 선택합니다
그런 다음 이진 검색 구현 실행 스레드를 타임라인에 고정합니다
CPU Bottleneck 행을 마우스로 가리키면 폐기된 병목 현상의 백분율이 높게 표시됩니다 아래의 세부 정보 보기는 검사 범위의 지표 집계를 보여 줍니다 Discarded Bottleneck 행을 선택하면 오른쪽의 확장된 세부 정보 보기에 설명이 표시됩니다 Instruments는 또한 타임라인의 차트 위에 비고를 표시합니다 이 비고를 클릭하면 아래에 자세한 내용이 표시됩니다 유용하기는 하지만 검색에서 병목 현상의 원인인 부분을 아직 모릅니다 Suggested Next Mode 열 아래의 Discarded Sampling 셀을 보조 클릭하면 다른 모드로 워크로드를 다시 프로파일링할 수 있는 옵션이 제공됩니다 한번 해 보죠 이 모드는 CPU Bottlenecks와 약간 다릅니다 여전히 카운터 데이터를 수집 중이지만 카운터가 샘플링을 트리거하도록 설정하고 있습니다 샘플 데이터는 폐기된 작업을 생성하고 있는 명령어에만 국한됩니다 이를 보여 드리기 위해 Points of Interest로 다시 방향을 잡아 보죠
그런 다음 테스트 프로세스 트랙을
선택하고 타임라인 아래의 Instruction Samples로 이동합니다
이건 호출 스택이 아니라 문제를 유발하는 정확한 명령어입니다 함수 이름 옆의 화살표를 클릭해 Source Viewer를 열면 CPU가 잘못된 분기 방향을 따랐기 때문에 샘플링된 소스 코드가 표시됩니다 여기서 바늘과 중간 값 사이의 비교 예측이 부정확합니다 이 소스 줄이 잘못된 예측을 다수 유발하는 이유를 이해하려면 CPU에 대해 좀 더 알아야 합니다
CPU는 명령어를 순서 없이 실행한다는 점에서 까다롭습니다 명령어가 완료되면 추가 순서 재지정 단계가 있기 때문에 명령어가 순차적으로 실행되는 것처럼 보일 뿐입니다 즉 CPU는 다음으로 실행될 명령어에 대해 사전에 예측합니다 담당 분기 예측기는 일반적으로 정확하지만 분기 선택 여부에 대한 이전 실행의 패턴이 일관적이지 않으면 잘못된 경로를 취할 수 있습니다
이 이진 검색 알고리즘의 루프에는 두 가지 분기가 있습니다 첫 번째 루프 조건은 일반적으로 루프가 끝날 때까지 지속됩니다 따라서 예측이 잘 맞으며 샘플링에 나타나지 않았습니다 하지만 바늘을 확인하는 것은 사실상 무작위 분기입니다 따라서 예측기가 예측에 어려움을 겪습니다
제어 흐름에 영향을 미치는 예측하기 어려운 분기를 피하고자 루프 본문을 다시 작성했습니다 if 문의 본문은 조건에 따라 값을 할당하기만 합니다 따라서 Swift 컴파일러가 조건부 이동 명령어를 생성해 다른 명령어로 분기되는 것을 피할 수 있습니다 함수에서 반환되거나 조건에 따라 루프가 중단되는 경우를 분기에 구현해야 하므로 조기 반환도 없애야 했습니다 프로그램 종료 분기를 피하고자 미확인 산술을 사용했습니다 미세 최적화가 취약해지고 지장이 생기기 쉬운 영역이 여기입니다 안전성도 떨어지고 이해하기도 어렵죠 이러한 변경 사항을 적용할 때는 초기 CPU Bottlenecks 모드로 돌아가 나머지 병목 현상에 미치는 영향을 확인해야 합니다 분기 없는 새로운 이진 검색의 추적을 이미 수집했는데요 분기가 있는 버전보다 약 두 배 더 빠릅니다 이제 Instruction Processing에서 거의 병목 현상이 발생합니다 Instruments는 Instruction Processing 모드로 워크로드를 다시 실행해야 함을 알려 주며 이 모드에는
L1D Cache Miss Sampling 모드 실행을 권장하는 비고가 있습니다 캐시 미스 샘플은 배열의 메모리에 접근하는 것이 CPU에서 명령어를 효율적으로 실행하지 못하는 이유임을 보여 줍니다 이유를 확인하고자 CPU와 메모리에 대해 자세히 알아보겠습니다
CPU는 캐시 계층 구조를 통해 메모리에 접근하므로 같은 주소에 대한 반복 접근이나 예측 가능한 접근 패턴의 속도가 빨라집니다 시작점은 각 CPU에 있는 L1 캐시입니다 데이터를 많이 저장하지 못하지만 메모리에 가장 빨리 접근 가능하죠 더 느린 L2 캐시는 CPU 외부에 있고 헤드룸이 더 큽니다 두 캐시를 모두 놓치고 메인 메모리에 접근해야 하는 요청은 빠른 경로보다 50배 느려집니다 이러한 캐시는 메모리를 64/128바이트 세그먼트(캐시 줄)로 그룹화하는데 명령어가 4바이트만 요청해도 캐시는 후속 명령어가 근처 다른 바이트에 접근해야 한다고 예상하고 더 많은 데이터를 가져옵니다
이것이 이진 검색 알고리즘에 미치는 영향을 고려해 보죠 이 예에서 파란색 선은 배열의 요소이고 회색 캡슐은 CPU 캐시가 연산하는 캐시 줄입니다
배열은 완전히 캐시 외부에서 시작됩니다 첫 번째 비교는 캐시 줄과 여러 요소를 L1 데이터 캐시로 가져오죠 하지만 다음 비교에서 캐시 미스가 발생합니다 후속 반복도 캐시에서 계속 누락됩니다 검색이 캐시 줄 크기의 지역으로 좁혀질 때까지 계속되죠 이진 검색은 CPU의 메모리 계층 구조에서 병리학적 사례와 같습니다
하지만 캐시 친화적인 방식을 위해 요소 순서 재지정을 감수할 수 있다면 동일한 캐시 줄에 검색 포인트를 넣을 수 있습니다 이를 Eytzinger 레이아웃이라고 하는데 같은 방식으로 가계도를 정리한 16세기 오스트리아 계보학자의 이름을 따온 것입니다 중대한 결과 없이 적용할 수 있는 일반적인 최적화는 아닙니다 이 경우 순차적 순회 속도를 희생하여 검색 속도를 향상하면서 해당 연산이 캐시에서 누락됩니다 이진 검색의 첫 번째 예시로 돌아가서 정렬된 배열을 Eytzinger 레이아웃으로 재정렬하는 방법을 보여 드리죠 중간 요소를 루트로 시작해 이진 검색 연산을 트리로 모델링합니다 여기서 중간점은 하위 노드입니다 Eytzinger 레이아웃은 트리의 너비 우선 순회로 구성됩니다
트리의 루트에 가까운 요소는 더 조밀하게 배열되고 캐시 줄을 공유할 가능성이 더 높습니다 이제 5를 다시 검색하면 첫 세 단계는 같은 캐시 줄에서 진행되죠 리프 노드는 배열 끝에 정렬되어 피할 수 없는 캐시 미스가 발생합니다
Eytzinger 이진 검색의 CPU Bottlenecks 추적을 기록했는데 분기 없는 검색보다 두 배 더 빠르다는 결과가 나왔습니다 하지만 이 예시에서는 흥미로운 점이 눈에 띕니다 Instruction Processing에는 계속 병목 현상이 있다는 것이죠 구현을 캐시 친화적으로 만들었지만 워크로드가 여전히 본질적으로 메모리에 의존합니다
성능을 모니터링해 앱의 다른 코드를 중지하고 최적화할 시기를 확인해야 합니다 검색이 더 이상 중요 경로 성능에 영향을 미치지 않기 때문입니다 이번 과정을 거치면서 검색 처리량이 크게 향상되었습니다 먼저 CPU Profiler를 사용해 Collection에서 Span으로 전환하며 상당한 속도 향상을 달성했습니다
이후 Processor Trace는 비특수 제네릭의 오버헤드를 보여 주었죠 마지막으로 Bottleneck Analysis의 도움으로 미세 최적화를 통해 성능을 대폭 높였습니다 전반적으로 Instruments 덕분에 검색 함수가 약 25배 빨라졌습니다 이러한 속도 향상을 위해 올바른 사고방식으로 시작하여 도구를 사용해 추상화의 비용에 대한 추측을 확인하고 직관을 발전시켰습니다 예상치 못한 오버헤드를 찾고자 점점 더 세부적인 도구를 적용했죠 이러한 문제는 해결하기 쉽지만 측정 없이는 간과하기도 쉽습니다 그리고 소프트웨어 오버헤드가 해결된 후 CPU 병목 현상에 초점을 맞춘 최적화를 살펴보았습니다 당연하게 여겼던 CPU의 기능에 대해 더 인식하게 되었고 심지어 공감하게 되었습니다 이러한 순서가 중요했는데 CPU 중심 도구가 추가적인 소프트웨어 런타임 오버헤드로 인해 혼동을 겪어서는 안 됩니다
이를 여러분의 앱에 적용하려면 성능 중심 사고방식으로 데이터를 수집하고 단서를 따라가세요 Instruments로 반복 측정이 가능하게 성능 테스트를 작성하고 포럼에 도구 사용에 대한 피드백이나 질문을 올리세요 앞서 언급한 세션과 Swift 성능에 대한 WWDC24 세션을 시청하면 Swift의 강력한 추상화의 비용에 대한 심리적 모델을 더 정확히 구축하는 데 도움이 될 것입니다 CPU가 코드를 실행하는 방식을 더 잘 이해하려면 Apple Silicon CPU 최적화 가이드를 읽어 보세요 시청해 주셔서 감사합니다 Instruments로 코드의 건초 더미에서 최적화 바늘을 찾는 재미에 빠져 보시기 바랍니다
-
-
6:37 - Binary search in Collection
public func binarySearch<E, C>( needle: E, haystack: C ) -> C.Index where E: Comparable, C: Collection<E> { var start = haystack.startIndex var length = haystack.count while length > 0 { let half = length / 2 let middle = haystack.index(start, offsetBy: half) let middleValue = haystack[middle] if needle < middleValue { length = half } else if needle == middleValue { return middle } else { start = haystack.index(after: middle) length -= half + 1 } } return start }
-
7:49 - Throughput benchmark
import Testing import OSLog let signposter = OSSignposter( subsystem: "com.example.apple-samplecode.MyBinarySearch", category: .pointsOfInterest ) func search( name: StaticString, duration: Duration, _ search: () -> Void ) { var now = ContinuousClock.now var outerIterations = 0 let interval = signposter.beginInterval(name) let start = ContinuousClock.now repeat { search() outerIterations += 1 now = .now } while (start.duration(to: now) < duration) let elapsed = start.duration(to: now) let seconds = Double(elapsed.components.seconds) + Double(elapsed.components.attoseconds) / 1e18 let throughput = Double(outerIterations) / seconds signposter.endInterval(name, interval, "\(throughput) ops/s") print("\(name): \(throughput) ops/s") } let arraySize = 8 << 20 let arrayCount = arraySize / MemoryLayout<Int>.size let searchCount = 10_000 struct MyBinarySearchTests { let sortedArray: [Int] let randomElements: [Int] init() { let sortedArray: [Int] = (0..<arrayCount).map { _ in .random(in: 0..<arrayCount) }.sorted() self.randomElements = (0..<searchCount).map { _ in sortedArray.randomElement()! } self.sortedArray = sortedArray } @Test func searchCollection() throws { search(name: "Collection", duration: .seconds(1)) { for element in randomElements { _ = binarySearch(needle: element, haystack: sortedArray) } } } }
-
13:46 - Binary search in Span
public func binarySearch<E: Comparable>( needle: E, haystack: Span<E> ) -> Span<E>.Index { var start = haystack.indices.startIndex var length = haystack.count while length > 0 { let half = length / 2 let middle = haystack.indices.index(start, offsetBy: half) let middleValue = haystack[middle] if needle < middleValue { length = half } else if needle == middleValue { return middle } else { start = haystack.indices.index(after: middle) length -= half + 1 } } return start }
-
15:09 - Throughput benchmark for binary search in Span
extension MyBinarySearchTests { @Test func searchSpan() throws { let span = sortedArray.span search(name: "Span", duration: .seconds(1)) { for element in randomElements { _ = binarySearch(needle: element, haystack: span) } } } @Test func searchSpanForProcessorTrace() throws { let span = sortedArray.span signposter.withIntervalSignpost("Span") { for element in randomElements[0..<10] { _ = binarySearch(needle: element, haystack: span) } } } }
-
19:17 - Binary search in Span
public func binarySearchInt( needle: Int, haystack: Span<Int> ) -> Span<Int>.Index { var start = haystack.indices.startIndex var length = haystack.count while length > 0 { let half = length / 2 let middle = haystack.indices.index(start, offsetBy: half) let middleValue = haystack[middle] if needle < middleValue { length = half } else if needle == middleValue { return middle } else { start = haystack.indices.index(after: middle) length -= half + 1 } } return start }
-
23:04 - Throughput benchmark for binary search in Span
extension MyBinarySearchTests { @Test func searchSpanInt() throws { let span = sortedArray.span search(name: "Span<Int>", duration: .seconds(1)) { for element in randomElements { _ = binarySearchInt(needle: element, haystack: span) } } } }
-
26:34 - Branchless binary search
public func binarySearchBranchless( needle: Int, haystack: Span<Int> ) -> Span<Int>.Index { var start = haystack.indices.startIndex var length = haystack.count while length > 0 { let remainder = length % 2 length /= 2 let middle = start &+ length let middleValue = haystack[middle] if needle > middleValue { start = middle &+ remainder } } return start }
-
27:20 - Throughput benchmark for branchless binary search
extension MyBinarySearchTests { @Test func searchBranchless() throws { let span = sortedArray.span search(name: "Branchless", duration: .seconds(1)) { for element in randomElements { _ = binarySearchBranchless(needle: element, haystack: span) } } } }
-
29:27 - Eytzinger binary search
public func binarySearchEytzinger( needle: Int, haystack: Span<Int> ) -> Span<Int>.Index { var start = haystack.indices.startIndex.advanced(by: 1) let length = haystack.count while start < length { let value = haystack[start] start *= 2 if value < needle { start += 1 } } return start >> ((~start).trailingZeroBitCount + 1) }
-
30:34 - Throughput benchmark for Eytzinger binary search
struct MyBinarySearchEytzingerTests { let eytzingerArray: [Int] let randomElements: [Int] static func reorderEytzinger(_ input: [Int], array: inout [Int], sourceIndex: Int, resultIndex: Int) -> Int { var sourceIndex = sourceIndex if resultIndex < array.count { sourceIndex = reorderEytzinger(input, array: &array, sourceIndex: sourceIndex, resultIndex: 2 * resultIndex) array[resultIndex] = input[sourceIndex] sourceIndex = reorderEytzinger(input, array: &array, sourceIndex: sourceIndex + 1, resultIndex: 2 * resultIndex + 1) } return sourceIndex } init() { let sortedArray: [Int] = (0..<arrayCount).map { _ in .random(in: 0..<arrayCount) }.sorted() var eytzingerArray: [Int] = Array(repeating: 0, count: arrayCount + 1) _ = Self.reorderEytzinger(sortedArray, array: &eytzingerArray, sourceIndex: 0, resultIndex: 1) self.randomElements = (0..<searchCount).map { _ in sortedArray.randomElement()! } self.eytzingerArray = eytzingerArray } @Test func searchEytzinger() throws { let span = eytzingerArray.span search(name: "Eytzinger", duration: .seconds(1)) { for element in randomElements { _ = binarySearchEytzinger(needle: element, haystack: span) } } } }
-
-
- 0:00 - 서론 및 어젠다
Apple Silicon CPU용 코드 최적화는 Swift 소스 코드 및 기계 명령어 간의 추상화 레이어뿐만 아니라 CPU가 명령어를 순서 없이 실행하고 메모리 캐시를 활용하는 복잡한 방식으로 인해 복잡합니다. Instruments는 개발자가 이러한 복잡성을 탐색하는 데 도움이 되고, 성능 조사를 지원하며, 시스템 성능을 프로파일링해 과도한 CPU 사용량을 식별합니다. Processor Trace 및 CPU Counters 도구를 사용하여 명령어를 기록하고, 비용을 측정하며, 병목 현상을 분석하여 궁극적으로 코드 효율성을 높이고 앱 성능을 개선합니다.
- 2:28 - 성능 개선을 위한 자세
앱에서 성능 문제를 조사할 때는 열린 마음을 유지하고 가정 검증을 위해 데이터를 수집하는 것이 중요합니다. 속도 저하의 원인은 리소스에서 대기하는 차단된 스레드, 잘못 사용된 API 또는 비효율적인 알고리즘 등 다양합니다. Xcode의 CPU Gauge뿐만 아니라 Instruments의 System Trace 및 Hangs 도구는 CPU 사용량 패턴, 차단 동작, UI 반응 없음을 식별하는 데 매우 중요합니다. 코드의 유지 관리를 어렵게 만드는 마이크로 최적화에 대해 자세히 알아보기 전에 대안이 되는 접근 방식을 살펴보는 것이 가장 좋습니다. 이러한 대안에는 불필요한 작업을 피하고, 동시성을 통해 해당 작업을 지연시키며, 값을 미리 계산하고, 복잡한 작업으로 계산된 상태를 캐싱하는 것이 포함됩니다. 이러한 전략을 모두 시도해도 소용이 없는 경우, CPU에 집중된 코드를 최적화해야 합니다. 사용자 상호작용의 주요 경로 등 사용자 경험에 상당히 큰 영향을 미치는 코드에 집중하세요. Xcode 및 Instruments 모두에서 자동화된 테스트와 성능 지표를 사용하여 진행 상황을 측정하는 점진적 최적화를 사용하는 것이 좋습니다.
- 8:50 - 프로파일러
본 세션의 이진 검색 예제의 CPU 성능을 분석하기 위해 Instruments 내 두 개의 프로파일러를 사용할 수 있습니다. 바로 Time Profiler와 CPU Profiler입니다. Time Profiler는 CPU 활동을 주기적으로 샘플링하지만, 주기적인 작업으로 인해 CPU 사용량 표현이 왜곡되는 앨리어싱 문제가 발생할 수 있습니다. 반면, CPU Profiler는 클록 주파수를 기반으로 CPU를 독립적으로 샘플링하기 때문에 더 정확하고 CPU 최적화에 더 적합합니다. 이러한 분석을 위해 CPU Profiler 도구를 선택하여 Xcode의 테스트 내비게이터에서 실행한 다음, Instruments의 녹음을 Deferred Mode로 설정하여 오버헤드를 최소화합니다. 타임라인 뷰, 타임라인의 트랙과 차선, 프로파일링된 결과를 보여주는 세부 뷰 등 Instruments 내 영역을 소개합니다. ‘xctest’ 프로세스의 관심 지점 트랙과 프로세스 트랙을 조사하면 예제 앱에서 이진 검색이 발생하는 특정 영역을 식별할 수 있습니다. 세부 뷰의 호출 트리는 ‘Collection’ 프로토콜과 관련된 함수가 상당한 CPU 시간을 소모한다는 것을 보여 줍니다. 성능을 최적화하려면 ‘Span’과 같은 더 효율적인 컨테이너 유형으로 전환하여 Copy-on-Write 의미론 및 제네릭이 있는 ‘Array’와 관련된 오버헤드를 피하는 것이 좋습니다.
- 13:20 - Span
Swift 6.2에서는 기본 주소와 개수가 있는 연속된 메모리 범위를 나타내는 메모리 효율적인 데이터 구조인 ‘Span’이 도입되었습니다. 이진 검색 입력 및 출력 유형에 ‘Span’을 사용하면 알고리즘을 변경하지 않아도 성능이 400% 향상됩니다. 다음으로 성능을 더욱 최적화하기 위해 Processor Trace 도구를 사용하여 경계 검사 오버헤드를 조사합니다.
- 14:05 - Processor Trace
Instruments 16.3에서는 Processor Trace라는 중요한 도구가 새롭게 도입되었습니다. 이 툴을 사용하면 M4 칩 이상에서는 Mac 및 iPad Pro 또는 A18 칩 이상에서는 iPhone에서 사용자 공간에 있는 앱의 프로세스가 실행하는 모든 명령어에 대한 포괄적인 추적을 캡처할 수 있습니다. Processor Trace를 사용하려면 특정 기기 설정을 활성화해야 하고 대량의 데이터를 생성할 수 있기 때문에 짧은 추적 세션에 사용하면 가장 효과적입니다. Instruments는 모든 분기 결정, 사이클 개수, 현재 시간을 기록하여 앱의 정확한 실행 경로를 재구성합니다. 데이터는 각 함수 호출에 걸리는 시간을 시간에 따라 보여주는 플레임 그래프로 시각적으로 표현됩니다. 샘플링을 사용하는 기존 플레임 그래프와 달리, Processor Trace의 플레임 그래프는 CPU가 코드를 실행한 방법을 정확하게 표현합니다. 이를 통해 전례 없는 정확도로 성능 병목 현상을 파악할 수 있습니다. 추적 데이터 분석을 통해 프로토콜 메타데이터 오버헤드와 숫자 비교를 즉시 처리하지 못하는 능력으로 특정 이진 검색 기능의 속도를 상당히 저하시키고 있음이 분명해졌습니다. 이 문제를 해결하기 위해 해당 함수를 Int 유형에 맞춰 수동으로 특수화하여 성능을 약 170% 향상했습니다. 그러나 앱의 이진 검색 구현이 전반적인 앱 속도 저하에 지속적으로 영향을 미치기 때문에 계속해서 최적화를 추가로 해야 합니다.
- 19:51 - 병목 분석
Apple Silicon CPU는 두 단계로 명령어를 실행합니다. 바로 명령어 수준 병렬 처리를 지원하기 위해 파이프라인으로 구성된 Instruction Delivery 및 Instruction Processing입니다. 이를 통해 여러 작업을 동시에 처리할 수 있어 효율성이 극대화됩니다. 그러나 파이프라인에서 병목 현상이 발생하여 작업이 중단되고 병렬 처리가 제한될 수 있습니다. CPU Counters 도구는 각 CPU 단위의 이벤트를 계산하여 이러한 병목 현상을 식별하는 데 도움이 됩니다. 사전 설정된 모드를 사용하여 CPU 성능을 측정하고 작업을 광범위한 범주로 분류합니다. 샘플링된 데이터를 분석하면 잘못 예측된 분기 방향 등 문제를 야기하는 특정 명령어를 정확히 찾아내 사이클 낭비와 성능 저하로 이어질 수 있습니다. CPU는 성능을 강화하기 위해 분기 예측기를 사용하여 명령어를 순서 없이 실행합니다. 그러나 무작위적인 분기는 이러한 예측기를 잘못 유인할 수 있습니다. 이를 완화하기 위해 예측하기 어려운 분기를 피하도록 코드를 재작성하여 약 두 배 정도 빠른 분기 없는 이진 검색을 구현했습니다. 그런 다음 앱 최적화의 초점은 메모리 액세스로 전환되는데 CPU는 캐시 계층을 활용하여 데이터 검색 속도를 높입니다. 이진 검색 알고리즘의 액세스 패턴은 이 계층 구조에 병적인 영향을 미쳐 빈번한 캐시 미스를 초래했습니다. Eytzinger 레이아웃을 사용하여 배열 요소를 재배열함으로써 캐시 로컬리티가 개선되고 이진 탐색 속도가 200% 더 빨라졌습니다. 이처럼 상당히 중요한 최적화에도 불구하고 코드는 아직 명령어 처리 부분에서 기술적인 병목 현상을 겪고 있지만, 다양한 프로파일링 및 마이크로 최적화 기술을 통해 전반적인 검색 기능은 약 2,500% 더 빨라졌습니다.
- 31:33 - 요약
먼저 소프트웨어 오버헤드를 측정하고 최적화한 다음 CPU 병목 현상에 집중하여 쉽게 간과되는 문제를 해결하고 CPU 아키텍처에 더욱 맞춰 이진 검색 앱의 성능이 개선되었습니다.
- 32:13 - 다음 단계
앱을 최적화하려면 Instruments를 사용하여 데이터를 수집하고, 성능 테스트를 실행하며, 결과를 분석하세요. 또한 Swift 성능에 대한 세션을 시청하고 개발자 문서에서 ‘Apple Silicon CPU 최적화 가이드’를 읽어볼 수 있습니다. 개발자 포럼에서 질문하거나 피드백을 제공하세요.