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

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

仮想化しないでも速いCanvasの作り方

XAML Advent Calendar 2014 - Qiita

こちら向けの記事です。1日遅刻しました。ごめんなさい。

あちこちで書いて回っておりますがPanel.Childrenがとても重いのです。 遅い原因はこちら。 http://referencesource.microsoft.com/#PresentationFramework/Framework/System/Windows/Controls/Panel.cs,1001

同じ階層のVisualはVisualTreeに結合した順番でしか表示できないので、ChildrenやPanel.ZIndexが変更される都度、Panel.ZIndexを使ってこのRecomputeZStateが走ります。 レイアウトパスの直前に1回だけしてくれたらいいのにと思うかもしれませんが、残念ながらそのような作りにはなっていないのが現状です。

とすると、FrameworkElementに独自のChildrenを実装してやるしか手がない。ということになります。 幸いなことに、XAMLで記述する場合も [DesignerSerializationVisibility(DesignerSerializationVisibility.Content)] という属性をつけて、IList<UIElement>を実装してやればいいので、結構簡単なんです。

<Window x:Class="FastCanvas.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:FastCanvas"
        Title="MainWindow">
    <ScrollViewer HorizontalScrollBarVisibility="Visible" VerticalScrollBarVisibility="Visible">
        <local:Canvas x:Name="_canvas" Width="3200" Height="32000"></local:Canvas>
    </ScrollViewer>
</Window>

今日の主役は仮想化しないで高速なCanvasなので、XAMLはこのくらいです。 XAMLの話の予定でしたのにすみません。 一応、100000個くらいのシェイプをゾロッと配置してみます。

using System.Windows;
using System.Windows.Media;
using System.Windows.Shapes;

namespace FastCanvas
{
    /// <summary>
    /// MainWindow.xaml の相互作用ロジック
    /// </summary>
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();

            for(int y=0 ; y<1000 ; y++)
            {
                for (int x = 0; x < 100; x++)
                {
                    var rectangle = new Rectangle() { Width = 32, Height = 32, Fill = Brushes.Aquamarine, RadiusY = 12, RadiusX = 12 };
                    FastCanvas.Canvas.SetLocation(rectangle, new Point(x * 32, y * 32));
                    _canvas.Children.Add(rectangle);
                }
            }
        }
    }
}

そして、これが問題のCanvas

using System.Windows;
using System.Windows.Media;
using System.ComponentModel;
using System.Collections.ObjectModel;

namespace FastCanvas
{
    public class Canvas : FrameworkElement
    {
        public static Point GetLocation(DependencyObject obj)
        {
            return (Point)obj.GetValue(LocationProperty);
        }

        public static void SetLocation(DependencyObject obj, Point value)
        {
            obj.SetValue(LocationProperty, value);
        }
        public static readonly DependencyProperty LocationProperty =
            DependencyProperty.RegisterAttached("Location", typeof(Point), typeof(Canvas),
            new FrameworkPropertyMetadata(default(Point),FrameworkPropertyMetadataOptions.AffectsArrange));

        [DesignerSerializationVisibility(DesignerSerializationVisibility.Content)]
        public ObservableCollection<UIElement> Children{get; private set;}

        public Canvas()
        {
            Children = new ObservableCollection<UIElement>();

            Children.CollectionChanged += Children_CollectionChanged;
        }

        void Children_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
        {
            if(e.OldItems != null)
            {
                foreach (UIElement oldItem in e.OldItems)
                {
                    RemoveVisualChild(oldItem);
                }
            }
            if(e.NewItems != null)
            {
                foreach (UIElement newItem in e.NewItems)
                {
                    AddVisualChild(newItem);
                }
            }
        }

        protected override int VisualChildrenCount
        {
            get
            {
                return Children.Count;
            }
        }

        protected override Visual GetVisualChild(int index)
        {
            return Children[index];
        }

        protected override Size ArrangeOverride(Size finalSize)
        {
            foreach(var child in Children)
            {
                var location = GetLocation(child);

                child.Arrange(new Rect(location, child.DesiredSize));
            }

            return base.ArrangeOverride(finalSize);
        }

        protected override Size MeasureOverride(Size availableSize)
        {
            foreach (var child in Children)
            {
                var fe = child as FrameworkElement;

                if( fe != null )
                {
                    child.Measure(new Size(fe.Width,fe.Height));
                }
            }

            return base.MeasureOverride(availableSize);
        }
    }
}

100000のシェイプを配置しても、Panel.Children.Addにあるようなもっさり感はありません。 ZIndexが不要ならこの程度で十分行けてしまうという程度で考えていただけると良いかなと思います。

FreezableにDataContextが伝播する仕掛け(その2)

素早く実証コードを書いてみました。
ViewModelとしては概ねこんな感じ

using System;
using System.Windows;
using System.Windows.Input;
using System.Diagnostics;

namespace WpfApplication1
{
    class MainWindowViewModel : DependencyObject
    {
        public string MessageText{
            get { return (string)GetValue(MessageTextProperty); }
            set { SetValue(MessageTextProperty, value); }
        }
        public static readonly DependencyProperty MessageTextProperty =
            DependencyProperty.Register("MessageText", typeof(string), typeof(MainWindowViewModel),
            new PropertyMetadata("試験"));
    }

    class ShowMessageCommand : Freezable, ICommand
    {
        protected override Freezable CreateInstanceCore(){ 
            return new ShowMessageCommand();
        }
        public bool CanExecute(object parameter){return true;}
        public event EventHandler CanExecuteChanged;
        public void Execute(object parameter){
            MessageBox.Show(Message);
        }
        public string Message{
            get { return (string)GetValue(MessageProperty); }
            set { SetValue(MessageProperty, value); }
        }
        public static readonly DependencyProperty MessageProperty =
            DependencyProperty.Register("Message", typeof(string), typeof(ShowMessageCommand),
            new PropertyMetadata(string.Empty,
                (d, e) =>{
                    Debug.Fail("ここで止めると、StackTrace取れます。");
                }));
    }
}

XAMLはこんな感じ

<Window x:Class="WpfApplication1.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:WpfApplication1"
        Title="MainWindow" Height="350" Width="525">
    <Window.DataContext>
        <local:MainWindowViewModel />
    </Window.DataContext>
    <StackPanel>
        <Button>
            ボタン
            <Button.Command>
                <local:ShowMessageCommand Message="{Binding MessageText}"/>
            </Button.Command>
        </Button>
    </StackPanel>
</Window>

止まったところでデバッガに入り、スタックトレースを全部見る(当然ですが「外部コードを表示」のチェックはON)と通過箇所はわかります。

重要なポイントはここ。

WindowsBase.dll!System.Windows.DependencyObject.OnInheritanceContextChanged(System.EventArgs args)
WindowsBase.dll!System.Windows.Freezable.AddInheritanceContext(System.Windows.DependencyObject context, System.Windows.DependencyProperty property)
WindowsBase.dll!System.Windows.DependencyObject.ProvideSelfAsInheritanceContext(System.Windows.DependencyObject doValue, System.Windows.DependencyProperty dp)

Microsoftのリファレンスソースを読めば、DependencyObjectがFreezableだけ特別扱いして、継承属性を持つコンテキストをまるっと引き渡していることが読めるはずです。
とりあえず、「仕掛けがわからない」は解消できたのではないでしょうか。

問題は、Microsoftがこのあたりをprivateやinternalで固めているため、いつ何時実装を変更しても文句を言えないというところでしょう。

それにしても、これでは XAMLで扱うものは、FrameworkElement あたりを使わない場合は Freezable 一択ということのようですね。

ここまで書いておいてなんですが、何か大きな勘違いをしてないか不安です。

FreezableにDataContextが伝播する仕掛け(その1)

依存関係プロパティ(継承タイプ)を変更しますと。
このあたりをスタート地点としまして。

http://referencesource.microsoft.com/#WindowsBase/Base/System/Windows/DependencyObject.cs,1243

長い処理を経てここに到達します。

http://referencesource.microsoft.com/#WindowsBase/Base/System/Windows/DependencyObject.cs,869

DependencyObject内にFreezableのために特別な処理が入っています。
具体的には継承する依存関係プロパティは、下位のFreezableなDependencyObjectに伝播するという仕掛けの様子。

継承する依存関係プロパティという概念は FrameworkElementからのはずですが、
ここでは、それとは別の継承関係に関する情報です。

http://referencesource.microsoft.com/#WindowsBase/Base/System/Windows/DependencyObject.cs,2786

FrameworkElementの中にFreezableがあるとDataContextがFreezableから参照できて、Bindingできるメカニズムが長いこと謎と言われていた気がするのですが、実はDependencyObjectのなかに何やらあった模様です。Microsoftは資料は出してないけど、リファレンスコードにはそれなりのヒントがあるっぽいです。
ただ、これで正解かは実際に動かしてないのでちょっと確認不足です。

Binding自体はFreezableはDependencyObjectを継承しているから特に問題はないはずですし、自分に届いたコンテキストのリストはFreezableにありますので、そこから欲しい値を取ってきてBindingしているのです。

・・・のだと思うのですが、やっぱりもう少し調べる必要はありそうです。
Binding.csのリファレンスコードあたりをよく読めば、多分このあたりの問題は解消できそうです。

今日は考えかけなので、また今度掘り下げますね。
いずれにしても、仕組みがわからないから放置は良くありません。

ベジェ曲線のHitTest

WPFにあまり任せてはいけないのがベジェ曲線のHitTestです。
几帳面で抜け目ありませんが、きわめて低速です。

真面目に方程式を解く手法ももちろんあるのですが、実はあんまり工夫しないでも、そこそこに優良なコードは書けます。
概ねこんなノリです。動かしてないので間違っていたらすみません。

/// <summary>
/// 矩形とベジェの交差判定
/// </summary>
/// <param name="v">Vecter[4]で、始点、制御点1、制御点2、終点</param>
/// <param name="rect">交差しているか判定したい範囲。スタックに山積みさせないために ref 。面積が0は不可</param>
/// <returns>矩形とヒットしている場合true</returns>
/// <remarks></remarks>
bool HitTestBezier(Vector[] v, ref Rect rect)
{
    var p0 = new Point(v[0].X, v[0].Y);
    var p3 = new Point(v[3].X, v[3].Y);
    // 始点か終点を含んでいれば、Hitしている。
    if (rect.Contains(p0) || rect.Contains(p3))
        return true;

    var area = new Rect(p0, p3);
    area.Union(new Rect(new Point(v[1].X,v[1].Y),new Point(v[2].X,v[2].Y)));

    // ここらへんで小細工の余地はあります。幅や高さが1未満だったら直線と大差ないとか、いろいろ。

    // 交差している可能性があるケースを判定
    if(rect.IntersectsWith(area))
    {
        var v01 = (v[0] + v[1]) / 2;
        var v12 = (v[1] + v[2]) / 2;
        var v23 = (v[2] + v[3]) / 2;

        var v0112 = (v01 + v12) / 2;
        var v1223 = (v12 + v23) / 2;

        var c = (v0112 + v1223) / 2;

        // 2分割してさらに探査
        return HitTestBezier(new[] { v[0], v01, v0112, c }, ref rect)
            || HitTestBezier(new[] { c, v1223, v23, v[3] }, ref rect);
    }
    // 可能性が無い
    return false;
}

とりあえず、WPFに丸任せするよりは高速です。お試しください。
長さが20億ピクセルくらいあるベジェ曲線でも最悪32段ほど潜ればちゃんと交差判定できます。小細工をすればもっと浅くできます。
これで、画面内に有るか無いか仮想化に役立てようという考えです。

続・ハッシュコードをバラけさせる意味はあるのか

というわけで、.NET Frameworkのご本尊様を調べてみたのですが。

http://referencesource.microsoft.com/#mscorlib/system/collections/generic/dictionary.cs,290

このコードを見る限り、GetHashCode()は別にバラケさせる必要はありませんね。
コスト比皆無です。
インクリメントで十分じゃありませんか。むしろ、インクリメント確実じゃないですか。

剰余で負の値が重複することを恐れて0x7fffffff論理積とか正味31bitとか、いろいろはっきりしました。

ビット演算は別に遅いとは言いませんが、uintへのキャストの方が最適化時に高速ではないのでしょうか・・・。
自分がレビュワーならそう指摘を書きます。

Dictionaryみたいな凄い使用頻度を持つクラスでこういうこともあります。

ハッシュコードをバラけさせる意味はあるのか

随分間が空きました。やっとプロジェクトが節目っぽいので再開します。

ハッシュコードについてよく言われることがあります。また、条件と言われるものがあります。

  1. 同じインスタンスは常に同じ値を返さなければならない
  2. 異なるインスタンスが同じ値を返すことはあり得るし許容されるが効率は落ちる
  3. ハッシュ値は極力バラけた値になるのが望ましい

最初の2つは意味がよくわかるんですが、最後のがどうもよくわからなかったりしています。
インスタンスごとにインクリメントするのではいけないのでしょうか。
ハッシュテーブル自体も要素数に応じた拡張が行われますが、確率的な話をしてみるとハッシュ値がバラけていることより(偶然同一値を返す事が有りえますし)、1回定めたユニーク値を返せることに注力した方が良くはないか?

等と思ったので今のハッシュはインスタンスごとに、インクリメントして値を保持してます。
GetHashCode()で変に演算するより速いし、ハッシュテーブルの辞書引きも目立った速度劣化が見当たらない気がするし。

何が気になるって、 GetHashCode()で、一生懸命に素数で乗算したりする意味を問いたい感じです。異種混合のハッシュテーブル作るなら意味がありますが。

.NET Frameworkのことだけでも調べておこうかな。

これも課題か。

イベント集約という手法

一長一短あります。
本日はイベント集約のこと。

イベントは大変便利ですが、1つのイベントにどの程度のリスナーが居るかを正確に管理するのは大切です。

WPFを手本にすると、バインディングパスとレイアウトパスに関して見事なイベント集約がなされていることに気づきます。

依存関係プロパティを書き換えるとバインディングは非同期で最後の変更だけが伝わります。

バインディングが終わると、メタデータのオプションや、計測と配置の無効化フラグを見ながら、極力1回ずつだけレイアウトパスが走ります。
特定の処理が終わるまで、イベントの有無だけを蓄積しておく。これが大雑把にイベント集約というものです。

使いこなすと最適化の効果は絶大。

ですが、イベント集約はパスの順序について明確な設計を要します。また、集約したイベントが正確性を欠くと大変なことに。
今日もそのバグで大変なことに。

foreachのILとループのパターン

糖衣構文としてとても有名なforeachはおおまかに2種類のILになる可能性があります。

  1. 配列に対するIL(for文とほぼ同等)
  2. IEnumerable になる IL

ループだけの速度では前者が約2倍速です。
IEnumerator が IDisposable なので IEnumerableは try - finally で囲われ、finally内でDisposeが呼ばれる・・・・ということになります。
実態の型に合わせて最適なコードを選ぶような積極的高速化は行われません。
foreachに渡す物が配列であるか、他のIEnumerableであるかだけが判断材料です。
過度な期待はできません。

さて、最適化というところに主眼を置いてC#を扱う場合、ループを組むときにはおおまかに3通りのパターンがあります。
LINQの内部実装について書いた際に扱ったことがありますが以下の3つです。

  1. 配列
  2. List<T> 長さが分かる(且つインデクサが速い)コレクション(←2014/12/6 修正)
  3. IEnumerable<T> 長さが分からないものの列挙

C#コンパイラに、型判別と最適なループに展開する属性とかを渡せたらいいのですが。
まあ、この属性を乱用したらループの都度コードが3倍になるわけで、場合によってはキャッシュヒット率の低下などかえって有害。素人にはお勧めできない仕様になってしまいますが。

あと、yield return の展開、次のバージョンではもう少しマシになってるんでしょうか。
まだ確認はしていませんが、期待と不安でいっぱいです。

体調不良の原因

昨日まで元気だったのに今日突然熱が出て(最近多い)、ちょっと生活を振り返ってみました。

食生活はちょっと多いけど普通。酒量もさほどでもなし。(本人の主観によるものです。)
睡眠はやや不足気味。
他の色々はようやくピークを越えて落ち着き始めたところ。

それで、リラクゼーションなどを考え、先月からマッサージやストレッチに通い始めているのですが、どうもこの発熱は揉み返しというやつではないだろうかと思い始めています。

そういえば、マッサージに行った後に多いような・・・。

簡素な仮想化パネル

こんなXAML

<Window x:Class="Test.FastCanvas.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:local="clr-namespace:Test.FastCanvas"
        Title="MainWindow" Height="350" Width="525">
    <ScrollViewer x:Name="scroller" HorizontalScrollBarVisibility="Visible">
        <local:FastCanvas x:Name="canvas" />
    </ScrollViewer>
</Window>

こんなコード(usingは省略してます)

namespace Test.FastCanvas
{
    /// <summary>
    /// MainWindow.xaml の相互作用ロジック
    /// </summary>
    public partial class MainWindow : Window
    {
        public MainWindow()
        {
            InitializeComponent();

            for (int y = 0; y < 1000; y++)
            {
                for (int x = 0; x < 100; x++)
                {
                    var element = new Ellipse() { Width = 32, Height = 32, Fill = Brushes.LightPink };
                    element.MouseEnter += (s,e) =>{ ((Ellipse)s).Fill = Brushes.Blue; };
                    element.MouseLeave += (s, e) => { ((Ellipse)s).Fill = Brushes.LightPink; };
                    Canvas.SetLeft(element, x * 32);
                    Canvas.SetTop(element, y * 32);
                    canvas.AddElement(element);
                }
            }
            canvas.Height = 32 * 1000;
            canvas.Width = 32 * 100;
            scroller.ScrollChanged += scroller_ScrollChanged;
        }

        void scroller_ScrollChanged(object sender, ScrollChangedEventArgs e)
        {
            var rect = new Rect(
                scroller.HorizontalOffset, 
                scroller.VerticalOffset, 
                scroller.ViewportWidth, 
                scroller.ViewportHeight);

            canvas.SetViewport(rect);
        }
    }
}

こんなコントロール(usingは省略してます)

namespace Test.FastCanvas
{
    public class FastCanvas : Canvas
    {
        private HashSet<UIElement> _virtualChildren = new HashSet<UIElement>();

        public void AddElement(UIElement element)
        {
            _virtualChildren.Add(element);
        }

        public void RemoveElement(UIElement element)
        {
            _virtualChildren.Remove(element);
        }

        public void SetViewport(Rect rect)
        {
            foreach(FrameworkElement child in _virtualChildren)
            {
                var childRect = new Rect(Canvas.GetLeft(child), Canvas.GetTop(child), child.Width, child.Height);
                if (!rect.IntersectsWith(childRect))
                {
                    if (Children.Contains(child))
                        Children.Remove(child);
                }
                else
                {
                    if (!Children.Contains(child))
                        Children.Add(child);
                }
            }
        }
    }
}

分かり易く仮想化を説明する教材として作ったものです。
ScrollChangedを使うと古風なので(あとレイアウトパスが過剰に走って遅いので)、データバインディングでViewportを設定できるようにするとMVVM風ですよね。マルチバリューコンバーターの出番です。

起動時間と、マウスヒットテストは仮想化の有無だけで相当違います。それを体感するためのサンプルと思っていただけたら。
ちなみに、同じコードでFastCanvasをCanvasに入れ替え、ScrollChangedのイベントをやめるだけで、仮想化自体の威力は体感できると思います。

実は最近、Visualを細切れにしたらいいんじゃないかと思い

最近まで、よりも速い仮想化パネルを作るということに心血注いでいて一段落ついたところなのですが、まだいくつかやり残したことがあります。

というのは、あるチューニング中の出来事で、再描画領域がパネルの左上から右下まで突き抜けているベジェを変更するときに、極限まで再描画範囲を削ってみたら、画面にWPFらしからぬごみが残る現象が発生しました。

そこでふと思ったのです。
ビジュアルツリー自体は配下のどの範囲が書き換えられるかというところにはすごく厳しいのですが、じゃあ、隣接域を侵害しないように細切れに物を配置してみたらどうだろうと。
仮想化としては、その細切れを根元から着脱・・・・。

もしかして、今よりもっと速い方法につながりうるのではないかと思ったのです。

ただ、用心が必要なのは、見かけのコントロール1つが細切れになって10くらいのVisualになれば、メモリは消費する可能性が高いし、・・・・どっちがいいんでしょう。

次回の開発に入る前に、1日くらい時間を取らせてもらって、そういう実装の速度を試験してみることも必要かなと思った次第です。