PERF: Native C++ parameter detection and execute pipeline - #549
PERF: Native C++ parameter detection and execute pipeline#549Gaurav Sharma (bewithgaurav) wants to merge 45 commits into
Conversation
Move parameter type detection from Python into C++ using raw CPython type checks (PyLong_CheckExact, PyFloat_CheckExact, etc.). Merge the DetectParamTypes → BindParameters → SQLExecute pipeline into a single DDBCSQLExecuteFast call so ParamInfo never crosses the pybind11 boundary. - DetectParamTypes: handles int (range-detected), float, bool, str (unicode + geometry sniffing), bytes, datetime/date/time, Decimal (MONEY range + generic numeric), UUID, None, with fallback to string - SQLExecuteFast_wrap: single pipeline with GIL release, always uses SQLPrepare for parameterized queries - cursor.py: fast path routing when no setinputsizes overrides present; old DDBCSQLExecute path preserved for setinputsizes callers - Named constants: MAX_INLINE_CHAR, MAX_INLINE_BINARY, MAX_NUMERIC_PRECISION, MONEY/SMALLMONEY ranges, PARAM_C_TYPE_TEXT platform macro
- Add complete DAE (Data-At-Execution) loop to SQLExecuteFast_wrap: SQL_NEED_DATA → SQLParamData/SQLPutData for large str/bytes/binary, matching the existing SQLExecute_wrap logic exactly - Fix DAE type assignment: non-unicode DAE strings use SQL_C_CHAR (not PARAM_C_TYPE_TEXT which maps to SQL_C_WCHAR on macOS/Linux) - Fix MONEY range lower bound: use MONEY_MIN not SMALLMONEY_MIN so negative decimals in MONEY range bind as VARCHAR (matches Python path) - Raise TypeError for unknown param types instead of silent str conversion - Add SQLFreeStmt(SQL_RESET_PARAMS) to unbind after execute
📊 Code Coverage Report
Diff CoverageDiff: main...HEAD, staged and unstaged changes
Summary
mssql_python/pybind/ddbc_bindings.cppLines 243-251 243 SQLDescribeParamFunc SQLDescribeParam_ptr = nullptr;
244
245 namespace {
246
! 247
248 const char* GetSqlCTypeAsString(const SQLSMALLINT cType) {
249 switch (cType) {
250 STRINGIFY_FOR_CASE(SQL_C_CHAR);
251 STRINGIFY_FOR_CASE(SQL_C_WCHAR);Lines 572-581 572 dataPtr = sqlwcharBuffer->data();
573 bufferLength = sqlwcharBuffer->size() * sizeof(SQLWCHAR);
574 strLenOrIndPtr = AllocateParamBuffer<SQLLEN>(paramBuffers);
575 // Use explicit byte length instead of SQL_NTS so embedded NUL chars
! 576 // aren't treated as string terminators.
! 577 *strLenOrIndPtr = static_cast<SQLLEN>(sqlwcharBuffer->size() * sizeof(SQLWCHAR));
578 }
579 break;
580 }
581 case SQL_C_BIT: {Lines 714-722 714 dataPtr = static_cast<void*>(sqlTimePtr);
715 break;
716 }
717 case SQL_C_SS_TIMESTAMPOFFSET: {
! 718 py::object datetimeType = PyTypeCache::get_datetime_class_obj();
719 if (!py::isinstance(param, datetimeType)) {
720 ThrowStdException(MakeParamMismatchErrorStr(paramInfo.paramCType, paramIndex));
721 }
722 // Checking if the object has a timezoneLines 1817-1826 1817
1818 // LEGACY — slated for removal in a future optimization round.
1819 //
1820 // Executes the provided query using a ParamInfo list that Python already built,
! 1821 // rather than detecting parameter types in C++. Retained only for setinputsizes()
! 1822 // callers, whose explicit type overrides the native path does not yet honour.
1823 // Every parameter crosses the pybind11 boundary as a ParamInfo object here, which
1824 // is the cost SQLExecute_wrap exists to avoid. Once setinputsizes is handled
1825 // natively this function and its DDBCSQLExecuteLegacy binding both go away.
1826 //Lines 1961-1970 1961 if (matchedInfo->paramCType == SQL_C_WCHAR) {
1962 std::u16string utf16 =
1963 borrow<py::str>(pyObj).cast<std::u16string>();
1964 rc = stream_dae_chunks(
! 1965 reinterpretU16stringAsSqlWChar(utf16),
! 1966 utf16.size() * sizeof(SQLWCHAR),
1967 putData);
1968 if (!SQL_SUCCEEDED(rc)) {
1969 LOG("SQLExecute: SQLPutData failed for SQL_C_WCHAR DAE streaming");
1970 return rc;Lines 2056-2071 2056 if (!statementHandle || !statementHandle->get()) {
2057 return SQL_INVALID_HANDLE;
2058 }
2059
! 2060 SQLHANDLE hStmt = statementHandle->get();
! 2061
2062 // Configure forward-only / read-only cursor (matches slow path semantics).
2063 if (SQLSetStmtAttr_ptr) {
! 2064 SQLSetStmtAttr_ptr(hStmt, SQL_ATTR_CURSOR_TYPE,
! 2065 (SQLPOINTER)SQL_CURSOR_FORWARD_ONLY, 0);
! 2066 SQLSetStmtAttr_ptr(hStmt, SQL_ATTR_CONCURRENCY,
! 2067 (SQLPOINTER)SQL_CONCUR_READ_ONLY, 0);
2068 }
2069
2070 // The encoding-settings dict has the form {"encoding": str, "ctype": int}.
2071 // Note: the Python layer's SQL_C_CHAR constant is numerically -8, the sameLines 2075-2091 2075 // real ODBC SQL_CHAR). We default to utf-8 and only honor the dict's
2076 // encoding when ctype == 1 (real ODBC SQL_CHAR). Otherwise the user's
2077 // "encoding" value is meant for the wide-char path and we leave it alone.
2078 std::string charEncoding = "utf-8";
! 2079 if (encoding_settings.contains("ctype") && encoding_settings.contains("encoding")) {
! 2080 int ctype = encoding_settings["ctype"].cast<int>();
! 2081 if (ctype == SQL_C_CHAR /* real ODBC value: 1 */) {
! 2082 charEncoding = encoding_settings["encoding"].cast<std::string>();
! 2083 }
! 2084 }
! 2085
! 2086 // The cursor.py caller always passes a fresh `list(actual_params)` so this
! 2087 // function is free to mutate slots in place. Even so, every site below uses
2088 // PyList_SetItem (which decrefs the old slot before stealing the new ref),
2089 // so the function is safe regardless of who owns the list.
2090
2091 // Run DetectParamTypes BEFORE SQLPrepare so that type-detection errorsLines 2106-2115 2106 {
2107 py::gil_scoped_release release;
2108 rc = SQLPrepare_ptr(hStmt, queryPtr, SQL_NTS);
2109 }
! 2110 if (!SQL_SUCCEEDED(rc)) return rc;
! 2111 statementHandle->clearDescribeCache();
2112 is_stmt_prepared[0] = py::bool_(true);
2113 } else {
2114 ThrowStdException("Cannot execute unprepared statement");
2115 }Lines 2137-2147 2137 while (true) {
2138 {
2139 py::gil_scoped_release release;
2140 rc = SQLParamData_ptr(hStmt, ¶mToken);
! 2141 }
! 2142 if (rc != SQL_NEED_DATA) break;
! 2143
2144 const ParamInfo* matchedInfo = nullptr;
2145 for (auto& info : paramInfos) {
2146 if (reinterpret_cast<SQLPOINTER>(const_cast<ParamInfo*>(&info)) == paramToken) {
2147 matchedInfo = &info;Lines 2160-2168 2160
2161 if (PyUnicode_Check(pyObj)) {
2162 if (matchedInfo->paramCType == SQL_C_WCHAR) {
2163 std::u16string u16 =
! 2164 borrow<py::str>(pyObj).cast<std::u16string>();
2165 rc = stream_dae_chunks(
2166 reinterpretU16stringAsSqlWChar(u16),
2167 u16.size() * sizeof(SQLWCHAR),
2168 putData);Lines 2177-2185 2177 } else {
2178 ThrowStdException("SQLExecute: unsupported C type for str in DAE");
2179 }
2180 } else if (PyBytes_Check(pyObj) || PyByteArray_Check(pyObj)) {
! 2181 // Handle bytes and bytearray separately — pybind11's bytes
2182 // caster does not safely convert bytearray.
2183 const char* dataPtr = nullptr;
2184 size_t totalBytes = 0;
2185 std::string bytesStorage; // lifetime must span the loopLines 2193-2204 2193 bytesStorage.assign(PyByteArray_AS_STRING(pyObj),
2194 static_cast<size_t>(PyByteArray_GET_SIZE(pyObj)));
2195 dataPtr = bytesStorage.data();
2196 totalBytes = bytesStorage.size();
! 2197 }
! 2198
! 2199 rc = stream_dae_chunks(dataPtr, totalBytes, putData);
! 2200 if (!SQL_SUCCEEDED(rc)) return rc;
2201 } else {
2202 ThrowStdException("SQLExecute: DAE only supported for str or bytes");
2203 }
2204 }Lines 2206-2214 2206 }
2207
2208 if (!SQL_SUCCEEDED(rc) && rc != SQL_NO_DATA) return rc;
2209
! 2210 // Unbind parameter buffers before they go out of scope.
2211 // Not called on error paths — diagnostics must remain readable.
2212 SQLRETURN exec_rc = rc;
2213 SQLFreeStmt_ptr(hStmt, SQL_RESET_PARAMS);
2214 return exec_rc;Lines 2625-2633 2625 DateTimeOffset* dtoArray =
2626 AllocateParamBufferArray<DateTimeOffset>(tempBuffers, paramSetSize);
2627 strLenOrIndArray = AllocateParamBufferArray<SQLLEN>(tempBuffers, paramSetSize);
2628
! 2629 py::object datetimeType = PyTypeCache::get_datetime_class_obj();
2630
2631 for (size_t i = 0; i < paramSetSize; ++i) {
2632 const py::handle& param = columnValues[i];Lines 2740-2748 2740
2741 // Get cached UUID class from module-level helper
2742 // This avoids static object destruction issues during
2743 // Python finalization
! 2744 py::object uuid_class = PyTypeCache::get_uuid_class_obj();
2745 // Get cached UUID class
2746
2747 for (size_t i = 0; i < paramSetSize; ++i) {
2748 const py::handle& element = columnValues[i];Lines 4426-4434 4426 break;
4427 }
4428 case SQL_TYPE_DATE: {
4429 PyObject* dateObj =
! 4430 PyTypeCache::get_date_class_obj()(buffers.dateBuffers[col - 1][i].year,
4431 buffers.dateBuffers[col - 1][i].month,
4432 buffers.dateBuffers[col - 1][i].day)
4433 .release()
4434 .ptr();mssql_python/pybind/param_detect.hppLines 378-387 378 // so calling the same method is what keeps the two paths in agreement.
379 py::object time_obj = steal(PyObject_CallMethod(obj, "isoformat", "s", "microseconds"));
380 if (!time_obj) throw py::error_already_set();
381 if (!PyUnicode_Check(time_obj.ptr())) {
! 382 throw py::type_error("datetime.time.isoformat() must return a str");
! 383 }
384 Py_ssize_t time_len = PyUnicode_GET_LENGTH(time_obj.ptr());
385 info.columnSize = std::max<SQLULEN>(info.columnSize, time_len);
386 // PyList_SetItem (lowercase) decrefs the old slot before stealing the new
387 // reference; safe here because cursor.py already passed a fresh list copy.Lines 385-394 385 info.columnSize = std::max<SQLULEN>(info.columnSize, time_len);
386 // PyList_SetItem (lowercase) decrefs the old slot before stealing the new
387 // reference; safe here because cursor.py already passed a fresh list copy.
388 if (PyList_SetItem(params, i, time_obj.release().ptr()) != 0) {
! 389 throw py::error_already_set();
! 390 }
391 continue;
392 }
393
394 // --- Decimal ---Lines 410-419 410 py::object digits_obj = steal(PyObject_GetAttrString(as_tuple_ptr.ptr(), "digits"));
411 if (!digits_obj) throw py::error_already_set();
412
413 if (!PyTuple_Check(digits_obj.ptr())) {
! 414 throw py::type_error("Decimal.as_tuple().digits must be a tuple");
! 415 }
416
417 Py_ssize_t num_digits = PyTuple_GET_SIZE(digits_obj.ptr());
418
419 // Read the exponent at full width and range-check it BEFORE narrowing to int.Lines 423-436 423 // any precision, so treat overflow as precision overflow rather than propagating
424 // OverflowError, matching what the legacy Python path reports.
425 long long exponent_ll = PyLong_AsLongLong(exponent_obj.ptr());
426 if (exponent_ll == -1 && PyErr_Occurred()) {
! 427 PyErr_Clear();
! 428 throw py::value_error(
! 429 "Precision of the numeric value is too high. "
! 430 "The maximum precision supported by SQL Server is " +
! 431 std::to_string(MAX_NUMERIC_PRECISION) + ".");
! 432 }
433 // Bound before any arithmetic or negation. MAX_NUMERIC_PRECISION on both sides is
434 // wider than anything bindable, and keeps -exponent well clear of INT_MIN, whose
435 // negation would be signed-overflow UB.
436 if (exponent_ll > MAX_NUMERIC_PRECISION || exponent_ll < -MAX_NUMERIC_PRECISION) {Lines 461-474 461 else
462 precision = -exponent;
463
464 if (precision > MAX_NUMERIC_PRECISION) {
! 465 throw py::value_error(
! 466 "Precision of the numeric value is too high. "
! 467 "The maximum precision supported by SQL Server is " +
! 468 std::to_string(MAX_NUMERIC_PRECISION) + ", but got " +
! 469 std::to_string(precision) + ".");
! 470 }
471
472 // Check SMALLMONEY first, then widen to MONEY, so common small values keep the narrowest
473 // exact range while still accepting larger fixed-point values supported by SQL Server.
474 // MONEY/SMALLMONEY: SQL Server stores these as fixed-point integers internally.Lines 500-509 500 PyObject* raw = formatted.release().ptr();
501 if (PyList_SetItem(params, i, raw) != 0) {
502 // PyList_SetItem steals (decrefs) the item even on failure,
503 // so raw is already freed — do NOT Py_DECREF here.
! 504 throw py::error_already_set();
! 505 }
506 continue;
507 }
508
509 // Build SQL_NUMERIC_STRUCT from the Decimal object. Store as a pybind11-castableLines 517-526 517 py::object numeric_obj = py::cast(nd);
518 PyObject* raw = numeric_obj.release().ptr();
519 if (PyList_SetItem(params, i, raw) != 0) {
520 // PyList_SetItem steals (decrefs) the item even on failure.
! 521 throw py::error_already_set();
! 522 }
523 continue;
524 }
525
526 // --- UUID ---Lines 534-543 534 info.columnSize = 16;
535 info.decimalDigits = 0;
536 if (PyList_SetItem(params, i, bytes_le) != 0) {
537 // PyList_SetItem steals (decrefs) the item even on failure.
! 538 throw py::error_already_set();
! 539 }
540 continue;
541 }
542
543 // --- Unknown type: raise TypeError (matches Python _map_sql_type) ---Lines 566-575 566 int sign_val = static_cast<int>(PyLong_AsLong(sign_obj.ptr()));
567 if (sign_val == -1 && PyErr_Occurred()) throw py::error_already_set();
568
569 if (!PyTuple_Check(digits)) {
! 570 throw py::type_error("Decimal.as_tuple().digits must be a tuple");
! 571 }
572
573 // SQL Server precision counts all stored decimal digits, while scale is just the
574 // fractional digits. A positive exponent moves trailing zeros into the integer part;
575 // a negative exponent means scale = -exponent and precision must still cover leadingLines 612-621 612 for (int j = 0; j < exponent; ++j) {
613 overflow |= mul10_add(0);
614 }
615 if (overflow != 0) {
! 616 throw py::value_error("Decimal magnitude exceeds the 16-byte SQL NUMERIC capacity");
! 617 }
618
619 NumericData nd;
620 nd.precision = static_cast<SQLCHAR>(precision);
621 nd.scale = static_cast<SQLSCHAR>(scale);mssql_python/pybind/py_type_cache.hppLines 44-55 44 // type detection in Python and can therefore reach here without the cache being warm;
45 // it can be dropped once that path is removed.
46 inline PyObject* get_cached_class(PyObject* cached, const char* module_name, const char* attr_name) {
47 if (cache_initialized && cached) return cached;
! 48 py::object mod = steal(PyImport_ImportModule(module_name));
! 49 if (!mod) return nullptr;
! 50 return PyObject_GetAttrString(mod.ptr(), attr_name);
! 51 }
52
53 // One-time init. Uses local py::objects so exception cleanup is automatic;
54 // only .release() into globals after ALL acquisitions succeed.
55 inline void initialize() {📋 Files Needing Attention📉 Files with overall lowest coverage (click to expand)mssql_python.pybind.logger_bridge.cpp: 59.2%
mssql_python.pybind.ddbc_bindings.h: 61.5%
mssql_python.pybind.logger_bridge.hpp: 70.8%
mssql_python.pybind.ddbc_bindings.cpp: 75.4%
mssql_python.__init__.py: 77.6%
mssql_python.row.py: 77.6%
mssql_python.ddbc_bindings.py: 79.6%
mssql_python.pybind.connection.connection_pool.cpp: 81.4%
mssql_python.pybind.connection.connection.cpp: 84.3%
mssql_python.logging.py: 85.5%🔗 Quick Links
|
- Comment out use_prepare parameter name (C4100: unreferenced parameter) - Remove unused catch variable name (C4101: unreferenced local variable)
Add explicit null pointer and zero-length guards before memcpy in build_numeric_data to satisfy DevSkim code scanning rule DS121708.
…or attrs, parity test Six review fixes for SQLExecuteFast_wrap and DetectParamTypes: 1. Encoding key: read 'encoding' from settings dict (was 'charEncoding' which never matched). Only honor when ctype==SQL_C_CHAR so the default utf-16le doesn't corrupt SQL_C_CHAR DAE/inline byte paths. 2. Subclass support: PyLong_Check/PyFloat_Check/PyUnicode_Check/PyBytes_Check instead of *_CheckExact. Fixes user-defined int/str/bytes/float subclasses that were silently rejected with TypeError. Switched PyBytes_GET_SIZE to PyBytes_Size for subclass-safe length. 3. GIL release in DAE loop: SQLParamData and SQLPutData now release the GIL during each ODBC call, matching slow-path concurrency for large blobs/strings. 4. Preserve exec_rc: stash the SQLExecute return code before SQLFreeStmt so SUCCESS_WITH_INFO and other non-success-non-error codes are not clobbered by the unbind call. 5. Shallow-copy params: params = py::list(params) at function entry so DetectParamTypes' in-place PyList_SET_ITEM cannot mutate the caller's list under any future code path that might pass it directly. 6. Cursor attrs: SQLSetStmtAttr(SQL_ATTR_CURSOR_TYPE/CONCURRENCY) at entry to match slow-path semantics regardless of prior hstmt state. Also adds tests/test_023_fast_path_parity.py covering int/str/bytes/float subclasses, caller-list non-mutation, and unsupported-type TypeError.
Eight follow-up fixes after review feedback on c5a827f. 1. Refcount leak (BLOCKER): replace PyList_SET_ITEM (uppercase, no decref of old slot) with PyList_SetItem (decrefs old slot before stealing the new reference) in DetectParamTypes time/Decimal/UUID branches. The previous shallow-copy defense via py::list(params) was a no-op because pybind11s list constructor only inc_refs an already-list argument. 2. Geometry + DAE conflict: gate the geometry-prefix override on the not-DAE branch so a long POLYGON/POINT/LINESTRING string does not end up with isDAE=true, dataPtr set, AND a non-zero columnSize. 3. Decimal NaN/Infinity: throw ValueError instead of silently binding 0 via build_numeric_data on an empty digits tuple. 4. Time format: always emit microseconds (HH:MM:SS.ffffff), matching slow path isoformat(timespec=microseconds). 5. PyObject_IsInstance: explicit equality check so a custom __instancecheck__ that raises (returns -1) does not fall through with a Python error set. 6. Dead code: removed unused SMALLMONEY_MIN/SMALLMONEY_MAX constants and the unused utf16Len assignments in DetectParamTypes. 7. Encoding-key contract: only honor encoding_settings encoding when the user explicitly opted in via setencoding(..., ctype=SQL_C_CHAR=1). The Python layer SQL_C_CHAR constant is numerically -8 (real ODBC SQL_C_WCHAR), so by default the wide-char path is taken and encoding is irrelevant. 8. Parity test rewrite: drop the dead _force_slow_path_roundtrip helper, use the project cursor fixture instead of a hard-coded conn string, and add (a) a real fast-vs-slow parity check via setinputsizes-forced slow path, (b) a refcount-leak regression test using a Decimal subclass + weakref, (c) explicit NaN-rejection coverage.
Resolve conflicts in ddbc_bindings.cpp from main's GH-610 work: - Keep both build_numeric_data (this PR) and ResolveNullParamType (main) - Adopt main's BindParameters/BindParameterArray signatures that take SqlHandle& handle; update the SQLExecuteFast_wrap call site to pass *statementHandle so the fast path uses the per-handle NULL describe cache - Migrate SQLExecuteFast_wrap from std::wstring + WStringToSQLWCHAR to std::u16string + reinterpretU16stringAsSqlWChar (main's uniform 16-bit query/param representation), dropping the platform #ifdef in both the prepare path and the DAE wide-char put-data loop Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Honor use_prepare flag (was silently ignored, always preparing) - Move DetectParamTypes before SQLPrepare to prevent half-prepared state - Fix bytearray DAE crash (pybind11 bytes caster doesn't handle bytearray) - Replace lossy double MONEY comparison with exact Decimal arithmetic - Add SMALLMONEY range detection (was missing from fast path) - Handle PyObject_IsInstance error return (-1) with proper exception propagation - Clear describe cache on prepare (matching slow path) - Add edge case tests: large bytearray/bytes/string DAE, MONEY boundaries, Infinity rejection, embedded nulls Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ny-perf-detect-types
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Replace pybind11 .attr()/.cast<>() with raw CPython calls throughout
DetectParamTypes and build_numeric_data
- datetime/date/time: use PyDateTime_Check/PyDate_Check/PyTime_Check macros
and PyDateTime_TIME_GET_* accessors (requires PyDateTime_IMPORT)
- Decimal: PyObject_CallMethod/GetAttrString/RichCompareBool instead of
py::module_::import + py::object .attr() chains
- UUID: PyObject_GetAttrString("bytes_le") instead of py::handle .attr()
- Cache MONEY/SMALLMONEY Decimal bounds in PythonObjectCache (constructed
once at init, not per-call) using cached Python-side constants
- Replace magic int range numbers with UINT8_MAX/INT16_MIN/MAX/INT32_MIN/MAX
- Proper Py_DECREF cleanup on all error paths in build_numeric_data
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ecuteLegacy The new C++ pipeline is the primary path (99% of calls). The old function is the legacy fallback for setinputsizes users only. Naming should reflect this. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Removes unnecessary pybind11 ↔ CPython round-trips in the hot path: - PythonObjectCache types stored as PyObject* (not py::object) - ParamInfo::dataPtr is raw PyObject* with explicit refcount management - DetectParamTypes takes PyObject* directly (not py::list&) - build_numeric_data returns NumericData struct (not py::object) - Added contextual comments explaining non-obvious design decisions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ility pybind11's type_caster needs copy semantics for std::vector<ParamInfo>& in the legacy path. Provide a copy ctor that Py_XINCREFs dataPtr. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Strings with embedded NUL characters (e.g., 'hello\x00world') were truncated at the first NUL because BindParameters used SQL_NTS (null-terminated string indicator). Now passes the actual byte/char length so ODBC sees the full string. Fixes test_string_with_embedded_nulls on all platforms. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add tests: integer overflow (2**63), Decimal NaN/sNaN, precision > 38 - Add LCOV_EXCL markers on CPython import-failure and cache-fallback paths - Add contextual comments on PythonObjectCache and ParamInfo operators Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR shifts the primary cursor.execute() pipeline from Python into native C++ by adding a raw-CPython DetectParamTypes fast path and routing calls to a single DDBCSQLExecute FFI entrypoint when setinputsizes isn’t active, targeting large parameter-count performance regressions (GH-500).
Changes:
- Added a native
DetectParamTypes → BindParameters → SQLExecutepipeline (DDBCSQLExecute) to avoid per-parameter Python/pybind11 overhead. - Preserved a legacy path (
DDBCSQLExecuteLegacy) forsetinputsizesusers and updated Python routing accordingly. - Added parity and regression tests covering fast/slow path equivalence, subclass handling, DAE streaming, and refcount safety.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| tests/test_023_fast_path_parity.py | Adds fast-vs-legacy parity tests and regression coverage for the new native execute pipeline. |
| tests/test_010_pybind_functions.py | Updates exposed-function expectations to include DDBCSQLExecuteLegacy. |
| mssql_python/pybind/ddbc_bindings.cpp | Implements native type detection, new execute entrypoints, and various binding/DAE handling updates. |
| mssql_python/cursor.py | Routes execute() to DDBCSQLExecute for the primary path and to DDBCSQLExecuteLegacy when setinputsizes overrides are present. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…E safety - Check PyList_SetItem return value at all 4 call sites in DetectParamTypes - Copy mutable bytearray into std::string before DAE streaming (both paths) - Revert LCOV_EXCL markers (not processed by llvm-cov pipeline) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add PyTuple_Check guard before PyTuple_GET_SIZE/GET_ITEM on Decimal.as_tuple().digits in DetectParamTypes and build_numeric_data - Add ParamResetGuard RAII struct to ensure SQLFreeStmt(SQL_RESET_PARAMS) fires on all exit paths after BindParameters succeeds - Wrap PythonObjectCache::initialize() in try/catch to clean up partial refs on any import failure Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ParamResetGuard called SQLFreeStmt(SQL_RESET_PARAMS) in its destructor before the caller could read SQLGetDiagRec, producing empty SQLSTATEs. Restore manual SQLFreeStmt on success-only paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…_class, import_attr - PyObjGuard: RAII cleanup for Decimal tuple extraction in DetectParamTypes and build_numeric_data (eliminates ~15 manual decref cascades) - stream_dae_chunks(): template replacing 6 identical DAE chunking loops across legacy and fast execute paths - get_cached_class(): single helper replacing 5 copy-paste type getter functions - import_attr(): consolidates import-module-getattr-decref pattern in PythonObjectCache::initialize() Net -94 lines, no functional changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Explain the SQL_NUMERIC_STRUCT conversion algorithm step-by-step, precision/scale computation logic, MONEY range check rationale, and one-liners on helper utilities (PyObjGuard, stream_dae_chunks, get_cached_class). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Introduce py_ref.hpp: PyPtr = std::unique_ptr<PyObject, PyDecRefDeleter> with adopt() and incref_borrow() helpers. Zero runtime overhead via empty-base optimisation. Replaces the bespoke PyObjGuard (fixed 8-slot array, manual track/release) with standard C++ RAII throughout DetectParamTypes and build_numeric_data. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… bewithgaurav/insertmany-perf-detect-types
…le-free bugs - Extract PythonObjectCache namespace to python_object_cache.hpp - Extend PyPtr usage to Groups A/B/D/E (22→7 manual decrefs) - Fix 3 double-free bugs in PyList_SetItem error paths - Document ParamInfo.dataPtr manual refcounting rationale Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Rename python_object_cache.hpp → py_type_cache.hpp - Rename namespace PythonObjectCache → PyTypeCache - Remove unused: incref_borrow using, <cctype>, <iomanip> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…om PyPtr drops py_ref.hpp (PyPtr = unique_ptr<PyObject, PyDecRefDeleter>) and uses pybind11's own RAII handle via a steal() shorthand. same ownership semantics, no behavior change: in release builds (-O3 -DNDEBUG, which is what we ship) py::object::dec_ref compiles to a bare Py_XDECREF, identical to the PyPtr deleter. the raw CPython calls in the hot detection path are untouched, only the refcount wrapper changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… bigint build_numeric_data walked every Decimal digit through PyNumber_Multiply/PyNumber_Add, allocating three PyLongs per digit, then called PyNumber_Absolute and to_bytes(16) to serialise. it also re-entered Python for as_tuple() and the digits/exponent attributes that DetectParamTypes had already fetched. SQL Server caps NUMERIC at 38 digits and callers reject anything larger, so the mantissa always fits 128 bits. accumulate it in four uint32 limbs and write the 16 little-endian bytes directly. limbs rather than __int128 because MSVC has no __int128, and the bytes are written explicitly so host endianness does not matter. as_tuple/digits/exponent are now passed in from the caller. numeric decimal path measured 2.3x-2.9x faster (19-digit 1643 -> 711 ns/param, 38-digit 2401 -> 824). money path and all non-decimal types unchanged. 1922 tests pass, and decimal round-trip verified across sign, zero, money bounds, positive exponents, 38-digit magnitudes and scale-38 values. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…led rule of five ParamInfo kept dataPtr as a raw PyObject* and hand-wrote a destructor, copy constructor, copy assignment, move constructor and move assignment to pair Py_XINCREF with Py_XDECREF. five special members existed only to keep one refcount straight, and the copy assignment decref'd the old value before increfing the new one, which is the wrong order if the two are ever the same object. py::object already owns a refcount correctly. holding dataPtr as py::object makes the compiler-generated destructor, copy and move all correct, so the entire rule-of-five block goes away and the struct needs no special members at all. 70 lines deleted, 10 added. the two DetectParamTypes sites drop their explicit Py_INCREF, the two SQLParamData consumers read .ptr(), and the pybind property getter returns the object directly instead of reborrowing it. destruction still runs with the GIL held: the gil_scoped_release scopes in both execute paths close before the ParamInfo values die. 1922 tests pass, the refcount harness shows zero drift over 300 executes on every parameter case including str_long_dae, and the DAE path round-trips all types exactly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… call site steal() wrapped py::reinterpret_steal but had no counterpart, so the file mixed a 6-call shorthand against 8 spelled-out py::reinterpret_borrow<T>(py::handle(x)) calls. the dataPtr change in the previous commit added two more of the long form, so the asymmetry was growing. both helpers are now templated on the target type with py::object as the default, matching nanobind's nb::steal and nb::borrow signatures. the template is not cosmetic: four of the eight borrow sites need py::str or py::bytes rather than py::object, so a fixed-return helper would have covered only half of them. existing steal() calls are unaffected by the default argument. having the pair side by side also documents the hazard. steal on a borrowed reference (PyList_GetItem, PyTuple_GetItem, PyDict_GetItem) is a premature decref and a use-after-free, and that precondition now sits on the declaration instead of being implied by the name. no behavior change. 1922 tests pass and the refcount harness reports zero drift over 300 executes on all 15 parameter cases. all eight converted sites live in the DAE streaming paths of SQLExecuteLegacy_wrap and SQLExecute_wrap, so those were exercised directly: 7 DAE cases spanning NVARCHAR(MAX), VARCHAR(MAX) and VARBINARY(MAX), plus bytearray and the 4001-unit boundary, all round-trip byte-exact. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
py_ref.hpp existed earlier in this branch as the home for the custom PyPtr wrapper, and was deleted when PyPtr was replaced by py::object. steal() had to land somewhere, so it went into py_type_cache.hpp, which was already using it. borrow() then followed it there. neither belongs in that file: its own first line describes it as a cache of Python type objects and MONEY boundary constants, and these two helpers are neither. restores py_ref.hpp with the reference-adoption helpers and nothing else, and gives py_type_cache.hpp back a description that matches its contents. only ddbc_bindings.cpp includes either header, so the include change is one line. no behavior change. rebuilt and the .so is byte-identical to the previous commit (sha256 43ec909d...), with ddbc_bindings.cpp recompiled and relinked rather than served from cache. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
this PR added roughly 430 lines of new parameter-detection code into ddbc_bindings.cpp, a file that was already 6600 lines. DetectParamTypes, build_numeric_data and the types they produce are the first stage of the execute pipeline and are cohesive enough to read on their own, so they now live in their own header. ddbc_bindings.cpp drops to 6064 lines and only the pieces this PR introduced moved, so no pre-existing code shifts and no other in-flight branch gains a conflict. the SQL Server ODBC constants that sql.h does not expose move from ddbc_bindings.cpp up into ddbc_bindings.h, because both the detection path and the fetch paths need them and the header is included before either. header rather than .cpp on purpose. the build is -O3 with no LTO, so a .cpp boundary is also an inlining boundary, and these helpers run once per parameter per execute. defining them inline in a header keeps them in the including translation unit. once LTO is enabled this can become a normal .cpp. the resulting binary is not quite bit-identical and the reason is worth stating: __text grows 92 bytes and DetectParamTypes gains an out-of-line symbol. previously it sat in an anonymous namespace with exactly one call site, so the compiler inlined it into SQLExecute_wrap and deleted the original; as an inline function with vague linkage it is now emitted once and called. that is one call per execute(), not per parameter, against a roughly 300us execute. build_numeric_data, which does run per decimal parameter, was already out-of-line before this change and still is: the only difference in its symbol is the mangled name losing the anonymous-namespace prefix. no other symbol changed. 1922 tests pass, the refcount harness reports zero drift across all 15 parameter cases over 300 executes, and the 7 DAE round-trip cases remain byte-exact. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…oval the two execute entry points are DDBCSQLExecute and DDBCSQLExecuteLegacy, so the surrounding code should read standard versus legacy. cursor.py still called the non-legacy branch use_fast_path, which named a third thing that does not exist and left the reader guessing which C++ function it reached. renamed to use_standard_execute, and the parity test file follows: test_023_fast_path_parity.py becomes test_023_execute_path_parity.py, with _fast_path_roundtrip becoming _standard_roundtrip. every legacy site now says out loud that it is temporary and why it still exists. the legacy branch survives only for setinputsizes() callers, whose explicit type overrides the native path does not yet honour; that is the single thing blocking its removal, and it was not written down anywhere. annotated in cursor.py at the branch and the call, on SQLExecuteLegacy_wrap, on the DDBCSQLExecuteLegacy binding, and on the PyTypeCache import fallback that exists only because the legacy path can run before the cache is warm. _create_parameter_types_list gets a fuller docstring rather than a removal note, because it has two callers and only one of them is legacy: executemany() still needs it and will keep needing it until columnwise detection is native too. calling it simply legacy would have been wrong. left alone: the 'Fast path: Data fits in buffer' comments in ddbc_bindings.h and the ASCII-prefix fast path in test_002 and test_014. same words, unrelated concept, pre-existing. comments and identifiers only, no logic touched. 1922 tests pass and the renamed parity file runs all 51 of its tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
| } | ||
|
|
||
| Py_ssize_t num_digits = PyTuple_GET_SIZE(digits_obj.ptr()); | ||
| int exponent = static_cast<int>(PyLong_AsLong(exponent_obj.ptr())); |
There was a problem hiding this comment.
Blocking: static_cast<int>(PyLong_AsLong(...)) narrows the exponent to 32 bits before we validate it. On LP64 platforms (Linux/macOS, where long is 64-bit), Decimal("1E+4294967297") has exponent 4294967297, which truncates to 1, sails past the precision <= 38 gate, and silently binds 10 — while the legacy Python path (arbitrary-precision int) computes precision > 38 and raises. (On 64-bit Windows long is 32-bit, so PyLong_AsLong overflows and line 416 catches it instead — but the value still can't bind, and it's a platform-dependent divergence either way.) INT_MIN-style truncations also invite UB on the later negation, and huge positive results blow up the packing loop.
Please reject num_digits > 38 and exponents outside [-38, 38] (treating a PyLong_AsLong overflow — returns -1 with PyErr_Occurred() — as precision overflow) before narrowing/arithmetic. Worth tests around INT_MIN, INT_MAX, and 2**32.
|
|
||
| // Check geometry prefixes (only for non-DAE strings; long geometry | ||
| // values stay on the DAE path with their already-set types). | ||
| if (!info.isDAE && length >= 5 && kind == PyUnicode_1BYTE_KIND) { |
There was a problem hiding this comment.
The geometry override is gated behind !info.isDAE, so a WKT string over 4000 UTF-16 units never gets the geometry treatment — it stays on the generic long-string DAE path (SQL_VARCHAR, columnSize 0, DAE=true). The legacy _map_sql_type checks the geometry prefix first and returns SQL_WVARCHAR / SQL_C_WCHAR / len(param) / non-DAE regardless of length. Large polygons are realistic, so this changes both the SQL type and the execution path.
Please move geometry detection ahead of the length/DAE decision (and across all Unicode storage kinds, not just 1-byte) so both paths agree, then add a >4000-char POLYGON parity test.
| int microsecond = PyDateTime_TIME_GET_MICROSECOND(obj); | ||
| // Always include microseconds (matches Python's isoformat(timespec="microseconds")). | ||
| char buf[32]; | ||
| snprintf(buf, sizeof(buf), "%02d:%02d:%02d.%06d", hour, minute, second, microsecond); |
There was a problem hiding this comment.
This hand-formats HH:MM:SS.ffffff from the raw time fields, which drops any tzinfo and bypasses subclass isoformat overrides. The legacy path calls value.isoformat(timespec="microseconds") (see _normalize_time_param in cursor.py), so an aware datetime.time produces e.g. 01:02:03.000004+05:30 there but 01:02:03.000004 here.
Simplest fix: call isoformat("microseconds") from C++ and bind the returned string/length instead of building it manually.
| #if defined(__APPLE__) || defined(__linux__) | ||
| inline constexpr SQLSMALLINT PARAM_C_TYPE_TEXT = SQL_C_WCHAR; | ||
| #else | ||
| inline constexpr SQLSMALLINT PARAM_C_TYPE_TEXT = SQL_C_CHAR; |
There was a problem hiding this comment.
On Windows PARAM_C_TYPE_TEXT resolves to the real ODBC SQL_C_CHAR (1), but Python's SQL_C_CHAR constant is -8 (identical to SQL_C_WCHAR). So the legacy path always binds text as wide, while the native path binds ASCII strings / money-text / time as narrow on Windows — a genuine C-type and encoding-path divergence. CI is green, but for exact parity I'd bind SQL_C_WCHAR on every platform.
If narrow Windows binding is intentional, let's state it as the parity contract and test it explicitly (incl. non-ASCII collation).
| """Force the slow path by setting an explicit inputsizes entry. The fast | ||
| path is gated on `not (self._inputsizes and any(s is not None ...))`, so a | ||
| non-None tuple here flips us to the legacy Python type-detection path.""" | ||
| cursor.setinputsizes([(sql_type, column_size, 0)]) |
There was a problem hiding this comment.
This "slow path" helper calls setinputsizes(...), which forces the supplied SQL type and bypasses _map_sql_type entirely. So the suite is really comparing native detection against hand-specified types — it can't catch the geometry / aware-time / Windows C-type / column-size / DAE divergences that actually matter (and the file comment claiming it exercises _map_sql_type is inaccurate).
Suggest exposing a test-only native detector and asserting all five fields (SQL type, C type, column size, decimal digits, DAE) directly against _map_sql_type, using fresh parameter lists and the threshold / aware-time / Decimal-exponent edge cases.
| // NaN / Infinity / sNaN: refuse rather than silently writing 0. | ||
| if (PyUnicode_Check(exponent_obj.ptr())) { | ||
| throw py::value_error( | ||
| "Cannot bind non-finite Decimal (NaN/Infinity) as SQL NUMERIC"); |
There was a problem hiding this comment.
Rejecting non-finite Decimals as ValueError here is reasonable, but the legacy path raises different types — NaN raises decimal.InvalidOperation (from the money <= comparison) and Infinity reaches _get_numeric_data and raises TypeError. If we want exception parity, add the same explicit non-finite check on the Python side so both raise ValueError, and assert the exact type.
| } | ||
|
|
||
| std::vector<std::shared_ptr<void>> paramBuffers; | ||
| rc = BindParameters(*statementHandle, hStmt, params, paramInfos, paramBuffers, charEncoding); |
There was a problem hiding this comment.
If BindParameters binds some parameters and then throws, we unwind without SQL_RESET_PARAMS, so the HSTMT keeps pointers into paramBuffers that are about to be destroyed, until the next reset. Exposure is limited (the next execute resets first), but an exception-only scope guard that resets params once binding has begun would make the invariant safe. Keep the existing ODBC-return error path untouched so diagnostics stay readable.
| } | ||
| } | ||
| if (!matchedInfo) { | ||
| ThrowStdException("SQLExecuteFast: unrecognized paramToken from SQLParamData"); |
There was a problem hiding this comment.
Leftover old name — SQLExecuteFast: here (and again at ~2134 and ~2158, plus a comment near line 1999). The function was renamed away from "fast"; these strings should follow so logs/messages aren't misleading.
| std::string bytesStorage; // lifetime must span the loop | ||
|
|
||
| if (PyBytes_Check(pyObj)) { | ||
| bytesStorage = borrow<py::bytes>(pyObj); |
There was a problem hiding this comment.
For immutable bytes this copies the whole payload into bytesStorage before streaming. matchedInfo->dataPtr holds a strong reference for the duration, so you can stream straight from PyBytes_AS_STRING(pyObj) / PyBytes_GET_SIZE(pyObj) and skip the copy. (The bytearray branch still needs its copy since it's mutable.)
| if (rc != SQL_NEED_DATA) break; | ||
|
|
||
| const ParamInfo* matchedInfo = nullptr; | ||
| for (auto& info : paramInfos) { |
There was a problem hiding this comment.
This linearly scans every param for each DAE token. Since the token is a stable ParamInfo* (never reallocated — the vector is sized up front), you can reinterpret_cast it back directly (or pass an index) instead of the O(N) search per chunk.
…t for time
two parity divergences from jahnvi480's review, both of which bound wrong data silently rather than failing.
the Decimal exponent was cast to int before it was validated. Decimal exponents are arbitrary precision, so on LP64 Decimal('1E+4294967297') truncated to 1, passed the precision <= 38 gate, and bound 10. Decimal('1E+2147483648') truncated to exactly INT_MIN and bound 0.1, and negating INT_MIN a few lines later is signed-overflow UB. the legacy Python path computes precision in arbitrary-precision ints and raises for both. the exponent is now read with PyLong_AsLongLong and bounded, along with the digit count, before any narrowing or arithmetic; an overflow from that read is reported as precision overflow rather than leaking OverflowError.
the time path hand-formatted HH:MM:SS.ffffff from the raw fields, which dropped tzinfo and ignored isoformat overrides on subclasses. an aware time whose isoformat is 01:02:03.000004+05:30 bound as 01:02:03.000004, a different time than the caller passed. it now calls isoformat(timespec='microseconds'), which is what _normalize_time_param does on the legacy side. SQL Server TIME has no offset so both paths now raise DataError for an aware time, verified against legacy auto-detection through executemany.
also finishes the fast_path rename from 2ecda91, which left four SQLExecuteFast strings in error messages and a stale comment in ddbc_bindings.cpp, plus six references in the parity test file.
12 tests added covering 2**32+1, INT_MIN, INT_MAX and their negatives, the 37 and -38 exponents that must still bind, and the aware/naive time pair. verified as real guards: reverting the header and rebuilding fails exactly the 2**32+1, INT_MIN and aware-time cases. 1934 tests pass, refcount harness reports zero drift.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ithub.com/microsoft/mssql-python into bewithgaurav/insertmany-perf-detect-types
The native detector resolved its text C type to SQL_C_WCHAR on Linux/macOS but to a real SQL_C_CHAR (1) on Windows. The legacy Python path binds text with the Python layer's SQL_C_CHAR constant, which is numerically -8, i.e. ODBC's SQL_C_WCHAR, so the legacy path has always bound text wide on every platform. Windows was therefore the only place where the two paths disagreed on C type and on the driver-side encoding path they took, and it was also the one combination CI never compared against a passing wide-bound baseline. Bind wide everywhere. Three call sites share the constant: ASCII strings (inline and DAE), datetime.time normalized to text, and MONEY-range Decimals formatted to text, so all three change on Windows only. Adds round-trip tests over ASCII, non-ASCII, inline/DAE boundary strings, NVARCHAR conversion, time and MONEY, so a reintroduced narrow binding shows up as a Windows-only failure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The native detector raises ValueError for NaN, sNaN and Infinity. The legacy Python path instead set precision=38 and carried on, so the failure happened incidentally and with a different type each time: NaN raised decimal.InvalidOperation from the MONEY range comparison in _map_sql_type, while Infinity reached _get_numeric_data and raised TypeError from comparing a str exponent against an int. Callers writing `except ValueError` saw different behaviour depending on whether setinputsizes happened to be set. Raise ValueError with the same message in both _map_sql_type and _get_numeric_data. _get_numeric_data needs its own check because executemany's typing pass reaches it directly. Tightens the existing rejection tests from `raises(Exception)` to the exact type, and adds a parity test asserting both paths raise ValueError for all five non-finite forms. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The old parity suite claimed to compare native (C++ DetectParamTypes) against legacy (Python _map_sql_type) detection, but its "legacy" helper reached the legacy path via setinputsizes — which supplies explicit types and bypasses _map_sql_type entirely. So it compared native detection against types hardcoded in the test, never ran the Python detector, and asserted on the round-tripped value, which SQL Server coercion can mask. Every real divergence found on this PR (geometry >4000, aware time, Windows narrow binding) was found by reading code; the suite was green through all of them. Coverage confirmed it: _map_sql_type's body (lines 431-719) and _get_numeric_data sat in the Missing list. Drop the forcing. Test each path through the door real callers use: - Native path: end-to-end via cursor.execute(), unchanged. - Python detection: assert _map_sql_type(value, [value], 0) directly as a pure function returning the 5-tuple (SQL type, C type, column size, decimal digits, DAE) — no DB round-trip, so coercion can't hide a wrong type. Covers every branch: int widths, float, decimal money/numeric, uuid, ascii/unicode inline/DAE strings, geometry, binary, date/datetime/time. - _get_numeric_data: direct precision/scale and overflow assertions. - Legacy execute path (DDBCSQLExecuteLegacy): kept, reached through its only real entry point (setinputsizes), used for what it is for — user-supplied type overrides — plus a shorter-than-params case that exercises the _map_sql_type fallback in _create_parameter_types_list. The long-POLYGON case pins the legacy contract and notes the native side still diverges (a known open item, not fixed here) so the gap stays visible. Net: this file's cursor.py coverage rises 30% -> 39%; 85 -> 128 tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Work Item / Issue Reference
Summary
Moves parameter type detection and binding from Python into a native C++ pipeline using raw CPython API calls. The new
DDBCSQLExecutehandles type detection → parameter binding → SQLExecute in a single FFI crossing, eliminating per-parameter Python overhead entirely.What changed:
DetectParamTypes— C++ type detection using raw CPython API (PyLong_Check,PyDateTime_Check,PyObject_RichCompareBool, etc.) replacing the Python-side_create_parameter_types_listloop for the standard execute path.DDBCSQLExecute(formerlyDDBCSQLExecuteFast) — single C++ pipeline: detect → bind → execute. ParamInfo never crosses the pybind11 boundary.DDBCSQLExecuteLegacy(formerlyDDBCSQLExecute) — retained forsetinputsizesusers only, and annotated in-code as slated for removal once those overrides are handled natively.PyTypeCache(py_type_cache.hpp) stores all type objects as rawPyObject*(notpy::object), eliminating pybind11 wrapper overhead on every cache hit.uint32limbs instead of walking every Decimal digit throughPyNumber_Multiply/PyNumber_Add. SQL Server caps NUMERIC at 38 digits, so the mantissa always fits 128 bits. Decimal detection measured 2.3–2.9x faster on its own.ParamInfo::dataPtrholds apy::objectrather than a rawPyObject*with a hand-written rule of five, andpy_ref.hppprovidessteal/borrowso every adoption of a CPython reference states whether it is taking a new reference or borrowing one.param_detect.hpprather than inline in the 6600-lineddbc_bindings.cpp. Header-only because the build is-O3with no LTO, so a.cppboundary would also be an inlining boundary.Routing (cursor.py):
Performance Results 🚀
The Python-side type detection cost was ~2.0–2.3µs per parameter — an
isinstancecheck,ParamInfoobject construction, and a pybind11 FFI boundary crossing per parameter, per execute call. The C++ path replaces this with ~35ns/param (rawPyLong_Check+ struct field write) — a ~60x faster per-parameter detection.macOS arm64 (Apple Silicon M-series), Python 3.13
Linux aarch64 (Docker container), Python 3.13
vs pyodbc (post-PR, macOS)
Customer scenarios (end to end, macOS arm64)
The numbers above isolate driver overhead. These are whole insert workloads, so they also carry the network round trip and SQL Server actually writing the rows, which this PR does not change and which dilutes the percentage. Measured against the merge base (
d94debd) with the two builds interleaved across 3 rounds, 7 iterations each, first 2 discarded.Between them these cover every parameter type whose detection moved: int, varchar, decimal, datetime2, uuid, and the nvarchar(max) DAE streaming path.
Single-row execute does not move, and that is the expected result rather than a disappointment. At roughly 420µs per call the cost is the network round trip; detection for 4 parameters was only ever about 9µs of it. The gain scales with parameters per execute, so batched and wide-row work benefits and one-row-at-a-time work stays where it was.
Bottom line
Checklist