POSTD PRODUCED BY NIJIBOX

POSTD PRODUCED BY NIJIBOX

ニジボックスが運営する
エンジニアに向けた
キュレーションメディア

本記事は、原著者の許諾のもとに翻訳・掲載しております。

.NETのリフレクションが遅い のは周知の事実ですが、なぜなのでしょうか。この投稿では、リフレクションの 実装 を見ながらなぜ遅いのかを解明します。

CRL型システム設計目標

リフレクションが速くない理由の1つとして、そもそも 高いパフォーマンス が設計上の目標にされてはいないことを挙げることができます。 型システム概要 – 設計の目標および非目標 では次のように記載されています。

目標

  • コード(リフレクションではない)の実行時に必要となる情報へのアクセスが高速なこと。
  • コード生成のためにコンパイル時に必要となる情報へのアクセスが容易であること。
  • ガベージコレクタ/スタックウォーカが必要な情報へアクセスする時に、ロックを解除したりメモリを割り当てたりしなくてよいこと。
  • 一度にロードされる型の数が最小限であること。
  • 指定された型をロードする時、ロードする数が最小限であること。
  • 型システムのデータ構造をNGENイメージに必ず格納できること。

目標対象外

メタデータ内の全情報がCLRのデータ構造に直接反映していること。

リフレクションの全操作が高速であること。

また、 型ローダ設計-主なデータ構造 では次のように記載されています。

EEClass
MethodTableのデータは、ワーキングセットとキャッシュの使用率を向上させるために、「ホット」な構造体と「コールド」な構造体に分割されます。MethodTable自身は、プログラムの安定状態で必要となる「ホット」なデータのみを保存するためのものです。 EEClassは、典型的には型の読み込み、JITコンパイル、またはリフレクションでのみ必要とされる「コールド」なデータを保存します。 各MethodTableは1つのEEClassを指し示します。

リフレクションはどのように機能するのか

リフレクションの高速処理を実現することが設計目標ではないことは分かりましたが、では、何に時間がかかっているのでしょうか。

実は複数のことが実行されているのですが、理解しやすいようにリフレクションの呼び出しで実行されるマネージドコードとアンマネージドコードのコールスタックを見てみましょう。

  • System.Reflection.RuntimeMethodInfo.Invoke(..)ソースコードのリンク

    • System.Reflection.RuntimeMethodInfo.UnsafeInvokeInternal(..) の呼び出し
  • System.RuntimeMethodHandle.PerformSecurityCheck(..)リンク

    • System.GC.KeepAlive(..) の呼び出し
  • System.Reflection.RuntimeMethodInfo.UnsafeInvokeInternal(..)リンク

    • System.RuntimeMethodHandle.InvokeMethod managed and unmanaged code (..) の呼び出しスタブ
  • System.RuntimeMethodHandle.InvokeMethod(..) のスタブ- リンク

上記のリンクにアクセスして個別のC#/cppメソッドを確認しなくても、 多く のコードが実行されていることは想像がつくと思います。例えば、最終メソッドであり、処理の大部分が実行される System.RuntimeMethodHandle.InvokeMethod の行数は400以上 にも及びます。

概要は分かったと思いますが、では 具体的に 何をしているのでしょうか。

メソッド情報の取得

リフレクションを介してフィールドやプロパティ、メソッドを呼び出す前に専用の FieldInfo/PropertyInfo/MethodInfo ハンドルを取得する必要があります。下記のコードを使用します。

Type t = typeof(Person);      
FieldInfo m = t.GetField("Name");

前セクションで記載したように、関連するメタデータを取得したり、パースしたりなどしなければならないため、これにはコストがかかります。興味深いことに、ランタイムは、全てのフィールドやプロパティ、メソッドの内部キャッシュを保持することで助けてくれます。このキャッシュは RuntimeTypeCache クラス によって実装され、使用例は RuntimeMethodInfo クラス で見ることができます。

このgist を実行してキャッシュの動きを見てみると、リフレクションを適切に使用してランタイム内部を検証しているのが分かります。

リフレクションで FieldInfo を取得する前に、gistのコードは下記を出力します。

  Type: ReflectionOverhead.Program
  Reflection Type: System.RuntimeType (BaseType: System.Reflection.TypeInfo)
  m_fieldInfoCache is null, cache has not been initialised yet

フィールドを1つでも取得すると下記が出力されます。

  Type: ReflectionOverhead.Program
  Reflection Type: System.RuntimeType (BaseType: System.Reflection.TypeInfo)
  RuntimeTypeCache: System.RuntimeType+RuntimeTypeCache, 
  m_cacheComplete = True, 4 items in cache
    [0] - Int32 TestField1 - Private
    [1] - System.String TestField2 - Private
    [2] - Int32 <TestProperty1>k__BackingField - Private
    [3] - System.String TestField3 - Private, Static

ReflectionOverhead.Program は下記のとおりです。

class Program
{
    private int TestField1;
    private string TestField2;
    private static string TestField3;

    private int TestProperty1 { get; set; }
}

つまり、すでに作成され、存在するリストをランタイムでフィルタするだけなので、継続的に GetField あるいは GetFields を呼び出す方がコストがかからないことを意味します。これは、 GetMethodGetProperty にも当てはまり、 GetMethodGetProperty を最初に呼び出した時に MethodInfo キャッシュあるいは PropertyInfo キャッシュが作成されます。

引数のバリデーションとエラー処理

MethodInfo 取得後、 Invoke を呼び出す前にまだやるべきことがたくさんあります。下記のようなコードを書いたと想像してください。

PropertyInfo stringLengthField = 
    typeof(string).GetProperty("Length", 
        BindingFlags.Instance | BindingFlags.Public);
var length = stringLengthField.GetGetMethod().Invoke(new Uri(), new object[0]);

実行すると、下記の例外が発生します。

System.Reflection.TargetException: Object does not match target type.
   at System.Reflection.RuntimeMethodInfo.CheckConsistency(..)
   at System.Reflection.RuntimeMethodInfo.InvokeArgumentsCheck(..)
   at System.Reflection.RuntimeMethodInfo.Invoke(..)
   at System.Reflection.RuntimePropertyInfo.GetValue(..)

上記は String クラスの Length プロパティの PropertyInfo を取得した結果なのですが、 Uri オブジェクトで呼び出したため、明らかに異なる型になっています。

これに加え、呼び出すメソッドに渡す引数のバリデーションも必要です。引数を渡すために、リフレクションAPIは、引数ごとに1つの object の配列であるパラメータを取ります。従って、リフレクションを使用して Add(int x、int y) メソッドを呼び出す場合は、 methodInfo.Invoke(..、new [] {5、6}) を使用します。渡された値と型は実行時にチェックする必要があります。この場合、値が2つで、両方とも int であることを保証する必要があります。欠点は、多くの場合、追加コストのかかる ボックス化 を伴うことですが、 将来的に軽減される ことを期待します。

セキュリティチェック

実行されている主なタスクの1つが複数のセキュリティチェックです。例えば、リフレクションを使用して好みのメソッドを呼び出すことはできません。信頼性の高い.NET Frameworkコードでのみ呼び出しのできる制約あるいは 「危険なメソッド」 が存在します。ブラックリストに加えて、 呼び出し中にチェックする 必要がある最新の コードアクセスセキュリティの許可 の有無に応じた動的なセキュリティチェックが実行されます。

リフレクションのコスト

リフレクションが 舞台裏で 何をしているのか分かったところで、かかるコストを見てみましょう。これらのベンチマークは、直接リフレクションを介してプロパティへの読み書きを比較していることにご注意ください。.NETにおいて、プロパティは実は コンパイラが生成する Get / Set メソッドのペアですが、単なるバッキングフィールドがあるプロパティの場合、パフォーマンス上の理由から.NET JITはメソッドの呼び出しをインライン化します。つまり、プロパティにアクセスするためのリフレクションの使用は最悪の方法と言えるのですが、 ORMJSONのシリアル化/逆シリアル化ライブラリ 、および オブジェクトマッピングツール などでも見られるとおり、最も一般的に使用されている方法なのです。

下記は BenchmarkDotNet で表示している生の結果です。さらに同じ結果を2つの表形式でも表示しています(ベンチマークの全コードは こちら です) 。

Reflection Benchmark Results

プロパティの読み込み(「Get」)

Method Mean StdErr Scaled Bytes Allocated/Op
GetViaProperty 0.2159 ns 0.0047 ns 1.00 0.00
GetViaDelegate 1.8903 ns 0.0082 ns 8.82 0.00
GetViaILEmit 2.9236 ns 0.0067 ns 13.64 0.00
GetViaCompiledExpressionTrees 12.3623 ns 0.0200 ns 57.65 0.00
GetViaFastMember 35.9199 ns 0.0528 ns 167.52 0.00
GetViaReflectionWithCaching 125.3878 ns 0.2017 ns 584.78 0.00
GetViaReflection 197.9258 ns 0.2704 ns 923.08 0.01
GetViaDelegateDynamicInvoke 842.9131 ns 1.2649 ns 3,931.17 419.04

注釈:method – メソッド
Mean – 平均
StdErr – Standard Error – 標準エラー
Scaled – 縮尺
Bytes Allocated/Op – 割り当てられたバイト数

プロパティの書き込み(「Set」)

Method Mean StdErr Scaled Bytes Allocated/Op
SetViaProperty 1.4043 ns 0.0200 ns 6.55 0.00
SetViaDelegate 2.8215 ns 0.0078 ns 13.16 0.00
SetViaILEmit 2.8226 ns 0.0061 ns 13.16 0.00
SetViaCompiledExpressionTrees 10.7329 ns 0.0221 ns 50.06 0.00
SetViaFastMember 36.6210 ns 0.0393 ns 170.79 0.00
SetViaReflectionWithCaching 214.4321 ns 0.3122 ns 1,000.07 98.49
SetViaReflection 287.1039 ns 0.3288 ns 1,338.99 115.63
SetViaDelegateDynamicInvoke 922.4618 ns 2.9192 ns 4,302.17 390.99

注釈:method – メソッド
Mean – 平均
StdErr – Standard Error – 標準エラー
Scaled – 縮尺
Bytes Allocated/Op – 割り当てられたバイト数

上記から通常のリフレクションコード( GetViaReflectionSetViaReflection )の方がプロパティに直接アクセスする( GetViaPropertySetViaProperty )よりもかなり遅いことは明確です。他の結果については、さらに詳しく見てみましょう。

設定

まず、下記のような TestClass から始めます。

public class TestClass
{
    public TestClass(String data)
    {
        Data = data;
    }

    private string data;
    private string Data
    {
        get { return data; }
        set { data = value; }
    }
}

さらに、全オプションの活用を可能にする共通のコードは下記のとおりです。

// Setup code, done only once 
TestClass testClass = new TestClass("A String");
Type @class = testClass.GetType();
BindingFlag bindingFlags = BindingFlags.Instance | 
                           BindingFlags.NonPublic | 
                           BindingFlags.Public;

通常のリフレクション

まず、開始地点や「最悪のケース」の役目を果たす通常のベンチマークコードを使用します。

[Benchmark]
public string GetViaReflection()
{
    PropertyInfo property = @class.GetProperty("Data", bindingFlags);
    return (string)property.GetValue(testClass, null);
}

オプション1 – PropertyInfoのキャッシュ

次は、毎回取得するのではなく、 PropertyInfo を参照先として保持することで、速度を少し上げます。しかし、プロパティに直接アクセスするよりもまだ遅いです、これは、リフレクションの「呼び出し」にはかなりコストがかかることを実証しています。

// Setup code, done only once

PropertyInfo cachedPropertyInfo = @class.GetProperty("Data", bindingFlags);

[Benchmark]
public string GetViaReflection()
{    
    return (string)cachedPropertyInfo.GetValue(testClass, null);
}

オプション2 – FastMemberの使用

ここでは、使い方の簡単なMarc Gravellの Fast Memberライブラリ を利用します。

// Setup code, done only once
TypeAccessor accessor = TypeAccessor.Create(@class, allowNonPublicAccessors: true);

[Benchmark]
public string GetViaFastMember()
{
    return (string)accessor[testClass, "Data"];
}

ここでの注意点は、他のオプションとは若干異なることをしていることです。1つだけではなく、型の プロパティへのアクセスを可能にする TypeAccessor を作成します。しかし、欠点は結果的に実行に余計時間がかかることです。これは、プロパティの値を取得する前にリクエストしたプロパティ(この場合は「データ」)の delegate をまず内部で取得する必要があるからです。でも、このオーバヘッドはかなり小さく、FastMemberを使用する方がリフレクションよりもかなり早い上、簡単なので、まずは見てみることをお勧めします。

このオプションを初め、これ以降のオプションは全てリフレクションコードを delegate に変換することで、毎回のリフレクションのオーバヘッドなしで直接呼び出し、高速化しています。

delegate の作成にもコストはかかることを述べておきます(詳しい情報は 関連記事 をお読みください)。簡単に言うと、コストのかかることを1回(セキュリティチェックなど)で終わらせ、強い型付けの delegate を格納することで、小さいオーバヘッドで繰り返し使用することができるのです。結果、速度を上げることができるのです。リフレクションの使用が1度だけならこのような手法は使用しませんし、1度だけならパフォーマンスの弊害にもなりませんので、遅いことが気になることはないでしょう。

delegate を介したプロパティの読み込みの方が直接のプロパティを読み込むより遅い理由は、.NET JITがプロパティへのアクセスのように delegate メソッドの呼び出しをインライン化しないからです。つまり、 delegate を使用する際は、メソッドの呼び出しには直接アクセスする場合にはかからないコストが発生するということです。

オプション3 – デリゲートの作成

このオプションでは CreateDelegate 関数を使用して、PropertyInfoを通常の delegate 変換します。

// Setup code, done only once
PropertyInfo property = @class.GetProperty("Data", bindingFlags);
Func<TestClass, string> getDelegate = 
    (Func<TestClass, string>)Delegate.CreateDelegate(
             typeof(Func<TestClass, string>), 
             property.GetGetMethod(nonPublic: true));

[Benchmark]
public string GetViaDelegate()
{
    return getDelegate(testClass);
}

欠点はコンパイル時に確定した型を知っている必要があることです。例えば、上記のコードの Func<TestClass, string> の部分です( Func<object, string> は使用できません。使用した場合は例外処理が実行されます)。リフレクションを使用している状況においてコンパイル時に確定した型が分かっている場合はほとんどありません。だからこそ、リフレクションを使用することになるのです。これは解決策としては不十分です。

これを回避できる非常に興味深く/思いがけない方法については、Jon Skeetの素晴らしいブログ記事 『リフレクションを機能させ、デリゲートを探る』MagicMethodHelper コード、あるいはこの後に続くオプション4またはオプション5をお読みください。

オプション4 – 式木としてコンパイル

ここでも delegate を生成しますが、何が違うかというと、 object を渡すことで「オプション 3」の制約を回避できることです。ここで使用するのは、動的コードの生成を可能にする.NETの Expression treeAPI です。

// Setup code, done only once
PropertyInfo property = @class.GetProperty("Data", bindingFlags);
ParameterExpression = Expression.Parameter(typeof(object), "instance");
UnaryExpression instanceCast = 
    !property.DeclaringType.IsValueType ? 
        Expression.TypeAs(instance, property.DeclaringType) : 
        Expression.Convert(instance, property.DeclaringType);
Func<object, object> GetDelegate = 
    Expression.Lambda<Func<object, object>>(
        Expression.TypeAs(
            Expression.Call(instanceCast, property.GetGetMethod(nonPublic: true)),
            typeof(object)), 
        instance)
    .Compile();

[Benchmark]
public string GetViaCompiledExpressionTrees()
{
    return (string)GetDelegate(testClass);
}

Expression ベースのアプローチの全コードはブログ投稿 『式木を使用してリフレクションを速くする』 を参照くしてださい。

オプション5 – IL Emitで動的コードの生成

やっと最下位レベルのアプローチ、生ILを記述する方法にたどり着きました。しかし、 「大いなる力には大いなる責任が伴う」 ものなのです。

// Setup code, done only once
PropertyInfo property = @class.GetProperty("Data", bindingFlags);
Sigil.Emit getterEmiter = Emit<Func<object, string>>
    .NewDynamicMethod("GetTestClassDataProperty")
    .LoadArgument(0)
    .CastClass(@class)
    .Call(property.GetGetMethod(nonPublic: true))
    .Return();
Func<object, string> getter = getterEmiter.CreateDelegate();

[Benchmark]
public string GetViaILEmit()
{
    return getter(testClass);
}

Expression treeの使用(オプション4)は、直接ILコードを記述する時のような柔軟性はありませんが、無効なコードを記述するのを防いでくれます。間違えたプログラミングをしても、もっと良いエラーメッセージで注意してくれる素晴らしい Sigilライブラリ の使用をお勧めします。

結論

要点は、リフレクションを使用してパフォーマンス問題に直面した場合のみ、いくつかの異なる方法で速度を上げることができるということです。 delegate の取得でこれらは可能になり、毎回のリフレクションのオーバヘッドなしでプロパティやフィールド、メソッドに直接アクセスすることができます。

この投稿の意見を /r/programming/r/csharp にお寄せください。

参考記事

参考としてランタイムが デリゲート作成 の際に通過するコールスタックやコードフローを一覧にしました。

  1. Delegate CreateDelegate(Type type, MethodInfo method)
  2. Delegate CreateDelegate(Type type, MethodInfo method, bool throwOnBindFailure)
  3. Delegate CreateDelegateInternal(RuntimeType rtType, RuntimeMethodInfo rtMethod, Object firstArgument, DelegateBindingFlags flags, ref StackCrawlMark stackMark)
  4. Delegate UnsafeCreateDelegate(RuntimeType rtType, RuntimeMethodInfo rtMethod, Object firstArgument, DelegateBindingFlags flags)
  5. bool BindToMethodInfo(Object target, IRuntimeMethodInfo method, RuntimeType methodType, DelegateBindingFlags flags);
  6. FCIMPL5(FC_BOOL_RET, COMDelegate::BindToMethodInfo, Object* refThisUNSAFE, Object* targetUNSAFE, ReflectMethodObject *pMethodUNSAFE, ReflectClassBaseObject *pMethodTypeUNSAFE, int flags)
  7. COMDelegate::BindToMethod(DELEGATEREF *pRefThis, OBJECTREF *pRefFirstArg, MethodDesc *pTargetMethod, MethodTable *pExactMethodType, BOOL fIsOpenDelegate, BOOL fCheckSecurity)