プログラミング
.NET 11におけるパフォーマンスの向上
Performance Improvements in .NET 11 (devblogs.microsoft.com)
要約
.NET 11では、ランタイムとライブラリのパフォーマンスが大幅に向上しました。本記事では、JITコンパイラの最適化、特に「デアストラクション」と呼ばれる、抽象化をランタイム時に解消してパフォーマンスを向上させる技術に焦点を当てています。これにより、コードの変更なしに、多くのシナリオで測定可能かつ証明可能な速度向上が実現されています。
全文翻訳
テレビ番組「The Office」や「Parks and Recreation」がモキュメンタリーを何百万人もの人々の心に刻み込む前に、クリストファー・ゲストがいました。彼はこのジャンルを発明したわけではありませんが、最も影響力のある実践者の一人として広く認識されており、私の意見では、これ以上のものはありません。私は「Waiting for Guffman」と「Best in Show」を数え切れないほど繰り返し見てきました。しかし、最も心に残っているのは、ほんの少しのきっかけで引用してしまう「This Is Spinal Tap」です。もしご覧になったことがあるなら、この話がどこへ向かうのかはすでにわかっているでしょう(まだ見ていないなら、週末の予定ができました)。この映画は、イギリスの老舗ロックバンド、Spinal Tapを題材にした架空のドキュメンタリーで、メンバーは派手なロックスターを想像するときに思い浮かべるすべてを備えています。最も記憶に残るシーンの一つで、ギタリスト(ナイジェル)が映画製作者(マーティ)に彼の最も大切な機材ツアーを行い、特に他にはないアンプを見せます。そのアンプは、ダイヤルが10で止まりません。これが、映画全体で最も引用される可能性のあるやり取りにつながります:ナイジェル:「わかるだろ、ほとんどの連中は10で演奏する。君はここで10だ、ずっと上まで、ずっと上まで、ずっと上まで、ギターで10だ。そこからどこへ行ける?どこへ?」マーティ:「わからない。」ナイジェル:「どこにも行けない。まさに。我々がやることといえば、もし崖っぷちからもう一段階プッシュが必要なら、何をするかわかるか?」マーティ:「11に上げる?」ナイジェル:「11だ。まさに。1つ大きい。」これが.NET 11です。それは1つ大きく、1つ louderで、ランタイムとライブラリをさらに速くするために、さらに1年分のパフォーマンス作業が行われました。もちろん、ナイジェルの特別なアンプの前提はばかげており、その後の数行のセリフで例示されています:マーティ:「なぜ10を大きくして、10を最大数にして、それをもう少し大きくしないのか?」ナイジェル:(一時停止)「…これは11まで行くんだ。」対照的に、.NET 11は実際に1つ高く、1つ louderです。続くセクションは、実際の改善でいっぱいです。境界チェックが削除され、もはや発生しない割り当て、取得されないロック、数サイクルで実行されるループ、定数に折りたたまれた比較、ループからホイストされた冗長なチェック、1つに融合された数命令、回避されたシステムコール、SIMDに引き渡された配列コピーなど、数え上げればきりがありません。それが実際のパフォーマンス作業の進め方であり、ゲインを積み重ね、それぞれが前のゲインに複利で増えていき、全体が測定可能で証明可能なほど louderになります。そして、この記事では、過去数年間に.NET 10、.NET 9、.NET 8、.NET 7、.NET 6、.NET 5、.NET Core 3.0、.NET Core 2.1、そしてそれ以前の.NET Core 2.0で行ってきたように、何百もの改善をゆっくりと見ていきます。これは長い記事です。意図的にそうしています。お好みの温かい飲み物を用意して、腰を落ち着けて、音量を上げましょう。ベンチマーク設定過去数年と同様に、この記事は個々の改善を示すマイクロベンチマークでいっぱいです。ほとんどすべてがBenchmarkDotNetを使用しており、それぞれが自己完結型で書かれているため、ご自身で試すことができます。まず、.NET 10と.NET 11の両方がインストールされていることを確認し(ほとんどのベンチマークは両方のバージョンで同じコードを実行して比較しています)、新しいベンチマークディレクトリに新しいコンソールプロジェクトを作成します:dotnet new console -o benchmarks cd benchmarks生成されたbenchmarks.csprojの内容を以下のように置き換えます。これは両方のバージョンをマルチターゲットするため、BenchmarkDotNetがそれぞれをビルドできます:<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFrameworks>net11.0;net10.0</TargetFrameworks> <LangVersion>preview</LangVersion> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> <AllowUnsafeBlocks>true</AllowUnsafeBlocks> <ServerGarbageCollection>true</ServerGarbageCollection> <SystemPackageVersion Condition="'$(TargetFramework)' == 'net10.0'">10.0.12</SystemPackageVersion> <SystemPackageVersion Condition="'$(TargetFramework)' == 'net11.0'">11.0.0-rc.1.26425.128</SystemPackageVersion> </PropertyGroup> <ItemGroup> <PackageReference Include="BenchmarkDotNet" Version="0.16.0-preview.1" /> <PackageReference Include="System.IO.Hashing" Version="$(SystemPackageVersion)" /> <PackageReference Include="System.Runtime.Caching" Version="$(SystemPackageVersion)" /> <PackageReference Include="System.Numerics.Tensors" Version="$(SystemPackageVersion)" /> </ItemGroup> </Project>テストしたい特定のベンチマークについては、その完全な内容をProgram.csのすべての上にコピーし、実行します。各ベンチマークには、使用する正確なコマンドがコメントとして上部に含まれています。ほとんどの場合、それは次のようになります:dotnet run -c Release -f net10.0 --filter "*" --runtimes net10.0 net11.0これはReleaseモードでビルドし、.NET 10と.NET 11の両方に対してベンチマークを実行し、並列比較を出力します。もう一つの一般的な形式は、ベンチマークが単一のランタイムで2つのコーディングアプローチを比較している場合(ランタイム間で同じコードを比較するのではなく)に使用されます:dotnet run -c Release -f net11.0 --filter "*"通常の免責事項が適用されます:これらはマイクロベンチマークであり、多くは瞬きでも見逃すほど短い操作を測定しています。結果は、ハードウェア、OS、ランタイム構成、その瞬間にマシンで何が実行されているか、そして水星が逆行しているかどうかによって異なります。すべてのマネージドコードの行は最終的にJITコンパイラに到達するため、そこから始めましょう。JIT.NETのパフォーマンスを改善する場所は数多くありますが、JIT(Just-In-Time)コンパイラほど広範な影響を持つ場所はほとんどありません。C#、F#、Visual Basicは通常、まず中間言語(IL)にコンパイルされ、JITは最終的にそのILをCPUが実行するネイティブ命令に変換します。したがって、JITの改善は、最適化されたパターンが発生する場所ならどこでも、アプリケーションとライブラリのコードにメリットをもたらす可能性があり、多くの場合、ソースコードの変更やアプリケーション自体の再コンパイルなしで済みます。単一の命令を削除したり、1つのチェックが不要であることを証明したりするだけでも、コードが非常にホットなパスにある場合は積み重なります。デアストラクション開発者として、私たちは抽象化を愛しています。それらは、クリーンで再利用可能なオブジェクト指向コードを書くことを可能にしますが、実行時にすべての抽象化のコストを払いたくはありません。ランタイムは、効果が観察可能でないと証明できる場合、抽象化を解除できます。仮想呼び出しを見て、どの具体的なメソッドが呼び出されるかを判断したり、ヒープ割り当てを見て、オブジェクトが現在のスタックフレームを離れないことを認識したり、インターフェイスキャストを見て、メソッドの早い段階で確立された型事実を再利用したりできます。このプロセスは「デアストラクション」と呼ばれます。.NETはこの分野で長年着実に改善されており、それは.NET 11でも続いています。C#でインターフェイスを書くたびに、コントラクト、つまりそのインターフェイスを実装する任意の型が他の型と交換可能であるという約束を作成します。この柔軟性は非常に価値があります。なぜなら、例えば、IEnumerable<T>を書き、それが配列、リスト、その他のコレクション、LINQ、カスタムイテレータなどに対して等しくうまく機能するからです。しかし、CPUはこれらのコントラクトについては何も知りません。それは単に命令を実行する方法を知っているだけです。「インターフェイス参照が指すメソッドを呼び出す」ことを実際の機械命令に変換するには、特別な仕組みが必要です。この例を考えてみましょう:// dotnet run -c Release -f net11.0 --filter "*" using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Running; using System.Runtime.CompilerServices; BenchmarkSwitcher.FromAssembly(typeof(Benchmarks).Assembly).Run(args); [DisassemblyDiagnoser, HideColumns("Job", "Error", "StdDev", "Median", "RatioSD")] public class Benchmarks { private Animal _animal = Environment.TickCount >= 0 ? new Dog() : new Cat(); [Benchmark] public int Speak() => _animal.Speak(); public abstract class Animal { public abstract int Speak(); } private sealed class Dog : Animal { [MethodImpl(MethodImplOptions.NoInlining)] public override int Speak() => 1; } private sealed class Cat : Animal { [MethodImpl(MethodImplOptions.NoInlining)] public override int Speak() => 2; } } コンパイル時に、他の条件がすべて同じであれば、JITは_aがどちらであるかを知りません。