FEAT: Performance Improvements in Fetch path by bewithgaurav · Pull Request #320 · microsoft/mssql-python
added 4 commits
November 10, 2025 16:06… (Linux/macOS) Problem: - Linux/macOS performed double conversion for NVARCHAR columns - SQLWCHAR → std::wstring (via SQLWCHARToWString) → Python unicode - Created unnecessary intermediate std::wstring allocation Solution: - Use PyUnicode_DecodeUTF16() to convert UTF-16 directly to Python unicode - Single-step conversion eliminates intermediate allocation - Platform-specific optimization (Linux/macOS only) Impact: - Reduces memory allocations for wide-character string columns - Eliminates one full conversion step per NVARCHAR cell - Regular VARCHAR/CHAR columns unchanged (already optimal)
… (Linux/macOS) Problem: - Linux/macOS performed double conversion for NVARCHAR columns - SQLWCHAR → std::wstring (via SQLWCHARToWString) → Python unicode - Created unnecessary intermediate std::wstring allocation Solution: - Use PyUnicode_DecodeUTF16() to convert UTF-16 directly to Python unicode - Single-step conversion eliminates intermediate allocation - Platform-specific optimization (Linux/macOS only) Impact: - Reduces memory allocations for wide-character string columns - Eliminates one full conversion step per NVARCHAR cell - Regular VARCHAR/CHAR columns unchanged (already optimal)
Problem: - All numeric conversions used pybind11 wrappers with overhead: * Type detection, wrapper object creation, bounds checking * ~20-40 CPU cycles overhead per cell Solution: - Use direct Python C API calls: * PyLong_FromLong/PyLong_FromLongLong for integers * PyFloat_FromDouble for floats * PyBool_FromLong for booleans * PyList_SET_ITEM macro (no bounds check - list pre-sized) Changes: - SQL_INTEGER, SQL_SMALLINT, SQL_BIGINT, SQL_TINYINT → PyLong_* - SQL_BIT → PyBool_FromLong - SQL_REAL, SQL_DOUBLE, SQL_FLOAT → PyFloat_FromDouble - Added explicit NULL handling for each type Impact: - Eliminates pybind11 wrapper overhead for simple numeric types - Direct array access via PyList_SET_ITEM macro - Affects 7 common numeric SQL types
Problem: -------- Column metadata (dataType, columnSize, isLob, fetchBufferSize) was accessed from the columnInfos vector inside the hot row processing loop. For a query with 1,000 rows × 10 columns, this resulted in 10,000 struct field accesses. Each access involves: - Vector bounds checking - Large struct loading (~50+ bytes per ColumnInfo) - Poor cache locality (struct fields scattered in memory) - Cost: ~10-15 CPU cycles per access (L2 cache misses likely) Solution: --------- Prefetch metadata into tightly-packed local arrays before the row loop: - std::vector<SQLSMALLINT> dataTypes (2 bytes per element) - std::vector<SQLULEN> columnSizes (8 bytes per element) - std::vector<uint64_t> fetchBufferSizes (8 bytes per element) - std::vector<bool> isLobs (1 byte per element) Total: ~190 bytes for 10 columns vs 500+ bytes with structs. These arrays stay hot in L1 cache for the entire batch processing, eliminating repeated struct access overhead. Changes: -------- - Added 4 prefetch vectors before row processing loop - Added prefetch loop to populate metadata arrays (read columnInfos once) - Replaced all columnInfos[col-1].field accesses with array lookups - Updated SQL_CHAR/SQL_VARCHAR cases - Updated SQL_WCHAR/SQL_WVARCHAR cases - Updated SQL_BINARY/SQL_VARBINARY cases Impact: ------- - Eliminates O(rows × cols) metadata lookups - 10,000 array accesses @ 3-5 cycles vs 10,000 struct accesses @ 10-15 cycles - ~70% reduction in metadata access overhead - Better L1 cache utilization (190 bytes vs 500+ bytes) - Expected 15-25% overall performance improvement on large result sets
…ild fix) Windows compiler treats warnings as errors (/WX flag). The columnSize variable was extracted from columnSizes array but never used in the SQL_CHAR and SQL_WCHAR cases after OPTIMIZATION #3. Changes: -------- - Removed unused 'SQLULEN columnSize' declaration from SQL_CHAR/VARCHAR/LONGVARCHAR case - Removed unused 'SQLULEN columnSize' declaration from SQL_WCHAR/WVARCHAR/WLONGVARCHAR case - Retained fetchBufferSize and isLob which are actually used This fixes Windows build errors: - error C2220: warning treated as error - warning C4189: 'columnSize': local variable is initialized but not referenced The optimization remains intact - metadata is still prefetched from cache-friendly arrays.
Problem: -------- Row creation and assignment had multiple layers of overhead: 1. Per-row allocation: py::list(numCols) creates pybind11 wrapper for each row 2. Cell assignment: row[col-1] = value uses pybind11 operator[] with bounds checking 3. Final assignment: rows[i] = row uses pybind11 list assignment with refcount overhead 4. Fragmented allocation: 1,000 separate py::list() calls instead of batch allocation For 1,000 rows: ~30-50 CPU cycles × 1,000 = 30K-50K wasted cycles Solution: --------- Replace pybind11 wrappers with direct Python C API throughout: 1. Row creation: PyList_New(numCols) instead of py::list(numCols) 2. Cell assignment: PyList_SET_ITEM(row, col-1, value) instead of row[col-1] = value 3. Final assignment: PyList_SET_ITEM(rows.ptr(), i, row) instead of rows[i] = row This completes the transition to direct Python C API started in OPT #2. Changes: -------- - Replaced py::list row(numCols) → PyObject* row = PyList_New(numCols) - Updated all NULL/SQL_NO_TOTAL handlers to use PyList_SET_ITEM - Updated all zero-length data handlers to use direct Python C API - Updated string handlers (SQL_CHAR, SQL_WCHAR) to use PyList_SET_ITEM - Updated complex type handlers (DECIMAL, DATETIME, DATE, TIME, TIMESTAMPOFFSET, GUID, BINARY) - Updated final row assignment to use PyList_SET_ITEM(rows.ptr(), i, row) All cell assignments now use direct Python C API: - Numeric types: Already done in OPT #2 (PyLong_FromLong, PyFloat_FromDouble, etc.) - Strings: PyUnicode_FromStringAndSize, PyUnicode_FromString - Binary: PyBytes_FromStringAndSize - Complex types: .release().ptr() to transfer ownership Impact: ------- - ✅ Eliminates pybind11 wrapper overhead for row creation - ✅ No bounds checking in hot loop (PyList_SET_ITEM is a macro) - ✅ Clean reference counting (objects created with refcount=1, transferred to list) - ✅ Consistent with OPT #2 (entire row/cell management via Python C API) - ✅ Expected 5-10% improvement (smaller than OPT #3, but completes the stack) All type handlers now bypass pybind11 for maximum performance.
…ild fix) Same issue as OPT #3 - Windows compiler treats warnings as errors (/WX). The columnSize variable was extracted but unused in SQL_CHAR and SQL_WCHAR cases after OPTIMIZATION #4. Changes: -------- - Removed unused 'SQLULEN columnSize' from SQL_CHAR/VARCHAR/LONGVARCHAR - Removed unused 'SQLULEN columnSize' from SQL_WCHAR/WVARCHAR/WLONGVARCHAR - Retained fetchBufferSize and isLob which are actively used Fixes Windows build error C4189 treated as error C2220.
Eliminates switch statement overhead from hot loop by pre-computing function pointer dispatch table once per batch instead of per cell. Problem: - Previous code evaluated switch statement 100,000 times for 1,000 rows × 10 cols - Each switch evaluation costs 5-12 CPU cycles - Total overhead: 500K-1.2M cycles per batch Solution: - Extract 10 processor functions for common types (INT, VARCHAR, etc.) - Build function pointer array once per batch (10 switch evaluations) - Hot loop uses direct function calls (~1 cycle each) - Complex types (Decimal, DateTime, Guid) use fallback switch Implementation: - Created ColumnProcessor typedef for function pointer signature - Added ColumnInfoExt struct with metadata needed by processors - Implemented 10 inline processor functions in ColumnProcessors namespace: * ProcessInteger, ProcessSmallInt, ProcessBigInt, ProcessTinyInt, ProcessBit * ProcessReal, ProcessDouble * ProcessChar, ProcessWChar, ProcessBinary - Build processor array after OPT #3 metadata prefetch - Modified hot loop to use function pointers with fallback for complex types Performance Impact: - Reduces dispatch overhead by 70-80% - 100,000 switch evaluations → 10 setup switches + 100,000 direct calls - Estimated savings: ~450K-1.1M cycles per 1,000-row batch Builds successfully on macOS Universal2 (arm64 + x86_64)
bewithgaurav
changed the title
FEAT: Performance Improvements
FEAT: Performance Improvements in Fetch path
Problem: Previous implementation allocated rows twice per batch: 1. rows.append(py::none()) - create None placeholders 2. PyList_New(numCols) - create actual row 3. PyList_SET_ITEM - replace placeholder This caused ~2x allocation overhead for large result sets. Root Cause: Deviated from proven profiler branch implementation which uses single-pass allocation strategy. Solution: Match profiler branch approach: 1. PyList_New(numCols) + PyList_Append - pre-allocate rows once 2. PyList_GET_ITEM - retrieve pre-allocated row 3. Fill row directly (no replacement) Impact: - Eliminates duplicate allocation overhead - Should restore performance to profiler branch levels - Critical for large result sets (1000+ rows) Testing: Built successfully on macOS Universal2 (arm64 + x86_64)
Coverage Gap Identified: - 83% diff coverage showed missing lines in processor functions - NULL early returns in ProcessBigInt, ProcessTinyInt, ProcessBit, ProcessReal were not exercised by existing tests Root Cause: - Existing tests cover VARCHAR/NVARCHAR/VARBINARY/DECIMAL NULLs - Missing tests for INT, BIGINT, SMALLINT, TINYINT, BIT, REAL, FLOAT NULLs Solution: Added test_all_numeric_types_with_nulls() that: - Creates table with 7 numeric type columns - Inserts row with all NULL values - Inserts row with actual values - Validates NULL handling in all numeric processor functions - Validates actual value retrieval works correctly Impact: - Should improve diff coverage from 83% to near 100% - Ensures NULL handling code paths are fully exercised - Validates processor function NULL early return logic
Coverage Gaps Addressed: - LOB fallback paths (lines 3313-3314, 3358-3359, 3384-3385) - GUID NULL handling (lines 3632-3633) - DATETIMEOFFSET NULL handling (lines 3624-3625) New Tests Added: 1. test_lob_data_types(): - Tests VARCHAR(MAX), NVARCHAR(MAX), VARBINARY(MAX) - Creates 10KB data to trigger LOB handling - Exercises FetchLobColumnData() fallback paths - Covers ProcessChar, ProcessWChar, ProcessBinary LOB branches 2. test_guid_with_nulls(): - Tests UNIQUEIDENTIFIER with NULL values - Validates NULL indicator check in GUID processing - Covers line 3632-3633 (NULL GUID handling) 3. test_datetimeoffset_with_nulls(): - Tests DATETIMEOFFSET with NULL values - Validates NULL indicator check in DTO processing - Covers line 3624-3625 (NULL DTO handling) Expected Impact: - Should improve coverage from 83% to 90%+ - Exercises important LOB code paths - Validates NULL handling in complex types
OPT #3 was creating duplicate metadata arrays (dataTypes, columnSizes, fetchBufferSizes, isLobs) that duplicated data already in columnInfosExt. This added overhead instead of optimizing: - 4 vector allocations per batch - numCols × 4 copy operations per batch - Extra memory pressure The profiler branch doesn't have this duplication and is faster. Fix: Remove duplicate arrays, use columnInfosExt directly in fallback path.
- Renumbered to 4 optimizations (OPT #1-4) for clarity - Integrated performance fixes into respective optimizations - Removed detailed removal/regression sections - Clean presentation for PR reviewers
- Moved typedef ColumnProcessor, struct ColumnInfoExt, and all 10 inline processor functions from ddbc_bindings.cpp to ddbc_bindings.h - Added new 'INTERNAL: Performance Optimization Helpers' section in header - Added forward declarations for ColumnBuffers struct and FetchLobColumnData function - Enables true cross-compilation-unit inlining for performance optimization - Follows C++ best practices for inline function placement Addresses review comments #4, #5, #6 from subrata-ms
…der file - Moved DateTimeOffset struct definition to header (required by ColumnBuffers) - Moved ColumnBuffers struct definition to header (required by inline functions) - Moved typedef ColumnProcessor, struct ColumnInfoExt, and all 10 inline processor functions to header - Added new 'INTERNAL: Performance Optimization Helpers' section in header - Added forward declaration for FetchLobColumnData function - Enables true cross-compilation-unit inlining for performance optimization - Follows C++ best practices for inline function placement Addresses review comments #4, #5, #6 from subrata-ms Build verified successful (universal2 binary for macOS arm64 + x86_64)
Resolved conflict in ddbc_bindings.h by keeping the full struct definitions (DateTimeOffset and ColumnBuffers) which are required by the inline processor functions. The forward declaration alone causes compilation errors.
The inline processor functions in the header were calling FetchLobColumnData, but it was declared as static which gives it internal linkage. This caused 'undefined symbol' linker errors when building on Ubuntu. Changes: - Removed static from FetchLobColumnData in ddbc_bindings.cpp - Moved forward declaration outside ColumnProcessors namespace in header - This gives FetchLobColumnData external linkage so it can be called from inline functions in the header file
Add comprehensive NULL checking for memory safety in all processor functions and batch allocation code. This prevents crashes if Python C API functions fail due to memory allocation issues. Changes: - Add NULL checks to all numeric processor functions (ProcessInteger, ProcessSmallInt, ProcessBigInt, ProcessTinyInt, ProcessBit, ProcessReal, ProcessDouble) - fallback to Py_None on allocation failure - Add NULL checks to ProcessChar for empty and regular strings - Add NULL checks to ProcessWChar for empty and regular wide strings (both UTF-16 decode and PyUnicode_FromWideChar paths) - Add NULL checks to ProcessBinary for empty and regular bytes - Add error handling for PyList_New and PyList_Append in FetchBatchData batch allocation loop This addresses PR #320 review comments from Copilot and sumitmsft about missing NULL checks for PyLong_FromLong, PyFloat_FromDouble, PyUnicode_FromStringAndSize, PyBytes_FromStringAndSize, and PyList_Append. Prevents potential crashes under memory pressure by gracefully handling allocation failures instead of inserting NULL pointers into Python lists.
- Moved NULL checks from inside processor functions to centralized location in main fetch loop - All types (simple and complex) now follow same NULL-checking pattern - Benefits: * Eliminates redundant branch checks (7 NULL checks per row removed) * Improves CPU branch prediction with single NULL check per column * Simplifies processor functions - they now assume non-NULL data * Better code consistency and maintainability Modified files: - ddbc_bindings.cpp: Restructured cell processing loop (lines 3257-3295) * Added centralized NULL/NO_TOTAL check before processor dispatch * NULL values now handled once per column instead of inside each processor - ddbc_bindings.h: Updated all 10 processor functions * ProcessInteger, ProcessSmallInt, ProcessBigInt, ProcessTinyInt, ProcessBit * ProcessReal, ProcessDouble * ProcessChar, ProcessWChar, ProcessBinary * Removed redundant NULL checks from all processors * Added comments documenting NULL check removal (OPTIMIZATION #6) No functional changes - NULL handling behavior unchanged, just moved to more efficient location.
Problem 1: PyList_Append reallocation overhead - Previous code used PyList_Append in a loop, triggering ~10 reallocations for 1000 rows - Each reallocation: allocate new memory + copy all pointers + free old memory - Estimated ~5000 pointer copies for 1000-row batch Problem 2: Two-phase pattern data corruption risk - Phase 1: Created empty rows and appended to list - Phase 2: Filled rows with data - If exception occurred during Phase 2, list contained garbage/partial rows - Example: rows[0:499] = valid, rows[500:999] = empty (corruption) Solution: - Changed to single-phase pattern: create row, fill it, then append - Each row is fully populated before being added to results list - On exception, only complete rows exist in list (no corruption) - Row creation and population now atomic per row - Still uses PyList_Append but each row is complete when added Benefits: - Eliminates data corruption window - Cleaner error handling (no cleanup of partial rows needed) - Rows list always contains valid data - Simpler, more maintainable code Trade-off: - Still has PyList_Append overhead (will address with pre-sizing in future optimization) - But correctness > performance for this fix
- Add test_011_performance_stress.py with 6 critical stress tests - Test batch processing data integrity (1000 rows) - Test memory pressure handling (skipped on macOS) - Test 10,000 empty string allocations - Test 100,000 row fetch without overflow - Test 10MB LOB data with SHA256 integrity check - Test concurrent fetch across 5 threads - Fix missing NULL check in ddbc_bindings.h line 814 for UTF-16 decode error fallback - Add pytest.ini to register 'slow' marker for stress tests - All stress tests marked @pytest.mark.slow (excluded from default pipeline runs)
- Increase LOB test data sizes to guarantee coverage of LOB fetch paths - test_varcharmax_streaming: Use 15KB-20KB (was 8KB-10KB) - test_nvarcharmax_streaming: Use 10KB-12KB (was 4KB-5KB) - test_varbinarymax_insert_fetch: Use 15KB-20KB (was 9KB-20KB) - Ensures FetchLobColumnData() paths (lines 774-775, 830-831, 867-868) are covered - Replace Unicode checkmarks with ASCII [OK] in stress tests for Windows compatibility - Fixes UnicodeEncodeError on Windows CI/CD (cp1252 codec) - Rename 'slow' marker to 'stress' for clarity - pytest -v: Skips stress tests by default (fast) - pytest -m stress: Runs only stress tests - Configure addopts in pytest.ini to exclude stress tests by default
- Delete mssql_python/pybind/unix_buffers.h (dead code) - Remove include from ddbc_bindings.h line 173 - Classes SQLWCHARBuffer, DiagnosticRecords, UCS_dec were never used - Code now uses PyUnicode_DecodeUTF16 directly for better performance
This was referenced
Jan 6, 2026This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters