Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 52 additions & 6 deletions py4j-python/src/py4j/java_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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" +\
Expand All @@ -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))
Expand Down Expand Up @@ -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]
Expand All @@ -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 +\
Expand All @@ -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):
Expand All @@ -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 +\
Expand All @@ -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):
Expand Down
6 changes: 6 additions & 0 deletions py4j-python/src/py4j/tests/perf/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
36 changes: 28 additions & 8 deletions py4j-python/src/py4j/tests/perf/jvm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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())
Expand Down
72 changes: 70 additions & 2 deletions py4j-python/src/py4j/tests/perf/scenarios/codspeed_macros.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
"""
Expand All @@ -41,6 +58,10 @@
X6_PoolSaturation,
X7_16k,
X8_16k,
XA_AttributeWalk3,
XB_GatewayReconnect,
XC_CallbackInfraOverheadUnused,
XD_FullColdStart,
)


Expand All @@ -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,
]


Expand Down Expand Up @@ -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)
Loading
Loading