Skip to content

Converge decode onto decode_into via CaptureWriter - #269

Closed
Saurabh Singh (saurabh500) wants to merge 3 commits into
mainfrom
dev/saurabh/fused-column-decode-spec
Closed

Converge decode onto decode_into via CaptureWriter#269
Saurabh Singh (saurabh500) wants to merge 3 commits into
mainfrom
dev/saurabh/fused-column-decode-spec

Conversation

@saurabh500

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

Copy link
Copy Markdown
Contributor

What

Row decoding maintained two hand-synced switches over TdsDataType:

  • GenericDecoder::decode_into — 33 arms, drives RowWriter
  • GenericDecoder::decode — ~320 lines, builds ColumnValues

plus an ~80-line near-duplicate pair in StringDecoder. The two produce the same
values by convention only. A divergence between them is silently wrong data,
not a compile error.

This PR collapses them. decode is now defined as decode_into plus a
CaptureWriter that records whatever was written:

async fn decode<T: TdsPacketReader + Send>(...) -> TdsResult<ColumnValues> {
    let mut capture = CaptureWriter::default();
    Self::decode_into(reader, metadata, col, &mut capture).await?;
    capture.into_value()
}

There is now one type switch. Adding a TdsDataType requires touching one
place, and the two decode sites cannot drift.

Changes

row_writer.rs — adds CaptureWriter, a RowWriter that stores the single
value written to it and hands it back as ColumnValues. This is the mechanism
that makes the collapse possible.

decoder.rs

  • decode_into's _ => arm previously delegated eight types back to decode:
    Xml, Json, Udt, Image, Vector, SsVariant, Decimal, Numeric.
    Each now has a real arm. Decimal and Numeric get an explicit rejection
    arm; the residual _ => is terminal UnimplementedFeature, which is exactly
    what decode already did for the same set. decode_into now names 41 of
    46
    TdsDataType variants; the terminal arm covers exactly five, none of
    which is a supported result-column type:

    variant why it never reaches row decode
    Void 0x1F MS-TDS: NULLTYPE is never emitted by the server
    VarBinary 0x25 legacy ID; result metadata uses BigVarBinary
    Binary 0x2D legacy ID; result metadata uses BigBinary
    SqlTable 0xF3 TVP is an outbound RPC parameter type, not a column type
    None 0x00 internal sentinel; fails TdsDataType::try_from

    All five are rejected by read_type_info before row decode, or cannot be
    parsed at all. Full enumeration on Precompute a per-column decode plan (ColumnDecodeSpec) and converge all decode sites on it #249.

  • Deletes GenericDecoder::decode's ~320-line switch.

  • Deletes StringDecoder::decode's ~80-line near-duplicate body.

  • Deletes read_intn, the now-dead ColumnValues twin of decode_into's
    IntN arm.

  • Extracts read_long_len_bytes, removing a third copy of the LONGLEN wire
    format that was duplicated across the Image arm, decode_string_into, and
    StringDecoder::decode.

Net +4 lines against current main, including 15 new tests.

Behaviour

Behaviour-preserving, with one cosmetic exception noted below.

The eight newly-inlined arms call the same helpers decode called, with the
same argument order and the same error text. Empty-payload semantics are
preserved and now pinned by tests: image treats an empty payload as NULL,
text/ntext treat it as an empty string.

One cosmetic change. Unifying the LONGLEN reader merged two oversize-length
error messages — "Image length ..." and "Text data length ..." — into a
single "LOB data length {length} exceeds maximum allowed size of {MAX} bytes".
No test asserted either original string (verified by grep). Both were
ProtocolError, so the error type is unchanged; only the message text differs.

Unreachable-by-construction tripwire. CaptureWriter::into_value now returns
Err(ImplementationError) rather than NULL if a decode_into arm returns Ok
without writing. Every arm either writes exactly once or returns an error, so
this is unreachable today and changes no observable behaviour — but reporting a
missing write as NULL would be precisely the silent wrong-data failure this PR
exists to remove, so the replacement should not reintroduce a smaller version of
it. A SQL NULL is written explicitly via write_null, so the two cases are
unambiguously distinguishable. A second write for the same column trips a debug
assertion. (Raised by Copilot review.)

Tests

15 new tests covering every newly-inlined arm and the terminal residual:

decode_into_xml_plp, decode_into_json_plp, decode_into_udt_plp,
decode_into_xml_plp_null, decode_into_json_rejects_non_plp_metadata,
decode_into_image_value, decode_into_image_null_text_pointer,
decode_into_image_empty_payload_is_null, decode_into_ssvariant,
decode_into_fixed_decimal_is_unimplemented,
decode_into_fixed_numeric_is_unimplemented,
decode_into_unknown_type_is_unimplemented,
capture_writer_returns_written_value,
capture_writer_distinguishes_written_null_from_no_write,
capture_writer_errors_when_nothing_was_written.

They use the existing assert_decode_equivalence harness, which asserts
decode_into and decode consume identical bytes and produce identical values
— the property this PR makes structural rather than aspirational.

Validation

gate result
cargo bfmt clean
cargo bclippy clean
cargo nextest -p mssql-tds --lib 1761 run, 1754 passed, 7 failed
cargo btest (workspace, before merging #264) 364 integration failures
RUSTFLAGS='--cfg fuzzing' cargo check -p mssql-tds --lib 0 errors
.\scripts\bfmt.ps1 (incl. mssql-py-core) clean
.\scripts\bclippy.ps1 (incl. mssql-py-core) clean

The 7 current lib failures are the pre-existing expired-certificate fixtures
(certificate_validator ×4, win_tls::validate ×3) — unrelated and failing on
main. The earlier 364 btest failures are all in tests/ integration binaries
failing at TLS handshake (SEC_E_WRONG_PRINCIPAL); they need a live SQL Server.
Zero datatypes:: or io:: unit-test failures.

Performance

Post-#264 remeasurement found a regression. After merging current main
(#264 at 7a9c0b93), the same in-crate harness was rerun against that commit
and this PR at de42362c: 60,000 rows × 48 columns, 3 warmups, 9 measured
passes, four alternating baseline/treatment runs. Each figure is the median of
the four per-run medians.

schema / sink main this PR Δ distributions
int_varchar / discard 109.14 ms 132.62 ms +21.5% non-overlapping
int_varchar / materialize 169.77 ms 201.75 ms +18.8% non-overlapping
mixed_shapes / discard 222.28 ms 235.54 ms +6.0% overlapping
mixed_shapes / materialize 292.99 ms 306.08 ms +4.5% overlapping

This invalidates the pre-merge “no change” result and keeps the PR in draft
pending disposition. A bounded #[inline(always)] experiment on decode and
decode_into did not recover the integer-heavy regression. Future-state growth
is also ruled out: convergence shrinks receive_row_into_internal from 1,392 B
to 872 B and drive_row_columns from 1,232 B to 712 B. The cause is not yet
proven.

The benchmark harness is deliberately not committed to this branch.

History

This PR originally implemented #249 — a precomputed per-column
ColumnDecodeSpec. That performance premise was tested and refuted across
three rounds of measurement; the write-up is on #249, which is closed as
tested-and-refuted.

The convergence never depended on the precomputation, so it was kept and the
plan was dropped. What remains is the part that carried the value: one type
switch instead of two, justified on correctness and maintainability alone.

Relates to #247, #249.

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

Precomputes column decode specifications for the TDS row pipeline, addressing #249 under the #247 performance work.

Changes:

  • Adds and caches ColumnDecodeSpec plans from COLMETADATA.
  • Unifies value and writer decoding through one planned dispatch.
  • Expands decode-shape and regression coverage.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
mssql-tds/src/datatypes.rs Registers the decode-plan module.
mssql-tds/src/datatypes/decode_spec.rs Defines planning, classification, errors, and tests.
mssql-tds/src/datatypes/decoder.rs Replaces duplicate type switches with planned decoding.
mssql-tds/src/datatypes/row_writer.rs Adds single-value capture support.
mssql-tds/src/datatypes/sql_string.rs Makes encodings copyable and safely resolves collation.
mssql-tds/src/io/token_stream.rs Uses cached plans in row decoding and skipping.
mssql-tds/src/token/tokens.rs Stores the eager per-column plan.
mssql-tds/src/token/parsers/colmetadata_parser.rs Builds planned metadata tokens.
mssql-tds/src/token/parsers/row_parser.rs Updates test metadata construction.
mssql-tds/src/token/parsers/nbcrow_parser.rs Updates test metadata construction.
mssql-tds/src/connection/tds_client.rs Updates test metadata construction.
mssql-tds/src/connection/metadata_retriever.rs Updates test metadata construction.

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

Comment thread mssql-tds/src/io/token_stream.rs Outdated
Comment on lines 577 to 579
match (spec.encrypted, decryptor) {
(true, Some(dec)) => {
let value = decrypt_encrypted_column(decoder, reader, meta, dec).await?;
@saurabh500

Copy link
Copy Markdown
Contributor Author

Provisional benchmark: this change is a regression on the #238 schema

Measured as a provisional number against the unmerged dispatch branch (dev/saurabh/row-decode-dispatch-perf @ cfa0dfe7, draft #264), because #252 has not landed on main and any number taken against today's main would measure headroom #252 has already removed.

Method

In-crate harness (mssql-tds/src/io/decode_bench.rs, #[cfg(test)]), driving receive_row_into_internal over a pre-built byte buffer with DiscardRowWriter. No server, no socket, no Criterion in the loop. Median of 9 passes after 3 warmups, 60 000 rows × 48 columns = 2.88M cells per pass, --release. Four independent runs of each binary.

Baseline = cfa0dfe7 + the bench commit. Treatment = the same tree plus this PR's commit, rebased on top.

Results

Schema Baseline (median of 4 run-medians) This PR Δ
int_varchar — 39 × INT + 9 × VARCHAR(6) (the #238 schema) 97.3 ms 116.5 ms +19.7 %
mixed_shapesINT/VARCHAR/DECIMAL(18,4)/TIME(7)/DATETIME2(7)/UNIQUEIDENTIFIER 181.7 ms 180.3 ms −0.8 %

Best-observed minima, which are the least noise-contaminated statistic:

Schema Baseline min This PR min Δ
int_varchar 93.20 ms 111.01 ms +19.1 %
mixed_shapes 171.60 ms 174.60 ms +1.7 %

On int_varchar the two distributions do not overlap across four runs each — the worst baseline pass is faster than the best treatment pass. That is not noise.

Why

The premise of #249 was that the per-cell match metadata.data_type is expensive. The measurement says it is not: it is a jump table on a discriminant in a struct the loop is already walking. What is actually expensive is the classification workget_encoding_type, is_plp, get_scale — and that only exists for string, decimal and date/time columns.

So on a schema that is 81 % INT, the plan hoists nothing and adds cost. Measured sizes:

ColumnSpec = 24 bytes   ColumnDecodeSpec = 20   EncodingType = 16   ColumnMetadata = 640

Per cell the plan-driven path adds a bounds-checked load and a 24-byte copy of ColumnSpec (spec_at), then a two-level match (Fixed { kind }FixedKind::Int4) where there used to be a single flat jump table on a one-byte discriminant. EncodingType being 16 bytes and inlined into the enum is what makes the spec that fat.

The mixed_shapes result is the same story from the other side: on the schema where the plan should pay, it comes out flat — the hoisted classification work is real, but it is cancelled by the fatter dispatch.

Recommendation

Do not merge as-is. The correctness and convergence work stands on its own — one type switch instead of two, no fallback variant, encryption kept out of the enum — but #249 was justified as a performance change, and as measured it is not one.

Two concrete directions, if this is worth another pass:

  1. Shrink ColumnSpec to a word. Intern or index the EncodingType instead of inlining 16 bytes, so the per-cell load is a register-width copy rather than three words.
  2. Flatten the enum. One variant per wire shape with payloads in a side table, restoring a single jump table and removing the nested branch.

Both attack the added dispatch cost rather than the hoisting, which is where the measurement points.

Numbers are provisional: they are taken against an unmerged draft. They should be re-taken once #264 merges.

@saurabh500

Copy link
Copy Markdown
Contributor Author

Benchmark round 2 — after shrinking ColumnSpec (still provisional, on top of unmerged #264)

Follow-up to the first round, which measured +19.7% on the #238 schema. The hypothesis under test was that the regression belonged to this implementation (a 24-byte by-value spec) rather than to precomputation as an idea.

What changed

  • EncodingType (16 bytes, inlines a SqlCollation) interned behind a 1-byte StringEncoding tag, materialized from metadata at the point of use.
  • spec_at replaced by resolve_plan, which validates plan length once per row instead of bounds-checking per cell. The "short plan re-derives, never defaults" invariant is preserved.
type before after
ColumnSpec 24 B 6 B
ColumnDecodeSpec 20 B 4 B
VarU8Kind 3 B
VarU16Kind 4 B

Method

Unchanged from round 1, plus a second sink axis. In-crate, driving receive_row_into_internal over a pre-built buffer; 60 000 rows x 48 columns = 2.88M cells per pass; median of 9 after 3 warmups; --release; 4 independent runs per cell of the table. Baseline is dispatch tip cfa0dfe7; both branches carry an identical bench module.

discard is DiscardRowWriter (cheapest possible sink, maximises decode's share). materialize is DefaultRowWriter, which builds ColumnValues per cell and hands the row off — the shape the public row API and the JS binding actually use.

Results

Median of the four per-run medians:

schema sink baseline +#249 Δ
int_varchar (39xINT + 9xVARCHAR(6)) discard 90.36 ms 100.41 ms +11.1 %
int_varchar materialize 163.73 ms 183.18 ms +11.9 %
mixed_shapes (INT/VARCHAR/DECIMAL/TIME/DATETIME2/GUID) discard 174.97 ms 168.70 ms −3.6 %
mixed_shapes materialize 255.74 ms 252.12 ms −1.4 %

Least-disturbed run (min of the four per-run minima), which is the more robust statistic for a CPU-bound loop:

schema sink baseline +#249 Δ
int_varchar discard 87.69 ms 97.35 ms +11.0 %
int_varchar materialize 154.66 ms 167.17 ms +8.1 %
mixed_shapes discard 166.60 ms 159.68 ms −4.2 %
mixed_shapes materialize 242.29 ms 239.91 ms −1.0 %

Confidence. Only int_varchar/discard has non-overlapping per-run distributions across all four runs (baseline 89.43–91.41, spec 98.62–109.56). The other three rows overlap, so treat mixed_shapes −3.6% as suggestive rather than established, and the materialize rows as directionally consistent but noisy.

Diagnostic: the remaining cost is not future size

Round 1 attributed the cost partly to the spec inflating the generated async state. That is now measured and refuted:

future baseline +#249
decode_into 928 B 216 B (−77 %)
receive_row_into_internal 1328 B 848 B (−36 %)

The plan-driven path produces substantially smaller futures and is still slower on the INT-heavy schema. Neither the by-value copy nor async-state growth explains what is left.

Where that leaves the model

Modelling the change as a fixed per-cell cost X minus a classification saving Y:

  • int_varchar: Y ≈ 0 (an IntN cell needs no get_encoding_type/is_plp/get_scale), so it measures X ≈ 3.5 ns/cell — down from 6.6 ns/cell before the shrink.
  • mixed_shapes: Y is real; at −3.6% on a 62 ns/cell baseline, Y ≈ 5.7 ns/cell.

Shrinking the spec 4x roughly halved X, and mixed_shapes moved from −0.8% to −3.6% exactly as that model predicts. If X could be driven to zero, mixed_shapes extrapolates to about −9%.

Two candidates remain for the residual X, and they are not distinguished by this data:

  1. An added dependent load. The spec now comes from a second array that must be loaded before the branch can resolve, where the baseline's discriminant was a field of the ColumnMetadata it was already walking.
  2. Two-level dispatch. ColumnDecodeSpec::Fixed(FixedKind::I32) is a nested pattern, so two dependent switches on the arm that serves 39 of 48 cells.

One observation argues for (1) over (2): mixed_shapes exercises far more distinct arms and improved, which is the opposite of what branch-predictor pressure would produce.

Status

Still do not merge as a performance change on integer-heavy schemas. The regression on the #238 shape is real and reproducible at +11%, roughly halved from +19.7% but not eliminated. The shape-diverse schema is now modestly positive.

Distinguishing the two remaining hypotheses requires flattening the spec enum, which is a design change rather than a tuning change, so it is not being taken unilaterally. Branches: dev/saurabh/bench-spec-on-dispatch and dev/saurabh/bench-dispatch-baseline.

@saurabh500

Copy link
Copy Markdown
Contributor Author

Benchmark round 3 — retiring the metadata walk (provisional, on top of unmerged #264)

Round 2 left the plan being consumed alongside the column metadata: drive_row_columns iterated columns.iter().zip(plan), so the hot loop advanced a 640-byte ColumnMetadata stride and a 6-byte spec stride, where baseline advanced only the former. The hypothesis was that the second stream was the cost, and that retiring the metadata walk would recover the regression.

It did not. Reporting the numbers.

What changed (5d7f1e60)

  • Resolved EncodingType moved into a side table on the plan (DecodePlan { specs, encodings }), built together so the two arrays cannot desync.
  • StringEncoding deleted — the String variants of VarU16Kind / LongLenKind / PlpKind are now payload-free unit variants.
  • decode_into no longer takes &ColumnMetadata at all; read_long_len_into takes the encoding table.
  • The hot loop iterates plan.specs(); columns is indexed only on the skip, PLP-pause, and encrypted branches, all cold.
  • RowLayout<'a> (Copy) bundles columns / encodings / decryptor, which also keeps the helpers inside clippy's argument budget.

size_of::<ColumnSpec>() == 6, size_of::<ColumnDecodeSpec>() == 4 — unchanged from round 2. This round was structural, not size.

Results

60,000 rows × 48 columns = 2.88M cells. Median of 9 after 3 warmups, 4 independent runs per cell; the figure quoted is the median of the four per-run medians. Both sides rebuilt --release in the same session.

schema sink baseline +#249 Δ per-run distributions
int_varchar (39×INT + 9×VARCHAR(6)) discard 87.42 ms 98.57 ms +12.8 % separated (84.92–89.12 vs 96.03–110.46)
int_varchar materialize 154.13 ms 163.72 ms +6.2 % separated (153.54–154.34 vs 160.12–165.38)
mixed_shapes discard 163.23 ms 160.61 ms −1.6 % overlapping
mixed_shapes materialize 235.05 ms 237.17 ms +0.9 % overlapping

Both int_varchar cells are non-overlapping regressions. Both mixed_shapes cells are inside noise.

Round-2 figures were +11.1 % / +11.9 % / −3.6 % / −1.4 %, but the absolute baselines moved between rounds (mixed_shapes/discard baseline 174.97 → 163.23 ms, ~7 %), so cross-round comparison is not reliable and I am not claiming a trend from it. The within-round conclusion is what stands: retiring the metadata walk did not recover the regression.

Cause, by elimination

Three candidates have now been tested and refuted by measurement:

candidate test result
24-byte by-value spec copy shrank ColumnSpec 24 B → 6 B regression persisted
async state growth measured generated future sizes: decode_into 928 → 216 B, receive_row_into_internal 1328 → 848 B futures shrank 77 % / 36 % and it was still slower
second 640-byte metadata stream removed metadata from the hot loop entirely regression persisted (this round)

What remains is the extra dispatch level. Baseline dispatches once on metadata.data_type and then on the length byte. The spec path dispatches on ColumnDecodeSpec, then on the inner kind, then on the length byte — three levels where baseline has two. The residual is ≈ 3.9 ns/cell (~12 cycles), which is the right order for an additional indirect branch.

This is consistent with the schema split rather than contradicted by it: model the plan as adding a fixed per-cell dispatch cost X and removing a classification cost Y. For INT cells Y ≈ 0, so int_varchar measures X alone. For mixed_shapes, Y (per-cell get_encoding_type / is_plp / get_scale) is real and roughly cancels X.

Status

This is the third and final measurement round agreed for this axis. The numbers are provisional in the sense that they are taken on top of unmerged #264, not on main.

Branches, both pushed and reproducible:

  • dev/saurabh/bench-spec-on-dispatch @ 5d7f1e60
  • dev/saurabh/bench-dispatch-baseline @ 5fcde533

The benchmark is byte-identical on both branches except metadata_token(), which must construct the token differently because ColMetadataToken::new only exists on the spec side.

Validation on 5d7f1e60: cargo bfmt clean, cargo bclippy clean, cargo nextest -p mssql-tds --lib 1734 run / 1727 passed / 7 failed (the pre-existing expired-certificate fixtures, unrelated), RUSTFLAGS='--cfg fuzzing' cargo check -p mssql-tds --lib clean.

Row decoding maintained two hand-synced ~40-arm switches over TdsDataType:
decode_into, which drives RowWriter, and decode, which builds ColumnValues.
A divergence between them is silently wrong data rather than a compile error.

Collapse them: decode is now defined as decode_into plus a CaptureWriter that
records the written value. This deletes GenericDecoder::decode's ~320-line
switch and StringDecoder::decode's ~80-line near-duplicate.

decode_into previously delegated eight types (Xml, Json, Udt, Image, Vector,
SsVariant, Decimal, Numeric) back to decode via a fallback arm. Those now have
real arms, so the residual _ => arm is terminal UnimplementedFeature, matching
what decode already did for the same types.

Extract read_long_len_bytes, removing a third copy of the LONGLEN wire format
that was duplicated across the Image arm, decode_string_into and
StringDecoder::decode. This unifies two oversize error messages into one.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d3a0066b-1b8a-4581-afc9-ec2b9d3034fb
@saurabh500
Saurabh Singh (saurabh500) force-pushed the dev/saurabh/fused-column-decode-spec branch from e638bee to 2b22354 Compare August 13, 2026 16:21
@github-actions

github-actions Bot commented Aug 13, 2026

Copy link
Copy Markdown

📊 Code Coverage Report

🔥 Diff Coverage

95%

🎯 Overall Coverage

91.6%

📦 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.7%): Missing lines 1483-1485,1530
  • mssql-tds/src/datatypes/row_writer.rs (96.1%): Missing lines 340-342,379

Summary

  • Total: 194 lines
  • Missing: 8 lines
  • Coverage: 95%

mssql-tds/src/datatypes/decoder.rs

  1479             Some(bytes) => write_column_value(writer, col, wrap(bytes)),
  1480             None => writer.write_null(col),
  1481         }
  1482         Ok(())
! 1483     }
! 1484 
! 1485     /// Reads a LONGLEN `image` payload. A zero-length payload is NULL here,
  1486     /// unlike `text`, which reports it as an empty string.
  1487     async fn read_image_into<T, W>(reader: &mut T, col: usize, writer: &mut W) -> TdsResult<()>
  1488     where
  1489         T: TdsPacketReader + Send + Sync,

  1526         reader.read_bytes(&mut buffer).await?;
  1527         Ok(Some(buffer))
  1528     }
  1529 }
! 1530 
  1531 impl SqlTypeDecode for GenericDecoder {
  1532     async fn decode<T>(&self, reader: &mut T, metadata: &ColumnMetadata) -> TdsResult<ColumnValues>
  1533     where
  1534         T: TdsPacketReader + Send + Sync,

mssql-tds/src/datatypes/row_writer.rs

  336     }
  337     fn write_decimal(&mut self, _col: usize, val: DecimalParts) {
  338         self.set(ColumnValues::Decimal(val));
  339     }
! 340     fn write_numeric(&mut self, _col: usize, val: DecimalParts) {
! 341         self.set(ColumnValues::Numeric(val));
! 342     }
  343     fn write_date(&mut self, _col: usize, val: SqlDate) {
  344         self.set(ColumnValues::Date(val));
  345     }
  346     fn write_time(&mut self, _col: usize, val: SqlTime) {

  375     }
  376     fn write_vector(&mut self, _col: usize, val: SqlVector) {
  377         self.set(ColumnValues::Vector(val));
  378     }
! 379     fn end_row(&mut self) {}
  380 }
  381 
  382 #[cfg(test)]
  383 mod tests {


🔗 Quick Links

View Azure DevOps Build · Coverage Report

@saurabh500 Saurabh Singh (saurabh500) changed the title Precompute a per-column decode plan and converge all decode sites on it Converge decode onto decode_into via CaptureWriter Aug 13, 2026
@saurabh500
Saurabh Singh (saurabh500) requested a balanced review from Copilot August 13, 2026 17:21

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 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (2)

mssql-tds/src/datatypes/row_writer.rs:276

  • Treating “no value written” as SQL NULL hides a violated decoder contract. NULL paths already call write_null, so a successful decode with no write is an implementation error; similarly, repeated writes currently overwrite the first value. Track the number of writes and return an error unless exactly one value was captured, then propagate that result from both decode implementations. Otherwise a future missing/duplicate writer call can silently produce wrong data—the failure mode this convergence is intended to remove.
    /// Takes the captured value, or `Null` if the decoder wrote nothing.
    pub(crate) fn into_value(self) -> ColumnValues {
        self.value.unwrap_or(ColumnValues::Null)

mssql-tds/src/datatypes/decoder.rs:1344

  • These arms no longer fall back to decode(), but the decode_into documentation at lines 1068–1071 still says all rare types do. Update that documentation to describe the single-switch implementation and, if useful, distinguish the helpers that temporarily materialize ColumnValues.
            // === Rare shapes: boxed so their locals stay out of the hot path's
            // future, but still handled here rather than in a second switch. ===

CaptureWriter::into_value returned NULL when a decode_into arm wrote
nothing, which would turn a missing writer call into silently wrong data
- the failure mode this convergence exists to remove. write_null already
records Some(Null), so an empty slot is unambiguously a decoder bug and
is now reported as ImplementationError.

Route the 24 setters through a checked helper so a second write for the
same column trips a debug assertion rather than overwriting the first.

Also correct decode_into's doc comment, which still described the rare
types as falling back to decode().

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d3a0066b-1b8a-4581-afc9-ec2b9d3034fb
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d3a0066b-1b8a-4581-afc9-ec2b9d3034fb
@saurabh500

Saurabh Singh (saurabh500) commented Aug 14, 2026

Copy link
Copy Markdown
Contributor Author

Final convergence result

Closing after the required remeasurement on top of #264 and one pre-committed
rescue attempt. The convergence correctness value is real, but the production
interaction is not performance-neutral and the remaining cost is not explained
well enough to land a maintainability-only change.

Measurements

All rounds used the same 60,000-row × 48-column in-crate harness, with 3 warmups
and 9 measured passes per cell, four alternating runs per side. The quoted
figure is the median of the four per-run medians.

Absolute baselines drifted between sessions (main int_varchar/discard moved
283.07 → 109.14 → 89.01 ms), so absolute times must not be compared across
rounds. Each delta below is a within-round PR-versus-main comparison and is
the valid result.

Before #264: no regression; three cells overlapped and the separated cell
was an improvement.

schema / sink main convergence delta
int_varchar / discard 283.07 ms 267.78 ms -5.4%
int_varchar / materialize 334.45 ms 337.46 ms +0.9%
mixed / discard 516.08 ms 502.69 ms -2.6%
mixed / materialize 583.52 ms 562.54 ms -3.6%

After #264 (main 7a9c0b93, PR de42362c): the integer-heavy
distributions separated. The discard regression handed back about 13.5% of
#264's absolute gain.

schema / sink main convergence delta
int_varchar / discard 109.14 ms 132.62 ms +21.51%
int_varchar / materialize 169.77 ms 201.75 ms +18.84%
mixed / discard 222.28 ms 235.54 ms +5.96%
mixed / materialize 292.99 ms 306.08 ms +4.47%

The same harness independently corroborated #264's headline:
int_varchar/discard moved 283.07 → 109.14 ms (-61.4%), versus #264's reported
-61.6%.

One cold-partition attempt: the code-size mechanism below was confirmed, so
only rare-type bodies and the two format-heavy error constructions were moved
behind a cold, non-inlined boxed helper. Every type remained defined exactly
once.

schema / sink main cold partition delta
int_varchar / discard 89.01 ms 96.81 ms +8.76%
int_varchar / materialize 154.27 ms 163.34 ms +5.88%
mixed / discard 199.41 ms 191.92 ms -3.76%
mixed / materialize 267.34 ms 266.79 ms -0.21%

That is a material recovery, but it fails the pre-committed
all-four-cells-neutral-or-better bar. No second optimization round was taken.

Mechanisms tested

Future size — refuted across three benchmark rounds. In #249 round 2,
decode_into shrank 928 → 216 B and receive_row_into_internal shrank
1,328 → 848 B while throughput regressed; round 3 retained those smaller
futures, removed the metadata walk, and still regressed. Here, relative to
post-#264 main, convergence shrank receive_row_into_internal
1,392 → 872 B (-37%) and drive_row_columns 1,232 → 712 B (-42%) while the
integer-heavy workload became 19–22% slower.

Inlining — did not recover the loss. A bounded #[inline(always)]
experiment on decode and decode_into remained around 130–137 ms for
integer discard and 198–202 ms for materialization. This is weak evidence:
on an async function the attribute applies to the future constructor, not
necessarily the generated poll body.

Generated code size — confirmed as a partial cause, then shown insufficient.
The comparison was per concrete hot instantiation, not aggregate generic IR:

  • BenchReader + DefaultRowWriter: 5,137 → 5,805 optimized LLVM IR lines
    (+13.0%), copy count 1 on each side.
  • BenchReader + DiscardRowWriter: 5,137 → 5,805 lines (+13.0%), copy count 1.
  • receive_row_into_internal stayed at 911 lines and drive_row_columns at
    702 lines.
  • The separate CaptureWriter instantiation was reported independently and
    was not folded into those comparisons.

The cold partition reduced each real-writer decode_into body to 5,101 lines,
slightly below main, and more than halved the regression, but the common
integer-heavy shape remained 5.9–8.8% slower. Code size was a cause, not the
whole cause.

Total decode footprint and the extra CaptureWriter monomorphization remain
a surviving hypothesis, not a measured cause.
The per-instantiation comparison
above answers "did the hot function grow?" A benchmark-specific total answers a
different question: "how much generated decode code did this binary carry?"

For the concrete BenchReader set used by the harness:

  • main: two real-writer copies × 5,137 = 10,274 lines;
  • convergence before partitioning: three copies × 5,805 = 17,415 lines (+69%);
  • convergence after partitioning: three copies × 5,101 = 15,303 lines (+49%).

The third copy is the CaptureWriter instantiation required by defining
decode through decode_into. The cold partition brought each hot real-writer
copy below main, but retained about 71% of the benchmark-specific
total-footprint excess and a substantial integer-path regression. That shape is
consistent with the remaining cost, but it does not establish causation.

Two caveats are load-bearing. Cold code that never executes should not consume
instruction cache unless code layout interleaves it with hot code; that is
plausible but unverified. And removing the extra instantiation requires no
longer defining decode in terms of decode_into, which removes the PR's
defining property. There is no clean experiment that isolates this candidate
while preserving the thesis, so it is recorded rather than measured or pursued.

The reusable conclusion is: future size is not a proxy for row-decode
throughput in this crate, and generated code size is a partial predictor at
best.

Cheaper successor

The risk this PR addressed is silent drift between decode and decode_into.
A zero-runtime-cost mitigation is to retain both implementations but expand the
existing assert_decode_equivalence harness into behaviour-parity coverage for
every supported type, asserting identical bytes consumed and values produced.
That is the recommended successor if this risk is revisited.

The StringDecoder half is also independently separable, but was deliberately
not implemented here: main already has decode_string_into, so its ~80-line
value-returning duplicate could be defined through that method plus
CaptureWriter without changing GenericDecoder.

Evidence

The session artifact archive retains:

  • post-merge-bench-runs.txt
  • post-merge-inline-experiment.txt
  • llvm-lines-main-de42362c.txt
  • llvm-lines-pr-de42362c.txt
  • llvm-lines-pr-cold-de42362c.txt
  • post-merge-cold-helper-bench-runs.txt
  • cold-helper-experiment.diff

The future-size/codegen postscript is also recorded on
#249.

No experimental code was ported. The branch remains clean at de42362c.
Closing rather than negotiating the bar or taking another hypothesis round.


Successor filed: #289Add exhaustive decode/decode_into behavior-parity tests. That issue carries the drift mitigation described above at zero runtime cost, plus the StringDecoder separability note as recorded-not-proposed.

Tracker: #247 — carries the consolidated record of which metrics predicted decode throughput on this path and which did not, and the standing requirement to measure on top of merged #264 rather than pre-#264 main.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants