Skip to content

perf: memoize __getattr__ on JVMView / JavaPackage / JavaClass (issue #557)#14

Closed
Tagar wants to merge 4 commits into
masterfrom
c1-python-attribute-caches
Closed

perf: memoize __getattr__ on JVMView / JavaPackage / JavaClass (issue #557)#14
Tagar wants to merge 4 commits into
masterfrom
c1-python-attribute-caches

Conversation

@Tagar

@Tagar Tagar commented May 22, 2026

Copy link
Copy Markdown
Member

Summary

Cold-start work, C1 of the issue py4j/py4j#557 series.

Today every attribute walk along gateway.jvm.X.Y.Z.method re-issues one reflection round-trip per level — JVMView, JavaPackage, and JavaClass all have __getattr__ methods that call out to the JVM without caching the result. After the first walk, the JVM-side answer is stable for the JVM's lifetime, so the next 999 walks pay the same round-trip cost for no new information.

This change adds a per-instance dict cache on each of:

Class Cache attribute What's memoized
JVMView _attr_cache top-level names resolved on .jvm.X
JavaPackage _attr_cache names resolved on .X.Y
JavaClass _members static members on .System.currentTimeMillis

Local CodSpeed delta (M-series + JDK 21)

Scenario Before C1 After C1 Delta
XA-3 (1000 attribute walks) 94 ms = 94 µs/walk 269 µs total = 0.27 µs/walk ~350× faster
X1-1 (10k currentTimeMillis calls) 232 ms 232 ms unchanged ✓
XC (10k calls with callbacks on) 236 ms 232 ms unchanged (within noise) ✓

The first walk in any chain still pays the full reflection cost; subsequent walks are free dict lookups. Steady-state attribute walking is now amortized.

Semantics & safety

  • Only successful resolutions are cached. Misses (Py4JError) keep raising so a later java_import or classpath change can still surface a previously-missing class.
  • UserHelpAutoCompletion sentinel path is NOT cached on JVMView — that helper returns a fresh instance per access by design.
  • Direct return values on JavaClass.__getattr__ (constants, static non-final fields) are NOT cached — there's no contract that the JVM-side value can't change across calls. Only JavaMember (methods) and JavaClass (nested classes) results are cached.
  • Thread safety mirrors the existing _statics / _dir_sequence_and_cache convention: plain dict assignments are atomic under CPython's GIL, worst case is double-fill with the same value. The existing comments in those caches explicitly accept this tradeoff.

Diff

  • py4j-python/src/py4j/java_gateway.py: +52 / -6

Test plan

  • 59 non-JVM unit tests pass locally (finalizer, protocol, signals)
  • 83/97 java_gateway_test + client_server_test pass locally; 14 failures are pre-existing GatewayLauncherTest jar-path issues unrelated to this change (confirmed on byteoak/master baseline)
  • XA-3 measurement shows ~350× speedup
  • X1-1 / X2 / XB / XC unchanged
  • Matrix CI green across Python 3.9–3.13 × Java 8/11/17/21 × ubuntu/macos/windows
  • CodSpeed shows XA-3 win after merge

Note on CodSpeed

The CodSpeed run on byteoak master is currently red because of XD's CI-runner startup race (see #13 for the fix). C1's own CodSpeed measurement (the XA-3 delta) will be visible after #13 lands and master CodSpeed goes green again. C1 and #13 are otherwise independent (different files, different concerns).

Tagar added 2 commits May 22, 2026 09:28
)

Each scenario maps 1:1 to a planned cold-start improvement so a per-
PR CodSpeed delta is unambiguous. Without these, the wins from the
upcoming PRs would be invisible to the dashboard.

* XA-3 — attribute_walk_jvm_java_lang_System
  1 000 walks of `gateway.jvm.java.lang.System`. Each walk re-issues
  3 reflection round-trips because JVMView / JavaPackage / JavaClass
  __getattr__ don't memoize today. Cache canary for the upcoming
  Python attribute-cache PR — should collapse to ~0 round-trips
  after first walk once memoization lands.

* XB — gateway_reconnect_against_running_jvm
  50 fresh JavaGateway() instances against the fixture JVM. Measures
  per-connection setup cost: Java accept() loop allocates 14 command
  class instances per connection (GatewayConnection.java:196-208),
  Python runs _create_connection + socket setup. Lever for the
  Java-side command-prototype-cache PR.

* XC — callback_infra_overhead_unused
  X1-1 workload (10 000 currentTimeMillis() calls) with
  CallbackServer started but no callbacks invoked. Delta vs X1-1
  is the steady-state overhead of running the callback server
  alongside the main gateway. Target of the lazy-CallbackClient PR.

* XD — full_cold_start_subprocess_first_call
  subprocess.Popen -> first call -> shutdown, one cycle per round.
  Slow per-iteration (~350 ms on M-series + JDK 21, multi-second on
  slower hosts); CodSpeed adapts iteration count. Target of the
  JVM-flag opt-ins (TieredStopAtLevel=1, AppCDS, CRaC).

Implementation notes:

XA / XB / XC follow the existing MacroScenario contract and run
through the macro_gateway fixture in codspeed_macros.py (shared JVM,
setup once, measure many).

XD owns its JVM lifecycle inside measure() — it must NOT share the
fresh_jvm()-managed fixture because both default to port 25333. So
XD is registered in a new _COLD_START_SCENARIOS list parametrized
through a new test_cold_start_scenario function. XD is also omitted
from ALL_MACRO_CLASSES so the framework runner doesn't try to share
its JVM either.

Local measurements on M-series + JDK 21 (Temurin 21.0.10):
  XA-3: ~94 ms / 1000 walks = 94 us per walk (3 RTTs each)
  XB:   ~21 ms / 50 reconnects = 425 us per reconnect
  XC:   ~236 ms / 10k calls = 23.6 us per call
  XD:   ~355 ms per full cold-start cycle

Co-authored-by: Isaac
The XD ``test_cold_start_scenario`` introduced in #12 used a blind
``time.sleep(0.25)`` followed by a direct ``JavaGateway()`` connect
attempt. On CodSpeed CI runners (and any host slower than a modern
laptop) the JVM hadn't yet bound its listen socket when the connect
fired, surfacing as ``ConnectionRefusedError: [Errno 111] Connection
refused`` and failing the whole codspeed workflow.

Switch XD's body to the existing ``fresh_jvm`` context manager with
``readiness_retries=5``. ``fresh_jvm`` polls connection readiness
with ``check_connection`` and only proceeds once the JVM is actually
accepting calls — same convention the rest of the test suite uses.
This also drops a bunch of try/finally boilerplate since
``fresh_jvm`` handles spawn/shutdown.

Locally on M-series + JDK 21: XD median ~355 ms (unchanged from
post-#12 baseline). The variance is higher in cold-start measurement
by nature; what matters is the elimination of hard failures on
slower hosts.

Co-authored-by: Isaac
@Tagar Tagar force-pushed the c1-python-attribute-caches branch from 1c24b7d to 5f5a9e5 Compare May 22, 2026 16:17
@codspeed-hq

codspeed-hq Bot commented May 22, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by ×45

⚡ 3 improved benchmarks
✅ 19 untouched benchmarks
⏩ 7 skipped benchmarks1

Performance Changes

Mode Benchmark BASE HEAD Efficiency
WallTime test_macro_scenario[XA-3] 392.5 ms 2 ms ×200
WallTime test_m3_jvmview_class_resolution 391.6 µs 2.6 µs ×150
WallTime test_m4_constructor_and_finalize 574.7 µs 192.8 µs ×3

Tip

Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.


Comparing c1-python-attribute-caches (a74edbe) with master (77eb450)

Open in CodSpeed

Footnotes

  1. 7 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@Tagar

Tagar commented May 22, 2026

Copy link
Copy Markdown
Member Author

@codspeedbot explain why this is faster

@codspeed-hq

codspeed-hq Bot commented May 22, 2026

Copy link
Copy Markdown

@codspeedbot explain why this is faster

The explanation doesn't require code changes — my response here will be posted as the PR comment. Here's the analysis:


Why this PR is faster

The bottleneck: repeated JVM reflection round-trips

In py4j, every Python attribute access along a chain like gateway.jvm.java.lang.System triggers a reflection round-trip to the JVM at each level. Three classes are involved, each with a __getattr__ that sends a command over the socket and waits for a response:

Level Class What happens on each access
gateway.jvm.java JVMView Sends REFL_GET_UNKNOWN → JVM resolves "java" → returns SUCCESS_PACKAGE
.java.lang JavaPackage Sends REFL_GET_UNKNOWN → JVM resolves "java.lang" → returns SUCCESS_PACKAGE
.lang.System JavaPackage Sends REFL_GET_UNKNOWN → JVM resolves "java.lang.System" → returns SUCCESS_CLASS

Every single access pays 3 socket round-trips, even though the JVM's answer is always the same — java.lang is always a package, java.lang.System is always a class. On master, doing this 1,000 times (the XA-3 scenario) costs 3,000 reflection round-trips.

The fix: per-instance dict caches

This PR adds a _attr_cache / _members dictionary on each of JVMView, JavaPackage, and JavaClass. On the first access, the reflection round-trip happens normally and the result is stored. On every subsequent access, __getattr__ returns the cached value directly — an O(1) dict lookup instead of a socket round-trip.

After the first walk of gateway.jvm.java.lang.System, the remaining 999 walks in XA-3 resolve entirely from Python-side dicts with zero JVM communication.

Benchmark-by-benchmark breakdown

test_macro_scenario[XA-3] — 392 ms → 2.1 ms (×190 faster)
Walks gateway.jvm.java.lang.System 1,000 times. On master this is 3,000 socket round-trips (~130 µs each). After caching, only the first walk (3 RTTs) hits the JVM; the remaining 999 are pure dict lookups. The speedup is essentially 3,000 RTTs → 3 RTTs.

test_m3_jvmview_class_resolution — 386 µs → 2.7 µs (×140 faster)
Resolves gateway.jvm.java.lang.String in a tight loop (50 rounds × 500 iterations). Same pattern: the first iteration pays 3 RTTs, all subsequent iterations are cached.

test_m4_constructor_and_finalize — 577 µs → 196 µs (×2.9 faster)
Calls gateway.jvm.java.lang.StringBuilder() — attribute resolution plus an actual JVM constructor invocation. Caching eliminates the 3 attribute-resolution RTTs, but the constructor call itself still requires a JVM round-trip every time (it creates a new object). That's why the speedup is more modest: the resolution savings are real, but the remaining ~196 µs is the irreducible constructor + finalizer registration cost.

Why other benchmarks are unchanged

Benchmarks like X1-1 (10k currentTimeMillis() calls) resolve the attribute chain once and then call the cached JavaMember in a loop — they were already paying the resolution cost only once. This PR doesn't change the per-call JVM invocation cost, only the attribute-resolution path.

Summary
  • Warming up the performance wizard...
  • Analyzing performance improvement — Analysis complete — posting explanation
  • Handling comment — Comment handling finished successfully

Tagar and others added 2 commits May 22, 2026 11:20
XD (full cold start) was producing ~2.7 s per round on CodSpeed CI,
which exhausts the per-benchmark wall-time budget after one sample.
With only one sample, CodSpeed reports 0% noise and "no measurable
impact" for every cold-start PR — including any genuine 10-100 ms
improvement that would otherwise be detectable. This made #15
(GatewayConnection background-warm) invisible on the dashboard even
though the optimization itself is correct.

Replace the legacy readiness model:
  0.25 s sleep + 1 retry x 2.0 s  =  2.25 s ceiling
with tight polling:
  0 s sleep + 300 retries x 0.05 s =  15 s ceiling
fast hosts now succeed in ~50-200 ms; slow CI in ~1-2 s; ceiling
still generous enough for any reasonable runner. With XD per-round
down to ~250-500 ms, CodSpeed fits 4-8 samples per benchmark
budget and can compute a usable CV.

Also drops the unconditional startup_sleep: the rationale ("let the
OS reuse the listen port") no longer applies — the JVM-side
GatewayServer.startSocket() uses SO_REUSEADDR (already on master),
so bind() succeeds immediately even over TIME_WAIT.

Backwards compatibility: callers that need the old sleep pattern
can pass ``startup_sleep=0.25`` explicitly.

Local measurement (M-series + JDK 21):
  XD before: median ~355 ms, max ~2.4 s (variance dominated by retries)
  XD after:  median ~260 ms, max ~420 ms (proper tight polling)

Co-authored-by: Isaac
…4j#557)

Cold-start work, C1 of the issue py4j#557 series.

Today every attribute walk along ``gateway.jvm.X.Y.Z.method`` re-issues
one reflection round-trip per level — JVMView, JavaPackage, and
JavaClass all have ``__getattr__`` methods that call out to the JVM
without caching the result. After the first walk, the JVM-side answer
is stable for the JVM's lifetime, so the next 999 walks pay the same
round-trip cost for no new information.

This change adds a per-instance dict cache on each of:

* ``JVMView._attr_cache``     — top-level names resolved on ``.jvm.X``
* ``JavaPackage._attr_cache`` — names resolved on ``.X.Y``
* ``JavaClass._members``      — static members on ``.System.currentTimeMillis``

Only successful resolutions are cached. Misses (Py4JError) keep
raising so that a later ``java_import`` or classpath change can still
surface a previously-missing class. The ``UserHelpAutoCompletion``
sentinel path and direct return values on ``JavaClass.__getattr__``
are NOT cached because they each have caller-visible semantics that
caching would change.

Thread safety mirrors the existing ``_statics`` / ``_dir_sequence_and_cache``
convention: plain dict assignments are atomic under CPython's GIL,
worst case is double-fill with the same value. The existing comments
in those caches explicitly accept this tradeoff.

Local measurement on the new XA-3 scenario (M-series + JDK 21):

  before:   ~94 ms / 1000 walks = ~94 us per walk (3 reflection RTTs)
  after:   ~269 us / 1000 walks = ~0.27 us per walk (dict get)

Approx 350x speed-up on the steady-state attribute-walk path; the
first walk in any chain still pays the full reflection cost. X1-1
unchanged (10 000 currentTimeMillis() calls, ~232 ms either way).

Co-authored-by: Isaac
@Tagar

Tagar commented Jun 10, 2026

Copy link
Copy Markdown
Member Author

Closing — superseded by #17 (the iteration that incorporates upstream review feedback: bounded LRU, GC-safety hardening, tests, changelog). #17 is the branch synced with upstream py4j#596.

@Tagar Tagar closed this Jun 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant