Notice
Recent Posts
Recent Comments
Link
관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - swift] [오픈소스 까보기] swift-benchmark - 벤치마크 방법 본문

오픈소스 까보기

[iOS - swift] [오픈소스 까보기] swift-benchmark - 벤치마크 방법

jake-kim 2023. 8. 31. 01:00

swift-benchmark 오픈소스

  • git repo
  • 특정 기능에 대해서 속도 최적화해보고 싶은 경우, 어떤 것이 더욱 최적화 되는 작업인지 확인해야하는데 이 때 벤치마크를 통해 의사결정이 가능
  • benchmark 사용하기 위해서 아래 URL을 사용하여 SPM으로 추가

  • 사용하는 것은 매우 간단하게, benchmark 함수의 클로저로 전달하면 완료
import Benchmark

benchmark("add string no capacity") {
    var x1: String = ""
    for _ in 1...1000 {
        x1 += "hi"
    }
}

benchmark("add string reserved capacity") {
    var x2: String = ""
    x2.reserveCapacity(2000)
    for _ in 1...1000 {
        x2 += "hi"
    }
}

Benchmark.main()

(빌드 최적화 상태에서 benchmark를 알아야하므로, 해당 스킴의 Run 설정을 변경)

  • Build Configuration을 Release로 변경
  • Debug excutable체크해제

(실행 후 콘솔을 확인)

  • 얼마나 시간이 걸리는지와, iterations 개수 파악이 가능
running add string no capacity... done! (1555.03 ms)
running add string reserved capacity... done! (1638.20 ms)

name                         time         std        iterations
---------------------------------------------------------------
add string no capacity       18333.000 ns ±   5.91 %      71320
add string reserved capacity 18166.000 ns ±  12.70 %      77317

BenchmarkSuite 개념

  • Suite는 관련된 테이스 케이스들을 묶은 하나의 집합
    • BenchmarkSuite라는것을 정의하여 benchmark에 하나씩 추가할 때 이 인스턴스로 저장하여, run() 시키면 벤치마크가 실행
public class BenchmarkSuite {
    public let name: String
    public let settings: [BenchmarkSetting]
    public var benchmarks: [AnyBenchmark] = []
    ...
}

BenchmarkSuite가 사용되는 플로우

  • benchmark 함수를 보면 클로저로 벤치마크할 코드를 주입
benchmark("add string no capacity") {
    var x1: String = ""
    for _ in 1...1000 {
        x1 += "hi"
    }
}
  • 내부 코드
    • 클로저를 defaultBenchmarkSuite라는 인스턴스의 benchmark 함수에 전달
// Benchmark.swift

public func benchmark(_ name: String, function: @escaping () throws -> Void) {
    defaultBenchmarkSuite.benchmark(name, function: function)
}
  • 타고 들어가면 이 인스턴스가 관리하고 있는 benchmarks 배열에 추가
// BenchmarkSuite.swift

public var benchmarks: [AnyBenchmark] = []

public func benchmark(_ name: String, function f: @escaping () throws -> Void) {
    let benchmark = ClosureBenchmark(name, settings: [], closure: f)
    register(benchmark: benchmark)
}

public func register(benchmark: AnyBenchmark) {
    benchmarks.append(benchmark)
}

BenchmarkRunner 개념

  • 위에서 추가하여 BenchmarkSuite로 관리되던 클로저들을 실행시켜야하는데, 이 실행시키는 역할을 BenchmarkRunner가 담당
  • BenchmarkRunner는 suites 배열을 가지고 있고, 이를 단순히 run 시킬수도 있고 밖으로부터 suite를 주입받아서 run 시킬 수 있는 기능 둘 다 제공
    • 결국 run(benchmark: AnyBenchmark, suite: BenchmarkSuite)을 실행
// BenchmarkRunner.swift

public struct BenchmarkRunner {
    let suites: [BenchmarkSuite]
    let settings: [BenchmarkSetting]
    let customDefaults: [BenchmarkSetting]
    var progress: ProgressReporter
    var reporter: BenchmarkReporter
    
    ...
    
    public mutating func run() throws {
        for suite in suites {
            try run(suite: suite)
        }
        reporter.report(results: results)
    }

    mutating func run(suite: BenchmarkSuite) throws {
        for benchmark in suite.benchmarks {
            try run(benchmark: benchmark, suite: suite)
        }
    }
    
    mutating func run(benchmark: AnyBenchmark, suite: BenchmarkSuite) throws {
        let settings = BenchmarkSettings([
            defaultSettings,
            self.customDefaults,
            suite.settings,
            benchmark.settings,
            self.settings,
        ])
    	...
    }
    
    ...
}

BenchmarkSettings 개념

  • BenchmarkRunner에서 run(benchmark: AnyBenchmark, suite: BenchmarkSuite)에서 benchmark 핵심 로직이 있는데, 가장 첫번째로 benchmarkSettings를 생성
mutating func run(benchmark: AnyBenchmark, suite: BenchmarkSuite) throws {
    let settings = BenchmarkSettings([
        defaultSettings,
        self.customDefaults,
        suite.settings,
        benchmark.settings,
        self.settings,
    ])
    
    ...
}
  • Setting은 value type이므로 settings값을 변경하는 처리보다는 config처럼 하나로 묶어서, 관련 코드들을 처리하기 쉽도록 instance만드는 것 (내부 코드를 보면 computed property위주로 존재)
// BenchmarkSetting.swift

public struct BenchmarkSettings {
   ...
   
    /// Convenience accessor for WarmupIterations setting.
    public var warmupIterations: Int {
        if let value = self[WarmupIterations.self]?.value {
            return value
        } else {
            return 0
        }
    }

    public var filter: String? {
        return self[Filter.self]?.value
    }

    public var filterNot: String? {
        return self[FilterNot.self]?.value
    }

    public var minTime: Double {
        if let value = self[MinTime.self]?.value {
            return value
        } else {
            fatalError("minTime must have a default.")
        }
    }

    public var timeUnit: TimeUnit.Value {
        if let value = self[TimeUnit.self]?.value {
            return value
        } else {
            fatalError("timeUnit must have a default.")
        }
    }
    ...
}

(밑에 전역변수로 defaultSettings도 존재)

public let defaultSettings: [BenchmarkSetting] = [
    MaxIterations(1_000_000),
    MinTime(seconds: 1.0),
    TimeUnit(.ns),
    InverseTimeUnit(.s),
    Format(.console),
    Quiet(false),
]

Benchmark 핵심 로직

  • BenchmarkRunner.swift에서 run 로직이 핵심이고, settings를 만든 후 계속 확인해보면 settings 인스턴스에서 제공해주는 filter를 통해 지금 테스트 하려는 것들을 대상만 동작하도록 필터링 작업 수행
// BenchmarkRunner.swift

mutating func run(benchmark: AnyBenchmark, suite: BenchmarkSuite) throws {
    let settings = BenchmarkSettings([
        defaultSettings,
        self.customDefaults,
        suite.settings,
        benchmark.settings,
        self.settings,
    ])
    
    let filter = try BenchmarkFilter(settings.filter, negate: false)
    if !filter.matches(suiteName: suite.name, benchmarkName: benchmark.name) {
        return
    }

    let filterNot = try BenchmarkFilter(settings.filterNot, negate: true)
    if !filterNot.matches(suiteName: suite.name, benchmarkName: benchmark.name) {
        return
    }
    
    ...
}
  • 필터링 작업이 끝나면 progress에 report 후 현재 시간을 기록
    • 주의) 현재 시간을 기록하는데, 이건 벤치마크할 대상 뿐만이 아닌, 아래에서 알아볼 warmupState하는 시간도 포함될 것
mutating func run(benchmark: AnyBenchmark, suite: BenchmarkSuite) throws {
	...
    
    progress.report(running: benchmark.name, suite: suite.name)
    let totalStart = now()
    
    ...
    
}

@inline(__always)
func now() -> UInt64 {
    return DispatchTime.now().uptimeNanoseconds
}
  • 이어서 warmupState라는 것을 만드는데, 원하는 타겟의 벤치마크를 수행하기 전에 준비하는 단계
    • 벤치마크 warmup은 실제 벤치마크 실행 전에 몇 번의 연습 루프(iteration)를 돌려 하드웨어나 컴파일러 등이 최적화를 수행할 수 있도록 하는 과정
mutating func run(benchmark: AnyBenchmark, suite: BenchmarkSuite) throws {
	...
    
    var warmupState: BenchmarkState? = nil
    if settings.warmupIterations > 0 {
        warmupState = doNIterations(
            settings.warmupIterations, benchmark: benchmark, suite: suite, settings: settings)
    }
    
    ...
    
}

func doNIterations(
    _ n: Int, benchmark: AnyBenchmark, suite: BenchmarkSuite, settings: BenchmarkSettings
) -> BenchmarkState {
    var state = BenchmarkState(iterations: n, settings: settings)
    do {
        try state.loop(benchmark)
    } catch is BenchmarkTermination {
    } catch {
        fatalError("Unexpected error: \(error).")
    }
    return state
}
  • warmup이 끝나면 이어서 n번 iternation을 돌면서 benchmark 수행
  • benchmark 수행 완료 후 progress에 report한 후 result를 저장하면 완료
mutating func run(benchmark: AnyBenchmark, suite: BenchmarkSuite) throws {
	...
    
    var state: BenchmarkState
    if let n = settings.iterations {
        state = doNIterations(n, benchmark: benchmark, suite: suite, settings: settings)
    } else {
        state = doAdaptiveIterations(
            benchmark: benchmark, suite: suite, settings: settings)
    }

    let totalEnd = now()
    let totalElapsed = totalEnd - totalStart

    progress.report(
        finishedRunning: benchmark.name, suite: suite.name, nanosTaken: totalElapsed)

    let result = BenchmarkResult(
        benchmarkName: benchmark.name,
        suiteName: suite.name,
        settings: settings,
        measurements: state.measurements,
        warmupMeasurements: warmupState != nil ? warmupState!.measurements : [],
        counters: state.counters)
    results.append(result)
}
  • 중간에 doNIternations과 doAdaptiveIterations이 있는데, doNIternations은 N번 수행하는 것이고 doAdaptiveIterations은 구현부에서 정한 적당한 값으로 반복문을 수행
    • while문을 돌면서 n값을 증가시켜주는데, predicNumberOfInterationsNeeded()메소드를 사용
// BenchmarkRunner.swift

func doAdaptiveIterations(
    benchmark: AnyBenchmark, suite: BenchmarkSuite, settings: BenchmarkSettings
) -> BenchmarkState {
    var n: Int = 1
    var state: BenchmarkState = BenchmarkState()

    while true {
        state = doNIterations(n, benchmark: benchmark, suite: suite, settings: settings)
        if n != 1 && hasCollectedEnoughData(state.measurements, settings: settings) { break }
        n = predictNumberOfIterationsNeeded(state.measurements, settings: settings)
        assert(
            n > state.measurements.count,
            "Number of iterations should increase with every retry.")
    }

    return state
}
  • predicNumberOfInterationsNeeded()메소드
    • 이전에 벤치마크를 통해 얼마나 걸렸는지 시간값(measurements)을 가지고 휴리스틱하게 적당한 값을 곱하여 구한 것
// BenchmarkRunner.swift

/// Heuristic for finding good next number of iterations to try, ported from google/benchmark.
func predictNumberOfIterationsNeeded(_ measurements: [Double], settings: BenchmarkSettings)
    -> Int
{
    let minTime = settings.minTime
    let iters = measurements.count

    // See how much iterations should be increased by.
    // Note: Avoid division by zero with max(timeInSeconds, 1ns)
    let timeInSeconds = measurements.reduce(0, +) / 1000000000.0
    var multiplier: Double = minTime * 1.4 / max(timeInSeconds, 1e-9)

    // If our last run was at least 10% of --min-time then we
    // use the multiplier directly.
    // Otherwise we use at most 10 times expansion.
    // NOTE: When the last run was at least 10% of the min time the max
    // expansion should be 14x.
    let isSignificant = (timeInSeconds / minTime) > 0.1
    multiplier = isSignificant ? multiplier : min(10.0, multiplier)
    if multiplier < 1.0 {
        multiplier = 2.0
    }

    // So what seems to be the sufficiently-large iteration count? Round up.
    let maxNextIters: Int = Int(max(multiplier * Double(iters), Double(iters) + 1.0).rounded())

    // But we do have *some* sanity limits though..
    let nextIters = min(maxNextIters, settings.maxIterations)

    return nextIters
}
  • 이렇게 쌓인 결과들은 results 배열에 삽입
// BenchmarkRunner.swift

public var results: [BenchmarkResult] = []

...

mutating func run(benchmark: AnyBenchmark, suite: BenchmarkSuite) throws {
	...

    let result = BenchmarkResult(
        benchmarkName: benchmark.name,
        suiteName: suite.name,
        settings: settings,
        measurements: state.measurements,
        warmupMeasurements: warmupState != nil ? warmupState!.measurements : [],
        counters: state.counters)
    results.append(result)
}
  • 이 메소드, mutating func run(benchmark: AnyBenchmark, suite: BenchmarkSuite)를 호출했던 곳으로 다시 되돌아가보면, 이 결과들을 reporter에 report하는 코드가 존재

 

// BenchmarkRunner.swift

public mutating func run() throws {
    for suite in suites {
        try run(suite: suite)
    }
    reporter.report(results: results)
}
  • reporter의 report 메소드에서 결과로 나온 result들을 보기 좋게 print()를 수행하여 완료
// BenchmarkReporter.swift

mutating func report(results: [BenchmarkResult]) {
    let (rows, columns) = BenchmarkColumn.evaluate(results: results, pretty: true)

    let widths: [BenchmarkColumn: Int] = Dictionary(
        uniqueKeysWithValues:
            columns.map { column in
                (
                    column,
                    rows.compactMap {
                        row in row[column]?.count
                    }.max() ?? 0
                )
            }
    )

    print("", to: &output)
    for (index, row) in rows.enumerated() {
        let components: [String] = columns.compactMap { column in
            var string: String
            if let value = row[column] {
                string = value
            } else {
                string = ""
            }
            let width = widths[column]!
            let alignment = index == 0 ? .left : column.alignment
            switch alignment {
            case .left:
                return string.rightPadding(toLength: width, withPad: " ")
            case .right:
                return string.leftPadding(toLength: width, withPad: " ")
            }
        }

        let line = components.joined(separator: " ")
        print(line, to: &output)

        if index == 0 {
            print(String(repeating: "-", count: line.count), to: &output)
        }
    }
}

(완료)

running add string no capacity... done! (1555.03 ms)
running add string reserved capacity... done! (1638.20 ms)

name                         time         std        iterations
---------------------------------------------------------------
add string no capacity       18333.000 ns ±   5.91 %      71320
add string reserved capacity 18166.000 ns ±  12.70 %      77317

정리

  • benchmark() { 테스트영역 }: 클로저에 벤치마크를 수행할 코드를 넣으면 BenchmarkSuite클로저를 형태로 저장
  • BenchmarkRunner의 메소드인 Benchmark.run()을 호출하면 시간을 기록하여 warmup하고 benchmark를 수행
    • benchmark는 반복문을 돌면서 수행
    • 테스트가 끝나면 results 배열에 저장하고, reporter 인스턴스에게 report()를 요청하면 result를 토대로 쉽게 알아볼 수 있도록 print를 수행

* 참고

https://github.com/google/swift-benchmark

Comments