Avoid copying every push batch into a fresh byte[] - #872
Draft
1linkovdim wants to merge 2 commits into
Draft
Conversation
The push write path buffers events for 200ms per connection and then
concatenated the whole batch into a newly allocated byte[totalBytes]
before handing it to the channel. On the shared mantisagent fleet that
is roughly 170k allocations per second averaging half a megabyte each,
against ~95 GB/s of push egress, and every byte is copied at least once
purely to make it contiguous — the channel is going to write it with a
gathering write anyway.
Wrap the event arrays in a composite ByteBuf instead. ChannelWriter
already accepts a ByteBuf and DefaultChannelWriter passes it straight to
the channel without copying, so the batch reaches the socket as the
original arrays. The arrays are freshly allocated per chunk by the
encoder and never reused, so wrapping them is safe.
Two details worth calling out:
- Unpooled.wrappedBuffer(byte[]...) caps the composite at 16
components and silently consolidates — copies everything into one
array — past that, which would have made this change a no-op for
real batch sizes. The component count is now always explicit, and a
test pins it.
- Batches above MAX_WRAPPED_COMPONENTS fall back to the old
concatenation. A gathering write is capped at 1024 iovecs, so past
that a composite buys nothing and the bookkeeping costs more than
the copy.
The legacy TCP handler benefits twice. Its ByteBuf branch now prepends
the header via a second wrap rather than copying the payload behind it,
and its byte[] branch — commented "handle heart beat writes", but which
all legacy TCP data actually took until now — allocated an unsized
256-byte buffer and grew it to the full batch size, around eleven
doubling reallocations and two passes over the payload per batch. Data
now takes the ByteBuf branch and the comment is finally accurate.
Retry semantics are unchanged: writeBytesAndFlush performs the write
eagerly as a side effect and returns only the flush Observable, so
.retry(writeRetryCount) retried the flush before this change and still
does.
PushServerBatchWrapTest compares the new path against the verbatim
pre-change concatenation, byte for byte, over generated batch shapes;
LegacyTcpPipelineConfiguratorTest pins the framing the handler emits.
Adds a module-local JMH source set to mantis-network and PushServerBatchWrapBenchmark, which compares the composite-ByteBuf wrap against a verbatim copy of the pre-change concatenation body across payload sizes. Each path has a +gather arm that also walks nioBuffers(), since that is what the channel does on the way to a gathering write and is where wrapping repays itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The push write path buffers events for 200ms per connection, then concatenated the whole batch into a freshly allocated
byte[totalBytes]before handing it to the channel. This wraps the event arrays in a compositeByteBufinstead, so nothing is copied to make the batch contiguous.Why
The concatenation exists only to produce one contiguous array. The channel writes it with a gathering write regardless, so the contiguity buys nothing downstream — the copy is pure overhead. Building the batch also took two passes over
bufferOfBuffersper connection (one to sum lengths, one to fill).Replacing the copy with a composite trades an O(total bytes) memcpy for O(component count) bookkeeping. That is a win exactly when events are large enough that the memcpy dominates the per-component cost — see Benchmarks for where that crossover sits and why the payloads this path carries clear it comfortably. Below the crossover it is a regression, and the PR is honest about that boundary rather than hiding it.
How
ChannelWriteralready exposeswriteBytesAndFlush(ByteBuf), andDefaultChannelWriterperforms no copy — both thebyte[]andByteBufoverloads just callwriteOnChannel(msg). SoPushServernow builds the batch as a composite:byte[totalBytes], all events copied inwrapBare— one component per eventbyte[totalBytes], prefix+event+suffix copied inwrapDelimited— three components per eventbyte[totalBytes]around the compressed blobwrap(prefix, compressed, suffix)Wrapping is safe: each
byte[]comes fromencoder.call(chunk), freshly allocated per chunk and never reused or retained elsewhere.LengthFieldPrependerand the legacy TCP handler preserve the property. The prepender adds a separate header buffer and retains the message rather than copying it, so the zero-copy batch survives to the socket. (JdkZlibEncodernecessarily copies when compression is enabled — unchanged behaviour, and it makes the wrap a no-op for compressed SSE, which is expected.)The legacy TCP handler gains more than the wrap
LegacyTcpPipelineConfigurator's outbound handler has two branches. Thebyte[]one is commented "handle heart beat writes" — but until this change all legacy TCP data took it, becausePushServeremitted abyte[]. That branch allocated an unsizedctx.alloc().buffer()— Netty's 256-byte default — and grew it to the full batch size: roughly eleven doubling reallocations and two passes over the payload per batch, on top ofPushServer's own concatenation.Data now takes the
ByteBufbranch, which prepends the header by wrapping rather than copying, and the comment is accurate for the first time. The heartbeat branch is now pre-sized too.The explicit
bytes.release()was dropped from that branch deliberately:Unpooled.wrappedBuffer(ByteBuf...)takes ownership of its components' refcounts, so releasing the composite releases the payload.Two traps worth flagging for review
Unpooled.wrappedBuffer(byte[]...)defaults tomaxNumComponents = 16and silently consolidates — copies everything into a single array — once the composite exceeds it. A 30-event batch is 30 components bare and ~90 delimited, so using the varargs form without an explicit count would have made this entire change a no-op with no visible symptom. Every call site now passes the count explicitly, andnormalBatchIsNotCopiedIntoOneBufferassertsnioBufferCount()is 64 / 192 for a 64-event batch so a regression here fails loudly.Unpooled.wrappedBufferreturnsEMPTY_BUFFERif any array element is null. A pre-sizednew byte[events][]would leave trailing nulls if a batch shrank between the counting pass and the fill pass — silent data loss. The helpers build aList<byte[]>and calltoArray(new byte[0][]), which makes the event count a sizing hint only.MAX_WRAPPED_COMPONENTS = 1024A gathering write is capped at 1024 iovecs by the JDK, so past that point a composite stops buying anything — the channel makes several write syscalls regardless, and the per-component bookkeeping costs more than the copy it was avoiding. Batches that large fall back to
copyBare/copyDelimited, which produce byte-identical output. Typical batches are tens of events per 200ms window, so this is a safety valve rather than a live path. The benchmark confirms the fallback rows are within noise of legacy — it never makes a large batch materially worse.Retry semantics are unchanged
writeBytesAndFlushperforms the write eagerly, as a side effect at call time, and returns only theflush()Observable. So.retry(writeRetryCount)retries the flush, never the write — identically forbyte[]andByteBuf. Worth noting separately that this makes the retry largely vacuous today; that's pre-existing and not touched here.Testing
./gradlew :mantis-network:test— green (JDK 17).PushServerBatchWrapTest(5 tests) compares the new path against the verbatim pre-change concatenation bodies, copied out ofPushServer.startServerrather than reimplemented, so the comparison cannot drift into testing the new code against a restatement of itself:bareWrapMatchesLegacyConcatenation/delimitedWrapMatchesLegacyConcatenation— byte equality over 16 generated batch shapes (a singleton; an empty inner list; empty-then-nonempty-then-empty; zero-length payloads; 12 random shapes of 1–6 inner lists × 0–7 events × 0–511 byte payloads, fixed seed).compressedFrameWrapMatchesLegacyConcatenation— the SSE compressed frame.oversizedBatchFallsBackToCopyWithIdenticalBytes—MAX_WRAPPED_COMPONENTS + 5events; asserts the fallback fired (nioBufferCount() == 1) and byte equality.normalBatchIsNotCopiedIntoOneBuffer— the 16-component consolidation guard described above.LegacyTcpPipelineConfiguratorTest(4 tests) drives the configured pipeline through anEmbeddedChanneland asserts the emitted frame is exactly[PROTOCOL_VERSION, nameLength, ...nameBytes, ...payload]for a multi-component composite, a singleByteBuf, abyte[]heartbeat, and the null/empty-name case.Benchmarks
This repo had no JMH source set, so this PR adds one to
mantis-network(src/jmh,me.champeau.jmhapplied module-locally so the root build is untouched) plusPushServerBatchWrapBenchmark.The baseline arm holds the verbatim pre-change concatenation body, copied out of the pre-change
PushServerrather than reimplemented, so the two arms cannot drift apart under review.@Setupasserts both arms produce byte-for-byte identical output before anything is measured.Wrapping does not make bytes disappear — it moves who touches them. Measuring construction alone would flatter it, because the channel still has to walk the finished buffer's
nioBuffers()on the way to a gathering write. So each path has a...Gatheredarm that also does that walk; the honest number is the...Gatheredarm, not the construction-only one.JDK 17.0.17-zulu,
@Fork(1), 5×1s warmup, 5×1s measurement,Mode.AverageTime, µs/op,-t 1:Bare (non-SSE), 30 events/batch
Delimited (SSE, uncompressed), 30 events/batch
Read this honestly:
nioBuffers()gather cost more than the copy they replace, and this is a regression. Delimited pays three components per event, so its crossover is higher.events ≥ 1024fallback rows (not shown) are within noise of legacy — the safety valve does not make large batches worse.byte[totalBytes]allocation (one per batch per connection) is not modelled here; under real allocation and GC pressure the win at large payloads is a lower bound.Where the payloads actually sit
Whether this PR is net-positive depends entirely on real payload size, so I measured it — from Atlas metrics, not profiling: per-
mantisJobName,tcpServer_..._bytesWritten(wire bytes/s) ÷PushServer_numSuccessfulWrites(events/s), us-east-1, 6h window:Every job that moves meaningful push volume sits well above both crossovers, in the region where the bare path wins 3–22× and the delimited path 1.8–6.5×. Caveat:
bytesWrittenis post-framing, post-compression wire bytes, so for compressed SSE the pre-compression payload the wrap actually sees is larger still — but compression also defeats the wrap (the zlib encoder copies), so the relevant jobs for this change are the uncompressed ones.Not addressed here
Two adjacent findings from the same measurement pass, deliberately out of scope:
processedWrites.incrementfires before the check, so that work is performed then discarded). This change makes each such batch cheaper to build but does not stop it being dropped.OperatorBufferWithTimetimer per connection, many of them near-idle.Both are cheaper per batch after this change but neither goes away.