본문 바로가기
iOS/개발

Demystify parallelization in Xcode builds

by 바등쪼 2023. 11. 3.

https://developer.apple.com/wwdc22/110364

 

Demystify parallelization in Xcode builds - WWDC22 - Videos - Apple Developer

Learn how the Xcode build system extracts maximum parallelism from your builds. We'll explore how you can structure your project to...

developer.apple.com

 

 

목차

Core concepts
Build phases
Cross-Target bulids

 

 

 

Core concepts

Xcode에서 CMD+B를 눌러서 앱을 빌드할 때 발생하는 일은 다음과 같다.

 

  • 빌드 시스템은 Xcode의 일부로 전체 프로젝트의 전체 프로젝트의 representation으로 호출된다.
  • 모든 소스 파일, 에셋, 빌드 세팅, 기타 configurations 들을 포함한다.
  • 빌드 시스템은 앱 빌드 방법에 대한 single source of truth이다.
  • 어떤 설정을 사용하여 어던 도구를 호출할지 최종적으로 앱을 만들기 위해 어떤 intermediate 파일을 생성할지 알고 있다.

다음 단계로 빌드 시스템은 프로젝트의 입력 파일을 처리하기 위해 Compiler와 같은 도구를 호출한다.

 

  • Clang과 Swift 컴파일러 모두 Obejct 파일(.o)을 생성한다.
  • 이 파일은 Linker가 executable program을 만들기 위해 필요하다.

이 순서는 말이 되지만 어디서 왔는지 명확하지 않다.

따라서 이러한 과정의 예시와 빌드 시스템이 모든 작업의 실행 순서를 결정하는 법을 살펴보자!

 

 

  • Swift 컴파일러는 input 소스 파일을 사용해 프로그래머의 intent를 캡처하고 machine-exeutable binary로 변환한다.
  • 도중에 에러가 있는지 소스 코드를 확인한다.
  • 이 프로세스는 실패할 수 있고 실패 시 빌드가 취소된다.
  • 성공한다면 각 input에 대한 object 파일을 생성한다.
  • 이러한 .o 파일들은 링커를 호출하는데 사용된다.
  • Linker는 object 파일들을 결합하고 executable 파일을 만들기 위해 외부에 연결된 라이브러리에 대한 참조를 추가한다.
  • 두 작업(컴파일러, 링커)은 소비와 생산을 기반으로 종속성을 가진다.
    • 컴파일러가 생성한 파일을 링커에 의해 소비된다.
  •  이로 인해 빌드 시스템에 종속성이 생긴다.
  • 이러한 작업 간 종속성은 중요하다.
  • input을 생성하는 작업이 완료됐따는 확인이 있어야 다음 작업을 시작할 수 있다.

 

컴파일링과 링킹은 전체 타겟을 빌드하기 위한 많은 작업 중 일부일 뿐이다.

그래프에 더 일반적인 작업들을 추가해서 나타내면 다음과 같다.

  • 에셋 컴파일링, 파일 복사 또는 codesigning과 같은 여러 작업들이 합쳐져 Framework 타겟의 빌드를 나타낸다.
  • 화살표로 각 Task의 종속성을 나타내고 있다.
  • A를 완료하면 B, C의 차단(block)이 해제된다.
  • B를 완료하면 D, E의 차단이 해제된다.
  • 차단이 해제(unblocked)된 작업을 downstream이라고 하고 차단하는 작업은 upstream이라고 한다.

 

많은 프로젝트에선느 둘 이상의 프레임워크 타겟이 포함되기 때문에 그래프를 더 확장해 보자!

 

Target은 프로젝트에서 서로 간의 종속성을 명시적 또는 암시적 종속성을 정의한다. 

(예를 들어, 'Link Binary with Library' 단계에 정의)

 

  • 위 예제에서 App은 App Extension을 포함하고 1개의 Framework에 링크된다.
  • App Extension은 프레임워크를 사용하고 있지 않기 때문에 의존 관계가 없다.
  • 빌드 그래프를 실행할 때 task 마다 다른 양의 시간이 필요하다.
    • Input의 크기와 필요한 계산에 따라 달라진다.
  • 컴파일링은 일반적으로 몇 개의 헤더 파일의 복사보다 시간이 오래 걸린다.

 

  • 빌드 시스템은 dependencies가 없는 작업을 먼저 실행한다.
  • 이것들이 완료되면 다운스트림의 작업의 차단을 해제해나간다.
  • 모든 작업이 완료될 때까지 이 프로세스를 따른다.

 

두 번째 빌드부터는 이전과 비교했을 때 input이 바뀌지 않았다면 빌드를 건너뛸 수 있다.

만약 타겟 B의 Input이 변경되었다면 다운스트림인 D, E도 다시 빌드해야 한다. (변경되지 않았다면 빌드 스킵 가능)

 

이처럼 다른 모든 작업들을 건너 뛸 수 있다면 프로젝트의 처리 시간이 반복적인 경우 작업 속도가 매우 빨라진다.

이를 증분 빌드 (incremental build)라고 한다.

하지만 우선 지금은 full build의 예시를 더 살펴보자

 

 

  • 작업 실행의 종속성과 소요 시간은 첫 다운스트림 작업을 시작할 수 있는 시간을 정의한다.
  • 이 정보를 이용해 critical path(임계 경로)를 계산할 수 있다.
    • 무한 리소스가 있을 때 빌드에 필요한 가장 짧은 시간을 의미
  • 이 path를 단축하여 높은 수준의 병렬화 및 확장성을 가진 빌드 그래프를 생성하는 것이 이 WWDC의 내용의 핵심이다.
  • 단축된 cirtical path가 항상 빌드 시간을 줄인느 것은 아니지만 빌드가 하드웨어와 함께 확장되도록 한다.
  • critical path는 빌드 속도를 제한하는 요소를 정의한다.
    • 하드웨어에서 허용할지라도 빌드를 더 빠르게 하지 못하게 막는 요소들을 정의
  • critical path는 그 안에 있는 종속성을 분해하여 수행된다.

  • 각 작업의 너비는 작업의 길이(소요 시간)를 나타낸다.
  • 그래프의 높이는 주어진 시간에 병렬로 실행되는 작업의 수를 의미한다. (CPU 또는 메모리 사용률과 직결되진 않는다.)
  • 빈 공간은 다운스트림 작업을 막는 작업들로 인해 발생한다.
  • 각 요소의 색상은 연관 타겟을 의미한다.

Xcode 빌드 타임라인

 

 

Xcode 14부터는 이러한 시각화를 직접 확인할 수 있다!

빌드가 완료된 후 성능을 이해하는데 도움이 된다.

  • 계층 구조가 아닌 병렬화를 기반으로 시각화하여 빌드 성능을 나타낸다.
  • 증분 빌드에서는 타임라인에 실제로 실행된 작업만 포함되어 장기 실행 작업을 발견할 수 있도록 한다.

 

Xcode14에서 빌드 타임라인 확인하기

  • Edit scheme 버튼을 누르면 위와 같은 화면이 나온다.
  • Build 탭
    • 모든 타겟 목록이 포함되어 있다.
    • 타겟은 명시적으로 스킴에 추가하거나 이미 스킴의 일보인 타겟의 종속성으로 암시적으로 추가할 수 있다.

  • 이 로그는 이전에 실행한 스킴의 빌드를 나타낸다.
  • 빌드 시스템이 실행한 모든(All) 항목을 포함한다.
  • 이 항목은 속해 있는 타겟 기반으로 계층 구조로 정리된다.
  • 위 예시에서는 'dooc' 타겟과 같이 해당 타겟의 실행 파일을 빌드하기 위해 이 노드의 자식이 나타낸 모든 작업을 실행했다.
  • 현재 로그는 'All'이 선택되었기 때문에 증분 빌드에서는 다시 실행할 필요가 없던 이전 빌드 작업도 표신된다.
  • 'Recent'를 선택하면 실제로 실행된 작업만 표시되고 건너뛴 작업들은 숨겨진다.
  • 필터도 지원해서 문제가 있거나 실패한 작업들만 표시할 수도 있다.

 

Assitant 버튼 클릭!

 

 

Assitant를 열면 빌드 타임라인이 등장한다!!

트랙패드를 사용하면 다양한 상호작용 또한 가능하다!

 

이상적인 타임라인은 빈 공간이 가능한 적게 세로로 채워진다.

이렇게 하는 것이 빌드 그래프가 가장 잘 확장되고 빌드가 빨라지며 이에 따라 하드웨어도 빨라진다.

 

이제 Xcode가 개별 타겟을 정의하고 빌드하는 방법 및 병렬화를 증가시키는 방버벵 대해 살펴보자~!!

 

Build Phases

 

  • 타겟을 구성할 때 Build Phases는 해당 타겟의 product 생산을 위해 수행해야 하는 작업들을 설명한다.

 

  • 컴파일해야 하는 에셋, 헤더, 리소스, 리크되는 라이브러리, 스크립트등을 포함한다.
  • 많은 Build phases는 다른 빌드 단계의 입출력 작업을 describe하며 그들 사이에 종속성을 생성한다.
    • 예를 들어, 타겟의 소스 파일은 링크되기 전에 컴파일되어야 한다.
  • 하지만 모든 빌드 단계에 이러한 종속성이 적용되는 것은 아니다.
  • 즉, 병렬로 처리 가능한 단계들이 있다!

 

  • 빌드 시스템은 빌드 단계의 입출력을 고려해 병렬로 실행 가능한지 결정한다.
  • 예를 들어 컴파일과 리소스 복사는 병렬 실행 가능하다.
  • 하지만 링킹은 컴파일이 끝나야 가능하다. (object 파일에 의존하기 때문)

 

위 사진은 'Run Script' 빌드 phases가 포함된 또 다른 타겟의 예시이다.

  • 다른 빌드 단계와 달리 스크립트 단계의 입출력은 타겟 editor에서 수동으로 구성해야 한다.
  • 결과적으로 빌드 프로세스에 Data race가 발생하지 않도록 빌드 시스템은 연속적 스크립트를 한 번에 하나씩 실행한다.

 

  • 만약 타겟의 스크립트가 종속성 분석을 기반으로 실행되고 입출력의 전체 목록을 지정하도록 구성된 경우 FUSE_BUILD_SCRIPT_PHASES 빌드 설정을 YES로 설정하여 빌드 시스템이 병렬로 시도해야 함을 나타낼 수 있다.

 

  • 하지만 스크립트 단계를 병렬로 실행하면 빌드 시스템은 지정된 입력과 출력에 의존해야 한다.
  • 따라서 불안정한 입출력 목록은 디버그가 매우 어려운 Data race로 이어질 수 있다는 점을 유의하자!!

이러한 문제점을 완화하기 위해 Xcode는 사용자 스크립트 샌드박싱을 지원하여 각 스크립트 단계의 종속성을 정확하게 선언할 수 있도록 한다.

 

  • SandBoxing은 쉘 스크립트가 실수로 소스 파일 및 중간 빌드 객체에 액세스 하는 것을 차단하는 선택적 기능이다.
  • 위 예시에서는 input.txt 파일이 모두 해당 스크립트 단계에 대한 종속성으로 선언되지 않았으므로 샌드박스는 프로젝트를 빌드할 때 스크립트가 두 파일을 읽고 쓰는 것을 차단한다.
  • 스크립트가 샌드박스를 위반하면 non-zero 종료 코드와 함께 빌드가 실패하게 된다.

 

  • 스크립트 단계에 종속성 정보를 정확히 추가하면 이 문제가 해결된다.
  • 이런 방식으로 샌드박스는 스크립트가 선언된 입출력 이외의 파일에 실수로 액세스하지 않도록 한다.

둘 이상의 스크립트 단계가 있는 예시를 살펴보자.

 

  • 두 스크립트 모두 동일한 파일(raw.txt)에 접근하며 checksum.txt를 읽고 쓴다.

  • 이런 단계에 대한 정확한 입출력 종속성 세트가 선언되지 않은 경우 Xcode는 두 스크립트를 병렬로 실행한다. (FUSE_BUILD_SCRIPT_PHASES가 켜져있을 때)
  • 이 예시에서는 checksumt.txt에 대한 Data race가 발생할 수 있다.
  • 샌드박싱이 없었으면 이 문제는 눈에 띄지 않았을 것이다.

 

Sandboxed shell scripts 정리

 

 

 

 

Cross-Target bulids

이제 좀 더 글로벌한 괁넘에서 Xcode가 Swift 타겟 간 종속성을 사용하여 빌드에서 최대 병렬 처리량을 추루하는 방법을 살펴보고 프로젝트의 구조 및 구성이 빌드 시간에 미치는 영향도 알아보자!

 

프로젝트를 구성하는 계층 구조에는 여러 수준이 있을 것이다.

  • 로컬 라이브러리에 의존하는 압 타겟은 의미론적 경계를 따라 여러 타겟 및 여러 프레임워크로 분할된다.
  • 각 타겟은 다양한 빌드 단계와 절차들을 포함하며 그리고 빌드 단계로부터 파일 종속성을 생성하고 사용한다.
  • 프로젝트의 크기가 커지면 이런 작업 그래프의 크기와 복잡도 또한 커진다.

Swift 타겟에 특화된 한 종류의 작업은 컴파일이다.

Swift 타겟의 소스 코드를 바이너리로 만드는 것은 일반적으로 빌드 계획이나 컴파일 및 링킹을 위한 많은 하위 작업을 포함하는 복잡한 일이다.

이러한 작업의 조직화는 Xcode tool chain의 특수 도구인 Swift Driver에 위임된다.

 

 

  • Driver는 타게 소스 코드에 필요한 컴파일러 및 링커 호출을 구성하는 시기와 방법에 대한 전문 지식을 가지고 있다.
  • Swift 코드를 포함하는 모든 타겟은 코드 배포의 단위인 모듈에도 해당된다.
  • 이 타겟의 Public interface를 캡처하는 바이너리 모듈 파일은 다운스트림 타겟이 컴파일을 시작하는데 필요한 빌드 product이다.

 

  • 타겟은 하나의 소스 파일 모음으로 구성된다.
  • 릴리즈 또는 최적화된 빌드에서 드라이버는 모든 소스 파일이 최적화 기회를 최대화 하는 하나의 컴파일 작업을 예약한다.
  • 이 단일 컴파일 작업 또한 타겟의 Swift 모듈을 생성한다.

 

  • 디버그 또는 증분 컴파일 모드에서 Swift 드라이버는 필요한 컴파일 effort를 병렬로 실행할 수 있는 더 작은 하위 작업으로 나눈다.
  • 이중 일부는 증분 빌드에서 다시 실행될 필요가 없다.
  • 그 다음 Swift 모듈을 생성하려면 각 컴파일 작업의 부분적인 중간 제품을 병합(merge module)하는 추가 단계가 필요하지만 만약 이 예시처럼 타겟에 있는 소스 파일 수가 많다면 개별 파일은 일괄 컴파일 하위 작업(batch compilation sub-tasks) 에 할당될 수도 있다.

 

  • 빌드 로그는 batch 컴파일 작업에 할당되는 소스 파일을 강조 표시하고 각 파일의 진단에 대해 별도의 항목을 사용한다.
  • 다양한 소스파일에 걸쳐 타겟의 빌드를 병렬화 할 수 있는 것은 더 빠르고 더 작은 증분 빌드에 중요하기 때문에 디버그 빌드가 증분 컴파일 모드 설정을 사용하는지 확인하자!

 

  • 앞서 배웠듯이 Swift 타겟 종속성은 종속 항목이 public interface 를 캡쳐하는 바이너리 모듈 파일을 제공하도록 함으로써 해결된다.
  • 각 타겟에 대한 최상위 Swift 드라이버 작업 빛 각 타겟의 개별 하위 작업이 타임라인에 나타나있다. (위 사진)
  • Xcode 14부터는 Swift로 작성된 완전히 새로운 Swift 드라이버 덕분에 빌드 시스템과 컴파일러가 완전히 통합되었다.
  • Xcode 빌드 시스템은 코드 컴파일을 위해 수행해야 하는 모든 작업의 central scheduler 역할을 한다.
  • 이 중앙 계획 매커니즘을 통해 Xcode는 세밀한 스케줄링 결정을 내릴 수 있고 프로젝트를 빌드할 때 사용 가능한 리소스만큼만 사용하도록 보장한다. (CPU obersubscribing과 시스템 성능 저하 없이)

 

  • 모든 개별 sub-tasks들이 central task pool에 있게 되면서 빌드 스케줄러가 만드는 trade-offs를 고려하는 것이 중요하다.
  • 예를 들어 8코어 시스템에서 스케줄러 기본 값은 사용 가능한 작업(종속성이 충족되어 이동할 준비가 된 작업을) 8개의 excutable slots 중 하나에 할당한다.
  • 슬롯 중 하나가 비는 즉시 빌드 시스템은 아직 미처리된 작업으로 슬롯을 채우려 시도한다.
  • 코어가 많은 시스템에서는 더 많은 동시 작업을 수행할 수 있다.
  • 그러나 현재 진행 중이거나 대기 중인 다른 작업에 의해 생성된 모든 미처리된 작업은 여전히 Input을 기다린다. (비효율적)
  • 새로운 통합 빌드 시스템은 스케줄러가 이러한 휴식(idle) 시간을 크게 줄일 수 있도록 한다.

