Skip to content

Perf: Optimize the ResultSet read path and reduce allocation overhead - #2974

Merged
Ananya2 merged 11 commits into
mainfrom
anagarg/perf-read-hotpath
Jul 24, 2026
Merged

Perf: Optimize the ResultSet read path and reduce allocation overhead#2974
Ananya2 merged 11 commits into
mainfrom
anagarg/perf-read-hotpath

Conversation

@Ananya2

@Ananya2 Ananya2 commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR introduces six targeted ResultSet read-path optimizations for allocation-heavy wide-row workloads. The changes reduce allocation churn, streamline common reader operations, and eliminate unnecessary wrapper objects for synchronous getString() calls while preserving the existing lazy decoding architecture and specialized code paths.

ResultSet bulk-read workloads incur substantial per-column allocation overhead from transient reader state and wrapper objects. On wide result sets, these short-lived allocations create significant young-generation GC pressure and reduce throughput.

Measured result on a 601-column × 1M-row × 200-iteration scan with 400 runs (default + fetchSize=512):

Metric Baseline driver This PR Improvement
Average fetch (ms) 52,158 47,651 8.64 % faster
Median fetch (ms) 51,423 46,976 8.65 % faster
P95 fetch (ms) 58,239 52,756 9.41 % faster
Max fetch (ms) 80,500 62,374 22.52 % faster
Standard deviation (ms) 3,344 2,704 19.14 % lower
Spikes > 55 s 53 15 72 % fewer
Spikes > 60 s 15 5 67 % fewer
Spikes > 65 s 3 0 100 % fewer
Throughput (rows/sec) 19,173 20,986 9.5 % higher

The tail-latency improvement is a notable secondary benefit: fewer allocations slow young-generation growth, reduce minor GC frequency, and shrink the GC-induced latency tail. The largest baseline outlier in this dataset reached 80.5 seconds; this PR's worst case was 62.4 seconds,18 seconds lower. The observed latency range narrowed from 33.4 seconds to 18.4 seconds (−45%).

Key improvements

  • Reuse a single ServerDTVImpl instance per column across rows.
  • Add inline fast paths for common IOBuffer operations.
  • Eliminate unnecessary stream wrapper allocations for common synchronous getString() calls.
  • Reduce logging overhead in byte-copy loops.
  • Harden ResultSet row cleanup bounds.
  • Preserve existing behavior for streaming, adaptive, encrypted, and cursor-based code paths.

Changes

1. Reuse a single ServerDTVImpl instance per-column (dtv.java)

Every column's DTV instance allocates a fresh ServerDTVImpl on first access (DTV.getValue(), DTV.skipValue(), DTV.initFromCompressedNull()), then discards it when DTV.clear() sets impl = null at end of row. On the benchmark workload, this translates into tens of billions of short-lived ServerDTVImpl allocations.

A single reusable-instance field (reusableServerImpl) on each DTV holds one ServerDTVImpl for the column's lifetime. The helper acquireServerImpl() creates it on first use and, on every reuse, calls reset() (zeroing its four wire-state fields) before handing it out, so it is always clean. During an active read, impl points at this same instance. DTV.clear() simply resets impl to null at end of row (the reusable instance stays put), and setImpl() needs no special handling on the ServerDTVImpl → AppDTVImpl swap because the instance is already retained.

private ServerDTVImpl reusableServerImpl;

final void clear() {
    impl = null;                                      // preserve isInitialized() contract; instance stays retained
}

private ServerDTVImpl acquireServerImpl() {
    ServerDTVImpl s = reusableServerImpl;
    if (null != s) {
        s.reset();                                    // zero wire-state before reuse
        return s;
    }
    reusableServerImpl = new ServerDTVImpl();          // create once on first use
    return reusableServerImpl;
}

void setImpl(DTVImpl impl) {
    this.impl = impl;                                 // instance already held in reusableServerImpl
}

ServerDTVImpl.reset() zeros the same four fields the constructor initializes (valueLength, valueMark, isNull, internalVariant), so a reused instance is externally indistinguishable from a fresh one.

Net effect: one reusable ServerDTVImpl per DTV instead of one allocation per accessed column per row.

Safety. The external contract of isInitialized() is preserved because impl is still cleared to null after clear(); the reusable instance is only returned on the next read-side call, and it is reset() at that single point before use. The reusable instance is per-DTV (one per column per ResultSet), so there is no cross-column or cross-row aliasing and no thread sharing. Only ServerDTVImpl is reused; AppDTVImpl is not, on the updatable-row swap, impl is replaced with a fresh AppDTVImpl while the ServerDTVImpl remains retained for the next read.


2. Inline fast path for readUnsignedByte() (IOBuffer.java)

Every BYTELENTYPE column's length prefix (e.g. char(1), varchar(64), nullable int/float/decimal) goes through readUnsignedByte(), which unconditionally invokes ensurePayload(). In the common case the current packet already contains payload data, making
ensurePayload() a no-op. Add an inline payloadOffset < currentPacket.payloadLength check before the call, mirroring the pattern already in place for readShort(), readInt(), and readLong():

final int readUnsignedByte() throws SQLServerException {
    if (payloadOffset < currentPacket.payloadLength) {
        return currentPacket.payload[payloadOffset++] & 0xFF;
    }
    if (!ensurePayload()) throwInvalidTDS();
    return currentPacket.payload[payloadOffset++] & 0xFF;
}

Safety. Identical semantics. The slow path is preserved verbatim for packet-boundary cases.


3. Zero-copy fast path for readDaysIntoCE() (IOBuffer.java)

readDaysIntoCE() is called for every DATE, DATETIME2, DATETIMEOFFSET column. The original allocates a byte[3], calls readBytes() to fill it, then loops to reassemble the 3 bytes into an int, a method-call chain and an allocation per call. Read the 3 bytes directly from the current packet payload when available; fall back to the existing cross-packet path otherwise:

private int readDaysIntoCE() throws SQLServerException {
    final int length = TDS.DAYS_INTO_CE_LENGTH;     // 3
    int daysIntoCE;
    if (payloadOffset + length <= currentPacket.payloadLength) {
        final byte[] p = currentPacket.payload;
        final int off = payloadOffset;
        daysIntoCE = (p[off]     & 0xFF)
                   | ((p[off + 1] & 0xFF) <<  8)
                   | ((p[off + 2] & 0xFF) << 16);
        payloadOffset += length;
    } else {
        // Slow path: same as before. Uses the shared per-reader scratch buffer,
        // so neither path allocates per call.
        final byte[] value = readWrappedBytes(length);
        daysIntoCE = 0;
        for (int i = 0; i < length; i++)
            daysIntoCE |= ((value[i] & 0xFF) << (8 * i));
    }
    if (daysIntoCE < 0) throwInvalidTDS();
    return daysIntoCE;
}

Safety. Output is equivalent to the existing implementation. The fallback is the original code unchanged.


4. Hoist isLoggable() out of the readBytes() and readSkipBytes() loops (IOBuffer.java)

readBytes() and readSkipBytes() are the byte-copy and byte-skip loops used by every variable-length column read and skip. Both inner bodies did if (logger.isLoggable(Level.FINEST)) on every iteration, one volatile read per packet copied or skipped, never constant-folded by the JIT. Hoist the check to a final boolean local before the loop in both methods, avoiding repeated logger.isLoggable() checks within the loop.

final void readBytes(byte[] value, int valueOffset, int valueLength) throws SQLServerException {
    final boolean isLogging = logger.isLoggable(Level.FINEST);  // hoisted
    for (int bytesRead = 0; bytesRead < valueLength;) {
        ...
        if (isLogging) logger.finest(...);
        ...
    }
}

final void readSkipBytes(int valueLength) throws SQLServerException {
    final boolean isLogging = logger.isLoggable(Level.FINEST);  // hoisted
    for (int bytesSkipped = 0; bytesSkipped < valueLength;) {
        ...
        if (isLogging) logger.finest(...);
        ...
    }
}

Safety. The only observable difference is that FINEST logging toggled during a single call to either method would not affect subsequent iterations of that call. Logging configuration is set at startup in any real deployment; this is the standard Java logging-performance idiom and the two methods are now symmetric.


5. Tighter loop bound in discardCurrentRow() (SQLServerResultSet.java)

