Skip to content

Remove per-row dispatch overhead from the row-decode path - #264

Merged
Saurabh Singh (saurabh500) merged 5 commits into
mainfrom
dev/saurabh/row-decode-dispatch-perf
Aug 14, 2026
Merged

Remove per-row dispatch overhead from the row-decode path#264
Saurabh Singh (saurabh500) merged 5 commits into
mainfrom
dev/saurabh/row-decode-dispatch-perf

Conversation

@saurabh500

@saurabh500 Saurabh Singh (saurabh500) commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Description

Removes per-row and per-column dispatch overhead from the TDS row-decode path. Three
commits, all rewriting the same signatures in the same call chain:

receive_row_into_internal -> drive_row_columns -> decode_or_decrypt_column
  -> GenericDecoder::decode_into -> TdsPacketReader methods

Why one PR instead of three. Splitting them means rewriting those exact signatures
three times with two rebases in between. #251 measures ~0% on its own, so it has no
standalone justification — it exists to unblock #257. They are kept as three separate
commits so the PR stays bisectable and reviewable commit by commit.

1eaa9536 — Remove async_trait from SqlTypeDecode (#251)

#[async_trait] boxes every returned future, so each column decode allocated. The trait
now returns impl Future<Output = ...> + Send directly.

  • decode_boxed is added for the SQL_VARIANT recursion — a native async fn that
    recurses into itself is E0733 without an explicit boxing step.
  • + Send is spelled out on the return type; bare AFIT does not promise it, and the
    callers need it.
  • One non-mechanical hunk: StringDecoder's return Ok(ColumnValues::Null) becomes a
    tail expression. This is required, not a drive-by — under #[async_trait] the body
    lived inside Box::pin(async move { ... }), so the return was not a tail expression.
    As a native async fn it is, and clippy::needless_return fires. Without this hunk the
    commit fails cargo bclippy in isolation.

e977feb9 — Convert TdsPacketReader to RPITIT (#252)

The widest change and the one that carries the PR: ~22 methods across both cfg-gated
trait definitions move from #[async_trait] to -> impl Future<...> + Send.

  • The impl TdsPacketReader for Box<dyn TdsPacketReader + Send + Sync> blanket impl is
    deleted — RPITIT is not dyn-compatible, and nothing needed the boxed form once the
    concrete readers were threaded through.
  • PlpChunkStreamReader / PlpColumnStream: seven &mut dyn parameters become a
    generic T.
  • fuzz_support.rs stored three Box<dyn TdsPacketReader>. Those become a two-variant
    FuzzPacketReader enum; FuzzReader and EmptyReader were the only types ever boxed
    there, and only under #[cfg(fuzzing)]. The four fuzz_targets/*.rs construction
    sites are updated to match.

cfa0dfe7 — Make the row-decode chain generic over the RowWriter (#257)

GenericDecoder::decode_into<T, W> where W: RowWriter + ?Sized was already generic. The
writer calls were still vtable calls purely because every caller upstream coerced to
&mut (dyn RowWriter + Send). Four functions in io/token_stream.rs now take
W: RowWriter + Send + ?Sized and writer: &mut W: drive_row_columns,
decode_or_decrypt_column, receive_row_into_internal, and resume_row_into_internal.

resume_row_into_internal (the pull-cursor twin) is included for symmetry. It is free —
its only callers pass dyn, so it adds no extra instantiation. Happy to drop it if a
reviewer prefers the tighter diff.

Object safety. Because the bound is ?Sized, dyn RowWriter + Send still satisfies
W. Every existing caller compiles unchanged — this commit required zero edits
outside the one file — and the public
TdsResultSet::next_row_into(&mut (dyn RowWriter + Send)) stays dyn-compatible.

Read this before reviewing #257's numbers: the win is not realized through the public
API today.
See below.

Where the writer's type is actually erased

Tracing the chain end to end:

mssql-js      BinaryRowWriter (concrete)  ─┐
mssql-py-core PyRowWriter     (concrete)  ─┤
                                           v
ResultSet::next_row_into(&mut (dyn RowWriter + Send))     tds_client.rs
  -> TdsClient::get_next_row_into(&mut (dyn RowWriter))
  -> self.transport.receive_row_into(..., writer)
     ^^^^^^^^^^^^^^  transport: Box<dyn TdsTransport>     tds_client.rs:139   <== ERASURE
  -> NetworkTransport::receive_row_into(&mut (dyn ...))
  -> receive_row_into_internal(...)                       token_stream.rs

Both FFI consumers already hold a concrete writer and coerce to dyn only because the
signature demands it. But the erasure that matters is one level lower:

pub struct TdsClient { pub(crate) transport: Box<dyn TdsTransport>, ... }   // :139
pub(crate) trait TdsTransport: TdsTokenStreamReader + Send + Sync + Debug   // :20

TdsTokenStreamReader is used as a trait object — transitively, as a supertrait of the
boxed TdsTransport. A grep for dyn TdsTokenStreamReader finds nothing because the
boxing is spelled Box<dyn TdsTransport>.

Two obvious ways to recover the concrete writer both fail:

  • Adding a next_row_into_typed<W>(..) where Self: Sized sibling — a Self: Sized method
    cannot be called on a trait object, and get_next_row_into must call through
    Box<dyn TdsTransport>. The typed method would be uncallable at the one place that
    needs it.
  • Genericizing the transport methods outright — a generic method with no Self: Sized
    makes TdsTransport dyn-incompatible, so Box<dyn TdsTransport> stops compiling. It is
    the core of the connection architecture and is also produced in four places in
    tds_connection_provider.rs.

So below the transport boundary W can only ever instantiate as dyn RowWriter + Send,
and #257's production delta is ~0% for structural reasons, not incidental ones.

Shipping it anyway is deliberate: it costs 12 lines and zero risk, it closes #257 with a
measured answer rather than a guess, and it leaves the entire chain below the transport
boundary ready so the win lands for free when that boundary is removed. The blocked
headroom is quantified below rather than left speculative, and filed as a follow-up (#265).

Measured results

Method: in-crate #[cfg(test)] microbenchmark against an in-memory reader, 20,000 rows
per pass, 3 warmup + 9 measured passes, median per run; 3 runs per branch, median of
medians
reported. Four throwaway local branches stacked one commit at a time from
main. Quiet machine, no concurrent builds. Run-to-run noise is ±1–2%. Numbers were
re-measured on this branch, not copied from #247.

Criterion is not usable here: receive_row_into_internal is pub(crate) and a bench is a
separate crate, and mssql-mock-tds caps a result set at ~200 rows via a u16 length
cast. The harness runs a verification pass first, asserting every value round-trips and
the reader lands exactly at end-of-buffer, so a harness bug cannot produce fast-but-wrong
numbers. Full harness and repro steps are in a follow-up comment.

What these numbers do and do not cover. The harness enters at
receive_row_into_internal, so every figure here measures the decode path below that
point. Anything above it is excluded from both the baseline and the result — notably the
CancelHandle::run_until_cancelled wrapper (core.rs:43), and the per-row
tokio::time::timeout on top of it when a request timeout is set. That makes these numbers
a clean isolation of the dispatch change, but not an end-to-end row-throughput prediction.
Those excluded layers are measured separately in #271, which finds the timeout — not the
cancellation wrapper — is where the time goes, and that today only mssql-py-core pays it
(cursor.rs:72 hardcodes timeout: Some(30); the other consumers leave it None, which
timeout_to_duration at tds_client.rs:519 turns into no timeout arm at all). They are
unmeasured here rather than measured and dismissed.

Median ms per 20,000-row pass (lower is better):

Case main +#251 +#252 +#257
39 INT + 9 VARCHAR(6), ROW 157.013 165.663 61.170 55.358
39 INT + 9 VARCHAR(6), NBCROW 147.002 138.772 51.360 47.721
8 × VARCHAR(512) 41.501 42.666 24.327 23.588
contiguous writer, 39+9 (dyn) 139.141 140.605 45.889 43.759
contiguous writer, 8×512 (dyn) 40.107 39.828 23.742 21.071

#251 alone: ~0% (+5.5% / −5.6% / +2.8% / +1.1% across cases — all inside noise; one
run produced a 213.9 ms outlier against a ~156 ms baseline, which is exactly why the
protocol is median-of-3-runs). Confirms the previously reported standalone result. It
ships for API hygiene and as the #257 enabler.

#252 vs main: this is nearly the entire win.

Case main +#252 delta
39 INT + 9 VARCHAR(6), ROW 157.013 61.170 −61.0%
39 INT + 9 VARCHAR(6), NBCROW 147.002 51.360 −65.1%
8 × VARCHAR(512) 41.501 24.327 −41.4%
contiguous writer, 39+9 139.141 45.889 −67.0%

−61.0% on the 39 INT + 9 VARCHAR(6) row reproduces the −61.6% reported on #247.

Cumulative, main → all three commits, through the real dyn production path:

Case main branch delta
39 INT + 9 VARCHAR(6), ROW 157.013 55.358 −64.7%
39 INT + 9 VARCHAR(6), NBCROW 147.002 47.721 −67.5%
8 × VARCHAR(512) 41.501 23.588 −43.2%
contiguous writer, 39+9 (dyn) 139.141 43.759 −68.5%
contiguous writer, 8×512 (dyn) 40.107 21.071 −47.5%

Quantifying the headroom #257 cannot reach

The contiguous-writer case (the shape mssql-js's BinaryRowWriter uses) is measured
twice on each branch — once passing the concrete writer, once with an explicit
as &mut (dyn RowWriter + Send) coercion. Before #257 the two are identical by
construction, so they act as a noise control:

Branch mono dyn mono advantage
main 140.706 139.141 −1.1% (control)
+#251 140.852 140.605 −0.2% (control)
+#252 45.027 45.889 +1.9% (control)
+#257 34.989 43.759 +20.0%

Controls stay within ±2%; #257 opens a 20.0% gap. That 20.0% is real, reachable, and
currently blocked by Box<dyn TdsTransport>
— no production caller can get it today.
This quantifies the dispatch headroom specifically — see "What these numbers do and do
not cover" above for what sits outside the harness.
Filed as #265 with this number attached so the work is justified rather than
speculative.

Validation

Check Result
cargo bfmt pass (also verified per-commit, so each commit is independently formatted)
cargo bclippy pass (-D warnings)
.\scripts\bfmt.ps1 / .\scripts\bclippy.ps1 pass — covers mssql-py-core, which is excluded from the workspace and implements RowWriter
cargo btest 2771 run, 2405 passed, 366 failed, 11 skipped — the failing set is name-for-name identical to main (see below)
cargo nextest run -p mssql-tds --lib 1746 run, 1739 passed, 7 failed — 7 identical to main, +1 test is the new guard (see below)
RUSTFLAGS=--cfg fuzzing cargo check -p mssql-tds --lib pass; 89 warnings on both main and this branch — no regression
RUSTFLAGS=--cfg fuzzing cargo check --all-targets in mssql-tds/fuzz pass
cargo build -p mssql-js --release pass — NAPI cdylib links (mssql_js.dll)
cargo build -p mssql-odbc --release pass

The fuzzing legs matter disproportionately here: fuzz_support.rs is #[cfg(fuzzing)], so
a default cargo check — and cargo bclippy, which does not set the cfg — compiles none
of it. #252 rewrites the reader storage in that file, and the cherry-pick initially
introduced three missing_docs warnings that only the explicit fuzzing leg caught. Those
are fixed. (MockTransport::new is still undocumented; that is pre-existing on main.)

row_fetch_futures_stay_small asserts on a future built with a concrete
DiscardRowWriter, so commit 3 might have been expected to change what it measures.
All four futures are byte-identical to main:

Future main branch budget
next_row_cursor 1128 B 1128 B 4096 B
read_row_column 376 B 376 B 4096 B
drain_rows 1408 B 1408 B 4096 B
get_next_row_into 1160 B 1160 B 4096 B

That identity is not evidence the decode chain is unchanged — it is evidence the guard
cannot see it. Those four futures are built on TdsClient, which re-boxes at
Box<dyn TdsTransport>; nothing below that boundary can propagate into them. Below it
the futures nearly doubled:

Future main branch budget
receive_row_into_internal (dyn) 752 B 1392 B 4096 B
drive_row_columns (dyn) 592 B 1232 B 4096 B

Well inside budget, and expected — RPITIT inlines what Box::pin used to keep on the
heap, which is the entire point of #252. But it was unguarded, so commit 4 adds
row_decode_futures_stay_small
in token_stream.rs, covering both the dyn and
monomorphic instantiations against the same 4096 B budget. Caught by David Engel (@David-Engel) in
review; #225 added the original guard for exactly this failure mode.

CI results

The full Azure DevOps validation pipeline went green on 4c64fd2f (the pre-merge head), 19/19 — all 5 build platforms
(Linux, Linux ARM, Windows, Windows ARM, macOS), Test macOS, the SQL host stage, Kerberos
authentication tests, and all three cross-repo mssql-python legs.

Three of those close gaps that could not be closed locally:

  • Test macOS runs the integration suite against a real SQL Server and passes. That is
    direct evidence that the 359 locally-failing integration tests (see below) fail purely
    because this machine has no server to connect to, not because of anything in this change.
  • The cross-repo mssql-python build and the mssql-python suite on the mssql-odbc
    driver both pass
    , exercising the PyO3 binding end-to-end rather than just compiling it.
  • The JS bindings are built and tested by the Build Linux stage (yarn install,
    yarn build, yarn buildapi, then yarn testci against a live SQL Server), which is
    the one thing I could not run locally. See the FFI section below.

It is re-running now on the merge commit 6cf02aa8; results will be updated here once it
settles.

Diff coverage: 98% — 123 changed lines, 2 missing; overall 91.5%. Both missing lines
are non-executable, and are artifacts of how diff-cover attributes lines rather than
untested logic:

  • decoder.rs:364 — the where T: TdsPacketReader + Send + Sync, continuation line of
    PlpChunkStreamReader::skip_to_end's signature, which this PR rewrites from &mut dyn
    to a generic. The body is covered: plp_chunk_stream_reader_skip_to_end_flushes_remaining_chunks
    exercises it directly, there are two further test call sites, and production calls it at
    token_stream.rs:434.
  • decoder.rs:3254 — a blank line immediately inside mod decode_into_tests {.

The counted diff grew from 58 lines to 123 when main was merged in: #237 rewrote
read_decimal in the same file, so this branch's de-async_trait hunks now sit against
larger surrounding functions. No logic was added. Everything executable in the change is
covered, which is the expected shape for a pure dispatch refactor — every touched line is
already on a path the existing tests exercise, and that is also why no new tests are added
here.

FFI bindings

#252 changes a trait these depend on transitively, so all three were checked:

  • mssql-py-core (PyO3, implements RowWriter) — cargo check + clippy clean via
    scripts\bclippy.ps1. It is outside the workspace, so the plain aliases miss it.
  • mssql-js (NAPI, implements RowWriter) — release build produces the cdylib locally.
    yarn install / yarn build / yarn test could not be run on my machine: yarn install
    fails with a TLS handshake error reaching the npm registry from this environment.
    CI covers this gap completely. The Build Linux stage runs enableJsBuild: true and
    enableJsTest: true, executing yarn install, yarn build, yarn buildapi and then
    yarn testci against a live SQL Server — the JS test step is gated on
    Build.Reason == PullRequest, so it runs precisely on PR builds like this one. That stage
    is green, so the TypeScript/AVA half is verified after all.
  • mssql-odbc — release build passes. It consumes rows through a different path and
    does not implement RowWriter; confirmed untouched.

Pre-existing test failures — not introduced here, and not fixed here

Two different scopes, so both are reported rather than just the flattering one.

Workspace. Re-measured on both refs after merging main into this branch:

Ref run passed failed skipped
main 068efe7a 2770 2404 366 11
this branch 6cf02aa8 2771 2405 366 11

Not just the same failure count — the same tests. Both failing sets were extracted and
diffed name-for-name: symmetric difference 0. The one extra test and extra pass on this
branch is row_decode_futures_stay_small, the guard added in commit 4. (The earlier figure
in this section, 364, was measured against the older base e40e779d; main has since added
tests of its own, which is where the other two came from — not from this change.)

Of those 366, 7 are mssql-tds lib unit tests (listed below) and the remaining 359
are integration tests under tests/
that need a live SQL Server; both sides split
7 + 359 = 366. In this environment
they fail during connect with
Schannel TLS handshake failed: SEC_E_WRONG_PRINCIPAL (0x80090322) — e.g.
test_always_encrypted (41), test_cursor_ops (39), test_rpc_datatypes (33),
test_bulk_copy (21). That is an environment limitation, not a signal about this change;
CI runs those against a real server.

Library only (cargo nextest run -p mssql-tds --lib). main 1745 run / 1738 passed /
7 failed; this branch 1746 / 1739 / 7 — the clean comparison, since it excludes everything
needing a server. Same 7 tests, with the +1/+1 again being the new guard:

  • connection::transport::certificate_validator::tests:: test_load_certificate_from_pem,
    test_load_certificate_from_der, test_is_certificate_expired_valid,
    test_pem_and_der_certificates_produce_same_der — all
    CertificateNotFound { path: "tests/test_certificates/..." }, i.e. missing fixtures.
  • connection::transport::win_tls::validate::tests:: validate_pinned_cert_matches_identical_der,
    validate_pinned_cert_mismatch_is_pin_error, validate_pinned_cert_missing_pin_file_is_pin_error.

Deliberately left alone — unrelated to this change.

On shipping the benchmark harness

The harness is not in this PR, and that was a judgement call rather than a default.

In favour of shipping: reviewers and future perf work get a stable baseline instead of
re-deriving one; it is #[cfg(test)], so zero production cost; #247 asks each PR to carry
its own benchmark; and it covers a contiguous-buffer writer shape no existing test does.

Against, and decisive: it is ~660 lines of self-described throwaway test code, roughly
twice the size of the actual change. Under cargo btest — llvm-cov-instrumented and not
--release — 1.2M row decodes would blow nextest's slow-timeout (60 s, terminate after
3 → 180 s hard kill) and fail CI. Marking it #[ignore] fixes CI but leaves 660 lines of
never-executed code, which the repo's "no AI slop" convention disfavours. The numbers stay
fully reproducible either way because the harness and exact repro steps are attached to the
benchmark comment below. Happy to land it as a separate PR under #258 if reviewers want a
durable baseline.

Merge risk

Commits 2 and 3 modify mssql-tds/src/io/token_stream.rs. Four other open PRs also touch
that file, none of them on main today:

PR State token_stream.rs
#238 ready +441/−4
#245 ready +209/−26
#186 draft +142/−28
#215 draft +89/−28

#238 is the PoC this work derives from and has the largest diff on the file. #245 reworks
NBCROW null-bitmap handling, which this PR's NBCROW benchmark cases exercise directly.
Whichever of these lands first forces the others to rebase this file. Noted here so
reviewers weighing merge order do not have to check for themselves.

main has since conflicted, and the conflict is resolved. The earlier claim that there
was no risk against main was accurate when measured at 445459bb, and stopped being
accurate afterwards. main advanced eight commits to 068efe7a, and #237 — "Guard decimal
magnitude reassembly against 128-bit shift overflow" — rewrote read_decimal in
datatypes/decoder.rs, the same file commit 1 de-async_traits. GitHub flipped the PR to
CONFLICTING / DIRTY.

Resolved by merging main into the branch in 6cf02aa8. Exactly one hunk conflicted,
and it was the import block rather than any logic: #237 added BigUint and ToPrimitive
directly adjacent to the use async_trait::async_trait; line that commit 1 deletes, so the
two sides edited touching lines. Kept both new imports, dropped async_trait — verified to
occur 0 times in the merged file, since commit 1 removes every attribute that used it.
#237's decimal rewrite itself needed no adaptation: its new
reader.read_bytes(&mut magnitude[..magnitude_len]).await? already satisfies the RPITIT
signature introduced by commit 2.

Merged rather than rebased, deliberately. Recent main history is linear single-parent, so
the repo squash-merges feature PRs and this merge commit is erased at merge time rather than
landing on main; and a force-push would stale the three resolved inline review threads
while the review is still open. Post-merge re-validation (fmt, clippy including
mssql-py-core, both fuzzing legs, full workspace suite, and both future-size guards) is
recorded in the Validation section above — every future-size number in this PR was
re-measured against the new main and is byte-for-byte unchanged.

Related Issues

Fixes #251
Fixes #252
Fixes #257

Part of #247.

Follow-up: #265 — the blocked dispatch headroom quantified above. Not addressed here; it needs the Box<dyn TdsTransport> boundary decision.

Checklist

  • cargo bfmt passes
  • cargo bclippy passes
  • cargo btest passes — 2771 run, 2405 passed, 366 failed, 11 skipped, with the
    failing set name-for-name identical to main (symmetric difference 0). 7 are
    pre-existing lib failures; the other 359 are integration tests needing a live SQL
    Server, and they pass in CI, which has one. See the note above.
  • New/changed functionality has tests — behaviour is unchanged, so the existing 2405
    passing tests are the regression net, and diff coverage on the change is 98%
    (123 lines, 2 missing — both non-executable: a where-clause continuation line and
    a blank line; see the CI results section); benchmark evidence is attached below
  • Public API changes are documented — no public API change. TdsPacketReader and
    SqlTypeDecode are pub(crate); next_row_into's signature is untouched and
    still dyn-compatible

The trait has a generic method, so it was already dyn-incompatible and the
per-call boxing bought nothing. Removal exposed two latent constraints:

- SQL_VARIANT decoding is genuinely recursive (decode -> read_sql_variant ->
  decode_zero_propbyte_variant -> decode). async_trait's box was silently
  breaking that cycle; a native async fn yields E0733. Reintroduced
  deliberately via decode_boxed, which pays one allocation per nested variant
  column instead of one per column read.
- Callers wrap decode in async_trait futures that require Send, and the bare
  AFIT desugaring does not promise it, so the trait declares
  -> impl Future<Output = ...> + Send.

Measured at roughly 0% on its own; it ships for API hygiene and as the
prerequisite for making the decode chain generic over the row writer.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 41e46220-ad76-4a77-b477-e768951dcf92
Replaces #[async_trait] on TdsPacketReader with explicit
-> impl Future<Output = ...> + Send. Every column read previously allocated a
Pin<Box<dyn Future>>; a 39 INT + 9 VARCHAR row issues ~96 reads, so the boxing
dominated the decode path.

Two consequences the trait definition forced:

- The bare AFIT desugaring does not promise Send, and callers wrap these
  futures in async_trait futures that do. Declaring + Send in the trait keeps
  every caller compiling unchanged.
- RPITIT makes the trait dyn-incompatible. The seven &mut dyn parameters in
  PlpChunkStreamReader/PlpColumnStream were only ever parameters, so they
  became generic. The three stored Box<dyn TdsPacketReader> live in fuzz-only
  code and admitted just two concrete types, replaced by the FuzzPacketReader
  enum. The blanket impl for Box<dyn TdsPacketReader + Send + Sync> is gone.

fuzz_support.rs is #[cfg(fuzzing)]-gated and is not built by a default
cargo check, so this was verified with RUSTFLAGS='--cfg fuzzing' against both
the lib and the fuzz targets.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 41e46220-ad76-4a77-b477-e768951dcf92
Adds W: RowWriter + Send + ?Sized to drive_row_columns,
decode_or_decrypt_column, receive_row_into_internal and
resume_row_into_internal, so a caller holding a concrete writer gets static
dispatch on the ~96 writer calls a wide row makes. GenericDecoder::decode_into
was already generic over W; these four were the only reason it always
instantiated as a trait object.

Because the bound is ?Sized, dyn RowWriter + Send still satisfies W, so every
existing caller compiles unchanged and TdsTransport stays dyn-compatible.

Production delta today is 0%, and the reason is structural rather than
incidental: TdsClient holds Box<dyn TdsTransport>, and TdsTransport has
TdsTokenStreamReader as a supertrait, so the writer's concrete type is erased
at that vtable call before it ever reaches this chain. Neither a generic
sibling method nor genericizing TdsTokenStreamReader can recover it -- a
where Self: Sized method is uncallable on a trait object, and a bare generic
method makes Box<dyn TdsTransport> illegal.

This commit makes everything below that boundary ready, so the win lands with
no further decode-path work once the transport indirection is removed. The
blocked headroom is measured on the PR.

resume_row_into_internal is included for symmetry. Its only callers pass dyn,
so it adds no extra monomorphization; drop it if reviewers prefer.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 41e46220-ad76-4a77-b477-e768951dcf92
@saurabh500

Copy link
Copy Markdown
Contributor Author

Benchmark: method, numbers, and repro

Re-measured on this branch. Nothing here is copied from #247.

Method

Criterion is not usable for this path: receive_row_into_internal is pub(crate) and a
Criterion bench is a separate crate, and mssql-mock-tds caps a result set at ~200 rows
via a u16 length cast — far too few to measure a per-column effect. So this is an
in-crate #[cfg(test)] harness driving receive_row_into_internal directly against an
in-memory reader that yields pre-built TDS packets.

Row shapes:

  • poc_row_39int_9varchar — 39 × INT + 9 × VARCHAR(6), ROW token. The shape from Row decode performance: productionize PoC #238 across four axes (tracking) #247.
  • poc_nbcrow_39int_9varchar — same columns, NBCROW token (null bitmap).
  • wide_strings_8x512 — 8 × VARCHAR(512), a string-dominated shape.
  • contig_* — same shapes written into a single contiguous Vec<u8>, modelling
    mssql-js's BinaryRowWriter. Measured twice: mono passes the concrete writer,
    dyn adds an explicit as &mut (dyn RowWriter + Send) coercion.

Repro

cargo test --release -p mssql-tds --lib bench_row_decode -- --nocapture --test-threads=1

Drop the harness at mssql-tds/src/decode_bench.rs and add #[cfg(test)] mod decode_bench;
to lib.rs. On main and on the #251 branch you must re-add use async_trait::async_trait;
and #[async_trait] on impl TdsPacketReader for MemReader; from #252 onward both must be
removed.

Raw medians (ms per 20,000-row pass, lower is better)

Case main +#251 +#252 +#257
poc_row_39int_9varchar 157.013 165.663 61.170 55.358
poc_nbcrow_39int_9varchar 147.002 138.772 51.360 47.721
wide_strings_8x512 41.501 42.666 24.327 23.588
contig_poc_row mono 140.706 140.852 45.027 34.989
contig_poc_row dyn 139.141 140.605 45.889 43.759
contig_wide mono 40.756 39.427 22.279 20.884
contig_wide dyn 40.107 39.828 23.742 21.071

Per-commit

#251 — remove #[async_trait] from SqlTypeDecode: ~0%.
+5.5% / −5.6% / +2.8% / +0.1% / +1.1% across the five cases — all within noise, no
consistent sign. Reproduces the previously reported standalone result. One run produced a
213.9 ms outlier on poc_row against a ~156 ms baseline, which is precisely why the
protocol is median-of-3-runs rather than a single measurement.

#252 — RPITIT TdsPacketReader: nearly the entire win.

Case main +#252 delta
poc_row_39int_9varchar 157.013 61.170 −61.0%
poc_nbcrow_39int_9varchar 147.002 51.360 −65.1%
wide_strings_8x512 41.501 24.327 −41.4%
contig_poc_row (dyn) 139.141 45.889 −67.0%
contig_wide (dyn) 40.107 23.742 −40.8%

−61.0% on the 39 INT + 9 VARCHAR(6) row reproduces the −61.6% reported on #247.

#257 — generic RowWriter: ~0% in production, 20.0% blocked.

The production path is dyn (the writer is erased at Box<dyn TdsTransport> — see the PR
description). The mono column is what a caller would get if that erasure were removed.

Case +#252 +#257 delta
contig_poc_row dyn (production) 45.889 43.759 −4.6%
contig_wide dyn (production) 23.742 21.071 −11.3%
contig_poc_row mono (typed) 45.027 34.989 −22.3%
contig_wide mono (typed) 22.279 20.884 −6.3%

The dyn deltas are small and I would not defend them as a real effect: making the
function generic does give W = dyn RowWriter + Send its own instantiation rather than a
shared body, which can shift inlining slightly, but −4.6% is close enough to noise that it
should be treated as ~0.

The controlled headroom experiment

Same row shape, same branch, same binary — only the writer's dispatch differs. Before #257
both variants erase identically, so any gap there is pure measurement noise and acts as a
control:

Branch contig_poc_row mono dyn mono advantage
main 140.706 139.141 −1.1% (control)
+#251 140.852 140.605 −0.2% (control)
+#252 45.027 45.889 +1.9% (control)
+#257 34.989 43.759 +20.0%

Three controls within ±2%, then a 20.0% gap once #257 lands. That gap is the
writer-devirtualization win — real and reachable, but blocked from every production caller
by Box<dyn TdsTransport>. It is the number attached to the follow-up issue.

Cumulative, main → all three commits (through the real dyn production path)

Case main branch delta
poc_row_39int_9varchar 157.013 55.358 −64.7%
poc_nbcrow_39int_9varchar 147.002 47.721 −67.5%
wide_strings_8x512 41.501 23.588 −43.2%
contig_poc_row (dyn) 139.141 43.759 −68.5%
contig_wide (dyn) 40.107 21.071 −47.5%

Caveats

  • Microbenchmark against an in-memory reader: no syscalls, no TLS, no network. It isolates
    decode CPU cost, which is the thing these commits change, but a real query's end-to-end
    win will be smaller in proportion to however much time it spends in I/O.
  • Single machine, single OS (Windows), single rustc (1.95.0).
  • Absolute values are not portable; the deltas are the point.
  • mono numbers for Investigate removing &mut dyn RowWriter from the per-column path (row decode perf, axis: dispatch) #257 are not reachable by any current caller. Do not quote them as
    shipped wins.
Harness source — drop at mssql-tds/src/decode_bench.rs (670 lines)
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

//! Throwaway row-decode microbenchmark used to measure the E1–E6 feasibility
//! spikes against their merge-base. Lives inside the crate (rather than in
//! `benches/`) because the row decode entry point and `TdsPacketReader` are
//! `pub(crate)` and a Criterion bench is a separate crate.
//!
//! Run with:
//! ```text
//! cargo nextest run --release -p mssql-tds --lib decode_bench --no-capture
//! ```
//!
//! This file is intentionally identical between the baseline worktree and the
//! feasibility branch except for the `#[async_trait]` attribute on the reader
//! impl, which the trait's own shape forces.

use std::hint::black_box;
use std::sync::Arc;
use std::time::{Duration, Instant};


use crate::core::TdsResult;
use crate::datatypes::column_values::{
    ColumnValues, SqlDate, SqlDateTime, SqlDateTime2, SqlDateTimeOffset, SqlMoney,
    SqlSmallDateTime, SqlSmallMoney, SqlTime, SqlXml,
};
use crate::datatypes::decoder::DecimalParts;
use crate::datatypes::row_writer::{DefaultRowWriter, RowWriter};
use crate::datatypes::sql_json::SqlJson;
use crate::datatypes::sql_string::SqlString;
use crate::datatypes::sql_vector::SqlVector;
use uuid::Uuid;
use crate::datatypes::sqldatatypes::{TdsDataType, TypeInfo};
use crate::io::packet_reader::TdsPacketReader;
use crate::io::token_stream::{
    ColumnPolicy, GenericTokenParserRegistry, ParserContext, receive_row_into_internal,
};
use crate::query::metadata::ColumnMetadata;
use crate::token::tokens::{ColMetadataToken, SqlCollation, TokenType};

/// Rows decoded per timed pass.
const ROWS: usize = 20_000;
/// Untimed passes before measurement, to settle caches and branch predictors.
const WARMUP_PASSES: usize = 3;
/// Timed passes; the reported figure is the median.
const MEASURED_PASSES: usize = 9;

// ---------------------------------------------------------------------------
// In-memory packet reader
// ---------------------------------------------------------------------------

/// Serves a pre-built byte buffer with no I/O, so a measurement reflects decode
/// cost rather than socket or syscall behavior.
struct MemReader {
    data: Arc<Vec<u8>>,
    pos: usize,
}

impl MemReader {
    fn new(data: Arc<Vec<u8>>) -> Self {
        Self { data, pos: 0 }
    }

    #[inline]
    fn take(&mut self, n: usize) -> TdsResult<&[u8]> {
        let end = self.pos + n;
        if end > self.data.len() {
            return Err(crate::error::Error::ProtocolError(
                "unexpected end of bench buffer".to_string(),
            ));
        }
        let slice = &self.data[self.pos..end];
        self.pos = end;
        Ok(slice)
    }
}

impl TdsPacketReader for MemReader {
    async fn read_byte(&mut self) -> TdsResult<u8> {
        Ok(self.take(1)?[0])
    }

    async fn read_int16_big_endian(&mut self) -> TdsResult<i16> {
        let r = self.take(2)?;
        Ok(i16::from_be_bytes([r[0], r[1]]))
    }

    async fn read_int32_big_endian(&mut self) -> TdsResult<i32> {
        let r = self.take(4)?;
        Ok(i32::from_be_bytes([r[0], r[1], r[2], r[3]]))
    }

    async fn read_uint40(&mut self) -> TdsResult<u64> {
        let r = self.take(5)?;
        Ok(u64::from(r[0])
            | u64::from(r[1]) << 8
            | u64::from(r[2]) << 16
            | u64::from(r[3]) << 24
            | u64::from(r[4]) << 32)
    }

    async fn read_float32(&mut self) -> TdsResult<f32> {
        let r = self.take(4)?;
        Ok(f32::from_le_bytes([r[0], r[1], r[2], r[3]]))
    }

    async fn read_float64(&mut self) -> TdsResult<f64> {
        let r = self.take(8)?;
        Ok(f64::from_le_bytes([
            r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7],
        ]))
    }

    async fn read_int16(&mut self) -> TdsResult<i16> {
        let r = self.take(2)?;
        Ok(i16::from_le_bytes([r[0], r[1]]))
    }

    async fn read_uint16(&mut self) -> TdsResult<u16> {
        let r = self.take(2)?;
        Ok(u16::from_le_bytes([r[0], r[1]]))
    }

    async fn read_uint24(&mut self) -> TdsResult<u32> {
        let r = self.take(3)?;
        Ok(u32::from(r[0]) | u32::from(r[1]) << 8 | u32::from(r[2]) << 16)
    }

    async fn read_int32(&mut self) -> TdsResult<i32> {
        let r = self.take(4)?;
        Ok(i32::from_le_bytes([r[0], r[1], r[2], r[3]]))
    }

    async fn read_uint32(&mut self) -> TdsResult<u32> {
        let r = self.take(4)?;
        Ok(u32::from_le_bytes([r[0], r[1], r[2], r[3]]))
    }

    async fn read_int64(&mut self) -> TdsResult<i64> {
        let r = self.take(8)?;
        Ok(i64::from_le_bytes([
            r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7],
        ]))
    }

    async fn read_uint64(&mut self) -> TdsResult<u64> {
        let r = self.take(8)?;
        Ok(u64::from_le_bytes([
            r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7],
        ]))
    }

    async fn read_bytes(&mut self, buffer: &mut [u8]) -> TdsResult<usize> {
        let n = buffer.len();
        buffer.copy_from_slice(self.take(n)?);
        Ok(n)
    }

    async fn read_u8_varbyte(&mut self) -> TdsResult<Vec<u8>> {
        let n = self.take(1)?[0] as usize;
        Ok(self.take(n)?.to_vec())
    }

    async fn read_u16_varbyte(&mut self) -> TdsResult<Vec<u8>> {
        let r = self.take(2)?;
        let n = u16::from_le_bytes([r[0], r[1]]) as usize;
        Ok(self.take(n)?.to_vec())
    }

    async fn read_varchar_u16_length(&mut self) -> TdsResult<Option<String>> {
        let r = self.take(2)?;
        let n = u16::from_le_bytes([r[0], r[1]]);
        if n == crate::io::packet_reader::LENGTH_NULL {
            return Ok(None);
        }
        Ok(Some(self.read_unicode(n as usize).await?))
    }

    async fn read_varchar_u8_length(&mut self) -> TdsResult<String> {
        let n = self.take(1)?[0] as usize;
        self.read_unicode(n).await
    }

    async fn read_unicode(&mut self, string_length: usize) -> TdsResult<String> {
        self.read_unicode_with_byte_length(string_length * 2).await
    }

    async fn read_unicode_with_byte_length(&mut self, byte_length: usize) -> TdsResult<String> {
        let raw = self.take(byte_length)?;
        let units: Vec<u16> = raw
            .chunks_exact(2)
            .map(|c| u16::from_le_bytes([c[0], c[1]]))
            .collect();
        Ok(String::from_utf16_lossy(&units))
    }

    async fn skip_bytes(&mut self, skip_count: usize) -> TdsResult<()> {
        self.take(skip_count)?;
        Ok(())
    }

    async fn cancel_read_stream(&mut self) -> TdsResult<()> {
        Ok(())
    }

    fn reset_reader(&mut self) {
        self.pos = 0;
    }
}

// ---------------------------------------------------------------------------
// Synthetic result-set construction
// ---------------------------------------------------------------------------

fn collation() -> SqlCollation {
    SqlCollation {
        info: 0x0000_0409,
        lcid_language_id: 0x0409,
        col_flags: 0,
        sort_id: 52,
    }
}

/// One column's shape. Metadata and wire bytes are both derived from this, so
/// they cannot drift apart.
#[derive(Clone, Copy, PartialEq, Eq)]
enum ColSpec {
    /// `INT`, sent as `IntN` with a 4-byte payload.
    Int,
    /// `VARCHAR(n)`, sent as non-PLP `BigVarChar`.
    Str(usize),
}

impl ColSpec {
    fn metadata(self, name: String) -> ColumnMetadata {
        match self {
            ColSpec::Int => int_column(&name),
            ColSpec::Str(len) => varchar_column(&name, len),
        }
    }
}

fn int_column(name: &str) -> ColumnMetadata {
    ColumnMetadata {
        user_type: 0,
        flags: 0x01, // nullable
        data_type: TdsDataType::IntN,
        type_info: TypeInfo::var_len(TdsDataType::IntN, 4).unwrap(),
        column_name: name.to_string(),
        multi_part_name: None,
        crypto_metadata: None,
    }
}

fn varchar_column(name: &str, len: usize) -> ColumnMetadata {
    ColumnMetadata {
        user_type: 0,
        flags: 0x01, // nullable
        data_type: TdsDataType::BigVarChar,
        type_info: TypeInfo::var_len_string(TdsDataType::BigVarChar, len, Some(collation()))
            .unwrap(),
        column_name: name.to_string(),
        multi_part_name: None,
        crypto_metadata: None,
    }
}

/// Column layout of the PoC's benchmark table: 39 `INT` + 9 `VARCHAR(6)`,
/// all nullable.
fn poc_columns() -> Vec<ColSpec> {
    let mut cols = vec![ColSpec::Int; 39];
    cols.extend(std::iter::repeat_n(ColSpec::Str(6), 9));
    cols
}

/// Narrower layout with large string payloads, to weight the value-handoff path
/// (E3) rather than per-column dispatch.
fn wide_string_columns() -> Vec<ColSpec> {
    vec![ColSpec::Str(512); 8]
}

fn context_for(specs: &[ColSpec]) -> ParserContext {
    let columns: Vec<ColumnMetadata> = specs
        .iter()
        .enumerate()
        .map(|(i, spec)| spec.metadata(format!("col_{i}")))
        .collect();
    ParserContext::ColumnMetadata(
        Arc::new(ColMetadataToken {
            column_count: columns.len() as u16,
            columns,
            ..Default::default()
        }),
        None,
    )
}

/// Encodes one column value onto the wire exactly as the server would.
fn push_value(buf: &mut Vec<u8>, spec: ColSpec, row: usize, col: usize) {
    match spec {
        ColSpec::Int => {
            buf.push(4);
            buf.extend_from_slice(&((row * 48 + col) as i32).to_le_bytes());
        }
        ColSpec::Str(len) => {
            buf.extend_from_slice(&(len as u16).to_le_bytes());
            buf.extend((0..len).map(|i| b'a' + ((i + col) % 26) as u8));
        }
    }
}

/// Builds `ROWS` ROW tokens with every column present.
fn build_row_stream(specs: &[ColSpec]) -> Vec<u8> {
    let mut buf = Vec::new();
    for row in 0..ROWS {
        buf.push(TokenType::Row as u8);
        for (col, spec) in specs.iter().enumerate() {
            push_value(&mut buf, *spec, row, col);
        }
    }
    buf
}

/// Builds `ROWS` NBCROW tokens where every 4th column is NULL, so the null
/// bitmap is both present and non-trivial.
fn build_nbcrow_stream(specs: &[ColSpec]) -> Vec<u8> {
    let bitmap_len = specs.len().div_ceil(8);
    let mut buf = Vec::new();
    for row in 0..ROWS {
        buf.push(TokenType::NbcRow as u8);
        let mut bitmap = vec![0u8; bitmap_len];
        for col in 0..specs.len() {
            if col % 4 == 3 {
                bitmap[col / 8] |= 1 << (col % 8);
            }
        }
        buf.extend_from_slice(&bitmap);
        for (col, spec) in specs.iter().enumerate() {
            if col % 4 != 3 {
                push_value(&mut buf, *spec, row, col);
            }
        }
    }
    buf
}

// ---------------------------------------------------------------------------
// Contiguous-buffer writer
// ---------------------------------------------------------------------------

/// Mirrors the shape of `mssql-js`'s `BinaryRowWriter`: every value is appended
/// into one reusable byte buffer rather than becoming an owned `ColumnValues`.
///
/// `DefaultRowWriter` cannot show E3's benefit because it allocates a fresh
/// `Vec` per value either way. This writer can: on the accumulator API the
/// decoder writes straight into `buf`, whereas the old API forces the decoder to
/// assemble a temporary `Vec` first and hand it over to be copied again.
#[derive(Default)]
struct ContiguousRowWriter {
    buf: Vec<u8>,
    row_start: usize,
}

impl ContiguousRowWriter {
    fn new() -> Self {
        Self {
            buf: Vec::with_capacity(64 * 1024),
            row_start: 0,
        }
    }
}

/// Generates the fixed-width writers the benchmark does not exercise. They must
/// exist to satisfy the trait but never run, so a uniform body is enough.
macro_rules! unused_writers {
    ($($name:ident($ty:ty)),* $(,)?) => {
        $(fn $name(&mut self, _col: usize, val: $ty) { black_box(&val); })*
    };
}

impl RowWriter for ContiguousRowWriter {
    fn write_null(&mut self, _col: usize) {
        self.buf.push(0);
    }

    fn write_i32(&mut self, _col: usize, val: i32) {
        self.buf.push(1);
        self.buf.extend_from_slice(&val.to_le_bytes());
    }

    fn write_string(&mut self, _col: usize, val: SqlString) {
        let bytes = val.as_raw_wire_bytes().expect("bench columns must be raw-wire encoded");
        self.buf.push(2);
        self.buf
            .extend_from_slice(&(bytes.len() as u32).to_le_bytes());
        let at = self.buf.len();
        self.buf.resize(at + bytes.len(), 0);
        self.buf[at..].copy_from_slice(bytes);
    }

    fn write_bytes(&mut self, _col: usize, val: Vec<u8>) {
        self.buf.push(3);
        self.buf.extend_from_slice(&(val.len() as u32).to_le_bytes());
        self.buf.extend_from_slice(&val);
    }

    fn end_row(&mut self) {
        // Stands in for handing the encoded row to the host runtime.
        self.buf.clear();
        self.row_start = 0;
    }


    unused_writers!(
        write_bool(bool),
        write_u8(u8),
        write_i16(i16),
        write_i64(i64),
        write_f32(f32),
        write_f64(f64),
        write_decimal(DecimalParts),
        write_numeric(DecimalParts),
        write_date(SqlDate),
        write_time(SqlTime),
        write_datetime(SqlDateTime),
        write_smalldatetime(SqlSmallDateTime),
        write_datetime2(SqlDateTime2),
        write_datetimeoffset(SqlDateTimeOffset),
        write_money(SqlMoney),
        write_smallmoney(SqlSmallMoney),
        write_uuid(Uuid),
        write_xml(SqlXml),
        write_json(SqlJson),
        write_vector(SqlVector),
    );
}

// ---------------------------------------------------------------------------
// Measurement
// ---------------------------------------------------------------------------

/// Decodes the whole buffer once, returning the elapsed time. The decoded values
/// are fed through `black_box` so the work cannot be optimized away.
async fn decode_pass(data: Arc<Vec<u8>>, context: &ParserContext, col_count: usize) -> Duration {
    let registry = GenericTokenParserRegistry::default();
    let mut reader = MemReader::new(data);
    let mut writer = DefaultRowWriter::new(col_count);

    let start = Instant::now();
    for _ in 0..ROWS {
        receive_row_into_internal(
            &mut reader,
            &registry,
            context,
            ColumnPolicy::DecodeAll,
            &mut writer,
        )
        .await
        .expect("row decode failed");
        black_box(writer.take_row());
    }
    start.elapsed()
}

/// Decodes one buffer and asserts the decoded values match what the builder
/// wrote, and that the stream was consumed exactly. Without this, a harness bug
/// (short reads, wrong wire shape) would silently produce fast but meaningless
/// numbers.
async fn verify_pass(data: Arc<Vec<u8>>, context: &ParserContext, specs: &[ColSpec], nbc: bool) {
    let registry = GenericTokenParserRegistry::default();
    let mut reader = MemReader::new(Arc::clone(&data));
    let mut writer = DefaultRowWriter::new(specs.len());

    for row in 0..ROWS {
        receive_row_into_internal(
            &mut reader,
            &registry,
            context,
            ColumnPolicy::DecodeAll,
            &mut writer,
        )
        .await
        .expect("row decode failed");

        let values = writer.take_row();
        assert_eq!(values.len(), specs.len(), "row {row} column count");

        for (col, spec) in specs.iter().enumerate() {
            let is_null = nbc && col % 4 == 3;
            match (&values[col], spec, is_null) {
                (ColumnValues::Null, _, true) => {}
                (ColumnValues::Int(v), ColSpec::Int, false) => {
                    assert_eq!(*v, (row * 48 + col) as i32, "row {row} col {col}");
                }
                (ColumnValues::String(s), ColSpec::Str(len), false) => {
                    let expected: Vec<u8> =
                        (0..*len).map(|i| b'a' + ((i + col) % 26) as u8).collect();
                    assert_eq!(
                        s.to_utf8_string(),
                        String::from_utf8(expected).unwrap(),
                        "row {row} col {col}"
                    );
                }
                (actual, _, _) => panic!("row {row} col {col}: unexpected value {actual:?}"),
            }
        }
    }

    assert_eq!(
        reader.pos,
        data.len(),
        "decoder did not consume the stream exactly"
    );
}

async fn decode_pass_contiguous(
    data: Arc<Vec<u8>>,
    context: &ParserContext,
    _col_count: usize,
) -> Duration {
    let registry = GenericTokenParserRegistry::default();
    let mut reader = MemReader::new(data);
    let mut writer = ContiguousRowWriter::new();

    let start = Instant::now();
    for _ in 0..ROWS {
        receive_row_into_internal(
            &mut reader,
            &registry,
            context,
            ColumnPolicy::DecodeAll,
            &mut writer,
        )
        .await
        .expect("row decode failed");
        black_box(&writer.buf);
        writer.end_row();
    }
    start.elapsed()
}

/// Same work as [`decode_pass_contiguous`], but the writer is explicitly erased to
/// `&mut (dyn RowWriter + Send)` before the call.
///
/// Before the row-writer generic lands these two are identical — the signature
/// erases either way, so any gap is pure measurement noise and acts as a control.
/// After it lands, the gap between this and [`decode_pass_contiguous`] is the
/// writer-devirtualization headroom that `Box<dyn TdsTransport>` currently blocks
/// from reaching production callers.
async fn decode_pass_contiguous_dyn(
    data: Arc<Vec<u8>>,
    context: &ParserContext,
    _col_count: usize,
) -> Duration {
    let registry = GenericTokenParserRegistry::default();
    let mut reader = MemReader::new(data);
    let mut writer = ContiguousRowWriter::new();

    let start = Instant::now();
    for _ in 0..ROWS {
        let erased: &mut (dyn RowWriter + Send) = &mut writer;
        receive_row_into_internal(&mut reader, &registry, context, ColumnPolicy::DecodeAll, erased)
            .await
            .expect("row decode failed");
        black_box(&writer.buf);
        writer.end_row();
    }
    start.elapsed()
}

fn run_case(name: &str, specs: Vec<ColSpec>, nbc: bool) {
    let col_count = specs.len();
    let data = Arc::new(if nbc {
        build_nbcrow_stream(&specs)
    } else {
        build_row_stream(&specs)
    });
    let context = context_for(&specs);
    let bytes = data.len();

    let rt = tokio::runtime::Builder::new_current_thread()
        .build()
        .expect("runtime");

    rt.block_on(verify_pass(Arc::clone(&data), &context, &specs, nbc));

    for _ in 0..WARMUP_PASSES {
        rt.block_on(decode_pass(Arc::clone(&data), &context, col_count));
    }

    let mut samples: Vec<Duration> = (0..MEASURED_PASSES)
        .map(|_| rt.block_on(decode_pass(Arc::clone(&data), &context, col_count)))
        .collect();
    samples.sort();

    let median = samples[MEASURED_PASSES / 2];
    let best = samples[0];
    let ns_per_row = median.as_nanos() as f64 / ROWS as f64;
    let rows_per_sec = ROWS as f64 / median.as_secs_f64();
    let mb_per_sec = bytes as f64 / median.as_secs_f64() / (1024.0 * 1024.0);

    println!(
        "BENCH\t{name}\tcols={col_count}\trows={ROWS}\tmedian_ms={:.3}\tbest_ms={:.3}\t\
         ns_per_row={ns_per_row:.1}\trows_per_sec={rows_per_sec:.0}\tMiB_per_sec={mb_per_sec:.1}",
        median.as_secs_f64() * 1000.0,
        best.as_secs_f64() * 1000.0,
    );
}

/// `erased` selects the explicitly-`dyn` pass, so the same row shape can be
/// measured through both dispatch styles on one branch.
fn run_contiguous_case_with(name: &str, specs: Vec<ColSpec>, nbc: bool, erased: bool) {
    let col_count = specs.len();
    let data = Arc::new(if nbc {
        build_nbcrow_stream(&specs)
    } else {
        build_row_stream(&specs)
    });
    let context = context_for(&specs);
    let bytes = data.len();

    let rt = tokio::runtime::Builder::new_current_thread()
        .build()
        .expect("runtime");

    let pass = async |d: Arc<Vec<u8>>, c: &ParserContext| {
        if erased {
            decode_pass_contiguous_dyn(d, c, col_count).await
        } else {
            decode_pass_contiguous(d, c, col_count).await
        }
    };

    for _ in 0..WARMUP_PASSES {
        rt.block_on(pass(Arc::clone(&data), &context));
    }

    let mut samples: Vec<Duration> = (0..MEASURED_PASSES)
        .map(|_| rt.block_on(pass(Arc::clone(&data), &context)))
        .collect();
    samples.sort();

    let median = samples[MEASURED_PASSES / 2];
    let best = samples[0];
    let ns_per_row = median.as_nanos() as f64 / ROWS as f64;
    let rows_per_sec = ROWS as f64 / median.as_secs_f64();
    let mb_per_sec = bytes as f64 / median.as_secs_f64() / (1024.0 * 1024.0);

    println!(
        "BENCH\t{name}\tcols={col_count}\trows={ROWS}\tmedian_ms={:.3}\tbest_ms={:.3}\t\
         ns_per_row={ns_per_row:.1}\trows_per_sec={rows_per_sec:.0}\tMiB_per_sec={mb_per_sec:.1}",
        median.as_secs_f64() * 1000.0,
        best.as_secs_f64() * 1000.0,
    );
}

#[test]
fn bench_row_decode() {
    println!("BENCH_BEGIN");
    run_case("poc_row_39int_9varchar", poc_columns(), false);
    run_case("poc_nbcrow_39int_9varchar", poc_columns(), true);
    run_case("wide_strings_8x512", wide_string_columns(), false);
    run_contiguous_case_with("contig_poc_row_mono", poc_columns(), false, false);
    run_contiguous_case_with("contig_poc_row_dyn", poc_columns(), false, true);
    run_contiguous_case_with("contig_wide_strings_mono", wide_string_columns(), false, false);
    run_contiguous_case_with("contig_wide_strings_dyn", wide_string_columns(), false, true);
    println!("BENCH_END");
}

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Removes boxed-future and dynamic-dispatch overhead from the TDS row-decoding path while preserving protocol behavior.

Changes:

  • Converts SqlTypeDecode and TdsPacketReader to RPITIT.
  • Makes internal row decoding generic over RowWriter.
  • Replaces fuzz-only boxed packet readers with a concrete enum.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated no comments.

Show a summary per file
File Description
mssql-tds/src/token/parsers/row_parser.rs Updates decoder test implementations.
mssql-tds/src/token/parsers/nbcrow_parser.rs Updates NBCROW decoder tests.
mssql-tds/src/token/parsers/common.rs Updates the mock packet reader.
mssql-tds/src/message/prelogin.rs Updates the prelogin reader mock.
mssql-tds/src/io/token_stream.rs Genericizes the row-writer decode chain.
mssql-tds/src/io/packet_reader.rs Converts packet-reader methods to RPITIT.
mssql-tds/src/fuzz_support.rs Introduces concrete fuzz-reader dispatch.
mssql-tds/src/datatypes/decoder.rs Converts decoding to RPITIT and boxes SQL_VARIANT recursion.
mssql-tds/src/connection/transport/network_transport.rs Updates the network reader implementation.
mssql-tds/src/connection/tds_client.rs Updates the test transport implementation.
mssql-tds/fuzz/fuzz_targets/fuzz_tds_client.rs Uses the concrete fuzz-reader enum.
mssql-tds/fuzz/fuzz_targets/fuzz_connection_provider.rs Updates provider fuzz construction.
mssql-tds/fuzz/fuzz_targets/fuzz_connection_provider_network.rs Updates network fuzz construction.
mssql-tds/fuzz/fuzz_targets/fuzz_connection_provider_context.rs Wraps the empty reader in the enum.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@saurabh500

Copy link
Copy Markdown
Contributor Author

/coverage

Re-triggering the diff-coverage report. The automatic run (31677060976) was cancelled 90 seconds in, and the cause was self-inflicted rather than a CI problem: pr-code-coverage.yml triggers on issue_comment: created as well as pull_request, and uses concurrency: cancel-in-progress: true on the group pr-coverage-<pr>. Posting the benchmark comment above (07:17:11Z) therefore started a second run in that group, which cancelled the first (07:17:30Z) before immediately skipping itself on the startsWith(body, '/coverage') guard.

Nothing is blocked by this — diff-cover runs without --fail-under and is reporting-only — but the PR had no coverage number at all, so it is worth having.

@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown

📊 Code Coverage Report

🔥 Diff Coverage

98%

🎯 Overall Coverage

91.5%

📦 Project: mssql-tds + mssql-odbc + mssql-py-core
ℹ️ Note: diff coverage is reported, not enforced.


Diff Coverage

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

  • mssql-tds/src/datatypes/decoder.rs (95.3%): Missing lines 364,3254
  • mssql-tds/src/io/token_stream.rs (100%)

Summary

  • Total: 123 lines
  • Missing: 2 lines
  • Coverage: 98%

mssql-tds/src/datatypes/decoder.rs

  360         Ok(written)
  361     }
  362 
  363     pub(crate) async fn skip_to_end<T>(&mut self, reader: &mut T) -> TdsResult<()>
! 364     where
  365         T: TdsPacketReader + Send + Sync,
  366     {
  367         while self.ensure_active_chunk(reader).await? {
  368             if self.chunk_remaining > 0 {

  3250         }
  3251     }
  3252 
  3253     mod decode_into_tests {
! 3254 
  3255         use byteorder::{ByteOrder, LittleEndian};
  3256 
  3257         use crate::core::TdsResult;
  3258         use crate::datatypes::column_values::{ColumnValues, SqlDateTime, SqlSmallDateTime};


🔗 Quick Links

View Azure DevOps Build · Coverage Report

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.

@David-Engel David Engel (David-Engel) left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

No blocking findings. Three mechanical commits (async_trait -> RPITIT on SqlTypeDecode and TdsPacketReader, then a generic W: RowWriter through token_stream.rs). I verified that the mechanical parts really are mechanical, and everything the description claims that I could check locally held up. Suggestions and nits inline plus below.

What I verified locally (worktree at cfa0dfe7)

Check Result
cargo fmt --check clean
cargo clippy --workspace --all-features --all-targets -- -D warnings clean
cargo nextest run -p mssql-tds --lib --no-fail-fast 1619 run, 1615 passed, 4 failed — all 4 are the pre-existing certificate_validator missing-fixture failures (Linux, so the 3 Windows-only win_tls ones do not appear, hence 4 not 7)
RUSTFLAGS=--cfg fuzzing cargo check -p mssql-tds --lib 89 warnings — matches your number
RUSTFLAGS=--cfg fuzzing cargo check --all-targets in mssql-tds/fuzz clean
mssql-py-core (outside the workspace) clippy -D warnings clean
Merge with current origin/main (4 commits ahead, including the ~1600-line tds_client.rs AQE change #206) no conflicts, clippy -D warnings clean

Verified by script rather than by eyeball:

  • All 23 FuzzPacketReader methods delegate to the same-named method on the inner reader and forward every argument — no copy-paste slip anywhere in that 160-line block.
  • The two #[cfg]-gated copies of TdsPacketReader have identical method sets in identical order.
  • No dyn TdsPacketReader or dyn SqlTypeDecode remains anywhere in the tree.
  • io is pub(crate), so the "no public API change" claim holds even under cfg(fuzzing).
  • decode_boxed recursion is bounded at depth 2: decode_zero_propbyte_variant only re-enters for FixedLengthTypes, and SQL_VARIANT is not one, so a hostile server cannot drive unbounded recursion through the new boxing step.
  • StringDecoder's return -> tail expression is semantically identical.

I did not reproduce the benchmark numbers, since the harness is not in the PR. The magnitude is plausible — a 48-column row was doing 50+ boxed-future allocations per row and very little else — but I am not independently endorsing -61%.

Suggestion: commit 3 (#257), the trade-off is worth naming explicitly

You already flag that the production delta is ~0% and offer to drop resume_row_into_internal. I confirmed the underlying claim: commit 3 required zero edits outside token_stream.rs, and dyn RowWriter + Send still satisfies W via the ?Sized bound. Given four other open PRs touch this file and the 20% is unreachable until the Box<dyn TdsTransport> decision is made, folding commit 3 into #265 — where it can land with a measurable win — is a defensible alternative to shipping it now. Either way is fine and I would not block on it.

One correction in the reassuring direction: your merge-risk table is stale. The branch merges cleanly with today's main and builds clean afterwards.

Nit: trait duplication now costs more per signature

The cfg(not(fuzzing)) / cfg(fuzzing) copies in io/packet_reader.rs are now 23 RPITIT signatures each that have to stay in lockstep. They do today — I checked — but each line got considerably wordier. A macro_rules! taking a $vis parameter would collapse them to one source of truth. Pre-existing duplication and arguably out of scope, so purely optional.

Comment thread mssql-tds/src/io/token_stream.rs
Comment thread mssql-tds/src/datatypes/decoder.rs
Comment thread mssql-tds/src/fuzz_support.rs
Guard the decode-chain futures. row_fetch_futures_stay_small (#225) builds
its futures on TdsClient, which re-boxes at Box<dyn TdsTransport>, so it
cannot observe this file. Measured: its four futures are byte-identical on
main and on this branch (1128/376/1408/1160) while receive_row_into_internal
went 752 -> 1392 B and drive_row_columns 592 -> 1232 B. Add
row_decode_futures_stay_small below the boundary, covering both the dyn and
monomorphic instantiations against the same 4096 B budget.

Move the read_sql_variant comment back onto read_sql_variant; decode_boxed
was inserted between them.

Add FuzzPacketReader::empty() so both variants have a constructor, which
drops the last EmptyReader import from the fuzz targets.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 41e46220-ad76-4a77-b477-e768951dcf92
@saurabh500

Saurabh Singh (saurabh500) commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — the future-size catch was the valuable one and it is fixed in 4c64fd2f, along with both nits. Replies inline. Three things below that are decisions rather than fixes.

Commit 3 (#257): keeping it, but your framing is now in the body

I took the suggestion seriously and came down on keeping it, for reasons that are about cost rather than value:

What I did take from this is that the body undersold the trade-off, so it now says plainly that commit 3 is ~0% in production today and why. If you would still rather it moved, say so and I will split it — the argument above is about ordering cost, not about it being wrong.

macro_rules! for the two #[cfg]-gated TdsPacketReader copies

Not doing this one, and I want to give a real reason rather than "out of scope":

  • The duplication predates this PR and the two copies genuinely differ (the cfg(fuzzing) one carries different bounds), so a macro would need to parameterise more than $vis to actually collapse them.
  • A macro_rules! trait definition is materially worse for the reader than 23 plain signatures: rust-analyzer go-to-definition, doc generation, and compiler error spans all degrade, and this trait is the one people land on when tracing the read path.
  • Your own script-based check is the cheaper control for the real hazard: I confirmed the two copies still have identical method sets in identical order after commit 4.

Happy to be overruled if the team wants it, but I would rather it be a deliberate, separate change than a rider on a dispatch PR.

Merge-risk correction — confirmed, and the section is rewritten

You were right that it was stale, and it was misleading in a second way I had not noticed: it presented merge risk as being about main when the table is actually about inter-PR collision. Both are fixed. I re-verified independently at 445459bb (the same four commits you saw, including AQE #206 and prepared-handle invalidation #148): git merge-tree reports no conflicts, and the merged tree passes cargo clippy --workspace --all-features --all-targets -- -D warnings. #245 has also flipped draft → ready since I wrote the table; that is corrected too.

On not endorsing −61%

Entirely reasonable, and I would not ask you to. The harness is deliberately out of the PR (reasoning is in the "On shipping the benchmark harness" section of the description); it is attached to the benchmark comment with exact repro steps if you want to run it. The number that does not depend on trusting me is the one you already verified structurally: ~50 boxed-future allocations per row removed on a 48-column row.

Validation on 4c64fd2f

cargo bfmt, cargo bclippy, scripts\bfmt.ps1, scripts\bclippy.ps1 (incl. mssql-py-core) all clean. RUSTFLAGS=--cfg fuzzing cargo check -p mssql-tds --lib and --all-targets in mssql-tds/fuzz clean. cargo nextest run -p mssql-tds --lib — 1711 run, 1704 passed, 7 failed, the same pre-existing set (4 certificate_validator + 3 Windows-only win_tls); the +1 is the new guard. cargo btest — 2708 run, 2344 passed, 364 failed, unchanged and all live-server integration tests.

@David-Engel David Engel (David-Engel) left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of 4c64fd2f — approving

All three inline findings are resolved correctly, and the responses on the two decisions are sound. No remaining findings.

Verified on 4c64fd2f

Check Result
cargo fmt --check clean
cargo clippy --workspace --all-features --all-targets -- -D warnings clean
cargo nextest run -p mssql-tds --lib --no-fail-fast 1620 run, 1616 passed, 4 failed — the same pre-existing certificate_validator fixture failures (Linux, so the 3 Windows-only win_tls ones do not appear); the +1 versus my previous run is the new guard
RUSTFLAGS=--cfg fuzzing cargo check --all-targets in mssql-tds/fuzz clean, 89 lib warnings unchanged
Merge with origin/main @ 445459bb no conflicts, clippy -D warnings clean on the merged tree

The new guard is not vacuous. I instrumented row_decode_futures_stay_small to print rather than just assert:

receive_row_into_internal (dyn)  = 1416 B
receive_row_into_internal (mono) = 1352 B
drive_row_columns (dyn)          = 1256 B
drive_row_columns (mono)         = 1208 B
resume_row_into_internal (dyn)   = 1408 B     budget 4096 B

Roughly 2.9x headroom, and covering both instantiations plus resume_row_into_internal is more than I asked for. Asserting <= MAX rather than exact equality is the right call: your figures (752/1392, 592/1232) differ slightly from mine (760/1416, 600/1256) because these are toolchain and platform dependent, so an equality assert would have been a CI flake generator.

Both nits are clean — the SQL_VARIANT comment is back above read_sql_variant, and FuzzPacketReader::empty() drops the EmptyReader import at the call site as predicted.

On the two decisions

Commit 3 (#257) — accepting your reasoning, suggestion withdrawn. The ordering-cost argument is the right frame and I had not weighted it properly: deferring means rewriting the same four signatures a second time against a token_stream.rs that #238/#245/#186/#215 will have moved.

Macro for the #[cfg]-gated trait copies — agreed, do not do it. The rust-analyzer / doc-generation / error-span argument is the convincing one.

One factual correction for the record, since it is load-bearing in your first bullet: the two copies do not carry different bounds. I diffed the two trait bodies — the entire difference is two #[allow(dead_code)] attributes (on read_u16_varbyte and read_unicode) plus pub(crate) versus pub on the trait itself. A $vis macro would in fact have collapsed them. The conclusion still holds on the tooling argument alone; only that one premise is off.

One small note

Your comment says the body "now says plainly that commit 3 is ~0% in production today and why", but the description diff shows no change in that section. Nothing was lost — the original body already said it explicitly ("the win is not realized through the public API today" / "#257's production delta is ~0% for structural reasons"). Flagging only in case a further edit was intended and did not land.

Approval scope

Approving on the code. CI on this head was still partly pending when I looked (8 pass, 10 pending) — please confirm the full ADO validation pipeline goes green before merging, since the JS yarn testci leg and the live-server integration tests are the parts I cannot run locally.

Resolves a conflict in the decoder import block: #237 added the BigUint and
ToPrimitive imports next to `use async_trait::async_trait;`, which this branch
deleted when it removed the attribute from SqlTypeDecode. Kept both new imports
and left async_trait out, since the file no longer references it.

The decimal reassembly rewrite from #237 merged cleanly into the de-async_trait
signatures and needs no further adaptation.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 41e46220-ad76-4a77-b477-e768951dcf92
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

4 participants