Skip to content

Commit c928b63

Browse files
jahnvi480Copilot
andcommitted
PERF: Reduce fetch options, LOB copies, and converter dispatch
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 1468a23 commit c928b63

8 files changed

Lines changed: 654 additions & 31 deletions

File tree

‎eng/profiler_benchmarks/controller.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,7 @@ def verify_reused_source(source_root, revision):
230230

231231

232232
# Closed, reviewed dispatch bodies: main666, f539, many-only, and plain completion.
233+
# The options snapshot changes marshalling, not the plain Row-completion policy.
233234
# Method/comment/docstring edits require review and an explicit fingerprint update.
234235
# These reviewed method-source SHA256 digests are not credentials.
235236
_LEGACY_PYTHON_ROUTE = (
@@ -244,6 +245,9 @@ def verify_reused_source(source_root, revision):
244245
_PLAIN_COMPLETION_ROUTE = (
245246
"776ebc4532b69fe2492317ef6af9d4b2bb08e8a0f112613a7ce2600500bc37d8" # DevSkim: ignore DS173237
246247
)
248+
_FETCH_OPTIONS_ROUTE = (
249+
"6c8545f38956380e22b8cc9376e215e032d45ec68d1a29c669201f09477ec507" # DevSkim: ignore DS173237
250+
)
247251

248252

249253
def python_source_identity(source_root, revision):
@@ -323,7 +327,7 @@ def python_source_identity(source_root, revision):
323327
fingerprint = hashlib.sha256(
324328
json.dumps(methods, ensure_ascii=True, separators=(",", ":")).encode()
325329
).hexdigest()
326-
if fingerprint in (_LEGACY_PYTHON_ROUTE, _PLAIN_COMPLETION_ROUTE):
330+
if fingerprint in (_LEGACY_PYTHON_ROUTE, _PLAIN_COMPLETION_ROUTE, _FETCH_OPTIONS_ROUTE):
327331
values = (False, False, False)
328332
elif fingerprint == _LEGACY_FUSED_ROUTE:
329333
values = (True, True, True)
@@ -344,7 +348,7 @@ def python_source_identity(source_root, revision):
344348
validate_row_route(route)
345349
if route != expected:
346350
raise ValueError("Python source-policy drift")
347-
elif fingerprint in (_MANY_ONLY_ROUTE, _PLAIN_COMPLETION_ROUTE):
351+
elif fingerprint in (_MANY_ONLY_ROUTE, _PLAIN_COMPLETION_ROUTE, _FETCH_OPTIONS_ROUTE):
348352
raise ValueError("Missing Python route declaration")
349353
return dict(
350354
source_commit=revision,

‎mssql_python/cursor.py‎

Lines changed: 44 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -677,8 +677,19 @@ def _refresh_decoding_cache(self):
677677
self._cached_char_encoding = char_decoding.get("encoding", "utf-16le")
678678
self._cached_char_ctype = char_decoding.get("ctype", ddbc_sql_const.SQL_WCHAR.value)
679679
self._cached_wchar_encoding = wchar_encoding
680+
self._cached_fetch_options = None
680681
self._cached_decoding_generation = generation
681682

683+
def _create_fetch_options(self):
684+
generation = self._cached_decoding_generation
685+
options = ddbc_bindings._FetchOptions(
686+
self._cached_char_encoding, self._cached_wchar_encoding, self._cached_char_ctype
687+
)
688+
# Allocation callbacks can refresh decoding; never install an older snapshot.
689+
if self._cached_decoding_generation == generation:
690+
self._cached_fetch_options = options
691+
return options
692+
682693
def _get_decoding_settings(self, sql_type):
683694
"""
684695
Get decoding settings for a specific SQL type.
@@ -2889,14 +2900,22 @@ def fetchone(self) -> Union[None, Row]:
28892900
started = perf_start()
28902901
try:
28912902
fetch = ddbc_bindings.DDBCSQLFetchOne
2892-
ret = fetch(
2893-
self.hstmt,
2894-
row_data,
2895-
char_enc,
2896-
wchar_enc,
2897-
self._cached_char_ctype,
2898-
self.messages,
2899-
)
2903+
if fetch is _DEFAULT_NATIVE_FETCH_ONE:
2904+
options = self._cached_fetch_options
2905+
if options is None:
2906+
options = self._create_fetch_options()
2907+
ret = ddbc_bindings._fetchone_with_options(
2908+
self.hstmt, row_data, options, self.messages
2909+
)
2910+
else:
2911+
ret = fetch(
2912+
self.hstmt,
2913+
row_data,
2914+
char_enc,
2915+
wchar_enc,
2916+
self._cached_char_ctype,
2917+
self.messages,
2918+
)
29002919
finally:
29012920
if started:
29022921
perf_stop("py::fetchone::cpp_call", started)
@@ -2990,15 +3009,23 @@ def fetchmany(self, size: Optional[int] = None) -> List[Row]:
29903009
started = perf_start()
29913010
try:
29923011
fetch = ddbc_bindings.DDBCSQLFetchMany
2993-
ret = fetch(
2994-
self.hstmt,
2995-
rows_data,
2996-
size,
2997-
char_enc,
2998-
wchar_enc,
2999-
self._cached_char_ctype,
3000-
self.messages,
3001-
)
3012+
if fetch is _DEFAULT_NATIVE_FETCH_MANY:
3013+
options = self._cached_fetch_options
3014+
if options is None:
3015+
options = self._create_fetch_options()
3016+
ret = ddbc_bindings._fetchmany_with_options(
3017+
self.hstmt, rows_data, size, options, self.messages
3018+
)
3019+
else:
3020+
ret = fetch(
3021+
self.hstmt,
3022+
rows_data,
3023+
size,
3024+
char_enc,
3025+
wchar_enc,
3026+
self._cached_char_ctype,
3027+
self.messages,
3028+
)
30023029
finally:
30033030
if started:
30043031
perf_stop("py::fetchmany::cpp_call", started)

‎mssql_python/pybind/ddbc_bindings.cpp‎

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
#include <cstring> // For std::memcpy
2424
#include <filesystem>
2525
#include <iostream>
26+
#include <stdexcept>
2627
#include <utility> // std::forward
2728
#include <datetime.h> // CPython datetime API (PyDateTime_IMPORT, PyDateTime_GET_*, etc.)
2829

@@ -3280,12 +3281,18 @@ py::object FetchLobColumnData(SQLHSTMT hStmt, SQLUSMALLINT colIndex, SQLSMALLINT
32803281

32813282
while (true) {
32823283
++loopCount;
3283-
std::vector<char> chunk(DAE_CHUNK_SIZE, 0);
3284+
const size_t offset = buffer.size();
3285+
if (buffer.max_size() - offset < DAE_CHUNK_SIZE) {
3286+
throw std::length_error("LOB data exceeds maximum buffer size");
3287+
}
3288+
// Keep the existing read windows and zero-filled terminator room.
3289+
buffer.resize(offset + DAE_CHUNK_SIZE, 0);
3290+
char* chunk = buffer.data() + offset;
32843291
SQLLEN actualRead = 0;
32853292
{
32863293
// Release the GIL during blocking SQLGetData LOB streaming
32873294
py::gil_scoped_release release;
3288-
ret = SQLGetData_ptr(hStmt, colIndex, cType, chunk.data(), DAE_CHUNK_SIZE, &actualRead);
3295+
ret = SQLGetData_ptr(hStmt, colIndex, cType, chunk, DAE_CHUNK_SIZE, &actualRead);
32893296
}
32903297
CaptureFetchDiagnostics(hStmt, ret, messages, true);
32913298

@@ -3328,11 +3335,13 @@ py::object FetchLobColumnData(SQLHSTMT hStmt, SQLUSMALLINT colIndex, SQLSMALLINT
33283335
// Wide characters
33293336
size_t wcharSize = sizeof(SQLWCHAR);
33303337
if (bytesRead >= wcharSize && (bytesRead % wcharSize == 0)) {
3331-
size_t wcharCount = bytesRead / wcharSize;
3332-
std::vector<SQLWCHAR> alignedBuf(wcharCount);
3333-
std::memcpy(alignedBuf.data(), chunk.data(), bytesRead);
3334-
while (wcharCount > 0 && alignedBuf[wcharCount - 1] == 0) {
3335-
--wcharCount;
3338+
while (bytesRead >= wcharSize) {
3339+
SQLWCHAR lastChar;
3340+
// The byte destination need not be aligned for SQLWCHAR.
3341+
std::memcpy(&lastChar, chunk + bytesRead - wcharSize, wcharSize);
3342+
if (lastChar != 0) {
3343+
break;
3344+
}
33363345
bytesRead -= wcharSize;
33373346
}
33383347
if (bytesRead < DAE_CHUNK_SIZE) {
@@ -3343,8 +3352,8 @@ py::object FetchLobColumnData(SQLHSTMT hStmt, SQLUSMALLINT colIndex, SQLSMALLINT
33433352
}
33443353
}
33453354
}
3355+
buffer.resize(offset + bytesRead);
33463356
if (bytesRead > 0) {
3347-
buffer.insert(buffer.end(), chunk.begin(), chunk.begin() + bytesRead);
33483357
LOG("FetchLobColumnData: Appended %zu bytes at loop %d", bytesRead, loopCount);
33493358
}
33503359
if (ret == SQL_SUCCESS) {
@@ -6667,6 +6676,20 @@ PYBIND11_MODULE(ddbc_bindings, m) {
66676676
py::arg("StatementHandle"), py::arg("colCount"), py::arg("row"), py::arg("charEncoding"),
66686677
py::arg("wcharEncoding"), py::arg("charCtype"), py::arg("messages") = py::none());
66696678
m.def("DDBCSQLMoreResults", &SQLMoreResults_wrap, "Check for more results in the result set");
6679+
py::class_<FetchOptions>(m, "_FetchOptions")
6680+
.def(py::init<const std::string&, const std::string&, int>());
6681+
m.def("_fetchone_with_options",
6682+
[](SqlHandlePtr statement, py::list& row, const FetchOptions& options,
6683+
py::handle messages) {
6684+
return FetchOne_wrap(std::move(statement), row, options.charEncoding,
6685+
options.wcharEncoding, options.charCtype, messages);
6686+
});
6687+
m.def("_fetchmany_with_options",
6688+
[](SqlHandlePtr statement, py::list& rows, int size, const FetchOptions& options,
6689+
py::handle messages) {
6690+
return FetchMany_wrap(std::move(statement), rows, size, options.charEncoding,
6691+
options.wcharEncoding, options.charCtype, messages);
6692+
});
66706693
m.def("DDBCSQLFetchOne", &FetchOne_wrap, "Fetch one row from the result set",
66716694
py::arg("StatementHandle"), py::arg("row"), py::arg("charEncoding") = "utf-16le",
66726695
py::arg("wcharEncoding") = "utf-16le", py::arg("charCtype") = SQL_C_WCHAR,
@@ -6778,6 +6801,10 @@ PYBIND11_MODULE(ddbc_bindings, m) {
67786801
// Add a version attribute
67796802
m.attr("__version__") = "1.0.0";
67806803

6804+
m.def("_apply_output_converters", &RowFactory::apply_output_converters,
6805+
"Apply a cached converter map after native value materialization",
6806+
py::arg("values"), py::arg("converters"));
6807+
67816808
// Fast Row construction in C++ — replaces Python list comprehension
67826809
m.def("construct_rows", &RowFactory::construct_rows,
67836810
"Build Row objects in C++ for fetchall/fetchmany fast path",

‎mssql_python/pybind/ddbc_bindings.h‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,15 @@ struct DateTimeOffset {
426426
SQLSMALLINT timezone_minute; // Offset minutes from UTC
427427
};
428428

429+
struct FetchOptions {
430+
const std::string charEncoding;
431+
const std::string wcharEncoding;
432+
const int charCtype;
433+
434+
FetchOptions(const std::string& charEncoding, const std::string& wcharEncoding, int charCtype)
435+
: charEncoding(charEncoding), wcharEncoding(wcharEncoding), charCtype(charCtype) {}
436+
};
437+
429438
// Struct to hold data buffers and indicators for each column
430439
struct ColumnBuffers {
431440
std::vector<std::vector<SQLCHAR>> charBuffers;

‎mssql_python/pybind/row_factory.hpp‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,90 @@
55

66
#include "py_ref.hpp"
77

8+
#include <utility>
9+
810
namespace RowFactory {
911

12+
// Only the post-fetch cached-map loop: no ODBC access, Row allocation, or UUID work.
13+
inline py::list apply_output_converters(const py::object& values, const py::object& converters) {
14+
if (!PyList_CheckExact(values.ptr()) || !PyList_CheckExact(converters.ptr())) {
15+
throw py::type_error("converter values and map must be exact lists");
16+
}
17+
py::list result =
18+
steal<py::list>(PyList_GetSlice(values.ptr(), 0, PyList_GET_SIZE(values.ptr())));
19+
if (!result)
20+
throw py::error_already_set();
21+
22+
// Keep the Python iterators: their retained tuples affect finalizer timing
23+
// when a callback replaces itself or mutates the source lists.
24+
py::object pairs = steal(PyObject_CallFunctionObjArgs(reinterpret_cast<PyObject*>(&PyZip_Type),
25+
values.ptr(), converters.ptr(), nullptr));
26+
if (!pairs)
27+
throw py::error_already_set();
28+
py::object items =
29+
steal(PyObject_CallOneArg(reinterpret_cast<PyObject*>(&PyEnum_Type), pairs.ptr()));
30+
if (!items)
31+
throw py::error_already_set();
32+
pairs = py::object();
33+
34+
// Retain the current inputs and last encoded value like the Python locals.
35+
py::object value, converter, value_bytes;
36+
for (Py_ssize_t i = 0;; ++i) {
37+
py::object item = steal(PyIter_Next(items.ptr()));
38+
if (!item) {
39+
if (PyErr_Occurred())
40+
throw py::error_already_set();
41+
break;
42+
}
43+
PyObject* pair = PyTuple_GET_ITEM(item.ptr(), 1);
44+
py::object next_value = borrow(PyTuple_GET_ITEM(pair, 0));
45+
py::object next_converter = borrow(PyTuple_GET_ITEM(pair, 1));
46+
value = std::move(next_value);
47+
converter = std::move(next_converter);
48+
item = py::object();
49+
const int enabled = PyObject_IsTrue(converter.ptr());
50+
if (enabled < 0)
51+
throw py::error_already_set();
52+
if (!enabled || value.is_none())
53+
continue;
54+
55+
try {
56+
const int is_string =
57+
PyObject_IsInstance(value.ptr(), reinterpret_cast<PyObject*>(&PyUnicode_Type));
58+
if (is_string < 0)
59+
throw py::error_already_set();
60+
PyObject* argument = value.ptr();
61+
if (is_string) {
62+
// Match str.encode's codec lookup, including the spelling.
63+
// Subclasses and __class__ proxies still dispatch encode dynamically.
64+
py::object encoded =
65+
steal(PyUnicode_CheckExact(value.ptr())
66+
? PyUnicode_AsEncodedString(value.ptr(), "utf-16-le", nullptr)
67+
: PyObject_CallMethod(value.ptr(), "encode", "s", "utf-16-le"));
68+
if (!encoded)
69+
throw py::error_already_set();
70+
value_bytes = std::move(encoded);
71+
argument = value_bytes.ptr();
72+
}
73+
py::object converted = steal(PyObject_CallOneArg(converter.ptr(), argument));
74+
if (!converted)
75+
throw py::error_already_set();
76+
// Checked assignment also preserves Python's caught IndexError if a
77+
// callback grows the input lists beyond the initial result copy.
78+
if (PyList_SetItem(result.ptr(), i, converted.release().ptr()) < 0)
79+
throw py::error_already_set();
80+
} catch (py::error_already_set& error) {
81+
if (!error.matches(PyExc_Exception))
82+
throw;
83+
// Preserve the existing cached path's keep-original-on-Exception contract.
84+
error.restore();
85+
PyErr_Clear();
86+
}
87+
}
88+
items = py::object();
89+
return result;
90+
}
91+
1092
inline void initialize_row(
1193
const py::object& row, PyObject* row_data, const py::object& column_map,
1294
const py::object& cursor_obj, const py::object& column_map_lower,

‎mssql_python/row.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import uuid as _uuid
1010
from collections.abc import Mapping
1111
from typing import Any
12+
from mssql_python import ddbc_bindings
1213
from mssql_python.logging import logger
1314

1415

@@ -198,13 +199,21 @@ def _apply_output_converters_optimized(self, values, converter_map):
198199
"""
199200
Apply output converters using pre-computed converter map for optimal performance.
200201
202+
Native materialization has already completed. Exact lists use a native
203+
dispatch loop; both paths copy values and preserve dynamic string encoding.
204+
201205
Args:
202206
values: Raw values from the database
203207
converter_map: Pre-computed list of converters (one per column, None if no converter)
204208
205209
Returns:
206210
List of converted values
207211
"""
212+
# Native fetches and the cursor cache supply exact lists. Keep arbitrary
213+
# iterables on the Python path, including their iteration side effects.
214+
if type(values) is list and type(converter_map) is list:
215+
return ddbc_bindings._apply_output_converters(values, converter_map)
216+
208217
converted_values = list(values)
209218

210219
for i, (value, converter) in enumerate(zip(values, converter_map)):

0 commit comments

Comments
 (0)