[CELEBORN-2391] Eliminate intermediate byte[] allocation in Encoders.Strings via Netty ByteBufUtil - #3769
[CELEBORN-2391] Eliminate intermediate byte[] allocation in Encoders.Strings via Netty ByteBufUtil#3769yew1eb wants to merge 8 commits into
Conversation
…Strings via Netty ByteBufUtil
…sage encoders ByteBufUtil.writeUtf8 reserves utf8MaxBytes capacity before writing, which may grow the header buffer beyond the exact encoded length. Assert on writerIndex to keep the exact-bytes-written invariant without depending on buffer capacity.
Cover NUL/control characters, 2/3-byte boundaries, BMP max, and unpaired surrogates. Assert byte-for-byte wire compatibility with getBytes(UTF_8) for all of them, and that lossy unpaired-surrogate decoding matches the getBytes-based round trip.
…de paths Match the documented intent of isolating the encoding cost: allocate the destination buffer once in setup and clear it per invocation, instead of allocating a fresh unpooled buffer per op. This removes benchmark-harness allocation noise so gc.alloc.rate.norm reflects only the encoder itself.
| buf.writeInt(bytes.length); | ||
| buf.writeBytes(bytes); | ||
| buf.writeInt(ByteBufUtil.utf8Bytes(s)); | ||
| ByteBufUtil.writeUtf8(buf, s); |
There was a problem hiding this comment.
writeUtf8 reserves utf8MaxBytes(s.length())—up to 3 bytes per UTF-16 code unit. Therefore, a buffer initially sized from encodedLength()may still grow.
There was a problem hiding this comment.
Confirmed -- writeUtf8 reserves utf8MaxBytes (3 bytes/char), so an exactly-sized buffer grows (36 -> 128 for a 30-char ASCII string in a quick check). Wire bytes are unaffected; the updated asserts check writerIndex(0, which is the invariant encode() actually guarantees. Pre-sizing header buffers could avoid the grow-and-copy, but I'd keep this PR focused and revisit if benchmarks show it matters.
There was a problem hiding this comment.
Thanks for confirming.Keeping the pre-sizing optimization out of this PR sounds reasonable; we can revisit it separately if profiling shows the extra allocation and copy are significant.
There was a problem hiding this comment.
in agoda we use this version
int lenIdx = buf.writerIndex(); - remember current index
buf.writeInt(0); - write 0
int written = ByteBufUtil.writeUtf8(buf, s); - write all data
buf.setInt(lenIdx, written); - update specific offset with actual size
in this case you don't need to traverse string byte[] twice to calculate how much data you will write
original code
buf.writeInt(ByteBufUtil.utf8Bytes(s)); - read string array and calculate required amount of bytes for each character (utf-8 have variable size for each)
ByteBufUtil.writeUtf8(buf, s); - write data
this double size calculation can be reason of increased time for encodeString (256)
There was a problem hiding this comment.
Thanks for the suggestions!
Done.
|
|
||
| // Malformed inputs: UTF-8 encoding is lossy for unpaired surrogates (replaced with '?'), | ||
| // so these do not round-trip, but the encoded bytes must still match getBytes(UTF_8). | ||
| private static final String[] LOSSY_STRINGS = {"abc\uD800", "܀abc", "a\uD800\uD800b"}; |
There was a problem hiding this comment.
unpaired-surrogate strings can't occur on Celeborn's wire: the fields encoded here are shuffleKey, partitionUniqueId, and errorString. So the Netty truncation is unreachable in practice -- and even if it did occur, utf8Bytes/writeUtf8 agree with each other, so framing stays intact.
There was a problem hiding this comment.
I understand your point. If these inputs are truly out of scope, some of these may not be necessary.
|
@yew1eb I can’t resolve comment threads right now due to a network issue. If you’ve verified the change, feel free to resolve it on your side. |
| int length = buf.readInt(); | ||
| byte[] bytes = new byte[length]; | ||
| buf.readBytes(bytes); | ||
| return new String(bytes, StandardCharsets.UTF_8); |
There was a problem hiding this comment.
also can be update to remove unnecessary allocation
int length = buf.readInt();
// in java 8 it implemented as
// this.value = StringCoding.decode(charset, bytes, offset, length);
// so we still have new decoded allocation inside StringCoding
// can cache decode in ThreadLocal, but it additional complexity
// in fresh java it reuses decoder instance and skips decoder allocation
// just wait new version, current implementation is good enough for now
String s = buf.toString(buf.readerIndex(), length, StandardCharsets.UTF_8);
buf.skipBytes(length);
return s;
There was a problem hiding this comment.
tracked as a separate issue CELEBORN-2393 to keep this PR focused on the encode path.
…oid double traversal Adopt reviewer suggestion: write a zero length slot, writeUtf8, then setInt the actual byte length. Avoids a second full-string utf8Bytes(s) traversal in the encode hot path; encodedLength still uses utf8Bytes(s) for framing.
0cd388a to
e3fe01b
Compare
What changes were proposed in this pull request?
Replace
s.getBytes(StandardCharsets.UTF_8)inEncoders.Strings#encodedLengthand#encodewith Netty'sByteBufUtil.utf8Bytes/ByteBufUtil.writeUtf8, eliminating the intermediatebyte[]allocated for every encoded string.In
#encode, write a zero length slot first, thenByteBufUtil.writeUtf8, then backfill the actual byte length viasetInt. This avoids traversing the string twice — once forutf8Bytes(s)to size the length field, again insidewriteUtf8— as suggested by @xhumanoid in the review.#encodedLengthstill usesutf8Bytes(s), since message framing needs the exact total length up front to size the header buffer.Also includes:
EncodersSuiteJ: round-trip tests (ASCII / Chinese / emoji) plus byte-for-byte wire-compatibility tests against the previousgetBytes(UTF_8)encoding, covering UTF-8 edge cases (NUL/control chars, 2/3-byte boundaries, BMP max, and lossy unpaired surrogates)EncodersJmhBenchmark: the JMH benchmark used for the measurements below, following the existingSlotsAllocatorJmhBenchmarkpattern (test-scopejmh-core/jmh-generator-annprocessincommon/pom.xmlandproject/CelebornBuild.scala)MessageEncoder,SslMessageEncoderandMessage#toByteBuffer(see below)Note on the assertion adjustments.
ByteBufUtil.writeUtf8reservesutf8MaxBytes(up to 3 bytes per char) of capacity before writing, which may grow the target buffer beyond the exact encoded length. The three encoding paths allocate the header buffer with exact capacity and assertedbuf.writableBytes() == 0after encoding; that assert fired under-eain tests even though exactlyencodedLength()bytes were written, because capacity had grown. The asserts now checkbuf.writerIndex() == expectedLength, which expresses the real invariant — bytes written byencode()equalencodedLength()— without depending on buffer capacity. The wire bytes are identical in both implementations.Why are the changes needed?
Encoders.Stringsis on the hot path of every transport message (PushData,PushMergedData,PushDataHandShake,RegionStart,RegionFinish, ...). EachgetBytes(UTF_8)call allocates a throwawaybyte[], which adds up to significant GC pressure under load.Measured with
EncodersJmhBenchmark(-prof gc, pure-ASCII strings — the only realistic case for shuffleKey/partitionUniqueId; encode paths reuse a pre-sized destination buffer to isolate the encoding cost). Allocation, before (originalgetBytes) vs after (this PR):Throughput, before (original
getBytes) vs after (this PR, including the encode backfill),-f 0 -wi 5 -i 10:The per-call
byte[]allocation is eliminated in all cases, and throughput improves for both short and long strings. The backfill in#encoderemoves the extrautf8Bytes(s)traversal from the encode path, which is what previously made the long-string (256) case regress relative to the originalgetBytes+writeBytespath; with the backfill it now improves across the board.Note: for ASCII-dominant keys,
writeUtf8reserves 3x capacity and may grow the pooled header buffer once per encode. This reallocation is recycled within Netty's arena and produces no GC garbage, unlike the eliminated per-callbyte[].Does this PR resolve a correctness bug?
No.
Does this PR introduce any user-facing change?
No.
How was this patch tested?
EncodersSuiteJ(round-trip + wire compatibility, incl. UTF-8 edge cases) — re-run after the backfill change, all cases passRpcIntegrationSuiteJ,SSLRpcIntegrationSuiteJ,AutoSSLRpcIntegrationSuite,SslMessageEncoderSuiteJ,RegistrationSuiteJEncodersJmhBenchmarkreproduced with: