C# リストのパフォーマンス最適化:ベストプラクティスと実測データ

C#

C#では、一般的に推奨される書き方や、パフォーマンスが良いとされる方法があります。しかし、実際にどの程度の差が生じるのか気になったことはありませんか?今回は、リストの処理に関するパフォーマンスを検証しました。

前提

以下の環境で動作確認を行っています。

oswindows11(4cpu、16GB)
.NET8.0.304
IDEVisual Studio 2022 Community

コレクション初期化時にcapacityを指定する

List は可変長のコレクションであり、要素を追加するたびに自動で容量を拡張します。List には Count(現在の要素数)と Capacity(確保されている最大要素数)のプロパティがあり、Capacity Count に達すると、メモリを拡張するための再割り当てが発生します。

そのため、初期化時に Capacity を指定することで、メモリの再割り当てを抑え、余分なメモリ確保を防ぐことができます。これにより、処理の効率化が期待できます。

以下のコードで、Capacity を指定した場合と指定しない場合のパフォーマンスを比較しました。「count」の値を1000から1000万まで1桁ずつ変えて測定しました。

	[Benchmark]
	public void NoCapacity()
	{
		var people = new List<Person>();
		for (var c = 1; c <= count; c++)
		{
			people.Add(new Person
			{
				Name = "名前",
				Age = 30,
				Gender = "male",
				Email = "test@gmail.com",
				Address = "日本東京都世田谷区1-1",
				Occupation = "システムエンジニア",
				PhoneNumber = "1234567890",
				Height = 170,
				Weight = 66
			});
		}
	}

	[Benchmark]
	public void Capacity()
	{
		var people = new List<Person>(capacity: count);
		for (var c = 1; c <= count; c++)
		{
			people.Add(new Person
			{
				Name = "名前",
				Age = 30,
				Gender = "male",
				Email = "test@gmail.com",
				Address = "日本東京都世田谷区1-1",
				Occupation = "システムエンジニア",
				PhoneNumber = "1234567890",
				Height = 170,
				Weight = 66
			});
		}
	}

以下は、2回ずつ実行した平均の値になります。

全体的に Capacity を指定した方がやや高速になりました。ただし、ミリ秒ほどの差であり、Capacityを指定しないほうが高速になることもあります。

メモリに関しては不要な割り当てがなくなる分Capacityありのほうが小さくなっています。

空のリストに要素を1つ追加すると、Capacityは4確保されます。その後は要素数がCapacityに達するたびに2倍の領域が確保されます。そのためCapacityは4、8、16…と増えていきます。

リストのデータ有無はCountプロパティを使う

リストが空かどうかを判定する方法として、Count プロパティを使う方法と Any() メソッドを使う方法があります。

  • Count == 0:リストの要素数を直接取得する。
  • Any():要素が存在するかどうかをチェックする処理を含む。

Count は単なるプロパティの取得で済むため、Any() よりも高速です。

	[Benchmark]
	public void TestAny()
	{
		if (_people.Any())
		{
			_logger.LogDebug("OK");
		}
	}

	[Benchmark]
	public void TestCount()
	{
		if (_people.Count == 0)
		{
			_logger.LogDebug("OK");
		}
	}

_people(リスト)の大きさを1000、1万、10万にして検証しています。

ナノ秒レベルの差ではありますが、Countプロパティを使用するほうが圧倒的に早い結果となりました。

count_peopleBenchmarkDotNetの機能を使ってテスト実行前に作成しています。

	[Params(1000, 10_000, 100_000)]
	public int count;

	private List<Person> _people = [];
	private Person _person;
	[GlobalSetup]
	public void Setup()
	{
		for (var c = 1; c <= count; c++)
		{
			_people.Add(new Person
			{
				Name = $"名前{c}",
				Age = c,
				Gender = "male",
				Email = "test@gmail.com",
				Address = "日本東京都世田谷区1-1",
				Occupation = "システムエンジニア",
				PhoneNumber = "1234567890",
				Height = 170,
				Weight = 66
			});
		}
		_person = _people[count - 1];
	}

大量のリストからの検索:First() は遅い?

リストの中から特定の条件に合致する要素を取得する方法として、First()Find()BinarySearch() を比較しました。

	[Benchmark]
	public void TestFind()
	{
		var data = _people.Find(x => x.Age == count);
	}

	[Benchmark]
	public void TestFirst()
	{
		var data = _people.First(x => x.Age == count);
	}

	[Benchmark]
	public void TestBinarySearch()
	{
		var index = _people.BinarySearch(_person);
		var data = _people[index];
	}


結果は以下のとおりです。

  • BinarySearch():最も高速(O(log n))
  • Find():線形探索のため O(n)
  • First()Find() と同様に O(n) だが、やや遅め

ただしBinarySeachを使う際にはいくつか注意点があります。

  • IComparable<T>インターフェースを継承し実装していること(もしくはTIComparableインターフェースを継承し実装していること)
  • 事前に検索する条件でソートされていること

BinarySeachでは事前にソートが必要なため、状況によってはFindよりも遅くなる可能性もあることには注意が必要です。

条件を指定しないFirst()は単に最初の要素を取得するので、リストの大きさによらず一定になります。

必要なタイミングで初期化

リストを使用する前に適切に初期化することは重要ですが、不要な初期化はメモリ消費を増やす可能性があります。

[Benchmark]
public void Nonew()
{
	var p = new People();
}

[Benchmark]
public void WithNew()
{
	var p = new PeopleNew();
}

public class People
{
	public List<Person> people = default!;
}

public class PeopleNew
{
	public List<Person> people = new();
}

こちらもナノ秒レベルの差ではありますが、違いが見て取れます。

コレクション式で初期化

今までは、ArrayListSpanなどそれぞれの書き方で値を生成してきました。これが、C#12から [] を使って初期化できるようになっています。コンパイラが最適なパフォーマンスの良い方法を判断してくれるため、非常に楽に書けるようになりました。

それでは今までの書き方と新しいコレクション式を使った書き方で比較します。

	[Benchmark]
	public void TestOld()
	{
		List<Person> people = new()
		{
			new Person{Name="A",Age=30, Occupation="システムエンジニア", PhoneNumber = "123456789"},
			new Person{Name="A",Age=30, Occupation="システムエンジニア", PhoneNumber = "123456789"},
			new Person{Name="A",Age=30, Occupation="システムエンジニア", PhoneNumber = "123456789"},
			new Person{Name="A",Age=30, Occupation="システムエンジニア", PhoneNumber = "123456789"},
			new Person{Name="A",Age=30, Occupation="システムエンジニア", PhoneNumber = "123456789"},
		};
	}

	[Benchmark]
	public void TestNew()
	{
		List<Person> people =
		[
			new Person { Name = "A",Age = 30, Occupation = "システムエンジニア", PhoneNumber = "123456789"},
			new Person { Name = "A", Age = 30, Occupation = "システムエンジニア", PhoneNumber = "123456789" },
			new Person { Name = "A", Age = 30, Occupation = "システムエンジニア", PhoneNumber = "123456789" },
			new Person { Name = "A", Age = 30, Occupation = "システムエンジニア", PhoneNumber = "123456789" },
			new Person { Name = "A", Age = 30, Occupation = "システムエンジニア", PhoneNumber = "123456789" },
		];
	}

2回試してその平均をとっています。

差は非常にわずかですが、コレクション式を使ったほうが簡潔で速く、割り当てられるメモリも必要な分だけになります。基本的には新しい方法で記載するのがいいと思います。

遅延実行

リストを返すのではなく IEnumerable を活用することで、メモリ効率を向上させることができます。

検索処理などで複数件のデータを返却する処理を想定して以下の2つのメソッドを作成します。

	public List<Person> GetList()
	{
		List<Person> people = [];
		for (var c = 1; c <= count; c++)
		{
			people.Add(new Person { Name = "A", Age = c });
		}
		return people;
	}

	public IEnumerable<Person> GetEnumerable()
	{
		for (var c = 1; c <= count; c++)
		{
			yield return new Person { Name = "A", Age = c };
		}
	}

それぞれのメソッドを呼び出します。その結果を受け取りログに出力する処理で比較します。リストの大きさ(count)は1000、1万、10万で試しています。

	[Benchmark]
	public void TestList()
	{
		foreach (var item in GetList())
		{
			if (item.Age == count) _logger.LogInformation(item.Age.ToString());
		}
	}

	[Benchmark]
	public void TestEnumerable()
	{
		foreach (var item in GetEnumerable())
		{
			if (item.Age == count) _logger.LogInformation(item.Age.ToString());
		}
	}

まずは速度です。リストの大きさが小さいうちは差はほとんど見られません。しかしリストの大きさが大きくなるほど、差も広がっていいく様子が見られます。

メモリについても全体的に遅延実行することでメモリの仕様が抑えられています

まとめ

C# の List を適切に扱うことで、パフォーマンスとメモリ使用量を最適化できます。

ただし、パフォーマンス改善の必要性や効果はアプリケーションの特性や実行環境などによります。また細かいレベルでのパフォーマンスにこだわって、可動性が低いコードになっては本末転倒です。それでも、れらの知識を持っておくことで、より効率的なコーディングが可能になります。

コメント