Skip to content

perf: background-warm GatewayConnection class during startSocket (issue #557)#15

Closed
Tagar wants to merge 1 commit into
masterfrom
c2-java-class-prewarm
Closed

perf: background-warm GatewayConnection class during startSocket (issue #557)#15
Tagar wants to merge 1 commit into
masterfrom
c2-java-class-prewarm

Conversation

@Tagar

@Tagar Tagar commented May 22, 2026

Copy link
Copy Markdown
Member

Summary

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

GatewayConnection.<clinit> references 14 command classes 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.

This change kicks 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.

What this is NOT

The original C2 plan also included "cache 14 command instance prototypes globally" to skip the clazz.newInstance() × 14 per connection in initCommands. I explored that and dropped it from this PR: Command instances hold per-connection state via init(gateway, connection), so they cannot be shared without changing the Command interface contract. The class-loading win above is the safe subset.

Risk: ~zero

  • Pure work re-ordering — no API change, no behavior change for any existing caller.
  • Best-effort: errors in the warm-up thread are swallowed; if Class.forName fails for any reason, the accept loop still triggers the same class-load on first connection — just without the parallelism benefit.
  • Daemon thread — never blocks JVM exit.
  • Thread name "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:

Scenario Expected delta
XD (full cold start) Some win each round (every round spawns a fresh JVM)
XB (gateway reconnect against running JVM) Win on first iteration only
X1 / X2 / X4 / X6 (held connections) None — connections are long-lived, classes loaded once

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 / -0

Test plan

  • All Python integration tests pass locally (83/83 in java_gateway_test + client_server_test, minus 14 pre-existing GatewayLauncher jar-path issues unrelated to this change)
  • Java matrix CI green across Java 8/11/17/21 × ubuntu/macos/windows
  • CodSpeed shows XD delta (small but consistent if the hypothesis holds)

@Tagar Tagar force-pushed the c2-java-class-prewarm branch from a2356fe to 7a8bd66 Compare May 22, 2026 16:16
@codspeed-hq

codspeed-hq Bot commented May 22, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 22 untouched benchmarks
⏩ 7 skipped benchmarks1


Comparing c2-java-class-prewarm (12dd189) 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 added a commit that referenced this pull request May 22, 2026
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
@Tagar Tagar force-pushed the c2-java-class-prewarm branch from 7a8bd66 to 12dd189 Compare May 22, 2026 19:53
@Tagar

Tagar commented May 22, 2026

Copy link
Copy Markdown
Member Author

Closing — the change is theoretically correct but its impact is permanently below CodSpeed's noise floor on our current scenarios.

Evidence

After rebasing onto post-#16 master (which tightened XD's polling and got us to 2 samples with proper variance):

Scenario Time Noise Samples
XD (full cold start) 1.08 s 3.2% 2
XB (gateway reconnect, warm JVM) 65 ms 6.4% 32

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 work

The 1.08 s breaks down approximately as:

  • JVM subprocess spawn: ~200 ms
  • JDK bootstrap + JIT C1 warmup: ~600-800 ms ← bedrock, py4j has no lever here
  • py4j class load + handshake: ~50-100 ms ← only this slice is what C2 targets

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, -XX:TieredStopAtLevel=1, CRaC. C2's class-load parallelism would be a rounding error on top of those.

No correctness concerns

The implementation itself was reviewed clean: pure work-reordering, JVM's per-class <clinit> lock guarantees no race, best-effort daemon thread, zero API change. If JVM-level cold-start work later changes the breakdown such that py4j class loading becomes a meaningful fraction, this PR can be revived as-is.

@Tagar Tagar closed this May 22, 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