Skip to content

Blackbird: use new nextNameMatchAndToken API for performance - #360

Closed
stevenschlansker wants to merge 38 commits into
FasterXML:3.xfrom
stevenschlansker:claude-blackbird-fused
Closed

Blackbird: use new nextNameMatchAndToken API for performance#360
stevenschlansker wants to merge 38 commits into
FasterXML:3.xfrom
stevenschlansker:claude-blackbird-fused

Conversation

@stevenschlansker

Copy link
Copy Markdown
Contributor

This PR is stacked on #359 ; only review the head commit (or wait for that PR to merge)

Bring in improvement from FasterXML/jackson-core#1688 - use combined nextNameMatchAndToken to avoid re-running utf8 parser token handling prologue twice.

Designed with Claude Fable. Claude's notes:

Generated codecs dispatch on the name-match index and then read the value, so each property pays two out-of-line parser calls: nextNameMatch and nextToken. The byte parser already classifies the value token during name matching; the fused call commits it directly. This change makes the generated deserializer loop use nextNameMatchAndToken: a non-negative match leaves the current token on the value, and the scalar, child, and field arms consume it in place. Negative results (unknown name, END_OBJECT, odd token) behave exactly as the two-call sequence, so no error path changes.

The change is 7 insertions and 10 deletions in BeanCodecGenerator — the fused form is smaller than the two-call emission it replaces.

Measured through these codecs (fused vs two-call, identical module otherwise, paired order-alternating calibrated JMH rounds, databind 3.3 stack, two architectures):

  • Record graph: +7.6% median (aarch64, all six rounds positive) / +12.7% median (x86_64).
  • Setter POJO: +5.4% / +6.1% median.

Claude (on behalf of Steven Schlansker) added 13 commits September 6, 2026 22:31
…ecs (ClassFile API)

New engine: BBDeserializerModifier wraps eligible stock BeanDeserializers in a
placeholder that generates a hidden-class codec at resolve time (matcher from
ctxt.tokenStreamFactory(), classData for the matcher and per-property
SettableBeanProperty payloads, no ClassOption.STRONG so codecs unload with the
mapper). Tier-A scalars (String/int/long/boolean public setters with stock
deserializers) inline; everything else rides the stock property from generated
dispatch; VALUE_NULL always routes through the stock property; an active view
delegates per call to the stock deserializer. Conservative modify-time gates
keep stock behavior for creators, builders, object ids, any-setters, aliases,
merge, non-public or inner classes.

Old LambdaMetafactory deserializer engine removed (SuperSonic*, Optimized*,
Settable*Property, CreatorOptimizer). Serializer side unchanged for now.
Module compiles at release 25 (java.lang.classfile).

Suite: 356/356 on the module path; on the classpath the pre-existing
tofix/TestBBClassloaders mode-dependence surfaces (passes there on pristine
3.x too). Known follow-up: hidden-class definition does not trigger loading of
its not-yet-loaded superclass under module-path deployment (worked around by
deriving the superclass ClassDesc from the class literal; minimal repro owed).
… artifact

The NoClassDefFoundError came from GeneratedCodecBase.class being absent from
target/test-classes (javac implicit compilation under --patch-module emits
only compile-time-referenced main classes, and the test module shadows
target/classes under ModuleFinder first-wins), not from hidden-class
superclass resolution. Investigated separately with a minimal repro; the JDK
follows its specification. The class-literal form stays as the durable fix.
Records collect components into typed locals in canonical-constructor order
and build via the constructor MethodHandle (invokeExact); VALUE_NULL and
non-tier-A components route through the stock CreatorProperty's deserialize()
with a wrapper-to-primitive convert. Gated on one CreatorProperty per
component with matching index and type, and on the user Lookup (records have a
private canonical constructor). Modifier bails under FAIL_ON_MISSING/NULL_
CREATOR_PROPERTIES and skips the no-arg-ctor check for records.
…or and build method

Builder-based beans generate a codec that creates the builder via the stock
ValueInstantiator, applies properties with deserializeSetAndReturn semantics
(tier-A fluent setters must return void or exactly the builder class), and
finishes through the build method unreflected via the user lookup and asType'd
to (Object)Object. The build method rides a ThreadLocal from updateBuilder to
modifyDeserializer, the only place it is exposed. Abstract value types are
allowed for builder beans (the builder instantiates).
…k odd-token semantics

CHILD properties (value deserializer is a generated codec) call the child
codec directly through a classData constant in all three modes, with a
non-START_OBJECT guard falling back to the stock property so coercion and
error semantics stay stock. Non-public POJO scalar setters reach their bean
through classData MethodHandles from the user lookup; access failure demotes
to the stock path. Unexpected tokens where a property name belongs now report
through DeserializationContext.handleUnexpectedToken like the stock
deserializer (engagement tests switch from exception-type probes to a capture
modifier; the builder flow keys modifiers by the builder class).
Generated writers: straight-line writeName/write-value with pre-encoded name
constants, tier-A public getters (exact BeanPropertyWriter, no null
suppression, stock scalar serializers), child-writer linking through classData
constants, the stock PropertyWriter.serializeAsProperty for everything else,
and per-call delegation to the stock serializer when a view is active.
Interface-typed beans dispatch getters with invokeinterface. Old
LambdaMetafactory serializer engine removed.
On the AArch64 C2 backend, inlining small hot write helpers into a looping
caller makes the register allocator spill working state across the loop
(perfasm: 31% of hot-region samples in stack traffic). Generated writer
bodies contain inline list loops, which is exactly the trigger shape. Emit
each property write through a per-kind static helper that fuses writeName
with the value write and is padded past FreqInlineSize, so every helper
compiles as a standalone unit. Paired benchmarks: order-record writes go
from -24% to +9% against the previous engine on aarch64, +4% on x86_64;
media writes +18%/+9%; run-to-run bimodality is gone on both architectures.
OpenJDK report on the underlying C2 behavior is drafted; the padding gets
the JDK- number once triaged.
Describe the ClassFile-API engine, the JDK 25 floor, the record and builder
acceleration, and the per-bean fallback gates. Remove the metaspace OOM
warning: hidden classes are anchored by the codec instances in the mapper's
caches and unload with the mapper.
The inject tests asserted the deleted per-property engine internals
(OptimizedSettableBeanProperty, OptimizedBeanPropertyWriter) and captured
deserializers before Blackbird's modifier ran. The harness now registers the
capture module first (modifiers run in reverse registration order) and the
tests assert the current contract: eligible classpath beans engage a
generated codec (BBCodecPlaceholder / BBSerCodecPlaceholder), values and
output match stock databind, and field-backed properties ride the codec's
stock-property arms. The CrossLoaderAccess fast-path pin stays and is deleted
together with CrossLoaderAccess (modules-base#350).
…#350)

After both engines were replaced, CrossLoaderAccess survived only as an
unused accessGrant constructor parameter on the modifiers, ReflectionHack had
no references at all, and Unchecked, Sneaky, CheckedFunction, and
CheckedSupplier only fed each other. FasterXML#350 already scheduled the
CrossLoaderAccess removal for 3.3 (deprecated forRemoval since 3.2); this
deletes the whole chain, drops the accessGrant plumbing, and removes the
CrossLoaderAccess fast-path pin test with it. Suites green: blackbird
361/361, blackbird-tests 8/8.
Field-backed properties were left on the stock path (Kind.STOCK / WKind.STOCK);
old Blackbird could not write fields at all through LambdaMetafactory. The
generator now stores a public non-final field through putfield and reads a
public field through getfield, in the hidden class. A non-public field is
reached through a user-lookup-derived setter handle on the deserializer side
(the same fallback non-public setters use); non-public fields on the
serializer side keep the stock writer, matching the lack of a non-public read
path there. Final fields keep stock behavior on the deserializer side.
Config gates and the VALUE_NULL / null-to-primitive routing are identical to
the setter path.

New BBCodecFieldTest covers public-field values/nulls/unknowns/reversed order,
null-to-primitive throwing, final-field and mixed field+setter parity with
stock, and private-field access with and without a lookup. blackbird-tests
FieldAccessNotOptimizedTest becomes FieldAccessTest (fields now accelerate);
SerializerInjectionTest's field case updated to the same contract.
…nst exotic beans

tofix/TestBBClassloaders failed on the module path because it read the bean
class bytes through classloader getResource, which JPMS encapsulation nulls;
Class#getResourceAsStream resolves inside the module and works in both modes.
With that fixed the test exposed a real gap: the modifier gates call
getEnclosingClass, which raises IncompatibleClassChangeError for a
child-classloader bean whose InnerClasses metadata disagrees with the
parent-loaded enclosing class. Both modifiers now demote to the stock
(de)serializer on any gate RuntimeException or LinkageError, and both
factories gate generation on the bean class resolving to the identical class
through this module's loader, since generated code refers to it by name.

The test moves to ser/ChildClassloaderTest as a positive cross-loader
regression test (the failure-expected annotation is gone; the child-loader
bean takes the stock path and the pinned contract is correct output). Suites
pass on the module path and with -Dsurefire.useModulePath=false.
Demoting to the stock path on RuntimeException or LinkageError keeps an
acceleration module from breaking beans stock databind handles, but a silent
demotion can also hide a real bug. CodegenFallbacks splits the policy by
phase: gate failures (reflective checks throwing on exotic classes) log a
warning once per type, and generation failures (the type passed every gate
and the generator still failed) log severe once per type - or rethrow when
the tools.jackson.module.blackbird.failOnCodegenError system property is set,
which both test bases enable so the whole suite runs fail-fast. The report
set is keyed by class name, never Class, so it pins no foreign classloader.
Suites green in both module-path and classpath modes.
Claude (on behalf of Steven Schlansker) added 7 commits September 7, 2026 16:14
…or checks, unwrapping, and entry shapes

Behavioral parity fixes, each verified against a vanilla mapper:

- Tier-A scalar arms take the inline read only when the value token is the
  expected one (VALUE_STRING, VALUE_NUMBER_INT, VALUE_TRUE/VALUE_FALSE);
  every other shape routes through the stock property, which owns coercion
  and null handling. Quoted scalars ({"i":"123"}) coerce again instead of
  throwing InputCoercionException.
- The unknown-name arm calls DeserializationContext.handleUnknownProperty
  through a base-class helper, so per-call FAIL_ON_UNKNOWN_PROPERTIES and
  DeserializationProblemHandlers work. Beans with ignored or included
  property sets stay on the stock deserializer (new modifier gate), keeping
  ignoral semantics exact.
- Record codecs track seen components in a bitmask and report missing ones
  like PropertyValueBuffer: @JsonProperty(required=true) fails, and per-call
  FAIL_ON_MISSING_CREATOR_PROPERTIES is honored. Records with more than 64
  components or injectable components stay stock.
- Generated codecs and placeholders forward unwrappingDeserializer/
  unwrappingSerializer (and acceptJsonFormatVisitor) to the stock delegate;
  @JsonUnwrapped with a codec-eligible child now produces stock output and
  reads back correctly, plain and prefixed.
- deserialize entered on any token but START_OBJECT delegates to the stock
  deserializer, which accepts PROPERTY_NAME and other entry shapes.
- BBDeserializerModifier recreates its transient ThreadLocal after JDK
  deserialization.

New BBCodecCompatibilityTest covers every case against vanilla; suites green
in module-path and classpath modes.
blackbird compiles at release 25 (ClassFile API) while the reactor floor
stays JDK 17, so the two blackbird modules move behind a jdk-activated
profile and the workflow matrix gains a 25 entry. The release/deploy leg
moves from 17 to 25 so the deployed reactor includes blackbird; every other
module still builds and tests on 17, 21, and 24.
…with the reference path

Non-public classes were the old engine's bread and butter; the rewrite now
covers them again. Non-public beans (package-private, protected static
nested; private stays stock) define their hidden codec in the bean's package
context via privateLookupIn with the user-supplied lookup - on the classpath
the module's own lookup suffices, and under JPMS a module that does not open
the bean's package demotes to stock (an environment gate, not an error).
Member gates relax accordingly: a non-private member of a same-package class
is directly accessible to a bean-context codec, decided by one shared
predicate (CodecAccess) on both the deserializer and serializer sides. A
constructor-accessibility gate replaces the public-only check, closing a
latent IllegalAccessError for public beans with package-private constructors.

Widening the accelerated set exposed three more parity gaps, now gated or
fixed:
- Property exceptions carry the reference path: every generated property arm
  runs under an exception handler that rethrows through the stock
  wrapAndThrow, so error paths and messages match stock databind.
- Beans with injected values stay stock (injection happens outside the
  property loop).
- Serializer-side custom includes (JsonInclude CUSTOM and friends) stay on
  the stock writer, detected through a BeanPropertyWriter suppression probe.

PackagePrivateBeanTest covers engagement and byte parity for package-private
beans, members, records (required check included), protected nested beans,
and the private-stays-stock contract. OptionalDeser355Test's package-private
beans engage codecs again, restoring the original test's intent with
new-engine equivalents of its per-property assertions. Suites green in
module-path and classpath modes.
A hidden codec defined in a user module's context must resolve its supertype
from that module, so GeneratedCodecBase and GeneratedWriterBase move to
tools.jackson.module.blackbird.internal, exported as documented-internal API
(package-info states the contract). The generators, CodecAccess, and
CodegenFallbacks stay unexported: only constant-pool-referenced supertypes
need to be resolvable from outside, and the bases' own bodies resolve in
blackbird's context. With the export, bean-context defines succeed for any
user module that reads blackbird - which supplying a lookup implies - so
package-private acceleration works under JPMS; a define still demotes to
stock only when the bean's module does not read blackbird at all. The OSGi
manifest already exports tools.jackson.module.* by wildcard.
The parent chain configures compiler source/target only, so a build on a
newer JDK would link 17-floor modules against newer JDK APIs with 17 syntax.
maven.compiler.release=17 in the root pom pins linkage to the JDK 17 API
(verified: mrbean built on JDK 26 emits class-file 61, and a JDK 21 API
reference fails to compile); blackbird keeps its release=25 override and sets
javac.src.version/javac.target.version to 25 so the jar manifest's
X-Compile-Source/Target-JDK entries match the class-file-69 bytecode. The
inherited source/target properties stay: the compiler ignores them once
release is set, and oss-parent feeds them into the manifest entries.
jackson-base upstream is the better long-term home for the release property.
…lackbird

blackbird.jpms.test is the arrangement a modular application has: a named
module that requires blackbird, holds a package-private bean, opens the bean
package to databind (as stock reflection needs), and supplies its own lookup.
With the lookup, the bean accelerates: the codec is defined in the test
module's package context and resolves its supertype through blackbird's
exported internal package - notably without the bean package being opened to
blackbird, and the accelerated path did not even need the databind opens to
construct. With the default lookup the bean demotes to the working stock
path. Engagement is asserted through the blackbird.debug.codegen diagnostic
stream, since generated and stock output are byte-identical by design.
The module asserts module-path semantics; the reactor-wide
-Dsurefire.useModulePath=false toggle must not apply to it (on the classpath
the unnamed module is fully open and the default-lookup control correctly
accelerates instead of demoting).
Claude (on behalf of Steven Schlansker) added 6 commits September 8, 2026 02:09
With -Dblackbird.debug.dumpDir=<dir>, both generators write each generated
class's bytes to <dir>/<bean-class-name>-codec.class or -writer.class
immediately before the hidden class is defined, so the emitted code can be
inspected with javap or a decompiler. A dump failure logs at FINE and never
affects generation. Documented next to the existing debug property in the
README.
An active view makes both generated codecs delegate the whole call to the
stock fallback, whose own view machinery (filtered writers on the ser side,
visibleInView checks on the deser side) then applies - the stock serialize
and deserialize entry points route to their view-filtered paths internally,
so whole-call delegation is stock-equivalent by construction. The existing
view tests use package-private beans, so this pins the contract through
codecs that provably engage: split-view properties compared against vanilla
in both directions and per view, a record, a field-backed bean, a nested
codec-generated child, skipped-value stream consumption, and an engagement
probe.
An active view previously delegated the whole call to the stock
(de)serializer, losing the generated fast path exactly where old Blackbird
kept it. Generated codecs now resolve a per-view visibility bitmask (cached
per view on the codec instance, computed from the stock properties so
visibleInView / getViews and DEFAULT_VIEW_INCLUSION semantics stay exact) and
each property arm tests its bit: a visible property runs the fast arm, a
hidden one is consumed with stock skip semantics (deser) or omitted (ser).
Beans with more than 64 properties keep the whole-call delegation, and a bean
no view can affect emits no view code at all. BBCodecViewTest extends to the
mask path, DEFAULT_VIEW_INCLUSION-off, and the >64-property delegation
fallback.
MethodParameters and LocalVariableTable entries on every generated method:
parameters (p/ctxt, value/g/ctxt, fallback, activeView, the helper
g/name/value triple) and the working locals (bean, builder, matcher, ix,
prop, e, seen, viewMask, child, and record components under their component
names). Both attributes are debug metadata the JIT ignores; slot entries are
emitted only when the slot is stored, since LocalVariableTable indexes must
stay within max_locals. Dumped codecs (blackbird.debug.dumpDir) now decompile
with source-like names.
The first mask rule mirrored stock's blanket _needViewProcesing flag, which
is true for every bean when DEFAULT_VIEW_INCLUSION is off - the 3.x default -
so every codec carried mask code on the no-view hot path. Worse, the resolved
properties cannot even distinguish a declared view from the empty view set
that disabled inclusion forces onto unannotated properties, so the modifier
now reads the declaration from the property definitions (member and
class-level @JSONVIEW through the annotation introspector) and hands the
placeholder a declaresViews flag. Beans that declare nothing emit no per-arm
view code: with inclusion on, views cannot affect them (NONE); with it off,
an active view hides everything, so the degenerate view-active call delegates
to stock (DELEGATE) and the no-view hot path stays free of mask tests. The
serializer side reads the same signal from BeanPropertyWriter.getViews, where
the forced set is empty rather than null. Declared views keep the MASK fast
path. Vanilla-compared view tests pass unchanged in both modes.
Claude (on behalf of Steven Schlansker) added 3 commits September 8, 2026 03:27
The JDK classDataAt bootstrap rejects any condy name but "_", which forced
positional indexes into the generators and unreadable classDataAt<"_",N>
references into dumps. Class data is now a name-addressed map resolved by a
classDataEntry bootstrap on the generated-code base classes (owner always
resolvable: the internal package is exported for the supertypes), and every
condy carries a meaningful name - propertyProp/propertyCodec/propertySetter/
propertyName per property (sanitized to JVM unqualified-name rules and
deduped), plus matcher, constructor, instantiator, and buildMethod. A condy
still links once and constant-folds, so steady-state code is unchanged; the
positional index bookkeeping in both generators is gone.
Codec stays the umbrella term for a reader/writer pair; single-direction
classes now say which they are: GeneratedReaderBase, BeanReaderGenerator,
BBReaderFactory, BBReaderPlaceholder, and on the write side BBWriterFactory
and BBWriterPlaceholder drop the Ser prefix. Generated reader classes are
named BBReader_<Bean> and dump as <bean>-reader.class. Direction-neutral
names (CodecAccess, CodegenFallbacks, CodegenDump, the debug properties, the
BBCodec*Test umbrella suites) stay.
The entry guard now branches forward to a delegation tail emitted after the
main body, so the cold fallback path sits out of the hot fall-through layout
and the guard emission loses its label gymnastics. Behavior is identical;
decompilers still render the guard as a nested conditional, which the
generated-code appendix notes.
The AsProperty polymorphic path hands a subtype deserializer a stream
positioned on the property after the type id (id first) or a buffered-replay
sequence starting on one (id last), so every polymorphic subtype read entered
the stock delegation tail and lost the generated fast path. The entry guard
now admits PROPERTY_NAME, and the first match mirrors stock
BeanDeserializer's currentNameMatch loop head: a name entry matches the
current name and dispatches into the same arms, which advance to their value
tokens themselves. Empty-remainder entries (END_OBJECT after an id-only
object) and all other shapes keep the stock delegation. New BBReaderEntryTest
pins the databind entry contract with a vanilla spy and compares polymorphic
reads (both id orders, record subtype with required components, unknown first
name, hand-positioned readValue) against vanilla.
Claude (on behalf of Steven Schlansker) added 4 commits September 8, 2026 14:42
…te strict-feature gates

The generated record path bypassed PropertyValueBuffer, so per-call
FAIL_ON_NULL_CREATOR_PROPERTIES was silently ignored once a codec was cached
(the build-time gate only helped when the first use carried the feature). The
codec now collects a null mask over reference-typed components at
construction - missing and explicit null alike, primitives never - and a cold
helper mirrors PropertyValueBuffer's reporting. With required, FAIL_ON_MISSING,
and FAIL_ON_NULL all enforced per call, the build-time creator-feature gate is
gone, as is the FAIL_ON_UNKNOWN_PROPERTIES gate that the unknown arm's
ctxt.handleUnknownProperty already honors, so strict-mode mappers accelerate.

New parity tests, all vanilla-compared with engagement asserted: strict
features per-call and build-time (BBCodecStrictFeaturesTest), polymorphic
writes through engaged writers for PROPERTY and WRAPPER_OBJECT inclusion
(BBWriterPolymorphicTest), and readerForUpdating (BBReaderUpdatingTest).
Beans carrying @JsonIgnoreProperties, @JsonIncludeProperties, or @JsonIgnore
demoted to stock because the codec's unknown arm implemented only the plain
contract. The modifier now carries the ignoral configuration into the codec
(a constructor argument on the generated reader), and _handleUnknown consults
it in the stock loop's exact order: ignore-all skips silently (even under
per-call FAIL_ON_UNKNOWN_PROPERTIES), explicitly ignored and not-included
names go through ignored-property handling (honoring
FAIL_ON_IGNORED_PROPERTIES with the stock exception), and everything else
runs the problem handlers as before. Per-use ignorals attached where a bean
is referenced still fall back to the stock contextual instance through the
placeholder. BBCodecIgnoralsTest covers each set kind, both strict features,
records, and unknown-outside-the-set reporting, vanilla-compared with
engagement asserted.
Case-insensitive matching demoted whole mappers and any alias demoted its
bean; both now ride the matcher, the way stock BeanPropertyMap builds it.
The modifier computes the effective per-class case-insensitivity exactly as
the stock builder does (the per-class @jsonformat override wins, the mapper
feature is baseline) - a probe showed the class-level override never reaches
createContextual, so a config-only check would silently read case-sensitively.
The factory then constructs the core CI matcher with the configured locale,
and appends alias names after the primaries, each switch case sharing its
primary's arm, so aliases hit the same generated code path (and the same
seen-bitmask bit for record required tracking). BBCodecMatcherTest covers CI
mappers, aliases through every name, alias-satisfies-required, aliases under
CI, and the class-level format override, vanilla-compared with engagement
asserted.
Vanilla-compared behavior tests assert these beans stay on the stock path
(raw stock from the modifier, or a placeholder whose codec never generated
for the factory-gated object-id case), so a future change that silently
starts generating for them fails a test instead of drifting.
Claude (on behalf of Steven Schlansker) added 2 commits September 10, 2026 09:50
canCreateUsingDefault and canCreateFromObjectWith are true for a no-arg or
component-matching @JsonCreator factory too, and databind then constructs
through the factory. The generated POJO and record codecs constructed with a
direct new / canonical-constructor invokeExact, silently bypassing it. Both
paths now gate on the instantiator's selected creator being the constructor
itself (for records, an AnnotatedConstructor that passes the per-component
index and type match is the canonical constructor); factory-creator beans
stay on the stock path. BBCodecCreatorTest pins both cases vanilla-compared.
Constructing through the instantiator held in classData is the follow-up
that would re-accelerate factory beans.
Generated codecs dispatch on the match index and then read the value, so
each property paid two out-of-line parser calls. The fused call commits the
value token the byte parser already classified during name matching; a
non-negative match leaves the current token on the value, and the scalar,
child, and field arms consume it directly. Negative results (unknown name,
END_OBJECT, odd token) behave exactly as the two-call sequence. Measured
through these codecs, fused vs two-call, identical module otherwise: record
graph +7.6% (aarch64) / +12.7% (x86_64) median, setter POJO +5.4% / +6.1%.

Requires jackson-core with nextNameMatchAndToken (the companion core PR);
stacked on the engine rewrite.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant