Perf: Optimize the ResultSet read path and reduce allocation overhead - #2974
Conversation
…Fast-Path Decoding
Codecov Report❌ Patch coverage is 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. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
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
ServerDTVImplinstances across rows to avoid repeated allocations during value reads. - Adds targeted
IOBuffer.TDSReaderfast 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. |
…larify pooledServerImpl doc
…no behavior change)
…o behavior change)
There was a problem hiding this comment.
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));
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):
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
ServerDTVImplinstance per column across rows.IOBufferoperations.getString()calls.ResultSetrow cleanup bounds.Changes
1. Reuse a single
ServerDTVImplinstance per-column (dtv.java)Every column's
DTVinstance allocates a freshServerDTVImplon first access (DTV.getValue(),DTV.skipValue(),DTV.initFromCompressedNull()), then discards it whenDTV.clear()setsimpl = nullat 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 eachDTVholds oneServerDTVImplfor the column's lifetime. The helperacquireServerImpl()creates it on first use and, on every reuse, callsreset()(zeroing its four wire-state fields) before handing it out, so it is always clean. During an active read,implpoints at this same instance.DTV.clear()simply resetsimpltonullat end of row (the reusable instance stays put), andsetImpl()needs no special handling on theServerDTVImpl → AppDTVImplswap because the instance is already retained.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 becauseimplis still cleared tonullafterclear(); the reusable instance is only returned on the next read-side call, and it isreset()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. OnlyServerDTVImplis reused;AppDTVImplis not, on the updatable-row swap,implis replaced with a freshAppDTVImplwhile theServerDTVImplremains retained for the next read.2. Inline fast path for
readUnsignedByte()(IOBuffer.java)Every
BYTELENTYPEcolumn's length prefix (e.g.char(1),varchar(64), nullableint/float/decimal) goes throughreadUnsignedByte(), which unconditionally invokesensurePayload(). In the common case the current packet already contains payload data, makingensurePayload()a no-op. Add an inlinepayloadOffset < currentPacket.payloadLengthcheck before the call, mirroring the pattern already in place forreadShort(),readInt(), andreadLong():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 everyDATE,DATETIME2,DATETIMEOFFSETcolumn. The original allocates abyte[3], callsreadBytes()to fill it, then loops to reassemble the 3 bytes into anint, 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:Safety. Output is equivalent to the existing implementation. The fallback is the original code unchanged.
4. Hoist
isLoggable()out of thereadBytes()andreadSkipBytes()loops (IOBuffer.java)readBytes()andreadSkipBytes()are the byte-copy and byte-skip loops used by every variable-length column read and skip. Both inner bodies didif (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 afinal booleanlocal before the loop in both methods, avoiding repeated logger.isLoggable() checks within the loop.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 callingcolumn.clear(). The original loop bound waslastColumnIndexwithout a defensive check againstcolumns.length, leaving open anArrayIndexOutOfBoundsExceptionin edge cases (server cursor with rowstat column under unusual access patterns). UseMath.min(lastColumnIndex, columns.length + 1)as the loop bound:Safety. Identical work in the normal case, strictly safer bounds.
6. Bypass
SimpleInputStreamfor 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:new SimpleInputStream(tdsReader, valueLength, ...): ~48 bytes includingBaseInputStreamfieldsTDSReaderMarkinsideBaseInputStream's constructor: ~24 bytesTDSReaderMarkwhen the stream'sclose()invokessetPositionAfterStreamed(): ~24 bytesbyte[valueLength]insidestream.getBytes()…all so that
convertStreamToObjectcan callnew 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:valueLength > 0 && valueLength <= 4000streamGetterArgs.streamType == StreamType.NONEgetBinaryStream/getAsciiStream/getCharacterStream) must return the wrapped stream to the caller!streamGetterArgs.isAdaptiveBufferedReader/etc. and hand it to user codejdbcType.isTextual()getInt()on a VARCHAR) requireconvertStringToObjecttotrim()/parse()the stringjdbcType != CLOB && jdbcType != NCLOBgetClob()/getNClob()must return aSQLServerClob/SQLServerNClob(from the stream path), not aString…read the bytes directly and construct the
Stringinline: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.convertStreamToObjectwere traced and verified to produce equivalent behavior.streamType == NONEand!isAdaptive, the original ultimately executesconvertStringToObject(new String(stream.getBytes(), charset), charset, jdbcType, NONE)whose default branch (for textual targets) returns theStringas-is. The fast path builds the sameStringwith the samecharset.CLOB/NCLOBtargets are explicitly excluded sogetClob()/getNClob()(andgetObject(i, Clob.class)/getObject(i, NClob.class)) continue to returnSQLServerClob/SQLServerNClobfrom the stream path rather than aString.switchfall-through is intentional and is explicitly documented in an inline comment so reviewers do not flag it as a bug.TEXT,NTEXT,IMAGE,BINARY,VARBINARY,TIMESTAMP,VECTOR) are untouched.Why this is safe to land
ServerDTVImplinstance is per-DTV, single-threaded by construction. EachDTVbelongs to a single column of a single result set; no cross-column or cross-row aliasing is possible. The instance isreset()at the single point of reuse (acquireServerImpl()) before being handed out, andimpl == nullbetween rows preserves the read-side null checks, so a stale value can never leak into the next row.Stringfast path is behaviorally equivalent. Traced through every branch ofconvertStreamToObjectfor the guarded inputs; The resultingStringis equivalent to the existing implementation for the guarded inputs.Benchmark results
Methodology
SELECT *over a 601-column table mixing numeric, string, decimal, and datetime columnsfetchSize=512Combined (default + 512 fetchSize, 400 runs each)
Observations
> 65 soutliers are eliminated (3 → 0), consistent with reduced allocation pressure in young generation GC regions.Testing