Skip to content

FEAT: Profiler#552

Draft
bewithgaurav wants to merge 11 commits into
mainfrom
bewithgaurav/profiling
Draft

FEAT: Profiler#552
bewithgaurav wants to merge 11 commits into
mainfrom
bewithgaurav/profiling

Conversation

@bewithgaurav

@bewithgaurav bewithgaurav commented Apr 30, 2026

Copy link
Copy Markdown
Collaborator

Work Item / Issue Reference

ADO Work Item: Fixed AB#44819


Summary

adds a two-layer performance profiler for diagnosing driver slowness across the python and native layers. off by default. the intended use is field diagnostics too: when a user reports a slow query, they can enable profiling, reproduce, and send back a dump that breaks the time down by phase, including inside the native code that a python sampling profiler sees as one opaque block.

  • C++ layer: performance_counter.hpp adds a PERF_TIMER("name") RAII macro, instrumentation across ddbc_bindings.cpp and the connection sources, exposed to python as the ddbc_bindings.profiling submodule (enable/disable/get_stats/get_timeline/reset).
  • python layer: perf_timer.py adds perf_phase("name") context managers around the execute/fetch phases in cursor.py. ddbc:: vs py:: prefixes tell you which layer a timer belongs to.
  • runner: profiler/ CLI (python -m profiler --scenarios ...) with 10 built-in scenarios plus aggregate and timeline reports. the profiler/ package is dev-only and excluded from the shipped wheel; the runtime instrumentation it drives (perf_timer.py, the ddbc_bindings profiling submodule) is what ships.

overhead when off (the default): measured on macOS/arm64 against SQL Server 2022, end-to-end workloads (execute+fetchall, fetchmany, fetchone loops) show no difference vs a no-profiler build. deltas stay within run-to-run noise and the branch is often faster. the per-call cost of a disabled timer is microseconds, orders of magnitude below per-row DB latency, so it does not surface in real workloads. C++ timers early-return before doing any work when disabled.

concurrency: the native counter uses a single global mutex, taken only when profiling is enabled. the target is single-threaded diagnostics where the lock is uncontended. this is a deliberate simplification, documented in performance_counter.hpp.

merged latest main and re-applied the instrumentation on top of the u16string migration and the issue #531 fetch changes. added tests/test_025_profiler.py covering both layers.

bewithgaurav and others added 4 commits April 9, 2026 00:05
Tasks 1, 2, 3: Update profiler, add new profiling points, expand benchmarks

Phase 1: Core Infrastructure (COMPLETE)
- Add performance_counter.hpp with thread-safe RAII profiling
- Integrate profiling submodule into ddbc_bindings.cpp
- Port run_profiler.py and profiling_results.md from old branch
- Support for enable/disable/get_stats/reset via Python API

Phase 2: Documentation (COMPLETE)
- PROFILER_SUMMARY.md: Executive summary and quick reference
- PERF_TIMER_LOCATIONS.md: All 43 timer locations with code snippets
- ENHANCED_PROFILING_PLAN.md: New profiling points and benchmarks
- PROFILER_UPGRADE_STATUS.md: Status tracker and phases

Phase 3: Implementation (TODO)
- 43 PERF_TIMER calls need to be added (documented in detail)
- New profiling points for types, transactions, pool, memory
- Comprehensive benchmark suite (8 new categories)

Key Features:
- Platform detection (Windows/Linux/macOS)
- Per-function timing with min/max/avg
- Granular timers for construct_rows bottleneck
- Designed for Windows vs Linux performance analysis

Reference PR: #147 (original profiler branch)
Based on analysis showing 2.3x Linux slowdown (now 16% after optimizations)
- perf_timer.py: Python phase-level profiling (perf_phase, perf_start/perf_stop)
- performance_counter.hpp: C++ timeline recording, ddbc:: prefix via macro
- cursor.py: Phase timers on execute, fetch*, executemany
- ddbc_bindings.cpp, connection.cpp, connection_pool.cpp: PERF_TIMER calls
- profiler/: CLI package (python -m profiler) with scenarios, timeline mode,
  custom script support (--script), aggregate + waterfall reporters
- my_bench.py: Example custom profiling script
@bewithgaurav bewithgaurav changed the title Bewithgaurav/profiling FEAT: Profiler May 7, 2026
bewithgaurav and others added 3 commits July 6, 2026 15:07
Re-apply py:: and ddbc:: profiling instrumentation on top of main's u16string signature migration, GIL-release changes, and the issue #531 charCtype fetch path. No behavior change to profiling; timers preserved across the refactored execute/fetch/connect paths.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Drop the root-level planning/status notes (ENHANCED_PROFILING_PLAN, LATEST_UPDATE, PERF_TIMER_LOCATIONS, PROFILER_SUMMARY, PROFILER_UPGRADE_STATUS, profiling_results) and my_bench.py. These were working scratch from building the profiler and shouldn't ship. The profiler tooling itself (profiler/, mssql_python/perf_timer.py, performance_counter.hpp) stays.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The merge resolution accidentally scoped main's 'skip detect_and_convert_parameters when re-executing the same SQL' fast path to only the multi-arg branch. Main applies it to both the single-container (execute(sql, (a, b))) and multi-arg forms. Restore main's structure: compute actual_params in the if/else, then run the same-SQL shortcut once for both, still inside the py::execute::param_unpack timer.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment thread mssql_python/cursor.py
self.rowcount = ddbc_bindings.DDBCSQLRowCount(self.hstmt)
with perf_phase("py::execute::post_execute"):
# Update rowcount after execution
# TODO: rowcount return code from SQL needs to be handled
Comment thread profiler/core.py

from profiler import Profiler

p = Profiler("Server=localhost,1433;UID=sa;Pwd=...;Encrypt=no;TrustServerCertificate=yes;")
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

📊 Code Coverage Report

🔥 Diff Coverage

93%


🎯 Overall Coverage

81%


📈 Total Lines Covered: 6978 out of 8583
📁 Project: mssql-python


Diff Coverage

Diff: main...HEAD, staged and unstaged changes

  • mssql_python/cursor.py (96.7%): Missing lines 1576,1578
  • mssql_python/perf_timer.py (98.3%): Missing lines 125
  • mssql_python/pybind/connection/connection.cpp (100%)
  • mssql_python/pybind/connection/connection_pool.cpp (100%)
  • mssql_python/pybind/ddbc_bindings.cpp (68.2%): Missing lines 990-992,998-999,1389,1652,1716,1764,2692-2694,3167,4066
  • mssql_python/pybind/performance_counter.hpp (98.7%): Missing lines 76

Summary

  • Total: 258 lines
  • Missing: 18 lines
  • Coverage: 93%

mssql_python/cursor.py

Lines 1572-1582

  1572             column_metadata = []
  1573             try:
  1574                 ddbc_bindings.DDBCSQLDescribeCol(self.hstmt, column_metadata)
  1575                 self._initialize_description(column_metadata)
! 1576             except Exception as e:  # pylint: disable=broad-exception-caught
  1577                 # If describe fails, it's likely there are no results (e.g., for INSERT)
! 1578                 self.description = None
  1579 
  1580         # Reset rownumber for new result set (only for SELECT statements)
  1581         if self.description:  # If we have column descriptions, it's likely a SELECT
  1582             self.rowcount = -1

mssql_python/perf_timer.py

Lines 121-129

  121         entry["total_ns"] += elapsed
  122         if elapsed < entry["min_ns"]:
  123             entry["min_ns"] = elapsed
  124         if elapsed > entry["max_ns"]:
! 125             entry["max_ns"] = elapsed
  126 
  127     if _timeline_enabled and start_ns:
  128         _timeline.append(
  129             {

mssql_python/pybind/ddbc_bindings.cpp

Lines 986-996

  986                 ThrowStdException(errorString.str());
  987             }
  988         }
  989         assert(SQLBindParameter_ptr && SQLGetStmtAttr_ptr && SQLSetDescField_ptr);
! 990         RETCODE rc;
! 991         {
! 992             PERF_TIMER("BindParameters::SQLBindParameter_call");
  993             rc = SQLBindParameter_ptr(
  994                 hStmt, static_cast<SQLUSMALLINT>(paramIndex + 1), /* 1-based indexing */
  995                 static_cast<SQLUSMALLINT>(paramInfo.inputOutputType),
  996                 static_cast<SQLSMALLINT>(paramInfo.paramCType),

Lines 994-1003

   994                 hStmt, static_cast<SQLUSMALLINT>(paramIndex + 1), /* 1-based indexing */
   995                 static_cast<SQLUSMALLINT>(paramInfo.inputOutputType),
   996                 static_cast<SQLSMALLINT>(paramInfo.paramCType),
   997                 static_cast<SQLSMALLINT>(paramInfo.paramSQLType), paramInfo.columnSize,
!  998                 paramInfo.decimalDigits, dataPtr, bufferLength, strLenOrIndPtr);
!  999         }
  1000         if (!SQL_SUCCEEDED(rc)) {
  1001             LOG("BindParameters: SQLBindParameter failed for param[%d] - "
  1002                 "SQLRETURN=%d, C_Type=%d, SQL_Type=%d",
  1003                 paramIndex, rc, paramInfo.paramCType, paramInfo.paramSQLType);

Lines 1385-1393

  1385     return instance;
  1386 }
  1387 
  1388 void DriverLoader::loadDriver() {
! 1389     PERF_TIMER("DriverLoader::loadDriver");
  1390     std::call_once(m_onceFlag, [this]() {
  1391         LoadDriverOrThrowException();
  1392         m_driverLoaded = true;
  1393     });

Lines 1648-1656

  1648 
  1649 SQLRETURN SQLColumns_wrap(SqlHandlePtr StatementHandle, const py::object& catalogObj,
  1650                           const py::object& schemaObj, const py::object& tableObj,
  1651                           const py::object& columnObj) {
! 1652     PERF_TIMER("SQLColumns_wrap");
  1653     if (!SQLColumns_ptr) {
  1654         ThrowStdException("SQLColumns function not loaded");
  1655     }

Lines 1712-1720

  1712     return errorInfo;
  1713 }
  1714 
  1715 py::list SQLGetAllDiagRecords(SqlHandlePtr handle) {
! 1716     PERF_TIMER("SQLGetAllDiagRecords");
  1717     LOG("SQLGetAllDiagRecords: Retrieving all diagnostic records for handle "
  1718         "%p, handleType=%d",
  1719         (void*)handle->get(), handle->type());
  1720     if (!SQLGetDiagRec_ptr) {

Lines 1760-1768

  1760 }
  1761 
  1762 // Wrap SQLExecDirect
  1763 SQLRETURN SQLExecDirect_wrap(SqlHandlePtr StatementHandle, const std::u16string& Query) {
! 1764     PERF_TIMER("SQLExecDirect_wrap");
  1765     LOG("SQLExecDirect: Executing query directly - statement_handle=%p, "
  1766         "query_length=%zu chars",
  1767         (void*)StatementHandle->get(), Query.length());
  1768     if (!SQLExecDirect_ptr) {

Lines 2688-2698

  2688             RETCODE rc;
  2689             {
  2690                 PERF_TIMER("BindParameterArray::SQLBindParameter_call");
  2691                 rc = SQLBindParameter_ptr(hStmt, static_cast<SQLUSMALLINT>(paramIndex + 1),
! 2692                                          static_cast<SQLUSMALLINT>(info.inputOutputType),
! 2693                                          static_cast<SQLSMALLINT>(info.paramCType),
! 2694                                          static_cast<SQLSMALLINT>(info.paramSQLType), info.columnSize,
  2695                                          info.decimalDigits, dataPtr, bufferLength, strLenOrIndArray);
  2696             }
  2697             if (!SQL_SUCCEEDED(rc)) {
  2698                 LOG("BindParameterArray: SQLBindParameter failed - "

Lines 3163-3171

  3163 SQLRETURN SQLGetData_wrap(SqlHandlePtr StatementHandle, SQLUSMALLINT colCount, py::list& row,
  3164                           const std::string& charEncoding = "utf-16le",
  3165                           const std::string& wcharEncoding = "utf-16le",
  3166                           int charCtype = SQL_C_WCHAR) {
! 3167     PERF_TIMER("SQLGetData_wrap");
  3168     // Note: wcharEncoding parameter is reserved for future use
  3169     // Currently WCHAR data always uses UTF-16LE for Windows compatibility
  3170     (void)wcharEncoding;  // Suppress unused parameter warning

Lines 4062-4070

  4062             ret);
  4063         return ret;
  4064     }
  4065     // Pre-cache column metadata to avoid repeated dictionary lookups
! 4066     PERF_TIMER("FetchBatchData::cache_column_metadata");
  4067     struct ColumnInfo {
  4068         SQLSMALLINT dataType;
  4069         SQLULEN columnSize;
  4070         SQLULEN processedColumnSize;

mssql_python/pybind/performance_counter.hpp

Lines 72-80

  72         timeline_enabled_ = true;
  73         epoch_ = std::chrono::high_resolution_clock::now();
  74     }
  75     void disable_timeline() { timeline_enabled_ = false; }
! 76     bool is_timeline_enabled() const { return timeline_enabled_; }
  77 
  78     void record(const std::string& name, int64_t duration_us,
  79                 std::chrono::time_point<std::chrono::high_resolution_clock> start) {
  80         if (!enabled_) return;


📋 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: 59.9%
mssql_python.pybind.logger_bridge.hpp: 70.8%
mssql_python.pybind.ddbc_bindings.cpp: 76.5%
mssql_python.__init__.py: 77.3%
mssql_python.row.py: 77.6%
mssql_python.ddbc_bindings.py: 79.6%
mssql_python.pybind.connection.connection_pool.cpp: 80.4%
mssql_python.connection.py: 83.6%
mssql_python.logging.py: 85.5%

🔗 Quick Links

⚙️ Build Summary 📋 Coverage Details

View Azure DevOps Build

Browse Full Coverage Report

Pick up 1.11.0 release, context-manager transaction (#639), bulkcopy timeout (#650), py-core 0.1.6, and macOS dylib config (#661). No profiler conflicts; auto-merge clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions github-actions Bot added the pr-size: large Substantial code update label Jul 14, 2026
bewithgaurav and others added 3 commits July 14, 2026 11:55
find_packages() was picking up the top-level profiler/ package, so the internal benchmark CLI (and a generic 'profiler' top-level name) would ship to PyPI. Exclude it. The runtime instrumentation it drives (perf_timer.py, the ddbc_bindings profiling submodule) lives inside mssql_python and still ships.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Cover both layers: the python perf_timer (perf_phase/perf_start/perf_stop, enable/disable, stats, timeline, reset vs reset_stats_only) and the C++ ddbc_bindings.profiling submodule (toggle, live query capture, timeline, reset). Autouse fixture resets and disables both layers around every test so profiling state never leaks into the rest of the suite.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Note in performance_counter.hpp that the global mutex is taken only when profiling is enabled, targets single-threaded diagnostics where it is uncontended, and is a deliberate simplification (thread_local is the upgrade path if multithreaded profiling ever matters).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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