Skip to content

Perf: Allocation-free MONEY and DECIMAL processing with scratch-buffer String reads in the ResultSet read path - #2991

Open
Ananya2 wants to merge 3 commits into
mainfrom
anagarg/perf-opt
Open

Perf: Allocation-free MONEY and DECIMAL processing with scratch-buffer String reads in the ResultSet read path#2991
Ananya2 wants to merge 3 commits into
mainfrom
anagarg/perf-opt

Conversation

@Ananya2

@Ananya2 Ananya2 commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR adds a third batch of targeted ResultSet perf optimizations focused on MONEY/SMALLMONEY decoding, BigDecimal byte encoding, synchronous String reads, and NBCROW null-bitmap initialization. Each change removes throwaway per-cell allocations or redundant per-column work while preserving the exact decoded value and the existing lazy-decoding architecture.

On wide-row bulk reads, where every column of every row is decoded, these per-cell temporaries (intermediate BigInteger objects, per-cell byte[] buffers, and repeated per-column method calls) multiply into billions of short-lived objects, creating young-generation GC pressure that shows up as both lower throughput and periodic latency spikes.

This is part of a series of performance improvement PRs. The type-agnostic changes (DTV state pooling and the length-prefix reader fast path) landed in #2974, and a separate PR covers the DECIMAL/NUMERIC decoder fast path. This PR is the home for the remaining datatype-specific decode/encode optimizations and the scratch-buffer String read.

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

Metric Baseline driver This PR Improvement
Average fetch (ms) 51,776 44,157 14.72 % faster
Median fetch (ms) 50,593 43,566 13.89 % faster
Min fetch (ms) 47,514 40,553 14.65 % faster
Max fetch (ms) 71,026 58,180 18.09 % faster
Range (ms) 23,512 17,627 25.03 % lower
P90 (ms) 56,235 47,433 15.65 % lower
P95 (ms) 60,745 50,143 17.45 % lower
Standard deviation (ms) 3,775 2,916 22.75 % lower
CV % 7.29 % 6.60 % lower variance
Spikes > 55 s 49 7 86 % fewer
Spikes > 60 s 25 0 100 % eliminated
Spikes > 65 s 9 0 100 % eliminated
Throughput (rows/sec) 19,314 22,646 17.3 % 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. This PR eliminates 100 % of the > 60 s and > 65 s catastrophic outliers.

Key improvements

  • Decode MONEY/SMALLMONEY without an intermediate BigInteger (both the plaintext and Always Encrypted paths).
  • Encode BigDecimal to wire bytes without an intermediate BigInteger.toByteArray() allocation when the magnitude fits in a long.
  • Decode short synchronous getString() values into the per-reader scratch buffer, avoiding a per-cell byte[].
  • Skip the redundant per-column NBCROW bitmap-initialization call once the row is already initialized.
  • Preserve existing behavior for large-magnitude values, streaming/adaptive access, and the Always Encrypted decode path.

Changes

1. Allocation-free MONEY/SMALLMONEY decode (IOBuffer.java, dtv.java)

readMoney() decodes every MONEY/SMALLMONEY column. The original wrapped the 8-byte (money) or 4-byte (smallmoney) unscaled value in a BigInteger before constructing the BigDecimal. The money/smallmoney unscaled magnitude always fits in a long, so build the BigDecimal directly with BigDecimal.valueOf(long, scale) and skip the intermediate BigInteger allocation.

// IOBuffer.java - readMoney()
long unscaledValue;
switch (valueLength) {
    case 8: // money
        ...
        unscaledValue = ((long) intBitsHi << 32) | (intBitsLo & 0xFFFFFFFFL);
        break;
    case 4: // smallmoney
        ...
        unscaledValue = readInt();
        break;
    ...
}
// money/smallmoney unscaled magnitude always fits in a long.
return DDC.convertBigDecimalToObject(BigDecimal.valueOf(unscaledValue, 4), jdbcType, streamType);

The same transformation is applied to the Always Encrypted decode path in ServerDTVImpl (SMALLMONEY and MONEY cases), replacing new BigDecimal(BigInteger.valueOf(...), 4) with BigDecimal.valueOf(..., 4).

Safety. Value-identical. BigDecimal.valueOf(long, 4) produces the same compact, long-backed BigDecimal the original path produced for these magnitudes.


2. Allocation-free BigDecimal byte encoding (DDC.java)

When encoding a BigDecimal to its TDS wire representation, the original always called BigInteger.toByteArray() to obtain the magnitude bytes. When the non-negative magnitude fits in a long, emit its little-endian bytes directly, matching the minimal two's-complement length toByteArray() would produce, without allocating the intermediate byte[]. Larger magnitudes fall through to the unchanged BigInteger path.

int bitLength = bi.bitLength();
if (bitLength < Long.SIZE) {
    long mag = bi.longValue();                 // >= 0 and exact because bitLength < 64
    int numMagBytes = bitLength / 8 + 1;       // == bi.toByteArray().length for non-negative bi
    valueBytes = new byte[numMagBytes + 3];
    int k = 0;
    valueBytes[k++] = (byte) bigDecimalVal.scale();
    valueBytes[k++] = (byte) (numMagBytes + 1); // data length + sign
    valueBytes[k++] = (byte) (isNegative ? 0 : 1);
    for (int i = 0; i < numMagBytes; i++)
        valueBytes[k++] = (byte) (mag >> (8 * i)); // little-endian magnitude
    return valueBytes;
}

Safety. Byte-identical output for the guarded inputs. numMagBytes is computed to match exactly what BigInteger.toByteArray().length yields for a non-negative magnitude, and larger magnitudes retain the original path.


3. Scratch-buffer decode for short synchronous String reads (IOBuffer.java, dtv.java)

Building on #2974's inline getString() fast path, this replaces the per-cell byte[] allocation with a new readStringFromBytes() helper that decodes short values from the per-reader scratch buffer. The scratch buffer is only read by the String constructor, so it does not escape.

// IOBuffer.java
final String readStringFromBytes(int valueLength, Charset charset) throws SQLServerException {
    if (valueLength <= valueBytes.length) {
        readBytes(valueBytes, 0, valueLength);
        return new String(valueBytes, 0, valueLength, charset);
    }
    byte[] bytes = new byte[valueLength];
    readBytes(bytes, 0, valueLength);
    return new String(bytes, 0, valueLength, charset);
}
// dtv.java - ServerDTVImpl.getValue(), CHAR/VARCHAR/NCHAR/NVARCHAR fast path
convertedValue = tdsReader.readStringFromBytes(valueLength, typeInfo.getCharset());

Safety. The produced String is identical to new String(bytes, charset); values larger than the scratch buffer still allocate a right-sized byte[]. The existing guards from #2974 (valueLength <= 4000, streamType == NONE, !isAdaptive, jdbcType.isTextual()) are unchanged.


4. Skip redundant per-column NBCROW initialization (SQLServerResultSet.java)

loadColumn() called initializeNullCompressedColumns() for every column access. For an NBCROW, the null bitmap is read on the first accessed column and a flag is set; subsequent columns re-entered the method only to return immediately. Guard the call with the existing areNullCompressedColumnsInitialized flag to skip the method call entirely once the row is initialized.

if (!areNullCompressedColumnsInitialized)
    initializeNullCompressedColumns();

Safety. Identical behavior. The guard replicates the method's own early-return condition. The flag is reset per row.


Why this is safe to land

  1. No public-API changes. No new public methods, no signature changes, no contract changes.
  2. All fast paths are guarded and fall back to the original code whenever the optimization does not apply (large magnitudes, values larger than the scratch buffer, non-textual/streaming/adaptive string access).
  3. Decoded and encoded values are identical. BigDecimal.valueOf(long, scale) and the direct byte-emit path produce the same results as the original BigInteger-based code for the guarded inputs.
  4. No shared mutable state escapes. The scratch buffer is only read by the String constructor within readStringFromBytes() and never handed to callers.
  5. 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 x 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) 51,776 44,157
Median (ms) 50,593 43,566
Min (ms) 47,514 40,553
Max (ms) 71,026 58,180
Range (ms) 23,512 17,627
P90 (ms) 56,235 47,433
P95 (ms) 60,745 50,143
StdDev (ms) 3,775 2,916
CV % 7.29 % 6.60 %
Spikes > 55 s 49 7
Spikes > 60 s 25 0
Spikes > 65 s 9 0
Throughput (rows/sec) 19,314 22,646

Improvement vs Baseline (negative % = faster)

Config Avg Median Max P95 StdDev
Combined -14.72 % -13.89 % -18.09 % -17.45 % -22.75 %
Default fetchSize -14.67 % -13.97 % -18.09 % -15.17 % -13.37 %
fetchSize = 512 -14.76 % -13.50 % -16.93 % -18.47 % -30.32 %

Observations

  1. ~14.7 % mean reduction (approximately 7.6 seconds faster per fetch), consistent across both fetch-size configurations (-14.67 % default, -14.76 % at 512), confirming the changes target per-cell decoding cost rather than network batching.
  2. 100 % of > 60 s and > 65 s outliers are eliminated (25 to 0 and 9 to 0), consistent with reduced young-generation GC pressure.
  3. Throughput rises +17.3 % (19,314 to 22,646 rows/sec).
  4. Reproducible: three consecutive July 16 datasets landed at -14.89 %, -14.90 %, and -14.72 % (1,056 total runs at ~15 %).

Testing

  • Existing test suite: all unit and integration tests pass against this PR. The MONEY/SMALLMONEY, DECIMAL/NUMERIC, and string decode paths are already exercised across precision/scale combinations, NBCROW null handling, packet-boundary spanning reads, and Always Encrypted columns.
  • Benchmark validation: the 400-run benchmark above was executed end-to-end against a live SQL Server instance with both the baseline and this PR builds; the ~14.7 % improvement was reproduced across multiple runs.
  • The WideRowReadTests nested class added in Perf: Optimize the ResultSet read path and reduce allocation overhead #2974 (reads a fat table of 10 rows x 601 columns and asserts the exact value of every cell via type-specific getters, covering 20 SQL types including both decimal decode paths) continues to cover the changes here.

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 introduces allocation-reducing optimizations in the ResultSet read/convert path of the Microsoft JDBC Driver for SQL Server, targeting high-volume wide-row scans by avoiding per-cell temporary objects while preserving existing decoding/streaming behavior.

Changes:

  • Add a scratch-buffer-backed readStringFromBytes helper to avoid per-cell byte[] allocations for small synchronous textual getString() reads.
  • Replace MONEY/SMALLMONEY decoding paths that used BigInteger intermediates with direct long-backed BigDecimal.valueOf(..., 4) construction (including Always Encrypted denormalization).
  • Add a BigDecimal wire-encoding fast path that emits magnitude bytes directly when the magnitude fits in a long, skipping BigInteger.toByteArray() allocation.
  • Skip redundant NBCROW null-bitmap initialization calls in SQLServerResultSet.loadColumn() once initialized for the current row.

Reviewed changes

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

File Description
src/main/java/com/microsoft/sqlserver/jdbc/SQLServerResultSet.java Avoid repeated NBCROW null-bitmap initialization calls after the row has been initialized.
src/main/java/com/microsoft/sqlserver/jdbc/IOBuffer.java Add scratch-buffer String decoding helper; make MONEY/SMALLMONEY decode allocation-free by using long directly.
src/main/java/com/microsoft/sqlserver/jdbc/dtv.java Route small synchronous textual reads through the new scratch-buffer String decode; remove BigInteger intermediates for AE MONEY/SMALLMONEY.
src/main/java/com/microsoft/sqlserver/jdbc/DDC.java Add BigDecimal encoding fast path that avoids BigInteger.toByteArray() when magnitude fits in long.

@codecov

codecov Bot commented Jul 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.66667% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 60.61%. Comparing base (565aadd) to head (b2aca75).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...rc/main/java/com/microsoft/sqlserver/jdbc/dtv.java 20.00% 4 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #2991      +/-   ##
============================================
+ Coverage     60.19%   60.61%   +0.41%     
- Complexity     5152     5269     +117     
============================================
  Files           153      153              
  Lines         36637    36652      +15     
  Branches       6721     6725       +4     
============================================
+ Hits          22055    22217     +162     
+ Misses        10762    10757       -5     
+ Partials       3820     3678     -142     

☔ 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.

Comment thread src/main/java/com/microsoft/sqlserver/jdbc/DDC.java
Comment thread src/main/java/com/microsoft/sqlserver/jdbc/DDC.java Outdated
Comment thread src/main/java/com/microsoft/sqlserver/jdbc/IOBuffer.java
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

3 participants