When next() advances to the next row, discardCurrentRow() iterates through previously-accessed columns calling column.clear(). The original loop bound was lastColumnIndex without a defensive check against columns.length, leaving open an ArrayIndexOutOfBoundsException in edge cases (server cursor with rowstat column under unusual access patterns). Use Math.min(lastColumnIndex, columns.length + 1) as the loop bound:

int clearUpTo = Math.min(lastColumnIndex, columns.length + 1);
for (int columnIndex = 1; columnIndex < clearUpTo; ++columnIndex)
    getColumn(columnIndex).clear();

Safety. Identical work in the normal case, strictly safer bounds.


6. Bypass SimpleInputStream for small synchronous string reads (dtv.java)

A major source of transient allocation pressure on this workload. For rs.getString() on a CHAR/VARCHAR/NCHAR/NVARCHAR column, ServerDTVImpl.getValue() allocates:

  1. new SimpleInputStream(tdsReader, valueLength, ...) : ~48 bytes including BaseInputStream fields
  2. A TDSReaderMark inside BaseInputStream's constructor: ~24 bytes
  3. A second TDSReaderMark when the stream's close() invokes setPositionAfterStreamed(): ~24 bytes
  4. The destination byte[valueLength] inside stream.getBytes()

…all so that convertStreamToObject can call new String(stream.getBytes(), charset) and return the string. The stream and both marks are immediately discarded. On a workload with ~40 % string columns this creates substantial transient allocation traffic.

When the call is provably a small synchronous getString(), i.e., all five of the following hold:

Guard Required because
valueLength > 0 && valueLength <= 4000 Truly large values stay on the streaming path (avoid one-shot huge allocations); zero-length cells stay on the regular path
streamGetterArgs.streamType == StreamType.NONE Stream getters (getBinaryStream / getAsciiStream / getCharacterStream) must return the wrapped stream to the caller
!streamGetterArgs.isAdaptive Adaptive paths wrap the stream in BufferedReader/etc. and hand it to user code
jdbcType.isTextual() Non-textual targets (e.g. getInt() on a VARCHAR) require convertStringToObject to trim()/parse() the string
jdbcType != CLOB && jdbcType != NCLOB Although textual, getClob() / getNClob() must return a SQLServerClob / SQLServerNClob (from the stream path), not a String

…read the bytes directly and construct the String inline:

case CHAR:
case VARCHAR:
case NCHAR:
case NVARCHAR: {
    if (valueLength > 0 && valueLength <= 4000
            && StreamType.NONE == streamGetterArgs.streamType
            && !streamGetterArgs.isAdaptive
            && jdbcType.isTextual()
            && JDBCType.CLOB != jdbcType && JDBCType.NCLOB != jdbcType) {
        byte[] bytes = new byte[valueLength];
        tdsReader.readBytes(bytes, 0, valueLength);
        convertedValue = new String(bytes, typeInfo.getCharset());
        break;
    }
    // Fall through to the stream-based path below for large/streaming/non-textual/LOB cases.
}
case TEXT:
case NTEXT:
case IMAGE:
case BINARY:
case VARBINARY:
case TIMESTAMP:
case VECTOR: {
    convertedValue = DDC.convertStreamToObject(
            new SimpleInputStream(tdsReader, valueLength, streamGetterArgs, this),
            typeInfo, jdbcType, streamGetterArgs);
    break;
}

When all guards hold (the dominant rs.getString() shape), 4 allocations + ~6 method calls per string cell collapse to 1 allocation + 1 method call.

Safety. The guarded execution paths through DDC.convertStreamToObject were traced and verified to produce equivalent behavior.

  • For textual JDBC types with streamType == NONE and !isAdaptive, the original ultimately executes convertStringToObject(new String(stream.getBytes(), charset), charset, jdbcType, NONE) whose default branch (for textual targets) returns the String as-is. The fast path builds the same String with the same charset.
  • CLOB / NCLOB targets are explicitly excluded so getClob() / getNClob() (and getObject(i, Clob.class) / getObject(i, NClob.class)) continue to return SQLServerClob / SQLServerNClob from the stream path rather than a String.
  • The Java switch fall-through is intentional and is explicitly documented in an inline comment so reviewers do not flag it as a bug.
  • All other cell-handling cases (TEXT, NTEXT, IMAGE, BINARY, VARBINARY, TIMESTAMP, VECTOR) are untouched.

