Kudo_Yohei
@Kudo_Yohei (工藤 洋平)

Are you sure you want to delete the question?

Leaving a resolved question undeleted may help others!

Linq内での変数に関して

Q&A

Closed

解決したいこと

c#初心者です。
Winformsのユーザーコントロールを自作しています。

コントロールにはテキストボックスがあり、入力された値に対して
親のフォームで生成されたDataTableからLinqでデータを抽出するような機能を
作成しました。

その際に、Whereの条件をテキストボックスの値を直接指定すると極端に
動作が遅くなりました。
そこで、テキストボックスの値を一度変数に代入して、Whereの条件に変数を
用いたらすぐに結果が反映されました。

テキストボックスの値を直接指定する方法と一度変数に代入する方法で
なぜ動作が極端に変わるのでしょうか?

開発環境
Windows 11
.NET Framework4.8
Visual Studio 2022 Version 17.7.6
Windows フォームアプリケーションを作成中

該当するソースコード

// テキストボックスのプロパティを用いたパターン
// 1万件程度のDataTableで約30秒の処理時間
var result = _dataSet.Tables["Naiji"].AsEnumerable()
                                     .Where(s => s[0].ToString() == TextBox.Text);
                                     .Where(s => s[1].ToString() == TextBox2.Text);

// 変数を用いたパターン
// 1万件程度のDataTableで約1秒の処理時間
var hensu = TextBox.Text
var hensu2 = TextBox2.Text
var result = _dataSet.Tables["Naiji"].AsEnumerable()
                                     .Where(s => s[0].ToString() == $"{hensu}");
                                     .Where(s => s[1].ToString() == $"{hensu2}");
1

3Answer

プロパティへのアクセスは、一見変数へのアクセスっぽく見えますが、実際はアクセサメソッドの呼び出しなので、その内部で様々な処理が行われている場合があります。

また、Releaseビルドでインライン展開されない限りは、一般的にメソッドの呼び出しは単純なローカル変数へのアクセスより低速です。

Linqでループ処理が行われるので、ループ回数が多くなるほどに、実行時間に差が出る事でしょう。

3Like

Comments

  1. @Kudo_Yohei

    Questioner

    返信ありがとうございます。

    アクセサメソッドの呼び出しについては原因となりうることは参考になりました。

    一般的にプロパティの値を一度変数に代入するのが常套手段なのでしょうか?

    Releaseビルドで行っても時間に差異はほとんどありませんでした。

  2. ループ回数が多い場合、ローカル変数に受けるのは一般的な手法だと思います。また、Releaseビルドだからといって、必ずインライン展開される訳ではありません。
    理由としては、クラスメンバは別スレッドの影響を考慮する必要があるので、最適化が困難だというのがあります。(ループ処理の途中で別スレッドでプロパティが変更された場合、それが反映されなければならない)

    Releaseでも殆ど変わらなかったという事は、それだけプロパティのオーバーヘッドが大きいと考えられますが、こういうのは実際にLinq抜きで計測して検証した方が早いです。下記はBenchmarkDotNetで計測した結果ですが、毎回律儀にTextプロパティを読むと、ぶっちぎりで遅くなりました。

    Method Mean Error StdDev Allocated
    GetTextOnce 83.65 μs 73.20 μs 4.012 μs 508 B
    GetTextN 444.90 μs 457.95 μs 25.102 μs 301596 B
    GetTextPropertyN 24,418.89 μs 7,018.72 μs 384.720 μs 1669920 B

    ・GetTextOnce
    textBox1.Textを取得してそのまま返す。

    ・GetTextN
    textBox1.Textをローカル変数に受け、10000回ループでStringBuilderに追加して返す。

    ・GetTextPropertyN
    10000回ループで毎回textBox1.Textを読み込み、StringBuilderに追加して返す。

    using BenchmarkDotNet.Attributes;
    using BenchmarkDotNet.Running;
    using System;
    using System.Text;
    using System.Threading;
    using System.Windows.Forms;
    
    
    [ShortRunJob]
    [MemoryDiagnoser(false)]
    public class SimpleBenchnark
    {
        private Form1 _frm = new Form1();
        private Thread _uiThread;
        const int N = 10000;
    
        [GlobalSetup]
        public void Setup()
        {
            _uiThread = new Thread(new ThreadStart(() => {
                    Application.EnableVisualStyles();
                    Application.SetCompatibleTextRenderingDefault(false);
                    Application.Run(_frm);
                }));
    
            _uiThread.SetApartmentState(ApartmentState.STA);
            _uiThread.Start();
        }
    
        [Benchmark]
        public string GetTextOnce()
        {
            var result = string.Empty;
            _frm.Invoke(new Action(() =>
            {
                result = _frm.textBox1.Text;
            }));
    
            return result;
        }
    
        [Benchmark]
        public string GetTextN()
        {
            var result = string.Empty;
            _frm.Invoke(new Action(() =>
            {
                var text = _frm.textBox1.Text;
                var buf = new StringBuilder(N * 10);
                for (int i = 0; i < N; i++)
                {
                    buf.Append(text);
                }
                result = buf.ToString();
            }));
            return result;
        }
    
        [Benchmark]
        public string GetTextPropertyN()
        {
            var result = string.Empty;
            _frm.Invoke(new Action(() =>
            {
                var buf = new StringBuilder(N * 10);
                for (int i = 0; i < N; i++)
                {
                    buf.Append(_frm.textBox1.Text);
                }
                result = buf.ToString();
            }));
            return result;
        }
    
        public static void Run() => BenchmarkRunner.Run<SimpleBenchnark>();
    }
    
    internal static class Program
    {
        [STAThread]
        static void Main()
        {
            SimpleBenchnark.Run();
    
            //Application.EnableVisualStyles();
            //Application.SetCompatibleTextRenderingDefault(false);
            //Application.Run(new Form1());
        }
    }
    
  3. ほぼ結論は出たと思いますが、疑問が解消されたのであれば質問のクローズをお願いします。

何を作っているか (WinForms?) と開発環境 (OS, Visual Studio のバージョン、.NET Framework or .NET/.NET Core どっちかとそのバージョン) を書きましょう。


【追記】

質問に書かれたコードには間違いがありますが、そこは直して自分の環境(Windows 10 PC、他は質問者さんと同じ)で試してみました。

初回は質問者さんが言われる通り 30 倍ほどの違いがありますが、

first.jpg

2 回目以降はほどんど変わらないという結果。

second.jpg

理由は不明で調査中です。分かったら追記します。質問者さん、回答者・閲覧者の方で理由が分かったら書いていただけると幸いです。

ちなみに、検証に使ったコードは以下の通りです。

using System;
using System.Data;
using System.Diagnostics;
using System.Linq;
using System.Windows.Forms;

namespace WindowsFormsApp1
{
    public partial class Form23 : Form
    {
        private DataTable table;

        public Form23()
        {
            InitializeComponent();

            this.table = new DataTable();
            this.table.Columns.Add("name", typeof(string));
            this.table.Columns.Add("description", typeof(string));
            for (int i = 0; i < 10000; i++)
            {
                var row = table.NewRow();
                row["name"] = $"name{i}";
                row["description"] = $"description{i}";
                table.Rows.Add(row);
            }
        }

        private void button1_Click(object sender, EventArgs e)
        {
            Stopwatch stopWatch = new Stopwatch();
            stopWatch.Start();
            string time = "";

            var result1 = this.table.AsEnumerable()
                          .Where(s => s[0].ToString() == this.textBox1.Text)
                          .Where(s => s[1].ToString() == this.textBox2.Text);
            
            time = stopWatch.Elapsed.ToString();

            stopWatch.Reset();
            stopWatch.Start();
            var hensu = this.textBox1.Text;
            var hensu2 = this.textBox2.Text;
            var result2 = this.table.AsEnumerable()
                          .Where(s => s[0].ToString() == $"{hensu}")
                          .Where(s => s[1].ToString() == $"{hensu2}");

            time += " / " + stopWatch.Elapsed.ToString();

            this.label1.Text = time;
        }
    }
}
0Like

Comments

  1. @Kudo_Yohei

    Questioner

    返信ありがとうございます。

    ご指摘ありがとうございました。
    開発環境を追加しました。

  2. 検証してみました。とりあえず結果のみ上の回答に追記しておきます。

  3. ・追記について
    Linqの遅延評価でループ処理されていない可能性もあるので、試しにToArray()で確定してみてください。

  4. 「Linqの遅延評価」は関係なくてキャッシュが影響している感じです。確証はありませんが、Linq には以下のようなキャッシュの話もあって、上の例でも知らないところでキャッシュされているということがあるかも。

    Linq to Entities でのキャッシュに注意
    http://surferonwww.info/BlogEngine/post/2021/09/19/be-careful-about-cache-on-operation-of-linq-to-entities.aspx

    ちなみに順番を反対に、以下のようにすると、

            private void button1_Click(object sender, EventArgs e)
            {
                Stopwatch stopWatch = new Stopwatch();
                stopWatch.Start();
                string time = "";
    
                var hensu = this.textBox1.Text;
                var hensu2 = this.textBox2.Text;
                var result2 = this.table.AsEnumerable()
                              .Where(s => s[0].ToString() == $"{hensu}")
                              .Where(s => s[1].ToString() == $"{hensu2}");
    
                //var result1 = this.table.AsEnumerable()
                //              .Where(s => s[0].ToString() == this.textBox1.Text)
                //              .Where(s => s[1].ToString() == this.textBox2.Text);
    
                time = stopWatch.Elapsed.ToString();
                stopWatch.Reset();
                stopWatch.Start();
    
                var result1 = this.table.AsEnumerable()
                              .Where(s => s[0].ToString() == this.textBox1.Text)
                              .Where(s => s[1].ToString() == this.textBox2.Text);
    
                //var hensu = this.textBox1.Text;
                //var hensu2 = this.textBox2.Text;
                //var result2 = this.table.AsEnumerable()
                //              .Where(s => s[0].ToString() == $"{hensu}")
                //              .Where(s => s[1].ToString() == $"{hensu2}");
    
                time += " / " + stopWatch.Elapsed.ToString();
    
                this.label1.Text = time;
            }
    

    逆転します (プロパティを使った方が短くなります)。

    result.jpg

  5. なるほど、知らない所でキャッシュされる仕組みがあるとすると判り難いですね…

  6. 質問者 @Kudo_Yohei さん

    質問のコードはどうやって試したんですか?

    上の私の回答・コメントのように 2 つのパターンを連続して試した場合、結果から Linq によるキャッシュが違いの原因ということのようですが。

  7. @Kudo_Yohei

    Questioner

    @SurferOnWww さん
    @radian-jp さん

    検証ありがとうございます。
    検証方法もBenchmarkDotNetを初めて知ったので勉強になります。

    今回、私が書いたプログラムは下記の通りです。

    // 1.TextBox1.Textの値が変更したときにイベント発火
    private async void TxtYearMonth_TextChanged(object sender, EventArgs e)
    {
        // 入力文字列の検証などの非同期処理
    
      // 2.親のフォームからDataSetを取得し、イベントを発火
        DataSourceChangedEvent?.Invoke(this, null);
    }
    
    // 3.DataSourceChangedEventにGetDataSetEventをdelegateしています
    DataSourceChangedEvent += GetDataSetEvent;
    
    // 4.GetDataSetEventの実行
    private async void GetDataSetEvent()
    {
        await Task.WhenAll(SampleMethod1Async(),SampleMethod2Async(),
                           SampleMethod3Async())
    } 
    
    private async Task SampleMethod1Async()
    {
      await Task.Run(()=>{
          // 5.Tableから一意のデータを取得(データは5000~10000件程度)
            var result = _dataSet.Tables["Table1"].AsEnumerable()
                                                  .Where(s => s[0].ToString() == TextBox.Text);
                                                  .Where(s => s[1].ToString() == TextBox2.Text);
        // 取得した一意のデータを画面に反映
      // 略
    });
    }
    
    // SampleMethod2Async SampleMethod3AsyncはSampleMethod1Asyncとほぼ同じ処理(取得するテーブルが異なるだけ)
    
    
  8. そのコードで最初の質問にあった 2 つのパターンの違いはどのようにして検証できたのですか?

    これってエラーになりますよね。コードを書くならもっと考えて書いてもらえませんか。

    var result = _dataSet.Tables["Table1"].AsEnumerable()
                                          .Where(s => s[0].ToString() == TextBox.Text);
                                          .Where(s => s[1].ToString() == TextBox2.Text);
    
  9. @Kudo_Yohei

    Questioner

    失礼したました。
    今後気をつけます。

    返答頂きありがとうございました。

  10. 上のコメントの質問「そのコードで最初の質問にあった 2 つのパターンの違いはどのようにして検証できたのですか?」に答えていただけませんか? それとは別のコードで検証したということであれば、そちらのコードを提示してください。

  11. 最初に実行した方の時間が掛かるのは、おそらく初回実行時のJITコンパイルの影響があるのではないかと考えられます。

    また、実行時間が短すぎるのが気になったので、@SurferOnWww さんのサンプルを少し弄って、読み込むプロパティを独自のものに置き換えてみました。

        private int _ReadMyName;
        private int _ReadMyDescription;
        private int _SearchNo = 9999;
    
        private string MyName
        {
            get
            {
                _ReadMyName++;
                return "name" + _SearchNo;
            }
        }
    
        private string MyDescription
        {
            get
            {
                _ReadMyDescription++;
                return "description" + _SearchNo;
            }
        }
    
        private void button1_Click(object sender, EventArgs e)
        {
            textBoxLog.Clear();
            Stopwatch stopWatch = new Stopwatch();
            stopWatch.Start();
            var result1 = this.table.AsEnumerable()
                          .Where(s => s[0].ToString() == this.MyName)
                          .Where(s => s[1].ToString() == this.MyDescription);
    
            stopWatch.Stop();
            textBoxLog.AppendText("(ReadProperty)\r\n");
            textBoxLog.AppendText($"Elapsed:{stopWatch.Elapsed}\r\n");
            textBoxLog.AppendText($"ReadMyName:{_ReadMyName}\r\n");
            textBoxLog.AppendText($"ReadMyDescription:{_ReadMyDescription}\r\n");
    
            stopWatch.Reset();
            stopWatch.Start();
            var hensu = this.MyName;
            var hensu2 = this.MyDescription;
            var result2 = this.table.AsEnumerable()
                          .Where(s => s[0].ToString() == $"{hensu}")
                          .Where(s => s[1].ToString() == $"{hensu2}");
    
            stopWatch.Stop();
            textBoxLog.AppendText("(ReadLocal)\r\n");
            textBoxLog.AppendText($"Elapsed:{stopWatch.Elapsed}\r\n");
            textBoxLog.AppendText($"ReadMyName:{_ReadMyName}\r\n");
            textBoxLog.AppendText($"ReadMyDescription:{_ReadMyDescription}\r\n");
        }
    

    ・初回
    (ReadProperty)
    Elapsed:00:00:00.0007150
    ReadMyName:0
    ReadMyDescription:0
    (ReadLocal)
    Elapsed:00:00:00.0002543
    ReadMyName:1
    ReadMyDescription:1
    ・10回目
    (ReadProperty)
    Elapsed:00:00:00.0000086
    ReadMyName:9
    ReadMyDescription:9
    (ReadLocal)
    Elapsed:00:00:00.0000043
    ReadMyName:10
    ReadMyDescription:10

    やはり、遅延評価でループが実行されていないようです。
    LinqをFirstOrDefault()で結果を確定すると、

    ・初回
    (ReadProperty)
    Elapsed:00:00:00.0042066
    ReadMyName:10000
    ReadMyDescription:1
    (ReadLocal)
    Elapsed:00:00:00.0009742
    ReadMyName:10001
    ReadMyDescription:2
    ・10回目
    (ReadProperty)
    Elapsed:00:00:00.0024808
    ReadMyName:100009
    ReadMyDescription:19
    (ReadLocal)
    Elapsed:00:00:00.0011527
    ReadMyName:100010
    ReadMyDescription:20

    このようになります。
    また、最初の数回はかなり揺らぎが大きいので、正確に計測する場合は異常値を排除したり、アベレージ取ったり工夫する必要がありそうです。

  12. 遅延評価でループが実行されていないようです。

    その通りのようですね。

    たぶん、質問者さんが上のコメントのコード中に書いた「// 取得した一意のデータを画面に反映」でループが回って、「データは5000~10000件程度」とのことですので、5000~10000回 TextBox.Text プロパティから文字列を取得してくるのに時間がかかったのだと思われます。

    それが、最初の質問にあった「テキストボックスのプロパティを用いたパターン」と「変数を用いたパターン」で、前者が 30 秒、後者が 1 秒という処理時間の違いになっていると思われます。

テキストボックスのプロパティを用いたパターンですが、当該Linqが呼び出されるトリガーは何でしょうか。
テキストボックスのプロパティ変更に関するイベントがトリガーの場合、入力の度にLinqが呼び出されて時間を要しているケースもありそうです。

0Like

Comments

  1. @Kudo_Yohei

    Questioner

    返答ありがとうございます。

    LinqはTextChangedイベント時に呼び出しています。
    (テキストボックスに入力された文字列で即時データのフィルタリングを行う事を想定しています)これは意図的に作っております。

    以上よろしくお願いします。

Your answer might help someone💌