의문의 시작
리팩토링 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
구문 같은 경우 bb1
과 bb2
라는 각각 가능한 path 를 표현하는 두개의 basic block 이 나오게 된다.
이제 진짜 해석을 해보자면, let range = (1...403)
을 처리하기 위해 %5
와 %7
에 각각 1
과 403
이라는 값을 할당하고 있는거 같다.
여기서 %5
나 %7
같이 %
로 시작하는 값들은 가상 레지스터를 뜻한다. 우측에 // user: %6
처럼 써있는 것은 해당 레지스터를 %6
에서 사용한다는 뜻이다.
마지막에 나오는 br bb1
은 현재의 basic block 에서 bb1
으로 control 을 넘기겠다는 뜻이다.
bb1: // Preds: bb0
br bb2 // id: %10bb2: // 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)
범위
여기서 알 수 있는 중요한 포인트는 bb3
와 bb4
에서 각각 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탄,, 올해는 다 읽을 수 있길 바라며 만약 틀린 부분이나 더 알려줄 부분 댓글로 남겨주시면 압도적 감사!