【Swift】map関数の闇を暴き最速コードを創出せよ!〜フラグ組み合わせ生成から導く配列操作の革命〜
はじめに
こんにちは、株式会社メンバーズ Cross Application カンパニーの田原です。
開発において、複数の設定パラメータの組み合わせを網羅的にテストする必要が生じる場面があります。このような状況下では、効率的な組み合わせ生成処理が、テスト時間の短縮や品質向上に大きく貢献します。
この記事では、Swift における組み合わせ生成処理の実装パターンを複数提示し、各パターンのパフォーマンスを比較検証します。検証を通じて、map 関数が配列生成処理においてパフォーマンスのボトルネックとなるケースがあるという、重要な知見が得られました。
Swift の map 関数は、配列の要素変換処理を簡潔に記述できる便利な機能ですが、大規模な配列操作やパフォーマンスが重視される処理においては、慎重な使用が求められます。本記事では、map 関数の潜在的なパフォーマンス上の課題を明らかにし、for-in ループなどの代替手段について解説します。
さらに、配列の事前割り当てによるパフォーマンス向上など、Swift で効率的なコードを記述するための最適化テクニックについても詳細に考察します。これらの最適化テクニックは、組み合わせ生成処理に限らず、Swift で配列を扱う開発全般において有用です。
本記事は、Swift でパフォーマンスの高いコードを実装したい開発者、特に配列操作における最適化に関心のある開発者を対象としています。
前提
この記事では、複数のフラグの状態の組み合わせを生成する処理に焦点を当てています。具体的には、以下のような機能フラグの状態の組み合わせを生成することを目的としています。
struct FeatureFlags {
var featureA: Bool
var featureB: Bool
var featureC: Bool
var featureD: Bool
}
上記の例では、featureA から featureD までの4つの機能フラグがあり、それぞれが true または false の状態を取り得ます。この記事では、これらのフラグの全ての組み合わせ(例えば、featureA=true, featureB=false, featureC=true, featureD=false など)を効率的に生成する方法を探求します。
これらの組み合わせは、各組み合わせでのアプリケーションの動作を検証するための基盤となります。つまり、生成された全ての組み合わせを使用してアプリケーションの動作をテストし、様々なシナリオでの挙動を確認するために使用されます。
検証には、プログラムの実行時間を計測するために、『[Swift]プログラムの実行時間・メモリ使用量を計測する簡易メソッド』より以下の measureExecutionTime 関数を使用します。
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()
}
計測は Swift Playground 環境で行います。
組み合わせ生成の実装パターン徹底比較
複数の機能フラグの状態の組み合わせを生成する方法はいくつか考えられます。ここでは、代表的な実装パターンを紹介し、それぞれの特徴とパフォーマンスを比較します。
この記事では、フラグの数(flagCount)を受け取り、全ての組み合わせを [[Bool]] 型の配列として返す関数を実装します。例えば、flagCount = 3 の場合、[[true, true, true], [true, true, false], ..., [false, false, false]] のような結果を生成します。
以下に、それぞれの実装パターンと特徴を示します。
1. ループ(for-in) を使用した実装
func generateCombinationsUsingLoop(flagCount: Int) -> [[Bool]] {
var allCombinations: [[Bool]] = [[]]
for _ in 0..<flagCount {
allCombinations = allCombinations.flatMap { current in
[current + [true], current + [false]]
}
}
return allCombinations
}
特徴:
- シンプルなループ構造で、理解しやすい。
- flatMap を使用して組み合わせを生成するため、一時的な配列生成のオーバーヘッドがある。
2. 再帰を使用した実装
func generateCombinationsRecursively(flagCount: Int) -> [[Bool]] {
if flagCount == 0 { return [[]] }
let previous = generateCombinationsRecursively(flagCount: flagCount - 1)
return previous.flatMap { current in
[current + [true], current + [false]]
}
}
特徴:
- 再帰的な処理により、コードが短く済む。
- フラグの数が多い場合、スタックオーバーフローが発生する可能性がある。
3. reduce を使用した実装
func generateCombinationsUsingReduce(flagCount: Int) -> [[Bool]] {
return (0..<flagCount).reduce([[]]) { accumulatedCombinations, _ in
accumulatedCombinations.flatMap { current in
[current + [true], current + [false]]
}
}
}
特徴:
- reduce を使用することで、コードが簡潔になる。
- flatMap を使用するため、ループを使用した実装と同様のオーバーヘッドがある。
4. ビット演算を使用した実装
func generateCombinationsUsingBitManipulation(flagCount: Int) -> [[Bool]] {
let combinationCount = 1 << flagCount
return (0..<combinationCount).map { i in
(0..<flagCount).map { j in
(i & (1 << (flagCount - 1 - j))) != 0
}
}
}
特徴:
- ビット演算により、高速な処理が可能。
- コードが複雑になり、可読性が低下する。
パフォーマンス比較
上記の実装パターンについて、flagCount を 20 として実行時間を計測しました。
let flagCount = 20
measureExecutionTime(for: "Loop") {
let _ = generateCombinationsUsingLoop(flagCount: flagCount)
}
measureExecutionTime(for: "Recursion") {
let _ = generateCombinationsRecursively(flagCount: flagCount)
}
measureExecutionTime(for: "Reduce") {
let _ = generateCombinationsUsingReduce(flagCount: flagCount)
}
measureExecutionTime(for: "Bit") {
let _ = generateCombinationsUsingBitManipulation(flagCount: flagCount)
}
計測結果は以下の通りです。
[Loop] 実行時間: 6.198 秒
[Recursion] 実行時間: 6.115 秒
[Reduce] 実行時間: 6.189 秒
[Bit] 実行時間: 32.405 秒
結果の考察:map アンチパターン
上記の計測結果から、以下の重要な点が明らかになりました。
flatMap の影響:ループ、再帰、reduce の共通点
ループ、再帰、reduce を使用した実装は、いずれもほぼ同等の実行時間を示しました。これらの実装に共通するのは、配列生成に flatMap を使用している点です。
flatMap は、内部で一時的な配列を生成し、クロージャを各要素に適用するため、処理のオーバーヘッドが発生します。したがって、これらの実装の実行時間は、主に flatMap による配列生成のコストに依存すると考えられます。
参考:flatMap(_:)
map の劇薬:ビット演算のパフォーマンス低下
一方で、ビット演算を使用した実装は、他の実装と比較して圧倒的に遅い結果となりました。この実装では、配列生成に map を多用しています。
Swift の Array は値型であり、map は新しい配列を生成するたびに要素のコピーを行います。さらに、ネストされた map は、複数回の一時的な配列生成とコピーを伴います。
これらの要素が組み合わさり、ビット演算のコードでは、メモリ割り当て、コピー、ARC(自動参照カウント)の処理など、様々なオーバーヘッドが累積します。結果として、map の使用は、ビット演算本来のパフォーマンスを大幅に低下させたと考えられます。
参考:map(_:)
参考:Array
参考:Automatic Reference Counting
考察の検証
map vs for-in:配列生成のパフォーマンス比較
map関数のオーバーヘッドについて、実際にmap関数とfor-inループを比較し、パフォーマンスの違いを検証します。
検証コード
let arraySize = 1_000_000
// mapを使った配列生成
func generateArrayUsingMap(size: Int) -> [Int] {
return (0..<size).map { $0 }
}
// for-inループを使った配列生成
func generateArrayUsingForLoop(size: Int) -> [Int] {
var array: [Int] = []
for i in 0..<size {
array.append(i)
}
return array
}
// map 関数と for-in ループの比較
measureExecutionTime(for: "Map Function Array Generation") {
let _ = generateArrayUsingMap(size: arraySize)
}
measureExecutionTime(for: "For-in Loop Array Generation") {
let _ = generateArrayUsingForLoop(size: arraySize)
}
計測結果
[Map Function Array Generation] 実行時間: 1.399 秒
[For-in Loop Array Generation] 実行時間: 91.282 ミリ秒
この結果から、map関数を使用した配列生成は、for-inループを使用した配列生成よりも大幅に遅いことがわかります。
事前割り当ての効果
次に、配列の事前割り当てがパフォーマンスに与える影響を検証します。
検証コード
// 事前割り当てなしの配列生成
func generateArrayWithoutPreallocation(size: Int) -> [Int] {
var array: [Int] = []
for i in 0..<size {
array.append(i)
}
return array
}
// 事前割り当てありの配列生成
func generateArrayWithPreallocation(size: Int) -> [Int] {
var array: [Int] = Array(repeating: 0, count: size)
for i in 0..<size {
array[i] = i
}
return array
}
// 事前割り当ての有無の比較
measureExecutionTime(for: "Array Generation Without Preallocation") {
let _ = generateArrayWithoutPreallocation(size: arraySize)
}
measureExecutionTime(for: "Array Generation With Preallocation") {
let _ = generateArrayWithPreallocation(size: arraySize)
}
計測結果
[Array Generation Without Preallocation] 実行時間: 92.173 ミリ秒
[Array Generation With Preallocation] 実行時間: 10.299 ミリ秒
この結果から、事前割り当てを行わない場合、appendメソッドによるメモリ再割り当てのコストが発生し、大幅にパフォーマンスが低下することがわかります。特に、配列のサイズが大きい場合、このコストは無視できません。
結論
flatMap と map は、いずれも配列の生成に関わるオーバーヘッドを持ちます。特に、ネストされた map は、メモリ効率が著しく悪化させ、パフォーマンスを大幅に低下させる可能性があります。
したがって、パフォーマンスが重要な場面では、これらの関数を多用せず、for-in ループや配列の事前割り当てなどの代替手段を検討するべきです。大きな配列を生成する場合、メモリ使用量が増加し、コピーコストも高くなるため、特に注意が必要です。
ビット演算の最適化:驚異的なパフォーマンス向上
これまで、ビット演算の実装がmapのオーバーヘッドにより大幅に遅延することを確認しました。ここでは、ビット演算の実装を最適化し、そのパフォーマンスを改善します。
最適化されたビット演算の実装
// bit optimized
func generateCombinationsUsingBitManipulationOptimized(flagCount: Int) -> [[Bool]] {
let combinationCount = 1 << flagCount
var combinations: [[Bool]] = Array(repeating: Array(repeating: false, count: flagCount), count: combinationCount)
for combinationValue in 0..<combinationCount {
for bitIndex in 0..<flagCount {
let bitPosition = flagCount - 1 - bitIndex
combinations[combinationValue][bitIndex] = (combinationValue & (1 << bitPosition)) == 1
}
}
return combinations
}
measureExecutionTime(for: "BitOptimized") {
let _ = generateCombinationsUsingBitManipulationOptimized(flagCount: flagCount)
}
計測結果
[BitOptimized] 実行時間: 1.2 秒
最適化前のビット演算の実装と比較するため、他の実装パターンの計測結果を再度確認します。
[Loop] 実行時間: 6.198 秒
[Recursion] 実行時間: 6.115 秒
[Reduce] 実行時間: 6.189 秒
[Bit] 実行時間: 32.405 秒
パフォーマンスチューンされたビット演算処理が、圧倒的な速度で動作することが確認できました。
まとめ
Swiftでパフォーマンスの高いコードを記述するには、実装パターンごとの特性を理解することが重要です。特に、配列の事前割り当てや、mapの利用を控えfor-inループや効率的なビット演算を活用することが効果的です。
最後までお読みいただき、ありがとうございました。
この記事を書いた人
Advent Calendar!
Advent Calendar 2024