Batch TDS row fetch to make mssql-odbc faster than msodbcsql18 - #186
Batch TDS row fetch to make mssql-odbc faster than msodbcsql18#186Saurabh Singh (saurabh500) wants to merge 4 commits into
Conversation
Measure the ODBC driver through the Driver Manager and compare it against
msodbcsql18. Both drivers are selected by the Driver={...} connection string
keyword, so the same binaries produce both sides of the comparison and a run
needs no registry edits or elevation.
Covers connect/disconnect, execute paths, row fetch at several sizes, and
per-type retrieval cost. Cases the driver cannot service are reported as
unsupported instead of being dropped, so capability gaps stay visible.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f0a91aef-805f-4f6b-bb35-0c3617130790
The ODBC fetch path paid a boxed `#[async_trait]` future, a cancellation wrapper, a timeout wrapper, two `Instant::now()` calls, several `Arc` clones and a full mutex/DBC handoff for every single row. Amortize all of that over a batch. - Add `TdsTokenStreamReader::receive_rows_into` (default impl plus a `NetworkTransport` override) so the batch loop lives below the boxed trait call instead of above it. - Add `TdsClient::fetch_rows_batch`, which resolves metadata, parser context and decryptor once per batch rather than once per row. - Give `DefaultRowWriter` a batch mode that queues completed rows and recycles row buffers from a caller-supplied spare pool. - Prefetch 64 rows in `SQLFetch` and serve subsequent calls from a lock-only fast path. Invalidate the batch wherever the cursor is invalidated. - Box `DbcState::client` and several cold decoder/token-stream arms to keep the per-row future small, and replace a `[0u8; 8192]` stack local in a rare branch that was being baked into every row's future. - Add zero-allocation fixed-width packet reads that serve from the buffer without constructing a future. - Add ASCII fast paths in `SQLGetData` to skip transcoding. - Add `BM_Fetch_RowsOnly` to separate SQLFetch cost from SQLGetData cost. Narrow-row fetch of 10k rows drops from 17.34 ms to 4.49 ms, now faster than msodbcsql18 (4.69 ms). Wide rows improve from 49.8 ms to 44.5 ms. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f0a91aef-805f-4f6b-bb35-0c3617130790
Every variable-length column allocated a fresh `Vec<u8>` and freed it when the row was recycled, so wide rows paid an allocate/free pair per column. - Add `RowWriter::take_string_buffer`, which lets the decoder source its byte buffer from the writer instead of the allocator. - `DefaultRowWriter` harvests the byte buffers out of recycled rows into a pool and hands them back, so a steady-state batch fetch reuses the same allocations rather than churning one per column. - Add `RowWriter::may_pause` so writers that never pause skip the per-column `pause_after_column` dispatch. Wide-row fetch of 10k x 8 varchar columns drops from 44.5 ms to 42.2 ms against msodbcsql18's 40.6 ms, closing the gap from 1.17x to 1.04x. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f0a91aef-805f-4f6b-bb35-0c3617130790
There was a problem hiding this comment.
Pull request overview
Optimizes ODBC row retrieval through batched TDS decoding, buffer reuse, faster SQLGetData, and a cross-platform performance harness.
Changes:
- Prefetches rows in batches and recycles row/string buffers.
- Adds fixed-width packet-read and
SQLGetDatafast paths. - Adds ODBC benchmarks and comparison runners.
Reviewed changes
Copilot reviewed 31 out of 31 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
mssql-tds/src/io/token_stream.rs |
Adds batched row decoding. |
mssql-tds/src/io/packet_reader.rs |
Adds fixed-width read fast paths. |
mssql-tds/src/datatypes/sqldatatypes.rs |
Uses fast scalar reads. |
mssql-tds/src/datatypes/sql_string.rs |
Exposes backing byte buffers. |
mssql-tds/src/datatypes/row_writer.rs |
Adds batching and buffer recycling. |
mssql-tds/src/datatypes/decoder.rs |
Optimizes decoding and allocations. |
mssql-tds/src/connection/transport/network_transport.rs |
Implements transport batching. |
mssql-tds/src/connection/tds_client.rs |
Adds batch-fetch APIs and context caching. |
mssql-odbc/src/handles/stmt.rs |
Stores prefetched rows and GetData state. |
mssql-odbc/src/handles/dbc.rs |
Boxes the TDS client. |
mssql-odbc/src/api/fetch.rs |
Serves batched prefetched rows. |
mssql-odbc/src/api/get_data.rs |
Adds chunk cursors and conversion fast paths. |
mssql-odbc/src/api/prepare.rs |
Invalidates prefetched state. |
mssql-odbc/src/api/more_results.rs |
Resets batch state between results. |
mssql-odbc/src/api/get_type_info.rs |
Clears batch state. |
mssql-odbc/src/api/execute.rs |
Resets state before execution. |
mssql-odbc/src/api/exec_direct.rs |
Resets state before direct execution. |
mssql-odbc/src/api/exec_common.rs |
Updates boxed-client ownership. |
mssql-odbc/src/api/driver_connect.rs |
Stores the boxed client. |
mssql-odbc/src/api/close_cursor.rs |
Discards cursor batch state. |
mssql-odbc/tests/perf/CMakeLists.txt |
Configures benchmark targets. |
mssql-odbc/tests/perf/README.md |
Documents benchmark usage. |
mssql-odbc/tests/perf/run_perf.sh |
Adds Unix benchmark runner. |
mssql-odbc/tests/perf/run_perf.ps1 |
Adds Windows benchmark runner. |
mssql-odbc/tests/perf/include/perf_fixture.h |
Declares benchmark utilities. |
mssql-odbc/tests/perf/lib/perf_fixture.cpp |
Implements benchmark plumbing. |
mssql-odbc/tests/perf/benches/fetch_bench.cpp |
Benchmarks row retrieval. |
mssql-odbc/tests/perf/benches/exec_bench.cpp |
Benchmarks execution paths. |
mssql-odbc/tests/perf/benches/datatype_bench.cpp |
Benchmarks datatype retrieval. |
mssql-odbc/tests/perf/benches/connect_bench.cpp |
Benchmarks connection and handles. |
mssql-odbc/.gitignore |
Ignores benchmark artifacts. |
Suppressed comments (1)
mssql-odbc/tests/perf/lib/perf_fixture.cpp:237
- Reaching
kMaxChunksPerColumnwithSQL_SUCCESS_WITH_INFOfalls out of the loop and is treated as success, despite the stated guard against a driver that never terminates truncation. Detect that final status and fail the benchmark instead of advancing to the next column with unread data.
for (int chunk = 0; chunk < kMaxChunksPerColumn; chunk++) {
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| let mut writer = DefaultRowWriter::batching(col_count, spare); | ||
| // Boxed deliberately: materializing this future inline in the caller's | ||
| // frame is measurably slower than the single allocation. | ||
| let count = Box::pin(self.get_rows_into(&mut writer, max_rows)).await?; |
| let spare = match stmt.inner.lock() { | ||
| Ok(mut ss) => { | ||
| let mut spare = std::mem::take(&mut ss.row_batch_spare); | ||
| if let Some(previous) = ss.current_row.take() { | ||
| spare.push(previous); | ||
| } | ||
| spare | ||
| } | ||
| Err(_) => Vec::new(), | ||
| }; |
| /// of a cursor is dominated by building and polling that state machine, so | ||
| /// draining a batch amortizes it; the cap bounds the memory a single fetch can | ||
| /// buffer for wide rows. | ||
| const FETCH_BATCH_ROWS: usize = 64; |
| do { | ||
| std::string drain_err; | ||
| if (DrainRows(stmt_, &drain_err) < 0) { | ||
| error_ = drain_err; | ||
| CloseCursor(stmt_); | ||
| return false; | ||
| } | ||
| } while (SQLMoreResults(stmt_) == SQL_SUCCESS); | ||
| CloseCursor(stmt_); |
| if (-not (Test-Path $exe)) { $exe = Join-Path $BuildDir "$benchName.exe" } | ||
| if (-not (Test-Path $exe)) { throw "Benchmark binary not found: $benchName" } | ||
|
|
||
| $out = Join-Path $ResultsDir "$benchName.$label.json" |
| match written { | ||
| ChunkOutcome::Complete => { | ||
| stmt_state.get_data_cursor = Some(GetDataCursor::exhausted(column_number, wide)); | ||
| SQL_SUCCESS |
| // `datetime` counts days from 1900-01-01 in 1/300-second ticks. | ||
| ColumnValues::DateTime(dt) => { | ||
| let days = DAYS_0001_TO_1900 as i64 + dt.days as i64; | ||
| let nanos = (dt.time as u64) * 10_000_000 / 3; |
| exit 1 | ||
| fi | ||
|
|
||
| local out="$RESULTS_DIR/$bench_name.$label.json" |
Splits the execute cost from the drain cost so the two can be attributed independently, mirroring BM_Fetch_RowsOnly on the fetch side. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f0a91aef-805f-4f6b-bb35-0c3617130790
ExecDirect /
|
| Benchmark | mssql-odbc | msodbcsql18 |
|---|---|---|
BM_ExecDirect_SelectOne |
230-238 us | 126-143 us |
BM_ExecDirect_NoDrain |
189 us | 133 us |
BM_PreparedExecute |
168 us | 120 us |
Phase attribution
A temporary Rust harness (since removed) timed each phase of SELECT 1,
p50 over 2800 samples after warmup:
prologue (begin_command + reconnect check + reset_reader) 0.1 us
fill (ALL_HEADERS + UCS-2 SQL into the packet buffer) 1.7 us
flush (finalize -> socket write) 20.1 us
boundary (advance_to_result_boundary) 148.3 us
rows (fetch_rows_batch) 3.6 us
---------
total 176.2 us
Instrumenting the transport showed the wire behaviour is already optimal:
- Exactly one
writeand onereadsyscall per query. - The server's whole response is a single 37-byte TDS packet
(type=4 status=1 len=37), consumed in one read. - Token parsing (
dispatch_token) is ~7 us; row decode is ~4 us.
So driver CPU is roughly 10-15 us. Everything else is time blocked in the
socket read waiting for SQL Server.
Why the socket dominates
A standalone tokio-vs-blocking ping-pong (60-byte request, 37-byte reply,
loopback, TCP_NODELAY, blocking echo server on its own thread) on the
benchmark machine:
| p50 | |
|---|---|
| tokio write | 36.0 us |
| tokio read | 48.7 us |
| tokio round trip | 86.6 us |
| std write | 37.8 us |
| std read | 33.2 us |
| std (blocking) round trip | 76.4 us |
Loopback socket ops on this box cost tens of microseconds even with plain
blocking sockets, and tokio's reactor adds ~10-15 us per round trip on top.
That difference accounts for the bulk of the remaining SELECT 1 gap:
subtracting the socket floor leaves both drivers at roughly the same amount of
SQL Server think time.
Also ruled out by measurement:
- Not TLS.
PreferOffmoved flush only 23.7 -> 20.3 us. - Not a reactor wake-up on the write. Poll counting showed exactly one
poll per send, i.e. the write never returnsPoll::Pending. - Not extra round trips or extra framing. One packet, one read.
- Spinning on
try_readbefore awaiting does not work — tokio readiness is
reactor-driven, so a spin loop that never yields will never observe the
socket become readable and simply burns CPU until it gives up.
What would actually move the needle
Only an architectural change to the I/O strategy: bypassing the tokio reactor
for the request/response hot path (blocking or direct-IOCP sockets on a
dedicated thread). That is a large, risky change well outside the scope of this
PR, and it trades CPU and scalability for latency, so it needs a separate
design discussion.
📊 Code Coverage Report
Diff CoverageDiff: main...HEAD, staged and unstaged changes
Summary
mssql-odbc/src/api/fetch.rsmssql-odbc/src/api/get_data.rsmssql-tds/src/connection/tds_client.rsmssql-tds/src/datatypes/decoder.rsmssql-tds/src/datatypes/row_writer.rsmssql-tds/src/datatypes/sqldatatypes.rsmssql-tds/src/io/packet_reader.rsmssql-tds/src/io/token_stream.rs🔗 Quick Links |
Description
Makes the
mssql-odbcfetch path faster than the nativemsodbcsql18driver.The row path was constructing and polling a fresh async state machine per row. Because
#[async_trait]boxes every trait method call, both the size and the frequency of that construction cost real time per row. EverySQLFetchpaid a boxed trait future, a cancellation wrapper, a timeout wrapper, twoInstant::now()calls, severalArcclones and a full ODBC mutex/DBC handoff — for one row.Changes
Batched row prefetch (the structural fix, worth more than every micro-optimization combined):
TdsTokenStreamReader::receive_rows_intowith a default impl plus aNetworkTransportoverride, so the batch loop lives below the boxed trait call instead of above it.TdsClient::fetch_rows_batch, which resolves metadata, parser context and decryptor once per batch rather than once per row.DefaultRowWritergains a batch mode that queues completed rows and recycles row buffers from a caller-supplied spare pool.SQLFetchprefetches 64 rows and serves subsequent calls from a lock-only fast path. The batch is invalidated everywhere the cursor is invalidated (SQLMoreResults,SQLCloseCursor, re-execute, etc.).Per-column allocation — every variable-length column allocated a
Vec<u8>and freed it on recycle:RowWriter::take_string_bufferlets the decoder source its byte buffer from the writer.DefaultRowWriterharvests byte buffers out of recycled rows into a pool, so a steady-state batch fetch reuses allocations instead of churning one per column.RowWriter::may_pauselets writers that never pause skip the per-columnpause_after_columndispatch.Future size — a large stack local inside an
async fnis baked into the future for every call, even on branches that never execute:[0u8; 8192]stack local in a rare branch ofget_next_row_into(worth 20% on its own).DbcState::clientand several cold decoder / token-stream arms.SQLGetData:varchar(max)chunked reads via an offset cursor + payload cache.Benchmark harness:
BM_Fetch_RowsOnly, which separatesSQLFetchcost fromSQLGetDatacost. This is what made the diagnosis possible and is worth keeping.Results
Median, 10k rows, 7–9 reps, local SQL Server 2022:
BM_Fetch_NarrowRows/10000BM_Fetch_RowsOnly/10000BM_Fetch_WideRows/10000BM_Type_VarcharMaxBM_Type_NVarcharBM_Connect_DisconnectBM_AllocFree_StmtNarrow-row fetch is 3.9× faster than baseline and now beats the native driver.
Not addressed here
BM_Fetch_WideRowsremains 1.04× slower. Closing it needsdecode_into's per-column match split so each arm's future is independent — a large refactor with real regression risk, better as a follow-up.BM_ExecDirect_SelectOne, ~1.8×) is untouched and still under investigation.Notes for reviewers
Behavioral details worth a look:
SQLFetchfast path deliberately does not drain server INFO messages — they can't be attributed to a specific row, so they stay buffered until a call that can report them.maybe_has_unread_rows()gates the observableSQL_NO_DATAbehavior identically.remaining_request_timeoutrather than one per row.Related Issues
Fixes #185
Checklist
cargo bfmtpassescargo bclippypasses (plusscripts/bclippy.ps1formssql-py-core)cargo btestpasses — 2148/2155 unit tests. The 7 failures are pre-existing missingtests/test_certificates/fixtures, verified identical on a stashed baseline.DefaultRowWriter, string-buffer recycling, plus previously untestedformat_date/format_money/ money mixed-endian reassembly.RowWriter::take_string_buffer,RowWriter::may_pause,TdsClient::fetch_rows_batch,SqlString::into_bytes.