.NET 9.0 LINQ のパフォーマンス改善点
(blog.ndepend.com)-.NET 9.0 は、複数の 一般的な LINQ シナリオで .NET 8 より実行時間を大幅に短縮し、一部のベンチマークではアロケーションも排除
- 配列や
List<T>を走査する際にTryGetSpan()でReadOnlySpan<T>を取得し、反復コストを下げることが主要な改善点の一つ TryGetSpan()はTSource[]とList<TSource>を型比較で識別するが、List<T>の内部配列を span としてつかむ方式は、容量変更時に無効化され得る Unsafe 系の最適化 -.NET 9 の LINQ は一般的な呼び出しチェーンを認識して 特殊な iterator を作り、Count()、First()、Last()、ElementAt()、Sum()などの終端メソッドで追加の最適化を適用- 単純な移行と再コンパイルだけで一部の LINQ パフォーマンス改善を得られ、SIMD 活用や空シーケンスの早期検出といった最適化も含まれる
配列とリストの走査が速くなった理由
- 最初のベンチマークは、
Enumerable.Range(1, 10_000).ToArray()をIEnumerable<int>として保持したうえでCount、All、Any、First、Single、Lastを実行し、.NET 8 と .NET 9 を比較するもの - BenchmarkDotNet を使用し、プロジェクトは
net8.0;net9.0を対象にして Release モードでコンパイルする必要がある - .NET 9 では複数のメソッドの実行時間が大幅に短縮され、アロケーションもなくなった
LinqCount: 16,198.490 ns から 3,043.563 ns、32 B のアロケーションからアロケーションなしへLinqAny: 17,096.735 ns から 2,483.927 ns、32 B のアロケーションからアロケーションなしへLinqFirst: 15,289.747 ns から 2,243.341 ns、32 B のアロケーションからアロケーションなしへLinqSingle: 21,684.114 ns から 4,884.329 ns、32 B のアロケーションからアロケーションなしへLinqAll: 10.588 ns から 2.562 ns、32 B のアロケーションからアロケーションなしへLinqLast: 15.967 ns から 6.918 ns
TryGetSpan() が生む違い
- パフォーマンス向上の主な要因は
TryGetSpan()の使用 - 走査対象の enumerable が配列またはリストであれば、
TryGetSpan()がReadOnlySpan<T>を返し、より高速な反復を可能にする - 中核となる分岐コードは
source.GetType() == typeof(TSource[])またはsource.GetType() == typeof(List<TSource>)を検査したうえで span を取得する- 配列は
Unsafe.As<TSource[]>(source)で処理する - リストは
CollectionsMarshal.AsSpan(Unsafe.As<List<TSource>>(source))で内部配列から span を取得する
- 配列は
- コード上は
source.GetType()が 2 回呼ばれ、キャスト後の null チェックでは処理していないが、.NET のパフォーマンス専門家が C# コンパイラと JIT 最適化を考慮して選んだ方式 - 高度に最適化された .NET スタックでは、マイクロ最適化が見た目とは異なる結果を生むことがある
CollectionsMarshal.AsSpan() の制約
List<TSource>は内部的に配列を参照しており、リストの容量を増減する必要がある場合は新しい配列を作成してそれを参照するCollectionsMarshal.AsSpan(Unsafe.As<List<TSource>>(source))は、この 内部配列 からSpan<TSource>を取得する- リストの容量が何らかの形で変わると、この方式で取得した配列が無効化される可能性がある
- この制約のため、
yieldのような遅延走査を含む一部のEnumerable操作では、この最適化に依存しにくい System.Runtime.CompilerServices.Unsafeという名前自体が、こうした危険性を示している
TryGetSpan() の呼び出し範囲
- NDepend で
System.Linq.dllをスキャンし、TryGetSpan()の直接・間接の呼び出し元を確認 - 解析対象のアセンブリパスは
C:\Program Files\dotnet\shared\Microsoft.NETCore.App\9.0.0-rc.1.24431.7\System.Linq.dll TryGetSpan()から コードクエリ を生成して呼び出し元を特定し、56 件のマッチしたメソッドを 依存関係グラフ としてエクスポート- 多くの標準
Enumerableメソッドは、コレクションが配列またはリストの場合に span 走査を試みる - ただし、リストの内部配列を保持する方式は安全ではないため、遅延実行が必要な演算には制限が残る
特殊 iterator ベースの最適化
- 2 つ目のベンチマークは、Consolidate LINQ’s internal IIListProvider/IPartition into base Iterator class PR にある事例を使用
- テスト対象は
Distinct().First()、Append().Select().Last()、Reverse().Count()、DefaultIfEmpty().Select().ElementAt()、Skip().Take().ElementAt()、Union().First()、Select().Where().Select().Sum()など - .NET 9 では一部の呼び出しチェーンが極端に高速化
DistinctFirst: 65.318 ns から 11.192 ns、328 B のアロケーションからアロケーションなしへAppendSelectLast: 4,122.007 ns から 2.661 ns、144 B のアロケーションからアロケーションなしへDefaultIfEmptySelectElementAt: 4,090.818 ns から 5.724 ns、144 B のアロケーションからアロケーションなしへRangeUnionFirst: 66.309 ns から 6.193 ns、344 B のアロケーションからアロケーションなしへListSkipTakeElementAt: 6.268 ns から 2.916 nsRangeReverseCount: 11.024 ns から 6.134 ns
- 一方で
SelectWhereSelectSumは .NET 8 の 3,959.622 ns から .NET 9 の 4,460.008 ns へ遅くなり、112 B のアロケーションも維持される
一般的な LINQ チェーンの認識
- .NET パフォーマンスチームは、一般的な LINQ 呼び出しチェーンを認識するようコードを設計
- 特定のチェーンが検出されると、処理フローをより効率的に扱う特殊な iterator が生成される
- チェーンが
Count()、First()、Last()、ElementAt()、Sum()などのメソッドで終わる場合、追加の最適化が可能 - 例えば
OrderBy(criteria).First()はMin(criteria)のように実行されるよう最適化できる
Iterator<T> と派生クラスの構造
- LINQ 内部には抽象基底クラス
Iterator<T>と 40 個の派生クラスがある - これらのクラスはすべて
Enumerableクラス内にネストされている Iterator<T>は抽象クラスだがメソッドは virtual のため、派生クラスは必要なメソッドだけを override する- この構造が、呼び出しチェーンごとの特殊な動作を持たせる基盤になる
ListWhereSelectIterator<TSource, TResult> の事例
ListWhereSelectIterator<TSource, TResult>は、リストでのWhere(...).Select(...)チェーンを 1 つの iterator として処理する- この iterator は、
ListWhereIterator<TSource, TResult>のSelect()override で生成される ListWhereIterator<TSource>は、Enumerable.Where()がソースがList<TSource>かどうか確認する際に生成されるListWhereSelectIterator<TSource, TResult>はTryGetFirst()やTryGetLast()のようなメソッドを override しない- パフォーマンス改善の核心は、リストで非常によく使われる
Where(...).Select(...)チェーンを 2 つの iterator ではなく 1 つの iterator にまとめた点MoveNext()内で_predicateと_selectorの 2 つの delegate がともに呼び出される
IListSkipTakeIterator<TSource> の事例
IListSkipTakeIterator<TSource>は、適用可能な場合に生成される特殊な iteratorMoveNext()は_state - 1をリストの 0 ベースインデックスとして使用する- 別のインデックスフィールドを持つほうが読みやすいが、iterator のフィールドサイズを減らすため
_stateに bias を入れて保存している - この iterator の最適化は、
_minIndexInclusiveと_maxIndexInclusiveの範囲外の要素を不要に走査しない点にある
移行だけで得られる追加の最適化
- .NET 9 では複数の一般的な LINQ シナリオが高速化されている
- 新しい .NET バージョンの改善を得るために必要な作業は、移行と再コンパイル
- LINQ は他の方法でも最適化されている
- 整数シーケンスの合計のような場合、可能な場所で SIMD を使用
- 空のシーケンスは早期検出により列挙コストが下がる
- DeepDotnet videos は、Scott Hanselman と Stephen Toub が登場する .NET 学習資料として見られる
1件のコメント
Hacker News の意見
LINQ で最も有用な部分は、
IQueryableの構文ツリーに基づく拡張構造でも、言語組み込み構文でもなく、IEnumerableの拡張メソッドだと思う以前はやや紛らわしく「LINQ to Objects」と呼ばれていて、C# を関数型スタイルで簡潔に書けるようにしてくれる
元記事は主に、これらの拡張メソッドの最適化を扱っている
Haskell を学んでからようやくこの方式がしっくり来たし、遅延評価のような Haskell の利点と落とし穴も一部共有している
むやみに使うと難解で遅いコードになり得るので、基本的な関数型のイディオムと遅延評価を理解している人がチームにいないなら勧めたくない
IEnumerableとIQueryableに対する LINQ 拡張の関数型的な側面が好きだ推論しやすいし、Entity Framework のような場所では常に最速の選択肢とは限らないものの、総じてかなり良い選択肢ではある
EF よりも Dapper を使うのも好きだ
ただし C# プロジェクトは抽象化レイヤーが途方もなく多くなりがちで、「エンタープライズ」開発は概して見ていてつらい
名前が少し非標準的なところはあるが、必要なものはすべて揃っている
Eric Lippert が LINQ と絡めてモナドを説明した素晴らしい連載を書いている: https://ericlippert.com/2013/04/02/monads-part-twelve/
ホスト言語の中に別の「組み込み」言語が入るのが好きではないし、最終的な結果も結局 C# に戻らなければならないからだ
Entity Framework や Dapper のような ORM を使わない場合でも、SQL を含むデータアクセスロジックは別の抽象化されたプロジェクトに置くのがよい
そうすればアプリケーション全体に広がらず、別の RDBMS が必要になったときに差し替えられる
20年間で実際にそうなったのは一度だけだったが
ジュニア開発者が LINQ を使うときは、プロファイラとデバッガを付けてあげると、内部で何が起きているかを理解する助けになる
ときにはまず
forループと通常の C# ロジックで書かせてから LINQ 実装と比較し、2つのアプローチの長所と短所を見せるのも有用だクエリ構文は
IEnumerable専用にハードコードされているわけではなく、デフォルトの動作がそうなっているだけで、ほとんどどこにでも使える演算子オーバーロードと少し似たように動作する
[1]: https://github.com/acple/ParsecSharp/blob/da8d0cb9ec39e28dd9...
dotnet チームがツールにもっと多くのリソースと時間を投資しない理由が分からない
doctest とドキュメント生成、実際のコードの横により良く高速な単体テストを書ける機能、ソースコードへのアクセス性、F12 を押したときに DLL を逆コンパイルしなくて済む環境、
pkg.go.devやdocs.rsのようなパッケージとドキュメントの中央ハブが必要だNuGet パッケージの大半はドキュメントがまったくないか、GitHub README だけか、短い wiki 程度だ
Rust、Go、Java、Python のような他の環境は、この点で何光年も先を行っている
ところが恐ろしいことに、それが事実に近いとも思える
例: https://learn.microsoft.com/en-us/dotnet/api/system.string.s...
NuGet パッケージのソースも Source Link を有効にすれば簡単に可能だが、まだ比較的新しい機能なので、すべてのパッケージが対応しているわけではない
今でも大半の C# コードは企業でクローズドソースとして書かれているのだと思う
Microsoft がここ数年のようにオープンな方向へ進み続ければ、時間とともに良くなるだろう
こうした機能の一部は Resharper のようなツールが提供しているが、互いの領域を侵さないという明示的または暗黙の合意があるのではないかと気になる
正直、C# プロジェクトで見たドキュメントの大半は品質が低く、結局ソースコードを見ることになる
自動補完ツールが多くても読む上ではあまり役に立たず、書くときにだけ役立つという経験がある
https://github.com/EWSoftware/SHFB
勧めるかどうかはよく分からない
自分で試してから元に戻したが、ビルド成果物のキャッシュが悪化したせいか、テストにより時間がかかるようだった
「LINQ のパフォーマンス改善」というより、「独自の
List実装のパフォーマンス改善」と言うべきMicrosoft は一般的な改善よりも、自分たちに必要な部分を改善することに時間を使っているように見える
LINQ、特にメソッド拡張ではなくクエリ構文には投資が必要
主にラムダの割り当てと、可能ならコンパイル時のラムダ削減が必要
値型のローカルラムダや、ラムダ割り当てが今のようにオーバーヘッドにならないようにする戦略が必要
LINQ 変数にも今やワイルドカード(
_)対応があるべきなのに、ラムダに導入されたとき完全に無視されたまた、LINQ 式の最後の項目として
select ...の代わりにIEnumerableやOptionのようなリフトされた型を使えるべき特定のユースケースでは
selectが不要なオーバーヘッドを生み、末尾再帰の LINQ 式のようなものも制限してしまう私のライブラリのように LINQ に全力投球しているが、
IEnumerable、IQueryable、LINQ 拡張を使わないライブラリは、ずっと無視されているMicrosoft が自社プロジェクトのパフォーマンス改善にだけ集中しているからだ
良い例が改善されたラムダ推論
ASP.NET Core の Minimal API に必要だったため前倒しされた
言語とフレームワーク機能のかなりの部分が、コミュニティの必要より Microsoft 内部の必要で動いているように見える
最悪なのは、LINQ 拡張である
Select、SelectMany、Whereだけでなく、GetAwaiterのようなマジックメソッドの集合が増え続けていることMicrosoft は、この魔法を解消するために本当に必要な高カインド特性を入れるのではなく、自分たち、主にコンパイラのために機能を追加している
そのためすべてが弱い型付けのまま残り、コンパイラは大まかにしか発見できない
LINQ は言語間の重要な差別化要素の一つなのに、C# 3 以降ほとんど放置されてきた
いまだに LINQ をリスト走査、特に自分たちのリスト実装の走査にだけ有用なものと見ているのは本当に残念
パフォーマンス改善そのものはありがたく、多くのユーザーの役に立つだろうが、焦点がいつも狭く定められ、可能性を制限している
[1] https://github.com/louthy/language-ext/
dotnet/runtimeに issue を立てるか PR を送るのがよい記事で扱われた LINQ のパフォーマンス改善の多くは、そのような形で入った
using文が多いが、プロジェクトを必要な単位に細かく分け、関心事を分離することを理解している人には大きな問題ではないしかし大半の開発者はそのようにはプロジェクトを構造化せず、こうした小さな点が平均的な開発者にとって障壁になり得る
ジュニア開発者は標準の LINQ 構文やメソッド、特にパフォーマンスでもすでに苦労していることが多い
README でこの点に触れているのはよい
普通はライブラリを「売り込む」ことに忙しいものだが、どこが強みで目的が何なのかを実際に書いている点が気に入った
慣用的でないという言及も、C#/.NET を学ぶ人にとっては問題になり得る
Microsoft はツールと言語が特定の慣行に従うことを望むだろうし、関数型プログラミングに自然に合わせた命名は、Microsoft が改善を検討する際にかなり大きな障害と見なされる可能性がある
リポジトリにスターを付けたし、作られているものに強い関心がある
最近作ったいくつかの大きなアプリケーションでは、
Optionとおおよそ似ているように見えるResult型を使ったしかしライブラリを見直してみると、関数型プログラミングをかなり知っていると思っていたが、実際にはそうではないと感じた
C# はかなり得意で複雑なこともやってきたが、関数型プログラミングはたくさん読んでもなお習得が難しく、F# For Fun And Profit がその中では最も理解しやすかった
結局、あなたが何か間違っているという意味ではない
Microsoft は自社エコシステムの大多数を占める平均的または初級の開発者を対象にするだろう
このライブラリが内部的な改善を得られるとよい
莫大な時間をかけたことが見て取れるし、GitHub のスター数を見るだけでも、実際に使って助けられている人がいるという十分なシグナルになっている
無視するように聞こえたなら申し訳ないが、作られたものは興味深く、知らない概念を少しずつ学べるだけのドキュメントも整っているように見える
C# が F# からもっと多くを借りてくるほど良い
判別共用体がついに C# に入り、ドメインモデリングをきちんとできるようになるのを待っている
そのたびに「単に F# を使えばいいのでは?」という疑問が浮かぶ
C# は何年も追いつこうとしてきた
.NET エコシステムの多くの革新を F# が推し進め、機能面でも数年先を行っていたのなら、なぜ利用することでその努力に報いないのか不思議に思う
望む言語開発の方向があるなら、実際の選択で後押しすべきだ
Java エコシステムでもそのやり方は効果があり、今では Java も改善されている
市場が大きくなればエンジニアリング努力も増えるという正のフィードバックループが生まれ得る
何年もフォーラムを読んでいると、C# 陣営は「ただ」自分たちの陣営に残って待ちたいという感じが強い
少し部族主義のように見え、チームが「C#」であるという感じだ
他の言語エコシステムではこうした文化はあまり見たことがなく、F# が .NET ではない別のエコシステムにあったなら、とっくに繁栄していたかもしれないという印象を受ける
工学や科学コードのメンテナンスがずっと楽になる
実際の活用例: https://chrlschn.dev/blog/2024/07/csharp-discriminated-union...
[0] https://github.com/mcintyre321/OneOf
[1] https://github.com/domn1995/dunet
ほかの言語やエコシステムで仕事をしているときに、いちばん恋しくなるのが LINQ
標準ライブラリにこういう機能があるのは本当に良いし、与えられた制約の中で美しく設計されている
.NET 9 のすべての性能改善を扱った、毎年恒例の本一冊分くらいある記事に関連セクションがある
https://devblogs.microsoft.com/dotnet/performance-improvemen...
なぜか HN では再投稿が許可されず、記事はフロントページに上がれず埋もれてしまった
LINQ に慣れて、たいてい LINQ が輝くドメインで働くようになると、別のやり方には戻りたくなくなる
LINQ はあなたを捕まえ、LINQ のない環境を恨むようになるだろう
dotnet でエンドツーエンドの Web 開発を学ぶのに良い、包括的な本やチュートリアルがあるのか気になる
見つけたものはたいてい基礎的すぎるか、古いか、品質が低かった
個人的には Silverlight と同じ道をたどると思っている
古い技術も .NET 9 にはまだあり、動作し、保守されている
最近 .NET で Web 開発をするというのは、だいたい HTTP/JSON/REST API を作り、好みのフロントエンドフレームワークとつなぐこと
自分の場合は React や NextJS を使っている
検索語は ASP.NET WebApi、またはより現代的には ASP.NET Minimal API がよい
Razor を使った .NET MVC のサーバーサイドレンダリングもまだ可能
ASP.NET MVC のマークアップ言語なので、“ASP.NET MVC Razor”で探せばよい
良くも悪くも、.NET で Web アプリケーションを作る方法は ASP.NET が事実上の答えに見える
代替案が少ない点は少し怪しいが
“ASP.NET Core in Action”の著者 Andrew Lock が出ているポッドキャストを聴いたが、内容をよく分かっている人のようだった
まだ本は読んでいないが、探している本かもしれない
1: https://dotnetcore.show/season-6/navigating-the-aspnet-core-...
2: https://www.manning.com/books/asp-net-core-in-action-third-e...
サーバーでは ASP.NET の上で Giraffe を動かせるし、C# と似た性能を出す関数型プログラミング層になる
フロントエンドでは、本物の関数型プログラミング言語で React を書ける
当然、フロントエンドとバックエンドの間で F# コードを共有することもできる
書籍では Mark J Price の “C# 12 and .NET 8 - Modern Cross-Platform Development Fundamentals - Eighth Edition: Start building websites and services with ASP.NET Core 8, Blazor, and EF Core 8”、Xiaodi Yan の “Web API Development with ASP.NET Core 8: Learn techniques, patterns, and tools for building high-performance, robust, and scalable web APIs” がある
チュートリアルは YouTube の IAmTimCorey と Shawn Wildermuth のシリーズがある
.NET バックエンドと JS フロントエンドの組み合わせなら、Minimal API を使う資料を探せばよい
MVC もよいが、後方互換性の重荷が大きく、それで Minimal API が出てきた
このアノテーションのスパゲッティより良い方法があるはず
現代の .NET コードを見るたびに目が痛くなる
ユニットテストやベンチマークコードは、たいていある程度スパゲッティっぽく見えるもの
とはいえ、実際のビジネスロジックでああなっている PR なら自分は通さない
本当に嫌なら、AspNetCore のようなものも属性に一切触れずに使える
自分は属性をほとんど使わない
Count()、First()、Last()、ElementAt()、Sum()のようなメソッドでチェーンが終わるときに、より多くの最適化が可能で、たとえばOrderBy(criteria).First()はMin(criteria)のように実行されるよう最適化できる、という部分は役に立つかもしれないただ、そもそもより良いコードを書くべき
動的に生成されるチェーンには興味深いだろうが、手書きのコードでこういう演算をしているなら、少し歪んだ正の強化のように感じる
ライブラリが非効率なパターンを認識して直してくれるということ
少なくとも、基盤コードの改善を提案するフィードバックがあることを望む