From eb4e496d0266a8aed59ab40e48dc513d1d3e53de Mon Sep 17 00:00:00 2001 From: Ruslan Dautkhanov Date: Fri, 22 May 2026 09:28:09 -0600 Subject: [PATCH 1/4] perf: add 4 CodSpeed scenarios for cold-start work (issue #557) (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- py4j-python/src/py4j/tests/perf/README.md | 6 + .../tests/perf/scenarios/codspeed_macros.py | 72 +++++++++- .../src/py4j/tests/perf/scenarios/macro.py | 133 ++++++++++++++++++ 3 files changed, 209 insertions(+), 2 deletions(-) diff --git a/py4j-python/src/py4j/tests/perf/README.md b/py4j-python/src/py4j/tests/perf/README.md index 44805ec5..197244f4 100644 --- a/py4j-python/src/py4j/tests/perf/README.md +++ b/py4j-python/src/py4j/tests/perf/README.md @@ -142,6 +142,12 @@ Aggregate throughput. Scenario IDs with variants reveal scaling curves. | X4 | `Collections.sort` on Python-`Comparable` proxies | Callback round-trip | | X5 | 1 000 × `ArrayList.get(-1)` → `Py4JJavaError` | Error-path latency | | X6 | 50 concurrent threads, 20 calls each | Pool saturation tail latency | +| X7-1k / X7-16k / X7-256k | `ByteBuffer.array()` returning N bytes | Bytes recv (decode_bytearray) | +| X8-1k / X8-16k / X8-256k | `BAOS.write(bytes, 0, N)` of N bytes | Bytes send (encode_bytearray) | +| XA-3 | 1 000 walks of `gateway.jvm.java.lang.System` | Attribute-resolution cache canary (issue #557) | +| XB | 50 fresh `JavaGateway()` against a running JVM | Per-connection setup cost (issue #557) | +| XC | X1-1 workload with CallbackServer started, no callbacks invoked | Callback-infra overhead delta (issue #557) | +| XD | `subprocess.Popen` → first call → shutdown, one cycle per round | Full cold start (issue #557 — CodSpeed only) | ## How to read the report diff --git a/py4j-python/src/py4j/tests/perf/scenarios/codspeed_macros.py b/py4j-python/src/py4j/tests/perf/scenarios/codspeed_macros.py index 0f18d494..8a74d1e4 100644 --- a/py4j-python/src/py4j/tests/perf/scenarios/codspeed_macros.py +++ b/py4j-python/src/py4j/tests/perf/scenarios/codspeed_macros.py @@ -11,8 +11,13 @@ on the dashboard, so regressions show up per-scenario rather than as one opaque aggregate. -Scenario coverage rationale: four macros covering the perf -characteristics most prone to regression on the py4j socket / call path: +Scenario coverage rationale: macros covering the perf characteristics +most prone to regression on the py4j socket / call path. Two test +functions live here: + +``test_macro_scenario`` — scenarios that share a long-running JVM +provided by the ``macro_gateway`` fixture. Each scenario reuses the +same gateway across rounds (setup once, measure many). * ``X1-1`` — single-thread concurrent_1_thread (10k sequential calls). Baseline round-trip latency floor. @@ -23,6 +28,18 @@ the most complex code path in py4j. * ``X6`` — pool_saturation_50_threads. Connection pool behavior under high concurrency, tail-latency sensitive. +* ``X7-16k`` / ``X8-16k`` — bytes recv/send 16k. +* ``XA-3`` — attribute walk depth (Python __getattr__ cache canary). +* ``XB`` — gateway reconnect against running JVM (per-connection + setup cost). +* ``XC`` — callback infra overhead unused (delta vs X1-1). + +``test_cold_start_scenario`` — scenarios that own their full JVM +lifecycle inside ``measure()``. Cannot share the fixture's JVM +because port 25333 is already in use; each round must spawn and +shut down its own subprocess. + +* ``XD`` — full_cold_start_subprocess_first_call. Adding more scenarios later is one parametrize entry per scenario. """ @@ -41,6 +58,10 @@ X6_PoolSaturation, X7_16k, X8_16k, + XA_AttributeWalk3, + XB_GatewayReconnect, + XC_CallbackInfraOverheadUnused, + XD_FullColdStart, ) @@ -51,9 +72,29 @@ # invisible to the per-PR dashboard — every prior macro returns # int / list / void / callback and the byte path was a measurement # blind spot. +# +# XA-XC add cold-start / attribute-walk / connection-setup / callback- +# infra measurement surfaces that share the long-running fixture JVM. +# Each maps 1:1 to a planned cold-start improvement PR (issue #557): +# XA — attribute caches (Python __getattr__ memoization) +# XB — Java per-connection command-prototype cache + background warm +# XC — lazy CallbackClient (delta vs X1-1 = current overhead) +# XD lives in _COLD_START_SCENARIOS below — owns its JVM lifecycle. +# Without these, a per-PR CodSpeed dashboard would not see the wins. _MACRO_SCENARIOS = [ X1_1Thread, X2_10k, X4_Callbacks, X6_PoolSaturation, X7_16k, X8_16k, + XA_AttributeWalk3, XB_GatewayReconnect, + XC_CallbackInfraOverheadUnused, +] + +# Scenarios whose measure() spawns its own JVM subprocess per round. +# They must NOT share the fixture's JVM — the fixture's JVM is already +# bound to the default port 25333, so a second spawn would race on +# bind. Each cold-start scenario is self-contained: spawn, connect, +# call, shut down. +_COLD_START_SCENARIOS = [ + XD_FullColdStart, ] @@ -94,3 +135,30 @@ def test_macro_scenario(benchmark, macro_gateway): if hasattr(scenario, "setup"): scenario.setup(gateway) benchmark(scenario.measure, gateway) + + +@pytest.mark.parametrize( + "scenario_cls", _COLD_START_SCENARIOS, + ids=[cls.id for cls in _COLD_START_SCENARIOS], +) +def test_cold_start_scenario(benchmark, scenario_cls): + """Run a cold-start scenario that owns its JVM lifecycle. + + No shared fixture JVM — each ``measure()`` invocation spawns a + fresh subprocess, runs one full cold-start cycle, and tears it + down. Slow per-iteration (~300 ms on M-series + JDK 21, multiple + seconds on slower hosts); CodSpeed adapts iteration count + accordingly. + + Skips cleanly if the Java side hasn't been built — checked via the + same ``verify_classpath`` path used by ``fresh_jvm``. + """ + from py4j.tests.perf.jvm import verify_classpath + try: + verify_classpath() + except JvmNotBuiltError as e: + pytest.skip(str(e)) + scenario = scenario_cls() + # Cold-start scenarios have no shared state; setup() is not + # expected. measure() ignores the gateway arg (it owns its own). + benchmark(scenario.measure, None) diff --git a/py4j-python/src/py4j/tests/perf/scenarios/macro.py b/py4j-python/src/py4j/tests/perf/scenarios/macro.py index 192840ee..c37fcb92 100644 --- a/py4j-python/src/py4j/tests/perf/scenarios/macro.py +++ b/py4j-python/src/py4j/tests/perf/scenarios/macro.py @@ -9,6 +9,7 @@ import threading +from py4j.java_gateway import JavaGateway from py4j.tests.perf.runner import MacroScenario @@ -380,6 +381,129 @@ class X8_256k(_X8BytesSendBase): iterations_per_round = 25 +# ===================================================================== XA +# Attribute walk depth: re-walks the gateway.jvm.java.lang.System +# attribute chain many times. py4j's JVMView / JavaPackage / JavaClass +# __getattr__ methods do NOT memoize their results today, so every +# walk re-issues N reflection round-trips proportional to the chain +# length (1 RTT per level past .jvm). X1-1 caches the bound method +# once before its inner loop, hiding this cost — XA exposes it. +# +# A future attribute-memoization PR (issue #557 cold-start work) +# should collapse repeat walks to 0 RTTs after the first; XA is the +# canary for that win. +class _XAAttributeWalkBase(MacroScenario): + """Walk an FQN chain on `gateway.jvm` repeatedly without caching.""" + chain = ("java", "lang", "System") + iterations_per_round = 1_000 + + def measure(self, gateway): + jvm = gateway.jvm + a, b, c = self.chain + for _ in range(self.iterations_per_round): + # Resolve the full chain; no terminal call, so we measure + # only the attribute-resolution path (no actual JVM method + # invocation). + getattr(getattr(getattr(jvm, a), b), c) + + +class XA_AttributeWalk3(_XAAttributeWalkBase): + id = "XA-3" + name = "attribute_walk_jvm_java_lang_System" + # Three-level walk: jvm.java.lang.System -> 3 reflection RTTs per + # iteration on master; should collapse to ~0 after caching lands. + chain = ("java", "lang", "System") + iterations_per_round = 1_000 + + +# ===================================================================== XB +# Gateway reconnect: open a fresh JavaGateway against the *already- +# running* JVM, make one call, close. Measures the per-connection +# setup cost on both sides — Java accept() loop allocates 14 command +# class instances per connection (GatewayConnection.java:196-208), +# Python side runs _create_connection + socket setup + optional auth. +# This is the lever for the Java-side command-prototype-cache PR. +class XB_GatewayReconnect(MacroScenario): + id = "XB" + name = "gateway_reconnect_against_running_jvm" + # One round = 50 reconnect cycles. Empirically ~10-20 ms per cycle + # on M-series + JDK 21, so each timed round is ~0.5-1.0 s — well + # past scheduler jitter. + iterations_per_round = 50 + + def measure(self, gateway): + # Use the running JVM's port. We're a separate client; the + # fixture's gateway stays connected throughout. + from py4j.java_gateway import GatewayParameters + port = gateway.gateway_parameters.port + for _ in range(self.iterations_per_round): + gw = JavaGateway( + gateway_parameters=GatewayParameters(port=port)) + try: + gw.jvm.java.lang.System.currentTimeMillis() + finally: + gw.close() + + +# ===================================================================== XC +# Callback infrastructure overhead: same workload as X1-1 (10k +# currentTimeMillis() calls, single thread) but with the CallbackServer +# started. NO callbacks are actually invoked — the delta vs X1-1 is +# the steady-state cost of running the callback server alongside the +# main gateway. Target of the lazy-CallbackClient PR. +class XC_CallbackInfraOverheadUnused(MacroScenario): + id = "XC" + name = "callback_infra_overhead_unused" + enable_callbacks = True + iterations_per_round = 10_000 + + def measure(self, gateway): + fn = gateway.jvm.java.lang.System.currentTimeMillis + for _ in range(self.iterations_per_round): + fn() + + +# ===================================================================== XD +# Full cold start: spawn a fresh JVM subprocess, build a JavaGateway, +# make one call, shut everything down. One measure() = one cold start. +# Unlike the other scenarios, XD owns its JVM lifecycle inside +# measure() and ignores the fixture-provided gateway (which exists +# only to confirm the classpath builds and skip the test cleanly if +# Java isn't available). Slow per-iteration (~300 ms on M-series + +# JDK 21, ~1-3 s on slower hosts), so CodSpeed will only do a handful +# of rounds. Targets of the AppCDS / TieredStopAtLevel=1 / CRaC work. +class XD_FullColdStart(MacroScenario): + id = "XD" + name = "full_cold_start_subprocess_first_call" + iterations_per_round = 1 + + def measure(self, gateway): + # Import inside measure() so the cost of import isn't paid + # repeatedly; jvm helpers are already imported by the fixture + # in any sane run. + from py4j.tests.perf.jvm import spawn_jvm, shutdown_jvm + process = spawn_jvm() + try: + # Brief sleep mirrors fresh_jvm's startup_sleep so we + # exercise the same path; without it on a fast machine the + # first connect attempt can race ahead of the listening + # socket. + import time + time.sleep(0.25) + gw = None + try: + gw = JavaGateway() + gw.jvm.java.lang.System.currentTimeMillis() + finally: + if gw is not None: + try: + gw.shutdown() + except Exception: + pass + finally: + shutdown_jvm(process, None) + + ALL_MACRO_CLASSES = [ X1_1Thread, X1_4Thread, X1_16Thread, X2_1k, X2_10k, X2_100k, @@ -389,4 +513,13 @@ class X8_256k(_X8BytesSendBase): X6_PoolSaturation, X7_1k, X7_16k, X7_256k, X8_1k, X8_16k, X8_256k, + XA_AttributeWalk3, + XB_GatewayReconnect, + XC_CallbackInfraOverheadUnused, + # XD_FullColdStart is intentionally omitted: it spawns its own + # JVM subprocess inside measure(), so it cannot share the + # fresh_jvm()-managed gateway the framework runner provides + # (port-bind collision on 25333). XD is registered only on the + # CodSpeed path via codspeed_macros._COLD_START_SCENARIOS, where + # it runs against test_cold_start_scenario (no shared fixture). ] From ef5bfd805f00081b6942f64a17bba64c4e0a2681 Mon Sep 17 00:00:00 2001 From: Ruslan Dautkhanov Date: Fri, 22 May 2026 09:41:57 -0600 Subject: [PATCH 2/4] fix: XD cold-start scenario races JVM startup on CI runners (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/py4j/tests/perf/scenarios/macro.py | 34 ++++++------------- 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/py4j-python/src/py4j/tests/perf/scenarios/macro.py b/py4j-python/src/py4j/tests/perf/scenarios/macro.py index c37fcb92..d14373a7 100644 --- a/py4j-python/src/py4j/tests/perf/scenarios/macro.py +++ b/py4j-python/src/py4j/tests/perf/scenarios/macro.py @@ -478,30 +478,16 @@ class XD_FullColdStart(MacroScenario): iterations_per_round = 1 def measure(self, gateway): - # Import inside measure() so the cost of import isn't paid - # repeatedly; jvm helpers are already imported by the fixture - # in any sane run. - from py4j.tests.perf.jvm import spawn_jvm, shutdown_jvm - process = spawn_jvm() - try: - # Brief sleep mirrors fresh_jvm's startup_sleep so we - # exercise the same path; without it on a fast machine the - # first connect attempt can race ahead of the listening - # socket. - import time - time.sleep(0.25) - gw = None - try: - gw = JavaGateway() - gw.jvm.java.lang.System.currentTimeMillis() - finally: - if gw is not None: - try: - gw.shutdown() - except Exception: - pass - finally: - shutdown_jvm(process, None) + # Delegate to fresh_jvm so JVM startup readiness is polled + # rather than blind-slept — CodSpeed CI runners are slower + # than a local laptop and the original 0.25 s sleep was + # racing the JVM's listening socket (causing + # ConnectionRefusedError under CI). fresh_jvm uses a retry + # loop matching the rest of the test suite's conventions and + # cleanly tears down the subprocess in __exit__. + from py4j.tests.perf.jvm import fresh_jvm + with fresh_jvm(readiness_retries=5) as gw: + gw.jvm.java.lang.System.currentTimeMillis() ALL_MACRO_CLASSES = [ From 77eb4501c991e11fe170d037b3a99418e2d17c29 Mon Sep 17 00:00:00 2001 From: Ruslan Dautkhanov Date: Fri, 22 May 2026 11:20:38 -0600 Subject: [PATCH 3/4] perf: tighten fresh_jvm readiness polling for XD CodSpeed sampling (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- py4j-python/src/py4j/tests/perf/jvm.py | 36 ++++++++++++++----- .../src/py4j/tests/perf/scenarios/macro.py | 14 ++++---- 2 files changed, 34 insertions(+), 16 deletions(-) diff --git a/py4j-python/src/py4j/tests/perf/jvm.py b/py4j-python/src/py4j/tests/perf/jvm.py index 51ef4513..acc92fee 100644 --- a/py4j-python/src/py4j/tests/perf/jvm.py +++ b/py4j-python/src/py4j/tests/perf/jvm.py @@ -152,10 +152,30 @@ def shutdown_jvm(process, gateway=None, timeout=10): @contextmanager -def fresh_jvm(heap="4g", startup_sleep=0.25, readiness_retries=1, - enable_callbacks=False): +def fresh_jvm(heap="4g", startup_sleep=0.0, readiness_retries=300, + readiness_poll_s=0.05, enable_callbacks=False): """Spawn a JVM, build a gateway, yield it, then tear both down. + Readiness model: poll connect ``readiness_retries`` times waiting + ``readiness_poll_s`` between attempts. Total wait ceiling = + ``startup_sleep + readiness_retries * readiness_poll_s`` (default + 15 s — enough headroom for any reasonable CI runner while keeping + the typical fast-host wait under ~100 ms). + + Tight polling matters for XD (full cold start) on CodSpeed: the + previous default (0.25 s sleep + 1 retry × 2 s) made each XD + iteration cost ~2.7 s, leaving CodSpeed only ~1 sample per benchmark + budget — too few for variance / regression detection. With 50 ms + polling each XD round now finishes in ~600 ms, enough for 4-5 + samples per budget. (issue #557) + + Backwards compatibility: callers that previously relied on the + 0.25 s startup_sleep can pass ``startup_sleep=0.25`` to restore + the old behavior. The JVM's listen socket uses ``SO_REUSEADDR``, + so the original "let the OS reuse the listen port" rationale for + the unconditional sleep no longer applies — bind() succeeds + immediately even over TIME_WAIT. + :param enable_callbacks: if True, start a CallbackServer alongside the gateway so Java can invoke Python proxies (needed for X4). @@ -164,17 +184,17 @@ def fresh_jvm(heap="4g", startup_sleep=0.25, readiness_retries=1, gateway.jvm.java.lang.System.currentTimeMillis() """ process = spawn_jvm(heap=heap) - # Brief sleep lets the OS reuse the listen port; mirrors the 250ms - # default used across the existing test suite. - time.sleep(startup_sleep) + if startup_sleep > 0: + time.sleep(startup_sleep) try: - check_connection(retries=readiness_retries) + check_connection(retries=readiness_retries, + retry_sleep=readiness_poll_s) except Py4JNetworkError: shutdown_jvm(process, None) raise JvmStartupError( "JVM spawned but did not accept connections within " - "{0}s. Is port 25333 already in use?".format( - startup_sleep + 2.0 * readiness_retries)) + "{0:.1f}s. Is port 25333 already in use?".format( + startup_sleep + readiness_poll_s * readiness_retries)) if enable_callbacks: gateway = JavaGateway( callback_server_parameters=CallbackServerParameters()) diff --git a/py4j-python/src/py4j/tests/perf/scenarios/macro.py b/py4j-python/src/py4j/tests/perf/scenarios/macro.py index d14373a7..888baeb8 100644 --- a/py4j-python/src/py4j/tests/perf/scenarios/macro.py +++ b/py4j-python/src/py4j/tests/perf/scenarios/macro.py @@ -478,15 +478,13 @@ class XD_FullColdStart(MacroScenario): iterations_per_round = 1 def measure(self, gateway): - # Delegate to fresh_jvm so JVM startup readiness is polled - # rather than blind-slept — CodSpeed CI runners are slower - # than a local laptop and the original 0.25 s sleep was - # racing the JVM's listening socket (causing - # ConnectionRefusedError under CI). fresh_jvm uses a retry - # loop matching the rest of the test suite's conventions and - # cleanly tears down the subprocess in __exit__. + # Delegate to fresh_jvm. New defaults poll readiness every + # 50 ms with a 15 s ceiling — fast hosts finish in ~100 ms, + # CI in ~1-2 s. This lets CodSpeed fit multiple samples per + # benchmark budget instead of just one giant 2.7 s sample + # that hid C2's (and any other cold-start tweak's) impact. from py4j.tests.perf.jvm import fresh_jvm - with fresh_jvm(readiness_retries=5) as gw: + with fresh_jvm() as gw: gw.jvm.java.lang.System.currentTimeMillis() From a74edbee5d11c83adbbb65052dd7d21259655205 Mon Sep 17 00:00:00 2001 From: Ruslan Dautkhanov Date: Fri, 22 May 2026 09:36:14 -0600 Subject: [PATCH 4/4] perf: memoize JVMView / JavaPackage / JavaClass __getattr__ (issue #557) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cold-start work, C1 of the issue #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 --- py4j-python/src/py4j/java_gateway.py | 58 +++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 6 deletions(-) diff --git a/py4j-python/src/py4j/java_gateway.py b/py4j-python/src/py4j/java_gateway.py index 0792fa22..84986f3b 100644 --- a/py4j-python/src/py4j/java_gateway.py +++ b/py4j-python/src/py4j/java_gateway.py @@ -1566,6 +1566,17 @@ def __init__(self, fqn, gateway_client): self._converters = self._gateway_client.converters self._gateway_doc = None self._statics = None + # Cache of resolved members keyed by attribute name. JVM-side + # answers for a given (class FQN, member name) are stable for + # the JVM's lifetime, so memoizing avoids repeated reflection + # round-trips on patterns like + # ``gateway.jvm.X.Y.System.currentTimeMillis()`` (issue #557). + # Only successful lookups are cached — misses keep raising so + # later imports (java_import / classpath changes) can still + # surface. Not thread-safe but matches the existing + # ``_statics`` / ``_dir_sequence_and_cache`` convention: worst + # case is double-fill with the same value under CPython's GIL. + self._members = {} @property def __doc__(self): @@ -1613,6 +1624,10 @@ def __getattr__(self, name): # don't propagate any magic methods to Java raise AttributeError + cached = self._members.get(name) + if cached is not None: + return cached + command = proto.REFLECTION_COMMAND_NAME +\ proto.REFL_GET_MEMBER_SUB_COMMAND_NAME +\ self._fqn + "\n" +\ @@ -1622,15 +1637,22 @@ def __getattr__(self, name): if len(answer) > 1 and answer[0] == proto.SUCCESS: if answer[1] == proto.METHOD_TYPE: - return JavaMember( + result = JavaMember( name, None, proto.STATIC_PREFIX + self._fqn, self._gateway_client) elif answer[1].startswith(proto.CLASS_TYPE): - return JavaClass( + result = JavaClass( self._fqn + "$" + name, self._gateway_client) else: + # Direct return values (constants, etc.) are NOT cached + # — the answer string is decoded into a fresh Python + # value here, and there's no contract that the JVM-side + # value can't change across calls (static non-final + # fields exist). return get_return_value( answer, self._gateway_client, self._fqn, name) + self._members[name] = result + return result else: raise Py4JError( "{0}.{1} does not exist in the JVM".format(self._fqn, name)) @@ -1719,6 +1741,11 @@ def __init__(self, fqn, gateway_client, jvm_id=None): if jvm_id is None: self._jvm_id = proto.DEFAULT_JVM_ID self._jvm_id = jvm_id + # Memoize resolved children. See JavaClass._members for the + # rationale (issue #557 — collapses repeat FQN walks like + # ``gateway.jvm.java.lang.System`` from 3 RTTs to 0 after the + # first walk). + self._attr_cache = {} def __dir__(self): return [UserHelpAutoCompletion.KEY] @@ -1734,6 +1761,10 @@ def __getattr__(self, name): # don't propagate any magic methods to Java raise AttributeError + cached = self._attr_cache.get(name) + if cached is not None: + return cached + new_fqn = self._fqn + "." + name command = proto.REFLECTION_COMMAND_NAME +\ proto.REFL_GET_UNKNOWN_SUB_COMMAND_NAME +\ @@ -1742,12 +1773,14 @@ def __getattr__(self, name): proto.END_COMMAND_PART answer = self._gateway_client.send_command(command) if answer == proto.SUCCESS_PACKAGE: - return JavaPackage(new_fqn, self._gateway_client, self._jvm_id) + result = JavaPackage(new_fqn, self._gateway_client, self._jvm_id) elif answer.startswith(proto.SUCCESS_CLASS): - return JavaClass( + result = JavaClass( answer[proto.CLASS_FQN_START:], self._gateway_client) else: raise Py4JError("{0} does not exist in the JVM".format(new_fqn)) + self._attr_cache[name] = result + return result class JVMView(object): @@ -1772,6 +1805,11 @@ def __init__(self, gateway_client, jvm_name, id=None, jvm_object=None): self._jvm_object = jvm_object self._dir_sequence_and_cache = (None, []) + # Memoize resolved top-level names. See JavaClass._members / + # JavaPackage._attr_cache for the rationale (issue #557 — the + # ``.java`` in ``gateway.jvm.java.lang.System`` is now a free + # lookup after first walk instead of a reflection RTT). + self._attr_cache = {} def __dir__(self): command = proto.DIR_COMMAND_NAME +\ @@ -1793,22 +1831,30 @@ def __dir__(self): def __getattr__(self, name): if name == UserHelpAutoCompletion.KEY: + # Returns a fresh UserHelpAutoCompletion instance per + # access by design (no per-instance state) — skip caching. return UserHelpAutoCompletion() + cached = self._attr_cache.get(name) + if cached is not None: + return cached + answer = self._gateway_client.send_command( proto.REFLECTION_COMMAND_NAME + proto.REFL_GET_UNKNOWN_SUB_COMMAND_NAME + name + "\n" + self._id + "\n" + proto.END_COMMAND_PART) if answer == proto.SUCCESS_PACKAGE: - return JavaPackage(name, self._gateway_client, jvm_id=self._id) + result = JavaPackage(name, self._gateway_client, jvm_id=self._id) elif answer.startswith(proto.SUCCESS_CLASS): - return JavaClass( + result = JavaClass( answer[proto.CLASS_FQN_START:], self._gateway_client) else: _, error_message = get_error_message(answer) message = compute_exception_message( "{0} does not exist in the JVM".format(name), error_message) raise Py4JError(message) + self._attr_cache[name] = result + return result class GatewayProperty(object):