You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Post-optimization CPU flame graphs from JjqProductionQueryBenchmark (after issue #19 optimizations) reveal five remaining systemic hotspots. These are ordered by impact.
Profiling Context
All flame graphs captured with async-profiler CPU event via JMH -prof async, 3 forks, 5 warmup + 5 measurement iterations. Profiles stored in profiles/production-query-post-opt/.
Hotspot 1: Repeated ensureHashIndex() construction during iteration (CRITICAL)
Where:prod_extractMetric — 71.8% of CPU in JqObject.ensureHashIndex
What happens: The query [.autobench_workload.data[0].pcp_time_series[] | .["mem.util.used"]] iterates 502 PCP time series objects, each with 127 keys. Every .get("mem.util.used") call on a distinct JqObject instance triggers ensureHashIndex(), which builds a HashMap<String, Integer> with 127 entries. The hash index is used for one lookup, then the next object builds its own from scratch. That is 502 HashMap constructions x 127 put() calls each = ~63,754 HashMap insertions per query invocation.
Why it matters: The hash index (issue #19) was designed to amortize across multiple get() calls on the same object. But in iteration patterns, each object is accessed once and discarded. The one-time construction cost exceeds what the linear scan would have cost.
Proposed fix — eager hash index at parse time:
Build the hash index during JqObject.ofArrays() for objects with size > HASH_THRESHOLD. The construction cost shifts from query execution to parsing, which:
Happens once for pre-parsed documents (h5m pattern: parse once, query many times)
Is already measured separately in parse benchmarks
Amortizes the cost if the same object is queried multiple times with different fields
publicstaticJqObjectofArrays(String[] keys, JqValue[] values, intsize) {
if (size == 0) returnEMPTY;
varobj = newJqObject(keys, values, size, null);
if (size > HASH_THRESHOLD) {
obj.ensureHashIndex(); // pre-build at construction
}
returnobj;
}
Expected impact:prod_extractMetric should improve ~3-5x (72% of CPU eliminated, limited by remaining ~15% VM iteration overhead).
Risk: Parse benchmarks will regress slightly for large objects since hash index construction moves there. Need to measure the trade-off. For h5m's use case (parse once, query many times), this is the right trade-off.
Hotspot 2: Evaluator fallback for compilable expressions (HIGH)
Where:prod_deepField — 52% of CPU in evaluator chain; prod_keys — 53% of CPU in evaluator chain
What happens: Expressions like .autobench_workload.data[0].results fall back to the tree-walk Evaluator.eval because chained field access with array indexing (data[0]) isn't compiled to bytecode. The evaluator adds overhead:
Component
CPU share
Why
Evaluator.eval
15-17%
Tree-walk dispatch loop
ArrayList.grow
7-9%
Result list starts at default size 10, resizes repeatedly
Evaluator$$TypeSwitch
5-6%
JDK-generated sealed-class switch on JqExpr subtypes
ArrayList$Itr.checkForComodification
3%
Iterator overhead
Evaluator$$Lambda.accept
3-4%
Lambda captures for continuation passing
prod_keys anomaly: The keys builtin has a BUILTIN_KEYS bytecode opcode, but the flame graph shows Evaluator.eval at 17%. This suggests some keys invocations still fall to the tree-walker — possibly when keys is used in a pipe chain that the compiler doesn't fully compile.
Proposed fix (two parts):
Compile chained field + index access: Teach the compiler to handle .field1.field2[0].field3 as a fused sequence of DOT_FIELD + INDEX(const) + DOT_FIELD opcodes, avoiding evaluator fallback.
Investigate prod_keys evaluator fallback: Determine why .autobench_workload.data[0].pcp_time_series[0] | keys hits the evaluator. The keys part should use BUILTIN_KEYS — the evaluator is likely triggered by the preceding field chain.
Expected impact: 40-50% throughput improvement on prod_deepField and prod_keys if evaluator fallback is eliminated.
Hotspot 3: VirtualMachine.push bounds check in fused iteration (MEDIUM-HIGH)
Where: 5 of 7 benchmarks, 3-31% of CPU in VirtualMachine.push
What happens:push() at VirtualMachine.java:790 checks if (sp >= stack.length) on every call:
Inside collectIterateBody, the stack never grows (iteration bodies use 2-3 slots from a 64-slot pre-allocated stack). The branch is always-not-taken but still costs:
Branch prediction resources
Prevents method inlining (the copyOf cold path makes the method too large for JIT inlining heuristics)
Repeated this.stack and this.sp field loads from the heap
Proposed fix — pre-check + direct array access:
privatevoidcollectIterateBody(intbodyStart, intbodyLen) {
// Pre-check: ensure stack has capacity for max body stack depthif (sp + MAX_BODY_STACK_DEPTH >= stack.length) {
stack = java.util.Arrays.copyOf(stack, stack.length * 2);
}
finalJqValue[] localStack = stack;
intlocalSp = sp;
// ... inner loop uses localStack[localSp++] instead of push()sp = localSp;
}
Or simpler: extract sp and stack to local variables at the top of collectIterateBody and write back at the end. This helps the JIT hoist the bounds check.
Expected impact: 10-20% improvement on iteration-heavy benchmarks (prod_iterateExtract, prod_collectConfig).
escapeJson scans every character of every string looking for characters that need escaping (", \, control chars). For strings parsed from JSON, the original text was already properly escaped — this scan is entirely redundant.
itable stub is JVM overhead from calling appendTo(StringBuilder) through the JqValue sealed interface. Each call goes through interface method dispatch table lookup rather than a direct virtual call.
Proposed fix (two parts):
Deferred strings (Issue Deferred string value parsing (Jackson-inspired lazy materialization) #13) for zero-copy serialization: Strings holding their original JSON bytes can call sb.append(source, start, end) directly without escaping — the knowledge base already documents this as a planned optimization. This eliminates both escapeJson (20.7%) and much of the itable stub cost.
Type-specialize appendTo dispatch in containers: In JqObject.appendTo and JqArray.appendTo, replace:
values[i].appendTo(sb); // itable dispatch
with:
JqValuev = values[i];
if (vinstanceofJqStrings) s.appendTo(sb); // direct callelseif (vinstanceofJqNumbern) n.appendTo(sb); // direct callelsev.appendTo(sb); // fallback
This lets the JIT devirtualize the common cases (93% of production data is strings).
Expected impact: 2-3x improvement on prod_roundTrip_identity if deferred strings eliminate escapeJson. The itable stub fix alone would give ~20% improvement.
Hotspot 5: Builder.put duplicate-key scan on known-unique keys (MEDIUM)
Where:prod_objectConstruct and prod_collectConfig — part of 24% String.equals
What happens:JqObject.Builder.put() scans all existing keys linearly to check for duplicates:
publicBuilderput(Stringkey, JqValuevalue) {
for (inti = 0; i < size; i++) {
if (key.equals(keys[i])) { // duplicate scanvalues[i] = value;
returnthis;
}
}
// ... append new key
}
For {user, uuid, run_id, start_time, end_time} (5 fields), that is 0+1+2+3+4 = 10 string comparisons per object construction. The keys come from the compiled expression's objLayouts array — they are known at compile time to be unique (static layout). The scan is wasted work.
Proposed fix — putUnchecked for known-unique keys:
In VirtualMachine.javaBUILD_OBJECT, use putUnchecked when the layout has unique keys (check at compile time: if all layout[i] indices are distinct, keys are guaranteed unique):
Summary
Post-optimization CPU flame graphs from
JjqProductionQueryBenchmark(after issue #19 optimizations) reveal five remaining systemic hotspots. These are ordered by impact.Profiling Context
All flame graphs captured with async-profiler CPU event via JMH
-prof async, 3 forks, 5 warmup + 5 measurement iterations. Profiles stored inprofiles/production-query-post-opt/.Hotspot 1: Repeated
ensureHashIndex()construction during iteration (CRITICAL)Where:
prod_extractMetric— 71.8% of CPU inJqObject.ensureHashIndexWhat happens: The query
[.autobench_workload.data[0].pcp_time_series[] | .["mem.util.used"]]iterates 502 PCP time series objects, each with 127 keys. Every.get("mem.util.used")call on a distinctJqObjectinstance triggersensureHashIndex(), which builds aHashMap<String, Integer>with 127 entries. The hash index is used for one lookup, then the next object builds its own from scratch. That is 502 HashMap constructions x 127put()calls each = ~63,754 HashMap insertions per query invocation.Why it matters: The hash index (issue #19) was designed to amortize across multiple
get()calls on the same object. But in iteration patterns, each object is accessed once and discarded. The one-time construction cost exceeds what the linear scan would have cost.Proposed fix — eager hash index at parse time:
Build the hash index during
JqObject.ofArrays()for objects withsize > HASH_THRESHOLD. The construction cost shifts from query execution to parsing, which:Expected impact:
prod_extractMetricshould improve ~3-5x (72% of CPU eliminated, limited by remaining ~15% VM iteration overhead).Risk: Parse benchmarks will regress slightly for large objects since hash index construction moves there. Need to measure the trade-off. For h5m's use case (parse once, query many times), this is the right trade-off.
Hotspot 2: Evaluator fallback for compilable expressions (HIGH)
Where:
prod_deepField— 52% of CPU in evaluator chain;prod_keys— 53% of CPU in evaluator chainWhat happens: Expressions like
.autobench_workload.data[0].resultsfall back to the tree-walkEvaluator.evalbecause chained field access with array indexing (data[0]) isn't compiled to bytecode. The evaluator adds overhead:Evaluator.evalArrayList.growEvaluator$$TypeSwitchJqExprsubtypesArrayList$Itr.checkForComodificationEvaluator$$Lambda.acceptprod_keysanomaly: Thekeysbuiltin has aBUILTIN_KEYSbytecode opcode, but the flame graph showsEvaluator.evalat 17%. This suggests somekeysinvocations still fall to the tree-walker — possibly whenkeysis used in a pipe chain that the compiler doesn't fully compile.Proposed fix (two parts):
Compile chained field + index access: Teach the compiler to handle
.field1.field2[0].field3as a fused sequence ofDOT_FIELD+INDEX(const)+DOT_FIELDopcodes, avoiding evaluator fallback.Investigate
prod_keysevaluator fallback: Determine why.autobench_workload.data[0].pcp_time_series[0] | keyshits the evaluator. Thekeyspart should useBUILTIN_KEYS— the evaluator is likely triggered by the preceding field chain.Expected impact: 40-50% throughput improvement on
prod_deepFieldandprod_keysif evaluator fallback is eliminated.Hotspot 3:
VirtualMachine.pushbounds check in fused iteration (MEDIUM-HIGH)Where: 5 of 7 benchmarks, 3-31% of CPU in
VirtualMachine.pushWhat happens:
push()atVirtualMachine.java:790checksif (sp >= stack.length)on every call:Inside
collectIterateBody, the stack never grows (iteration bodies use 2-3 slots from a 64-slot pre-allocated stack). The branch is always-not-taken but still costs:copyOfcold path makes the method too large for JIT inlining heuristics)this.stackandthis.spfield loads from the heapProposed fix — pre-check + direct array access:
Or simpler: extract
spandstackto local variables at the top ofcollectIterateBodyand write back at the end. This helps the JIT hoist the bounds check.Expected impact: 10-20% improvement on iteration-heavy benchmarks (
prod_iterateExtract,prod_collectConfig).Hotspot 4: Serialization —
escapeJson+ itable stub (MEDIUM)Where:
prod_roundTrip_identity— 20.7%escapeJson+ 17.4%itable stub= 38% combinedWhat happens:
escapeJsonscans every character of every string looking for characters that need escaping (",\, control chars). For strings parsed from JSON, the original text was already properly escaped — this scan is entirely redundant.itable stubis JVM overhead from callingappendTo(StringBuilder)through theJqValuesealed interface. Each call goes through interface method dispatch table lookup rather than a direct virtual call.Proposed fix (two parts):
Deferred strings (Issue Deferred string value parsing (Jackson-inspired lazy materialization) #13) for zero-copy serialization: Strings holding their original JSON bytes can call
sb.append(source, start, end)directly without escaping — the knowledge base already documents this as a planned optimization. This eliminates bothescapeJson(20.7%) and much of theitable stubcost.Type-specialize
appendTodispatch in containers: InJqObject.appendToandJqArray.appendTo, replace:with:
This lets the JIT devirtualize the common cases (93% of production data is strings).
Expected impact: 2-3x improvement on
prod_roundTrip_identityif deferred strings eliminateescapeJson. The itable stub fix alone would give ~20% improvement.Hotspot 5:
Builder.putduplicate-key scan on known-unique keys (MEDIUM)Where:
prod_objectConstructandprod_collectConfig— part of 24%String.equalsWhat happens:
JqObject.Builder.put()scans all existing keys linearly to check for duplicates:For
{user, uuid, run_id, start_time, end_time}(5 fields), that is 0+1+2+3+4 = 10 string comparisons per object construction. The keys come from the compiled expression'sobjLayoutsarray — they are known at compile time to be unique (static layout). The scan is wasted work.Proposed fix —
putUncheckedfor known-unique keys:In
VirtualMachine.javaBUILD_OBJECT, useputUncheckedwhen the layout has unique keys (check at compile time: if alllayout[i]indices are distinct, keys are guaranteed unique):For expressions with runtime-computed keys (e.g.,
{(expr): value}), continue usingput()with the duplicate scan.Expected impact: 5-10% improvement on object construction benchmarks. Small change, low risk.
Summary Table
ensureHashIndexrebuild per objectpushbounds check in hot loopescapeJson+ itable stubputUncheckedfor unique keysReferences
profiles/production-query-post-opt/(CPU)benchmark-results/production-query-gc-final.json