Background
Franz Nigro (Red Hat, Quarkus/Jackson performance) discovered a register spilling problem on x86_64 with per-class generated serializers in Quarkus Jackson. The finding applies to any code generation approach that produces per-class methods with many inlined field operations.
The Problem
When C2 inlines N field accessor calls (e.g., writeString, getField().asString()) into a single generated method, x86_64's 16 GP registers are insufficient for all the live values. C2 inserts register spills (push/pop to stack frame) before and after each inlined call. With N fields, the spilling overhead accumulates and dominates execution time.
ARM (Apple M2/M3) is unaffected — ARM has 31 GP registers, enough to hold all live values without spilling. This makes the problem invisible on Mac benchmarks.
Current jjq-mapper-processor Architecture
The processor generates one class per @JqMapped record. For a 5-field record:
public User fromJqValue(JqValue input, JqMapper mapper) {
return new User(
P_NAME.apply(input).asString(null), // inlined: get + asString
(int) P_AGE.apply(input).asLong(0), // inlined: get + asLong
P_SCORE.apply(input).asDouble(0.0), // inlined: get + asDouble
P_ACTIVE.apply(input).asBoolean(false), // inlined: get + asBoolean
P_EMAIL.apply(input).asString(null) // inlined: get + asString
);
}
Each P_*.apply(input).asXxx() chain may be inlined by C2, creating N copies of the extraction logic in the caller frame. For 5 fields this is fine (22 ns on x86_64). For 20+ fields, register pressure could cause spilling.
Franz's Solution: Unified Accessor
Instead of per-class generated methods, generate a single class with type-dispatched accessors:
public final class UnifiedJqMapping {
// Single method for string extraction — ONE inline site
public static String extractString(JqValue input, String fieldName) {
JqValue v = input.getField(fieldName);
return v.asString(null);
}
public static int extractInt(JqValue input, String fieldName) {
return (int) input.getField(fieldName).asLong(0);
}
public static double extractDouble(JqValue input, String fieldName) {
return input.getField(fieldName).asDouble(0.0);
}
}
C2 sees ONE extractString → inlines it once. The switch on field name is handled by the JIT's branch predictor. No N-way register pressure.
Trade-offs:
- Pro: Predictable register usage regardless of field count
- Pro: Smaller code size (1 class instead of N)
- Pro: Faster JIT warmup (fewer classes to compile)
- Con: Extra switch/dispatch overhead (~1-2 ns per field)
- Con: Bytecode constant pool limit risk with many types in one class
Action Items
- Benchmark with large records (10, 20, 50 fields) on x86_64 to determine if spilling occurs with the current per-class approach
- Compare x86_64 vs ARM numbers to detect the spilling signal (if ARM is significantly faster, spilling is likely)
- If spilling is detected: prototype the unified accessor approach and benchmark
- If no spilling: document the field count at which it becomes a problem (or confirm it's a non-issue for jjq due to simpler extraction logic vs Jackson's writeString)
Why jjq May Be Less Affected
jjq's field extraction (JqObject.get() + asString()) is simpler than Jackson's serialization path (writeString involves encoding, buffer management, flush checks). The simpler inlined body may use fewer registers per field, pushing the spilling threshold higher. But this needs measurement, not assumption.
References
- Franz Nigro's Quarkus Jackson
UnifiedAccessor pattern
- Jackson Afterburner module (does similar per-class bytecode generation)
- x86_64: 16 GP registers (RAX, RBX, RCX, RDX, RSI, RDI, RBP, RSP, R8-R15)
- ARM64: 31 GP registers (X0-X30)
- C2 register allocation: linear scan with fixed register budget per method
Background
Franz Nigro (Red Hat, Quarkus/Jackson performance) discovered a register spilling problem on x86_64 with per-class generated serializers in Quarkus Jackson. The finding applies to any code generation approach that produces per-class methods with many inlined field operations.
The Problem
When C2 inlines N field accessor calls (e.g.,
writeString,getField().asString()) into a single generated method, x86_64's 16 GP registers are insufficient for all the live values. C2 inserts register spills (push/pop to stack frame) before and after each inlined call. With N fields, the spilling overhead accumulates and dominates execution time.ARM (Apple M2/M3) is unaffected — ARM has 31 GP registers, enough to hold all live values without spilling. This makes the problem invisible on Mac benchmarks.
Current jjq-mapper-processor Architecture
The processor generates one class per
@JqMappedrecord. For a 5-field record:Each
P_*.apply(input).asXxx()chain may be inlined by C2, creating N copies of the extraction logic in the caller frame. For 5 fields this is fine (22 ns on x86_64). For 20+ fields, register pressure could cause spilling.Franz's Solution: Unified Accessor
Instead of per-class generated methods, generate a single class with type-dispatched accessors:
C2 sees ONE
extractString→ inlines it once. The switch on field name is handled by the JIT's branch predictor. No N-way register pressure.Trade-offs:
Action Items
Why jjq May Be Less Affected
jjq's field extraction (
JqObject.get()+asString()) is simpler than Jackson's serialization path (writeStringinvolves encoding, buffer management, flush checks). The simpler inlined body may use fewer registers per field, pushing the spilling threshold higher. But this needs measurement, not assumption.References
UnifiedAccessorpattern