標準ライブラリのLINQオペレータにはパフォーマンス向上のための様々なトリックが仕込まれている。
実装をざっと調べたので、その成果をここにまとめておく。
- 調査対象は.Net Core 2.。
- オーダーが同じだからと言ってパフォーマンスが同じとは限らない。
- 結構雑な調査なので信用しすぎないように。
- 項目について
- シグニチャの特殊化 :オーバーロードによって最適化の度合いが異なる場合、各オーバーロードを表す。
- ソースの特殊化 : ソースシーケンスの具象型によって最適化の度合いが異なる場合、各具象型を表す。
-
コールスタック : オペレータ利用による、
MoveNext
/Current
のコールスタック深さの増加分を表す。深くなるとcallvirt
呼び出しが増えるためパフォーマンスが劣化する。 - 戻り値の特殊化型 : オペレータの戻り値が他のオペレータのパフォーマンス向上に寄与するような具象型を持つとき、その具象型を列挙する。
- オーダーの記号について
- $n$: ソースシーケンスの要素数。複数のソースからなる場合は添え字が省略されている。
- $k$: ソースの要素値とオペレータの引数で決まる定数。O記法ではふつうこのような値を使わないが、LINQを考える上ではなかなかに効いてくるので便宜的に導入する。
集計メソッド
|
|
|
|
|
---|---|---|---|---|
メソッド名 | シグニチャの特殊化 | ソースの特殊化 | 時間計算量 | 備考 |
Aggregate |
$O(n)$ | |||
Any |
$O(n)$ | |||
All |
$O(n)$ | |||
Average |
$O(n)$ | |||
Contains |
$O(n)$ | |||
Count |
$O(n)$ | |||
ElementAt |
$O(n)$ | |||
IPartition |
$O(1)$ | |||
IList |
$O(1)$ | |||
ElementAtOrDefault |
$O(n)$ | |||
IPartition |
$O(1)$ | |||
IList |
$O(1)$ | |||
First |
$O(1)$ | |||
IPartition |
$O(1)$ | |||
IList |
$O(1)$ | |||
FirstOrDefault |
$O(1)$ | |||
IPartition |
$O(1)$ | |||
IList |
$O(1)$ | |||
Last |
$O(n)$ | |||
IPartition |
$O(1)$ | |||
IList |
$O(1)$ | |||
LastOrDefault |
$O(n)$ | |||
IPartition |
$O(1)$ | |||
IList |
$O(1)$ | |||
Max |
$O(n)$ | |||
Min |
$O(n)$ | |||
SequenceEqual |
$O(n)$ | 両シーケンスがICollection の場合、事前に要素数チェックが入る。両シーケンスがIListの場合、イテレータではなくインデクサでアクセスするためcallvirt呼び出しが減る。 |
||
ICollection |
$O(n)$ | |||
IList |
$O(n)$ | |||
Single |
$O(1)$ | シーケンスがIList の場合、Count とインデクサにより処理される。 |
||
IList |
$O(1)$ | |||
Sum |
$O(n)$ | |||
ToArray |
$O(n)$ | シーケンスがIListProvider の場合、各インターフェースに実装されたアルゴリズムで処理される。 |
||
IListProvider |
$O(n)$ | |||
ToList |
$O(n)$ | |||
IListProvider |
$O(n)$ | |||
ToDictionary |
$O(n)$ | シーケンスが配列またはList の場合、専用のアルゴリズムで処理される。シーケンスが ICollection の場合、Count をもとに初期キャパシティを取得する。 |
||
ICollection |
$O(n)$ | |||
T[] |
$O(n)$ | |||
List |
$O(n)$ | |||
ToHashSet |
$O(n)$ |
射影メソッド
|
|
|
|
|
|
|
|
---|---|---|---|---|---|---|---|
メソッド名 | シグニチャの特殊化 | ソースの特殊化 | コールスタック | 時間計算量 | 戻り値の特殊化型 | 備考 | |
最初の1件 | 全件 | ||||||
Append |
1 | $O(1)$ | $O(n)$ |
AppendPrependIterator , Iterator , IListProvider
|
AppendPrependIterator はソースと前後のリンクリストを保持する。AppendPrependIterator へのAppend /Prepend はリンクリストを延長するだけなのでコールスタックが深くならない。 |
||
AppendPrependIterator |
0 | $O(1)$ | $O(n)$ | ||||
Prepend |
1 | $O(1)$ | $O(n)$ |
AppendPrependIterator , Iterator , IListProvider
|
|||
AppendPrependIterator |
0 | $O(1)$ | $O(n)$ | ||||
OfType |
1 | $O(1)$ | $O(n)$ | ||||
Cast |
1 | $O(1)$ | $O(n)$ | ||||
Concat |
1 | $O(1)$ | $\sum O(n)$ |
ConcatIterator , Iterator , IListProvider
|
ConcatIterator はシーケンスのリンクを保持する。ConcatIterator へのConcat はリンクを延長するだけなのでコールスタックが深くならない。 |
||
ConcatIterator |
0 | $O(1)$ | $\sum O(n)$ | ||||
DefaultIfEmpty |
1 | $O(1)$ | $O(n)$ | ||||
Distinct |
1 | $O(1)$ | $O(n)$ |
Iterator , IListProvider
|
|||
Except |
1 | $O(n_{inner})$ | $\sum O(n)$ |
Set に依存。 |
|||
GroupJoin |
1 | $O(n_{inner})$ | $\sum O(n)$ |
Lookup に依存。 |
|||
GroupBy |
1 | $O(n)$ | $O(n)$ | ||||
Intersect |
1 | $O(n_{inner})$ | $\sum O(n)$ |
Set に依存。 |
|||
Join |
1 | $O(n_{inner})$ | $\sum O(n)$ |
Lookup に依存。 |
|||
ToLookup |
1 | $O(n)$ | $O(n)$ | IListProvider |
戻り値の実体はLookup 。 |
||
OrderBy |
1 | $O(n)$ | $O(n)$ |
IPartition , IOrderedEnumerable
|
|||
Reverse |
1 | $O(n)$ | $O(n)$ | ||||
Select |
Func<T, TResult> |
1 | $O(1)$ | $O(n)$ |
Iterator , IListProvider
|
配列、List はIList と同じオーダーだが、一部のメソッド呼び出しにより強力な最適化が働く。セレクタにインデックスをとるオーバーロードは最適化が弱い。 |
|
Iterator |
1 | $O(1)$ | $O(n)$ |
Iterator , IListProvider
|
|||
T[] |
1 | $O(1)$ | $O(n)$ |
Iterator , IPartition
|
|||
List |
1 | $O(1)$ | $O(n)$ |
Iterator , IPartition
|
|||
IList |
1 | $O(1)$ | $O(n)$ |
Iterator , IPartition
|
|||
IPartition |
1 | $O(1)$ | $O(n)$ |
Iterator , IPartition
|
|||
Func<T, int, TResult> |
1 | $O(1)$ | $O(n)$ | ||||
SelectMany |
Func<TSource, IEnumerable<TResult>> |
1 | $O(1)$ | $O(n)$ |
Iterator , IListProvider
|
セレクタにインデックスをとるオーバーロードは最適化が弱い。 | |
Func<TSource, int, IEnumerable<TResult>> |
1 | $O(1)$ | $O(n)$ | ||||
Skip |
1 | $O(k)$ | $O(n)$ | Iterator |
|||
IIterator |
0 ~ 1 | $O(k)$ | $O(n)$ | Iterator |
|||
IPartition |
0 | $O(1)$ | $O(n)$ | IPartition |
|||
SkipWhile |
1 | $O(k)$ | $O(n)$ | ||||
SkipLast |
1 | $O(1)$ | $O(n)$ | ||||
Take |
1 | $O(1)$ | $O(k)$ |
Iterator , IPartition
|
|||
IPartition |
0 | $O(1)$ | $O(k)$ |
Iterator , IPartition
|
|||
IList |
1 | $O(1)$ | $O(k)$ |
Iterator , IPartition
|
|||
TakeWhile |
1 | $O(1)$ | $O(k)$ | ||||
TakeLast |
1 | $O(n)$ | $O(n)$ | ||||
IPartition |
0 | $O(1)$ | $O(k)$ |
Iterator , IPartition
|
|||
IList |
1 | $O(1)$ | $O(k)$ |
Iterator , IPartition
|
|||
Union |
1 | $O(1)$ | $\sum O(n)$ |
UnionIterator , Iterator , IListProvider
|
UnionIterator はシーケンスのリンクを保持し、イテレート時に重複の除外を行う。ソースがUnionIterator で、かつ同じcomparer を使っているときはリンクを追加するだけなのでコールスタックが深くならない。 |
||
UnionIterator |
0 ~ 1 | $O(1)$ | $\sum O(n)$ |
UnionIterator , Iterator , IListProvider
|
|||
Where |
1 | $O(k)$ | $O(n)$ |
Iterator , IListProvider
|
配列、List は一部のメソッド呼び出しにより強力な最適化が働く。セレクタにインデックスをとるオーバーロードは最適化が弱い。 |
||
T[] |
1 | $O(k)$ | $O(n)$ |
Iterator , IListProvider
|
|||
List |
1 | $O(k)$ | $O(n)$ |
Iterator , IListProvider
|
|||
Func<T, int, bool> |
1 | $O(k)$ | $O(n)$ |
Iterator , IListProvider
|
|||
Zip |
1 | $O(1)$ | $\min(O(n))$ |
最適化に用いられる型
Iterator
クラス
LINQオペレータで広く利用される抽象クラス。IEnumerable
, IEnumerator
を実装している。
ステートとスレッドIDを管理しており、自身を生成したスレッド内かつ唯一の利用ならGetEnumerator
が自身を返すことでオブジェクト数を増やさないようになっていたりする。
ステートフィールドの解釈は子クラス側に委ねられており、OOP的にはあまりよろしくない設計となっているが、パフォーマンスを出すためには仕方ないことなのだろうか。
IListProvider
インターフェース
ToArray
, ToList
の専用実装を用意できるようにするインターフェース。
IPartition
インターフェース
ランダムアクセス可能なシーケンスであることを表すインターフェース。参照型向けのSpan
みたいなもの。
Lookup
クラス
ILookup
の実装。ToLookup
やGroupBy
などで用いられる、グループキーとシーケンスの軽量な辞書。
Set
クラス
軽量なハッシュセットクラス。System.Collections.Generic.HashSet
はコアライブラリで使うにはリッチすぎるということだろうか。
まとめ
- 同じオペレータを連続適用すると効率が良い(ことがそこそこある)
-
Select
/Where
はインデックス込みで評価できるオーバーロードを使うと最適化が利かなくなったりする。
最後に
正直こういうの考える必要があるほどパフォーマンスが大事なら素直にforeach
で書いた方がいいと思う。
QiitaでHTML属性使えなくなってテーブルきれいに書くのとてもつらいんだけど誰かいい方法教えて