perf: background-warm GatewayConnection class during startSocket (issue #557)#15
perf: background-warm GatewayConnection class during startSocket (issue #557)#15Tagar wants to merge 1 commit into
Conversation
a2356fe to
7a8bd66
Compare
Merging this PR will not alter performance
Comparing Footnotes
|
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
…ssue py4j#557) Cold-start work, C2 of the issue py4j#557 series. GatewayConnection's static initializer references 14 command classes (ArrayCommand, CallCommand, ConstructorCommand, ..., StreamCommand) via class literals — so the *first* time GatewayConnection is touched at runtime, the JVM class-loads all 14 in sequence. Today that first touch happens inside the accept() loop when the first client connects, on the same thread that needs to immediately handshake back. The class-loading cost lands on the cold-call critical path. Kick off a one-shot daemon thread inside startSocket() that calls ``Class.forName("py4j.GatewayConnection", true, classLoader)`` — its ``<clinit>`` runs in parallel with the ``bind()`` on the main thread. By the time a client connects and we instantiate the first GatewayConnection in the accept loop, the class data is already in the JVM's class cache. Pure work re-ordering — no API change, no behavior change for any existing caller. Best-effort: errors in the warm-up thread are swallowed (the accept loop will still trigger the same class-load on first connection, just without the parallelism benefit). Thread safety: the JVM's per-class ``<clinit>`` lock is the synchronization primitive — if a client connects and triggers ``new GatewayConnection(...)`` before the warmup thread finishes ``<clinit>``, the main thread blocks on the same ``<clinit>`` lock just as it would have without warmup. Concurrent attempts cannot double-init or see partial state. Expected win is modest — ~10-100 ms on first connect depending on filesystem cache state. Subsequent connects to the same JVM are unchanged (classes already loaded). Targets XD (full cold start) and the first round of XB (gateway reconnect against running JVM); X1 / X2 / X4 / X6 won't move since they hold long-running connections once. The companion idea of "cache 14 command instance prototypes globally" was explored and dropped — Command instances hold per- connection state via init(gateway, connection), so they cannot be shared without an interface change. The class-loading win above is the safe subset of that idea. Co-authored-by: Isaac
7a8bd66 to
12dd189
Compare
|
Closing — the change is theoretically correct but its impact is permanently below CodSpeed's noise floor on our current scenarios. EvidenceAfter rebasing onto post-#16 master (which tightened XD's polling and got us to 2 samples with proper variance):
C2's expected impact is 10-30 ms shaved off first-connect class loading = 0.9-2.8% of XD's 1.08 s baseline. CodSpeed's measured noise on XD is 3.2% — C2's signal is permanently below it. Why XD can't shrink further with py4j-side workThe 1.08 s breaks down approximately as:
To make XD shrink enough for py4j-class-level optimizations to be visible (and to dent the user's reported 3 s cold start in issue py4j#557 generally), we need JVM-level levers — AppCDS, No correctness concernsThe implementation itself was reviewed clean: pure work-reordering, JVM's per-class |
Summary
Cold-start work, C2 of the issue py4j/py4j#557 series.
GatewayConnection.<clinit>references 14 command classes via class literals — so the first timeGatewayConnectionis touched at runtime, the JVM class-loads all 14 in sequence. Today that first touch happens inside theaccept()loop when the first client connects, on the same thread that needs to immediately handshake back. The class-loading cost lands on the cold-call critical path.This change kicks off a one-shot daemon thread inside
startSocket()that callsClass.forName("py4j.GatewayConnection", true, classLoader)— its<clinit>runs in parallel with thebind()on the main thread. By the time a client connects and we instantiate the firstGatewayConnectionin the accept loop, the class data is already in the JVM's class cache.What this is NOT
The original C2 plan also included "cache 14 command instance prototypes globally" to skip the
clazz.newInstance() × 14per connection ininitCommands. I explored that and dropped it from this PR: Command instances hold per-connection state viainit(gateway, connection), so they cannot be shared without changing theCommandinterface contract. The class-loading win above is the safe subset.Risk: ~zero
Class.forNamefails for any reason, the accept loop still triggers the same class-load on first connection — just without the parallelism benefit."py4j-warmup"for visibility in jstack output.Expected impact
Modest: ~10-100 ms on first connect depending on filesystem cache state. Subsequent connects to the same JVM are unchanged (classes already loaded). Targets:
Local measurement
XD median ~350 ms with C2 applied (M-series + JDK 21). Variance on a single laptop with 5 rounds (~350-2400 ms range) is too high to see a 10-50 ms delta — CodSpeed CI's uniform hardware + larger sample size should surface it.
Diff
py4j-java/src/main/java/py4j/GatewayServer.java: +41 / -0Test plan
java_gateway_test+client_server_test, minus 14 pre-existing GatewayLauncher jar-path issues unrelated to this change)