그 방법을 보기 위해 컴파일에 대한 타겟 dependency인 바이너리 모듈 파일이 어떻게 resolve 되는지 다시 살펴보자!

 

 

  • 하위 작업 컴파일의 부분적 결과는 타겟의 최종 모듈 제품에 병합된다.
  • 타겟의 Product가 완성(available) 해지면 다운 스트림 타겟이 컴파일을 시작할 수 있다.

 

  • Xcode 14 부터는 새로게 타겟 모듈의 구성이 모든 소스 파일에서 직접 별도의 emit-module 작업에서 수행된다.
  • 이는 타겟 종속성이 모듈 방출 작업이 완료되는 즉시 다른 컴파일러 작업을 기다리지 않고 컴파일을 시작할 수 있음을 의미한다.

 

  • 다운스트림 타겟 컴파일 block을 더 빨리 해제할 수 있게 되면 빌드 타임 라인의 활동 사이의 빈 공간인 idle CPU 코어로 사용 가능한 작업을 기다리는 시간을 줄일 수 있다.

 

  • 전체 프로젝트로 확장해 보면 전반적으로 비슷한 양의 작업을 수행하고 있지만 빌드 시스템은 컴퓨터의 리소스를 더 효율적으로 사용할 수 있고 종종 빌드를 더 빠르게 완료할 수 있다.

 

Eager linking

이제 빌드 시스템이 Swift 빌드 시에 수행할 수 있는 두 번째 cross target 최적화 기능은 eager linking에 대해 살펴보자!

  • 타겟 B가 타겟 A를 링킹하기 때문에 타겟 B의 링크 작업은 타겟 A의 링크된 Output이 생성되고 자체 컴파일 작업이 완료될 때까지 기다려야 한다.

  • Eager linking를 사용하면 타겟 B의 링크 작업이 타겟 A의 emit-module 작업에 의존할 수 있다.
  • 그 결과 타겟 B는 링킹을 빌드 초기에 시작할 수 있다.
  • 타겟 A와 병렬로 실행하며 ciritical path를 단축할 수 있다.

어떻게 이것이 가능할까?

 

  • 일반적으로 링크된 product 디펜던시가 있는 두 타겟의 종속성 그래프는 위와 같다.
  • 디펀덴시 타겟을 링크하려면 타겟 자체의 컴파일 Output과 함께 자체 디펜던시들이 링크된 Product가 필요하다.

 

  • Eager linking을 사용하면 이 종속성이 깨지면서 종속 타겟이 더 일찍 링킹 될 수 있도록 한다.
  • 디펜던시가 링크된 제품에 의존하는 대신 이전 빌드 프로세스에서 emit-module 작업에 의해 생성된 텍스트 기반 dynamic library stub에 의존한다.
  • 이 Stub에는 종속성이 사용할 수 있도록 링크된 product에 심볼 목록이 포함되어있다.

  • Xcode 빌드 세팅에서 이 최적화를 활성화 할 수 있다.
  • Eager linking은 디펜던시 항목에 의해 동적으로 링킹된 모든 Swift-only 타겟에 적용된다.

 

정리

 

 

댓글