Skip to content

PERF: reuse native parameter bindings across repeated executes - #761

Draft
Gaurav Sharma (bewithgaurav) wants to merge 1 commit into
mainfrom
bewithgaurav/insertmany-perf-cached-bind
Draft

PERF: reuse native parameter bindings across repeated executes#761
Gaurav Sharma (bewithgaurav) wants to merge 1 commit into
mainfrom
bewithgaurav/insertmany-perf-cached-bind

Conversation

@bewithgaurav

Copy link
Copy Markdown
Collaborator

Work Item / Issue Reference

GitHub Issue: #500


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 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. 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:

  • Cached: input-only integer, boolean, floating-point, and inline text/binary parameters, up to 2,100 parameters and 8,000 bytes per retained text/binary buffer.
  • Uncached (existing path): NULL, data-at-execution, and complex C types. Decimal overrides formatted to text reuse only with matching precision and scale.
  • Invalidated: new preparation, incompatible metadata or sizes, explicit reset, direct/catalog/array execution, statement-attribute changes, and any execution or conversion error.

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.


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.
  • Core parameter and execute suites (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 cb0bd6c baseline (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.

Scenario Before After Delta Spread
Orders insert (int, varchar, decimal, datetime2) 475.0 ms 480.2 ms +1.1% 8.5%
Event log insert (uuid, datetime2, varchar, int) 453.1 ms 447.6 ms -1.2% 2.7%
Document insert (nvarchar(max) ~10KB, DAE) 1098.5 ms 1100.5 ms +0.2% 22.1%
Wide row insert (50 mixed columns) 734.5 ms 755.2 ms +2.8% 7.8%
Single-row execute x5000 2235.9 ms 2250.7 ms +0.7% 4.0%

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.

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>
Copilot AI lite review requested due to automatic review settings September 8, 2026 18:52
@github-actions github-actions Bot added the pr-size: large Substantial code update label Sep 8, 2026

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.

🟡 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 + preparedQuery tracking on SqlHandle, and implement reuse/invalidations across SQLExecute, 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.

Comment on lines +183 to 193
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();
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