仕事では、10万件ほどの商品マスタ、数万件のトランザクションデータ、100レコードほどの属性・関連マスタを扱っています。この規模のデータをC#側で集計するなら、処理時間とメモリ割当が気になります。
大量のデータから、商品ごとの件数や合計だけを出したい。こういう集計では、CountByやAggregateByが候補です。全明細をグループにまとめる工程を省き、集計に必要な情報を持つ形で処理できるからです。
検証用の10,000件・5ロールのデータでは、GroupByでグループを作ってから件数や合計を出す処理に比べて、平均実行時間が約43〜56%短く、割当は約150分の1になりました。仕事のデータへ取り入れるのはこれからです。
Index()やSpanのparamsも比べました。書き換えて速くなったものと、速くなるとは言えなかったものを、その結果から見ていきます。
今回の集計は、CountByとAggregateByが速かった
GroupByでグループに分けてから件数を数えている処理は、CountByで書けます。キーごとに値を集計するならAggregateByもあります。例えば、ロールごとの人数を数えるなら、こう書けます。
var counts = users.CountBy(user => user.Role);
グループに分けたあとの全要素を使いたいなら、GroupByを残す場面もあります。今回は、件数や合計だけを取り出す処理を比べました。要素と0始まりの番号を一緒に扱うIndex()も、番号付きのSelectと比べています。
測ったのは2026年10月21日です。環境はWSL2 / Ubuntu 24.04.4 LTS、Intel Core i7-10510U。名前・ロール・スコアを持つ10,000件の配列を使い、ロールは5種類にしました。
BenchmarkDotNet 0.14.0のReleaseビルド、ShortRun、InProcessNoEmitToolchainを使い、各ケースはウォームアップ3回、本測定3回、起動1回です。ランタイムは9.0.19、10.0.11、11.0.0-rc.2.26504.105でした。
比較したLINQの式
各ベンチマークでは、同じ配列に次の式を一つずつ使い、結果を返しています。
var groupedCounts = users.GroupBy(u => u.Role).ToDictionary(g => g.Key, g => g.Count());
var directCounts = users.CountBy(u => u.Role).ToDictionary();
var groupedTotals = users.GroupBy(u => u.Role).ToDictionary(g => g.Key, g => g.Sum(u => u.Score));
var directTotals = users.AggregateBy(u => u.Role, 0, (total, u) => total + u.Score).ToDictionary();
var selected = users.Select((item, index) => (index, item)).ToArray();
var indexed = users.Index().ToArray();
実行時間はこうなりました。平均実行時間±標準偏差で、単位はすべてμsです。
| 処理内容(10,000件・5ロール) | .NET 9 | .NET 10 | .NET 11 RC 2 |
|---|---|---|---|
GroupBy+Count |
351.03 ± 4.80 | 313.27 ± 3.69 | 236.23 ± 2.17 |
CountBy |
174.18 ± 1.50 | 144.68 ± 1.32 | 134.35 ± 2.14 |
GroupBy+Sum |
390.11 ± 3.39 | 354.14 ± 6.55 | 339.54 ± 27.78 |
AggregateBy |
182.08 ± 2.57 | 157.04 ± 2.19 | 151.15 ± 6.77 |
番号付きSelect |
232.18 ± 1.02 | 283.16 ± 44.39 | 194.57 ± 1.35 |
Index |
251.64 ± 2.64 | 268.62 ± 12.18 | 249.69 ± 1.21 |
メモリ割当も見てみます。同じ測定での、1回あたりの値です。最後の2行は、番号と要素そのものを配列にする処理を比べています。
| 処理 | .NET 9 | .NET 10 | .NET 11 RC 2 |
|---|---|---|---|
GroupBy+Count |
166,218 B | 166,218 B | 166,224 B |
CountBy |
1,097 B | 1,097 B | 1,097 B |
GroupBy+Sum |
166,378 B | 166,218 B | 166,226 B |
AggregateBy |
1,121 B | 1,121 B | 1,121 B |
番号付きSelect |
160,343 B | 160,344 B | 160,343 B |
Index |
160,328 B | 160,328 B | 160,328 B |
CountByとAggregateByは、途中のグループを確保せずに集計できます。今回の5ロールの配列では、割当が約150分の1になり、平均実行時間も短くなりました。
Index()の割当は、番号付きSelectとほぼ同じでした。ただ、.NET 11 RC 2の平均実行時間は約28%長くなっています。.NET 10では逆に短く出ていて、ばらつきも大きい。今回の短い測定で、いつもどちらが速いとまでは決められません。
同じ番号付きSelectの式を.NET 8〜11で測った、別の試行もあります。こちらの割当は.NET 8.0.30で423,048 B、.NET 9以降で160,395 Bでした。上の新旧APIの比較とは分けて見ています。同じ式でもランタイムで結果が変わります。速さを理由にIndex()へ書き換えるなら、実際に扱うデータで比べます。
Spanのparamsで、引数を渡す時の割当を比べた
C# 13では、paramsの引数に配列以外のコレクション型も使えるようになりました。例えば、ReadOnlySpan<string>を受け取るメソッドなら、こう書けます。
public void LogMessages(params ReadOnlySpan<string> messages)
{
foreach (var message in messages)
Console.WriteLine(message);
}
割当と時間を比べたのは、コンソール出力を含まない別のメソッドです。文字列4つの長さを合計させ、インライン展開を止めて測りました。params string[]では56 B(平均16.26〜18.18 ns)、params ReadOnlySpan<string>では0 B(平均3.74〜5.26 ns)でした。時間の範囲は.NET 9・10・11 RC 2それぞれの平均値の最小〜最大です。
この呼び出し方では割当を減らせましたが、paramsをSpanにすれば、すべての呼び出しで配列割当がなくなる、という話ではありません。自分のメソッドで使う時も、引数の渡し方まで確かめます。
もう一つ引っかかったのが、受け取ったSpanをstring.Join(string? separator, params ReadOnlySpan<string?> value)へ渡す時です。<Nullable>enable</Nullable>、C# 13、.NET 9の組み合わせで、ReadOnlySpan<string>をそのまま渡すとCS8620の警告が出ました。引数をparams ReadOnlySpan<string?>に合わせると通ります。
同じ.NET 9の参照環境でも、C# 14では警告が出ませんでした。ラッパーを書く時は、引数の型と使うC#のバージョンを確認します。
仕事のデータで比べるなら、商品別の件数や合計から
例えば、読み込んだトランザクションから商品ごとの件数を数えたり、数量や金額の合計だけを取り出したりする処理です。グループごとの明細一覧が不要なら、CountByやAggregateByが候補です。商品別の集計に限らず、ログの種類ごとの件数や、区分ごとの合計を出す処理にも使いどころを考えられます。
仕事で扱う商品マスタや属性・関連マスタとの照合は、キーからどのデータを探すかという処理です。そこは集計の書き方と分けて見ます。実際のデータで比べるなら、C#側で件数や合計を出している箇所からです。
Index()は番号付きの処理を読みやすく書けるかで選び、Spanのparamsは引数の渡し方と言語バージョンまで合わせて見ます。
確認した公式情報
2026年9月29日に確認し、10月21日にRC 2の計測と発表、10月23日にAPIと言語バージョンによる警告の違いを照合。11月5日に集計APIの仕組みを再確認しました。

コメント