【C#】
処理速度のケース別実測検証、高速化に寄与するテクニック


(最終更新日:

記事イメージ

C#は処理速度に優れた言語ですが、書き方によってはその強みが十分に生かせない場合があります。

今回は、過去の経験をもとに、低速コードを書いてしまいがちなポイントと解決法を解説します。(.NET6.0、C#10.0にて確認しています。)

Linq.ToList()/Linq.ToArray()による即時評価は、場面によっては処理高速化に寄与する

Linqにおいて、基本的にはIEnumerableの状態で遅延評価するのがメモリ・処理速度の面でセオリーとされています。他方、ToList/ToArrayを効果的に使うことで、実行速度が向上する場面が存在します。

以下、ケースごとに検証していきます。処理速度をSystem.Diagnostics.Stopwatchクラスで測定します。

ToList/ToArrayによる即時評価が実行速度向上に寄与する場面① 絞り込み結果を事前評価

次のコードのように、Whereで絞込した後にFirstメソッドで取り出すといった処理を行う場合、事前に即時評価を行った方が高速になります。

ケース①ではFirst実行時にWhereが未評価のため全件検査になる一方、ケース②では絞り込みが評価された後ですので、検索範囲が絞られた効果を得ることが出来ます。

C#
//事前準備。list変数は要素数1万のList<TestClass>クラスインスタンス
//各要素のProp1にインデックスと同じ自然数を格納。(兼ウォーミングアップ)
var list = new System.Collections.Generic.List<TestClass>();
for (int i = 0; i < 10000; i++) {
    list.Add(new TestClass() { Prop1 = i });
}

//テストケース①即時評価せずFirstで取り出し
var narrowedEnumerable = list.Where(x => x.Prop1 > 4000);
var result = narrowedEnumerable.First(x => x.Prop1 == 4345);

//テストケース②即時評価してからFirstで取り出し
var narrowedList = list.Where(x => x.Prop1 > 4000).ToList();
var result = narrowedList.First(x => x.Prop1 == 4345);

上記コードのFirstメソッドの行だけを10,000回実行します。下表のとおり、ToListをあらかじめ行った方が処理が早くなったことが分かります。

表:ToListしてからFirstする方が数倍高速となりました。(即時評価せず取り出し vs 即時評価してから取り出し。要素数1万のList<TestClass>クラスに対して上記のFirstメソッドの行だけ10000回ループ)
即時評価せず取り出し 448ミリ秒
即時評価してから取り出し 71ミリ秒

ToList/ToArrayによる即時評価が実行速度向上に寄与する場面② Countメソッド

IEnumerableで要素数を取得するにはCountメソッド(ListならCountプロパティ)を使用します。

例えば次のようなコードでは、Countメソッドを使用するよりToListを事前に行った方が高速になります。

テストケース①でもforeachの段階で評価されることになるので、要素数が必要ならあらかじめ評価してしまった方が良いという考察が出来ます。

C#
//事前準備。list変数は要素数1万のList<TestClass>クラスインスタンス
//各要素のProp1にインデックスと同じ自然数を格納。
var list = new System.Collections.Generic.List<TestClass>();
for (int i = 0; i < 10000; i++) {
    list.Add(new TestClass() { Prop1 = i });
}

//テストケース① 事前にToListせすCountメソッドを利用
var narrowedEnumerable = list.Where(x => x.Prop1 > 4000);
if (narrowedEnumerable.Count() > 0) {
    foreach (var item in narrowedEnumerable) {
        var a = item.Prop1;
    }
}

//テストケース② 事前にToList
var narrowedList = list.Where(x => x.Prop1 > 4000).ToList();
if (narrowedList.Count > 0) {
    foreach (var item in narrowedList) {
        var a = item.Prop1;
    }
}

表:ToListしてから要素数を検証したほうが1.4倍ほど高速になりました。(上記コードをそれぞれ10000回ループ)
事前にToListせす
Countメソッドを利用
1,548ミリ秒
事前にToList 1,119ミリ秒

(参考)ToList()の処理自体にはさほどの処理速度負荷はかからない

次のようなコードで比較してみたところ、処理速度では大きな差は見られませんでした。(尚、メモリ効率の面では①の方が優れています)

C#
//事前準備。list変数は要素数1万のList<TestClass>クラスインスタンス
//各要素のProp1にインデックスと同じ自然数を格納。
var list = new System.Collections.Generic.List<TestClass>();
for (int i = 0; i < 10000; i++) {
    list.Add(new TestClass() { Prop1 = i });
}

//テストケース① 評価は1回だけ
var narrowedEnumerable1 = list.Where(x => x.Prop1 < 5000);
var narrowedEnumerable2 = narrowedEnumerable1.Where(x => x.Prop1 < 2000);
var narrowedEnumerable3 = narrowedEnumerable2.Where(x => x.Prop1 < 1000);
var result = narrowedEnumerable3.ToList();

//テストケース② Whereするごとに評価する
var narrowedList1 = list.Where(x => x.Prop1 < 5000).ToList();
var narrowedList2 = narrowedList1.Where(x => x.Prop1 < 2000).ToList();
var narrowedList3 = narrowedList2.Where(x => x.Prop1 < 1000).ToList();

表:あまり速度に差がない結果となりました。(評価は1回だけ vs Whereするごとに即時評価。要素数1万のList<TestClass>クラスに対して10000回ループ)
評価は1回だけ 1,025ミリ秒
Whereするごとに即時評価 1,192ミリ秒

(参考)遅延評価との比較に関する追加考察

ここで見たように、遅延評価をあえて外すことによる処理速度向上の事例を見ることができました。背景として、遅延評価方式の場合、複数回列挙(評価)するとその都度計算が発生するため、あえて先に評価することでそのデメリットを回避していることが挙げられます。

他方、今回のメリットを得られるような巨大なシーケンスを即時評価するとメモリリソースを圧迫し、計算量・メモリの面での即時評価の優位性を失うこととなります。利用可能リソースや要件とも相談の上、このロジックを採用するかを検討していただくとよいでしょう。(とはいえ、同じコレクションに対して様々な検討や操作を行う場面も相応にあるでしょう。そういった場面で是非ご活用ください。)

ループ内でLinqを使っているなら、事前の辞書型への変換を検討しよう

ループ内でLinqを使う場合、特に対象列挙対の要素数が多い場合は、大幅なパフォーマンス低下を招きがちです。

ループに突入する前に一旦ループを回して辞書型に変換したほうが、圧倒的に速度が出るケースはよくあります。急がば回れ、ですね。

検索値が一意でない場合は直接辞書型にはできませんが、その場合は検索値と結果リストの辞書型にすればOKです。

C#
//修正前:ループ内でLinq
var list = new MyClass[] { ... };

for (int i = 0; i < n; i++) {
    //IDで検索。遅い!
    var myClassModel = list.First(x => x.Id == ?_getId(i));

    //後続処理
}


↑の処理は(Linq)Firstメソッドで負荷がかかりがちです。

そこでループを行う前に辞書型に変換します。辞書型はこのような検索に最適化されているため、Linqに比べはるかにパフォーマンスが良好です。

C#
//修正後:先に辞書に変換。急がば回れ。
var list = new MyClass[] { ... };

//IdからMyClassオブジェクトを高速で引けるように変換
var dict = new Dictionary<int, MyClass>();
foreach (var item in list) {
    dict.Add(item.Id, item);
}
//辞書への変換処理は次のようにも書けます
//var dict = list.ToDictionary(x => x.Id, x => x);

//ここからが本来の処理
for (int i = 0; i < n; i++) {
    //同じくIDで検索しているが、Linqに比べはるかに速い
    var myClassModel = dict[_getId(i)];

    //後続処理
}

検索値が一意でない場合は、検索値と結果リストの辞書を作る

以下はこれから修正するコードです。ループごとに該当するノードを抽出処理します。都度Whereメソッドを使うと、母数が多い場合にパフォーマンス面で問題が生じます。

C#
//修正前:ループ内でLinq(Whereで複数の結果を得る場合)
var list = new MyClass[] { ... };

for (int i = 0; i < n; i++) {
    //名前で検索(Whereで複数の結果を得るケース)。やはり遅い!
    var myClassModels = list.Where(x => x.Name == _getName(i));

    //後続処理
}


これを同じように修正します。辞書のつくり方がポイントです。回りくどいですが処理は早いです。

C#
//修正後:先に辞書に変換。急がば回れ。※より簡略に書く方法あり。後述。
var list = new MyClass[] { ... };
var dict = new Dictionary<string, List<MyClass>>();
foreach (var item in list) {
    if (dict.TryGetValue(item.Name, out var models)) {
        models.Add(item);
    } else {
        dict.Add(item.Name, new List<MyClass> { item });
    }
}
//ここからが本来の処理
for (int i = 0; i < n; i++) {
    //同じくNameで検索しているが、Linqに比べはるかに速い。
    //結果はList型であるがLinqのWhereを使用した場合とほぼ同等の結果(遅延評価効果は得られないので注意)。
    var myClassModels = dict[_getName(i)];

    //後続処理
}

尚、「先に辞書に変換」の箇所については、以下のようにToLookupまたはGroupByメソッドを使って簡略に記述することもできます。(匿名でコメントをお寄せいただいた方、情報提供ありがとうございます。)

C#
var dict = list.ToLookup(item => item.Name).ToDictionary(group => group.Key, group => group.ToList()); ;

C#
var dict = list.GroupBy(item => item.Name).ToDictionary(g => g.Key, g => g.ToList());

【社内PR】チーム・ウォーク

多重ループ処理の並列化(Parallel.For、Parallel.ForEachの利用)

2次元セルへの書き込み等において多重ループを行う場面では並列処理にすることで高速化に寄与します。以下の例では、2重For文でParallelを適用することにより実行時間を約85%削減されました。

表:2重ループ(10000×10000)の実行時間測定
普通にFor2重ループ 18,390ミリ秒
Parallelを適用 2,750ミリ秒

測定に使用したコード

C#
//測定①:普通の2重Forループで測定
var digits = new List<int>();
for (int i = 0; i < 100000; i++) {
    digits.Add(i);
}

var stopwatch = new Stopwatch();
stopwatch.Start(); //測定開始

//2重ループ実行(普通に2重For文)
for (int i = 0; i < digits.Count; i++) {
    for (int j = 0; j < digits.Count; j++) {
        var a = j;
    }
}

stopwatch.Stop(); //測定完了
Console.WriteLine(stopwatch.ElapsedMilliseconds.ToString());

C#
//測定②:2重ForループにParallelを適用
var digits = new List<int>();
for (int i = 0; i < 100000; i++) {
    digits.Add(i);
}

var stopwatch = new Stopwatch();
stopwatch.Start(); //測定開始

//2重ループ実行(Parallel.For利用)
Parallel.For(0, digits.Count, (i) => {
    for (int j = 0; j < digits.Count; j++) {
        var a = j;
    }
});

stopwatch.Stop(); //測定完了
Console.WriteLine(stopwatch.ElapsedMilliseconds.ToString());

上記2重ループは、次のようにParallel.ForEachメソッドで書き換え可能です。

C#
Parallel.ForEach(digits, i => {
    for (int j = 0; j < digits.Count; j++) {
        var a = j;
    }
});

並列処理を使用する場合の注意点

並列処理は上記のように実行時間の大幅短縮に寄与しますが、常に短縮可能とは限りません。実行したい内容を吟味・時に実証実験を行いながら判断する必要があります。また、並列で処理を行う都合上、同一変数への書き込み・利用等には注意する必要があります。

詳しくはMicrosoft公式ページをご参考ください。データとタスクの並列化における注意点 | Microsoft Learn

文字列結合は、string型に比べてStringBuider型が大幅に早い

StringBuiderは内部実装がstringと異なっており、APIとして記法が便利なだけではなく、文字列結合処理でパフォーマンスが出るように最適化されています。(stringの方は、結合するたびにインスタンスを生成しなおしています)

特に多量の文字列結合を行う場合は、StringBuiderを利用しましょう。

表:stringとStringBuilderの文字列結合パフォーマンス比較結果(ひらがな1文字を10万回結合)
StringBuilder 37ミリ秒
string 1518ミリ秒
C#
//測定に使用したコード

using System.Diagnostics;
using System.Text;

//StringBuilderで文字列結合
var stopwatch = new Stopwatch();
stopwatch.Start();
var sb = new StringBuilder();
for (int i = 0; i < 100000; i++) {
    sb.Append("あ");
}
stopwatch.Stop();
Console.WriteLine(stopwatch.ElapsedMilliseconds);

//stringで文字列結合
stopwatch = new Stopwatch();
stopwatch.Start();
var s = "";
for (int i = 0; i < 100000; i++) {
    s += "あ";
}
stopwatch.Stop();
Console.WriteLine(stopwatch.ElapsedMilliseconds);

string.StartsWithメソッド、string.EndsWithメソッドのパフォーマンスが、適切な引数で200倍になる

string.StartsWith、string.EndsWith、string.Containsは、それぞれ「文字列中に特定の文字列で始まる・終わる・含まれる」を判定する組み込みメソッドです。

直感的にはstring.StartsWith、string.EndsWithの方がstring.Containsより高速に思えますが、予備知識なしに使用してしまうと思わぬ低速化を招きます。

ポイントは、オーバーロードで第2引数に指定できるStringComparison列挙型を指定するか否かです。ラテン文字等の文化的相違を考慮した比較結果を制御するための引数ですが、日本語・英語の範疇でプログラミングする人にとっては気にする必要はまずないでしょう。処理が早くなる選択を行うべきです。

この第2引数の指定次第で、実測で200倍の差が生じました。


//第2引数にStringComparison列挙型を指定可能。
//EndsWith、Containsメソッドも同様。
//文化的(カルチャ)文字相違の結果制御するが、日本語・英語の範疇では気にする必要はない。
"あいうえお".StartsWith("かきくけこ", StringComparison.Ordinal);

//↓のように、第2引数を省略することができますが、パフォーマンスが約200分の1に低下します。(実測ベース)
"あいうえお".StartsWith("かきくけこ");

この引数の違いによる差を確認する為、実際にそれぞれ1,000万回実行して、System.Diagnostics.Stopwatchクラスで時間を測定してみました。結果は次の通りです。

メソッド表:別・第2引数の有無別に10,000,000回試行した結果
メソッド StringComparison指定なし StringComparisonを指定する
string.StartsWith 5,010ミリ秒 29ミリ秒
string.EndsWith 8575ミリ秒 69ミリ秒
string.EndsWith 119ミリ秒 157ミリ秒

測定に使用したコード

C#
//string.StartsWith、string.EndsWithの速度検証コード

using System.Diagnostics;
var stopwatch = new Stopwatch();
Console.WriteLine(Stopwatch.IsHighResolution);

var TEST = "あいうえおかきくけこさしすせそたちつてと";
var SEARCH_TARGET = "なにぬねの";

var TEST_TIMES = 10000000;

//StartsWith(StringComparisonなし)
stopwatch = new Stopwatch();
stopwatch.Start();
for (int i = 0; i < TEST_TIMES; i++) {
    TEST.StartsWith(SEARCH_TARGET);
}
stopwatch.Stop();
Console.WriteLine($"StartsWith(StringComparisonなし):{stopwatch.ElapsedMilliseconds}ミリ秒 ");


//StartsWith(StringComparisonあり)
stopwatch = new Stopwatch();
stopwatch.Start();
for (int i = 0; i < TEST_TIMES; i++) {
    TEST.StartsWith(SEARCH_TARGET, StringComparison.Ordinal);
}
stopwatch.Stop();
Console.WriteLine($"StartsWith(StringComparisonあり):{stopwatch.ElapsedMilliseconds}ミリ秒");


//StartsWith(StringComparisonなし)
stopwatch = new Stopwatch();
stopwatch.Start();
for (int i = 0; i < TEST_TIMES; i++) {
    TEST.EndsWith(SEARCH_TARGET);
}
stopwatch.Stop();
Console.WriteLine($"EndsWith(StringComparisonなし) :{stopwatch.ElapsedMilliseconds}ミリ秒");


//StartsWith(StringComparisonあり)
stopwatch = new Stopwatch();
stopwatch.Start();
for (int i = 0; i < TEST_TIMES; i++) {
    TEST.EndsWith(SEARCH_TARGET, StringComparison.Ordinal);
}
stopwatch.Stop();
Console.WriteLine($"EndsWith(StringComparisonあり) :{stopwatch.ElapsedMilliseconds}ミリ秒");


//Contains(StringComparisonなし)
stopwatch = new Stopwatch();
stopwatch.Start();
for (int i = 0; i < TEST_TIMES; i++) {
    TEST.Contains(SEARCH_TARGET);
}
stopwatch.Stop();
Console.WriteLine($"Contains(StringComparisonなし) :{stopwatch.ElapsedMilliseconds}ミリ秒");

//Contains(StringComparisonあり)
stopwatch = new Stopwatch();
stopwatch.Start();
for (int i = 0; i < TEST_TIMES; i++) {
    TEST.Contains(SEARCH_TARGET, StringComparison.Ordinal);
}
stopwatch.Stop();
Console.WriteLine($"Contains(StringComparisonあり) :{stopwatch.ElapsedMilliseconds}ミリ秒");

.NET での文字列の比較に関するベスト プラクティス(Microsoft docs)にも、OrdinalまたはOrdinalIgnoreCase(大文字小文字を無視)を指定することが推奨されています。

指定しない場合、カルチャ依存ルールの検証が入る為、パフォーマンスが低下するようです。

尚、string.StartsWith、string.EndsWithの第二引数にあてるStringComparison(Enum)の内容は次の通りです。

C#
//StringComparison列挙型の内訳(string.StartsWith、string.EndsWithの第2引数に指定する型)

//カルチャに依存するソートルールと現在のカルチャを使用して文字列を比較します
CurrentCulture
//カルチャに依存するソートルール、現在のカルチャを使用し、比較される文字列の大文字と小文字を無視して、文字列を比較します。
CurrentCultureIgnoreCase
//カルチャに依存するソートルールと不変のカルチャを使用して文字列を比較します。
InvariantCulture
//カルチャに依存するソートルール、不変のカルチャを使用し、比較される文字列の大文字と小文字を無視して、文字列を比較します。
InvariantCultureIgnoreCase
//序数(バイナリ)の並べ替えルールを使用して文字列を比較する
Ordinal
//序数(バイナリ)のソート規則を使用し、比較される文字列の大文字と小文字を無視して、文字列を比較します。
OrdinalIgnoreCase

forとforeachのパフォーマンスの差。差はほとんどない。

何かと議論になるforとforeachですが、1億回の実測を行った結果、次の通りほぼ差はありませんでした。

むしろList(System.Collections.Generic)とArrayの差の方が着目に値します。1.8倍程度、Arrayのループの方が高速という結果となりました。

表:for・foreachを1億回ループした時のパフォーマンス比較結果
List Array
for 311ミリ秒 170ミリ秒
foreach 308ミリ秒 171ミリ秒

測定に使用したコード

Listの場合のみ。Arrayの場合、ToArrayで変換後に実行。

forの検証

C#
using System.Diagnostics;

//1億個の要素を持つリストを作成(兼ウォーミングアップ)
var list = new List<int>();
for (int i = 0; i < 100000000; i++) {
    list.Add(i);
}


//forでループ
var stopwatch = new Stopwatch();
stopwatch.Start();
for (int i = 0; i < list.Count; i++) {
    var a = list[i];
}
stopwatch.Stop();
Console.WriteLine(stopwatch.ElapsedMilliseconds);

foreachの検証

C#
using System.Diagnostics;

//1億個の要素を持つリストを作成(兼ウォーミングアップ)
var list = new List<int>();
for (int i = 0; i < 100000000; i++) {
    list.Add(i);
}

//foreachでループ
var stopwatch = new Stopwatch();
stopwatch.Start();
foreach (var item in list) {
    var a = item;
}
stopwatch.Stop();
Console.WriteLine(stopwatch.ElapsedMilliseconds);

System.Reflection(リフレクション)の遅さとデリゲート利用による改善

個別論点として別記事に分離しました。リフレクションの各処理の時間実測と改善手法について解説します。

関連記事:【C#】リフレクションは遅い?処理時間実測と改善方法

記事筆者へのお問い合わせ、仕事のご依頼

当社では、IT活用をはじめ、業務効率化やM&A、管理会計など幅広い分野でコンサルティング事業・IT開発事業を行っております。

この記事をご覧になり、もし相談してみたい点などがあれば、ぜひ問い合わせフォームまでご連絡ください。

皆様のご投稿をお待ちしております。

記事筆者へ問い合わせする

※ご相談は無料でお受けいたします。

この記事へのコメント

とっと

こちらの記事のお陰で改善されました!ありがとうございます!

業務アプリで、とあるカスタムクラスのListからある条件でLINQで対象を抜き出し、
その対象レコードのある項目の集計値をLIST内の合計用レコードの項目に集計する、という処理を行っています。
その一連の処理に12、3秒掛かっていまして3期分の処理で計40秒弱を要していました。

よくLINQの高速化の記事でToList/ToArrayをするとLIST内が走査されるため遅い、旨の記事を見かけていたため、無条件でToList/ToArrayを最小限にする記述をしておりました。

今回は対象のリストからLINQでSelect、Sumしていましたので「もしかしたら」と思い、ToList/ToArrayによる即時評価を試したところ、約12秒の処理が0秒以下に短縮されました。

大変参考になりました!

筆者(堺)より返信

とっと様、この度は当記事へのコメントご投稿、誠にありがとうございます。記事執筆者の堺と申します。
うれしいお言葉をありがとうございます、お役に立てたのでしたら何よりです!
ToList/ToArrayも使いよう、ということがもっと知られるといいですね。
今後とも堺.docsをよろしくお願いいたします。

(匿名)

高速化について、私の頭の中にあるもの全てがここに存在しており、素晴らしいと感じ
ました。
高速化と言う意味では、私の知識でもこれ以上は無理ですが、
速度を維持したまま、コードを簡略化する手があります。

「ループ内でLinqを使っているなら、事前の辞書型への変換を検討しよう」の
「検索値が一意でない場合は、検索値と結果リストの辞書を作る」です。

ToLookup()という拡張メソッドがあり、
var lookup = list.ToLookup(x => x.Id);
とするだけで、同様の結果が得られます。

LINQのGrouupBy()も同様の結果が得られます。

筆者(堺)より返信

情報提供、誠にありがとうございます。記事に反映させていただきました。

ニックネーム(任意)

返信通知先Emailアドレス(任意)

本文


* 感想やご意見等、お気軽にコメントください。但し、公序良俗に反するコメントはお控えください。
* 管理者が承認したコメントはこの箇所に公開させていただく可能性がございます。
* 返信通知先Emailアドレスは、筆者のみに通知され、公開されることはありません。返信通知先Emailを入力された場合は、コメントへの返信をこちらに掲載した際に通知させていただきます。その他の目的には使用いたしません。
* スパム対策のため、コメントは日本語のみ受け付けております。

堺財経電算合同会社 小規模IT構築サービス