Skip to content

Write a message's fragments in groups instead of one write each - #340

Open
AngeloRubens wants to merge 2 commits into
eclipse-ee4j:masterfrom
AngeloRubens:perf/coalesce-fragments
Open

AngeloRubens wants to merge 2 commits into
eclipse-ee4j:masterfrom
AngeloRubens:perf/coalesce-fragments

Conversation

@AngeloRubens

@AngeloRubens AngeloRubens commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

What this changes

Every fragment of a streamed message was a separate write on the connection, under the connection's write lock. GlassFish's default fragment size is 1 KiB, so a call carrying a few kilobytes made several writes and a 64 KiB string about 130 each way: on a GlassFish server, counted from /proc, 3.3 write system calls per call for a 2.6 KB reply and 131 for the 64 KiB string.

BufferManagerWriteStream now copies each full fragment, its header completed, into a holding buffer, and writes the fragments it holds together when another whole fragment would take them past 16 KiB, and with the last fragment of the message. The fragments on the wire are the same, byte for byte, in the same order; only the writes are fewer. A message that fits in one fragment takes exactly the path it took before, and fragments larger than 8 KiB are written one by one as before. Copying a kilobyte costs far less than the system call it saves.

  • sentFragment() counts only fragments actually written, so a message that fails while its fragments are still held sends no CancelRequest, having sent nothing to cancel; close() drops them.
  • The write goes through a new Connection.sendWithoutLock(ByteBuffer). It is a default method, so other implementations of the SPI need no change; ConnectionImpl overrides it to report a failure exactly as sendWithoutLock(CDROutputObject) does.
  • CDROutputObject.writeTo's header and trace handling moved into sealFragment(), so both paths share it.
  • Each thread keeps one 16 KiB holding buffer, taken from the ByteBufferPool the first time and given back to the thread when a message is sent or closed. The first version allocated one per message that fragments, which made the server allocate more bytes per call than master; see the second commit.

This is independent of #339 and of eclipse-ee4j/glassfish#26246, which raises GlassFish's default fragment size to 8 KiB. With 8 KiB fragments this still groups two fragments per write; at the current 1 KiB it groups sixteen.

Measurements

GlassFish 8.0.4 on a GitHub Actions runner with the ORB jars swapped in, JDK 21 to build and JRE 25 to run, GlassFish's default 1 KiB fragments. Client and server are pinned to separate cores, two each, and the server is the bottleneck. Four rounds per configuration, 15 s warm-up and 45 s measured, interleaved; medians below, master on the left of each arrow and this branch on the right. master is eclipse-ee4j/orb master as released in 5.0.3, built in the same job. The server's CPU time and system calls are read from /proc over a window inside each measurement and divided by the calls in it; allocation and collections are counted exactly from the heap summaries around each collection in the JFR recording of the same window. Run: #37039197017, zero request errors.

scenario server writes per call server CPU per call calls/s p99 bytes allocated per call collections per 1000 calls
small request and reply 1.28 → 1.28 31.9 → 31.9 µs 48,528 → 48,570 0.34 → 0.33 ms 8,642 → 8,574 0.09 → 0.08
50-node object graph (2.6 KB reply) 3.32 → 1.34 119.2 → 101.7 µs (−15%) 14,728 → 16,091 (1.09x) 1.39 → 1.19 ms 34,943 → 35,402 (+1.3%) 0.35 → 0.35
64 KiB string each way 131 → 9.2 1,284 → 456 µs (−65%) 1,189 → 3,755 (3.2x) 21.0 → 5.8 ms 500,562 → 499,922 5.11 → 4.99

Every round of this branch used less CPU per call than every round of master on the object graph (100.2–106.6 µs against 116.1–128.2) and on the string (445–482 µs against 1,251–1,301). A message that fits in one fragment is unaffected, as it should be.

Allocation. With one holding buffer per thread the bytes allocated per call are master's: on the object graph this branch's rounds are 34,970–35,954 bytes against 34,861–34,983, a median 1.3% above master that is close to the spread between rounds. The first version of this pull request allocated a holding buffer per message: counted the same way, 12% more bytes per call on the object graph and 7% more on the string, with as many more collections. An earlier version of this description gave allocation figures from JFR's allocation samples, which are throttled and weighted estimates, and they had that the wrong way round; the numbers above replace them.

