Skip to content

query_stats renders and ships the same ~200 plans every minute: 47x more plan XML than is distinct, and the reason its slow runs are slow #5158

Closed

Description

@erikdarlingdata

Claude posting for Erik Darling.

query_stats renders and ships the same plans every minute: about 47 times more plan XML than there is distinct plan XML. On a large store that's 54 GB of plans rendered per hour on the monitored servers, 29 GB of it sent over the network, and it is what makes the slow query_stats runs slow.

Measured (one production store, 43 SQL Server targets, the last hour, read-only)

  • Fleet:

    • plan XML rendered: 53.75 GB (sum of query_plan_xml_bytes), from 479,000 rows;
    • shipped: 28.83 GB (the rows under the 512 KB cap);
    • rendered on the server and then discarded: 22,752 rows over the cap;
    • distinct plans, each counted once per server and plan hash: 1.14 GB.
  • Per server: 1.4–1.8 GB of plans per hour each, from about 55 runs of 200 rows. Only 160–200 distinct plans per hour per server, so 40–59× redundancy.

  • The busiest-tail server:

    • 11.9 MB shipped per run, against 2.4 plans per run not seen in the previous run;
    • its slowest runs were 78–85 s, of which 72–78 s was sql_drain_ms;
    • across the fleet, the slowest query_stats runs this hour were 90–96% drain.
  • What the slow runs look like (measured earlier on another server whose query_stats p95 rose from 2.8 to 11 s over four days):

    • the same 200 rows and about 14 MB of plans in slow and normal runs alike;
    • the stream rate fell from about 18 MB/s to 0.17–0.30 MB/s in the slow runs.

    So a run's time is roughly bytes ÷ the server's stream rate, and the bytes are almost all repeats.

Why

QueryStatsCollector splices PlanSelectFragment/PlanApplyFragment into the main query whenever CapturePlanXml is on (Darling). So every run calls sys.dm_exec_text_query_plan for each of its ~200 surviving rows, whether or not the store already holds that plan. #2862 put procedure_stats on a plan-capture cadence and deliberately left query_stats alone. #2164 gave query_store a skip for already-shipped plans. query_stats has neither.

Costs

  • On the monitored server: dm_exec_text_query_plan renders on the server's CPU, and the server streams the result.
  • On the server's other collectors: collectors on one server run in sequence, so a long query_stats drain holds that server's every-minute collectors (CPU, perfmon, waits). This hour the busiest-tail server had gaps of 160–246 s on them while query_stats ran 80–90 s.
  • On the store: it receives and de-duplicates about 47× more than it keeps.

Fix

Fetch plan XML only for plans the store doesn't already hold, as query_store does (#2164):

  1. The main query returns the stats rows with each row's plan identity and no plan XML.
  2. A store probe finds which of them are already captured.
  3. A second target query renders only the missing ones.

Readers already handle rows whose plan is NULL and fall back to the latest row that has one (the #1767 guard that #2862 relies on).

A cadence gate like #2862's is the smaller fallback. It cuts the volume by the interval, but still re-renders every plan on each captured cycle.

Acceptance

On the same store after the fix:

  • plan XML rendered per hour falls to near the distinct-plan volume;
  • query_stats p95 and the slowest runs' sql_drain_ms fall;
  • the every-minute collectors' gaps on the busiest-tail servers close.

Measure all three with the same read-only queries, same hour of day.

Activity

  1. erikdarlingdata commented on Oct 6, 2026

    @erikdarlingdata
    OwnerAuthor

    Claude posting for Erik Darling.

    The query_stats half (#5173, #5174, #5209), measured on the largest production store after the nightly that first carries #5209: query_stats now reads 0.97 GB/h from its 43 targets, against 23.8 GB/h before. That's 96% less. Run time fell too: p95 2,096 → 596 ms, p50 925 → 254 ms. There are no new gaps.

    How it was measured:

    • Source: the store's own collection_log, for query_stats on 43 SQL Server targets.
    • Before: build 3.9.0-nightly.20261005.509, 25.75 h (2026-10-05 11:00Z to 2026-10-06 12:45Z).
    • After: build 3.9.0-nightly.20261006.515, 3.25 h (13:10Z to 16:25Z, 2026-10-06).
    • Excluded: the 12:45–13:10Z install window.
    • Settings: queryStatsDeferredPlanFetch is at its default (on).
    Before (509) After (515)
    Runs 66,113 8,409
    Bytes read from targets (drain_bytes_read), GB/h 23.80 0.97
    Plan XML rendered on targets (plans_rendered_bytes), GB/h not recorded on 509 0.32
    Run duration p50 / p95 925 / 2,096 ms 254 / 596 ms
    Collection gap p50 / p95 60.0 / 67.1 s 60.0 / 69.6 s
    Gaps over 5 min, per hour 1.3 (33 in 25.75 h) 1.5 (5 in 3.25 h)
    Non-success runs 0 0

    By hour:

    • Before: 19–28 GB an hour, with p95 1.3–2.9 s.
    • Every full hour since: 0.94–1.05 GB, with p95 575–636 ms.
    • Plans rendered per hour went from about 9–11 K in the first hour after the restart, when the plan cache is empty, to about 1.0–1.2 K, as recognized plans stopped being rendered again.

    Not covered:

    • The longer view: this is the first 3.25 h. A 24 h comparison will follow.
    • The "rendered" baseline: 509 doesn't write the plans_rendered* note, so that row has no before value.
  2. erikdarlingdata commented on Oct 7, 2026

    @erikdarlingdata
    OwnerAuthor

    Claude posting for Erik Darling.

    24 h follow-up on the query_stats half (deferred plan fetch, build 515), one production store, 43 servers. Install windows and the cold hours are excluded.

    before (509), 25.8 h after (515), 19.9 h after (517), 2.8 h
    plan XML shipped, GB/h 23.8 1.12 0.85
    plan XML rendered, GB/h not recorded 0.32 0.59
    p50, ms 925 252 246
    p95, ms 2,096 578 605
    gaps over 5 min 33 0 27

    The reduction held over the longer window. Rendered volume stayed under 1 GB/h in the 515 window, and p95 stayed near the 596 ms measured earlier. One hour after a restart, the 517 window shows 1.23 GB rendered and p95 824 ms. I believe that is a post-restart effect, but I have not measured it. The 27 gaps over 5 minutes in the 517 window fall in a window that contains one service restart (for the procedure_stats on switch below), which takes about two minutes to reach collection; that they are that restart is inferred, not yet checked per gap.

    The query_stats half is done. The procedure_stats half moved from shadow to on on this store today, after its gate passed: over about 2 warm hours, 1,329 shadow runs on 43 servers, 0 shape false hits and 35 over-cap false hits. Its before and after comes after a day on on.

  3. erikdarlingdata commented on Oct 8, 2026

    @erikdarlingdata
    OwnerAuthor

    Claude posting for Erik Darling.

    The procedure_stats half, measured on the large SQL Server measurement store (43 servers), read-only. One side ran in shadow and the other in on. Both windows cover the same hours of the day, 11 h 40 min each. Restarts are left out.

    • shadow: 2026-10-06 13:20 to 2026-10-07 01:00 UTC, nightly 515.
    • on: 2026-10-07 13:20 to 2026-10-08 01:00 UTC, nightly 517. The store switched to on at 12:38 UTC.
    shadow on
    Runs 30,093 30,098
    Bytes read from targets (drain_bytes_read), GB/h 8.29 0.153 (minus 98%)
    Plans rendered 1,009,506 309,601 (minus 69%)
    Run p50 / p95 / max, ms 183 / 2,040 / 79,924 226 / 1,126 / 57,966
    Average rows per run 144.5 144.6
    Failed runs 0 0
    Gap p95 / max, s 67.5 / 153.1 66.1 / 131.8
    Gaps over 5 minutes 0 0
    • On the six servers that read the most, each read 2.6 to 2.7 GB from its target in the shadow window. Each read 44 to 46 MB in the on window. Their p95 went from 1.6 to 2.5 s down to 0.8 to 1.6 s.
    • The p50 went up by 43 ms. I think the identity check on each run causes this. I did not measure it.
    • I left out the plans_rendered_bytes totals. Only 7,523 of the 30,093 shadow runs wrote that note, so the two sides do not compare.
    • Before the switch, 1,329 warm shadow runs had no shape false hits.

    Limits:

    The default stays off. The query_stats half needs no setting and is done.

  4. erikdarlingdata commented on Oct 9, 2026

    @erikdarlingdata
    OwnerAuthor

    Claude posting for Erik Darling.

    The procedure_stats half, re-measured in on with #5463 and #5502 in the build: 0 failed runs and 0 gaps over 5 minutes, but bytes read from targets went up 5×. That's 0.772 GB/h, against 0.153 GB/h before #5463. Against the earlier shadow window (8.29 GB/h) it is 91% down, not 98%. The store is the large SQL Server measurement store (43 servers), and the read was read-only.

    The two sides, both on and both on the same hours of day (10 h 5 min each), with restarts left out:

    517 on 533/534 on
    Runs 26,009 26,017
    Bytes read from targets (drain_bytes_read), GB/h 0.153 0.772
    Plans rendered 270,288 317,974
    Run p50 / p95 / max, ms 226 / 1,134 / 57,966 287 / 1,305 / 29,747
    Average rows per run 144.7 473.4
    Failed runs 0 0
    Gap p95 / max, s 66.1 / 131.8 69.0 / 153.6
    Gaps over 5 minutes 0 0

    The 20:00 to 01:00 hours alone (5 h, 534 on the after side):

    517 on 534 on
    Runs 12,890 12,905
    Bytes, GB/h 0.153 0.754
    Plans rendered 134,257 153,581
    p50 / p95 / max, ms 227 / 1,125 / 29,252 284 / 1,318 / 25,460
    Failed runs 0 0
  5. erikdarlingdata commented on Oct 9, 2026

    @erikdarlingdata
    OwnerAuthor

    Claude posting for Erik Darling.

    Closing as completed, at Erik's call: 91% down against the shadow baseline is good enough for 3.10. The figures are in the comment above. We can revisit after the full release if the bytes need another look.

  6. added a commit that references this issue on Oct 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions