Skip to content

PERF: Speed up output-converter batching and add CI coverage - #852

Open
Jahnvi Thakkar (jahnvi480) wants to merge 5 commits into
mainfrom
jahnvi/converter-path-speedup
Open

Jahnvi Thakkar (jahnvi480) wants to merge 5 commits into
mainfrom
jahnvi/converter-path-speedup

Conversation

@jahnvi480

@jahnvi480 Jahnvi Thakkar (jahnvi480) commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Work Item / Issue Reference

AB#49494


Summary

Reduces the overhead of fetching rows with output converters, without changing the public API.

  • Passes owned UTF-16LE bytes directly to eligible converter callbacks, avoiding unnecessary conversion to Python strings and back to bytes.
  • Uses guarded native row construction while preserving callback order, NULL handling, converter updates, error fallbacks, and custom Row behavior.
  • Adds focused regression tests and separate 10,000-row CI performance workloads for fetchall() and fetchmany(1000).

Local profiling-OFF benchmarks showed about 33% lower batch-fetch time than pinned main. The fetchone() improvement target and faster-than-pyodbc target remain unmet.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI balanced review requested due to automatic review settings October 19, 2026 04:16
@github-actions github-actions Bot added the pr-size: large Substantial code update label Oct 19, 2026
@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

PR Performance Report

✅ Performance improved

2 database tasks consistently improved across 2 measured environments. No consistent slowdowns were detected.

2 IMPROVEMENTS 0 SLOWDOWNS 2/2 ENVIRONMENTS

Signal fingerprint

Database task Unix / SQL Server 2022 Unix / SQL Server 2025
10,000 output-converted rows / fetchall() 27.8% faster 29.4% faster
10,000 output-converted rows / fetchmany(1000) 23.1% faster 21.7% faster

Coverage: 2 of 2 environments completed. Advisory result; does not block merging.

Measured timings
Environment Database task Before After Change
Unix / SQL Server 2022 10,000 output-converted rows / fetchall() 27.272 ms 19.622 ms -27.8%
Unix / SQL Server 2022 10,000 output-converted rows / fetchmany(1000) 31.098 ms 23.892 ms -23.1%
Unix / SQL Server 2025 10,000 output-converted rows / fetchall() 27.748 ms 19.757 ms -29.4%
Unix / SQL Server 2025 10,000 output-converted rows / fetchmany(1000) 31.731 ms 24.745 ms -21.7%
Performance diagnostics

Phase times are inclusive diagnostics and must not be added together. They identify where measured time changed, not why it changed.

Unix / SQL Server 2022

10,000 output-converted rows / fetchall(): py::fetchall::row_wrap -7.769 ms; ddbc::SQLDescribeCol_wrap -0.000 ms; ddbc::FetchBatchData::cache_column_metadata -0.000 ms.
10,000 output-converted rows / fetchmany(1000): py::fetchmany::row_wrap -7.761 ms; ddbc::SQLBindColums -0.001 ms; ddbc::SQLNumResultCols_wrap -0.001 ms.

Unix / SQL Server 2025

10,000 output-converted rows / fetchall(): py::fetchall::row_wrap -7.898 ms; ddbc::FetchBatchData::SQLFetchScroll_call -0.249 ms; ddbc::FetchBatchData -0.131 ms.
10,000 output-converted rows / fetchmany(1000): py::fetchmany::row_wrap -7.300 ms; ddbc::SQLBindColums -0.003 ms; ddbc::SQLNumResultCols_wrap -0.000 ms.

All database tasks and timings

Unix / SQL Server 2022

Database task Before After Paired change Result
Connection opening 10.469 ms 10.252 ms -2.4% no signal
SELECT queries 0.991 ms 1.002 ms +0.8% no signal
Row insertion 32.060 ms 31.737 ms -0.5% no signal
Executemany inserts 159.903 ms 158.902 ms +0.1% no signal
Fetch-all queries 120.361 ms 121.132 ms +0.4% no signal
Row-by-row fetching 14.351 ms 14.390 ms -0.5% no signal
Batched row fetching 116.813 ms 117.460 ms +0.8% no signal
Transaction commit and rollback 105.143 ms 103.795 ms -2.2% no signal
Arrow row fetching 89.464 ms 89.015 ms -0.7% no signal
100,000-row insertion 434.705 ms 442.705 ms +0.4% no signal
Row fetching in batches of 100 121.776 ms 121.165 ms -0.3% no signal
Row fetching in batches of 10,000 130.150 ms 128.157 ms -0.2% no signal
Repeated positional queries 31.739 ms 31.527 ms -0.6% no signal
Repeated named-parameter queries 34.012 ms 33.989 ms +0.4% no signal
Legacy 100,000-row insertion 351.643 ms 352.266 ms -0.5% no signal
Insertion with explicit input sizes 485.945 ms 490.896 ms -1.0% no signal
Joined aggregation queries 176.717 ms 177.226 ms +0.3% no signal
Large joined-result fetching 191.993 ms 189.897 ms -0.6% no signal
1.2-million-row fetching 3413.598 ms 3407.682 ms -0.4% no signal
Common table expression queries 5.373 ms 5.444 ms -0.0% no signal
256 KiB VARCHAR(MAX) / fetchall() 1.617 ms 1.592 ms -4.9% no signal
10,000 scalar values / fetchval() (debug disabled) 107.423 ms 106.467 ms +0.1% no signal
10,000 output-converted rows / fetchall() 27.272 ms 19.622 ms -27.8% consistent improvement
10,000 output-converted rows / fetchmany(1000) 31.098 ms 23.892 ms -23.1% consistent improvement
10,000 rows x 32 columns / dict(row._mapping) (warm) 32.436 ms 31.071 ms -5.4% no signal

Unix / SQL Server 2025

Database task Before After Paired change Result
Connection opening 97.012 ms 96.578 ms -0.4% no signal
SELECT queries 1.062 ms 1.019 ms -4.1% no signal
Row insertion 33.030 ms 32.378 ms -1.5% no signal
Executemany inserts 151.428 ms 152.677 ms +0.7% no signal
Fetch-all queries 120.625 ms 122.721 ms +1.6% no signal
Row-by-row fetching 14.544 ms 14.771 ms +0.8% no signal
Batched row fetching 117.561 ms 118.778 ms +1.3% no signal
Transaction commit and rollback 108.370 ms 108.638 ms +0.7% no signal
Arrow row fetching 90.676 ms 90.756 ms +1.9% no signal
100,000-row insertion 427.077 ms 427.417 ms +0.3% no signal
Row fetching in batches of 100 123.364 ms 122.232 ms -1.3% no signal
Row fetching in batches of 10,000 128.926 ms 143.044 ms +8.0% no signal
Repeated positional queries 32.238 ms 32.158 ms -0.8% no signal
Repeated named-parameter queries 35.257 ms 34.676 ms -2.3% no signal
Legacy 100,000-row insertion 345.214 ms 347.738 ms -0.2% no signal
Insertion with explicit input sizes 483.756 ms 479.306 ms -0.7% no signal
Joined aggregation queries 162.074 ms 160.759 ms -0.1% no signal
Large joined-result fetching 191.061 ms 189.470 ms -1.5% no signal
1.2-million-row fetching 3470.612 ms 3464.977 ms -0.1% no signal
Common table expression queries 5.113 ms 5.099 ms -0.4% no signal
256 KiB VARCHAR(MAX) / fetchall() 1.486 ms 2.013 ms +35.9% no signal
10,000 scalar values / fetchval() (debug disabled) 107.009 ms 108.860 ms +0.8% no signal
10,000 output-converted rows / fetchall() 27.748 ms 19.757 ms -29.4% consistent improvement
10,000 output-converted rows / fetchmany(1000) 31.731 ms 24.745 ms -21.7% consistent improvement
10,000 rows x 32 columns / dict(row._mapping) (warm) 30.997 ms 32.368 ms +1.4% no signal
Build and measurement details

ADO build 189586

PR head: a36bcf9f85d05633a563f2c6eb7034031c106024
Base: 6391b10760ebef25184476d89525fc31745637f7
Measured merge: 073e2c2248b21fe6d80a75aeee3ed9f05329f856

  • Unix / SQL Server 2022: Python 3.12.3, x86_64, SQL 16.0.4305.5; 5 paired comparisons and 1 warmup.
  • Unix / SQL Server 2025: Python 3.12.3, x86_64, SQL 17.0.5035.5; 5 paired comparisons and 1 warmup.

A consistent change requires more than 20% median paired movement, at least 1 ms between the median runtimes, and at least 80% of pairs exceeding the relative threshold in the same direction. A slowdown without enough pair agreement is reported as inconsistent.

The displayed change is the median of paired before-and-after ratios. It is not recalculated from the two displayed median runtimes.

Both revisions use profiling-enabled builds on the same agent and database, with alternating order and discarded warmups. Results are diagnostic and do not represent production-wheel latency.

Raw samples and logs are attached to the ADO run as profiler-* artifacts.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Native ownership and dispatch changes require human review, with performance qualification on HOLD and cross-platform validation still limited.

No open issues 🎉

What changed in this PR

Optimizes output-converter fetching across the Python and native driver layers and adds CI benchmark coverage. The change remains draft-only and performance qualification is on HOLD.

Changes:

  • Retains owned UTF-16LE bytes and adds guarded native Row construction.
  • Adds converter correctness, fallback, mutation, ownership, and dispatch tests.
  • Adds two converter-fetch benchmarks, expanding the registry to 24 tasks without changing comparison thresholds.
File Description
tests/​test_output_converter_bytes.py Tests converter semantics, ownership, and native dispatch.
tests/​test_036_profiler_ci.py Tests benchmark boundaries, cleanup, and reporting.
mssql_python/​row.py Delegates list-based conversion to native code.
mssql_python/​pybind/​output_converter.hpp Implements owned text, conversion, and guarded Row construction.
mssql_python/​pybind/​ddbc_bindings.h Adds deferred text handling to column processors.
mssql_python/​pybind/​ddbc_bindings.cpp Passes converter maps through fetches and exposes helpers.
mssql_python/​cursor.py Integrates native converter paths and fallbacks.
eng/​profiler_benchmarks/​workloads.py Adds fetchall and fetchmany converter workloads.
eng/​profiler_benchmarks/​report.py Adds readable benchmark report labels.
eng/​profiler_benchmarks/​README.md Documents workloads and measurement boundaries.

Review effort: Balanced

@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

📊 Code Coverage Report

🔥 Diff Coverage

93%


🎯 Overall Coverage

85%


📈 Total Lines Covered: 10730 out of 12566
📁 Project: mssql-python


Diff Coverage

Diff: main...HEAD, staged and unstaged changes

  • mssql_python/cursor.py (100%)
  • mssql_python/pybind/ddbc_bindings.cpp (68.0%): Missing lines 3647,3827-3832,4486
  • mssql_python/pybind/ddbc_bindings.h (100%)
  • mssql_python/pybind/output_converter.hpp (94.6%): Missing lines 66,112,119,205-207,244-246,250
  • mssql_python/row.py (100%)

Summary

  • Total: 270 lines
  • Missing: 18 lines
  • Coverage: 93%

mssql_python/pybind/ddbc_bindings.cpp

Lines 3643-3651

  3643                                 py::object text;
  3644                                 if (OutputConverters::enabled(converters, i - 1))
  3645                                     text = OutputConverters::text(textData, textSize);
  3646                                 row.append(text ? text
! 3647                                                 : FetchText::from_utf16_native(textData, textSize));
  3648                                 LOG("SQLGetData: CHAR column %d fetched as WCHAR, "
  3649                                     "length=%lu",
  3650                                     i, (unsigned long)numCharsInData);
  3651                             } else {

Lines 3823-3836

  3823                                 // omits the terminator.
  3824                                 const char* textData =
  3825                                     reinterpret_cast<const char*>(dataBuffer.data());
  3826                                 Py_ssize_t textSize =
! 3827                                     static_cast<Py_ssize_t>(numCharsInData * sizeof(SQLWCHAR));
! 3828                                 py::object text;
! 3829                                 if (dataType != SQL_SS_VARIANT &&
! 3830                                     OutputConverters::enabled(converters, i - 1))
! 3831                                     text = OutputConverters::text(textData, textSize);
! 3832                                 row.append(text ? text
  3833                                                 : FetchText::from_utf16_native(textData, textSize));
  3834                                 LOG("SQLGetData: Appended NVARCHAR string "
  3835                                     "length=%lu for column %d",
  3836                                     (unsigned long)numCharsInData, i);

Lines 4482-4490

  4482 SQLRETURN FetchBatchData(SQLHSTMT hStmt, ColumnBuffers& buffers, const Metadata& columnNames,
  4483                          py::list& rows, SQLUSMALLINT numCols, SQLULEN& numRowsFetched,
  4484                          const std::vector<SQLUSMALLINT>& lobColumns,
  4485                          const std::string& charEncoding = "utf-16le", int charCtype = SQL_C_WCHAR,
! 4486                          py::handle messages = {}, py::handle converters = {}) {
  4487     PERF_TIMER("FetchBatchData");
  4488     LOG("FetchBatchData: Fetching data in batches");
  4489     SQLRETURN ret;
  4490     {

mssql_python/pybind/output_converter.hpp

Lines 62-70

  62     for (Py_ssize_t i = 0; i < PyList_GET_SIZE(values.ptr()); ++i) {
  63         if (raw_text(PyList_GET_ITEM(values.ptr(), i))) {
  64             py::object value = original(py::handle(PyList_GET_ITEM(values.ptr(), i)));
  65             if (PyList_SetItem(values.ptr(), i, value.release().ptr()) < 0)
! 66                 throw py::error_already_set();
  67         }
  68     }
  69     return values;
  70 }

Lines 108-116

  108         if (!replaced && i < PyList_GET_SIZE(result.ptr()) &&
  109             raw_text(PyList_GET_ITEM(result.ptr(), i))) {
  110             py::object restored = original(py::handle(PyList_GET_ITEM(result.ptr(), i)));
  111             if (PyList_SetItem(result.ptr(), i, restored.release().ptr()) < 0)
! 112                 throw py::error_already_set();
  113         }
  114     }
  115     for (; i < PyList_GET_SIZE(result.ptr()); ++i) {
  116         if (raw_text(PyList_GET_ITEM(result.ptr(), i))) {

Lines 115-123

  115     for (; i < PyList_GET_SIZE(result.ptr()); ++i) {
  116         if (raw_text(PyList_GET_ITEM(result.ptr(), i))) {
  117             py::object restored = original(py::handle(PyList_GET_ITEM(result.ptr(), i)));
  118             if (PyList_SetItem(result.ptr(), i, restored.release().ptr()) < 0)
! 119                 throw py::error_already_set();
  120         }
  121     }
  122     return py::reinterpret_borrow<py::list>(result);
  123 }

Lines 201-211

  201             return false;
  202         PyObject* current_apply = PyDict_GetItemWithError(
  203             PyModule_GetDict(bindings_.ptr()), apply_name_.ptr());
  204         if (!current_apply) {
! 205             if (PyErr_Occurred()) throw py::error_already_set();
! 206             return false;
! 207         }
  208         if (current_apply != apply_.ptr()) return false;
  209         // Changing function.__code__ does not invalidate the owning type's tag.
  210         for (size_t i = 0; i < code_.size(); ++i) {
  211             if (code_[i] && PyFunction_GetCode(hooks_[i].ptr()) != code_[i].ptr())

Lines 240-254

  240         for (Py_ssize_t i = 0; i < PyList_GET_SIZE(rows_data.ptr()); ++i) {
  241             py::object values = borrow(PyList_GET_ITEM(rows_data.ptr(), i));
  242             PyObject* selected = PyDict_GetItemWithError(cursor_globals.ptr(), row_name_.ptr());
  243             if (!selected) {
! 244                 if (!PyErr_Occurred()) PyErr_SetString(PyExc_NameError, "name 'Row' is not defined");
! 245                 throw py::error_already_set();
! 246             }
  247             py::object row_class = borrow(selected);
  248             py::object names = cursor.attr("_cached_result_columns");
  249             if (!PyList_Check(values.ptr()))
! 250                 throw py::type_error("native converter rows must contain lists");
  251             result.append(construct(py::reinterpret_borrow<py::list>(values), row_class, converters,
  252                                     column_map, cursor, uuid_indices, lower_map, names));
  253         }
  254         return result;


📋 Files Needing Attention

📉 Files with overall lowest coverage (click to expand)
mssql_python.pybind.performance_counter.hpp: 0.7%
mssql_python.pybind.logger_bridge.cpp: 57.9%
mssql_python.pybind.ddbc_bindings.h: 65.1%
mssql_python.pybind.logger_bridge.hpp: 70.8%
mssql_python.pybind.ddbc_bindings.cpp: 80.1%
mssql_python.pybind.row_mapping_cache.hpp: 81.5%
mssql_python.pybind.connection.connection_pool.cpp: 81.8%
mssql_python.pybind.connection.connection.cpp: 82.5%
mssql_python.logging.py: 86.2%
mssql_python.pybind.row_mapping.hpp: 86.8%

🔗 Quick Links

⚙️ Build Summary 📋 Coverage Details

View Azure DevOps Build

Browse Full Coverage Report

@jahnvi480
Jahnvi Thakkar (jahnvi480) marked this pull request as ready for review October 19, 2026 05:17
@jahnvi480 Jahnvi Thakkar (jahnvi480) changed the title REFACTOR: Speed up output-converter batching and add CI coverage PERF: Speed up output-converter batching and add CI coverage Oct 19, 2026
Copilot AI balanced review requested due to automatic review settings November 4, 2026 06:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

The intertwined native allocation, ownership, and fetch changes require rebuilt-extension, native lifetime, and live SQL Server validation across supported Python/platform combinations, which could not be performed in this review environment.

No open issues 🎉

Review effort: Balanced

Preserve converter and row-to-dictionary profiler workloads and their contract tests; reconcile the combined 25-task registry.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI balanced review requested due to automatic review settings November 5, 2026 11:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

The intertwined native UTF-16 ownership, Row-constructor bypass, and hook invalidation changes require rebuilt-extension, live SQL Server, and cross-platform validation beyond the available source-only review.

No open issues 🎉

Review effort: Balanced

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr-size: large Substantial code update

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants