なぜリフレクションは遅いのか

.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のExpressiontreeAPIです。

    // 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);
    }
    

    Expressiontreeの使用(オプション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)