Jackson 2.18 even faster floating-point reads! (another +20%!)

(by fixing a regression in 2.15 handling)

@cowtowncoder
5 min readJun 27, 2024

As I wrote earlier (“Jackson 2.15 faster floating-point reads”, “Jackson 2.16 faster BigDecimal reads”), lately Jackson performance of floating-point number reads from JSON (into float/double/BigDecimal) has been improving.

But I was surprised to find out — based on feature request which essentially transmogrified into a bug report (jackson-core#1229) — that there was actually something causing sub-optimal handling.

What Happened?

So: although actual decoding, using excellent FastDoubleParser library, undoubtedly (and measurably) improved, it seemed that formerly working String-allocation avoidance (that is, most of the time decoding numbers directly from input buffer without allocating intermediate String to store textual representation) had stopped working.

This was an unintended side effect of important improvements to prevent accidental loss of precision (decoding JSON Number as 2-based Double when 10-based BigDecimal is ultimately needed), needed to support buffering of JSON content. While it is necessary to store a String representation in buffering cases, it should NOT be necessary in non-buffering cases — but this is exactly what was happening, starting with Jackson 2.15 (I think) or 2.16 at latest.

What was done to avoid String allocation

Since JSON Number decoding uses TextBuffer for keeping textual representation around if value is not immediately decoded (Jackson will also try to avoid unnecessary work altogether — if number value is not accessed, it is not decoded, at least for floating-point values (for ints decoding cost is marginal)), the first step was to streamline decoding path by adding couple of new methods in TextBuffer. This was done via jackson-core#1230.

But as importantly, actual decode calls for JsonParser (implemented in ParserBase used by most format backends) needed to be changed to avoid buffering when value is specifically requested. This turned out to be relatively simple (see PR jackson-core#1313).

Great!

… But Performance Was Not There?!?

Ok so this seemed good: although I wasn’t able to figure out a good way to unit test that no allocation occurs (I am guessing Mocking frameworks might have a way to do that but I have not tackled such problem before — any help appreciated, like filing issue at jackson-core :) ), things seemed right.

So I decided to fire up “Currency” tests for jackson-benchmarks, used for my earlier testing wrt 2.15/2.16 speedups.

And… what…. is going on? Performance did not change measurably. I tried to squint, see if it was just too small a difference.
But no, it looked as if there was no difference.

Found it!

So I decided to do some debug logging; verifying that TextBuffer.contentsAsDouble() actually selects path it should.

It didn’t. Problem was line 554 of TextBuffer:

if (_currentSize == 0) { // all content in current segment!

which was trying to see whether number of (extra) segments is above 0 (if so, content is segmented and we can not decode from contiguous buffer).

Except, that’d be _segmentSize, NOT _currentSize (which actually indicates number of characters in the current segment). Whops.
Unfortunately this bug did not break any unit tests as code simply skipped an optimization, proceeding to use slower String allocation (due to assume segmentation).

Did it work?

Ok. With the fix (all tests still passing) it was time to see whether there was a difference.

Indeed there was. Let’s have a look.

Baseline Performance: 2.17

Although we could just reuse results from earlier blog posts, let’s re-run tests with the latest published version of Jackson, 2.17.1.

FP/Double (2.17.1)

./mvnw clean package
// JDK 1.8.0_412
java -Xmx256m -jar target/perf.jar ".*Json.*StdReadVanilla.readCurrencyPojo.*" -wi 3 -w 1 -i 5 -r 1 -f 5

Benchmark Mode Cnt Score Error Units
JsonStdReadVanilla.readCurrencyPojoDefault thrpt 25 59892.793 ± 441.931 ops/s
JsonStdReadVanilla.readCurrencyPojoFast thrpt 25 68672.584 ± 725.991 ops/s

Results are basically the same as with 2.15 that was tested earlier.

FP/BigDecimal (2.17.1)

./mvnw clean package
// JDK 1.8.0_412
java -Xmx256m -jar target/perf.jar ".*Json.*StdReadVanilla.readCurrencyBig.*" -wi 3 -w 1 -i 5 -r 1 -f 5

Benchmark Mode Cnt Score Error Units
JsonStdReadVanilla.readCurrencyBigDecPojoDefault thrpt 25 66263.416 ± 934.989 ops/s
JsonStdReadVanilla.readCurrencyBigDecPojoFast thrpt 25 67736.009 ± 471.049 ops/s

Performance here is somewhat higher (+10%) than in the blog post: this may be due to using JDK 8 instead of JDK 21 (for some reason my experience for micro-benchmarks is that later JDKs often have bit lower throughput than JDK 8). Also interesting there is no difference between default “slow” and “fast” variants.

Improved Performance: 2.18 (preview/SNAPSHOT)

And then re-build from 2.18 branch, 2.18.0-SNAPSHOT.

FP/Double (2.18.0-SNAPSHOT)

./mvnw clean package
// JDK 1.8.0_412

java -Xmx256m -jar target/perf.jar ".*Json.*StdReadVanilla.readCurrencyPojo.*" -wi 3 -w 1 -i 5 -r 1 -f 5
Benchmark Mode Cnt Score Error Units
JsonStdReadVanilla.readCurrencyPojoDefault thrpt 25 60133.557 ± 517.816 ops/s
JsonStdReadVanilla.readCurrencyPojoFast thrpt 25 80708.352 ± 1147.880 ops/s

FP/BigDecimal (2.18.0-SNAPSHOT)

./mvnw clean package
// JDK 1.8.0_412

java -Xmx256m -jar target/perf.jar ".*Json.*StdReadVanilla.readCurrencyBig.*" -wi 3 -w 1 -i 5 -r 1 -f 5
Benchmark Mode Cnt Score Error Units
JsonStdReadVanilla.readCurrencyBigDecPojoDefault thrpt 25 78752.383 ± 2588.583 ops/s
JsonStdReadVanilla.readCurrencyBigDecPojoFast thrpt 25 80000.146 ± 1795.855 ops/s

(interesting there’s also limited difference between throughput of “fast” and default handling (within measurement error margin) — need to investigate if it’s test setup issue)

Performance Comparison (2.17.1 vs 2.18.0-SNAPSHOT)

So, looking at differences in optimized (“fast”) cases we find that:

  1. Reading of Double/double values improved by further +20%! (from 69k to 80k) — total speedup of about +35% between 2.17 and 2.18
  2. Similarly, reading of BigDecimal values improved by +20% as well (from 68k to 80k) — total speedup of +20% between 2.17 and 2.18 (and possibly +35% from 2.15?)
  3. Optimized performance of reading doubles and BigDecimals seems to be essentially the same

But wait! There’s possibly more: benchmark use case is not as Floating-Point heavy as that of, say, reading Big Vectors (see “Jackson 2.15: faster floating-point reads” for discussion)

So it is possible that a FP-heavy use case could be twice as fast with optimizations enabled (or maybe even more).

When Can I Have It? :)

Things are progressing fast with Jackson 2.18 developments — see Jackson 2.18 Release Notes (WIP) page — if all goes well, we might get the release candidate (2.18.0-rc1) out by end of July 2024.

Until then, all the help in testing 2.18.0-SNAPSHOT (built locally or accessed from Sonatype OSS Snapshot repo) would be most welcome.

--

--

@cowtowncoder

Open Source developer, most known for Jackson data processor (nee “JSON library”), author of many, many other OSS libraries for Java, from ClassMate to Woodstox