From 7e906f2d40644a1270e29c8ea332df4433e66a76 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Wed, 25 Mar 2026 09:13:47 -0400 Subject: [PATCH] Improve Query Store grid query performance (#143) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes from community feedback (41s → 3s on slow machines): 1. Remove WHERE p.query_plan IS NOT NULL from the ranked CTE — this predicate on nvarchar(max) causes an expensive implicit conversion scan. NULL plans are already handled by the C# reader (skip row). 2. Two-phase approach: materialize TOP N into #top_plans first using only numeric columns, then join to query_text/plan XML tables for just the winners. Avoids dragging expensive nvarchar(max) columns through the entire CTE evaluation. 3. Remove OPTION (LOOP JOIN) — reported 3x faster without it on multiple test environments. Tested on SQL2022 with PerformanceMonitor Query Store data. Closes #143 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Services/QueryStoreService.cs | 64 +++++++++++++------ 1 file changed, 43 insertions(+), 21 deletions(-) diff --git a/src/PlanViewer.Core/Services/QueryStoreService.cs b/src/PlanViewer.Core/Services/QueryStoreService.cs index 4b0d51e..a04ebd1 100644 --- a/src/PlanViewer.Core/Services/QueryStoreService.cs +++ b/src/PlanViewer.Core/Services/QueryStoreService.cs @@ -143,15 +143,17 @@ public static async Task> FetchTopPlansAsync( parameters.Add(new SqlParameter("@hoursBack", hoursBack)); } - // 1. plan_agg: aggregate runtime_stats by plan_id only (cheapest grouping, - // avoids joining query_text for the entire dataset). - // 2. ranked: join the small aggregated result to plan to get query_id, - // ROW_NUMBER to pick best plan per query. - // 3. Final SELECT: TOP N, then join query_text + plan XML only for winners. - // Filter clauses applied here where q/p are available. + // Two-phase approach for performance (see GitHub issue #143): + // Phase 1: Aggregate + rank into #top_plans using only numeric columns. + // - No join to query_text/plan XML (expensive nvarchar(max) columns). + // - Removed WHERE p.query_plan IS NOT NULL (implicit conversion on nvarchar(max)). + // - Removed OPTION (LOOP JOIN) — hurts more than it helps in testing. + // Phase 2: Join only the TOP N winners to text/plan/metadata tables. var sql = $@" SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; +DROP TABLE IF EXISTS #top_plans; + WITH plan_agg AS ( SELECT rs.plan_id, @@ -195,13 +197,10 @@ CASE WHEN pa.total_executions > 0 ROW_NUMBER() OVER (PARTITION BY p.query_id ORDER BY {orderClause} DESC) AS rn FROM plan_agg pa JOIN sys.query_store_plan p ON pa.plan_id = p.plan_id - WHERE p.query_plan IS NOT NULL ) SELECT TOP ({topN}) r.query_id, r.plan_id, - qt.query_sql_text, - CAST(p.query_plan AS nvarchar(max)) AS query_plan, r.avg_cpu_us, r.avg_duration_us, r.avg_reads, @@ -209,13 +208,37 @@ SELECT TOP ({topN}) r.avg_physical_reads, r.avg_memory_pages, r.total_executions, - CAST(r.total_cpu_us AS bigint), - CAST(r.total_duration_us AS bigint), - CAST(r.total_reads AS bigint), - CAST(r.total_writes AS bigint), - CAST(r.total_physical_reads AS bigint), - CAST(r.total_memory_pages AS bigint), - r.last_execution_time, + CAST(r.total_cpu_us AS bigint) AS total_cpu_us, + CAST(r.total_duration_us AS bigint) AS total_duration_us, + CAST(r.total_reads AS bigint) AS total_reads, + CAST(r.total_writes AS bigint) AS total_writes, + CAST(r.total_physical_reads AS bigint) AS total_physical_reads, + CAST(r.total_memory_pages AS bigint) AS total_memory_pages, + r.last_execution_time +INTO #top_plans +FROM ranked r +WHERE 1 = 1 {rnClause} +ORDER BY {outerOrder} DESC; + +SELECT + tp.query_id, + tp.plan_id, + qt.query_sql_text, + CAST(p.query_plan AS nvarchar(max)) AS query_plan, + tp.avg_cpu_us, + tp.avg_duration_us, + tp.avg_reads, + tp.avg_writes, + tp.avg_physical_reads, + tp.avg_memory_pages, + tp.total_executions, + tp.total_cpu_us, + tp.total_duration_us, + tp.total_reads, + tp.total_writes, + tp.total_physical_reads, + tp.total_memory_pages, + tp.last_execution_time, CONVERT(varchar(18), q.query_hash, 1), CONVERT(varchar(18), p.query_plan_hash, 1), CASE @@ -223,13 +246,12 @@ WHEN q.object_id <> 0 THEN OBJECT_SCHEMA_NAME(q.object_id) + N'.' + OBJECT_NAME(q.object_id) ELSE N'' END -FROM ranked r -JOIN sys.query_store_plan p ON r.plan_id = p.plan_id +FROM #top_plans tp +JOIN sys.query_store_plan p ON tp.plan_id = p.plan_id JOIN sys.query_store_query q ON p.query_id = q.query_id JOIN sys.query_store_query_text qt ON q.query_text_id = qt.query_text_id -WHERE 1 = 1 {rnClause}{filterSql} -ORDER BY {outerOrder} DESC -OPTION (LOOP JOIN);"; +WHERE 1 = 1{filterSql} +ORDER BY {outerOrder} DESC;"; var plans = new List();