Skip to content

perf(kernel): convert small results to rows without pandas - #1048

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

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

Conversation

@vuanhphung

@vuanhphung vuanhphung commented Nov 13, 2026 •

Copy link
Copy Markdown
Collaborator

What type of PR is this?

  • Other (performance)

Description

On the kernel backend every fetchall() / fetchmany() / fetchone() turns the Arrow result into rows through a pandas DataFrame (ResultSet._convert_arrow_table). For a one-row result that round trip costs 600-700 µs of GIL-held CPU, about half of the Python CPU a query spends in the client. Under 200 threads sharing one process, a py-spy --gil profile against a local mock put it at 33% of all GIL-held samples, the largest single item.

KernelResultSet now builds small results from to_pylist() when every column has a type for which that returns the same Python values as the pandas path: integers, booleans, floats without NaN, decimals, strings, binary, date32, and timestamps not in nanoseconds. to_pylist() costs more per value than pandas, so "small" is a cost budget rather than a row count: rows × the sum of per-type weights must stay within 1024 (and rows ≤ 128). A weight is the type's per-value to_pylist() cost relative to an integer: 1 for numbers, 3 for strings, 6 for binary and decimals, 12 for dates and timestamps, and 400 for tz-aware timestamps, which pyarrow converts slowly when pytz isn't installed. Everything else (NaN floats, nested, duration, interval, date64/time32/time64, nanosecond units, _disable_pandas, and results whose to_pylist() raises, such as dates past year 9999) takes the existing path unchanged, so values and errors match the pandas path. The Thrift result set is not touched.

How is this tested?

  • Unit tests
  • Manually

Unit tests compare rows from both paths (values, Python types, tzinfo) for every allowlisted type with nulls and edge values, chunked and duplicate-named columns, and check that excluded types, NaN, out-of-range values and results over the budget go through pandas. They pass with pandas 1.3.5, 1.5.3, 2.0.3, 2.2.3 and 3.1.1 on pyarrow 23.

The budget comes from a microbenchmark of single-thread CPU per conversion (13 column kinds, 1-300 columns, 1-128 rows, the five pandas versions above). The slowest result that now skips pandas costs 0.67× the pandas path; results that don't skip it cost the same as before (median 1.01×). Fast-path CPU relative to the pandas path, range over the five pandas versions:

result 128-row cap (first revision) cost budget
1 DOUBLE × 1 row (TPC-H Q6) 0.03-0.04× 0.03-0.04×
TPC-H lineitem, 16 columns × 8 rows 0.15-0.21× 0.14-0.22×
TPC-H lineitem, 16 columns × 128 rows 0.92-1.15× pandas
64 STRING × 128 rows 1.20-2.23× pandas
32 DATE × 128 rows 3.23-4.23× pandas
64 TIMESTAMP × 4 rows 0.78-2.63× pandas
300 BIGINT × 1 row 0.07-0.12× 0.06-0.13×

With 16 threads converting concurrently, results at the budget's edge keep 2.6-8.9× the pandas path's throughput.

C800 benchmark (cold start, 4 processes × 200 sessions, each running TPC-H Q6, one row, back to back; 16 client cores; 3 interleaved rounds, medians; measured on the first revision, whose one-row path is unchanged here):

main this PR
Mock server (50 ms server time, 60 s): avg QPS 5846 14538
same: peak QPS 6232 15326
same: query p50 / p99 130 / 199 ms 52 / 67 ms
same: CPU per query 1.33 ms 0.64 ms
Live Reyden warehouse (120 s): CPU per query 2.08 ms 1.66 ms

On the live warehouse QPS is bound by the warehouse today (avg 550 vs 515, peak 996 vs 1041, within run-to-run noise); the mock shows the driver's own capacity. With this revision, one process × 200 sessions against a local mock (20 ms server time, 4 interleaved rounds, medians): avg QPS 1510 → 3603, peak 1633 → 4199, query p50 101 → 33 ms, CPU per query 1.25 → 0.66 ms (the first revision measured 3498 / 4362 / 34 ms / 0.66 ms).

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 connector main 0c1b9e8 + kernel 1.4.0, 5 interleaved rounds, nothing else on the warehouse. Measured at 51214d2. Median (min-max):

main this PR
Average QPS (setup included) 2166 (2010-2222) 2185 (1781-2266)
Steady QPS (after all sessions open) 2208 (2044-2265) 2234 (2208-2306)
Query latency p50 (ms) 350 (342-357) 347 (322-352)
Client CPU per query (ms) 1.34 (1.17-1.39) 0.92 (0.75-1.00)
Peak RSS per process (MB) 394 (387-400) 377 (372-390)

The warehouse is the bottleneck here (query latency is queueing at 800 sessions), so the ~35% lower client CPU per query shows up as headroom rather than QPS. One base and one PR round hit server-side 503/504s and are included.

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 connector main 0c1b9e8 + kernel 1.4.0, measured at 51214d2, run in rotation with the other factory PRs, 2 rounds. Median (min-max):

main this PR
Average QPS (setup included) 6094 (6040-6147) 6760 (6538-6982)
Peak QPS (best second) 6513 (6503-6523) 7662 (7603-7720)
Query latency p50 (ms) 124 (123-124) 108 (106-111)
Client CPU per query (ms) 1.34 (1.33-1.35) 0.63 (0.63-0.64)

Average QPS change per round: +8.3%, +13.6%.

Related Tickets & Documents

This pull request and its description were written by Isaac.

KernelResultSet built a pandas DataFrame for every fetch, which costs
hundreds of microseconds of GIL-held CPU even for a one-row result.
Results of up to 128 rows (4 with a tz-aware timestamp column) are now
converted with to_pylist() when every column has a type for which that
gives the same values as the pandas path; other results still go
through pandas.

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.

to_pylist() costs more per value than pandas, so with only a 128-row cap
wide or string/decimal/date-heavy results were slower than the pandas
path (16 lineitem columns x 128 rows 0.92-1.15x, 64 strings x 128 rows
up to 2.2x, 32 dates x 128 rows up to 4.2x). A result now skips pandas
only while rows times the sum of per-type weights stays within 1024,
which kept every admitted shape at most 0.67x of the pandas CPU on
pandas 1.3-3.1.

date64, time32 and time64 now take the pandas path, since to_pylist()
rounds pre-1970 date64 values down and wraps out-of-range times where
pandas raises. A to_pylist() error (e.g. a date past 9999-12-31) also
falls back to pandas, so errors match the pandas path.

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: 2 Low

Looks good: two low-severity notes, one on recomputing the per-schema row limit on every fetch and one on the broad except Exception in the fallback. The fast path is safe: it only takes types allowlisted by type ID, skips NaN floats and nanosecond units, and falls back to pandas on any error. Tests compare its rows against the pandas path for every allowlisted type, for chunked and duplicate-named columns, and at the budget boundary.

…rror

Review feedback: a to_pylist() failure silently switched to the pandas
path, so a pyarrow regression that broke it for a common type would only
show up as a slowdown. Log the fallback at debug level with the
traceback; the catch stays broad so pandas keeps deciding the result or
error.

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: the fast path checks types conservatively, falls back to pandas, and is well tested against the pandas path. One low concern: tz-aware timestamps may come back with a different tzinfo type when pandas ≥3 and pytz are both installed.

This branch was previously deployed

1 inactive deployment
azure-prod — 51214d25 Deployed Nov 13, 2026 by vuanhphung via test-with-coverage #1239
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