skip to content

はじめての Performance Improvemnets in .NET

/ 37 min read

Table of Contents

#CSharp

> この記事は C# アドベントカレンダー2024 に参加しています!

> 2024/12/16 以下の誤りを修正してます

  • uint にキャストする一連の最適化の目的の理解が間違ってたので修正。CPU命令の違いではなく分岐を減らしてました
  • 「インライン化されている」と書いていた箇所のうち 、インライン化ではなくdevirtualizationであるものはその旨を説明するよう修正。(結果的にインライン化される場合はある)
  • 「JITの最適化」と書いていた箇所のうち、ILコンパイル時の最適化だった点を修正

C# といえば、パフォーマンスが最強レベルであることが特徴のひとつです。

いま手元でTech empower の webアプリケーションベンチマークの 現在のラウンド 2023/10/17 をチラ見しました。参加選手じつに 496 のうち、C#の標準的なフレームワークである ASP .NET Core は、 15位 。上位3%未満、ていうかこのへんになってくるともはやどれくらい本番で使われているのかよくわからない名前が多分に含まれているため、実質トップレベルと言っていいと思いますが、たぶん .NET9 でさらに成績が上がってるんじゃないでしょうか。

たしか一昨年くらいには、gRPCのベンチマークでC#実装がRustを抑えて優勝してたみたいな話もありました。

C#処理系である .NET が Linux を含むクロスプラットフォーム型に舵を切り、まるっと書き直されたのが 2016年。そこから十年弱、Microsoftが全体重をかけてパフォーマンス面を含む先進的な改造をほどこし続けている結果、最近では「C# ってなんかよくわからないけどWindowsでしか動かないレガシーなやつなんでしょ?」みたいなイメージも少しずつ変化したりしなかったりしてきてます。「C#ってなんかしらんけどすごいんでしょ? しらんけど」

.NET というの は仮想マシンランタイムで、つまり JVMとアーキテクチャが似てます。 素朴に考えるなら、「仮想マシンのオーバヘッドがあるからC++/Rus/Zig などのネイティブAOTコンパイル型言語のほうが圧倒的に速いに違いない」といった印象を抱く初学者は多いかもしれません。ところが、Java も昔からそうですが、 ベンチマークの内容によっては 仮想マシンランタイムが勝利することは決して珍しいことではありません。

なぜでしょう? なぜなんですか?

その答えは一言で言い表すこともできますが、よりよく理解するための良質文献のひとつが、一連のBlogシリーズ、「Performance Improvemnts in .NET」、です。

.NET チームの Stephen Toub 氏は、メジャーリリースごとに、どのようなパフォーマンス改善が行われたかを紹介する長大なBlog記事を公開してくれています。 先日リリースされた .NET 9 についても Performance Improvements in .NET 9 が投げ込まれております。

近年はもっぱら最初のセクションが「JIT」となっており、URLを開くとx86 のアセンブリコードがいっぱい載っているという様相が繰り広げられてます。 そうです。もはや近年の .NET のパフォーマンス改善のベンチマークは、実行速度もさることながら「謹製JITコンパイラが最終的に吐くネイティブアセンブリの命令の少なさ」も前後比較されているという、熟練の宮大工がカンナで木を削るとティッシュより薄い削り屑がでる、みたいな、ミクロな戦いに達してます。

このシリーズを追っていくと、.NETに限らないパフォーマンス改善のアレコレや、JITコンパイラの直感に反する最適化などおもしろいトピックスに触れることができます。C#プログラマはパフォーマンス改善のための引き出しが増えそうだし、非C#プログラマに対しては 近年のC#がどんだけ最適化されてるか紹介できるいい情報源だとおもいます。

とはいえこの記事は一回一回が長大で、リンク先も含めて目を通すのはとても時間がかかる上、前回のバージョンからの段階的な改善が前提になっていたりします。

僕は興味本位から、 .NET 9 リリースを期に、このBlogシリーズ第一段である.NET Core 2.0 から順に歴史を追いかけてみました。

以下は主観的な目線でパフォ改善の歴史を追ったメモ書きです。Performance Improvements .NET を読みはじめるなんらかのとっかかりのひとつになれば幸い。

(.NET 9 まで全部追いかけてまとめてみようと思ったんですが案の定アドカレに間に合いませんでした)

一行まとめ

  • .NET Core 2.0
    • (旧).NET Framework時代の C#ライブラリの刷新の話題が多い。
  • .NET Core 2.1
    • Span活用ほかアロケーション削減(特にasync)の話題が多い。JIT改善の項が登場。インライン化が賢くなっている等々。
  • .NET Core 3.0
    • Unsafe や MemoryMarshal などの新スタイルでの標準ライブラリ高速化案件多数。JITの気持ちを意識したC#コード増殖。
  • .NET 5
    • 「bounds-check-free」という用語が登場するのがアツい。nullチェックや配列の境界チェックのスキップの継続的な改善。
  • .NET 6
    • この回から本文でJIT/GCについてかなりくわしい解説をしてくれるようになっている。
  • .NET 7
    • OSR (On Stack Replacement) がデフォルトで有効に。Tired JIT コンパイルができる対象が拡充した。
  • .NET 8
    • Dynamic PGO がデフォルトで有効に。
  • .NET 9
    • Object Stack Allocation (!) へー JVMてそういう機能あったんだ(逆に)

※これ以外にも広い範囲でいろいろな取り組みが書かれているので是非本文を読もう。

.NET Core 2.0

この回ではベンチマーク対象が (旧).NET Framework になっている。標準ライブラリの実装の改善も多い。 この時代は今からみると、改善前がそこまで最適化されてない案件もあったようだ。新 .NET がパフォーマンスに注力しはじめたことを象徴する書きかえっぷりである。アプリケーションプログラマの視点でも真似できそうなレベルの変更も多い。

新 .NET が使えない環境 (Unityとか) では、現代でもプロが最適化のためだけにコレクションを自作しているのを見たことがあるが、.NET以降のこの辺の仕事はそういう場合に参考になるかも。

個人的にかなり気になったのは この List<T>.Add の最適化。 https://github.com/dotnet/coreclr/pull/9539

[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Add(T item)
{
var array = _items;
var size = _size;
_version++;
if ((uint)size < (uint)array.Length)
{
_size = size + 1;
array[size] = item;
}
else
{
AddWithResize(item);
}
}

このPRでは、フィールド変数をあえてローカル変数に入れるように変更している。

この回のブログと上記のPR自体には実はくわしい説明はないようだったのだが、ローカル変数に乗せると、マルチスレッドで参照が書き換わる可能性がなくなるため、JIT がより最適化をする余地ができる。ということらしい。 この場合、if条件式のarrayとifブロック内部のarrayが同一である場合、 size が 配列の要素数以下であることが確定しているので、配列の境界チェックがスキップできることが保証される。みたいなことのようである。

もうひとつ、array.Length をあえて uint にキャストするというキモい変更が行われているのが不可解だ。 これについてはブログ記事自体に説明はなかったが、後のブログシリーズで伏線が回収されることになる……

.NET Core 2.1

この回から先頭に「JIT」セクションが登場。

C# は ILコードと呼ばれる中間コード (バイトコード) にまずコンパイルされることは C#erなら皆知っている。 .NETランタイム では主として、バイトコードを実行するとみせかけて実行時に ネイティブコードにコンパイルしてそれを走らせる。俗に言うJIT(Just In Time)コンパイルである。

JITコンパイラは ILコードをただネイティブコードに翻訳するのではなく、最適化も行う。これが巷で「JITだから速い」とか言われている所以であるが、一連のブログのなかでは、JITの最適化がどのようなものか紹介すると同時に、「なぜAOT(事前コンパイル)よりもJITのほうが最適化の余地があるのか?」といった話に踏み込んだりしていっている。 JITがどのような最適化を行うのか、俗に言う「JITのきもち」によって、速いコードの書きかたが変わったりする。

Performance Improvements in .NET Core 2.1 では、このJITの最適化の改善が紹介されている。

まずわかりやすいのはこのへん。

https://github.com/dotnet/coreclr/pull/14125

EqualityComparer<T>.Default.Equals(..) という式を JIT がみつけると、こいつ専用のスペシャルな実装にリダイレクトする。宣言型をみるとこの .Default の戻り値はabstractクラスなのだが、T が決まると型も一意に決定できるため、JITは 具象型の .Equals(..) を直接呼び出すコードを生成する。仮想関数呼び出しがなくなり、インライン化も行われる可能性が高まる。

ちなみに .NET では C# をIL にコンパイルする時点ではインライニングを行わないらしく、インライニングはもっぱらJITによるネイティブコンパイル時の仕事になってるそうですね。まるとく情報。

この最適化の結果として、 EqualityComparer<T>.Default を変数に入れてから .Equals を呼び出すよりも、 EqualityComparer<T>.Default.Equals() と、ひとつなぎに呼び出したほうが圧倒的に速い、という直感的には奇妙な結果がもたらされるそう。

おもしろことに、後のPRで、この種の一時変数の使用が廃止される変更も同時に行われたそうである。

そういう意味でこれは、JITのきもちになることで直感的には遅いコードが速くなる例のひとつと言える。

続いて、このPRでは、ループ内に return 文がある場合に JITが生成するコードが太る問題が直ったそうなんですが、 https://github.com/dotnet/runtime/issues/7474

この修正が入る以前は、「ループを書くよりも goto文の方が速い」というケースが存在していたが、これ以後でそういうハックの意味がなくなったらしい。 JITのきもちが変わった結果、速かったコードが逆に普通になるという逆のパターンである…。 これ以降もしばしば、以前のバージョンでは速くなったはずの最適化が逆に遅くなったのでrevertする、といった話題はたまに出てくる。

続いて、 C#er にとって有名なやつがこちらである。 https://github.com/dotnet/coreclr/pull/14698 https://github.com/dotnet/coreclr/pull/17006

private void BoxingAllocations<T>(T thing)
{
if (thing is IAnimal)
((IAnimal)thing).MakeSound();
}
private struct Dog : IAnimal
{
public void Bark() { }
void IAnimal.MakeSound() => Bark();
}
private interface IAnimal
{
void MakeSound();
}

IL のきもちまでしか想像していない場合は、(IAnimal)thing がボクシングされてしまい遅そうであるが、なんと JIT は、「IAnimal の仮想関数呼び出しにみえるけどこの実装は絶対に thing の MakeSound()になる」ことがわかる。そしてそのように直接呼び出しをする。インライン化の可能性も高まる。 このバージョンの .NET はこのキャストのコードがなければ IAnimal を経由した呼び出しになるため、つまり ナンセンスなキャストが存在するほうが逆に速いシリーズの筆頭である。

ほかのセクションで印象深いのは 、 asyncメソッドや CacellationToken のオーバヘッドの削減あたり。 このバージョンでは、asyncメソッドの GCアロケーションが一回につき4だったのが1になった的な話が紹介されている。 以降のバージョンでもasyncについてのホットパスまわりはけっこう話題にでてくる。

ライブラリまわりの最適化では、引き続きSpan や stackalloc による最適化が進んでいたりといった話がいろいろでてくるが、しばしば「vectorize」というキーワードが登場しはじめる。 データを一度にたくさん CPUに渡し、それを一撃で処理するハードウェア命令を使用する、SIMD(Single Instruction/Multiple Data) のあれである。 vectorize とか SIMDとか聞くと、128bit とか 256bit とかのでかい 構造体にデータを詰めて専用命令を呼ぶやつを想像するが、byteや char のループみたいなやつを long に詰めるみたいな簡単なやつもみかけた。このへんはアプリケーションプログラマもいざというとき参考になるかもしれない。

そのほか、HttpClientHandler がC#実装になった結果スループット10倍、Guid生成もC#実装になって高速化、といった話題もけっこう興味深い。 ネイティブ実装からマネージドC#実装に移植すると、ポータビリティだけでなく実行速度も改善される、という現象は以降のバージョンでも増殖していき、より詳しく語られることになる。

.NET Core 3.0

JIT。 このバージョンで Tired compile (階層型コンパイル) がデフォルトに有効に。逆に、ここまでその機能なかったというのは個人的には意外だった。 Tired Compile によって、JIT はコンパイルが速いTier 0 と、コンパイルに時間をかける代わりに 最適化を積極的にする Tier 1 の二段階のネイティブコードを生成するようになった。これによって起動速度(コンパイル速度)と最高速度を両立できる。さいきんの .NET はコンパイル&起動までの速度が相当速いとおもうが、その開発体験と最高実行速度の両立ができてWinWinです。

ほか JITの改善で興味深いのは、 static readonly field の解釈が賢くなったという点。 https://github.com/dotnet/coreclr/pull/20886 Array.Pool.Shared は実装が static な SharedArrayPool という具象型だが、JITはこの型が実行時に絶対に SharedArrayPoolでしかありえないことを理解して、仮想関数呼び出しをスキップしてダイレクトに呼びだすことができる。

ほか、この回では、ここまでのバージョンで標準ライブラリ内でも多用されている Span そのものを速くしたという話から始まっている。

個人的な名作はこれです。 https://github.com/dotnet/coreclr/pull/20771/files#diff-315e3f65102dc2ff2386661c6fc672d8411233cc90ba786421ed2ca8d2d5ac0bR348

if ((ulong)(uint)start + (ulong)(uint)length > (ulong)(uint)array.Length)

(ulong) へのキャストを増やすことで速くなってるとかホンマか? と不思議に思ったんですが、変更前の条件式が以下で、

if ((uint)start > (uint)_length || (uint)length > (uint)(_length - start))

分岐がひとつ減っている、という話らしい。このコードは現在の .NET でも生きているようだ。

これに限らず int をあえて uint にキャストする 、を利用した最適化は一連のPRでたまに出現する。 これは 負の値を uint にキャストすると、アンダーフローによって必ず int.MaxValue より大きい値になることを利用しているそう。 a, b が int のとき、a >= 0 && a < b は (uint)a < (uint)b と同じ結果になる。後者は分岐がひとつ減っている。分岐が減るのはCPUにとてもやさしい。

またさらに細かい同種の最適化として、 1 とか 2とかと比較するよりも 0 と比較するほうが CPU命令としては高速なものがつかえる、みたいなのもあるようです。(つまり for in ループとかは末尾から0に向かってデクリメントする方が速いらしい) 覚えておくとめちゃめちゃミクロなチューニングが必要になったときに使えたり使えなかったりするかもしれません。

Unsafe や MemoryMarshal といった新顔を利用した最適化は、C#プログラマにとっては興味深いです。 https://github.com/dotnet/coreclr/pull/17891

Unsafe は マネージドポインタ(参照) に対してポインタ演算とかをする低レベルなアレ。MemoryMarhal は Span に対して unsafe なキャストなどを使うアレ、と覚えておこう。

ほか、このリリースでは内部的に使用されていた StringBuilderがクビになったという話題にそこそこ紙面が割かれていたりする。 Webサーバのユースケースについてよく言及されているので、 .NET コアチーム的には やっぱり割とサーバサイドを意識してるようである。

asyncまわりで参考になりそうなのは、 IValueTaskSource とか IThreadPoolItem とかのインターフェイスの活用。

C# の Task は スレッドプール上にスケジュールされるけど、IThredPoolItem を実装した任意の型をスレッドプールに投げ込めるようになった結果、クロージャを投げこむ場合に比べてアロケーションを削減できるようになったらしい。 こういうのは地味に参考になる。 AsyncStatemachineとかが IThreadPoolItemになったらしいですね。

実装 が刷新された ConcurrentDictionary、 ConcurrentQueue とかコレクションについて、ユーザからのレポートによってめっちゃ改善してる的なコミュニティのパワー的な話が続く。

引き続きインライン化による高速化の話題も。 https://github.com/dotnet/corefx/pull/35364

これに限らずですが、 例外のthrowを含むメソッドはインライン化を阻害するという特性があるらしく、例外のthrowだけするメソッドを切り出して呼び出し元はインライン化するといった黄金パターンが登場します。

Sokcetまわりは OS 毎のネイティブ実装を呼び出してるそう。epoll で必ず発生していたアロケーションがぜろになったなどの改善がいろいろ紹介されてます。 ZlibやBrotoli はネイティブ実装を呼びだしているそうですが、パッチが当たったやつが使われてるらしい。 このへんもWebサーバ用途が意識されるのがうかがえます。

ほか、毎回LINQ改善はかなりがんばってるみたいです。

また、この回では 「interop」という項目があります。 SafeHandleの実装がマネージドコードになった、という話や、FFIで使用されていた暗黙のマーシャル機能をやめて StringBuilderを滅ぼして気合いで生ポインタ渡しするようになった、といったやっぱそうすか的な案件が紹介されてます。

このバージョンになっても、アロケーションを減らす修正が大量に紹介されていることはけっこう興味深いです。 GC自体もかなり改善が重ねられているはずですが、GCヒープアロケーションの削減は常に有効な施策として登場します。 new byte[T] して るところを Array<T>.Empty になったとか、まだそういう箇所あったのかよ的な修正も出てます。ちなみに最新のC# ならこれは [] と書かえるようになってるので、new T[0] とかは禁止していきましょう。

ほか興味深かったのは、staticコンストラクタを削除することによってパフォーマンス改善する、というケースです。これは知らなかった。 static コンストラクタを実行する必要のない型については、C#コンパイラは BeforeFieldInit アトリビュートを付与する。これがあるとJITは任意のタイミングでの初期化を許されるため、最適化の余地が増える、とのことです。(詳細は本文を参照してほしい)

下のほうへ行くと、CancellationToken.Register において ExecutionContext を完全無視する UnsafeRegisterの話題がでてきたりします。 このへんのAPI、初見では Unsafe ってどういう意味なのかかなりよくわかんねーですが、ようするにExecutionoContext や SynchronizationContext を無視する API に「Unsafe」という名前がついているという話のようです。 .NET の API はなんやかんやで ExecutionContext や SynchoronizationContext の操作が必要だったりしますが、UniTask なんかも ExecutionContext の存在を無視していたり、削れるオーバヘッドのひとつとして知っておくといいかもしれません。

GCの改善では以下の話が紹介されてました。

.NET5

この回以降、JITやGCが目玉の改善として冒頭の一曲目からはじまるようになってる。

このあたりから 徐々に最適化も高度になっていくというか、C# 書く上で参考にできそうな情報というより ランタイムが賢くなってすげー、といった感想になってくる。

GC の改善については、マークするアルゴリズムの改善や、OSにメモリを返却する「デコミット」の改善など細かいいろいろが参考リンク込みで説明されているので興味のある人はみてみよう。

この回で興味深い話題のひとつは、ネイティブ実装されていたコードのマネージドC#コード移植の拡大について。 マネージドに移行することの利点は、移植性の向上や、最適化に乗っかることによる結果的なパフォーマンス改善などもありますが、もうひとつ、GCによるスレッドの停止時間の削減、というメリットが紹介されてます。曰く、ネイティブコードが実行されている間はGCはスレッドに割り込めないため、ネイティブコードの実行を待つ必要があり、結果的に任意のタイミングで割り込める場合よりもGC Collectの実行時間が伸びてしまうそうです。へー しらなかった。

GCの話題では、 [SkipLocalsInit] アトリビュートの追加の話と共に、ゼロクリア問題についての説明があります。 本文によると、.NET のGC は “precise” (精密) なGCであるため、“conservative” (保守的) GCと違ってオブジェクト参照を完璧に追跡するそう。その影響で、ローカル変数は使う前にゼロクリアしてあげないと、中身が参照だった場合にGCがオブジェクト参照と誤解してしまうリスクがあるが、.NET のGCはそれを許容しない。そのためローカル変数は .NET の仕様としてゼロクリアが必須になっているが、unsafe コード内でこの制約を回避するための 裏ワザが [SkipLocalsInit] とのこと。

この流れで「マネージドコードの税金」という言い回しが出現します。さらなる最適化のため、マネージドコードが提供する抽象度に対しての安全性を保ちつつ、安全性の保証のためのオーバヘッドをいかに消していくか、という問題設定がされました。

税金のひとつが配列の境界チェック。

たとえばC言語には、配列はあるものの、配列という機能に対しての安全性は提供されていないので、C言語で存在しない配列の添字を参照しようとすると容赦なく セグメンテーションフォルトとかになってクラッシュします。一方、C#のような言語機能に対して安全性をもつ言語では、 存在しない配列のインデックスを参照すると IndexOutOfRangeException 例外が投げられるという仕様が保証されてます。

JIT にとって、この挙動を保証するためには余計な分岐が必要です。

ですが、配列のインデックスアクセスのなかには、絶対に境界をオーバしていないと推論できるコードも存在します。 .NET ではたとえば以下のようなコードは配列の境界チェックが不要であると判断します。

int[] arr = ...;
for (int i = 0; i < arr.Length; i++)
Use(arr[i]);

.NET 5 では、このような配列の境界チェックのスキップ判定が賢くなった事例が紹介されてます。

新たに境界チェックが不要になったパターンとして以下のような例が紹介されてます。

static ReadOnlySpan<byte> span => new byte[] { 1,2,3,4 };
byte Case1(int i) => span[i & 2];
byte Case2(int i) => span[i & (span.Length - 1)]; // "i & 3"
byte Case3(int i) => span[(i & 10) >> 2]; // or <<
byte Case4(uint i) => span[(int)(i % 2)]; // only for unsigned i
for (int i = 0; i < span.Length - 1; i++)
if (span[i] > span[i + 1])
return false;

このような、配列境界チェックが削除できるコードのことを 本文では「bounds-check-free」と呼んでます。ロックフリーとかGCフリーとかは聞いたことありますが、バウンズチェックフリーは聞いたことなかったす。来るとこまで来た感があります。

境界チェックのほかには、似た話としてnullチェックの除去の話もでてます。 ここはむしろ こんな無駄なやつあったんだ…… というのが知らなかったので意外だった。 C# は コンパイラによる nullableチェックの機能が入ってますが、依然としてコンパイラチェックをすりぬける方法は存在してる言語なので、実行時には null チェックが挿入されざるを得ないようですね。

ほかの話題をみると、ふつうに考えると遅そうだけど最適化 によって遅くななるケースが増えてます。

以下のコードはふつうに考えると実行時に byte配列のヒープアロケーションが発生しそうですが、

ReadOnlySpan<byte> map = new byte[] { (byte)'0', (byte)'1', (byte)'2', (byte)'3', (byte)'4', (byte)'5', (byte)'6', (byte)'7', (byte)'8', (byte)'9', (byte)'A', (byte)'B', (byte)'C', (byte)'D', (byte)'E', (byte)'F' };

.NET5以降ではコンパイル時に以下のようなコードに変換されるとのこと。

private static readonly byte[] s_map = new byte[] { (byte)'0', (byte)'1', (byte)'2', (byte)'3', (byte)'4', (byte)'5', (byte)'6', (byte)'7', (byte)'8', (byte)'9', (byte)'A', (byte)'B', (byte)'C', (byte)'D', (byte)'E', (byte)'F' };
...
ReadOnlySpan<byte> map = s_map;

エッジケースよりなところだと、ジェネリクス型の共変性チェックを排除する話がでてきます。

array.GetType() != typeof(T[]) などの型チェックの際、sealed がついている場合に右辺がインライン化されるそうです。

ほか、特定の条件が揃えばGenericsもインライン化できるパターンが増えた、などの話題も。

JITが生成するコード量の削減についても注意が払われてます。

var s0 = new Span<int>(arr, _offset, 1);
var s1 = new Span<int>(arr, _offset + 1, 1);
var s2 = new Span<int>(arr, _offset + 2, 1);
var s3 = new Span<int>(arr, _offset + 3, 1);
var s4 = new Span<int>(arr, _offset + 4, 1);
var s5 = new Span<int>(arr, _offset + 5, 1);

C# のレベルだとどこに重複コードがあるのか意味不明ですが、上記のコードでは、Spanのコンストラクタの引数チェックで、例外を投げるコードがあり、これは冷静に考えるとジャンプ先の例外throwが重複してるのが見える的なかんじらしいです。

このほか、JITの細かい改善がたくさん入っていて、このへんからx86アセンブリの前後比較も出てきてます。 “hogehoge”.Length が 定数にコンパイルされたり、微妙におもしろかったのは、b ? 1 : 0 みたいなコードの場合にレジスタに 1や0が入る命令が利用されるなどマニアックめなやつも出てます。

“On Stack Replacement”(OSR) の話題があります。このバージョンではデフォルトではこの機能は無効のようですが、メソッドの命令が stack上に既に読み込まれていようがいまいが実装をすげかえることが可能いなる機能らしいです。 これ以前は、実行時間の長そうなメソッドについてはtier 1 コンパイルが阻害されていたところが、そういった制約がなくなったそうです。

「intrinsics」という項目では、.net core 3 で 入ったx86用の命令と同等のものが arm社員の人物の貢献で追加されまくったそうです。

このバージョンにおいても、ものすごい細かい最適化が逆に遅くなったので元に戻す、みたいなことが発生していると紹介されてます。 .NET本体でもこうなのだから、ライブラリとかアプリケーションとか書くときにミクロすぎる最適化をするときは結果をちゃんと調べないとダメすね、という気持ちを新たにさせてくれます。