[Swift] Canonical SIL 까지의 최적화에 대한 고찰

naljin
20 min readFeb 10, 2022

--

의문의 시작

리팩토링 2판 책을 읽다가

반복문 안에서 로직이 분리된다면, 각각에 대해 “반복문 쪼개기”

를 하는게 좋다고 하더라 👀

그러니까

let range = (1...403)var totalAmount = 0
var volumeCredits = 0
range.forEach { amount in
totalAmount += amount
volumeCredits += amount
}

를 요렇게 고치는게 좋다고? 🤔🤔🤔🤔

let range = (1...403)var totalAmount = 0
range.forEach { amount in
totalAmount += amount
}
var volumeCredits = 0
range.forEach { amount in
volumeCredits += amount
}

성능 이슈가 없나??? 하고 생각하던 찰나, 둘 사이의 성능은 각종 최적화 기법 덕에 크게 차이가 없을 가능성이 많다는 문구가 들어왔다

그걸 보니 “그럼 코드 상으로는 for 문을 두번 돌게 되어있어도, 최적화를 거치고 나면 for 문 한번만 돌게 고쳐줄수도 있는건가?” 라는 생각이 들었다

그러니까 이런식으로??

// 내가 이런식으로 짜더라도
range.forEach { amount in
totalAmount += amount
}
range.forEach { amount in
volumeCredits += amount
}
// 최적화를 거치고 나면, 내부적으로 이렇게 동작할수도 있는건가???
range.forEach { amount in
totalAmount += amount
volumeCredits += amount
}

최적화 결과를 어떻게 확인할까

이걸 확인하려면 어떻게 해야할까,, 생각하다가 예전에 어딘가에서 SIL 까본 글을 봤던게 떠올랐다. (다시 찾아보니 우아한 형제들 테크 블로그였다)

왜 최적화 확인 방법으로 SIL (Swift Intermediate Language) 이 떠올랐는지를 알기 위해 우선 스위프트의 빌드 단계를 보자

Swift Code → 구문분석(AST 생성) → 의미분석 (타입 정보 포함한 AST 생성) → 모듈 임포트 → SIL 생성 (raw SIL) → SIL 정규화 (canonical SIL) → SIL 최적화 → LLVM IR 생성 (여기까지 LLVM의 FrontEnd) → 최적화 → Compiler backend → Assembler → 링커

여기서 중간 정도에 보이는 “SIL 정규화 (canonical SIL)”에서는 바로 전단계에서 나온 raw SIL 을 갖다가 Guaranteed Optimization Passes 와 Diagnostic Passes 를 수행한다

초기화되지 않은 변수의 사용 등과 같이 프로그램의 정확성에 영향을 미치는 추가 dataflow 진단을 수행해서 결과 성능을 향상시키는 것이다.

이렇게 SIL 정규화가 수행되고 나면 최종 결과로 Canonical Swift IL, 즉 Canonical SIL이 나오는데, 이는 “코드에 대한 최적화 및 진단이 수행된 결과” 이다. (lib/SILOptimizer/Mandatory 에서 구현)

그러니까 Canonical SIL 을 까보면 최적화가 내가 생각한대로 되는지 알 수 있지 않을까? 생각했다.

참고로 canonical 뜻은 “정식” 이라고 한다. 영어 잘하고 싶다.

Swift 컴파일러 swiftc에 아래의 옵션을 넣는 것으로 지정한 단계까지 처리한 결과를 텍스트로 확인할 수 있다

미디엄 표 언제 지원할래?

참고로 swiftc -help 를 쳐보면 더 많은 옵션들을 확인할 수 있다

좋아 그럼 Canonical SIL 을 생성해서 까보자

Canonical SIL 분석

아래와 같은 optimization.swift 파일을 작성하고

import Foundationlet range = (1...403)var totalAmount = 0
range.forEach { amount in
totalAmount += amount
}
var volumeCredits = 0
range.forEach { amount in
volumeCredits += amount
}

아래 명령어를 통해 대응하는 Canonical SIL 을 확인할거다

swiftc -emit-sil optimization.swift

음 근데 저 몇줄 안되는 코드가 392줄이 되어서 나왔따 ㅋㅎ,,

ㅎㅎ,, 약간 어질한데;; 마음을 가다듬고 살펴봤다. 아래부터 설명할 내용은 내가 SIL 문법을 제대로 공부한 다음에 살펴 본게 아니라, 느낌적인 느낌으로 살펴본게 강하다보니 틀린 부분이 있을수도 있다

처음에는 기존의 range, totalAmount, volumeCredits 변수 앞뒤에 이상한 문자들이 더 붙어서 global variable 로 쓰이기 위한 기초 작업을 하는 것 같다.

// range
sil_global hidden [let] @$s12optimization5rangeSNySiGvp : $ClosedRange<Int>
// totalAmount
sil_global hidden @$s12optimization11totalAmountSivp : $Int
// volumeCredits
sil_global hidden @$s12optimization13volumeCreditsSivp : $Int

이제 main 으로 감싸진 함수의 시작 부분을 살펴 볼건데, 보기 쉽게 많이 생략해 왔다.

bb0(%0 : $Int32, %1 : $UnsafeMutablePointer<Optional<UnsafeMutablePointer<Int8>>>):
...(중략)...
%5 = integer_literal $Builtin.Int64, 1 // user: %6
...(중략)...
%7 = integer_literal $Builtin.Int64, 403 // user: %8
...(중략)...
br bb1

bb0 으로 시작을 하는 것을 볼 수 있다. bb0 은 basic block 0 의 약자로, 함수가 구현될 때는 항상 bb0 으로 시작한다.

그렇다면 basic block 이 뭐냐?? 이는 바로 computation의 단위로, if/else 구문 같은 경우 bb1bb2 라는 각각 가능한 path 를 표현하는 두개의 basic block 이 나오게 된다.

이제 진짜 해석을 해보자면, let range = (1...403) 을 처리하기 위해 %5%7 에 각각 1403 이라는 값을 할당하고 있는거 같다.

여기서 %5%7 같이 % 로 시작하는 값들은 가상 레지스터를 뜻한다. 우측에 // user: %6 처럼 써있는 것은 해당 레지스터를 %6 에서 사용한다는 뜻이다.

마지막에 나오는 br bb1 은 현재의 basic block 에서 bb1 으로 control 을 넘기겠다는 뜻이다.

bb1:                                              // Preds: bb0
br bb2 // id: %10
bb2: // Preds: bb1
br bb3 // id: %11

bb1, bb2 코드는 보다시피 별거 없고, 다음 basic block 으로 컨트롤을 넘기기만 한다. 그럼 bb3 을 보자.

bb3:                                              // Preds: bb2
...(중략)...
// function_ref ClosedRange.init(_uncheckedBounds:)
...(중략)...
%18 = apply %17<Int>(%3, %13, %15, %12) : $@convention(method) <τ_0_0 where τ_0_0 : Comparable> (@in τ_0_0, @in τ_0_0, @thin ClosedRange<τ_0_0>.Type) -> @out ClosedRange<τ_0_0>
...(중략)...
// function_ref closure #1 in
%30 = function_ref @$s12optimizationySiXEfU_ : $@convention(thin) (Int) -> () // user: %31
...(중략)...
// function_ref Sequence.forEach(_:)
...(중략)...
try_apply %37<(ClosedRange<Int>)>(%36, %28) : $@convention(method) <τ_0_0 where τ_0_0 : Sequence> (@noescape @callee_guaranteed @substituted <τ_0_0> (@in_guaranteed τ_0_0) -> @error Error for <τ_0_0.Element>, @in_guaranteed τ_0_0) -> @error Error, normal bb4, error bb6 // id: %38

여기도 많이 생략해왔다. 중요해보이는 로직들만 단계별로 뽑아서보자

// function_ref ClosedRange.init(_uncheckedBounds:)
%18 = apply %17<Int>(%3, %13, %15, %12) : $@convention(method) <τ_0_0 where τ_0_0 : Comparable> (@in τ_0_0, @in τ_0_0, @thin ClosedRange<τ_0_0>.Type) -> @out ClosedRange<τ_0_0>

(1...403) 에 해당하는 ClosedRange 를 실제로 생성하는 것 같다. 인자로 (%3, %13, %15, %12) 이런 것들을 넘기길래 (사실 인자가 맞는지도 잘 모르겠긴 함), 각 레지스터에는 뭐가 담겨있는지 추적을 해봤다

  • %3 - s12optimization5rangeSNySiGvp
  • %13 - 1
  • %15 - 403
  • %12 - metatype $@thin ClosedRange<Int>.Type

음.. 그러니까 대충 1과 403 범위의 값을 ClosedRange 타입으로 생성을 하고 그걸 range 변수에 넣는다.. 이런 얘기 아닐까? ㅋ,, 아닐 가능성 93% 정도.

그럼 다음 ㄱ

// function_ref closure #1 in 
%30 = function_ref @$s12optimizationySiXEfU_ : $@convention(thin) (Int) -> () // user: %31

s12optimizationySiXEfU_ 라는 곳으로 함수를 참조하는 것 같다. 그럼 또 여기로 털레털레 가봐야지..

// closure #1 in 
sil private @$s12optimizationySiXEfU_ : $@convention(thin) (Int) -> () {
// %0 "amount" // users: %6, %2
bb0(%0 : $Int):
%1 = global_addr @$s12optimization11totalAmountSivp : $*Int // user: %3
...(중략)...
cond_fail %10 : $Builtin.Int1, "arithmetic overflow" // id: %11
...(중략)...
} // end sil function '$s12optimizationySiXEfU_'

bb0 바로 위에 // %0 "amount" 라고 쓰여있는 것, bb0 안에서 totalAmount 를 사용하는 것, arithmetic overflow 에러 처리가 되어있는 것들로 미루어보아 { amount in totalAmount += amount } 에 대한 실질적 로직을 처리하고 있는게 아닌가 싶다.

그럼 다음 ㄱ

// function_ref Sequence.forEach(_:)
try_apply %37<(ClosedRange<Int>)>(%36, %28) : $@convention(method) <τ_0_0 where τ_0_0 : Sequence> (@noescape @callee_guaranteed @substituted <τ_0_0> (@in_guaranteed τ_0_0) -> @error Error for <τ_0_0.Element>, @in_guaranteed τ_0_0) -> @error Error, normal bb4, error bb6 // id: %38

try_apply 를 통해서 %37 에 해당하는 function 으로 control 을 넘긴다. 이때 (%36, %28) 도 인자로 같이 넘긴다.

각 레지스터에는 뭐가 담겨있는지 다시 추적을 해봤다.

  • %37 - forEach
  • %36 - { amount in totalAmount += amount } 로직 담고 있었던 클로저
  • %28 - (1...403) 범위

그렇다. 결국 아래의 swift 코드에 대응하는 거였다.. 허어………

range.forEach { amount in
totalAmount += amount
}

try_apply 끝 부분을 보면 normal bb4 라고 되어있는데 느낌적인 느낌으로 에러가 없으면 이어서 bb4 가 실행됨을 추측할 수 있다

bb4 안에 로직을 살펴보면 bb3 과 비슷하게 동작하는 것을 알 수 있는데, 여기서도 마지막 부분에 주목해보자.

// function_ref Sequence.forEach(_:)
try_apply %57<(ClosedRange<Int>)>(%56, %48) : $@convention(method) <τ_0_0 where τ_0_0 : Sequence> (@noescape @callee_guaranteed @substituted <τ_0_0> (@in_guaranteed τ_0_0) -> @error Error for <τ_0_0.Element>, @in_guaranteed τ_0_0) -> @error Error, normal bb5, error bb7 // id: %58

try_apply 를 통해서 (%56, %48) 인자와 함께 %57 에 해당하는 function 으로 control 을 넘긴다. 이때 각 레지스터에 안에는 아래와 같은 정보가 담겨있다.

  • %57 - foreEach
  • %56 - { amount in volumeCredits += amount }
  • %48 - (1...403) 범위

여기서 알 수 있는 중요한 포인트는 bb3bb4 에서 각각 forEach 에 대한 동작을 수행한다는 것이다. 즉, 처음에 생각했던 "코드 상으로는 for 문을 두번 돌게 되어있어도, 최적화를 거치고 나면 for 문 한번만 돌게 고쳐줄수도 있는건가?" 라는 생각이 틀렸음을 알 수 있었다.

그렇다면 번외로 아래의 코드에 대한 Canonical SIL 을 출력해보기로 했다.

import Foundationlet range = (1...403)var totalAmount = 0
var volumeCredits = 0
range.forEach { amount in
totalAmount += amount
volumeCredits += amount
}

bb3 이 대충 아래와 같이 나오는데

bb3:                                              // Preds: bb2
...(중략)...
// function_ref ClosedRange.init(_uncheckedBounds:)
...(중략)...
%18 = apply %17<Int>(%3, %13, %15, %12) : $@convention(method) <τ_0_0 where τ_0_0 : Comparable> (@in τ_0_0, @in τ_0_0, @thin ClosedRange<τ_0_0>.Type) -> @out ClosedRange<τ_0_0>
...(중략)...
// function_ref closure #1 in
%35 = function_ref @$s13optimization2ySiXEfU_ : $@convention(thin) (Int) -> () // user: %36
...(중략)...
// function_ref Sequence.forEach(_:)
...(중략)...
try_apply %42<(ClosedRange<Int>)>(%41, %33) : $@convention(method) <τ_0_0 where τ_0_0 : Sequence> (@noescape @callee_guaranteed @substituted <τ_0_0> (@in_guaranteed τ_0_0) -> @error Error for <τ_0_0.Element>, @in_guaranteed τ_0_0) -> @error Error, normal bb4, error bb5 // id: %43

마지막 try_apply 의 인자로 넘어가는 %41 을 살펴보면

// closure #1 in 
sil private @$s13optimization2ySiXEfU_ : $@convention(thin) (Int) -> () {
// %0 "amount" // users: %7, %20, %3
bb0(%0 : $Int):
%1 = global_addr @$s13optimization213volumeCreditsSivp : $*Int // user: %17
%2 = global_addr @$s13optimization211totalAmountSivp : $*Int // user: %4
...(중략)...
} // end sil function '$s13optimization2ySiXEfU_'

s13optimization213volumeCreditsSivp, s13optimization211totalAmountSivp 두 주소에 대해 접근이 발생한다.

따라서 한번의 for loop 안에서 두 변수에 대한 계산이 모두 이뤄짐을 추측할 수 있었다.

결론

일단 Canonical SIL 까지의 최적화에서는 처음에 생각했던 “코드 상으로는 for 문을 두번 돌게 되어있어도, 최적화를 거치고 나면 for 문 한번만 돌게 고쳐줄수도 있는건가?” 라는 생각이 틀렸음을 알 수 있었다.

하지만 처음 빌드 단계에서 봤듯이 SIL 정규화 이후에도 몇번의 최적화 단계가 더 있다.

Swift Code → 구문분석(AST 생성) → 의미분석 (타입 정보 포함한 AST 생성) → 모듈 임포트 → SIL 생성 (raw SIL) → SIL 정규화 (canonical SIL) → SIL 최적화 → LLVM IR 생성 (여기까지 LLVM의 FrontEnd) → 최적화 → Compiler backend → Assembler → 링커

SIL 정규화 다음에 수행되는 SIL 최적화, 즉 General SIL Optimization Passes 는 성능 향상을 위해 Canonical SIL 을 한번 더 최적화한다. 하지만 SIL 정규화처럼 무조건 수행되는건 아니고 optional 하게 수행 된다 (ARC 최적화, 탈가상화(devirtualization) 등을 수행. lib/Analysis, lib/ARC, lib/LoopTransforms, lib/Transforms 에서 구현되어 있음)

또한 LLVM backend 도 생성된 LLVM IR 을 가지고 LLVM optimizations 을 optional 하게 수행한다.

따라서 최종적으로 binary code 까지 갔을때는 loop 를 두 번 돌지 않을 수도 있지 않을까?? 라는 생각이 들기도 한다.

하지만 이걸 직접 확인해보기에는,, 족굼,, 나는 사람인데,, 어셈블리까지 읽어야할까,,?? 어셈블리 읽는건 언젠가 있었던 학부 과제로 충분하지 않았을까,,?? 라고 생각한다 🤢🤢

어쨌거나 저쨌거나

let range = (1...403)var totalAmount = 0
range.forEach { amount in
totalAmount += amount
}
var volumeCredits = 0
range.forEach { amount in
volumeCredits += amount
}

요 코드가 Canonical SIL 에서는 for loop 를 한번 도는 방식으로 최적화 되지 않음을 확인했다.

근데 사실 저렇게 분리했을때 성능은 둘째 치고, 이전에 비해 과연 읽기는 좋은 코드가 맞나………? 라는 의문이 든다. 예제가 너무 간단해서 이런 생각이 드는건가.. 흠…🤔

튼 리팩토링 2탄,, 올해는 다 읽을 수 있길 바라며 만약 틀린 부분이나 더 알려줄 부분 댓글로 남겨주시면 압도적 감사!

참고

--

--

Responses (1)