Xcode UnitTest ④ - 병렬 처리!! 퍼포먼스 테스팅(measure)

7 분 소요


목차

1: Xcode UnitTest ① - RxTest!! 테스트 스케줄러

2: Xcode UnitTest ② - RxSwift + Moya!! 네트워크도 목업으로 테스트

3: Xcode UnitTest ③ - RxSwift + Moya 네트워크 딜레이 테스팅!!

4: Xcode UnitTest ④ - 병렬 처리!! 퍼포먼스 테스팅(measure)


참고

How to write a performance test case(Apple Developer Forums)
jpsim/parallel_map.swift(Github Gist)
How to implement a parallel map in swift(StackOverflow)



문제 발견

보통 다루던 데이터가 그렇게 큰 편은 아니라 퍼포먼스에는 크게 신경을 안 썼었는데.
앱 구경하다가 어떤 아이템에서 뷰 띄우는 게 갑자기 어? 이거 좀 느리지 않음? 해서 서버 데이터 한 번 직접 보니까

1

헉… 일케 긴 건 첨 보네요


전제

struct Model1: Codable {
    let arr: [Model2]
}

struct Model2: Codable {
    let data1, data2: [Model3]
}

struct Model3: Codable {
    // ...
}

지금 상황은 대충 위와 같은 모델이다.
Model1을 변환을 해야 하는데, Model1은 Model2의 배열이고 Model2는 Model3의 배열인 데이터가 2개 있다.
그런데 Model2는 두 Model3 배열 데이터가 함께 처리되야 하고 배열의 한 엘리먼트가 앞뒤에도 영향이 있었지만, Model1의 어레이에서는 앞과 뒤 엘리먼트는 전혀 연관이 없어서 충분히 패러렐로 처리해도 되는 부분이었다.

암튼… 요약하자면 arr.map {}으로 변환을 했었는데, 여기서 map을 병렬 처리하면 훨씬 빨라지겠다는 점


병렬 처리?

병렬(Parallelism)과 동시성(Concurrecny)

  • 동시성
    • 스레드를 동시에 돌아가는 것처럼 번갈아 가면서 실행해주기
  • 병렬
    • 실제로 각 코어에서 따로 동시에 돌아가기
iOS는 멀티코어라서 딱히 상관은 없지만 갑자기 든 의문인데…

작업1과 작업2, 작업3이 각각 3초씩 걸리는 작업이라면, 3코어로 병렬 돌리면 3초만 걸릴 건데, 단일 코어 멀티 스레딩은 총 9초가 걸리는 게 맞을까?? 그럼 단일 코어에서 멀티 스레딩은 필요가 없나??

해서 찾아보고 도움이 많이 된 링크 → How does threading save time?

멀티 스레딩을 언제 어떻게 쓰는 게 맞을까여?? 일단 작업에 대해 두 경우를 생각해볼 수 있다

  • IO, 네트워크 작업(CPU 바운드 X)
    • 요청을 보내고 기다리게 됨. ⇒ 즉 그동안 다른 일 하기 ⇒ 매우 리즈너블하네여 패스
  • CPU 바운드 작업
    • 1과 달리 CPU에서 계속 처리 해 줘야 하는 작업(계산 등) ⇒ 얘는 단일 코어일 경우 멀티 스레딩이 의미가 없는 거 아냐?? ⇒ 아래에 계속

Case 1.

작업1, 작업2가 있고 각각 1초 걸리는 경우

  1. 단일 스레드
    • 작업1이 완료된 시간: 1초
    • 작업2가 완료된 시간: 2초
    • 총 시간: 2초
  2. 단일 코어, 두 스레드
    • 작업1이 완료된 시간: 2초
    • 작업2가 완료된 시간: 2초
    • 총 시간: 2초

둘 다 총 2초가 걸리지만 후자의 경우 작업1은 1초가 더 걸리므로 오히려 손해다.

  1. CPU 바운드 작업이며
  2. CPU보다 스레드가 더 많고(ex. 단일 코어에서의 멀티 스레딩)
  3. 중간 결과가 무의미한 작업일 경우

굳이 멀티 스레딩을 하면 오히려 스레드 간 스위칭 비용, 전체 시간이 길어질 수 있음.

그러면 저런 경우에 멀티 스레딩은 무조건 나쁜 걸까?? 도 더 생각해 보면

Case 2.

이번엔 작업1이 1초, 작업2가 10초 걸리는 경우

  1. 단일 스레드
    • 작업1이 완료된 시간: 1초
    • 작업2가 완료된 시간: 11초
    • 총 시간: 11초
  2. 단일 코어, 두 스레드
    • 작업1이 완료된 시간: 2초
    • 작업2가 완료된 시간: 11초
    • 총 시간: 11초

case1과 마찬가지로 작업1의 종료 시간이 좀 늦어짐

Case 3.

그러나!! 작업1이 10초, 작업2가 1초 걸리는 경우

  1. 단일 스레드
    • 작업1이 완료된 시간: 10초
    • 작업2가 완료된 시간: 11초
    • 총 시간: 11초
  2. 단일 코어, 두 스레드
    • 작업1이 완료된 시간: 11초
    • 작업2가 완료된 시간: 2초
    • 총 시간: 11초

멀티 스레딩을 할 경우 작업2의 종료 시간이 훨씬 빨라짐!!

결론: 경우 따라서 잘 사용하는 게 중요하다.


swift로 병렬 처리는 어케할까요??

DispatchQueue.concurrentPerform(iterations:execute:)
  • iterations
    • execute 블록의 반복 횟수
    • 해당 값이 클 수록 시스템이 멀티코어에서 효율적으로 균형을 유지할 수 있음
      • 이 기능의 이점을 최대한 활용하려면 반복 횟수를 사용 가능한 코어 수의 3배 이상으로 구성하세요 라네요
  • execute
    • 병렬로 실행할 블록. 현재 인덱스를 인자로 받음

블록을 지정된 횟수만큼 실행하고 반환하기 전에 모든 반복이 완료될 때까지 기다려줌.
그럼 map()을 병렬로 처리하려면 어케 해야할까요?


찾아봤던 코드

// https://stackoverflow.com/questions/42619447/how-to-implement-a-parallel-map-in-swift
extension Collection {
    func parallelMap<R>(_ transform: @escaping (Element) -> R) -> [R] {
        var res: [R?] = .init(repeating: nil, count: count)

        let lock = NSRecursiveLock()
        DispatchQueue.concurrentPerform(iterations: count) { i in
            let result = transform(self[index(startIndex, offsetBy: i)])
            lock.lock()
            res[i] = result
            lock.unlock()
        }

        return res.map({ $0! })
    }
}

빈 어레이를 하나 만들고, DispatchQueue.concurrentPerform()으로 각 엘리먼트를 변환하여 어레이를 채워준다.
음… 근데 이미 할당 다 된 거고 서로 간섭이 없는데 락은 왜 쓴 걸까 쓸 필요 없을 거 같은데
나는 굳이 Collection에 할 필요도 없고(index(startIndex, offsetBy: i)의 시간 복잡도는 O(i)니까 더 오래 걸림, Array 같은 RandomAccessCollection은 O(1)이지만)

락 부분 지우고 Array로 고쳐서 쓸까


하다가 찾아 본 다른 코드

// https://gist.github.com/jpsim/ec98b46de13842a207fae5b193ae556b
extension Array {
  func parallelMap<T>(transform: (Element) -> T) -> [T] {
    var result = ContiguousArray<T?>(repeating: nil, count: count)
    return result.withUnsafeMutableBufferPointer { buffer in
      DispatchQueue.concurrentPerform(iterations: buffer.count) { idx in
        buffer[idx] = transform(self[idx])
      }
      return buffer.map { $0! }
    }
  }
}

오… 생소한 게 있어서 좀 당황함

  • ContiguousArray?
    • 항상 연속적인 메모리 영역에 Element를 저장하는 배열
    • 일반 Array는 Element가 class나 @objc 프로토콜 타입이면 메모리에 연속적으로 저장되지 않음. NSArray로 바꾸거나 Objective-C API에 변환해서 보낼 수 있음
      • Element가 struct나 enum이면 ContiguousArray처럼 연속적임
    • 그래서 왜 씀?? → NSArray나 Objective-C랑 관련 없으면 일반 Array보다 ContiguousArray를 쓰면 더 빠르니까
      • Element가 struct나 enum이면 둘이 같은 효율
  • withUnsafeMutableBufferPointer()?
    • 배열의 가변 연속 저장소에 대한 포인터로 주어진 클로저를 호출
    • body에 반환 값이 있는 경우, 그 값은 withUnsafeMutableBufferPointer(_:)의 반환 값으로도 사용됨
    • 포인터 인수는 메소드 실행 동안만 유효, 따라서 포인터를 따로 저장하거나 반환하면 안 됨
    • 문서에서: Often, the optimizer can eliminate bounds checks within an array algorithm, but when that fails, invoking the same algorithm on the buffer pointer passed into your closure lets you trade safety for speed.
      • → 종종 옵티마이저는 배열 알고리즘 내에서 범위 검사를 제거할 수 있지만, 그게 실패할 경우 같은 알고리즘을 클로저에 전달된 버퍼 포인터에서 호출하면 안전을 속도로 교환할 수 있습니다…??
      • 즉 옵티마이저가 범위 검사를 생략할 수가 있는데, 생략을 못하게 될 수도 있다
      • 그럴 때 withUnsafeMutableBufferPointer()를 사용해서 버퍼 포인터를 받아서 사용하면 안전하진 않지만 속도가 빠르다라는 뜻인 듯
    • 그래서 왜 씀?? → 범위 검사가 필요 없을 때 빠르게 배열을 조작하기 위해서

즉 위 코드에서는 NSArray나 obj-C랑 관련 없는 작업만 하니까 ContiguousArray를 쓰고, 범위를 벗어난 조작을 할 일도 없기 때문에 withUnsafeMutableBufferPointer()를 사용함(으로 이해함)!
반환값은 withUnsafeMutableBufferPointer() 파라미터의 클로저에서 Element가 T?였던 버퍼를 T인 어레이로 map하여 리턴.

이거 좋은 거 같아서 사용하기로 함
나는 compactMap도 비슷하게 추가해서 사용함


퍼포먼스 테스팅

func testPerformanceExample() throws {
    // This is an example of a performance test case.
    self.measure {
        // Put the code you want to measure the time of here.
    }
}

아마 유닛 테스트 파일을 처음 만들면 저게 기본으로 나와 있을 거다.

func testPerformance() throws {
    let data1 = try! Data(contentsOf: Bundle(for: PerformanceTests.self).url(forResource: "bigData", withExtension: "json")!)
    let data2 = try! Data(contentsOf: Bundle(for: PerformanceTests.self).url(forResource: "bigData2", withExtension: "json")!)
    let data3 = try! Data(contentsOf: Bundle(for: PerformanceTests.self).url(forResource: "bigData3", withExtension: "json")!)
    
    self.measure {
        let model1 = try! JSONDecoder().decode(Model1.self, from: data1)
        _ = model1.convert()
        
        let model2 = try! JSONDecoder().decode(Model1.self, from: data1)
        _ = model2.convert()

        let model3 = try! JSONDecoder().decode(Model1.self, from: data1)
        _ = model3.convert()
    }
}

이렇게 measure 블럭 안에 측정할 코드들을 적어두면, 해당 블럭을 수행하는데 걸린 시간을 측정해준다. 기본으로 10번 수행하고 그 평균값, 표준편차 등을 구해준다.
옵션은 XCTMeasureOptions()measure(option:)에 파라미터로 넘겨줘서 커스텀 가능

짱 큰 데이터들 3개 정도 모아서 한 번 돌려 보기로 함.

별 건 아니지만 테스트에서 코드가 아닌 파일(string, json, …) 다루기
let url = Bundle(for: RefineCompareTableTests.self)
                        .url(forResource: FILE_NAME, withExtension: EXTENSION)!
let data = try Data(contentsOf: url!)

테스트 시에만 사용하는 파일의 경우, Target을 테스트에 설정하여 파일을 생성하고(생성 시 안 했다면 오른쪽 인스펙터에서 설정하면 됨)

번들도 main이 아닌 테스트로 하면 됨. 걍 현재 클래스 파일로 해도 된다

처음에 별 생각 없이 Bundle.main 하고 아 왜 안 됨 했었어서ㅋㅋ


그럼 한 번 그냥 map을 쓴 거랑 parallelMap을 쓴 거랑 퍼포먼스 비교를 해 볼까요


테스트 결과

«적용 전» [Time, seconds] average: 1.134, relative standard deviation: 4.617%, values: [1.086112, 1.065081, 1.056581, 1.123043, 1.153108, 1.106715, 1.150017, 1.196935, 1.184166, 1.213950]

«적용 후» [Time, seconds] average: 0.321, relative standard deviation: 5.212%, values: [0.349529, 0.319746, 0.324115, 0.288805, 0.304874, 0.332917, 0.311530, 0.323564, 0.341812, 0.317149]

오~~ 완전 많이 줄었다 3배가 넘게 차이난다.
근데 생각보다 더 오래 걸리기도 하고 편차가 왤케 클까 했는데 로그를 안 껐었네

로그도 다 빼고 실행해 본 결과

«적용 전» [Time, seconds] average: 0.812, relative standard deviation: 1.548%, values: [0.846853, 0.811108, 0.814523, 0.816815, 0.806404, 0.802449, 0.805633, 0.815261, 0.803270, 0.802517]

«적용 후» [Time, seconds] average: 0.197, relative standard deviation: 2.437%, values: [0.208380, 0.201618, 0.195276, 0.198906, 0.196422, 0.197121, 0.196075, 0.190808, 0.191353, 0.195538]

와! 4배!

되게 간단한 방법이지만 확 효율이 늘었다
이제 멀티 스레딩 열심히 해 봐야 겠음



유닛 테스트 시리즈는 이걸로 마무리일 듯 하네여

사실 테스트 자체보다 어떻게 해야 테스트 가능할까 해서 딴 길로 많이 샌 것 같기도 한데ㅋㅋ
그래서 테스트의 중요성도 많이 깨달았지만, 테스트가 가능하게 짠다는 것도 참 많이 중요함을 느꼈슴니다
클린 아키텍처!! 테스트가 가능하게 짜려면 목업 넣고 그러기 위해 굉장히 유연해져야 하기 때문에, 구조 상 OOP 다 지키고 깔끔하게 짜야할 수 밖에 없음

테스트 돌리면 계속 ❌ 이거 나오다가 결국 ✅ 이거 볼 때가 제일 기분 좋은 것 같네여 이렇게 보면 TDD도 나름 할 만할 것 같아 보이기도 하고…?
물론 프로젝트 처음부터 맨땅에 적용하기는 좀 그런데, 전체적으로 구조가 정해져서 안정적인 중후반에는 해볼만 할 것 같기도 하다
사실 상 수도코드 적고 그대로 구현한다는 느낌 아닌가 싶음

암튼 재밌었다!!


태그: ,

카테고리:

업데이트:

댓글남기기