Skip to content

perf(kernel): cut per-query Python work for small results - #1059

Open
vuanhphung wants to merge 2 commits into
perf/rd-py-overheadfrom
perf/rd-py-overhead-3
Open

vuanhphung wants to merge 2 commits into
perf/rd-py-overheadfrom
perf/rd-py-overhead-3

Conversation

@vuanhphung

@vuanhphung vuanhphung commented Nov 14, 2026 •

Copy link
Copy Markdown
Collaborator

What type of PR is this?

  • Other (performance)

Stacked on #1048 (base perf/rd-py-overhead); review only the top commit.

Description

With #1048, the GIL-held Python per query on the kernel path is small but still repeated work. Each query rebuilt the cursor description, a Row factory and the to_pylist() cost budget from an Arrow schema that is the same for every run of a statement. Each row fetch also combined the record batches into a pyarrow.Table only to read its columns back out, and Table.from_batches releases and re-takes the GIL, an extra hand-off per fetch that costs throughput when 200 threads share one GIL.

Each connection now keeps what it derives from the last result schema and reuses it while statements return an equal schema (schema.equals(..., check_metadata=True): about 0.05 µs, against 2-16 µs to rebuild). Small results are converted from the batches directly, with the same cost budget, allowlist and to_pylist() error fallback as #1048; a batch whose schema doesn't match goes through the Table, which raises as before. Two smaller items: closing a sync result no longer takes the async-handle lock for bookkeeping it never needed, and staging-verb detection reads the first 7 characters of the statement instead of copying it twice.

How is this tested?

  • Unit tests
  • Manually

New tests cover reuse and invalidation of the derived schema data, rows built from several batches after partial fetches, a schema mismatch, a call-count check that the cost budget is computed once per schema rather than per fetch, the lock-free close, and more staging-verb cases (also fuzzed against the old implementation, 300k inputs, no differences). The result-set tests pass with pandas 1.3.5, 1.5.3, 2.0.3, 2.2.3 and 3.1.1, and on pyarrow 14.

Central C800 against a mock server (Rust mock with 20 ms server time, 4 processes x 200 sessions, 60 s, 16 client cores, kernel with the factory PRs #453 #455 #456 #457 #458, 2 rounds, medians; measured on this change's previous base, d965a66 vs fdada64):

#1048 this PR
avg QPS 16846 19263 (+14%)
peak QPS 18962 21406
query p50 / p99 44.0 / 90.7 ms 38.6 / 74.8 ms
CPU per query 0.581 ms 0.527 ms

Rebased on the final #1048 (51214d2 vs 5668a0e), locally (1 x 200 sessions, 60 s, Python mock with 20 ms server time, 8 cores, kernel main + #457 + #458): 3963 / 3858 vs 4320 / 4477 QPS and 0.52 vs 0.49 ms CPU per query; the pure-Python part of a one-row query drops from 31.5 to 23.7 µs.

Peak RSS: the central run showed +30 MB per process. Python heap retained per query is the same with and without this change (249 B, all of it Connection._cursors, which #1052 fixes); at a fixed 60,000 queries peak RSS is equal (231 vs 232 MB), and with #1052 applied to both sides, 60 s runs reach 233 vs 237 MB while this change completes 13-18% more queries. The per-connection cache holds one schema entry.

Live benchmark (staging Large Reyden warehouse)

TPC-H Q6 on a Large Real-Time warehouse, 800 sessions (4 processes x 200 threads), cold start, each session querying back to back for 120 s, 16 client cores. Isolated A/B: only this change vs #1048 51214d2 (this PR is stacked on it), 3 interleaved rounds, nothing else on the warehouse. Measured at 5668a0e. Median (min-max):

#1048 (base of this stack) this PR
Average QPS (setup included) 2155 (2088-2220) 2158 (2014-2183)
Steady QPS (after all sessions open) 2194 (2124-2262) 2197 (2050-2229)
Query latency p50 (ms) 349 (344-356) 351 (348-372)
Client CPU per query (ms) 0.80 (0.76-0.81) 0.74 (0.73-0.75)
Peak RSS per process (MB) 392 (380-412) 377 (375-377)

QPS is server-bound here; the CPU saving matters when the client is the bottleneck (see the mock numbers above).

SELECT 1 on an X-Large Real-Time warehouse (driver-bound). Q6 above is limited by the warehouse, so the same harness also ran SELECT 1, where the client is the limit: X-Large warehouse, 4 processes x 200 threads, 120 s, 16 client cores, this change vs #1048 (51214d2), measured at bc2e0f2, run in rotation with the other factory PRs, 2 rounds. Median (min-max):

#1048 this PR
Average QPS (setup included) 6760 (6538-6982) 7090 (7055-7126)
Peak QPS (best second) 7662 (7603-7720) 7866 (7806-7926)
Query latency p50 (ms) 108 (106-111) 104 (103-106)
Client CPU per query (ms) 0.63 (0.63-0.64) 0.59 (0.59-0.59)

Average QPS change per round: +9.0%, +1.0%.

Related Tickets & Documents

Stacked on #1048.

This pull request and its description were written by Isaac.

Each query rebuilt the cursor description and a Row factory from the
Arrow schema, and each row fetch combined the record batches into a
Table, whose construction releases and re-takes the GIL. Connections
now keep what they derive from the last result schema and reuse it while
statements return an equal schema, and small results are converted from
the batches directly (falling back to the Table when a batch schema does
not match). Closing a sync result skips the async-handle bookkeeping,
and staging-verb detection no longer copies the statement.

Signed-off-by: Vu Anh Phung <[email protected]>
Co-authored-by: Isaac <[email protected]>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: 1 Low

Looks good overall. I found one low-severity issue: the kernel _convert_arrow_table override is no longer called from src/, so the main pandas-parity tests no longer cover the _rows path that production uses. Nit: in test_the_cost_budget_is_computed_once_per_schema_not_per_fetch, [1, 2, 3, 4] is split one element per line, which is hard to read; binding the fetched values to a local first would keep it on one line. I checked the schema cache, the close_command fast path and the staging-verb head-slice for correctness and found no problems.

Row fetches now go through KernelResultSet._rows, so its
_convert_arrow_table override had no callers left; remove it and point
the parity, out-of-range and fallback-logging tests at _rows.

Signed-off-by: Vu Anh Phung <[email protected]>
Co-authored-by: Isaac <[email protected]>

@peco-review-bot peco-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No issues identified by the review bot.

This branch was previously deployed

1 inactive deployment
azure-prod — bc2e0f27 Deployed Nov 14, 2026 by vuanhphung via followup #2300
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant