Skip to content

Write direct buffers in place through their NIO view above 512 bytes - #20

Merged
merlimat merged 2 commits into
streamnative:masterfrom
merlimat:nio-write
Sep 1, 2026
Merged

Write direct buffers in place through their NIO view above 512 bytes#20
merlimat merged 2 commits into
streamnative:masterfrom
merlimat:nio-write

Conversation

@merlimat

@merlimat merlimat commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Replaces #18.

Problem

Since #12, writeTo() to a non-array buffer (direct, composite) stages the whole message in a heap byte[] scratch and bulk-copies it. The scratch is retained on the instance only up to SCRATCH_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 MB CommandGetTopicsOfNamespaceResponse for 8192 partitions) pays its full size in transient heap per write — one of the two 0.8.0 regressions behind the ProxyPatternConsumerBackPressureMultipleConsumersTest OOM on apache/pulsar#26256 (the other, retention in clear(), 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 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.

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). No per-field logic is duplicated.

writeTo() dispatches:

  • heap buffers — in place through the backing array (unchanged);
  • single-region direct buffers with serializedSize > NIO_WRITE_MIN (512 B) — in place through the NIO view;
  • everything else (small messages; composites and other buffers without a single NIO region) — the scratch path, unchanged.

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 _scratch only 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:

message 0.8.0 scratch #18 write-through NIO view (this) chunked scratch
Frame (tiny, ~20 B) 6.4 ns 6.5 ns 16.2 ns 6.4 ns
BaseCommand (~50 B) 23.0 ns 22.7 ns 22.7 ns 22.7 ns
MessageMetadata (~70 B) 70.4 ns 71.4 ns 86.2 ns 70.9 ns
topic list 6 KB 277 ns 254 ns 174 ns 265 ns
topic list 16 KB 704 ns 676 ns 413 ns 754 ns
topic list 100 KB 5.74 µs 5.68 µs 3.91 µs 5.80 µs
bytes payload 2 MB 129.7 µs 39.5 µs 40.1 µs 41.7 µs
topic list 4.6 MB 376.7 µs 176.5 µs 173.4 µs 262.1 µs

(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:

message JDK 21 master JDK 21 this PR Δ JDK 26 master JDK 26 this PR Δ
Frame (tiny, ~20 B) 15.6 ns 15.3 ns -2% 6.5 ns 6.5 ns -0%
BaseCommand (~50 B) 22.8 ns 23.9 ns +5% 23.4 ns 23.9 ns +2%
MessageMetadata (~70 B) 75.7 ns 75.0 ns -1% 70.6 ns 70.6 ns +0%
topic list 600 B 35.8 ns 27.5 ns -23% 35.5 ns 27.9 ns -21%
topic list 1 KB 57.4 ns 40.9 ns -29% 60.0 ns 40.4 ns -33%
topic list 2 KB 0.103 µs 68.9 ns -33% 0.101 µs 68.0 ns -33%
topic list 3 KB 0.149 µs 97.2 ns -35% 0.148 µs 97.1 ns -34%
varint-dense 2 KB 0.979 µs 0.746 µs -24% 0.900 µs 0.749 µs -17%
topic list 6 KB 0.282 µs 0.166 µs -41% 0.277 µs 0.170 µs -38%
varint-dense 8 KB 4.333 µs 3.414 µs -21% 4.347 µs 3.446 µs -21%
topic list 16 KB 0.665 µs 0.410 µs -38% 0.725 µs 0.402 µs -44%
topic list 100 KB 5.482 µs 3.636 µs -34% 5.617 µs 3.703 µs -34%
bytes payload 2 MB 130.8 µs 40.7 µs -69% 133.2 µs 39.5 µs -70%
topic list 4.6 MB 404.2 µs 169.3 µs -58% 378.5 µs 174.6 µs -54%

(Δ 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 Pulsar BaseCommand shape.
  • NioWriteTest: routing flips exactly at NIO_WRITE_MIN (scratch retained at ≤ 512 B, no scratch above), composites keep the scratch path, and — via ThreadMXBean.getThreadAllocatedBytes — 5 writes of a 4.6 MB topic list / 5 MB payload allocate less than a quarter of one message.
  • LargeMessageBenchmark added (600 B … 4.6 MB topic lists, varint-dense 2 KB / 8 KB, 2 MB payload).
  • End-to-end (0.8.1-SNAPSHOT in Pulsar's gradle build, with Release data references in clear() for messages above CLEAR_RETAIN_MAX #19): ProxyPatternConsumerBackPressureMultipleConsumersTest passes, 500/500 requests, min free heap 52 MB, versus OOM at 275/500 on 0.8.0.

…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.
@merlimat
merlimat merged commit 1745887 into streamnative:master Sep 1, 2026
1 check passed
@merlimat
merlimat deleted the nio-write branch September 1, 2026 21:37
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