Skip to content

perf: disable Nagle's algorithm on py4j socket connections (closes #516) - #6

Merged
Tagar merged 1 commit into
masterfrom
disable-nagle-loopback
May 21, 2026
Merged

Tagar merged 1 commit into
masterfrom
disable-nagle-loopback

Conversation

@Tagar

@Tagar Tagar commented May 21, 2026

Copy link
Copy Markdown
Member

Disables Nagle's algorithm on every py4j socket connection — both languages, all 3 Java connection classes, all 3 Python connect/accept sites.

Why

py4j's text protocol is a write-write-read pattern: most method calls flush in two parts (command body + END marker, or a BufferedWriter overflow on large payloads). Nagle holds the second write waiting to coalesce; delayed-ACK on the receiver waits for piggyback opportunity; per-call latency jumps from sub-millisecond to ~40 ms.

Issue py4j#516 has a precise reproducer:

b = jvm.java.nio.ByteBuffer.allocate(8192)
for i in range(100): b.array()  # 4.4 seconds

vs the 4096 variant which takes 0.04 s — the 8 KB payload crosses the BufferedWriter buffer boundary into the two-write pattern.

py4j runs over loopback (same-host JVM) or low-latency LAN (PySpark driver↔executor in a cluster). Nagle's coalescing brings no benefit in either case, only the bug. Every modern RPC system (gRPC, Thrift) disables Nagle unconditionally for exactly this reason; this PR follows the same convention.

Sites covered

Python (3):

  • GatewayConnection.start() — JavaGateway client connect
  • CallbackServer accept loop — Java→Python callback accept
  • ClientServerConnection.connect_to_java_server() — ClientServer mode client connect (PythonServer accept inherits CallbackServer's patched loop)

Java (3 connection classes):

  • GatewayConnection — JavaGateway server-side
  • ClientServerConnection — ClientServer mode (both directions)
  • CallbackConnection — Java→Python callback client-side

The Python side uses a small disable_nagle() helper added next to the existing set_reuse_address / set_linger socket-option helpers.

Tests

  • DisableNagleHelperTest — pins the helper's contract (sets TCP_NODELAY; best-effort on closed sockets).
  • GatewayConnectionNagleIntegrationTest::test_gateway_connection_has_tcp_nodelay — opens a real JavaGateway, makes one JVM call, asserts the pooled connection's socket has TCP_NODELAY enabled. Regression guard against accidentally dropping or moving the disable_nagle() call relative to connect().
  • GatewayConnectionNagleIntegrationTest::test_bytebuffer_array_8k_does_not_take_seconds — runs issue Performance Issue due to TCP Nagle's Algorithm write,write,read Situation py4j/py4j#516's exact reproducer (100× ByteBuffer.allocate(8192).array()) and asserts it completes well under 1.5 s. With Nagle on this would take ~4.4 s; with TCP_NODELAY on it's well under 50 ms. Catches the regression directly at the behavioral level.

Credit to @trohwer for surfacing the issue and the original loopback-conditional fix proposal in PR py4j#517; this PR broadens to unconditional and covers both languages plus all 3 Java connection classes / 3 Python connect sites.

Closes py4j#516.

Co-authored-by: Isaac

@codspeed

codspeed Bot commented May 21, 2026 •

Copy link
Copy Markdown

Merging this PR will improve performance by ×42

⚡ 1 improved benchmark
✅ 17 untouched benchmarks
⏩ 7 skipped benchmarks1

Performance Changes

Mode Benchmark BASE HEAD Efficiency
⚡ WallTime test_macro_scenario[X7-16k] 4,399.8 ms 103.6 ms ×42

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 disable-nagle-loopback (c2dffda) with master (b7e694f)

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 21, 2026
Complements the existing perf framework (M-micro / X-macro scenarios)
and the per-PR CodSpeed tracking with measurement-reporting tests
that print throughput / latency numbers and catch dramatic
regressions.

What's added:

* **X8 (perf framework): bytes SEND macro scenario** at 1k / 16k /
  256k. Complements X7 (bytes recv) by exercising the encode_bytearray
  + Python->Java send path. Same BAOS.write(byte[]) Nagle-sensitive
  surface. X8-16k joins the CodSpeed CI scenario list.

* **ColdStartLatencyTest** (issue py4j#557) — measures
  `JavaGateway() + first-call` latency once. JVM init is excluded
  (handled by start_example_app_process); the timed window is what
  py4j actually controls. Prints the value; asserts <2s.

* **First-vs-warm ratio test** (issue py4j#557) — same gateway, first
  call vs median of 100 warm calls. Asserts <100x ratio (loose
  enough to not flake; tight enough to catch a real cold-path
  regression).

* **StringArgLatencyCurveTest** — per-call latency at string args
  of 16 / 256 / 4K / 16K / 64K bytes. Fills the gap between M2b
  (5-byte arg) and X7-class large-payload scenarios; surfaces the
  Nagle-knee around the BufferedWriter buffer boundary on Linux.

* **BandwidthSummaryTest** — MB/s at 1K / 16K / 256K for both
  recv (Java->Python ByteBuffer.array()) and send (Python->Java
  byte[] argument). Locally on macOS loopback: recv 7→94 MB/s,
  send 6→245 MB/s — confirms send is faster (no base64 decode)
  and predicts the decode_bytearray gain from #5.

Validation handle for the open PRs:
* PR #5 (decode_bytearray) — `BandwidthSummaryTest` recv numbers
  should improve substantially after the list-comprehension fix.
* PR #6 (Nagle) — `StringArgLatencyCurveTest` should show a
  flatter curve across the BufferedWriter boundary; `X8-16k` /
  `X7-16k` (#5) drop dramatically on Linux runners.

Tests print results when run with `pytest -v -s`; without `-s`
pytest captures the prints. Asserts use generous bounds to catch
catastrophic regressions without being CI-flaky on hardware-
dependent absolute numbers.

Co-authored-by: Isaac
Tagar added a commit that referenced this pull request May 21, 2026
This PR establishes a measurement baseline that the open optimization
PRs (decode_bytearray #5, Nagle #6) can be validated against, plus
adds regression-guard coverage for several blind spots.

Designed to be merged FIRST so subsequent perf PRs see the new
scenarios as already-existing baselines in CodSpeed.

## CodSpeed scenarios (perf framework)

* **X7 — bytes round-trip RECV** (1k / 16k / 256k). Each call
  round-trips `ByteBuffer.array()` over the wire and routes through
  OUTPUT_CONVERTER[BYTES_TYPE] -> decode_bytearray. Without X7 the
  decode_bytearray path is a measurement blind spot (every prior
  macro returns int / list / void / callback). X7-16k joins the
  CodSpeed CI scenario list.

* **X8 — bytes SEND** (1k / 16k / 256k). Complements X7 on the
  encode_bytearray + Python -> Java direction via
  ByteArrayOutputStream.write(byte[], int, int). X8-16k joins
  the CodSpeed list.

Together X7-16k and X8-16k cover both halves of the byte-codec
bandwidth surface that's Nagle-sensitive on Linux.

## perf_coverage_test.py (run with `pytest -v -s`)

* **ColdStartLatencyTest (issue py4j#557)** — three measurements with
  progressively more of the cold path excluded so a regression can
  be localized:
  - `test_full_cold_start` — fresh `java` subprocess + listening-
    socket wait + py4j handshake + first call. Includes JVM class
    loading; issue py4j#557's reported window. Caps <30 s.
  - `test_py4j_handshake_under_500ms` — JVM is already listening;
    only `JavaGateway()` + first call is timed. Isolates py4j's
    own contribution (<500 ms).
  - `test_first_vs_warm_call_ratio` — first call vs warm median,
    catches regressions in protocol-cache fill.

* **StringArgLatencyCurveTest** — per-call latency at 16 / 256 /
  4K / 16K / 64K byte string args. Fills the gap between M2b
  (~5 B arg) and X7-class large payloads. Surfaces the Nagle
  knee around the BufferedWriter buffer boundary (~8 K) on Linux.

* **BandwidthSummaryTest** — MB/s at 1K / 16K / 256K for both
  directions (recv via ByteBuffer.array, send via BAOS.write).
  Reports the numbers; asserts >0.1 MB/s floor to catch
  protocol-layer regressions without flaking on hardware variance.

## Validation handle for the open PRs

* PR #5 (decode_bytearray) — `BandwidthSummaryTest` recv MB/s
  should jump substantially after the list-comprehension fix.
  X7-16k drops on the CodSpeed dashboard.
* PR #6 (Nagle) — `StringArgLatencyCurveTest` flattens across
  the 8 K boundary; X7-16k and X8-16k drop dramatically on
  Linux CodSpeed runners (issue py4j#516's 4.4s / 100 calls
  reproducer is exactly X7-16k's pattern).

## Local sample output (macOS loopback)

```
[full-cold-start] subprocess.Popen -> first call: 0.31 s
[py4j-handshake-only] gateway+first-call: 1.4 ms
[first-vs-warm] first: 196us  warm-median: 108.4us  ratio: 1.8x
[string-arg latency curve]
       16 B arg:    361.2 us/call
      256 B arg:    270.6 us/call
     4096 B arg:    339.6 us/call
    16384 B arg:    387.6 us/call
    65536 B arg:    462.9 us/call
[bandwidth summary]
       size    recv MB/s    send MB/s
      1024B          7.1          6.8
     16384B         60.3         79.3
    262144B         94.1        244.7
```

macOS loopback shows no Nagle penalty (Linux CodSpeed runners will);
send is ~2.5x faster than recv at 256K because recv pays the
base64-decode + list-comprehension cost in decode_bytearray that
PR #5 fixes.

Co-authored-by: Isaac
Tagar added a commit that referenced this pull request May 21, 2026
…ge (#7)

This PR establishes a measurement baseline that the open optimization
PRs (decode_bytearray #5, Nagle #6) can be validated against, plus
adds regression-guard coverage for several blind spots.

Designed to be merged FIRST so subsequent perf PRs see the new
scenarios as already-existing baselines in CodSpeed.

## CodSpeed scenarios (perf framework)

* **X7 — bytes round-trip RECV** (1k / 16k / 256k). Each call
  round-trips `ByteBuffer.array()` over the wire and routes through
  OUTPUT_CONVERTER[BYTES_TYPE] -> decode_bytearray. Without X7 the
  decode_bytearray path is a measurement blind spot (every prior
  macro returns int / list / void / callback). X7-16k joins the
  CodSpeed CI scenario list.

* **X8 — bytes SEND** (1k / 16k / 256k). Complements X7 on the
  encode_bytearray + Python -> Java direction via
  ByteArrayOutputStream.write(byte[], int, int). X8-16k joins
  the CodSpeed list.

Together X7-16k and X8-16k cover both halves of the byte-codec
bandwidth surface that's Nagle-sensitive on Linux.

## perf_coverage_test.py (run with `pytest -v -s`)

* **ColdStartLatencyTest (issue py4j#557)** — three measurements with
  progressively more of the cold path excluded so a regression can
  be localized:
  - `test_full_cold_start` — fresh `java` subprocess + listening-
    socket wait + py4j handshake + first call. Includes JVM class
    loading; issue py4j#557's reported window. Caps <30 s.
  - `test_py4j_handshake_under_500ms` — JVM is already listening;
    only `JavaGateway()` + first call is timed. Isolates py4j's
    own contribution (<500 ms).
  - `test_first_vs_warm_call_ratio` — first call vs warm median,
    catches regressions in protocol-cache fill.

* **StringArgLatencyCurveTest** — per-call latency at 16 / 256 /
  4K / 16K / 64K byte string args. Fills the gap between M2b
  (~5 B arg) and X7-class large payloads. Surfaces the Nagle
  knee around the BufferedWriter buffer boundary (~8 K) on Linux.

* **BandwidthSummaryTest** — MB/s at 1K / 16K / 256K for both
  directions (recv via ByteBuffer.array, send via BAOS.write).
  Reports the numbers; asserts >0.1 MB/s floor to catch
  protocol-layer regressions without flaking on hardware variance.

## Validation handle for the open PRs

* PR #5 (decode_bytearray) — `BandwidthSummaryTest` recv MB/s
  should jump substantially after the list-comprehension fix.
  X7-16k drops on the CodSpeed dashboard.
* PR #6 (Nagle) — `StringArgLatencyCurveTest` flattens across
  the 8 K boundary; X7-16k and X8-16k drop dramatically on
  Linux CodSpeed runners (issue py4j#516's 4.4s / 100 calls
  reproducer is exactly X7-16k's pattern).

## Local sample output (macOS loopback)

```
[full-cold-start] subprocess.Popen -> first call: 0.31 s
[py4j-handshake-only] gateway+first-call: 1.4 ms
[first-vs-warm] first: 196us  warm-median: 108.4us  ratio: 1.8x
[string-arg latency curve]
       16 B arg:    361.2 us/call
      256 B arg:    270.6 us/call
     4096 B arg:    339.6 us/call
    16384 B arg:    387.6 us/call
    65536 B arg:    462.9 us/call
[bandwidth summary]
       size    recv MB/s    send MB/s
      1024B          7.1          6.8
     16384B         60.3         79.3
    262144B         94.1        244.7
```

macOS loopback shows no Nagle penalty (Linux CodSpeed runners will);
send is ~2.5x faster than recv at 256K because recv pays the
base64-decode + list-comprehension cost in decode_bytearray that
PR #5 fixes.

Co-authored-by: Isaac
Py4J's text protocol is a write-write-read pattern: most method calls
flush in two parts (command body + END marker, or a BufferedWriter
overflow for large payloads). Nagle holds the second write waiting to
coalesce; delayed-ACK on the receiver waits for piggyback opportunity;
per-call latency jumps from <1 ms to ~40 ms.

Issue py4j#516 has a precise reproducer: ByteBuffer.allocate(N).array()
in a tight loop, where 4 KB takes 0.04 s but 8 KB takes 4.4 s — 100x
slower as the payload crosses the BufferedWriter buffer boundary into
the two-write pattern.

Py4J runs over loopback (same-host JVM) or low-latency LAN (PySpark
driver↔executor, cluster); Nagle's coalescing brings no benefit in
either case, only the bug. Modern RPC systems (gRPC, Thrift) disable
Nagle unconditionally for this reason; this PR follows the same
convention rather than the loopback-only check from py4j#517.

Sites:

Python (3):
* GatewayConnection.start() — JavaGateway client connect
* CallbackServer accept loop — Java -> Python callback accept
* ClientServerConnection.connect_to_java_server() — ClientServer
  client connect (PythonServer accept inherits CallbackServer's
  patched loop)

Java (3 connection classes):
* GatewayConnection — JavaGateway server-side
* ClientServerConnection — ClientServer mode (both directions)
* CallbackConnection — Java -> Python callback client-side

Tests:
* DisableNagleHelperTest — pin the Python helper's contract
  (sets TCP_NODELAY; best-effort on closed sockets).
* GatewayConnectionNagleIntegrationTest — open a real JavaGateway,
  make one JVM call, assert the pooled connection's socket has
  TCP_NODELAY enabled. Regression guard against accidentally
  dropping or moving the disable_nagle() call relative to connect().

Credit to @trohwer (PR py4j#517) for surfacing the issue and the
loopback fix; this PR broadens to unconditional and covers both
languages and all 3 Java connection classes / 3 Python connect sites.

Co-authored-by: Isaac
@Tagar
Tagar force-pushed the disable-nagle-loopback branch from 3835508 to c2dffda Compare May 21, 2026 16:10
@Tagar
Tagar merged commit 3c2f164 into master May 21, 2026
58 checks passed
Tagar added a commit that referenced this pull request May 21, 2026
…03)"

This reverts commit 5026653 — removing FindBugs was overreach on
weak evidence:

* The 403 was on a SINGLE cell in PR #9's matrix (Python 3.9 /
  Java 8 / ubuntu-latest). Other cells in the SAME matrix run
  resolved `findbugs:3.0.+` successfully — proving the 403 is
  transient (likely Maven Central IP-throttling fresh runners),
  not a permanent policy change.
* Master CI on PRs #4 / #5 / #6 / #7 / #8 has been passing the
  same FindBugs resolution step reliably for months.
* Removing static analysis to "fix" a single flake degrades code
  quality on every future build.

The right defensive measures are already in this PR:
* shell-level retry around `./gradlew check && assemble`
* `shell: bash` for cross-platform consistency

If FindBugs ever does become permanently unavailable, that's the
moment to switch to SpotBugs — a real plugin migration, not a
panic delete.

Co-authored-by: Isaac
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.

Performance Issue due to TCP Nagle's Algorithm write,write,read Situation

1 participant