ページ

2010年8月11日水曜日

[LINQ] CompiledQuery を使ったときの LINQ のパフォーマンスの注意点?

Potential Performance Issues with Compiled LINQ Query Re-Compiles」 より。
何度も実行するクエリーは CompiledQuery を使ってキャッシュするとパフォーマンスを向上することができます。しかし、気をつけないとリコンパイルされてしまってパフォーマンスに影響する場合があるので注意、というような内容です。
紹介されているクエリーはこんな感じ。

static Func<NorthwindEntities, string, IQueryable<Customer>> compiledCustQuery =
   CompiledQuery.Compile((NorthwindEntities ctx, string start) =>
   (from c in ctx.Customers
    where c.CustomerID.StartsWith(start)
    select c));

これを

var qryAnyCust = compiledCustQuery(ctx, "C");
if (qryAnyCust.Any())
{
    var qryCust = compiledCustQuery(ctx, "C");
    qryCust.ToList().Count();
}

このように使う場合の問題点が紹介されています。
Any() を使って 「該当データが存在すれば○○、しなければ××」 みたいなことをする場合ってことですね。
(2回 compiledCustQuery を呼び出さずに 1回にまとめられるだろ、というのは横に置いておいて。あくまで例ってことで)
compiledCustQuery がコンパイル済みクエリーなんですが、Any() を使うとリコンパイルを引き起こすそうです。
ではどうするんだ、というと、

static Func<NorthwindEntities, string, bool> compiledAnyCustQuery =
   CompiledQuery.Compile((NorthwindEntities ctx, string start) =>
   (from c in ctx.Customers
    where c.CustomerID.StartsWith(start)
    select c).Any());
if (compiledAnyCustQuery(ctx, "C"))
{
    var qryCust = compiledCustQuery(ctx, "C");
    qryCust.ToList().Count();
}

こんな風に Any() の呼び出しまで含めたコンパイル済みクエリーを別に用意してやればよい、とのことです。
これで 9ms だったものが 6ms と 33% も速度が向上したそうです。

と、ここまで読んで、ちょっと疑問が。。。
リコンパイルされるってほんと?

もともと、LINQ to SQL の仕組みは

コンパイル時にやること
LINQ 構文を本来の拡張メソッド (Queryable.Where() とか Queryable.Select() とか) に置き換えてそれらを呼び出すコードを作るだけ。
ちなみに、Queryable.Where() の引数はラムダ式で書かれた条件式が式木 (Expression Tree) に変換されたものが渡されるようなコードができあがる。これは引数の型が Expression<Func<TSource, bool>> とかになってるのでコンパイラがラムダ式を Exression 型へと暗黙の型変換してくれるから。Select() とかも同じ理屈。

実行時にやること
LINQ to SQL プロバイダが Where() やら Select() やらに渡された式木を見つつ SQL 文を構築する。
そして、実際に SQL を実行して結果を得る。

のはず。
これは CompiledQuery クラスを使っていても基本的には同じ。
ただ、実行時の遅延実行のタイミングが微妙に変わります。
というか、私もきちんとは知らなかったので SQL Server のトレースログを取って確認してみました。
そうしたら以下のようになってました。

var qryCust = compiledCustQuery(ctx, "C");
    ↑この行を実行した時点で SQL 文が構築される。
     (SQL 文はキャッシュされて 2回目以降は使いまわされる)
     また、この時点で SQL Server に接続し、SQL 文が発行される。

foreach (var row in qryCust)   ←データの取得はこの段階で行われる。
{
    
}

このように CompiledQuery.Compile() メソッドで取得したデリゲートにアクセスした時点で SQL 文が構築され、SQL Server に接続し、SQL 文が発行されていました。
ちなみに、CompiledQuery を使っていないときは foreach などで最初に結果セットにアクセスしたときに SQL 文構築、SQL Server への接続、SQL 文の発行が行われます。

ということを踏まえて Any() の話。
紹介した記事では Any() を呼びだすとリコンパイルされる、となっていますが本当でしょうか?
実際に DataContext.Log や SQL Server トレースログを見てみましたが、Any() を呼び出す時点では SQL 文の構築らしきことは何もされていません。

var qryAnyCust = compiledCustQuery(ctx, "C");
    ↑この行を実行した時点で SQL 文構築、SQL Server 接続、SQL 文発行。

if (qryAnyCust.Any())   ←SQL 文を再度発行なんてことはしていない。
{
    
}

上で示した CompiledQuery の遅延実行の流れとまったく同じですが、このように Any() を呼び出す前の段階で SQL 文の発行までは終わってました。Any() は IQueryable を通じて結果セットにアクセスしてるだけにしか見えません。

ですから、Any() を使うとリコンパイルされるということは無いんじゃないかと思います。

では、Any() まで含んだ CompiledQuery を用意してやると 33% も速くなったという話。
これは、試してみればわかりますがそもそも構築される SQL 文が違います。

SELECT
    (CASE
        WHEN EXISTS(
            SELECT NULL AS [EMPTY]
            FROM [dbo].[Customers] AS [t0]
            WHERE [t0].[CustomerID] LIKE @p0 ESCAPE '~'
            ) THEN 1
        ELSE 0
     END) AS [value]

Any() が付いてるとちゃんと 「条件に該当するものが 1件でもあれば 1、なければ 0 を返す」 という SQL 文を作ってくれます。実行している SQL 文が違うんですから、そりゃ速度も違うでしょうし、普通に考えてこっちの SQL 文の方が効率いいでしょうね。
しかし、Any() があるとこんな SQL 文を作ってくれるなんて、ほんとに LINQ to SQL のプロバイダーはよくできてるなぁ。

あと、紹介した記事の後半に IQueryable の代わりに IEnumerable を使うという例が紹介されていますがこれはよくわかりません。
IEnumerable を使うと一気にメモリに取り込むからそれに Any() を適用してもそれなりに速い、ということみたいですが、私が試してみたところでは IQueryable の場合とほとんど変わらないような感じでした。理屈的に考えても、結果セットの大きさが小さい場合はあまり変わらないように思うんですが。。。

■ 結論
Any() を使ったからといってリコンパイルされることは無いですし、遅くなるということもありません。(と思う)
けど、CompiledQuery で取得したものに後から Any() を付け足したりすると非効率になる場合があるのは確か。きちんと Any() まで含んだクエリーを CompiledQuery にした方が効率が良くなる場合が多い、とは言えると思います。まぁ、どっちの方がいいかは、構築された SQL 文を見てみないとわからないって場合もあるとは思いますが。

一応書いておきますが、ずっと Any() を例にしてますが、もちろん他のメソッドでも同じです。Any() が特別なことをしているというわけではありません。

0 件のコメント:

コメントを投稿