PERF: Optimize Row construction and repeated cursor bookkeeping - #558
Conversation
… on SUCCESS, __slots__ Row, and C++ Row construction - Cache decoding encoding strings in cursor __init__ to avoid 2 method calls + 2 dict.get() per fetch - Skip DDBCSQLGetAllDiagRecords on SQL_SUCCESS (ODBC spec: zero records on SUCCESS) - Replace param.encode('ascii') try/except with str.isascii() (C-level check) - Class-level _SQL_TO_C_TYPE lookup table (built once, shared across cursors) - Add __slots__ to Row class (eliminates per-instance __dict__, ~232 bytes/row savings) - Add Row._fast_create static method (bypasses __init__ for common case) - Add C++ construct_rows function (builds Row objects in tight C loop, avoiding Python loop overhead) - Zero-copy Row fast path when no converters/UUID processing needed Benchmark results (5-run average, richbench repeat=5 number=5): - Fetch one: -1.7x -> -1.4x (18% improvement) - Fetch many: -1.7x -> -1.3x (24% improvement) - 100 inserts: 4.9x -> 5.6x (14% faster) - SELECT: -1.1x -> -1.0x (on par with pyodbc) Profiler wall clock (50K rows): - fetchall: 176.7ms -> 158.1ms (11% faster) - fetchmany: 166.6ms -> 138.6ms (17% faster) No overlap with PR #549 (execute fast path) or PR #526 (simdutf).
📊 Code Coverage Report
Diff CoverageDiff: main...HEAD, staged and unstaged changes
Summary
mssql_python/pybind/ddbc_bindings.cppLines 3631-3641 3631 case SQL_SMALLINT: {
3632 SQLSMALLINT smallIntValue;
3633 SQLLEN indicator = 0;
3634 ret = SQLGetData_ptr(hStmt, i, SQL_C_SHORT, &smallIntValue, 0, &indicator);
! 3635 if (SQL_SUCCEEDED(ret) && indicator == SQL_NULL_DATA) {
! 3636 row.append(py::none());
! 3637 break;
3638 }
3639 if (SQL_SUCCEEDED(ret)) {
3640 row.append(static_cast<int>(smallIntValue));
3641 } else {Lines 3647-3660 3647 break;
3648 }
3649 case SQL_REAL: {
3650 SQLREAL realValue;
! 3651 SQLLEN indicator = 0;
! 3652 ret = SQLGetData_ptr(hStmt, i, SQL_C_FLOAT, &realValue, 0, &indicator);
! 3653 if (SQL_SUCCEEDED(ret) && indicator == SQL_NULL_DATA) {
3654 row.append(py::none());
3655 break;
! 3656 }
3657 if (SQL_SUCCEEDED(ret)) {
3658 row.append(realValue);
3659 } else {
3660 LOG("SQLGetData: Error retrieving SQL_REAL for column %d - "mssql_python/pybind/row_factory.hppLines 34-42 34
35 for (Py_ssize_t i = 0; i < n; ++i) {
36 py::object row = steal(row_type->tp_alloc(row_type, 0));
37 if (!row)
! 38 throw py::error_already_set();
39
40 PyObject* row_data = PyList_GET_ITEM(rows_data.ptr(), i);
41
42 if (PyObject_GenericSetAttr(row.ptr(), attr_values.ptr(), row_data) < 0 ||📋 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: 64.1%
mssql_python.pybind.logger_bridge.hpp: 70.8%
mssql_python.pybind.ddbc_bindings.cpp: 78.4%
mssql_python.pybind.connection.connection_pool.cpp: 82.3%
mssql_python.pybind.connection.connection.cpp: 82.5%
mssql_python.logging.py: 86.2%
mssql_python.pooling.py: 90.1%
mssql_python.pybind.py_type_cache.hpp: 91.6%🔗 Quick Links
|
Preserve late output converter fallback behavior and UUID conversion while retaining the no-converter fast path. Add regression and cache operation-count coverage for all fetch APIs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
Fetch diagnostics, public Row compatibility, and C++ input validation require fixes.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR optimizes fetch performance through cached settings, faster Row construction, and C++ bulk row creation.
Changes:
- Adds cache invalidation and optimized fetch paths.
- Introduces slotted rows and fast construction.
- Adds C++ bulk row construction and regression tests.
File summaries
| File | Summary |
|---|---|
tests/test_fetch_settings_cache.py |
Tests cache behavior and fast paths. |
mssql_python/row.py |
Adds slots and optimized row creation. |
mssql_python/pybind/ddbc_bindings.cpp |
Implements C++ row construction. |
mssql_python/cursor.py |
Uses cached settings and optimized fetch paths. |
mssql_python/connection.py |
Tracks configuration generations. |
Review details
Suppressed comments (5)
mssql_python/cursor.py:2571
DDBCSQLFetchManyreturns a negative SQLRETURN for binding/fetch failures, but this method does not otherwise callcheck_error; this new gate skips the diagnostics and the code then proceeds to construct rows from whatever partial data is present. Check the return code before row-count and row construction, while keeping theSQL_SUCCESS_WITH_INFOdiagnostic path.
if ret == ddbc_sql_const.SQL_SUCCESS_WITH_INFO.value and self.hstmt:
self.messages.extend(ddbc_bindings.DDBCSQLGetAllDiagRecords(self.hstmt))
mssql_python/cursor.py:2500
- This guard can hide diagnostics returned by SQLFetch: the DDBCSQLFetchOne bridge overwrites the SQLFetch return code with SQLGetData's final status, so SQL_SUCCESS_WITH_INFO from the fetch (or an earlier column) is no longer observable here. Before this change the unconditional call preserved those records. Please propagate an info flag through the bridge and retrieve diagnostics when either operation reports it.
if ret == ddbc_sql_const.SQL_SUCCESS_WITH_INFO.value and self.hstmt:
mssql_python/cursor.py:2570
- For the LOB path, FetchMany_wrap returns SQL_SUCCESS even when its per-row SQLFetch or SQLGetData calls returned SQL_SUCCESS_WITH_INFO, so this guard skips their diagnostic records. Please make the bridge preserve an aggregate info status (or otherwise signal it) before gating DDBCSQLGetAllDiagRecords.
if ret == ddbc_sql_const.SQL_SUCCESS_WITH_INFO.value and self.hstmt:
mssql_python/cursor.py:2636
- FetchAll_wrap loops until SQL_NO_DATA and returns that final status, even if an earlier FetchBatchData call returned SQL_SUCCESS_WITH_INFO; its LOB path also returns SQL_SUCCESS after discarding intermediate statuses. Consequently this condition is false for most fetchall warnings and regresses cursor.messages. Please preserve an aggregate info status in FetchAll_wrap and use it here.
if ret == ddbc_sql_const.SQL_SUCCESS_WITH_INFO.value and self.hstmt:
mssql_python/row.py:32
Rowis exported asmssql_python.Row, so adding__slots__removes observable existing behavior: callers can no longer assign non-column attributes, inspectrow.__dict__, or weak-reference rows. Preserve the prior instance API (which reduces the memory win) or explicitly treat this as a breaking public API change rather than silently applying it to every fetched row.
__slots__ = ("_values", "_column_map", "_cursor")
- Files reviewed: 5/5 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Resolve fetch and Row conflicts while retaining current-main converter dispatch, lowercase column maps, profiling scopes, and CHAR decoding ctype. Extend fetch fast paths and regression coverage for the merged behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
Moderate issues remain in diagnostic preservation, converter fast-path handling, and native argument validation.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (4)
mssql_python/cursor.py:2889
- The LOB branch of
DDBCSQLFetchManyreturnsSQL_SUCCESSafter its loop even when an individualSQLFetchreturnedSQL_SUCCESS_WITH_INFO. Therefore this new condition never drains diagnostics for warnings encountered while fetching LOB rows. The native bridge needs to propagate or drain an intermediate info status before this optimization can safely replace the unconditional drain.
if ret == ddbc_sql_const.SQL_SUCCESS_WITH_INFO.value and self.hstmt:
self.messages.extend(ddbc_bindings.DDBCSQLGetAllDiagRecords(self.hstmt))
mssql_python/cursor.py:2961
DDBCSQLFetchAlldoes not expose the status of its internal fetches: the batch loop ends withSQL_NO_DATA, while its LOB path explicitly returnsSQL_SUCCESS. Thus anSQL_SUCCESS_WITH_INFOfrom an earlier batch/row is not visible here and its diagnostic records are skipped, unlike the old unconditional drain. Aggregate/propagate or drain diagnostics inside the native bridge before gating this call on the final return code.
if ret == ddbc_sql_const.SQL_SUCCESS_WITH_INFO.value and self.hstmt:
self.messages.extend(ddbc_bindings.DDBCSQLGetAllDiagRecords(self.hstmt))
mssql_python/cursor.py:1416
- When any output converter is registered but none matches this result set, this returns a truthy list containing only
Nonevalues. The new fast-path checks therefore fail in all three fetch methods, andRow._apply_output_converters_optimizedcopies every row even though no conversion is needed. ReturnNone(or otherwise record whether the map contains a converter) for an all-Nonemap so unrelated converters do not impose per-row overhead.
self._cached_converters_generation = generation
return converter_map
mssql_python/pybind/ddbc_bindings.cpp:6054
- This newly exposed native function casts any Python object to
PyTypeObject*and dereferences it; for example,construct_rows([[1]], None, {}, None)can dereference a null pointer and crash the interpreter instead of raising a Python exception. ValidatePyType_Check(row_class.ptr())before the cast.
PyTypeObject* row_type = reinterpret_cast<PyTypeObject*>(row_class.ptr());
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Lite
Validate the native row type argument, restore unconditional diagnostic retrieval, raise fetch errors before row processing, and retain fast paths when registered converters do not match. Add isolated crash and fetch contract regressions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
A critical SQL_SS_VARIANT converter-dispatch issue and a moderate Row API compatibility issue remain unresolved.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (1)
mssql_python/row.py:31
Rowis exported as a public class (mssql_python/__init__.py:68,357) and previously had normal instance semantics. Adding slots here removesrow.__dict__/arbitrary attribute assignment and weak-reference support for every fetched row, so consumers that attach metadata or useweakref.ref(row)now fail withAttributeError/TypeError. Please preserve those capabilities (for example, by including__dict__and__weakref__) or explicitly document and version this as a breaking API change rather than an optimization-only change.
__slots__ = ("_values", "_column_map", "_cursor", "_column_map_lower")
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Lite
Preserve scalar SQL NULL values without suppressing fetch errors. Cover fixed-width types, LOB and bound fetch paths, literal NULL and OBJECT_ID results, and cursor recovery. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🔵 Needs a closer look
Native bindings, caching, conversion, and diagnostics are affected, and the full suite and cross-platform matrix were not run.
Review details
- Files reviewed: 5/5 changed files
- Comments generated: 0 new
- Review effort level: Lite
PR Performance ReportThis PR has 4 consistent improvement signals across 2 database tasks and 2 environments.
Coverage: 2 of 2 environments completed. Advisory result; does not block merging.
Affected phases and call countsPhase times are inclusive diagnostics and must not be added together. They identify where measured time changed, not why it changed. Unix / SQL Server 2022Insertion with explicit input sizes: py::execute::cpp_call -30.720 ms; ddbc::SQLExecute_wrap -30.082 ms; ddbc::BindParameters -10.304 ms. Unix / SQL Server 2025Insertion with explicit input sizes: py::execute::cpp_call -17.528 ms; ddbc::SQLExecute_wrap -16.864 ms; ddbc::BindParameters -7.866 ms. All database tasks and timingsUnix / SQL Server 2022
Unix / SQL Server 2025
Build, commits and measurement detailsPR head:
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 |
There was a problem hiding this comment.
🔵 Needs a closer look
Row.__slots__ may break public attribute and weak-reference compatibility; this should be addressed before approval.
Review details
Suppressed comments (1)
mssql_python/row.py:31
Rowis exported asmssql_python.Row(mssql_python/__init__.py:67-68), so adding__slots__changes the public object contract: instances no longer accept user attributes and are no longer weak-referenceable. Existing callers that attach metadata (row.foo = ...) or useweakref.ref(row)will now fail even though column access is unchanged. Preserve compatibility (for example by retaining__dict__and__weakref__, with the corresponding memory trade-off) or explicitly treat this as a documented breaking API change.
__slots__ = ("_values", "_column_map", "_cursor", "_column_map_lower")
- Files reviewed: 5/5 changed files
- Comments generated: 0 new
- Review effort level: Lite
Value-gate cached string fallbacks while preserving explicit converter precedence. Restrict native allocation to Row and its subclasses. Preserve dynamic Row attributes and weak references, with live and subprocess regression coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Move Row construction into row_factory.hpp, leaving the Python registration in ddbc_bindings.cpp. Adopt allocated rows with steal() and release ownership into the result list while retaining borrowed input pointers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Sumit Sarabhai (sumitmsft)
left a comment
There was a problem hiding this comment.
PR #558: Both previously reported findings are addressed. Late string-converter registration preserves non-string variant values and explicit converter precedence. Native Row construction rejects incompatible types before allocation and handles reference ownership and failure cleanup safely.
No remaining actionable findings in the reviewed changes.
Preserve main's row factory and temporal NULL indicators alongside the six checked temporal construction sites. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Work Item / Issue Reference
Summary
Reduce Python-side overhead in
fetchone(),fetchmany(), andfetchall()while preserving decoding, converter, diagnostic, and Row-access behavior. The branch incorporates main throughe279a4f6and includes the review fixes in56c3d3b5.Fetch optimizations
fetchmany()/fetchall()rows in nativeconstruct_rows, bypassing the Python per-row initialization loop. UseRow._fast_create()for the correspondingfetchone()path.Row.__slots__and retain zero-copy storage of fetched values when no conversion is required. Initialize the shared lowercase column map on both Python and native construction paths.str.isascii()for Unicode detection.Correctness and compatibility
execute()updates subsequent fetches without rebuilding mappings on unchanged cache hits.Rowconstruction's connection-converter fallback when no precomputed mapping is supplied, including UUID stringification.row_classwithPyType_Checkbefore casting in the Python-callable native helper. Invalid arguments raiseTypeErrorrather than crashing the interpreter.SQL_SUCCESS_WITH_INFOis unsafe. The earlier diagnostic-skip optimization has been removed; this restores prior behavior rather than claiming to repair pre-existing native diagnostic-record overwrites.fetchone()andfetchmany()before updating row positions or constructing rows, consistent withfetchall().Performance: current main versus this PR
fetchmany(1000)fetchall()fetchmany(1000)fetchall()Positive reduction means less time, calculated from unrounded medians. Narrow/wide batch cases improved in 10/10 and 9/10 pairs respectively.
fetchmany(1)medians were only 1.15-1.80% lower, with narrow results inconclusive. Narrowfetchone()was 5.02% lower; widefetchone().These are workload-specific results, not a universal or cross-platform speedup. Full results for all 11 fetch cases, control, dispersion, paired intervals, build identities, and raw repetitions are retained locally; older pre-merge numbers are not mixed in.