Skip to content

perf: surpass Perl on hot closure calls and bit-packed Life loops #1196

Open

Description

@fglock

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 none

Observed 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

  1. Add compiler analysis/annotations for statically safe zero-argument closure calls and simple scalar returns.
  2. Emit a guarded direct closure invocation path that bypasses generic RuntimeCode.apply setup only when those annotations and runtime identity checks permit it; retain the existing path as fallback.
  3. Avoid RuntimeArray/RuntimeList materialization and repeated warning-scope lookup for that path.
  4. Add focused project-owned regression/performance coverage for a captured-lexical zero-arg coderef call, including JVM/interpreter parity and system-Perl behavior validation.
  5. Continue scalar/integer specialization in Life only where it does not alter unsigned IV/BigInt semantics.

Related work

Activity

  1. fglock commented on Sep 8, 2026

    @fglock
    OwnerAuthor

    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:

    • The representative portfolio's geometric-mean point estimate is at least 1.05x Perl, with its 95% confidence interval entirely above 1.00x.
    • The closure and bit-packed Life anchors individually satisfy the same rule.
    • No scored workload is below 0.90x Perl.
    • Perl behavior, JVM/interpreter parity, memory safety, and lifecycle semantics remain intact.

    Phase 1: establish trustworthy benchmark authority

    Create dev/design/performance-over-perl.md and a versioned runner under dev/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:

    1. Run at least ten one-second warmup windows.
    2. Continue warming until the last five windows have less than 2% throughput slope and 3% coefficient of variation, capped at 60 seconds. Mark failure to stabilize as inconclusive.
    3. Record fifteen one-second measurement windows.
    4. Run seven fresh-process Perl/PerlOnJava pairs, alternating launch order.
    5. Record source and JAR hashes, Temurin JDK 24 details, 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:

    1. Closure invocation.
    2. Method dispatch and blessed-hash access.
    3. Lexical/global numeric loops.
    4. String operations.
    5. Regex matching.
    6. Bit-packed Life, combining parallel and flat layouts.
    7. Deterministic JSON::PP encode/decode.

    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:

    • JFR CPU, allocation, GC, lock, thread, and code-cache events.
    • async-profiler CPU and allocation flame graphs to reduce safepoint bias.
    • HotSpot compilation, inlining, uncommon-trap, and deoptimization logs.
    • Generated bytecode size/disassembly and native assembly where available.
    • Exclusive and inclusive costs reported separately.

    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:

    • Cache the bound PerlRuntime and execution state once per call boundary.
    • Replace separate argument, context, active-code, warning, and closure-frame stacks with a compact reusable CallFrameStack.
    • Represent @_ and @DB::args with lazy/copy-on-write call-frame state so eligible zero-argument calls allocate nothing while mutation and caller inspection remain exact.
    • Store warning/hint state in the frame and lazily materialize observable special-variable state.
    • Reuse closure-lifetime frame storage instead of allocating a JvmClosureFrame and collections per call.
    • Add conservative compiler capability metadata for argument access/mutation, context dependence, lvalue return, non-local control flow, eval/debugger hooks, closure escape, and dynamic frame observation.
    • Add an internal scalar fast-entry interface for eligible calls. The generated site must load the current coderef, check its capability/version, invoke the fast entry, and use the existing RuntimeCode.apply path on guard failure.
    • Keep a lightweight caller frame on the fast path so nested caller, warnings, debugging, exception reporting, and stack-sensitive behavior remain observable.

    Use a simple typed interface first. Add a guarded invokedynamic inline 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:

    • Add a JVM-only analysis pass with a LONG / DOUBLE / DYNAMIC type lattice plus alias, escape, tie, overload, and mutation facts.
    • Keep provably non-escaping lexical counters and accumulators in primitive JVM locals; materialize RuntimeScalar only at observable boundaries.
    • Emit guarded primitive arithmetic and bitwise operations, retaining existing generic operators as the fallback.
    • Re-evaluate PR WIP perf: Life word-kernel candidate for #1196 #1195 only after the benchmark authority exists. Integrate it only if its Life improvement is at least 3%, the 95% confidence interval excludes zero, and unsigned-IV/BigInt compatibility remains green.
    • Do not introduce an alternative Number representation 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 prove first. The matrix must cover:

    • @_ aliasing/mutation and @DB::args.
    • Nested caller, wantarray, warning scopes, and local $^W.
    • Eval, debugger entry, overload, ties, lvalue returns, exceptions, recursion, goto &sub, and non-local loop control.
    • Escaping closures and capture lifetime.
    • Signed/unsigned boundaries, dualvars, numeric strings, BigInt chaining, array aliasing, and representation transitions.

    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=1 and JPERL_DISABLE_NUMERIC_SPECIALIZATION=1 rollback switches until the results remain stable across two release cycles.

    Current handoff state

    • Current phase: measurement infrastructure not started.
    • First task: create dev/design/performance-over-perl.md and the versioned benchmark runner.
    • PR WIP perf: Life word-kernel candidate for #1196 #1195: remains an experiment; it is not the established solution.
    • Rule: no production optimization begins until the controlled baseline and attribution report are complete.

    Update the design document's progress tracking after every phase and post milestone results back to this issue.

  2. self-assigned this
    on Sep 8, 2026
  3. changed the title [-]enhancement: specialize hot closure calls and bit-packed numeric loops[/-] [+]perf: surpass Perl on hot closure calls and bit-packed Life loops[/+] on Sep 8, 2026
  4. 284 remaining items

  5. fglock commented on Oct 15, 2026

    @fglock
    OwnerAuthor

    New reproduction (2026-10-15)

    A same-input short run on the current master worktree shows a larger gap than the historical Life numbers above:

    timeout 60 perl examples/life_bitpacked.pl -w 200 -h 200 -g 100 -r none -p glider
    timeout 60 ./jperl examples/life_bitpacked.pl -w 200 -h 200 -g 100 -r none -p glider

    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.

  6. fglock commented on Oct 26, 2026

    @fglock
    OwnerAuthor

    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.

  7. fglock commented on Oct 26, 2026

    @fglock
    OwnerAuthor

    PR #1886 merged the performance investigation workflow, including the current profiling guidance in the profile-perlonjava skill.

    The portfolio's compute-only Life measurement does not cover terminal output. The reported ~0.5x result for default examples/life_bitpacked.pl output 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).

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

Metadata

Metadata

Assignees

Labels

area:performancePerformance optimization and hangsenhancementNew feature or request

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions