Skip to content

Write outlier messages through the ByteBuf API instead of staging in a heap array - #18

Closed
merlimat wants to merge 1 commit into
streamnative:masterfrom
merlimat:large-write-through
Closed

Write outlier messages through the ByteBuf API instead of staging in a heap array#18
merlimat wants to merge 1 commit into
streamnative:masterfrom
merlimat:large-write-through

Conversation

@merlimat

@merlimat merlimat commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

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 (e.g. a ~4.6 MB CommandGetTopicsOfNamespaceResponse for 8192 partitions) now pays its full serialized size in transient heap per write. This is one of the two 0.8.0 regressions behind the ProxyPatternConsumerBackPressureMultipleConsumersTest OOM seen on apache/pulsar#26256 — the other (retention in clear()) is fixed by #19.

Fix

writeTo() now dispatches three ways:

The ByteBuf write helpers this reuses (writeVarInt, writeString, …) were kept by #12 and encode byte-identically to the array writers: same ASCII test, and computeStringUTF8Size already uses Netty's utf8Bytes, so the UTF-8 fallback agrees with reserveAndWriteUtf8 byte for byte.

Verification

  • LargeWriteThroughTest: byte-identity against protobuf-java for >1 MiB messages on direct and composite targets (repeated strings incl. non-ASCII, bytes payloads, nested-message trees, and the exact Pulsar BaseCommand shape, which also exercises the bit-driven traversal variant); scratch retention just below the threshold; and — via ThreadMXBean.getThreadAllocatedBytes — that 5 writes of a multi-MB message allocate less than a quarter of one message size.
  • JMH (interleaved A/B vs 0.8.0, JDK 21, Apple M-series): small-message serialize unchanged.
  • End-to-end together with Release data references in clear() for messages above CLEAR_RETAIN_MAX #19 (0.8.1-SNAPSHOT in Pulsar's gradle build): the proxy back-pressure test goes from OOM at 275/500 requests (free-heap floor 1–7 MB) to 500/500 in ~8 s (min free heap 97 MB).

Independent of #19; the two merge cleanly in either order (verified by merge simulation plus the full suite on each branch).

…a heap array

Since streamnative#12, serializing to a non-array buffer stages the whole message in a
heap byte[] and bulk-copies it. Messages above SCRATCH_RETAIN_MAX (1 MiB)
never retain that scratch, so every writeTo() allocated a fresh full-size
array — multi-MB G1 humongous allocations, invisible to any accounting
sized to the target buffer. Pulsar's proxy back-pressure test
(ProxyPatternConsumerBackPressureMultipleConsumersTest, ~4.6 MB
CommandGetTopicsOfNamespaceResponse x 500 concurrent requests) OOMs on
0.8.0 where 0.7.3 passes.

writeTo() now dispatches three ways: heap buffers write in place through
the backing array (unchanged), messages up to SCRATCH_RETAIN_MAX compose in
the retained scratch (unchanged — this is where the streamnative#12 wins live, and the
scratch is now always retainable so the conditional retention is gone), and
larger messages fall back to a generated _writeTo(ByteBuf) that writes
field by field through the ByteBuf API. The fallback restores the pre-streamnative#12
zero-allocation property without restoring its sun.misc.Unsafe usage, and
unlike pre-streamnative#12 it also works for CompositeByteBuf targets. Nested messages
write through as well, so no element of the tree stages in a scratch array.
Bulk data still moves via bulk getBytes()/writeBytes(); only tags and
varints pay the per-call ByteBuf overhead, which is irrelevant at multi-MB
message sizes.

The ByteBuf write helpers this reuses (writeVarInt, writeString, ...) were
kept by streamnative#12 and encode byte-identically to the array writers: same ASCII
test, and computeStringUTF8Size already uses Netty's utf8Bytes so the
UTF-8 fallback agrees with reserveAndWriteUtf8 byte for byte.

New tests cover byte-identity against protobuf-java for >1 MiB messages on
direct and composite targets (repeated strings incl. non-ASCII, bytes
payloads, nested-message trees, and the exact Pulsar BaseCommand shape,
which also exercises the bit-driven traversal variant), scratch retention
just below the threshold, and — via ThreadMXBean.getThreadAllocatedBytes —
that 5 writes of a multi-MB message allocate less than a quarter of one
message size. The string-heavy allocation assertion is skipped under
-XX:-CompactStrings, where both write paths must copy each string's bytes
(the bytes-payload variant asserts unconditionally).
@merlimat

merlimat commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #20: instead of falling back to per-field ByteBuf-API writes above the scratch cap, direct buffers above 512 bytes are written in place through their NIO view (no scratch, no copy, at any size), which measured 21–44% faster than the scratch path from 600 B to 100 KB and matches this PR's numbers on the multi-MB cases, while the tiny hot-path messages keep the unchanged scratch path. Closing in favor of #20.

merlimat added a commit that referenced this pull request Sep 1, 2026
…20)

* Add large-message serialize benchmark and non-array target identity sweep

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.

* Write direct buffers in place through their NIO view above 512 bytes

Since #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 #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 #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.
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