概要
パフォーマンス最適化とは、同じ数のリクエストを処理しながら、より少ないリソース(通常はCPUまたはメモリ、他にもOS IOハンドル、ネットワークトラフィック、ディスク使用量など)で済ませる方法です。しかし、ほとんどの場合、CPUとメモリの使用率を下げることが目標となります。
以前に共有した内容には限界があり、直接改造するのは難しいものでした。今日はシンプルな方法をご紹介します。いくつかのコレクションタイプを置き換えるだけで、パフォーマンス向上とメモリ使用量削減の効果が得られます。
本日は Collections.Pooled というライブラリを紹介します。名前からもわかるように、メモリをプール化することでメモリ使用量とGCを削減します。後で実際のパフォーマンスを見てみましょう。また、なぜパフォーマンスが向上するのか、ソースコードも見ていきます。
Collections.Pooled
プロジェクトリンク:https://github.com/jtmueller/Collections.Pooled
このライブラリは System.Collections.Generic のクラスをベースにしており、新しい System.Span<T> と System.Buffers.ArrayPool<T> を活用するように変更されています。これにより、メモリ割り当ての削減、パフォーマンスの向上、そして現代的なAPIとの相互運用性の向上を実現しています。
Collections.Pooled は .NET Standard 2.0(.NET Framework 4.6.1+)および .NET Core 2.1+ 向けの最適化ビルドをサポートします。corefx から多数の単体テストとベンチマークが移植されています。
テスト総数:27501。合格:27501。失敗:0。スキップ:0。
テスト実行成功。
テスト実行時間:9.9019秒
使い方
NuGet から簡単にインストールできます。NuGet Version
Install-Package Collections.Pooled
dotnet add package Collections.Pooled
paket add Collections.Pooled
Collections.Pooled ライブラリでは、よく使うコレクション型に対してプール版が用意されています。以下が .NET ネイティブ型との対比です。
| .NET ネイティブ | Collections.Pooled | 備考 |
|---|---|---|
| List |
PooledList |
ジェネリックリスト |
| Dictionary<TKey, TValue> | PooledDictionary<TKey, TValue> | ジェネリック辞書 |
| HashSet |
PooledSet |
ジェネリックハッシュセット |
| Stack |
PooledStack |
ジェネリックスタック |
| Queue |
PooledQueue |
ジェネリックキュー |
使用時には、対応する .NET ネイティブ版を Collections.Pooled 版に置き換えるだけです。以下のコード例をご覧ください。
using Collections.Pooled;
// 使い方は同じです
var list = new List<int>();
var pooledList = new PooledList<int>();
var dictionary = new Dictionary<int,int>();
var pooledDictionary = new PooledDictionary<int,int>();
// PooledSet、PooledQueue、PooledStack も同様の使い方です
var pooledList1 = Enumerable.Range(0,100).ToPooledList();
var pooledDictionary1 = Enumerable.Range(0,100).ToPooledDictionary(i => i, i => i);
ただし、Pooled 型は IDispose インターフェースを実装しており、Dispose() メソッドで使用したメモリをプールに返却します。そのため、Pooled コレクションオブジェクトを使い終わったら Dispose() を呼び出す必要があります。または using var キーワードを使うこともできます。
using Collections.Pooled;
// using var を使うと、pooled オブジェクトの使用後に自動的に解放されます
using var pooledList = new PooledList<int>();
Console.WriteLine(pooledList.Count);
// using スコープを使うと、スコープ終了後に解放されます
using (var pooledDictionary = new PooledDictionary<int, int>())
{
Console.WriteLine(pooledDictionary.Count);
}
// Dispose メソッドを手動で呼び出す
var pooledStack = new PooledStack<int>();
Console.WriteLine(pooledStack.Count);
pooledList.Dispose();
注意: Collections.Pooled のコレクションオブジェクトは解放するのが推奨されます。解放しなくても GC が最終的に回収しますが、プールに戻せないためメモリ節約の効果は得られません。
メモリ空間を再利用するため、内部配列をプールに戻す際に要素を処理する必要があります。そのための ClearMode 列挙型が用意されています。定義は次の通りです。
namespace Collections.Pooled
{
/// <summary>
/// この列挙型は、内部配列を ArrayPool に返却するときにデータをどのように扱うかを制御します。
/// デフォルトの Auto 以外のオプションを使用する前に、各オプションの効果をよく理解してください。
/// </summary>
public enum ClearMode
{
/// <summary>
/// <para><code>Auto</code> はターゲットフレームワークによって動作が異なります。</para>
/// <para>.NET Core 2.1: 参照型および参照型を含む値型は、内部配列がプールに戻されるときにクリアされます。参照型を含まない値型はクリアされません。</para>
/// <para>.NET Standard 2.0: すべてのユーザー型は、参照型を含む可能性があるため、プールに戻す前にクリアされます。.NET Standard では Auto と Always は同じ動作です。</para>
/// </summary>
Auto = 0,
/// <summary>
/// <para><code>Always</code> を設定すると、プールに戻す前に常にユーザー型をクリアします。</para>
/// </summary>
Always = 1,
/// <summary>
/// <para><code>Never</code> を設定すると、プールに戻す前にユーザー型を決してクリアしません。</para>
/// </summary>
Never = 2
}
}
デフォルトでは Auto で問題ありません。特別なパフォーマンス要件があり、リスクを理解した上で Never を使用することもできます。
参照型および参照型を含む値型については、メモリ空間をプールに戻すときに配列の参照をクリアする必要があります。クリアしないと GC がそのメモリ空間を解放できなくなります(要素の参照がプールに保持され続けるため)。純粋な値型であればクリアしなくても構いません。構造体でクラスを置き換えるの記事で説明したように、純粋な値型にはオブジェクトヘッダーがなく、GCの介入も不要です。
パフォーマンス比較
独自にベンチマークを取らず、オープンソースプロジェクトの結果を使用します。多くの項目でメモリ使用量が0になっているのは、プールされたメモリを使用しているため、余分な割り当てがないからです。
PooledList<T>
ベンチマークでは、コレクションに 2048 個の要素をループで追加しています。.NET ネイティブの List<T> は 110us(実際の結果に基づく。図中のミリ秒は誤記と思われる)と 263KB のメモリ、一方 PooledList<T> は 36us と 0KB のメモリです。

PooledDictionary<TKey, TValue>
ベンチマークでは、辞書に 100,000 個の要素をループで追加しています。.NET ネイティブの Dictionary<TKey, TValue> は 11ms と 13MB のメモリ、一方 PooledDictionary<TKey, TValue> は 7ms と 0MB のメモリです。

PooledSet<T>
ベンチマークでは、ハッシュセットに 100,000 個の要素をループで追加しています。.NET ネイティブの HashSet<T> は 5348ms と 2MB、一方 PooledSet<T> は 4723ms と 0MB のメモリです。

PooledStack<T>
ベンチマークでは、スタックに 100,000 個の要素をループで追加しています。.NET ネイティブの PooledStack<T> は 1079ms と 2MB、一方 PooledStack<T> は 633ms と 0MB のメモリです。

PooledQueue<T>
ベンチマークでは、キューに 100,000 個の要素をループで追加しています。.NET ネイティブの PooledQueue<T> は 681ms と 1MB、一方 PooledQueue<T> は 408ms と 0MB のメモリです。

解放しないシナリオ
前述の通り、Pooled コレクション型は解放が必要ですが、解放しなくても GC が回収するため大きな問題はありません。
private static readonly string[] List = Enumerable
.Range(0, 10000).Select(c => c.ToString()).ToArray();
// デフォルトのコレクション型を使用
[Benchmark(Baseline = true)]
public int UseList()
{
var list = new List<string>(1024);
for (var index = 0; index < List.Length; index++)
{
var item = List[index];
list.Add(item);
}
return list.Count;
}
// PooledList を使用し、適切に解放
[Benchmark]
public int UsePooled()
{
using var list = new PooledList<string>(1024);
for (var index = 0; index < List.Length; index++)
{
var item = List[index];
list.Add(item);
}
return list.Count;
}
// PooledList を使用し、解放しない
[Benchmark]
public int UsePooledWithOutUsing()
{
var list = new PooledList<string>(1024);
for (var index = 0; index < List.Length; index++)
{
var item = List[index];
list.Add(item);
}
return list.Count;
}
ベンチマーク結果は以下の通りです。

上記のベンチマーク結果から次のことがわかります。
Pooled型コレクションをすぐに解放すると、GC はほとんどトリガーされず、メモリ割り当てもわずか(上図では 56Byte)です。- 解放しなくても、プールからメモリを取得するため、
ReSizeによる拡張時にもメモリが再利用され、GC によるメモリ割り当て初期化をスキップするため、比較的高速です。 - 最も遅いのは通常のコレクション型で、
ReSize拡張のたびに新しいメモリ空間を確保し、GC が以前のメモリを回収する必要があります。
原理の解説
以前の記事コレクション型には初期サイズを設定すべきやC# Dictionary の実装原理で説明した通り、.NET BCL の開発者たちは高いランダムアクセス性能を実現するために、基本コレクション型の内部データ構造として配列を使用しています。List<T> を例に取ります。
- 新しい配列を作成して要素を格納します。
- 配列の容量が不足すると、2倍のサイズで拡張操作が発生します。
コンストラクタコードは以下の通りで、直接ジェネリック配列を作成しています。
public List(int capacity)
{
if (capacity < 0)
ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.capacity, ExceptionResource.ArgumentOutOfRange_NeedNonNegNum);
if (capacity == 0)
_items = s_emptyArray;
else
_items = new T[capacity];
}
では、メモリをプール化するには、ライブラリ内で new キーワードを使ってメモリを確保している箇所を、プールを使った確保に変更すればよいのです。ここで .NET BCL に含まれる ArrayPool 型を紹介します。これは再利用可能なジェネリックインスタンスの配列リソースプールを提供し、GC への負荷を軽減し、配列の頻繁な作成と破棄が発生するシナリオでパフォーマンスを向上させます。
Pooled 型の内部では ArrayPool を使ってリソースプールを共有しています。コンストラクタを見ると、デフォルトでは ArrayPool<T>.Shared を使って配列を割り当てます。もちろん独自の ArrayPool を作成して使用させることも可能です。
// デフォルトで ArrayPool<T>.Shared プールを使用
public PooledList(int capacity, ClearMode clearMode, bool sizeToCapacity) : this(capacity, clearMode, ArrayPool<T>.Shared, sizeToCapacity) { }
// 配列割り当てに ArrayPool を使用
public PooledList(int capacity, ClearMode clearMode, ArrayPool<T> customPool, bool sizeToCapacity)
{
if (capacity < 0)
ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.capacity, ExceptionResource.ArgumentOutOfRange_NeedNonNegNum);
_pool = customPool ?? ArrayPool<T>.Shared;
_clearOnFree = ShouldClear(clearMode);
if (capacity == 0)
{
_items = s_emptyArray;
}
else
{
_items = _pool.Rent(capacity);
}
if (sizeToCapacity)
{
_size = capacity;
if (clearMode != ClearMode.Never)
{
Array.Clear(_items, 0, _size);
}
}
}
また、容量調整操作(拡張)では、古い配列をプールに返却し、新しい配列もプールから取得します。
public int Capacity
{
get => _items.Length;
set
{
if (value < _size)
{
ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.value, ExceptionResource.ArgumentOutOfRange_SmallCapacity);
}
if (value != _items.Length)
{
if (value > 0)
{
// プールから配列を取得
var newItems = _pool.Rent(value);
if (_size > 0)
{
Array.Copy(_items, newItems, _size);
}
// 古い配列をプールに戻す
ReturnArray();
_items = newItems;
}
else
{
ReturnArray();
_size = 0;
}
}
}
}
private void ReturnArray()
{
if (_items.Length == 0)
return;
try
{
// プールに戻す
_pool.Return(_items, clearArray: _clearOnFree);
}
catch (ArgumentException)
{
// ArrayPool が例外をスローする可能性があるため、握りつぶす
}
_items = s_emptyArray;
}
さらに、作者は Span を使って Add、Insert などの API を最適化し、ランダムアクセス性能を向上させています。また TryXXX 系の API も追加されており、より便利に使えます。例えば List<T> と比較して PooledList<T> には 170 もの変更が加えられています。

まとめ
実際のオンライン使用において、Pooled が提供するコレクション型をネイティブのコレクション型の代わりに使用することで、メモリ使用量と P95 レイテンシの削減に非常に効果的です。
また、たとえ解放を忘れても、ネイティブのコレクション型を使うよりも大幅に性能が劣ることはありません。ただし、適切に解放する習慣をつけるのが最善です。