C#+WPFチューニング戦記

C#とWPFで高速なコードと最適なシステムを書くためにやってきたいろいろな事を書いてみます。.NET Frameworkのソースコードを読み解きましょう。なお、ここに書かれているのは個人の見解であって何らかの団体や企業の見解を代表するものではありません。

callとcallvirtの違いとstructとinterfaceの関係

List<T>の話は具体的なILの話に続いていきます。

ILに変換された後にcallであるかcallvirtであるか、という違いは案外大きいようです。

callである場合、現時点の .NET Frameworkは比較的積極的な最適化を行ってくれます。一方callvirtになる場合の最適化は消極的です。

試しにすべてstruct(値型)でList<T>を実装してみました。

List<T>を実装するという事は大まかには struct StructList<T> : IList<T>というクラスを作成し、GetEnumeratorが返すものはstruct StructList<T>.Enumrator : IEnumerator<T> とするわけです。

そしてここが重要ですが、interfaceを単純に実装するだけでは実は callvirt に変換されるという罠が待っています。

interfaceのメソッドを明示的に実装し、同名で具象型を返す(ただし、具象型の側はinterfaceは実装している)メソッドやプロパティ一式を書きます。ここまでやって初めて、csc.exeはinterfaceメソッドをcallにすることを保証してくれます*1

ビルド時に最適化を有効として、デバッガを介さずにプログラムを走らせると、通常のList<T>よりも1.2~2倍程度の速度*2、インデクサによるアクセスは最適化の恩恵で配列と同等という真に優れたList<T>の高速版が出来上がります。

足りない機能は、内部配列が変更された際に例外が出ないことと(少しリスクはありますが、デバッグ完了後にDEBUG定義の有無で実装を入れ替えると良いでしょう)、List<T>のように継承ができないことくらいでしょうか。

でも、残念なことにデバッガ上で実行すると、純然たるループの速度が通常のList<T>の2倍以上かかります。これは本当に、内部実装をDEBUG定義の有無で切り替える必要があることを意味します。最適化した後のビルドで速度計測しなければ意味がない、という意見も時々ありますが、それは完成品を使うユーザーの話です。私たち開発者にとって「デバッグ中にコードが遅いことも耐え難い」と私は思います。

コアコードには十分な量のユニットテストコードを書き、Debug版とRelease版の両方で確実に動くことを確認すべきです。十分な安全を確保していれば恐れるには足りません。List<T>の代替コードは200行に収まります。この程度ならば。

なお、列挙中のリストの変更に例外を出すなどの色気を出すとなると、標準の.NET Frameworkのコードには恐らく勝てません。

このあたりは塩加減が重要、という話です。

*1:厳密にはこれも保証ではありません。IEnumeratorの基底インターフェース IDisposableのDisposeは何をどうしてもcallvirtでした。interfaceが他のinterfaceを継承する場合、基底interfaceのメソッドはcallにはなりません。何たることでしょう。ILコードのレベルではここまでです。.NET Framework本体側の最適化に期待するしか無いようです。

*2:call命令で呼ばれる上、内部のILのサイズを28バイトまでは縮めることができるでしょう。これはインライン展開対象となり得ます