2 ポイント 投稿者 GN⁺ 2024-10-20 | 1件のコメント | WhatsAppで共有

-.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> として保持したうえで CountAllAnyFirstSingleLast を実行し、.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 ns
    • RangeReverseCount: 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> は、適用可能な場合に生成される特殊な iterator
  • MoveNext()_state - 1 をリストの 0 ベースインデックスとして使用する
  • 別のインデックスフィールドを持つほうが読みやすいが、iterator のフィールドサイズを減らすため _state に bias を入れて保存している
  • この iterator の最適化は、_minIndexInclusive_maxIndexInclusive の範囲外の要素を不要に走査しない点にある

移行だけで得られる追加の最適化

  • .NET 9 では複数の一般的な LINQ シナリオが高速化されている
  • 新しい .NET バージョンの改善を得るために必要な作業は、移行と再コンパイル
  • LINQ は他の方法でも最適化されている
    • 整数シーケンスの合計のような場合、可能な場所で SIMD を使用
    • 空のシーケンスは早期検出により列挙コストが下がる
  • DeepDotnet videos は、Scott Hanselman と Stephen Toub が登場する .NET 学習資料として見られる

1件のコメント

 
GN⁺ 2024-10-20
Hacker News の意見
  • LINQ で最も有用な部分は、IQueryable構文ツリーに基づく拡張構造でも、言語組み込み構文でもなく、IEnumerable の拡張メソッドだと思う
    以前はやや紛らわしく「LINQ to Objects」と呼ばれていて、C# を関数型スタイルで簡潔に書けるようにしてくれる
    元記事は主に、これらの拡張メソッドの最適化を扱っている
    Haskell を学んでからようやくこの方式がしっくり来たし、遅延評価のような Haskell の利点と落とし穴も一部共有している
    むやみに使うと難解で遅いコードになり得るので、基本的な関数型のイディオムと遅延評価を理解している人がチームにいないなら勧めたくない

    • 自分も IEnumerableIQueryable に対する LINQ 拡張の関数型的な側面が好きだ
      推論しやすいし、Entity Framework のような場所では常に最速の選択肢とは限らないものの、総じてかなり良い選択肢ではある
      EF よりも Dapper を使うのも好きだ
      ただし C# プロジェクトは抽象化レイヤーが途方もなく多くなりがちで、「エンタープライズ」開発は概して見ていてつらい
    • 自分も LINQ はこう使っている
      名前が少し非標準的なところはあるが、必要なものはすべて揃っている
      Eric Lippert が LINQ と絡めてモナドを説明した素晴らしい連載を書いている: https://ericlippert.com/2013/04/02/monads-part-twelve/
    • LINQ は常にメソッド構文だけを使ってきた
      ホスト言語の中に別の「組み込み」言語が入るのが好きではないし、最終的な結果も結局 C# に戻らなければならないからだ
      Entity Framework や Dapper のような ORM を使わない場合でも、SQL を含むデータアクセスロジックは別の抽象化されたプロジェクトに置くのがよい
      そうすればアプリケーション全体に広がらず、別の RDBMS が必要になったときに差し替えられる
      20年間で実際にそうなったのは一度だけだったが
      ジュニア開発者が LINQ を使うときは、プロファイラとデバッガを付けてあげると、内部で何が起きているかを理解する助けになる
      ときにはまず for ループと通常の C# ロジックで書かせてから LINQ 実装と比較し、2つのアプローチの長所と短所を見せるのも有用だ
    • Haskell が好きなら、LINQ のクエリ構文を使ったコンビネータパーサの構築のような別の活用法も気に入るかもしれない
      クエリ構文は IEnumerable 専用にハードコードされているわけではなく、デフォルトの動作がそうなっているだけで、ほとんどどこにでも使える
      演算子オーバーロードと少し似たように動作する
      [1]: https://github.com/acple/ParsecSharp/blob/da8d0cb9ec39e28dd9...
    • LINQ 構文が明日なくなってもあまり惜しくはないが、関数型合成は本当に強力で、保守もしやすい
  • dotnet チームがツールにもっと多くのリソースと時間を投資しない理由が分からない
    doctest とドキュメント生成、実際のコードの横により良く高速な単体テストを書ける機能、ソースコードへのアクセス性、F12 を押したときに DLL を逆コンパイルしなくて済む環境、pkg.go.devdocs.rs のようなパッケージとドキュメントの中央ハブが必要だ
    NuGet パッケージの大半はドキュメントがまったくないか、GitHub README だけか、短い wiki 程度だ
    Rust、Go、Java、Python のような他の環境は、この点で何光年も先を行っている

    • Microsoft が OpenAI に投資したのは、.NET/NuGet パッケージのドキュメントを探索する唯一まともな方法だからだ、という冗談を言いたくなる
      ところが恐ろしいことに、それが事実に近いとも思える
    • 今では Microsoft のドキュメントには、見ているメソッドのソースへ直接移動するリンクが入っている
      例: https://learn.microsoft.com/en-us/dotnet/api/system.string.s...
      NuGet パッケージのソースも Source Link を有効にすれば簡単に可能だが、まだ比較的新しい機能なので、すべてのパッケージが対応しているわけではない
    • 同意するが、オープンソースの C# が比較的最近のことだという点も原因である可能性が高い
      今でも大半の C# コードは企業でクローズドソースとして書かれているのだと思う
      Microsoft がここ数年のようにオープンな方向へ進み続ければ、時間とともに良くなるだろう
      こうした機能の一部は Resharper のようなツールが提供しているが、互いの領域を侵さないという明示的または暗黙の合意があるのではないかと気になる
      正直、C# プロジェクトで見たドキュメントの大半は品質が低く、結局ソースコードを見ることになる
      自動補完ツールが多くても読む上ではあまり役に立たず、書くときにだけ役立つという経験がある
    • Sandcastle Help File Builder はかなり昔からあり、記憶では Microsoft 内部プロジェクトとして始まったはずだが、不思議なことに使っているライブラリは少ない
      https://github.com/EWSoftware/SHFB
    • コードの横にテストを書く方法もある: https://clipperhouse.com/go-test-csharp/
      勧めるかどうかはよく分からない
      自分で試してから元に戻したが、ビルド成果物のキャッシュが悪化したせいか、テストにより時間がかかるようだった
  • 「LINQ のパフォーマンス改善」というより、「独自の List 実装のパフォーマンス改善」と言うべき
    Microsoft は一般的な改善よりも、自分たちに必要な部分を改善することに時間を使っているように見える
    LINQ、特にメソッド拡張ではなくクエリ構文には投資が必要
    主にラムダの割り当てと、可能ならコンパイル時のラムダ削減が必要
    値型のローカルラムダや、ラムダ割り当てが今のようにオーバーヘッドにならないようにする戦略が必要
    LINQ 変数にも今やワイルドカード(_)対応があるべきなのに、ラムダに導入されたとき完全に無視された
    また、LINQ 式の最後の項目として select ... の代わりに IEnumerableOption のようなリフトされた型を使えるべき
    特定のユースケースでは select が不要なオーバーヘッドを生み、末尾再帰の LINQ 式のようなものも制限してしまう
    私のライブラリのように LINQ に全力投球しているが、IEnumerableIQueryable、LINQ 拡張を使わないライブラリは、ずっと無視されている
    Microsoft が自社プロジェクトのパフォーマンス改善にだけ集中しているからだ
    良い例が改善されたラムダ推論
    ASP.NET Core の Minimal API に必要だったため前倒しされた
    言語とフレームワーク機能のかなりの部分が、コミュニティの必要より Microsoft 内部の必要で動いているように見える
    最悪なのは、LINQ 拡張である SelectSelectManyWhere だけでなく、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# に入り、ドメインモデリングをきちんとできるようになるのを待っている

    • .NET コミュニティでこうした話がよく出るのは興味深い
      そのたびに「単に F# を使えばいいのでは?」という疑問が浮かぶ
      C# は何年も追いつこうとしてきた
      .NET エコシステムの多くの革新を F# が推し進め、機能面でも数年先を行っていたのなら、なぜ利用することでその努力に報いないのか不思議に思う
      望む言語開発の方向があるなら、実際の選択で後押しすべきだ
      Java エコシステムでもそのやり方は効果があり、今では Java も改善されている
      市場が大きくなればエンジニアリング努力も増えるという正のフィードバックループが生まれ得る
      何年もフォーラムを読んでいると、C# 陣営は「ただ」自分たちの陣営に残って待ちたいという感じが強い
      少し部族主義のように見え、チームが「C#」であるという感じだ
      他の言語エコシステムではこうした文化はあまり見たことがなく、F# が .NET ではない別のエコシステムにあったなら、とっくに繁栄していたかもしれないという印象を受ける
    • 測定単位型も本当に欲しい
      工学や科学コードのメンテナンスがずっと楽になる
    • OneOf[0] と Dunet[1] を使えば、すでに判別共用体をかなり簡単に導入できる
      実際の活用例: https://chrlschn.dev/blog/2024/07/csharp-discriminated-union...
      [0] https://github.com/mcintyre321/OneOf
      [1] https://github.com/domn1995/dunet
    • F# が C# と VB.NET 機能の実験場であることは公然の秘密であり、Hanselman のような公式寄りの人物たちも何度も引用している
  • ほかの言語やエコシステムで仕事をしているときに、いちばん恋しくなるのが LINQ
    標準ライブラリにこういう機能があるのは本当に良いし、与えられた制約の中で美しく設計されている

  • .NET 9 のすべての性能改善を扱った、毎年恒例の本一冊分くらいある記事に関連セクションがある
    https://devblogs.microsoft.com/dotnet/performance-improvemen...
    なぜか HN では再投稿が許可されず、記事はフロントページに上がれず埋もれてしまった

  • LINQ に慣れて、たいてい LINQ が輝くドメインで働くようになると、別のやり方には戻りたくなくなる

    • 友よ、LINQ 中毒になってはいけない
      LINQ はあなたを捕まえ、LINQ のない環境を恨むようになるだろう
    • それでも polars みたいなものほど強力ではない
  • dotnet でエンドツーエンドの Web 開発を学ぶのに良い、包括的な本やチュートリアルがあるのか気になる
    見つけたものはたいてい基礎的すぎるか、古いか、品質が低かった

    • .NET の Web 開発で最近新しくて熱いのは Blazor だが、Microsoft ブログ圏の外ではあまり人気がなく、今後もそうはならなそう
      個人的には 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”で探せばよい
    • 最近 C# での Web 開発に興味を持つようになった
      良くも悪くも、.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...
    • 少し非主流だが、F# と Fable の組み合わせは非常に強力
      サーバーでは 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 のシリーズがある
    • サーバーレンダリング UI なら Razor を使う資料を探し、最初は Blazor の資料を避けるのがよい
      .NET バックエンドと JS フロントエンドの組み合わせなら、Minimal API を使う資料を探せばよい
      MVC もよいが、後方互換性の重荷が大きく、それで Minimal API が出てきた
  • このアノテーションのスパゲッティより良い方法があるはず
    現代の .NET コードを見るたびに目が痛くなる

    • その属性は、記事で使っているベンチマークライブラリに対応するもの
      ユニットテストやベンチマークコードは、たいていある程度スパゲッティっぽく見えるもの
      とはいえ、実際のビジネスロジックでああなっている PR なら自分は通さない
      本当に嫌なら、AspNetCore のようなものも属性に一切触れずに使える
    • どんな .NET コードを見ているのか分からない
      自分は属性をほとんど使わない
  • Count()First()Last()ElementAt()Sum() のようなメソッドでチェーンが終わるときに、より多くの最適化が可能で、たとえば OrderBy(criteria).First()Min(criteria) のように実行されるよう最適化できる、という部分は役に立つかもしれない
    ただ、そもそもより良いコードを書くべき
    動的に生成されるチェーンには興味深いだろうが、手書きのコードでこういう演算をしているなら、少し歪んだ正の強化のように感じる
    ライブラリが非効率なパターンを認識して直してくれるということ
    少なくとも、基盤コードの改善を提案するフィードバックがあることを望む