【C#】
処理速度のケース別実測検証、遅くなる記法と解決方法

記事イメージ


(最終更新日:

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

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

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

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

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

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

次のコードのように、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による即時評価が実行速度向上に寄与する場面② 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ミリ秒

文字列結合は、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開発事業を行っております。

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

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

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

※ご相談は無料でお受けいたします。
IT活用経営を実現する - 堺財経電算合同会社