大量データの集計を見直したい。CountBy・AggregateByとSpanを測った

仕事では、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の仕組みを再確認しました。

コメント

タイトルとURLをコピーしました