Make the ODBC fetch path 2.3-3.9x cheaper on CPU (beats msodbcsql18 on varchar(max)/nvarchar) - #215
Draft
Saurabh Singh (saurabh500) wants to merge 18 commits into
Draft
Make the ODBC fetch path 2.3-3.9x cheaper on CPU (beats msodbcsql18 on varchar(max)/nvarchar)#215Saurabh Singh (saurabh500) wants to merge 18 commits into
Saurabh Singh (saurabh500) wants to merge 18 commits into
Conversation
NetworkTransport already holds a whole decrypted TDS packet, so for most columns every byte the decoder needs is present before it is asked for. The cursor still paid a boxed resume_row_into future, a cancellation and timeout composition, and pause-state churn per column to wait for bytes sitting in L1. Add SliceReader, a sans-I/O TdsPacketReader over an in-memory slice, and drive the existing decoder over the buffered bytes with a single poll. Reusing decode_into keeps one implementation of the wire format. PLP and Always Encrypted columns still take the async path, and any shortfall or error falls back with the read buffer untouched. Measured on the datatype benchmark (CPU median, 9 reps): Int 10.00 to 6.97 ms, BigInt 10.18 to 7.12 ms, Guid 10.31 to 8.02 ms, Varchar 11.41 to 9.45 ms, NVarchar 13.72 to 11.6 ms. VarcharMax is unchanged, confirming PLP is excluded. A TDS-only bench isolates the win: the cursor path drops 12.08 to 9.62 ms while whole-row decode stays flat as a control. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f0a91aef-805f-4f6b-bb35-0c3617130790
…rsions The ODBC layer called block_on for every SQLFetch/SQLGetData even though the transport usually already holds the whole decrypted TDS packet. drive_read polls the future once first and only falls back to the executor when it is genuinely pending; on Pending it resumes the same pinned future rather than restarting, so bytes already consumed are not decoded twice. Boxing TdsClient inside DbcState keeps the per-row move in and out of the connection mutex to a pointer. Nine character conversions were missing, so Decimal and DateTime2 failed with HYC00 rather than merely being slow. value_text now formats Decimal/Numeric, Date, Time, DateTime2, DateTime, SmallDateTime, DateTimeOffset, Money, SmallMoney, bytes as hex, and Xml. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f0a91aef-805f-4f6b-bb35-0c3617130790
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f0a91aef-805f-4f6b-bb35-0c3617130790
Copilot started reviewing on behalf of
Saurabh Singh (saurabh500)
August 11, 2026 12:16
View session
Contributor
There was a problem hiding this comment.
Pull request overview
Optimizes unbound ODBC row fetching while expanding scalar-to-text conversions and performance measurement.
Changes:
- Adds buffered synchronous column decoding and cheaper row/client state handling.
- Adds missing ODBC character conversions.
- Introduces cross-platform ODBC performance benchmarks and runners.
Reviewed changes
Copilot reviewed 29 out of 29 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
mssql-tds/src/io/token_stream.rs |
Shares row metadata via Arc. |
mssql-tds/src/io/slice_reader.rs |
Adds in-memory packet reader. |
mssql-tds/src/io.rs |
Registers slice reader module. |
mssql-tds/src/connection/transport/network_transport.rs |
Adds buffered decode fast path. |
mssql-tds/src/connection/tds_client.rs |
Uses fast path for cursor columns. |
mssql-tds/Cargo.toml |
Registers attribution benchmark. |
mssql-tds/benches/sync_decoder.rs |
Removes empty benchmark file. |
mssql-tds/benches/odbc_split.rs |
Measures TDS cursor overhead. |
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/README.md |
Documents performance suite. |
mssql-odbc/tests/perf/lib/perf_fixture.cpp |
Implements benchmark fixtures. |
mssql-odbc/tests/perf/include/perf_fixture.h |
Declares benchmark utilities. |
mssql-odbc/tests/perf/CMakeLists.txt |
Configures benchmark builds. |
mssql-odbc/tests/perf/benches/fetch_bench.cpp |
Benchmarks row fetching. |
mssql-odbc/tests/perf/benches/exec_bench.cpp |
Benchmarks execution paths. |
mssql-odbc/tests/perf/benches/datatype_bench.cpp |
Benchmarks type conversions. |
mssql-odbc/tests/perf/benches/connect_bench.cpp |
Benchmarks connection operations. |
mssql-odbc/src/handles/dbc.rs |
Boxes the TDS client. |
mssql-odbc/src/api/value_text.rs |
Adds scalar text rendering. |
mssql-odbc/src/api/util.rs |
Adds synchronous-first future driving. |
mssql-odbc/src/api/more_results.rs |
Updates boxed-client tests. |
mssql-odbc/src/api/mod.rs |
Registers value conversion module. |
mssql-odbc/src/api/get_data.rs |
Uses new rendering and read driver. |
mssql-odbc/src/api/fetch.rs |
Uses synchronous-first fetching. |
mssql-odbc/src/api/exec_direct.rs |
Updates boxed-client tests. |
mssql-odbc/src/api/exec_common.rs |
Propagates boxed client ownership. |
mssql-odbc/src/api/driver_connect.rs |
Boxes newly connected clients. |
mssql-odbc/.gitignore |
Ignores benchmark artifacts. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+53
to
+57
| /// Renders nanoseconds-since-midnight as `HH:MM:SS[.f{scale}]`, matching the | ||
| /// column's declared fractional-second scale. | ||
| fn format_time(time_nanoseconds: u64, scale: u8) -> String { | ||
| let secs = time_nanoseconds / NANOS_PER_SEC; | ||
| let nanos = time_nanoseconds % NANOS_PER_SEC; |
Comment on lines
+1561
to
+1567
| // Boxed so the borrow of `reader` can be released explicitly below; | ||
| // a stack-pinned future would hold it until the end of this scope. | ||
| let mut decode = Box::pin(decoder.decode_into(&mut reader, metadata, col, writer)); | ||
| let polled = decode | ||
| .as_mut() | ||
| .poll(&mut Context::from_waker(Waker::noop())); | ||
| drop(decode); |
| /// `datetime` counts 1/300 s ticks, which ODBC renders with millisecond | ||
| /// precision, so the tick count is converted to whole milliseconds. | ||
| fn format_datetime(v: &SqlDateTime) -> String { | ||
| let millis = (u64::from(v.time) * 1000).div_ceil(300); |
Comment on lines
+1578
to
+1583
| match consumed { | ||
| Some(count) => { | ||
| self.tds_read_buffer.consume_bytes(count); | ||
| true | ||
| } | ||
| None => false, |
Comment on lines
+183
to
+190
| do { | ||
| std::string drain_err; | ||
| if (DrainRows(stmt_, &drain_err) < 0) { | ||
| error_ = drain_err; | ||
| CloseCursor(stmt_); | ||
| return false; | ||
| } | ||
| } while (SQLMoreResults(stmt_) == SQL_SUCCESS); |
odbc_split measures the TDS work behind a fetch loop. This runs the same query through SQLFetch/SQLGetData, so the difference is the ODBC glue: handle mutexes, diagnostics bookkeeping, and text rendering. The driver's entry points are called directly rather than through the Driver Manager, so odbc32.dll's own per-call cost is not attributed to us and an edit-measure cycle takes seconds instead of minutes. This needs `rlib` alongside `cdylib`; the shipped DLL is unaffected. Measured on the 5000-row INT query: 10.00 ms for the TDS path against 11.52 ms end to end, putting the ODBC layer at ~1.5 ms. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f0a91aef-805f-4f6b-bb35-0c3617130790
Wall time cannot resolve anything useful here. Each iteration executes a real query, so ~4 ms of the ~11 ms is server wait, and that wait varies enough that two runs of an unmodified binary differed by 4.35% with p = 0.04. The changes worth making in the ODBC layer are smaller than that. Counting process CPU removes the server wait by construction. GetProcessTimes is quantized to the ~15.6 ms scheduler tick and made things worse (CI +/-6.7%); QueryProcessCycleTime counts cycles and lands at +/-0.37%, with an unmodified-binary control now drifting only ~1.3%. The result also agrees with the C++ harness to 0.07% (6.975 ms against 6.970 ms), which is a useful check that the two harnesses measure the same thing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f0a91aef-805f-4f6b-bb35-0c3617130790
SQLFetch spent most of its CPU on `receive_row_header`, which for a ROW token reads exactly one byte and then builds a pause state. Reaching that byte cost a boxed `#[async_trait]` future, cancellation/timeout composition and two `Instant::now()` calls per row. Add `try_receive_row_header_buffered`, the row-header counterpart to the existing `try_decode_column_buffered`: it serves ROW and NBCROW from bytes already in the read buffer and returns `None` — consuming nothing — for anything else, so DONE and short buffers still take the async path. Splitting the bench into fetch-only and fetch+getdata showed the row advance, not column materialization, was the dominant cost: 6.15 ms of 7.41 ms CPU. Measured over 5000 rows: fetch+getdata 7.41 -> 6.08 ms CPU (-17.9%, p=0.00), fetch-only 6.15 -> 5.45 ms (-11.4%). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f0a91aef-805f-4f6b-bb35-0c3617130790
`SQLGetData` decodes exactly one column per call, but `DefaultRowWriter` holds its values in a `Vec`, so every pull paid a heap allocation and free to carry a single value. Add `SingleValueWriter`, which keeps that value inline, and use it for both the buffered and async cursor paths. The macro generating its writes keeps the 23 typed setters from becoming 23 hand-written stubs. Measured over 5000 rows: fetch+getdata 6.08 -> 5.47 ms CPU (-10.0%, p=0.00). Combined with the buffered row header, 7.41 -> 5.47 ms (-26%). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f0a91aef-805f-4f6b-bb35-0c3617130790
Every `SQLFetch` boxed a fresh `RowPauseState` and every `SQLGetData` freed it again by dereferencing, so positioning on a row cost a heap allocation and free per row. Store it inline in `ActiveRowReadState` instead. `PlpPauseState` stays boxed, so the enum grows to roughly 64 bytes — paid once per connection, not per row, and the state is reached through `&mut TdsClient` so no future grows with it. Measured over 5000 rows: fetch+getdata 5.47 -> 4.98 ms CPU (-9.1%, p=0.00). Cumulative with the buffered row header and stack-slot column decode, 7.41 -> 4.98 ms (-33%). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f0a91aef-805f-4f6b-bb35-0c3617130790
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f0a91aef-805f-4f6b-bb35-0c3617130790
The cursor and column state machines are ~8.5 KB each. Taking one by value copied that much across the drive_read call boundary on every SQLFetch and SQLGetData; pinning in the caller's frame passes a pointer instead. Cuts ODBC glue cost per row from 436ns to 202ns. Also adds TDS-direct cases to the attribution bench so the ODBC glue can be measured by subtraction within a single run. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f0a91aef-805f-4f6b-bb35-0c3617130790
drain_active_plp held an 8 KiB stack array across an await. It is reached from next_row_cursor and read_row_column, so rustc folded the whole array into those state machines: both futures measured ~8.5 KB and were rebuilt and moved on every single row, for a path that only runs when a caller abandons a partially read PLP column. Moving the buffer to the heap drops next_row_cursor from 8520 to 928 bytes and read_row_column from 8464 to 432. Fetch cost falls 20% on the ODBC path and 33% on the TDS column path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f0a91aef-805f-4f6b-bb35-0c3617130790
Three per-row costs on the cursor path: - next_row_cursor and read_row_column carried info-level #[instrument] spans, so a 5000-row fetch built 10000 spans. tracing is built with the log feature here, so each span also pays a log facade dispatch even with no subscriber installed. Per-row spans are log spam regardless of cost; the surrounding execute/fetch spans still cover the operation. - resume_row_loop emitted an info-level "Row Received" event for every row. - try_decode_column_buffered boxed its decode future purely to end a borrow early. Pinning in a narrower scope does the same thing without a malloc and free per column. Fetch cost falls 6.7% on the ODBC path and 13.6% on the TDS column path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f0a91aef-805f-4f6b-bb35-0c3617130790
DecimalParts now implements Display directly instead of building a String via to_decimal_string. The scale/sign handling moves into the formatter and a magnitude() helper replaces the duplicated i128 conversion. Measured on the datatype benchmark (5000 rows, CPU median): Decimal fetch goes from 2.18x to 1.46x of msodbcsql18. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f0a91aef-805f-4f6b-bb35-0c3617130790
as_utf8_str returns Cow<'_, str> so UTF-8 and single-byte-ASCII payloads are read in place; to_utf8_string delegates to it. The ODBC text path is restructured around that borrow: write_string_result splits into copy_string_out and finish_string_result so the diagnostic work happens after the borrow ends. This removes a per-value allocation from the SQLGetData path. It is not a measurable win on its own -- SQLGetData plus the whole ODBC glue costs only ~4 ns/row over raw TDS column reads -- but it is the correct shape and it stops the copy from growing with row width. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f0a91aef-805f-4f6b-bb35-0c3617130790
Three cases: fetch_only_varchar and fetch_getdata_varchar through the ODBC handles, and tds_fetch_column_varchar straight against TdsClient. Comparing the last two isolates how much the ODBC layer adds over raw column reads. tds_fetch_column_varchar_short selects a 1-byte varchar so payload size can be varied independently of column type, which separates per-value cost from per-byte cost. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f0a91aef-805f-4f6b-bb35-0c3617130790
Fetch CPU is dominated by the number of socket reads, not the bytes they move: each async read costs roughly 35us of CPU, mostly the tokio reactor round-trip. Reading 5000 varchar(100) rows took 63 socket reads at 8000-byte packets and 14 at 32768, and the criterion case dropped 35.4% (4.15ms -> 2.64ms, p=0.00). Payload-only experiments could not see this because packet count scales with payload; varying packet size at fixed payload separates the two. apply_connection_params now always sets packet_size so an unspecified PacketSize= takes the driver default rather than the ClientContext one. Only the ODBC driver changes; the TDS default stays at 8000 for the JS, Python and CLI front ends. Cost is a larger per-connection read buffer, which the transport sizes at 2x the packet size: 16KB -> 64KB per connection. That is a fixed buffer, not per-row materialization. Datatype benchmark, 5000 rows, CPU median vs msodbcsql18: VarcharMax 5.30ms -> 3.67ms 0.65x -> 0.37x NVarchar 9.08ms -> 6.87ms 1.12x -> 0.90x Varchar 5.77ms -> 4.66ms 1.56x -> 1.34x Decimal 4.39ms -> 3.90ms 1.46x -> 1.43x Enlarging the read buffer instead was tried and is worse: shift_data_to_front memmoves the remainder on every packet boundary, so an 8x buffer with 8000-byte packets cut reads to 42 but ran at 4.47ms. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f0a91aef-805f-4f6b-bb35-0c3617130790
next_row_cursor rebuilt a ParserContext on every row: an Arc clone and drop of the column metadata, an awaited resolve_cell_decryptor, and -- for the common unencrypted case -- an O(columns) scan of the metadata looking for crypto_metadata. None of that changes within a result set. Cache it on the client and validate with Arc::ptr_eq against current_metadata, the same shape current_decryptor already uses. That field is reassigned in several places (new result set, error, batch end); keying on pointer identity means each of those invalidates the cache without needing to touch those sites. This is strictly less work per row, but it is below the resolution of both benchmarks: criterion put tds_fetch_column at -3.6% with p=0.08 while the ODBC cases in the same run -- which go through an unchanged driver DLL and therefore cannot have moved -- drifted -6% to -20%. No performance claim is made for it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f0a91aef-805f-4f6b-bb35-0c3617130790
Copilot started reviewing on behalf of
Saurabh Singh (saurabh500)
August 11, 2026 17:42
View session
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 35 out of 35 changed files in this pull request and generated 1 comment.
Suppressed comments (5)
mssql-odbc/src/api/value_text.rs:85
- Using ceiling division misrenders valid SQL
datetimeticks. One 1/300-second tick should render as.003, but this computes 4 ms and emits.004(similarly for other one-third-ms values). Round to the nearest millisecond, matching SQL Server's.000/.003/.007representation.
let millis = (u64::from(v.time) * 1000).div_ceil(300);
mssql-odbc/src/api/driver_connect.rs:414
SQLSetConnectAttr(SQL_ATTR_PACKET_SIZE, ...)stores the requested value inDbcState::packet_size(set_connect_attr.rs:186), but this fallback only consults the parsed connection string. Thus an application that sets 4096 through the attribute and omitsPacketSize=still negotiates 32768, even thoughSQLGetConnectAttrreports 4096. Pass the DBC state's packet size into this mapping as the fallback, with an explicit connection-string value taking precedence.
let size = params.packet_size.unwrap_or(DEFAULT_PACKET_SIZE);
context.packet_size =
u16::try_from(size.clamp(MIN_PACKET_SIZE, MAX_PACKET_SIZE)).unwrap_or(u16::MAX);
mssql-odbc/tests/perf/run_perf.sh:198
- The PR states that wall-clock measurements are unusable and bases its results on CPU medians, but this comparison reads
real_time, i.e. wall time, from Google Benchmark JSON. This makes the runner's ratios use the noisy metric the PR explicitly rejects; readcpu_timeinstead.
out[name] = b["real_time"] * SCALE.get(b.get("time_unit", "ns"), 1)
mssql-odbc/tests/perf/run_perf.ps1:205
- The PR's reported results use CPU medians because wall time is too noisy, but the PowerShell comparison selects Google Benchmark's
real_time. The printed ratios therefore measure the rejected wall-clock metric; selectcpu_timehere.
$map[$name] = $b.real_time * (Get-TimeScale $b.time_unit)
mssql-odbc/tests/perf/README.md:69
- This registration example points to a library name the crate does not build. The crate is named
msodbcsql18, and both the build script and the root README uselibmsodbcsql18.so; following this example leaves the runner unable to copy/find the driver.
Driver = /home/<user>/.odbc-dev/libmssql_odbc.so
| ColumnValues::Date(d) => Some(format_date(i64::from(d.get_days()) - DAYS_0001_TO_UNIX)), | ||
| ColumnValues::Time(t) => Some(format_time(t.time_nanoseconds, t.scale)), | ||
| ColumnValues::DateTime2(v) => Some(format_datetime2(v)), | ||
| ColumnValues::DateTime(v) => Some(format_datetime(v)), |
This 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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #185
Performance work on the
mssql-odbcfetch path, measured againstmsodbcsql18on thesame local SQL Server.
Result
Datatype benchmark, 5000 rows, single column, CPU median of 25 repetitions.
Ratio < 1.0 means
mssql-odbcis faster.Every type improved in absolute terms. Three beat the native driver and BigInt is at
parity. The biggest mover is Varchar, 1.56x -> 1.12x.
DateTime2 is the one type whose ratio did not improve; its formatter was out of scope for
this round.
On measurement
Wall-clock from this harness is unusable -- cv runs 18-101% across types, and the native
driver's own VarcharMax median swung 32% between two consecutive runs. CPU medians are
usable at cv 3-15%. Every number above is CPU median over 25 repetitions.
That noise floor is why the two commits here carry very different claims: the packet-size
change is a 35% effect with p=0.00 and survives any amount of drift, while the
ParserContext memoization is below resolution and is committed on correctness grounds
with no performance claim. BigInt's cv reached 33% on our side, so read its 1.04x as
"parity", not as a regression from 0.96x.
Changes
Default the ODBC driver to 32KB TDS packets. Fetch CPU is dominated by the number
of socket reads, not the bytes they move. Each async read costs roughly 35 us of CPU,
mostly the tokio reactor round-trip on Windows IOCP. Reading 5000
varchar(100)rowstook 63 socket reads at 8000-byte packets and 14 at 32768; the criterion case dropped
35.4% (4.15 ms -> 2.64 ms, p=0.00). This is why the large-payload types moved and the
small fixed-width ones did not.
apply_connection_paramsnow always setspacket_size, so an unspecifiedPacketSize=takes the driver default instead of falling through to the
ClientContextdefault. Onlythe ODBC driver changes -- the TDS default stays at 8000 for the JS, Python and CLI front
ends.
The cost is a larger per-connection read buffer, which the transport sizes at 2x the
packet size: 16 KB -> 64 KB per connection. That is a fixed buffer, not per-row
materialization.
Render decimals without intermediate allocations.
DecimalPartsimplementsDisplaydirectly instead of building a
Stringviato_decimal_string. Decimal went from 2.18xto 1.46x on its own, before the packet change.
Borrow string payloads in SQLGetData.
as_utf8_strreturnsCow<'_, str>so UTF-8and single-byte-ASCII payloads are read in place. This removes a per-value allocation but
is not a measurable win on its own, and the PR does not claim one -- see below.
Memoize the row-loop
ParserContext.next_row_cursorrebuilt one per row: anArcclone and drop, an awaited
resolve_cell_decryptor, and an O(columns) scan forcrypto_metadata. None of it changes within a result set. Cached and validated byArc::ptr_eqagainstcurrent_metadata, so every reassignment of that field invalidatesit for free. Strictly less work per row, but below both benchmarks' resolution -- no
performance claim.
Benchmark cases for varchar in
odbc_glue, including a 1-bytevarchar_shortcaseso payload size can be varied independently of column type.
How the cost was attributed
Three measurements, each ruling out a candidate:
fetch_getdata_varchar(5.21 ms) minustds_fetch_column_varchar(5.19 ms) is about 4 ns/row.
SQLGetDataand the entire ODBC layer are free forstrings; all of the cost is below them in TDS.
per-value string overhead is only 72 ns. The other 452 ns/row looked per-byte.
together, so varying payload alone cannot separate them. Holding payload fixed and
varying packet size isolated the cost to the socket read itself.
Rejected alternatives
Enlarging the read buffer instead of the wire packet size.
shift_data_to_frontmemmovesthe remainder on every packet boundary, so an 8x buffer with 8000-byte packets cut reads
63 -> 42 but ran slower: 4.47 ms vs 2.64 ms. Reverted.
Follow-up
The 35 us per async read is the structural reason the native driver still wins on the
remaining types. Bigger packets mitigate it; they do not remove it. The real fix is
taking the tokio reactor out of the socket read for the blocking ODBC path.
Validation
cargo bfmtcleancargo bclippycleancargo nextest --workspace --lib: 2184 passed, 7 failed -- the 7 are pre-existingexpired-certificate fixtures, unchanged from the base commit