PERF: reuse native parameter bindings across repeated executes - #761
PERF: reuse native parameter bindings across repeated executes#761Gaurav Sharma (bewithgaurav) wants to merge 1 commit into
Conversation
Repeated executions of the same prepared statement rebound every parameter from scratch on each call, even when the parameter shape never changed. Give each statement handle a single reusable generation of native input buffers and skip the SQLBindParameter loop when the next execution presents identical binding metadata. The existing detector and binder still run in full on every execution: types are detected, values converted, and validation performed exactly as before. Reuse is gated on identical prepared SQL, parameter count, C and SQL types, column size, scale, direction, effective encoding, and actual buffer byte lengths and indicator addresses; string storage is updated in place only when the size is unchanged, preserving each buffer address and ODBC BufferLength. Scope is deliberately conservative. Input-only integer, boolean, floating-point, and inline text/binary parameters are cached, up to 2,100 parameters and 8,000 bytes per retained text/binary buffer. NULL, data-at-execution, and complex C types fall back to the existing uncached path; decimal overrides formatted to text reuse only with matching precision and scale. New preparation, incompatible metadata or sizes, explicit reset, direct/catalog/array execution, statement attribute changes, and any execution or conversion error invalidate reuse. Native storage stays owned until ODBC resets the bindings or frees the handle, including error paths and parent connection teardown, so no still-bound address is freed early and no Python reference is retained in the cache. The DB-API threadsafety=1 contract is unchanged. This is an internal reuse optimization with no public API or behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
The updated disconnect path can still drop the DBC handle in the no-GIL shutdown/destructor scenario without marking child statements as implicitly freed on SQLDisconnect failure, risking later double-free/ODBC calls on implicitly-freed HSTMTs.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR introduces a native, per-statement cache for execute-time parameter bindings in the C++ (ddbc_bindings) layer so repeated executions of the same prepared SQL can skip redundant SQLBindParameter calls when the binding “shape” is unchanged, while still re-running detection/validation/conversion each time.
Changes:
- Add
ExecuteBindingCache+preparedQuerytracking onSqlHandle, and implement reuse/invalidations acrossSQLExecute, reset, catalog, and direct execution paths. - Update Python cursor soft-reset to preserve compatible cached bindings (
preserve_bindings=True). - Add an integration test suite that asserts reuse/invalidation behavior via existing debug-log events, plus documentation in the native README.
File summaries
| File | Description |
|---|---|
| tests/test_037_cached_bindings.py | New integration tests validating bind reuse/invalidation via native debug logging. |
| mssql_python/pybind/README.md | Document the repeated-execute binding reuse rules and invalidation triggers. |
| mssql_python/pybind/ddbc_bindings.h | Add binding-cache structs and new SqlHandle fields/methods for caching + reset. |
| mssql_python/pybind/ddbc_bindings.cpp | Implement buffer reuse/caching, invalidation, and reset behavior across execution paths. |
| mssql_python/pybind/connection/connection.cpp | Adjust disconnect sequencing/lifetime handling for child statement owners and caches. |
| mssql_python/cursor.py | Preserve compatible cached bindings during soft cursor reset. |
Review details
- Files reviewed: 6/6 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if (SQL_SUCCEEDED(ret)) { | ||
| for (const auto& handle : childHandles) { | ||
| handle->markImplicitlyFreed(); | ||
| handle->releaseAfterFree(); | ||
| } | ||
| std::lock_guard<std::mutex> lock(_childHandlesMutex); | ||
| _childStatementHandles.clear(); | ||
| _allocationsSinceCompaction = 0; | ||
| } | ||
| // triggers SQLFreeHandle via destructor, if last owner | ||
| _dbcHandle.reset(); |
Work Item / Issue Reference
Summary
Repeated executions of the same prepared statement rebind every parameter from scratch on each call, even when the parameter shape never changes. This change gives each statement handle a single reusable generation of native input buffers and skips the
SQLBindParameterloop when the next execution presents identical binding metadata.The existing detector and binder still run in full on every execution: types are detected, values converted, and validation performed exactly as before. Only the redundant rebinding is elided. Reuse is gated on identical prepared SQL, parameter count, C and SQL types, column size, scale, direction, effective encoding, and actual buffer byte lengths and indicator addresses. String storage is updated in place only when the size is unchanged, which preserves each buffer address and its ODBC
BufferLength.Scope is deliberately conservative:
Native storage stays owned until ODBC resets the bindings or frees the handle, including error paths and parent connection teardown, so no still-bound address is freed early and no Python reference is retained in the cache. The DB-API
threadsafety=1contract is unchanged. This is an internal reuse optimization with no public API or behavior change.Validation
macOS arm64, Python 3.13, release build (
-O3 -DNDEBUG, universal2), SQL Server 2022.tests/test_037_cached_bindings.py: 73 reuse and invalidation cases pass. These assert live round trips and the actual native allocate/bind/reuse events through the existing debug logger, not a test-only API.test_004_cursor,test_010_pybind_functions,test_023_execute_path_parity): 699 pass, 8 skipped, no regressions.Preliminary Local Measurements
Interleaved A/B against the
cb0bd6cbaseline (alternating before/after so machine noise lands on both), median of per-scenario medians across 3 rounds, with the worst round-to-round swing shown as spread.Every delta is smaller than its own spread, so this wall-clock harness cannot resolve a difference on this machine. That is expected: these are end-to-end INSERT workloads where the SQL Server round trip dominates, and the client-side rebinding this change removes is a small fraction of each call. These numbers are published only to show the change does not regress, not to claim a speedup.
The isolated client-side effect will be quantified with the native profiler (tracked separately in #552) rather than end-to-end wall clock, and the measured numbers will be added here before this PR leaves draft.