INotifyPropertyChangedと大量データの転記

以前に何度か紹介したが、拙作のフレームワークではS2Container.NETと同様のRealProxyを用いたAOP機能により、DTOのプロパティ変更通知を自動化している。

カスタム属性を用いたデータバインディングの省力化(INotifyPropertyChanged実装編)

この機能のおかげでGUIDTOのデータバインドを自動化し、下記のように記述が必要なプロパティの変更通知を行うコードを書く必要がなくなっている。

//通常はデータバインドする全てのカスタムオブジェクト(DTO)に必要

    public void NotifyPropertyChanged(String propertyName)
    {
        if (PropertyChanged != null)
        {
            PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
        }
    }

    public virtual string LastName
    {
       get { return this.lastName;}
       set
       {
          if (value != this.lastName)
          {
              this.NotifyPropertyChanged("LastName"); //変更通知
          }
          this.lastName = value;
       }
    }

このように非常に便利な機能なのだが、先日、このフレームワークを適用したアプリケーションでDataGridViewにデータを流し込むのが異常に遅いという問題が発生した。調べてみると、データバインドでDataGridViewにバインドしたDTOに新たなデータを数千件程度追加している。データバインドアプリケーションを開発したことのある開発者であればこの時点ですぐにピンとくるはずだ。

この問題のように大量のデータをデータバインドしたGUI又はDTOに流し込む処理を書く場合は、下記のように一時的にデータバインド機能を止めないとデータの転記時にデータバインディング処理が逐一実行されるので非常に大きなパフォーマンスヒットとなることがある。

:
//リスト更新イベント通知の停止
bindingSource.RaiseListChangedEvents = false;
//データバインドをサスペンド
bindingSource.SuspendBinding();
:
: 大量データの流し込み処理
:
//リスト更新イベント通知の開始
bindingSource.RaiseListChangedEvents = true;
//データバインドをレジウム
bindingSource.ResumeBinding();
//現在の内容で表示値を更新
bindingSource.ResetBindings(false);
:
:

最初に調べた際には間違いなくこれが原因だと思ってとっととコードを修正したテストしたのだが、期待したほどに性能は改善されなかった。ならばと思い、愛用のJetBrains dotTraceで実際にはどこにボトルネックがあるのかを調べてみた所、原因は別な所にあることが判明した。

以下はdotTraceにより、AOP機能で実行されているINotifyPropertyChanged#NotifyPropertyChangedメソッドのプロファイル結果からホットスポットと思われるサブツリーを抽出したダンプ結果だが、テストでは500件×4カラムのデータをDatGridViewにバインド中のDTOに追加している際に2000回のNotifyPropertyChangedメソッドーのコールが発生し、8秒程度の時間を消費しているのが解る。(プロファイルしながらの実行なので実際の処理に8秒かかる訳ではない)

98.35% Invoke - 8238.7 ms - 2000 calls - Framework.DTO.NotifyPropertyChangedInterceptor.Invoke(IMethodInvocation)
  97.22% NotifyPropertyChanged - 8144.5 ms - 2000 calls - Framework.DTO.Impl.DTOImpl.NotifyPropertyChanged(String)
    97.12% System.ComponentModel.BindingList.Child_PropertyChanged... - 8135.7 ms - 2000 calls
      53.62% Invoke - 4491.7 ms - 127249 calls - Framework.Aop.AopProxy.Invoke(IMessage)
        21.50% Invoke - 1800.7 ms - 127249 calls - Framework.Util.MethodUtil.Invoke(MethodBase, Object, Object )
          21.18% Invoke - 1774.1 ms - 127249 calls - System.Reflection.MethodBase.Invoke(Object, Object )
        18.55% get_MethodBase - 1553.6 ms - 127249 calls - System.Runtime.Remoting.Messaging.Message.get_MethodBase()
        4.80% get_Args - 402.3 ms - 127249 calls - System.Runtime.Remoting.Messaging.Message.get_Args()
        4.71% ReturnMessage..ctor - 394.4 ms - 127249 calls - System.Runtime.Remoting.Messaging.ReturnMessage..ctor(Object, Object , Int32, LogicalCallContext, IMethodCallMessage)
        1.46% ContainsKey - 122.0 ms - 127249 calls - System.Collections.Generic.Dictionary.ContainsKey(TKey)
        0.10% get_LogicalCallContext - 8.0 ms - 127249 calls - System.Runtime.Remoting.Messaging.Message.get_LogicalCallContext()
        0.10% get_Name - 8.0 ms - 127249 calls - System.Reflection.RuntimeMethodInfo.get_Name()
  0.39% System.Reflection.RuntimePropertyInfo.GetValue... - 32.4 ms - 4000 calls
  0.21% Proceed - 18.0 ms - 2000 calls - Framework.Aop.Impl.MethodInvocationImpl.Proceed()
  0.19% GetProperty - 16.0 ms - 2000 calls - System.Type.GetProperty(String, BindingFlags)
  0.13% StartsWith - 10.8 ms - 2000 calls - System.String.StartsWith(String)
  0.09% Split - 7.9 ms - 2000 calls - System.String.Split(String , StringSplitOptions)

そもそも.NET FrameworkのRealProxy機能を利用したAOPはリモーティングと同様の扱いになるため、性能的に不利なのは予想していたのだが、大量のデータの転記ではやはり影響が大きいようだ。

現在の所対策としては

1.データバインドサスペンド中はINotifyPropertyChangedインタフェースの自動通知も止める
2.扱うデータの件数を絞るために、DataGridViewの仮想モード(Virtual Mode)を使用する
3.データバインド機能をあきらめて、アンバウンドなGUIとしてDataGridViewを利用する

この3案が考えられる。

重要なのは処理結果を早く表示することなので、途中経過は省略しても何も問題は無いはずだ。よってできるだけ1.で対処したい所。
2.はプログラミングが煩雑になるので次善の策としたい。3.を選択しなくてはならないケースでは、そもそも扱うデータの規模を設計しなおすべきだろう。