⏱️

[Swift]プログラムの実行時間・メモリ使用量を計測する簡易メソッド

2024/12/16に公開

はじめに

コードレビューの中で、あるいは個人開発や学習の中で、「この実装はパフォーマンスどうなんだろう」と時に疑問を感じつつ、検証が面倒でモヤモヤした気持ちに蓋をすることが・・・

正直、結構あります。
(レビューでコードのパフォーマンスを指摘されて、「その指摘、本当?」と内心疑ったことも🙏)

そんなモヤモヤを年末大掃除すべく、簡単にパフォーマンス計測するための簡易的なメソッドを作ろうと思い立ったのが、本記事のモチベーションです。

本記事では、Swiftによるパフォーマンス計測メソッドを解説し、実例を通じてその有用性(?)を示します。

作成したもの

この記事で紹介する計測メソッドとそのヘルパー実装は、GitHub Gistにまとめてあります。

実行時間の計測メソッド

まずは、処理の実行時間を計測する簡単なメソッドを実装してみます。

実装

以下のコードは、処理の開始時刻と終了時刻をCFAbsoluteTimeGetCurrent()で取得し、実行時間を計測する方法を示しています。
クロージャで受け取った処理の開始前と終了後の時刻の差分を取ることで実行時間を算出し、見やすいように秒またはミリ秒の形式にしてコンソール出力します。

func measureExecutionTime(for processName: String = #function, _ processing: @escaping () -> Void) {
    let startTime = CFAbsoluteTimeGetCurrent()
    defer {
        let elapsedTime = CFAbsoluteTimeGetCurrent() - startTime
        
        if elapsedTime >= 1.0 {
            print("[\(processName)] 実行時間: \(String(format: \"%.3f\", elapsedTime)) 秒")
        } else {
            let milliseconds = elapsedTime * 1000
            print("[\(processName)] 実行時間: \(String(format: \"%.3f\", milliseconds)) ミリ秒")
        }
    }
    processing()
}

使用例

例えば、以下のように20の階乗を求める処理を計測できます。

func factorial20() {
    var result = 1
    for i in 1...20 {
        result *= i
    }
    print("20! = \(result)")
}

measureExecutionTime(for: "Factorial") {
    factorial20()
}
実行結果
20! = 2432902008176640000
[Factorial] 実行時間: 0.188 ミリ秒

また、特定の関数内の処理を丸ごとラップして計測する場合も、以下のように簡潔に記述できます。

func factorial20() {
    measureExecutionTime() {
        var result = 1
        for i in 1...20 {
            result *= i
        }
        print("20! = \(result)")
    }
}

factorial20()
実行結果
20! = 2432902008176640000
[factorial20()] 実行時間: 0.188 ミリ秒

[検証] CFAbsoluteTimeGetCurrent(Date, NSDateとの比較)

Date型やNSDate型を用いた計測では、不要な日時処理やオブジェクト生成のオーバーヘッドが発生するため(本当でしょうか?この点は後ほど検証します。)

今回作成した関数を用いて、実際に比較計測してみました。1億回繰り返して処理時間にどのような違いが生じるのかを確認します。

    let iterations = 100_000_000
    
    measureExecutionTime(for: "NSDate") {
        for _ in 0..<iterations {
            let start = NSDate()
            let end = NSDate()
            _ = end.timeIntervalSince(start as Date)
        }
    }
    
    measureExecutionTime(for: "Date") {
        for _ in 0..<iterations {
            let start = Date()
            let end = Date()
            _ = end.timeIntervalSince(start)
        }
    }
    
    measureExecutionTime(for: "CFAbsoluteTimeGetCurrent") {
        for _ in 0..<iterations {
            let start = CFAbsoluteTimeGetCurrent()
            let end = CFAbsoluteTimeGetCurrent()
            _ = end - start
        }
    }
実行結果
[NSDate] 実行時間: 28.376 秒
[Date] 実行時間: 23.438 秒
[CFAbsoluteTimeGetCurrent] 実行時間: 22.540 秒

NSDateはObjective-Cのクラスであり、オブジェクト生成時にメモリ割り当てやObjective-Cランタイムによる処理が発生します。そのため、DateCFAbsoluteTimeGetCurrentの処理と比較すると、オーバーヘッドが大きくなりやすいと言えます。
一方で、Dateの処理時間はCFAbsoluteTimeGetCurrentと比ベて約4%程度しか長くないという結果になり、これは意外でした。この差は、Swiftで最適化された構造体としてのDateが軽量かつ効率的に動作することを示唆しています。しかし、CFAbsoluteTimeGetCurrentが最も処理時間の少ない結果を示したことから、精度が求められる場面でCFAbsoluteTimeGetCurrentを用いることの合理性が明らかになったといえます。

メモリ使用量の計測メソッド

実行時間の計測ができるようになりましたが、メモリ使用量を正確に取得する方法については、まだ試行錯誤の段階です。

利用可能なAPI

調査の結果、以下のようなAPIを使用することで、プロセスの物理メモリや仮想メモリに関する詳細な情報を取得できることがわかりました。

  1. task_vm_info_data_t
  2. task_basic_info
  3. mach_task_basic_info

これらのAPIを使用することで、プロセスの物理メモリや仮想メモリに関する詳細な情報を取得できます。

メモリ使用量を取得するメソッドの実装

本記事の実装では、task_vm_info_data_tを採用して、プロセスの仮想メモリや物理メモリの使用量を計測するように実装しました。

enum MemoryType {
    case physical
    case virtual
}

func getMemoryUsage(for type: MemoryType) -> UInt64? {
    var info = task_vm_info_data_t()
    var count = mach_msg_type_number_t(MemoryLayout<task_vm_info_data_t>.size) / 4
    let result = withUnsafeMutablePointer(to: &info) {
        $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) {
            task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count)
        }
    }
    guard result == KERN_SUCCESS else { return nil }

    switch type {
    case .physical:
        return UInt64(info.phys_footprint)
        
    case .virtual:
        return UInt64(info.virtual_size)
    }
}
解説

var info = task_vm_info_data_t()

task_vm_info_data_t構造体は、メモリに関する詳細な統計情報を保持するために使用されます。この構造体は名前に「vm」(仮想メモリ)を含んでいますが、実際にはphys_footprintというフィールドから物理メモリの使用量にアクセスできます。

var count = mach_msg_type_number_t(MemoryLayout<task_vm_info_data_t>.size) / 4

  • countは、task_info関数で渡すメモリ領域のバイト数を指定するための変数です。
    MemoryLayout<task_vm_info_data_t>.sizeは、task_vm_info_data_t構造体のバイトサイズ(バイト数)を返します。このサイズを4で割っているのは、task_info関数が引数として渡すときに、integer_t型で処理するため、適切なバイト数に合わせるためです。

let result = withUnsafeMutablePointer(to: &info) {...}

  • withUnsafeMutablePointer(to:)は、ポインタを安全に操作するためのクロージャを提供します。ポインタを直接操作する場合、メモリ管理に慎重を期す必要があるため、withUnsafeMutablePointer(to:)を使って安全にポインタ操作を行います。&infoは、infoのアドレス(ポインタ)をクロージャに渡し、その内容を直接操作できるようにします。

  • クロージャ内では、withMemoryRebound(to:capacity:)を使って、infoのメモリをinteger_t型に「リバウンド」させています。これにより、型を変更することなくポインタを扱い、task_info関数が期待する型でメモリを処理できるようにします。integer_tは通常4バイトの整数型です。

  • capacity: Int(count)は、リバウンドするメモリ領域のサイズを指定します。countはバイト数単位で計算され、infoのメモリサイズに基づいています。

task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), $0, &count)

  • task_info関数は、指定したタスク(プロセス)の情報を取得するために使用されるAPIです。この関数は、タスクのメモリ使用状況を含む詳細な情報を取得できます。mach_task_self_は、現在のタスク(プロセス)を指し、これにより実行中のプロセスの情報を取得します。

  • 引数のtask_flavor_t(TASK_VM_INFO)は、取得したい情報の種類を指定します。TASK_VM_INFOは仮想メモリ関連の情報を要求するため、物理メモリの使用状況を正確に反映するphys_footprintvirtual_sizeを取得するために使用します。

  • $0は、infoへのポインタであり、task_info関数はこのポインタを通じて情報をinfo構造体に格納します。

  • 引数の&countは、取得したデータのサイズを格納するために使用されます。この引数を使うことで、関数実行後にデータのサイズを確認することができます。

guard result == KERN_SUCCESS else { return nil }

  • KERN_SUCCESSは、task_info関数が成功したことを示す定数です。この値を使って、メモリ情報の取得が正常に行われたかどうかを判定します。

メモリ使用量と最大メモリ使用量を計測するメソッドの実装

先に実装したgetMemoryUsageメソッドを利用して、クロージャに渡した処理のメモリ使用量とメモリの最大使用量を計測するメソッドを作成します。

func measureMaxMemoryUsage(
    for processName: String = #function,
    memoryType: MemoryType = .physical,
    samplingInterval: TimeInterval = 0.05,
    _ processing: @escaping () -> Void
) {
    var maxMemory: UInt64 = 0
    @Atomic var stopMonitoring = false

    let monitoringQueue = DispatchQueue(label: "MemoryMonitoringQueue")
    let monitoringGroup = DispatchGroup()

    monitoringGroup.enter()
    monitoringQueue.async {
        while !stopMonitoring {
            if let currentMemory = getMemoryUsage(for: memoryType) {
                maxMemory = max(maxMemory, currentMemory)
            }
            Thread.sleep(forTimeInterval: samplingInterval)
        }
        monitoringGroup.leave()
    }

    let startMemory = getMemoryUsage(for: memoryType)

    defer {
        stopMonitoring = true
        monitoringGroup.wait()

        var memoryUsed: UInt64?
        if let endMemory = getMemoryUsage(for: memoryType), let startMemory = startMemory {
            memoryUsed = endMemory >= startMemory ? endMemory - startMemory : 0
        }

        let memoryUsedString = memoryUsed != nil
            ? ByteCountFormatter.string(fromByteCount: Int64(memoryUsed!), countStyle: .memory)
            : "未計測"
        let maxMemoryString = ByteCountFormatter.string(fromByteCount: Int64(maxMemory), countStyle: .memory)

        print("[\(processName)] 使用メモリ: \(memoryUsedString), 最大メモリ使用量: \(maxMemoryString)")
    }

    processing()
}

@propertyWrapper
class Atomic<Value> {
    private var value: Value
    private let queue = DispatchQueue(label: "Atomic")

    init(wrappedValue: Value) {
        self.value = wrappedValue
    }

    var wrappedValue: Value {
        get { queue.sync { value } }
        set { queue.sync { value = newValue } }
    }
}

[検証] NSCacheDictionaryの使用メモリの比較

作成したメソッドを使用して、大きなデータをキャッシュする際、NSCacheDictionary を使用した場合のメモリ使用状況を比較・検証します。NSCacheは、データの「コスト」に基づいて自動的に古いデータを削除する機能があるため、メモリ制約のある環境で特に有効だと考えられます。

検証の準備

データ生成用のクラス

Data型は、NSCacheにそのまま格納できないため、CacheableDataクラスを作成してラップしています。

class CacheableData {
    let data: Data
    
    init(_ data: Data) {
        self.data = data
    }
}

func generateLargeData(sizeInMB: Int) -> CacheableData {
    let data = Data(repeating: 0, count: sizeInMB * 1024 * 1024) // 1MB = 1024 * 1024 bytes
    return .init(data)
}

キャッシュ管理クラス

  • NSCacheStore: NSCacheを使用してキャッシュを管理するクラス。キャッシュ制限を500MBに設定。
  • DictionaryCacheStore: Dictionaryを使用してキャッシュを管理するクラス。
class NSCacheStore {
    private let cache: NSCache<NSString, CacheableData>
    
    init() {
        self.cache = NSCache<NSString, CacheableData>()
        cache.totalCostLimit = 500 * 1024 * 1024
    }
    
    func storeLargeData() {
        for i in 0..<10_000 {
            let largeData = generateLargeData(sizeInMB: 5)
            cache.setObject(largeData, forKey: "\(i)" as NSString, cost: largeData.data.count)
        }
    }
}

class DictionaryCacheStore {
    private var cache: [String: CacheableData]
    
    init() {
        self.cache = [String: CacheableData]()
    }
    
    func storeLargeData() {
        for i in 0..<10_000 {
            let largeData = generateLargeData(sizeInMB: 5)
            cache["\(i)"] = largeData
        }
    }
}

検証

それぞれのキャッシュ管理クラスに大量のデータを保存し、物理メモリの使用量を計測します。

    let nsCacheStore = NSCacheStore()
    measureMaxMemoryUsage(for: "NSCache", memoryType: MemoryType.physical) {        
        nsCacheStore.storeLargeData()
    }
    
    let dictionaryCacheStore = DictionaryCacheStore()
    measureMaxMemoryUsage(for: "Dictionary for cache") {
        dictionaryCacheStore.storeLargeData()
    }
結果
[NSCache] 使用メモリ: 139.2 MB, 最大メモリ使用量: 627 MB
[Dictionary for cache] 使用メモリ: 60.95 GB, 最大メモリ使用量: 60.8 GB

NSCacheは、設定された totalCostLimit(500MB)を超えたデータを自動的に削除します。そのため、使用メモリは効率的に制御され、最大メモリ使用量も抑えられています。一方でDictionaryは自動的にメモリを解放する仕組みを持たないため、すべてのデータを保持します。その結果、膨大なメモリを消費することになりました。

時間計測メソッドとメモリ計測メソッドの統合

measureExecutionTimeメソッドとmeasureMaxMemoryUsageメソッドを統合して一つのmeasureExecutionTimeAndMaxMemoryメソッドを作成します。
引数にmonitorMemorySizeフラグを持たせて、メモリ監視のON/OFFを切り替えられるようにします。

実装

func measureExecutionTimeAndMaxMemory(
    for processName: String = #function,
    memoryType: MemoryType = .physical,
    monitorMemorySize: Bool = true,
    samplingInterval: TimeInterval = 0.01,
    _ processing: @escaping () -> Void
) {
    var maxMemory: UInt64 = 0
    @Atomic var stopMonitoring = false

    let monitoringQueue = DispatchQueue(label: "MemoryMonitoringQueue")
    let monitoringGroup = DispatchGroup()

    if monitorMemorySize {
        monitoringGroup.enter()
        monitoringQueue.async {
            while !stopMonitoring {
                if let currentMemory = getMemoryUsage(for: memoryType) {
                    maxMemory = max(maxMemory, currentMemory)
                }
                Thread.sleep(forTimeInterval: samplingInterval)
            }
            monitoringGroup.leave()
        }
    }

    let startTime = CFAbsoluteTimeGetCurrent()
    let startMemory = monitorMemorySize ? getMemoryUsage(for: memoryType) : nil

    defer {
        let endTime = CFAbsoluteTimeGetCurrent()
        var memoryUsed: UInt64?

        if monitorMemorySize {
            stopMonitoring = true
            monitoringGroup.wait()

            if let endMemory = getMemoryUsage(for: memoryType), let startMemory = startMemory {
                memoryUsed = endMemory >= startMemory ? endMemory - startMemory : 0
            }
        }

        let elapsedTime = endTime - startTime
        let timeString: String
        if elapsedTime >= 1.0 {
            timeString = "\(String(format: "%.3f", elapsedTime)) 秒"
        } else {
            let milliseconds = elapsedTime * 1000
            timeString = "\(String(format: "%.3f", milliseconds)) ミリ秒"
        }

        let memoryUsedString = memoryUsed != nil
            ? ByteCountFormatter.string(fromByteCount: Int64(memoryUsed!), countStyle: .memory)
            : "未計測"
        let maxMemoryString = monitorMemorySize
            ? ByteCountFormatter.string(fromByteCount: Int64(maxMemory), countStyle: .memory)
            : "未計測"

        print("[\(processName)] 実行時間: \(timeString), 使用メモリ: \(memoryUsedString), 最大メモリ使用量: \(maxMemoryString)")
    }

    processing()
}

実行時間と使用メモリを計測して遊ぶ

measureExecutionTimeAndMaxMemoryを使っていくつかのケースを検証してみます。

[検証] for-in-loop vs forEach-method

for文とforeEachではどちらがより効率的に動作するかを比較します。
1 から 1,000,000 までの整数を配列に格納し、それらを合計する処理です。

    measureExecutionTimeAndMaxMemory(for: "for-in-loop") {
        let array = [Int](0...1_000_000)
        var sum = 0
        for value in array {
            sum += value
        }
        print(sum)
    }
    
    measureExecutionTimeAndMaxMemory(for: "forEach-method") {
        let array = [Int](0...1_000_000)
        var sum = 0
        array.forEach {
            sum += $0
        }
        print(sum)
    }
結果
500000500000
[for-in-loop] 実行時間: 293.412 ミリ秒, 使用メモリ: 336 KB, 最大メモリ使用量: 11.4 MB
500000500000
[forEach-method] 実行時間: 294.901 ミリ秒, 使用メモリ: 320 KB, 最大メモリ使用量: 11.4 MB

for-inループ とforEachメソッドは、どちらを使用しても大きな差はないようです。好みやコードの可読性を基準に選択して問題なさそうです。
reduceでも同じ処理を書けるので検証してみます。

    measureExecutionTimeAndMaxMemory(for: "reduce") {
        let array = [Int](0...1_000_000)
        let sum = array.reduce(0, +)
        print(sum)
    }
結果
500000500000
[reduce] 実行時間: 293.346 ミリ秒, 使用メモリ: 272 KB, 最大メモリ使用量: 11.4 MB

若干ですがメモリ使用量が少なくなりました。reduceを使用すると、処理の簡潔さを保ちながら、若干のメモリ節約が期待できるかもしれません。

[検証] Modulo vs Bitwise

モジュロ演算(%)を使って偶数判定を行う方法と、ビット演算(&)を使って偶数判定を行う方法でパフォーマンスに違いがあるかを検証します。

    var evensModulo = [Int]()
    measureExecutionTimeAndMaxMemory(for: "isEven-Modulo") {
        let array = [Int](0...10_000_000)
        for value in array {
            if value % 2 == 0 {
                evensModulo.append(value)
            }
        }
        print("evens.count: \(evensModulo.count)")
    }
    
    var evensBitwise = [Int]()
    measureExecutionTimeAndMaxMemory(for: "isEven-Bitwise") {
        let array = [Int](0...10_000_000)
        for value in array {
            if value & 1 == 0 {
                evensBitwise.append(value)
            }
        }
        print("evens.count: \(evensBitwise.count)")
    }
evens.count: 5000001
[isEven-Modulo] 実行時間: 3.123, 使用メモリ: 56.6 MB, 最大メモリ使用量: 136.2 MB
evens.count: 5000001
[isEven-Bitwise] 実行時間: 3.119, 使用メモリ: 56.6 MB, 最大メモリ使用量: 157 MB

有意な差は見られないように思います。
ビット演算による偶数判定式は見慣れない人もいそうなので、可読性の観点でいえばモジュロ演算で判定するのが無難かもしれません。

[検証] Memoization vs Non-Memoization

動的計画法(メモ化)を使用した再帰関数と、メモ化を使用しない再帰関数のパフォーマンスを比較します。
ここでは配列からk個の要素の組み合わせを取得する関数を用意します。

func combinationsWithoutMemo<T>(_ array: [T], choose k: Int) -> [[T]] {
    func helper(startIndex: Int, k: Int) -> [[T]] {
        if k == 0 {
            return [[]]
        }
        if startIndex == array.count {
            return []
        }
        
        let currentElement = array[startIndex]
        let subCombinationsWith = helper(startIndex: startIndex + 1, k: k - 1).map { [currentElement] + $0 }
        let subCombinationsWithout = helper(startIndex: startIndex + 1, k: k)
        
        return subCombinationsWith + subCombinationsWithout
    }
    
    return helper(startIndex: 0, k: k)
}

func combinationsWithMemo<T>(_ array: [T], choose k: Int) -> [[T]] {
    var memo: [String: [[T]]] = [:]
    
    func helper(startIndex: Int, k: Int) -> [[T]] {
        let key = "\(startIndex)-\(k)"
        if let result = memo[key] {
            return result
        }
        
        if k == 0 {
            return [[]]
        }
        if startIndex == array.count {
            return []
        }
        
        let currentElement = array[startIndex]
        let subCombinationsWith = helper(startIndex: startIndex + 1, k: k - 1).map { [currentElement] + $0 }
        let subCombinationsWithout = helper(startIndex: startIndex + 1, k: k)
        
        let result = subCombinationsWith + subCombinationsWithout
        
        memo[key] = result
        
        return result
    }
    
    return helper(startIndex: 0, k: k)
}

1から20までの整数配列から10個の要素を選ぶ組み合わせを算出して、計測します。

    measureExecutionTimeAndMaxMemory(for: "Combinations without Memo") {
        print("combinationsCount: \(combinationsWithoutMemo([Int](1...20), choose: 10).count)")
    }
    measureExecutionTimeAndMaxMemory(for: "Combinations with Memo") {
        print("combinationsCount: \(combinationsWithMemo([Int](1...20), choose: 10).count)")
    }
結果
combinationsCount: 184756
[Combinations without Memo] 実行時間: 1.001 秒, 使用メモリ: 6.1 MB, 最大メモリ使用量: 27.6 MB
combinationsCount: 184756
[Combinations with Memo] 実行時間: 149.964 ミリ秒, 使用メモリ: 7.4 MB, 最大メモリ使用量: 49.1 MB

実行時間の違いが非常に顕著です。特に再帰処理で同じパターンを何度も計算する場合、メモ化を使うことで効率的に処理ができることがわかります。再計算を避けることで、パフォーマンスが大幅に向上します。

あとがき

measureExecutionTimeAndMaxMemory()メソッドを作成し利用することで、これまで何となく理解したつもりでいたパフォーマンスのアンチパターンや、まことしやかに囁かれるオカルト的な実装について、「実際どうなのか?」を気軽に計測して確認できるようになりました。

これにより、心の中で黒く濁り始めていたモヤモヤを消し去り、晴れやかな気持ちで2025年を迎える準備が整いました。

最後までお読みいただき、ありがとうございます。

少し早いですが、良いお年をお迎えください。

参考文献

Discussion