Why this is safe to land

  1. No public-API changes. No new methods, no signature changes, no contract changes.
  2. All existing slow paths are preserved unchanged. Every fast path is gated on a condition that defaults to the original code path whenever the optimization does not apply. Packet-boundary reads, large values, streaming/adaptive access, non-textual conversions, encrypted columns, LOBs, PLP types, scrollable/updatable cursors, and server cursors all continue through the existing implementations.
  3. The reusable ServerDTVImpl instance is per-DTV, single-threaded by construction. Each DTV belongs to a single column of a single result set; no cross-column or cross-row aliasing is possible. The instance is reset() at the single point of reuse (acquireServerImpl()) before being handed out, and impl == null between rows preserves the read-side null checks, so a stale value can never leak into the next row.
  4. The String fast path is behaviorally equivalent. Traced through every branch of convertStreamToObject for the guarded inputs; The resulting String is equivalent to the existing implementation for the guarded inputs.
  5. The optimized hot paths eliminate or reuse transient objects where possible and do not introduce additional allocation-heavy intermediate structures.
  6. Defaults unchanged. No new connection property, no opt-in flag. The optimized paths are used automatically whenever their guard conditions are satisfied.

Benchmark results

Methodology

  • Workload: SELECT * over a 601-column table mixing numeric, string, decimal, and datetime columns
  • Volume: 1,000,000 rows × 200 iterations per run
  • Sample size: 400 runs per build per configuration
  • Configurations: default fetch size + fetchSize=512
  • Builds compared: stock mssql-jdbc (baseline) and this PR
  • Environment: single VM, warmed JVM, no other load

Combined (default + 512 fetchSize, 400 runs each)

Metric Baseline This PR
Average (ms) 52,158 47,651
Median (ms) 51,423 46,976
Min (ms) 47,062 43,984
Max (ms) 80,500 62,374
Range (ms) 33,438 18,390
P90 (ms) 55,841 50,304
P95 (ms) 58,239 52,756
StdDev (ms) 3,344 2,704
CV % 6.41 % 5.67 %
Spikes > 55 s 53 15
Spikes > 60 s 15 5
Spikes > 65 s 3 0
Throughput (rows/sec) 19,173 20,986

Observations

  1. The ~9 % mean reduction is uniform across both fetch-size configurations (−8.74 % default, −8.53 % at 512), confirming the changes target per-cell decoding cost rather than network batching.
  2. 100 % of > 65 s outliers are eliminated (3 → 0), consistent with reduced allocation pressure in young generation GC regions.
  3. Throughput rises +9.5 % (19,173 → 20,986 rows/sec).

Testing

  • Existing test suite: all unit and integration tests pass against this PR.
  • Benchmark validation: the 400-run benchmark above was executed end-to-end against a live SQL Server instance with both the baseline and PR builds; the ~9 % improvement was reproduced across multiple runs.
  • Added WideRowReadTests nested class in ResultSetTest that reads a fat table (10 rows x 601 columns) and asserts the exact value of every cell via type-specific getters. Covers 20 SQL types including both decimal decode paths: decimal(18,0) with an 8-byte magnitude and decimal(38,4) with a larger-than-8-byte magnitude.

@github-project-automation github-project-automation Bot moved this to In progress in MSSQL JDBC Jun 19, 2026
@Ananya2 Ananya2 self-assigned this Jun 19, 2026
@codecov

codecov Bot commented Jun 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.33333% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 59.40%. Comparing base (3c5f681) to head (72e37a9).
⚠️ Report is 10 commits behind head on main.

Files with missing lines Patch % Lines
...in/java/com/microsoft/sqlserver/jdbc/IOBuffer.java 62.50% 2 Missing and 1 partial ⚠️
...rc/main/java/com/microsoft/sqlserver/jdbc/dtv.java 90.00% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #2974      +/-   ##
============================================
- Coverage     59.52%   59.40%   -0.12%     
+ Complexity     5122     5084      -38     
============================================
  Files           153      153              
  Lines         36292    36382      +90     
  Branches       6636     6657      +21     
============================================
+ Hits          21603    21613      +10     
+ Misses        11045    11041       -4     
- Partials       3644     3728      +84     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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

This PR focuses on reducing allocation and overhead in the ResultSet read hot path within the Microsoft JDBC Driver for SQL Server, primarily by adding fast paths in TDSReader operations and reusing per-column reader state to cut GC pressure on wide-row workloads.

Changes:

  • Reuses per-column ServerDTVImpl instances across rows to avoid repeated allocations during value reads.
  • Adds targeted IOBuffer.TDSReader fast paths and logging micro-optimizations for frequently executed read loops.
  • Hardens SQLServerResultSet.discardCurrentRow() cleanup bounds to avoid out-of-range column clearing in edge cases.

Reviewed changes

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

File Description
src/main/java/com/microsoft/sqlserver/jdbc/SQLServerResultSet.java Tightens row-discard cleanup bounds when clearing previously accessed columns.
src/main/java/com/microsoft/sqlserver/jdbc/IOBuffer.java Adds read fast paths (readUnsignedByte, readDaysIntoCE) and hoists isLoggable checks out of hot loops.
src/main/java/com/microsoft/sqlserver/jdbc/dtv.java Pools ServerDTVImpl per DTV and adds a guarded fast path for small synchronous textual getString() reads.

@machavan machavan added this to the 13.5.1 milestone Jun 23, 2026
Comment thread src/main/java/com/microsoft/sqlserver/jdbc/SQLServerResultSet.java
Comment thread src/test/java/com/microsoft/sqlserver/jdbc/resultset/ResultSetTest.java Outdated
Comment thread src/test/java/com/microsoft/sqlserver/jdbc/resultset/ResultSetTest.java Outdated
Comment thread src/test/java/com/microsoft/sqlserver/jdbc/resultset/ResultSetTest.java Outdated
Comment thread src/main/java/com/microsoft/sqlserver/jdbc/dtv.java
Comment thread src/main/java/com/microsoft/sqlserver/jdbc/dtv.java Outdated
Comment thread src/main/java/com/microsoft/sqlserver/jdbc/dtv.java Outdated
Comment thread src/main/java/com/microsoft/sqlserver/jdbc/dtv.java Outdated
Comment thread src/main/java/com/microsoft/sqlserver/jdbc/dtv.java

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

Comments suppressed due to low confidence (4)

src/test/java/com/microsoft/sqlserver/jdbc/resultset/ResultSetTest.java:2981

  • These assertNull calls use the JUnit 4 parameter order (message first). With JUnit 5 this will not compile; message should be the last argument.
                    assertTrue(rs.wasNull(), "int should be null on row 2");
                    assertNull("varchar should be null on row 2", rs.getString(3));
                    assertTrue(rs.wasNull());
                    assertNull("decimal should be null on row 2", rs.getBigDecimal(4));

src/test/java/com/microsoft/sqlserver/jdbc/resultset/ResultSetTest.java:3112

  • assertArrayEquals here uses the JUnit 4 overload (message first). With JUnit 5 the expected/actual arrays must come first and the message last.
                assertEquals(3, rs.getInt(1));
                assertArrayEquals("row 3 corrupted by a late stream close()", row3, rs.getBytes(2));
                assertFalse(rs.next());

src/test/java/com/microsoft/sqlserver/jdbc/resultset/ResultSetTest.java:3157

  • assertArrayEquals uses the JUnit 4 overload (message first). JUnit 5 requires (expected, actual, message).
                assertArrayEquals("row 2 corrupted on scrollable cursor", row2, rs.getBytes(2));

src/test/java/com/microsoft/sqlserver/jdbc/resultset/ResultSetTest.java:3163

  • assertArrayEquals uses the JUnit 4 overload (message first) and will not compile under JUnit 5.
                assertArrayEquals("row 1 corrupted after scrolling back on a scrollable cursor", row1,
                        rs.getBytes(2));

@Ananya2
Ananya2 merged commit e4bf37a into main Jul 24, 2026
23 of 24 checks passed
@github-project-automation github-project-automation Bot moved this from In progress to Closed/Merged PRs in MSSQL JDBC Jul 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Closed/Merged PRs

Development

Successfully merging this pull request may close these issues.

5 participants