Write direct buffers in place through their NIO view above 512 bytes - #20
Merged
Conversation
…weep LargeMessageBenchmark serializes the Pulsar topic-list shape from 600 B to 4.6 MB, a varint-dense repeated-int64 message at 2 KB and 8 KB, and a 2 MB bytes payload into pooled direct buffers. NonArrayTargetIdentityTest checks that writeTo() to direct, offset and multi-component composite targets is byte-identical to the heap-array path for sizes swept byte by byte across every plausible internal boundary, for built and parsed messages and across repeated writes.
Since streamnative#12, writeTo() to a non-array buffer stages the whole message in a heap byte[] scratch and bulk-copies it. Messages above SCRATCH_RETAIN_MAX (1 MiB) never retain that scratch, so every write allocated a fresh full-size array — multi-MB G1-humongous allocations that OOMed Pulsar's proxy back-pressure test (apache/pulsar#26256, together with the clear() retention fixed in streamnative#19). Below the cap the copy itself was still paid. A single-region direct buffer exposes its memory as a java.nio.ByteBuffer through ByteBuf.internalNioBuffer(). Absolute puts on a DirectByteBuffer compile to a bounds check plus a jdk.internal.misc.Unsafe store — which, unlike sun.misc.Unsafe, carries no JDK 24+ deprecation check — so the message can be written in place: no scratch array and no bulk copy, at any size. writeTo() now dispatches heap buffers in place through the backing array (unchanged), single-region direct buffers larger than NIO_WRITE_MIN (512 bytes) through the NIO view, and everything else (small messages; composites and other buffers without a single NIO region) through the scratch path as before. Above the threshold no direct-buffer write touches the scratch, so it only grows past 512 bytes for composite targets. The threshold exists because the view's per-put cost is a fixed tax per message while the copy it saves grows with size. Interleaved JMH on pooled direct buffers (JDK 21/26): the view is 15-19% slower on the ~70-byte varint-dense MessageMetadata, at parity on BaseCommand, 20% faster at 600 bytes, and 35-40% faster from 6 KB to 100 KB; on the 2 MB / 4.6 MB cases it removes the per-write allocation (-70% / -55%) and matches the per-field ByteBuf-API write-through of streamnative#18, which it replaces. The field emitters are parameterized over the write sink (WriteSink.ARRAY / WriteSink.NIO): one emitter produces both _writeTo(byte[], int) and _writeTo(ByteBuffer, int), differing only in the sink variable and in how bulk data is copied out of a ByteBuf; every raw writer in LightProtoCodec is overloaded for both sinks. NonArrayTargetIdentityTest sweeps sizes byte by byte across every boundary (64 B .. 1 MiB) on direct, offset and multi-component composite targets, for built and parsed messages, repeated strings incl. non-ASCII, bytes payloads, nested trees and the Pulsar BaseCommand shape. NioWriteTest checks the routing flips exactly at NIO_WRITE_MIN, that composites keep the scratch path, and (via ThreadMXBean.getThreadAllocatedBytes) that 5 writes of a 4.6 MB topic list or a 5 MB payload allocate less than a quarter of one message. LargeMessageBenchmark covers 600 B .. 4.6 MB topic lists, varint-dense 2 KB / 8 KB messages and a 2 MB payload.
11 tasks
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.
Replaces #18.
Problem
Since #12,
writeTo()to a non-array buffer (direct, composite) stages the whole message in a heapbyte[]scratch and bulk-copies it. The scratch is retained on the instance only up toSCRATCH_RETAIN_MAX(1 MiB); a larger message allocates a fresh exact-size heap array on every serialization — multi-MB G1-humongous allocations, invisible to any accounting sized to the target buffer. Pulsar always serializes into pooled direct buffers, so any message over ~1 MiB (a ~4.6 MBCommandGetTopicsOfNamespaceResponsefor 8192 partitions) pays its full size in transient heap per write — one of the two 0.8.0 regressions behind theProxyPatternConsumerBackPressureMultipleConsumersTestOOM on apache/pulsar#26256 (the other, retention inclear(), was #19).#18 fixed this by falling back to per-field ByteBuf-API writes above the cap. That only removes the allocation; it leaves the scratch→target copy in place for everything below 1 MiB, and adds a third write path whose per-call cost is the very thing #12 moved away from.
Fix: write direct buffers in place through their NIO view
A single-region direct buffer exposes its memory as a
java.nio.ByteBuffer(ByteBuf.internalNioBuffer). Absolute puts on aDirectByteBuffercompile to a bounds check plus ajdk.internal.misc.Unsafestore — which, unlikesun.misc.Unsafe, carries no JDK 24+ deprecation check — so the message can be written in place: no scratch array and no bulk copy, at any size.The field emitters are parameterized over the write sink (
WriteSink.ARRAY/WriteSink.NIO); one emitter produces both_writeTo(byte[], int)and_writeTo(ByteBuffer, int), differing only in the sink variable and in how bulk data is copied out of a ByteBuf (every raw writer inLightProtoCodecis overloaded for both). No per-field logic is duplicated.writeTo()dispatches:serializedSize > NIO_WRITE_MIN(512 B) — in place through the NIO view;The threshold exists because the view's per-put cost is a fixed tax per message while the copy it saves grows with size: measured on the tiny varint-dense hot-path messages (
MessageMetadata, ~70 B) the view is 15–19% slower, while at 600 B it is already 20% faster and from 6 KB up 35–40% faster. Below 512 B the scratch is by definition tiny; above it no direct-buffer write ever touches the scratch, so_scratchonly grows past 512 B for composite targets.Measurements
Interleaved JMH (alternating rounds so thermal/background drift cancels), pooled direct buffers, mean of 3 rounds, time per serialization (lower is better) — the design comparison that led here, on JDK 26:
(The NIO column above is the pure NIO prototype; the 512 B threshold keeps the first three rows on the scratch path. The two multi-MB rows allocate 2 MB / 4.6 MB per write on 0.8.0 and 0 B on every alternative.)
Final A/B of this implementation vs master, same protocol:
(Δ is the mean of per-round deltas. The three hot-path rows run the unchanged scratch path and sit inside the ±5% band that identical jars show on this machine.)
Verification
NonArrayTargetIdentityTest:writeTo()to direct, offset and multi-component composite targets is byte-identical to the heap-array path for sizes swept byte by byte across every boundary (64 B … 1 MiB, including 512), for built and parsed (lazy passthrough) messages, repeated strings incl. non-ASCII, bytes payloads, nested trees and the PulsarBaseCommandshape.NioWriteTest: routing flips exactly atNIO_WRITE_MIN(scratch retained at ≤ 512 B, no scratch above), composites keep the scratch path, and — viaThreadMXBean.getThreadAllocatedBytes— 5 writes of a 4.6 MB topic list / 5 MB payload allocate less than a quarter of one message.LargeMessageBenchmarkadded (600 B … 4.6 MB topic lists, varint-dense 2 KB / 8 KB, 2 MB payload).ProxyPatternConsumerBackPressureMultipleConsumersTestpasses, 500/500 requests, min free heap 52 MB, versus OOM at 275/500 on 0.8.0.