以下のコードでは2通りの方法でIntの配列を作っています。
どちらの方がパフォーマンスが良いでしょうか。
let sequence = stride(from: 0, to: 1000000, by: 1)
// パターンA
let arrayA = sequence.map({ $0 * ($0 + 2) }).filter({ $0 % 2 == 0 }).map({ $0 + 1 })
// パターンB
let lazySeq = sequence.lazy.map({ $0 * ($0 + 2) }).filter({ $0 % 2 == 0 }).map({ $0 + 1 })
let arrayB = Array(lazySeq)
arrayA == arrayB // => true
答えは、パターンBの方がパフォーマンスが良いです。
この記事ではその理由を解説します。
お願い
注意して書いたつもりですが、間違いや勘違いなどあるかもしれません。その場合はご指摘お願いいたします
環境
- Swift 4.1
- macOS 10.13.4
map
やfilter
にはちょっと非効率なところがある
まずはパターンAの処理の詳細を考えてみます。
パターンAのほうが遅い理由は無駄な配列を生成しているからです。
もう一度コードをみてみましょう。
let sequence = stride(from: 0, to: 1000000, by: 1)
let arrayA = sequence.map({ $0 * ($0 + 2) }).filter({ $0 % 2 == 0 }).map({ $0 + 1 })
処理の最初の部分は
sequence.map({ $0 * ($0 + 2) })
になっています。
ドキュメントによると、sequence.map({ $0 * ($0 + 2) })
は配列を返します。これが1つ目の配列です。
配列に対してfilter({ $0 % 2 == 0 })
を呼ぶともう一つ配列を作ります。
最後のmap({ $0 + 1 })
が3つ目の配列をつくります。
結局、配列を3つ作っています。
最終的に必要な配列はarrayA
に代入するための1つだけで、残りの2つはすぐに解放されてしまいます。これは無駄です。
今回の例ではひとつひとつの配列の要素数が多いのためなおさらパフォーマンスに悪影響を与えます。
対して、パターンBは配列を1つしか作りません。
その理由をこれから解説します。
今度はパターンBのコードをもう一度確認しましょう。
let sequence = stride(from: 0, to: 1000000, by: 1)
let lazySeq = sequence.lazy.map({ $0 * ($0 + 2) }).filter({ $0 % 2 == 0 }).map({ $0 + 1 })
let arrayB = Array(lazySeq)
途中にあるsequence.lazy
は
LazySequence<StrideTo<Int>>
という型の値を返します。
こいつがキモになります。
LazySequence
/ LazyCollection
とは
記事の冒頭のコードはちょっとだけ忘れて、別の例でLazySequence
/ LazyCollection
について解説します。
Sequence
やCollection
のlazy
の典型的な使い方は重い処理を遅延させる処理です。
/// 画像を描画する重い処理
func heavyImageRender(_ index: Int) -> UIImage {
// 重い処理
return heavyImage
}
// 画像は描画されない
let lazyImageSequence = stride(from: 0, to: 100, by: 1).lazy.map(heavyImageRender)
// 1枚目のみ描画される
let firstImage = lazyImageSequence.first
コメントにもありますが、lazyImageCollection.first
で1枚目の画像が本当に必要になったときになって初めて画像を描画します。
対して、stride(from: 0, to: 100, by: 1).lazy.map(heavyImageRender)
からlazy
を取り除くと100枚の画像を全て描画してしまいます。
このように、Collection
やSequence
の要素が本当に必要になるまで評価しないメカニズムを提供してくれるのがlazy
です( 「本当に必要になるまで評価しない」という処理は遅延評価ともよばれます )。
たとえば、Tinderのようなカードスワイプ系アプリで全ての画像をあらかじめロードしておくとパフォーマンスが悪くなってしまう場面などに効果を発揮します。
さきほどのコードにあるlazyImageSequence
の型はLazyMapSequence<StrideTo<Int>, UIImage>
です。
なんだかややこしい型です。最初のLazyMapSequence
がSequence
に対してmap
の処理を遅延評価してくれるという意味になります。
後半の<StrideTo<Int>, UIImage>
という部分はStride<Int>
がおおもとのSequenceで、最終的な評価の結果がUIImage
であることを意味します。
他にもバリエーションがあります。
-
LazyFilterSequence
-
Sequence
に対してfilter
の処理を遅延評価してくれる
-
-
LazyMapCollection
-
Collection
に対してmap
の処理を遅延評価してくれる
-
いろいろなバリエーションがありますが、基本的には、map
やfilter
といった処理を遅延させてくれます。
これらをSwiftではLazySequenceやLazyCollectionと言うようです。
LazySequence
/ LazyCollection
の実装
もう一度この記事の冒頭のコードを確認します。
let sequence = stride(from: 0, to: 1000000, by: 1)
let lazySeq = sequence.lazy.map({ $0 * ($0 + 2) }).filter({ $0 % 2 == 0 }).map({ $0 + 1 })
let arrayB = Array(lazySeq)
sequence.lazy
の型はLazySequence<StrideTo<Int>>
でした。
LazySequence
の実装を見ましょう。
重要な部分を抜粋します。
public struct LazySequence<Base : Sequence>
: LazySequenceProtocol, _SequenceWrapper {
internal init(_base: Base) {
self._base = _base
}
public var _base: Base
}
_base
というプロパティがあります。この_base
には大元のSequence
が代入されます。この例ではsequence
つまりstride(from: 0, to: 1000000, by: 1)
が代入されます。
次に、sequence.lazy.map({ $0 * ($0 + 2) })
の型はLazyMapSequence<StrideTo<Int>, Int>
です。
LazyMapSequence
の実装をみてみましょう。
重要な部分を抜粋したのがこの以下のコードです。
public struct LazyMapSequence<Base : Sequence, Element>
: LazySequenceProtocol {
internal init(_base: Base, transform: @escaping (Base.Element) -> Element) {
self._base = _base
self._transform = transform
}
internal var _base: Base
internal let _transform: (Base.Element) -> Element
}
プロパティに_base
があるのは同じです。増えているのは_transform
というプロパティです。
_transform
プロパティはクロージャ式や関数への参照になっています。
したがって、
プロパティ | 代入される値 |
---|---|
_base | sequence.lazy |
_transform | { $0 * ($0 + 2) } |
になります。
次に行きましょう。
sequence.lazy.map({ $0 * ($0 + 2) }).filter({ $0 % 2 == 0 })
は、LazyFilterSequence<LazyMapSequence<StrideTo<Int>, Int>>
という型です。
LazyFilterSequence
の実装を見てみましょう。
重要な部分を抜粋するとこのようになっています。
public struct LazyFilterSequence<Base : Sequence>
: LazySequenceProtocol {
public init(
_base base: Base,
_ isIncluded: @escaping (Base.Element) -> Bool
) {
self.base = base
self._include = isIncluded
}
internal var _base: Base
internal let _predicate: (Base.Element) -> Bool
}
LazyMapCollection
とよく似ています。表にするとこうなります。
プロパティ | 代入される値 |
---|---|
_base | sequence.lazy.map({ $0 * ($0 + 2) }) |
_predicate | { $0 % 2 == 0 }) |
最後は、
let lazySeq = sequence.lazy.map({ $0 * ($0 + 2) }).filter({ $0 % 2 == 0 }).map({ $0 + 1 })
です。
lazySeq
はLazyMapSequence
なので、以下のようになっています。
プロパティ | 代入される値 |
---|---|
_base | sequence.lazy.map({ $0 * ($0 + 2) }).filter({ $0 % 2 == 0 }) |
_transform | { $0 + 1 } |
ここまでの処理は非常に軽いことに注意してください。
やっていることはプロパティにクロージャ式を代入しているだけです。
最後に配列を生成する
配列を生成するコードは以下です。単にイニシャライザを呼んでいるだけです。
let arrayB = Array(lazySeq)
Array
のイニシャライザはここの部分がキモのようです。
イニシャライザの引数(ここではlazySeq
)にmakeIterator()
を読んでやってその結果をバッファにどんどんappendしていきます。
var iterator = source.makeIterator()
for _ in 0..<initialCapacity {
builder.addWithExistingCapacity(iterator.next()!)
}
while let element = iterator.next() {
builder.add(element)
}
LazyMapSequence
はmakeIterator()
に独自の実装を与えています。
public func makeIterator() -> LazyMapIterator<Base.Iterator, Element> {
return LazyMapIterator(_base: _base.makeIterator(), _transform: _transform)
}
_base.makeIterator
という部分の_base
の型はLazyFilterSequence
でした。
LazyFilterSequence
にもmakeIterator()
の独自の実装があります。
public func makeIterator() -> LazyFilterIterator<Base.Iterator> {
return LazyFilterIterator(
_base: base.makeIterator(), _include)
}
それぞれのmakeIterator()
は専用のIterator
としてLazyMayIterator
, LazyFilterIterator
というのを返しています。
抜粋すると、
public struct LazyMapIterator<
Base : IteratorProtocol, Element> : IteratorProtocol, Sequence {
public mutating func next() -> Element? {
return _base.next().map(_transform)
}
public var base: Base { return _base }
internal var _base: Base
internal let _transform: (Base.Element) -> Element
internal init(_base: Base, _transform: @escaping (Base.Element) -> Element) {
self._base = _base
self._transform = _transform
}
}
public struct LazyFilterIterator<
Base : IteratorProtocol
> : IteratorProtocol, Sequence {
public mutating func next() -> Base.Element? {
while let n = _base.next() {
if _predicate(n) {
return n
}
}
return nil
}
internal init(
_base: Base,
_ isIncluded: @escaping (Base.Element) -> Bool
) {
self._base = _base
self._predicate = isIncluded
}
public var base: Base { return _base }
internal var _base: Base
internal let _predicate: (Base.Element) -> Bool
}
これで登場する全ての型の紹介ができました。
Array
のイニシャライザはmakeIterator()
を
sequence.lazy.map({ $0 * ($0 + 2) }).filter({ $0 % 2 == 0 }).map({ $0 + 1 })
に対して呼びます。すると、
-
LazyMapSequence
のmakeIterator()
の実装により_base
( 型はLazyFilterSequence
)に対してさらにmakeIterator()
を呼びます。 -
LazyFilterSequence
のmakeIterator()
の実装によりまたさらに_base
( 型はLazyMapSequence
)に対しmakeIterator()
を呼びます。 -
LazyMapSequence
のmakeIterator()
の実装によりまたさらに_base
つまり、sequence.lazy
に対してmakeIterator()
を呼びます。 -
sequence.lazy
は_base
にmakeIterator()
を呼びます。1 -
sequence
つまりstride(from: 0, to: 1000000, by: 1)
は普通のSequence
なので、makeIterator()
を呼ぶと普通のIterator
を返します。
ArrayのイニシャライザはこうしてできたLazyMapSequence
にnext()
を次々と呼んでいきます。
public mutating func next() -> Element? {
return _base.next().map(_transform)
}
_base
つまりLazyFilterIterator
にnext()
を呼び、その結果にクロージャ式を与えています。
public mutating func next() -> Base.Element? {
while let n = _base.next() {
if _predicate(n) {
return n
}
}
return nil
}
LazyFilterIterator
のnext()
は_base
のnext()
に対してクロージャを当てはめてtrueかfalseかを判断しているだけです。LazyFilterIterator
の_base
は同じくLazyMapSequence
です。
こうしてさかのぼっていくと、
sequence
つまりstride(from: 0, to: 1000000, by: 1)
のIterator
にたどりつきます。こいつはnext()
を呼ぶたびに0, 1, 2, 3, 4, ..., 999999, nil
を返します。
以上から、Array
のイニシャライザはおおもとのsequence
つまりstride(from: 0, to: 1000000, by: 1)
をイテレートしながらつぎつぎにクロージャ式を呼んでいき、最後に一つの配列を作ります。
やっと、配列が1つしか作られない理由がわかりました!
最後にベンチマーク
以下のコードでベンチマークしました。マシンはMacBook Pro (Retina, 13-inch, Mid 2014)
2です。
$ swiftc --version
Apple Swift version 4.1 (swiftlang-902.0.48 clang-902.0.37.1)
Target: x86_64-apple-darwin17.5.0
$ cat patternA.swift
let sequence = stride(from: 0, to: 1000000, by: 1)
let arrayA = sequence.map({ $0 * ($0 + 2) }).filter({ $0 % 2 == 0 }).map({ $0 + 1 })
print(arrayA.count, arrayA[100])
$ cat patternB.swift
let sequence = stride(from: 0, to: 1000000, by: 1)
let lazySeq = sequence.lazy.map({ $0 * ($0 + 2) }).filter({ $0 % 2 == 0 }).map({ $0 + 1 })
let arrayB = Array(lazySeq)
print(arrayB.count, arrayB[100])
$ swiftc -O patternA.swift -o patternA
$ swiftc -O patternB.swift -o patternB
$ time ./patternA
500000 40401
real 0m0.101s
user 0m0.027s
sys 0m0.056s
$ time ./patternB
500000 40401
real 0m0.050s
user 0m0.033s
sys 0m0.013s
大幅に短くなっています