Latency. The receiver of a large message gets its first fragment once 16 KiB are ready rather than 1 KiB; the latencies above include that.

The harness, the test bean and the load client live on a branch of my fork, not here; each run attaches its raw output and a JFR recording of every measured run.

Limits. One four-core runner with the client and the server on it, both running this ORB; only comparisons within a run mean anything, and runners differ: an earlier run of the first version on a slower runner gave −16% and −75% CPU per call for the same two scenarios.

Tests

mvn install on JDK 21, the whole reactor: 519 tests, none failing - 395 in orbmain, 88 in rmic, 15 in the annotation processor, 10 in internal-api, 9 in idlj, 2 in omgapi.

The encoding tests' connection fake now splits every write back into GIOP messages by their headers, and its fake message header writes the size field as the real ones do. So every existing test of fragment contents - wstrings across fragment boundaries, chunked values, bulk arrays - now also checks that grouped fragments arrive whole and in order. The new tests check the number of writes for one, three, forty and large fragments, that held fragments do not count as sent, and that closing an unfinished message writes nothing; with grouping disabled, four of the six fail.

Every fragment of a streamed message was a separate write on the
connection, under the connection's write lock. With GlassFish's
default of 1 KiB fragments a 64 KiB value takes about 130 of them each
way. Measured on a GlassFish server with the write and read system
calls counted from /proc: a call carrying a 2.6 KB reply made 3.3
write calls at 1 KiB fragments and 1.3 at 8 KiB, and a 64 KiB string
131 and 17, with the server spending 18% and 68% more CPU per call at
the smaller size.

BufferManagerWriteStream now copies a full fragment, its header
completed, into a holding buffer, and writes the fragments it holds
together when another whole fragment would take them past 16 KiB, and
with the last fragment of the message. The fragments are the same,
byte for byte and in the same order; only the writes are fewer. A
fragment larger than 8 KiB is written on its own as before. Copying a
fragment of a kilobyte costs far less than the system call it saves.

sentFragment() counts only fragments actually written, so a message
that fails while its fragments are still held sends no CancelRequest,
having sent nothing to cancel; close() drops them. The write goes
through a new Connection.sendWithoutLock(ByteBuffer), a default method
so that other implementations need no change; ConnectionImpl reports a
failure exactly as sendWithoutLock(CDROutputObject) does.
CDROutputObject.writeTo's header and trace handling moved into
sealFragment() so that both paths share it.

The encoding tests' connection fake now splits every write back into
GIOP messages by their headers, and its message header writes the size
as the real ones do, so every existing test of fragment contents also
checks that grouped fragments arrive whole. The new tests check the
number of writes, that held fragments do not count as sent, and that
closing an unfinished message writes nothing; with grouping disabled
four of them fail.
The holding buffer was allocated for every message that fragments, and
grown from four fragments to 16 KiB on a large one. Counted exactly
from the heap summaries around each collection, that made a call
returning a 2.6 KB object graph allocate 12% more bytes, the 4 KiB
buffer, and a call carrying a 64 KiB string 7% more, with as many more
collections per call - more than grouping saved.

Each thread now keeps one 16 KiB holding buffer, taken from the
ByteBufferPool the first time and given back to the thread when a
message is sent or closed, as the indirection tables are; the buffer
no longer grows.
@AngeloRubens

Copy link
Copy Markdown
Contributor Author

Correction and a second commit. The description first said this allocated 2% more bytes per call on the object graph and 47% fewer on the 64 KiB string; those came from JFR's allocation samples, which are estimates, and they were wrong. Counted exactly from the heap summaries, the first version allocated 12% more bytes per call on the object graph and 7% more on the string, because it allocated a holding buffer for every message that fragments. The second commit keeps one holding buffer per thread instead; measured again, allocation per call is now master's (+1.3% on the object graph, close to the spread between rounds), and CPU per call is still 15% lower on the object graph and 65% lower on the string. The description has the new run and the exact allocation and collection columns.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant