Repository navigation
perf: surpass Perl on hot closure calls and bit-packed Life loops #1196
Description
Summary
Improve JVM compiler/runtime specialization for hot closure and bit-packed numeric loops. Both workloads are dominated by generic dynamic-call and scalar machinery rather than their useful work.
Reproductions
perl dev/bench/benchmark_closure.pl
./jperl dev/bench/benchmark_closure.pl
perl examples/life_bitpacked.pl -r none
./jperl examples/life_bitpacked.pl -r noneObserved closure result (Apple Silicon development machine): system Perl completes 5,000 timethis iterations in 9.20 CPU seconds (543.48/s); PerlOnJava requires 44.35 CPU seconds (112.73/s), roughly 4.8x slower. A separate JFR run reported 46.42 CPU seconds (107.71/s).
life_bitpacked.pl (100x100, 5,000 generations, display disabled) was approximately 2.2–2.4x slower than system Perl in contemporaneous runs: system Perl 3.06–4.03 CPU seconds, PerlOnJava 10.98–14.01 CPU seconds. Wall-clock values vary with host load, so CPU time and sampled hotspots are the primary evidence.
JFR evidence
The closure benchmark's dominant samples are generic subroutine-call scaffolding:
| Samples | Method |
|---|---|
| 2,354 | RuntimeCode.apply(String, RuntimeArray, int) |
| 2,213 | RuntimeCode.apply(RuntimeScalar, String, RuntimeBase[], int) |
| 1,886 | generated closure body |
| 808 | PerlRuntime.current/currentOrNull |
| 670 | ThreadLocal.get / ThreadLocalMap.getEntry |
| 370 | RuntimeCode.restoreCallerWarningScope |
| 213 | RuntimeCode.enterCalleeWarningScope |
Each $f->() therefore pays for argument-frame installation, warning-scope lookup/push/pop, active-code/call-depth bookkeeping, closure-frame setup, and RuntimeList scalar-result coercion. The benchmark's arithmetic is not the limiting cost.
For Life, the original profile sampled BigInteger.and, BigInteger.valueOf, BitwiseOperators.unsignedValue, and BitwiseOperators.unsignedResult(BigInteger). WIP PR #1195 adds native-word handling for unsigned values that already fit in 64 bits; the post-change profile no longer samples those BigInteger operations. It still samples RuntimeCode.apply, RuntimeList/RuntimeScalar allocation, and closure-frame/argument machinery, so that local optimization does not close the end-to-end gap.
Scope and constraints
The call path implements observable Perl behavior. A fast path must preserve or safely decline when a callee/caller depends on:
@_,caller, or call-context inspection;- dynamic warning scopes, lexical hints, or
local $^W; eval, debugger hooks, overload, ties, or non-local control flow;- closure-frame lifetime/protection and returned lvalues.
An attempted representation-only optimization that retained unsigned 64-bit values in a custom Number instead of BigInteger failed integer_pragma_word_arithmetic.t, zzzz_iv64_unsigned_bitwise.t, and math_bigint_method_chain.t; unsigned representation semantics must remain intact.
Proposed direction
- Add compiler analysis/annotations for statically safe zero-argument closure calls and simple scalar returns.
- Emit a guarded direct closure invocation path that bypasses generic
RuntimeCode.applysetup only when those annotations and runtime identity checks permit it; retain the existing path as fallback. - Avoid
RuntimeArray/RuntimeListmaterialization and repeated warning-scope lookup for that path. - Add focused project-owned regression/performance coverage for a captured-lexical zero-arg coderef call, including JVM/interpreter parity and system-Perl behavior validation.
- Continue scalar/integer specialization in Life only where it does not alter unsigned IV/BigInt semantics.
Related work
- WIP PR WIP perf: Life word-kernel candidate for #1196 #1195: native unsigned-word bitwise fast path and compatibility coverage.
dev/bench/benchmark_closure.plexamples/life_bitpacked.pl
Activity
fglock commentedon Sep 8, 2026
Performance investigation and optimization handoff
The current issue identifies plausible hotspots, but it moves from sampled stack counts to a closure-call fast path before proving the exclusive cost of each layer, allocation pressure, JIT behavior, or whether the same costs dominate a representative workload portfolio. This handoff replaces that hypothesis-led direction with an evidence-driven program.
Goal and acceptance contract
The performance target applies to the default JVM compiler backend. The interpreter must remain correct and regression-free, but is not required to beat Perl.
Compare against a pinned optimized build of the current maintained Perl release on the same reference host. Exclude process startup, parsing, bytecode generation, and JVM warmup from the scored interval.
The work is complete only when:
Phase 1: establish trustworthy benchmark authority
Create
dev/design/performance-over-perl.mdand a versioned runner underdev/bench/. Every benchmark must have deterministic input, a correctness checksum, an operation count, explicit warmup/measurement windows, and machine-readable JSON output.Protocol per engine/workload:
perl -V, JVM flags, host state, wall time, process CPU time, allocation rate, GC time, and all raw samples.Score seven equally weighted workload groups so a large collection of microbenchmarks cannot hide an application regression:
Keep a separate unscored diagnostic matrix for zero/one/many arguments, named/coderef/method calls, scalar/list/void context, captured/non-captured closures, integer/unsigned/double operations, and array access.
Phase 2: find the real bottlenecks
Collect complementary evidence rather than treating JFR stack counts as causal proof:
Create diagnostic-only ablations for the call layers: dispatch, argument frames, caller metadata, warnings/hints, cleanup/mortals, closure lifetime tracking, control-flow handling, and result coercion. These intentionally non-semantic builds quantify the maximum possible gain from each layer; none becomes the production shortcut.
For closure calls, compare no-op and arithmetic bodies across the diagnostic call matrix. For Life, separately measure scalar conversion, bitwise operators, array lookup/store, loop control, allocation, and GC. Produce a cost model in nanoseconds and allocated bytes per operation.
An optimization track proceeds only if it explains at least 10% of an anchor workload or 5% of the scored portfolio. Store the profiles, raw benchmark output, commands, and interpretation with the design document.
Phase 3: call-boundary redesign
If call scaffolding clears the evidence threshold, improve the general call boundary before adding a closure-only shortcut:
PerlRuntimeand execution state once per call boundary.CallFrameStack.@_and@DB::argswith lazy/copy-on-write call-frame state so eligible zero-argument calls allocate nothing while mutation and caller inspection remain exact.JvmClosureFrameand collections per call.RuntimeCode.applypath on guard failure.caller, warnings, debugging, exception reporting, and stack-sensitive behavior remain observable.Use a simple typed interface first. Add a guarded
invokedynamicinline cache only if dispatch still consumes more than 10% of the closure benchmark and HotSpot evidence shows failed inlining or harmful polymorphism.Phase 4: primitive numeric specialization
If scalar representation and conversion clear the evidence threshold:
LONG/DOUBLE/DYNAMICtype lattice plus alias, escape, tie, overload, and mutation facts.RuntimeScalaronly at observable boundaries.Numberrepresentation that changes unsigned IV or Math::BigInt semantics.Consider specialized array word storage only if the post-specialization profile still assigns at least 10% of Life time or allocation to boxed array access. It must have guarded representation transitions and fall back when aliasing, ties, sharing, overload, or scalar identity is observable.
Phase 5: generated-code and JIT quality
Track method size, compilation tier, inlining failures, code-cache use, and deoptimizations for every scored workload. Split cold semantic fallback logic out of hot methods when size prevents C2 compilation or inlining. Confirm in profiles and generated code that fast entries inline into loops and that wrapper allocations disappear; do not accept changes that merely relocate samples without improving end-to-end throughput or allocation rate.
Correctness and delivery gates
Before each runtime optimization, add focused project-owned semantic coverage and validate new Perl-level tests with system
provefirst. The matrix must cover:@_aliasing/mutation and@DB::args.caller,wantarray, warning scopes, andlocal $^W.goto &sub, and non-local loop control.Run focused tests on both backends after every change. Before merging runtime work, require
make, relevant bundled-module tests, the complete performance gate, allocation/GC comparison, and documentation link checks. Capture every bounded test and benchmark run to a file.Land independently reviewable phases: benchmark authority, attribution report, call-frame consolidation, scalar fast entry, primitive lexical specialization, optional array specialization, and final portfolio validation. Provide temporary
JPERL_DISABLE_FAST_CALLS=1andJPERL_DISABLE_NUMERIC_SPECIALIZATION=1rollback switches until the results remain stable across two release cycles.Current handoff state
dev/design/performance-over-perl.mdand the versioned benchmark runner.Update the design document's progress tracking after every phase and post milestone results back to this issue.
[-]enhancement: specialize hot closure calls and bit-packed numeric loops[/-][+]perf: surpass Perl on hot closure calls and bit-packed Life loops[/+]docs: record call-boundary candidate profiling
perf: unify RuntimeCode call-frame lifecycle
docs: record authoritative call-boundary result
docs: record rejected regex map experiment
docs: record direct leaf closure high-load portfolio
docs: record unstable direct leaf pair follow-up
docs: record method call-boundary profile selection
docs: bound method frame setup budget
docs: record method frame allocation selection
perf: reuse guarded immediate-unpack method frames
284 remaining items
docs: refresh #1196 string JFR selection
docs: refresh #1196 regex JFR selection
perf: fast-path current active lexical registration
perf: reject active lexical frame shortcut
docs: define Life bitwise-tree lowering boundary
perf: stage native integer bitwise expression trees
perf: reject regex cursor snapshot pool
docs: record rebased method call-boundary attribution
test: guard direct argument copies from tied observers
docs: select fused concat-substr expression boundary
perf: fuse eligible concat-substr snapshot expressions
perf: reject regressive concat-substr lowering
fglock commentedon Oct 15, 2026
New reproduction (2026-10-15)
A same-input short run on the current
masterworktree shows a larger gap than the historical Life numbers above:Both completed with final live cell count 10. The script's timed simulation interval was 0.188 s for Perl (531.62 generations/s) and 6.920 s for PerlOnJava (14.45 generations/s), about 36.8x slower on this host. This is one short run and should be treated as a signal for investigation, not a stable benchmark result; it uses the exact same deterministic input and excludes startup/compilation from the script timer.
fglock commentedon Oct 26, 2026
PR #1870 met the three #1863 anchor criteria (method, closure, Life), but it did not run #1196's seven-group portfolio. Its other scored groups—method dispatch, numeric loops, strings, regex, and JSON—still need measurements; the #1196 portfolio acceptance is not established. The #1870 merge closed this issue via a PR closing keyword, so I am reopening it. We should track the remaining per-workload 1:1 targets without treating the three anchor result as completion.
fglock commentedon Oct 26, 2026
PR #1886 merged the performance investigation workflow, including the current profiling guidance in the
profile-perlonjavaskill.The portfolio's compute-only Life measurement does not cover terminal output. The reported ~0.5x result for default
examples/life_bitpacked.ploutput is documented as a separate I/O dimension, to compare with the same sink and verified output bytes (with PTY and redirected-output results distinguished).