[LINQ]用法・用量を守って使いましょう

c# List 検索
c# List 最大値

こんな感じでググると大抵 LINQ が出てくるので、もはや使っていない人はいないと思われますが、絶対神かと言うと、けしてそうではありません。

先に答えを言っておくと、処理速度を求められるケースでは使わない。
(そのケースで)どうしても使う必要がある場合、なるべく使用回数を減らす事を考えましょう。

そんなの当たり前、と思ってたんですが意外と知らない人もいるようなので、記事にしておきます。

コードは unity ですが、C# 全般の話題です。

LINQ を使った方がいいケース

C、C++、LINQ 無き頃の C#、VB などなど……。
昔の言語に慣れ親しんでいるとどうしても LINQ を避けがちですが、以下のようなケースであれば使っていきましょう。

コードもスッキリしていますし、実行速度的にも問題ありません。

LINQ で検索

using System.Collections.Generic;
using System.Linq;
using UnityEngine;

public class Sample : MonoBehaviour
{
    void Start()
    {
        var list = new List<int> { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };

        var query = list.Where( l => (l % 2) == 1 );
        
        Debug.Log(string.Join(", ", query));
    }
}

LINQ を使わずに検索

using System.Collections.Generic;
using UnityEngine;

public class Sample : MonoBehaviour
{
    void Start()
    {
        var list = new List<int> { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
        var newlist = new List<int>();

        foreach (var l in list)
        {
            if ((l % 2) == 1)
            {
                newlist.Add(l);
            }
        }

        Debug.Log(string.Join(", ", newlist));
    }
}

LINQ では厳しいケース

ただ、「使った方がいいケース」のような単純なコードは、実際にツールやゲームを作っているとなかなかお目にかかりません。
よくある、アイテムデータや敵データなど、ID(番号)を振っておいたデータを検索するようなコードを見てみましょう。

id, dummy
1, "number1"
2, "number2"
3, "number3"

... more (データ 10000 個)

LINQ で検索

using System.Collections.Generic;
using System.Linq;
using UnityEngine;

public class Sample : MonoBehaviour
{
    class TrashData
    {
        public int id;
        public string dummy;
    }

    void Start()
    {
        var list = new List<TrashData>();
        
        for (int i = 0; i < 10000; i++)
        {
            list.Add( new TrashData() { id = i+1, dummy = $"number{i+1}" });
        }
        
        var rand = new System.Random(1);

        var sw = new System.Diagnostics.Stopwatch();
        sw.Start();

        for (int i = 0; i < 1000; i++)
        {
            int search_id = rand.Next(1, 10000);

            var data = list.Where( d => d.id == search_id ).ToList();

            Debug.Log($"{data[0].dummy}");
        }

        sw.Stop();
        Debug.Log($"{sw.ElapsedMilliseconds} ms");
    }
}

ランダムなデータを 1000 回 検索し、デバッグ表示したところ処理に 0.241 秒 かかりました。
純粋な検索処理だけで言えば 0.17 秒ほどでした。

これはちょっと厳しいですよね。

60fps を維持するのであれば、1フレームにかけられる処理は 0.016 秒。
アイテム検索だけでこの 10 倍かかってしまう計算になります。

また、大き目のゲームやツールの場合、簡単にこのデータ数、検索数をオーバーすることもあります。

LINQ を使わずに検索

id が重複しないのであれば、Dictionary でデータを保持しておき、取り出す方法を考えます。
(重複する場合も <int, List<TrashData>> とか似たような方法が使えますが、ここでは触れません)

using System.Collections.Generic;
using UnityEngine;

public class Sample : MonoBehaviour
{
    class TrashData
    {
        public int id;
        public string dummy;
    }

    void Start()
    {
        var list = new List<TrashData>();
        
        for (int i = 0; i < 10000; i++)
        {
            list.Add( new TrashData() { id = i+1, dummy = $"number{i+1}" });
        }
        
        var dic = new Dictionary<int, TrashData>();
        list.ForEach( l => dic.Add(l.id, l) );

        var rand = new System.Random(1);

        var sw = new System.Diagnostics.Stopwatch();
        sw.Start();

        for (int i = 0; i < 1000; i++)
        {
            int search_id = rand.Next(1, 10000);

            var data = dic[search_id];

            Debug.Log($"{data.dummy}");
        }

        sw.Stop();
        Debug.Log($"{sw.ElapsedMilliseconds} ms");

    }
}

純粋な検索処理は 0.00 秒。ゼロです。
デバッグ表示込みで 0.052 秒。

予めデータを Dictionary に登録する必要がある(21-22行)ので、初回起動が少し重い(といっても 0.001 秒)、メモリを多めに使うデメリットはありますが、それを補ってあまりある速度改善です。

LINQ は便利だが、動作は重い。
これは意識しておいた方がいいと思います。

LINQ のウッカリした使い方で処理速度が爆落ちするケース

色のついている箇所、Max を使う場所が少し違うだけで、0 と 1 の2つは同じ処理を行います。

using System.Collections.Generic;
using System.Linq;
using UnityEngine;

public class Sample : MonoBehaviour
{
    void Start()
    {
        int num0 = 100;
        int num1 = 1000;
        int num2 = 10000;

        // 0
        var list = new List<int> { num0, num1, num2 };

        var sw = new System.Diagnostics.Stopwatch();
        sw.Start();

        for (int j = 0; j < 10000; j++)
        {
            for (int i = 0; i < list.Max(); i++)
            {
                // no operation
            }
        }

        sw.Stop();
        Debug.Log($"{sw.ElapsedMilliseconds} ms");

        // 1
        sw.Reset();
        sw.Start();

        var max = new List<int> { num0, num1, num2 }.Max();

        for (int j = 0; j < 10000; j++)
        {
            for (int i = 0; i < max; i++)
            {
                // no operation
            }
        }

        sw.Stop();
        Debug.Log($"{sw.ElapsedMilliseconds} ms");
    }
}

ほとんど差がないコードなのに、なんと 100 倍以上の実行速度差が!

ここから想像できるのは「Max() は実行内容をキャッシュせず、毎回真面目に計算している」という事です。
0 のコードだと 10000 回、Max() が実行され、リストの最大値を計算している事になります。
(あまりに遅いので、List を毎回作り直している可能性すらありそう……?)

1 のコードはループの外で変数に入れたため、Max() は1回しか実行されません。

この問題は「LINQ がいつ式を評価(実行)しているか見えづらい」ために発生します。
また、たとえ動作原理を知っていたとしても、やらかす可能性が高いミスではないでしょうか。

Max() がそもそも遅すぎる気もする

LINQ を過信しないこと

LINQ はわかりやすいですし、使えるようになるとついつい頼りがちですが、全てを解決する光の矢というわけではありません。

参考までに、10万行のデータを1万回検索した時の、それぞれの速度です。

Dictionary0
DataView - Find0
DataTable - Select0.169
DataTable - Where(LINQ)122.252
DataView - RowFilter201.635 (sec)

RowFilter 使うくらいなら LINQ がマシですが、どちらも他のに比べて嘘みたいに遅いという結果に。
速度が必要なら DataTable - Find、Dictionary の2択になります。
Find は要 Sort なんで、Sort 発生時、内部に検索用 Dictionary 用意してたりとかしそう。

テスト中は大きなダミーデータを作って、パフォーマンステストをするよう心がけましょう。
データが少なかったり検索回数が小さければどれでも大差なくなるので、好きなのを使えばいいと思います。

返信を残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA