Skip to content

Blackbird: use generated accessors for primitive double properties - #375

Merged
cowtowncoder merged 10 commits into
FasterXML:2.xfrom
parker89:pw/blackbird-double-accessors
Nov 3, 2026
Merged

cowtowncoder merged 10 commits into
FasterXML:2.xfrom
parker89:pw/blackbird-double-accessors

Conversation

@parker89

@parker89 parker89 commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #364. Blackbird's specialized accessors came over from Afterburner, which never had one for double, so a primitive double setter or getter still went through reflection and boxed on every call. For a bean that's mostly doubles, Blackbird did about as well as plain databind.

This adds SettableDoubleProperty and DoublePropertyWriter, ported from the current long versions and following the same rules from #364, #366 and #367: setup checks, exact-type and receiver checks, null handling and failure reporting that match MethodProperty, and no retries.

The main win is allocation: 24 bytes less for each double read or written. Throughput improves a lot where number parsing and formatting are cheap or skipped (a TokenBuffer, as with convertValue, or a binary format), and a little with JSON.

Matching stock:

  • Deserialization only takes the fast path for VALUE_NUMBER_FLOAT, read with getDoubleValue(), which is exactly what DoubleDeserializer does for that token. Integers, Strings (like "NaN"), nulls and arrays still go through the stock deserializer, so coercion config and FAIL_ON_NULL_FOR_PRIMITIVES behave as before. set() and setAndReturn() only use the generated setter for a Double.
  • Serialization compares suppressed values the way Double.equals does (-0.0 isn't 0.0, NaN matches NaN), gives a custom valueFilter the boxed value, and leaves a getter with a type serializer to BeanPropertyWriter, since DoubleSerializer writes a type id for NaN and infinities. The STRING shape and custom serializers use the fallback writer, as for the other types.

Also fixed, for all property types:

  • Inside @JsonUnwrapped(prefix = ...), the STRING shape and custom serializers were ignored ({"p_l":5} instead of {"p_l":"5"}), because a renamed writer dropped broken and its fallback kept the old name.
  • NullSerializer, set with @JsonSerialize(using = ...) or registered for a type, was treated as a default serializer, so int, long, boolean and String getters wrote their value instead of null. It's now excluded, as ToStringSerializer already is (Use of ToStringSerializer via @JsonSerialize on int/long property does not work #117).
  • LongPropertyWriter.serializeAsElement fell back as a field, not an element, after an access error.

Not changed, as other types already behave this way: the int and long writers ignore CUSTOM value filters, regular bean accessors don't check CAN_OVERRIDE_ACCESS_MODIFIERS, and a getter that fails with an access error is retried through reflection.

Tests compare against vanilla Jackson across inputs, mapper and inclusion settings, shapes, special values (-0.0, NaN, infinities, min/max), regular beans, fluent setters, creators, builders, as-array and unwrapped beans. All Blackbird tests pass on Java 8, 11, 17 and 25.

The new benchmarks use 1,000 beans per operation, each with six doubles and an int. JDK 25 on an 8-core Linux box, 4 forks each, alternating base (2.x) and head:

benchmark ops/ms KB/op
doubleBeanArrayBufferDeser 6.22 → 8.62 (+39%) 253 → 112
doubleBeanArrayBufferSer 6.33 → 7.08 (+12%) 407 → 266
doubleBeanArrayDeser 1.28 → 1.32 (+3%) 841 → 700
doubleBeanArraySer 1.67 → 1.76 (+5%) 900 → 760
doubleBuilderArrayDeser 1.15 → 1.15 856 → 716

On JDK 17 the saving is 47 bytes per double. Smile showed +21% deser and +30% ser in a separate run. The existing benchmarks didn't change.

#359 replaces this engine from 3.3/3.4 on, so this is for 2.x and 3.1/3.2.

Benchmark commands (use a disposable checkout)
# Builds Blackbird from origin/2.x and from this branch, each into a benchmarks jar
# built from this branch's benchmark sources (which add the double benchmarks).
set -euo pipefail
HEAD_DIR=$PWD
BASE_DIR=$(mktemp -d)
git archive origin/2.x | tar -x -C "$BASE_DIR"
rm -rf "$BASE_DIR/blackbird/benchmarks"
cp -R blackbird/benchmarks "$BASE_DIR/blackbird/benchmarks"

build() {  # $1 = checkout to install Blackbird from, $2 = output jar
  (cd "$1" && ./mvnw -B -q -ntp clean install -pl blackbird -DskipTests)
  (cd "$1/blackbird/benchmarks" && ../../mvnw -B -q -ntp clean package -DskipTests)
  cp "$1/blackbird/benchmarks/target/benchmarks.jar" "$2"
}

build "$BASE_DIR" /tmp/benchmarks-base.jar
build "$HEAD_DIR" /tmp/benchmarks-head.jar
JMH_ARGS=(-f 2 -wi 5 -w 2s -i 5 -r 2s -prof gc)
for round in 1 2; do
  for jar in base head; do
    java -jar "/tmp/benchmarks-$jar.jar" '\.Blackbird\.double' "${JMH_ARGS[@]}"
  done
done

cc @stevenschlansker @cowtowncoder

// Renamed (as for an unwrapped property, after its serializer was assigned):
// the fallback must write the new name, and a writer that already uses it
// (like one with a non-default serializer) must keep doing so
fallbackWriter = _renamed(base.fallbackWriter, name);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drive-by fix: inside @JsonUnwrapped(prefix = ...), a renamed writer used to forget broken and keep a fallback with the old name, so the STRING shape and custom serializers were ignored for every type. Doubles would have picked this up, so I fixed it here. TestUnwrappedWriters covers it.

return false;
}
// Nor one that writes null whatever the value (as used to hide one)
if (ser instanceof NullSerializer) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drive-by fix: NullSerializer is @JacksonStdImpl, so it counted as a default serializer and int/long/boolean/String getters wrote their value instead of null. Same exclusion as ToStringSerializer (#117).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you file an issue for https://github.com/FasterXML/jackson-databind/issues for this? (to remove that annotation)

value = _acc.applyAsLong(bean);
} catch (Throwable t) {
_handleProblem(bean, gen, prov, t, false);
_handleProblem(bean, gen, prov, t, true);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drive-by fix: this was false, so after an access error an as-array bean fell back to writing a field instead of an element. The other writers already pass true.

@cowtowncoder

Copy link
Copy Markdown
Member

@parker89 made a change based on Claude review to fix support of Custom filters for boolean, int and long writers.

@cowtowncoder cowtowncoder left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@cowtowncoder cowtowncoder changed the title Blackbird: use generated accessors for primitive double properties (2.x) Blackbird: use generated accessors for primitive double properties Nov 3, 2026
@cowtowncoder cowtowncoder added this to the 2.23.0 milestone Nov 3, 2026
@cowtowncoder
cowtowncoder merged commit d97abf3 into FasterXML:2.x Nov 3, 2026
4 checks passed
@parker89

parker89 commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review!

Filed FasterXML/jackson-databind#6402

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants