From 2cea0550abfc16596dad9a8ee9599511c276c0ba Mon Sep 17 00:00:00 2001 From: Nikolay Izhikov Date: Thu, 27 Aug 2026 17:55:22 +0300 Subject: [PATCH 1/9] IGNITE-27088 BinaryWriter should use internal String#value --- .../binary/JmhBinaryStringWriteBenchmark.java | 177 +++++++ .../internal/binary/BinaryWriterExImpl.java | 24 +- .../ignite/internal/binary/StringWriter.java | 454 ++++++++++++++++++ .../ignite/IgniteCommonsSystemProperties.java | 9 + .../direct/stream/DirectByteBufferStream.java | 9 +- .../internal/binary/StringWriterSelfTest.java | 208 ++++++++ .../streams/BinaryStreamsTestUtils.java | 8 + .../IgniteBinaryObjectsTestSuite.java | 2 + 8 files changed, 874 insertions(+), 17 deletions(-) create mode 100644 modules/benchmarks/src/main/java/org/apache/ignite/internal/benchmarks/jmh/binary/JmhBinaryStringWriteBenchmark.java create mode 100644 modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/StringWriter.java create mode 100644 modules/core/src/test/java/org/apache/ignite/internal/binary/StringWriterSelfTest.java diff --git a/modules/benchmarks/src/main/java/org/apache/ignite/internal/benchmarks/jmh/binary/JmhBinaryStringWriteBenchmark.java b/modules/benchmarks/src/main/java/org/apache/ignite/internal/benchmarks/jmh/binary/JmhBinaryStringWriteBenchmark.java new file mode 100644 index 0000000000000..6087226381c1d --- /dev/null +++ b/modules/benchmarks/src/main/java/org/apache/ignite/internal/benchmarks/jmh/binary/JmhBinaryStringWriteBenchmark.java @@ -0,0 +1,177 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.ignite.internal.benchmarks.jmh.binary; + +import org.apache.ignite.internal.benchmarks.jmh.runner.JmhIdeBenchmarkRunner; +import org.apache.ignite.internal.binary.StringWriter; +import org.apache.ignite.internal.binary.streams.BinaryOutputStream; +import org.apache.ignite.internal.binary.streams.BinaryStreams; +import org.apache.ignite.internal.binary.streams.JmhBinaryStreamsFactory; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; +import org.openjdk.jmh.profile.GCProfiler; +import org.openjdk.jmh.runner.Runner; +import org.openjdk.jmh.runner.options.OptionsBuilder; + +import static java.util.concurrent.TimeUnit.NANOSECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.apache.ignite.IgniteCommonsSystemProperties.IGNITE_BINARY_STRING_ZERO_COPY; +import static org.openjdk.jmh.annotations.Mode.AverageTime; +import static org.openjdk.jmh.annotations.Scope.Thread; + +/** + * Compares zero-copy string serialization ({@link StringWriter} encoding directly into the stream buffer) with the + * legacy serialization through a temporary array produced by {@link String#getBytes}. The {@code zeroCopy} parameter + * selects the implementation via the {@code IGNITE_BINARY_STRING_ZERO_COPY} system property, which is set before the + * first use of {@link StringWriter} in each forked JVM. + */ +@State(Thread) +@OutputTimeUnit(NANOSECONDS) +@BenchmarkMode(AverageTime) +@Warmup(iterations = 5, time = 1, timeUnit = SECONDS) +@Measurement(iterations = 5, time = 1, timeUnit = SECONDS) +public class JmhBinaryStringWriteBenchmark { + /** */ + @Param({"true", "false"}) + private boolean zeroCopy; + + /** */ + @Param({"8", "64", "512", "4096"}) + private int len; + + /** */ + @Param({"ascii", "latin1", "cyrillic", "mixed"}) + private String content; + + /** */ + @Param({"heap", "offheap"}) + private String stream; + + /** */ + private BinaryOutputStream out; + + /** */ + private String str; + + /** + * @param args Optional values of the {@code stream} parameter to run (e.g. {@code offheap}); all when empty. + */ + public static void main(String[] args) throws Exception { + OptionsBuilder builder = JmhIdeBenchmarkRunner.create() + .forks(1) + .benchmarks(JmhBinaryStringWriteBenchmark.class.getName()) + .profilers(GCProfiler.class) + .optionsBuilder(); + + if (args.length > 0) + builder.param("stream", args); + + new Runner(builder.build()).run(); + } + + /** */ + @Setup + public void setup() { + // Must be set before the first use of StringWriter in this JVM: the flag is read on class initialization. + System.setProperty(IGNITE_BINARY_STRING_ZERO_COPY, String.valueOf(zeroCopy)); + + StringBuilder sb = new StringBuilder(len); + + for (int i = 0; sb.length() < len; i++) { + switch (content) { + case "ascii": + sb.append((char)('a' + i % 26)); + + break; + + case "latin1": + // Every 8th char is a Latin-1 char with the sign bit set. + sb.append(i % 8 == 7 ? (char)(0xC0 + i % 0x20) : (char)('a' + i % 26)); + + break; + + case "cyrillic": + sb.append((char)('\u0410' + i % 32)); + + break; + + case "mixed": + // ASCII, Latin-1, 2-byte, 3-byte chars and a surrogate pair. + switch (i % 5) { + case 0: + sb.append((char)('a' + i % 26)); + + break; + + case 1: + sb.append('\u00e9'); + + break; + + case 2: + sb.append('\u0416'); + + break; + + case 3: + sb.append('\u20ac'); + + break; + + default: + sb.append("\ud83d\ude00"); + } + + break; + + default: + throw new IllegalArgumentException("Unknown content type: " + content); + } + } + + str = sb.toString(); + + out = "offheap".equals(stream) + ? JmhBinaryStreamsFactory.offheapOutputStream(4 * len + 64) + : BinaryStreams.outputStream(4 * len + 64); + } + + /** */ + @TearDown + public void tearDown() { + out.close(); + } + + /** */ + @Benchmark + public void writeString(Blackhole bh) { + out.position(0); + + StringWriter.write(str, out); + + bh.consume(out.position()); + } +} diff --git a/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/BinaryWriterExImpl.java b/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/BinaryWriterExImpl.java index 033be969abcf7..0098bd38d8edd 100644 --- a/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/BinaryWriterExImpl.java +++ b/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/BinaryWriterExImpl.java @@ -30,6 +30,7 @@ import java.util.Map; import java.util.UUID; import org.apache.ignite.IgniteCheckedException; +import org.apache.ignite.IgniteCommonsSystemProperties; import org.apache.ignite.binary.BinaryObjectException; import org.apache.ignite.binary.BinaryRawWriter; import org.apache.ignite.internal.UnregisteredClassException; @@ -40,13 +41,16 @@ import org.apache.ignite.marshaller.Marshallers; import org.jetbrains.annotations.Nullable; -import static java.nio.charset.StandardCharsets.UTF_8; +import static org.apache.ignite.IgniteCommonsSystemProperties.IGNITE_BINARY_STRING_ZERO_COPY; import static org.apache.ignite.internal.util.CommonUtils.MAX_ARRAY_SIZE; /** * Binary writer implementation. */ class BinaryWriterExImpl implements BinaryWriterEx { + /** Zero-copy serialization enabled flag. */ + static final boolean ZERO_COPY = IgniteCommonsSystemProperties.getBoolean(IGNITE_BINARY_STRING_ZERO_COPY, true); + /** Length: integer. */ private static final int LEN_INT = 4; @@ -733,20 +737,10 @@ void writeBooleanField(@Nullable Boolean val) { @Override public void writeString(@Nullable String val) throws BinaryObjectException { if (val == null) out.writeByte(GridBinaryMarshaller.NULL); - else { - byte[] strArr; - - if (BinaryUtils.USE_STR_SERIALIZATION_VER_2) - strArr = BinaryUtils.strToUtf8Bytes(val); - else - strArr = val.getBytes(UTF_8); - - out.unsafeEnsure(1 + 4); - out.unsafeWriteByte(GridBinaryMarshaller.STRING); - out.unsafeWriteInt(strArr.length); - - out.writeByteArray(strArr); - } + else if (ZERO_COPY) + StringWriter.write(val, out); + else + StringWriter.writeWithTemporaryArray(val, out); } /** {@inheritDoc} */ diff --git a/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/StringWriter.java b/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/StringWriter.java new file mode 100644 index 0000000000000..91a1de2af0325 --- /dev/null +++ b/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/StringWriter.java @@ -0,0 +1,454 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.ignite.internal.binary; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import org.apache.ignite.IgniteCommonsSystemProperties; +import org.apache.ignite.internal.binary.streams.BinaryOutputStream; +import org.apache.ignite.internal.util.GridUnsafe; +import org.jetbrains.annotations.NotNull; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.apache.ignite.internal.binary.BinaryWriterExImpl.ZERO_COPY; + +/** + * Writes {@link String} values to a {@link BinaryOutputStream} in UTF-8 without allocation of temporary byte arrays: + * UTF-8 bytes are encoded directly into the stream buffer after a reserved length slot which is patched afterwards. + *

+ * On JDKs with compact strings (9+) an additional fast path is used: for Latin-1 strings the internal {@code byte[]} + * value of the string is encoded without per-char conversion, and for pure ASCII strings it is copied into the stream + * as-is, since the UTF-8 representation is identical to the internal one. + *

+ * The produced bytes are identical to serialization of {@code val.getBytes(UTF_8)}, including replacement of + * malformed surrogates with {@code '?'}. Zero-copy serialization can be disabled with the + * {@link IgniteCommonsSystemProperties#IGNITE_BINARY_STRING_ZERO_COPY} system property. + */ +public final class StringWriter { + /** Latin-1 value of the {@code java.lang.String#coder} field. */ + private static final byte LATIN1 = 0; + + /** Mask to test 8 bytes for a set sign bit at once. */ + private static final long NEGATIVE_BYTES_MSK = 0x8080808080808080L; + + /** Offset of the {@code java.lang.String#value} field, or {@code -1} if the compact string fast path is unavailable. */ + private static final long STR_VALUE_OFF; + + /** Offset of the {@code java.lang.String#coder} field, or {@code -1} if the compact string fast path is unavailable. */ + private static final long STR_CODER_OFF; + + /** + * Handle of the intrinsified {@code java.lang.StringCoding#hasNegatives}, or {@code null} if unavailable. + * The intrinsic scans the array with SIMD instructions, far faster than any scalar loop. + */ + private static final MethodHandle HAS_NEGATIVES; + + static { + long valOff = -1; + long coderOff = -1; + + MethodHandle hasNegatives = null; + + if (ZERO_COPY) { + try { + Method mtd = Class.forName("java.lang.StringCoding") + .getDeclaredMethod("hasNegatives", byte[].class, int.class, int.class); + + // Requires '--add-opens=java.base/java.lang=ALL-UNNAMED' which Ignite scripts pass by default. + mtd.setAccessible(true); + + MethodHandle candidate = MethodHandles.lookup().unreflect(mtd); + + if (!(boolean)candidate.invokeExact(new byte[] {1, 2, 3}, 0, 3) + && (boolean)candidate.invokeExact(new byte[] {1, -2, 3}, 0, 3)) + hasNegatives = candidate; + } + catch (Throwable ignored) { + // The scalar implementation will be used. + } + } + + HAS_NEGATIVES = hasNegatives; + + if (ZERO_COPY) { + try { + Field valField = String.class.getDeclaredField("value"); + Field coderField = String.class.getDeclaredField("coder"); + + // On JDK 8 the value field is a char[], only the generic encoder can be used. + if (valField.getType() == byte[].class && coderField.getType() == byte.class) { + valOff = GridUnsafe.objectFieldOffset(valField); + coderOff = GridUnsafe.objectFieldOffset(coderField); + + if (!probe(valOff, coderOff)) { + valOff = -1; + coderOff = -1; + } + } + } + catch (Throwable ignored) { + valOff = -1; + coderOff = -1; + } + } + + STR_VALUE_OFF = valOff; + STR_CODER_OFF = coderOff; + } + + /** */ + private StringWriter() { + // No-op. + } + + /** + * Writes a string to the output stream as a {@link GridBinaryMarshaller#STRING} flag followed by UTF-8 length + * (int) and UTF-8 bytes. + * + * @param val Value. + * @param out Output stream. + */ + public static void write(@NotNull String val, BinaryOutputStream out) { + int len = val.length(); + + // Worst case is 3 bytes per char: a surrogate pair (2 chars) produces 4 bytes, a lone surrogate 1 byte. + // 1 byte for `GridBinaryMarshaller.STRING` and integer (4 bytes) for length. + int worstLen = 1 + 4 + 3 * len; + + if (worstLen + out.position() > Integer.MAX_VALUE) { + writeWithTemporaryArray(val, out); + + return; + } + + out.unsafeEnsure(worstLen); + + out.unsafeWriteByte(GridBinaryMarshaller.STRING); + + int lenPos = out.position(); + + out.unsafePosition(lenPos + 4); + + byte[] latin1 = latin1Value(val); + + int writtenBytes; + + if (out.hasArray()) { + // Encode into the backing array directly: plain indexed writes are much faster than + // per-byte virtual calls through the stream interface. + int start = lenPos + 4; + + int end = latin1 != null + ? encodeLatin1(latin1, out.array(), start) + : encodeChars(val, out.array(), start); + + writtenBytes = end - start; + + out.unsafePosition(end); + } + else { + writtenBytes = latin1 != null + ? writeLatin1(latin1, out) + : writeChars(val, out); + } + + out.unsafeWriteInt(lenPos, writtenBytes); + } + + /** + * Checks that the internal layout of {@link String} behaves as the compact string fast path expects. + * + * @param valOff Offset of the {@code value} field. + * @param coderOff Offset of the {@code coder} field. + * @return {@code True} if the fast path can be used. + */ + private static boolean probe(long valOff, long coderOff) { + String probe = "Ignite\u00e9"; + + // Compact strings can be disabled with -XX:-CompactStrings, then all strings are UTF-16 encoded. + if (GridUnsafe.getByteField(probe, coderOff) != LATIN1) + return false; + + Object val = GridUnsafe.getObjectField(probe, valOff); + + if (!(val instanceof byte[])) + return false; + + byte[] arr = (byte[])val; + + if (arr.length != probe.length()) + return false; + + for (int i = 0; i < arr.length; i++) { + if ((arr[i] & 0xFF) != probe.charAt(i)) + return false; + } + + return true; + } + + /** + * @param val String. + * @return Internal Latin-1 array of the string, or {@code null} if the string is UTF-16 encoded or the internal + * layout of {@link String} is unknown. + */ + public static byte[] latin1Value(String val) { + if (STR_VALUE_OFF < 0 || GridUnsafe.getByteField(val, STR_CODER_OFF) != LATIN1) + return null; + + return (byte[])GridUnsafe.getObjectField(val, STR_VALUE_OFF); + } + + /** + * Writes a Latin-1 encoded string value to the stream. Stream capacity must be ensured by the caller. + * + * @param val Internal Latin-1 array of the string. + * @param out Output stream. + * @return Number of bytes written. + */ + private static int writeLatin1(byte[] val, BinaryOutputStream out) { + if (!hasNegatives(val)) { + // Pure ASCII: UTF-8 representation matches the internal array, copy it as-is. + out.write(val, 0, val.length); + + return val.length; + } + + int utfLen = 0; + + for (int i = 0; i < val.length; i++) { + byte b = val[i]; + + if (b >= 0) { + out.unsafeWriteByte(b); + + utfLen++; + } + else { + int c = b & 0xFF; + + out.unsafeWriteByte((byte)(0xC0 | (c >> 6))); + out.unsafeWriteByte((byte)(0x80 | (c & 0x3F))); + + utfLen += 2; + } + } + + return utfLen; + } + + /** + * Writes string chars UTF-8 encoded to the stream. Replicates {@code String#getBytes(UTF_8)} behavior exactly, + * including replacement of malformed surrogates with {@code '?'}. Stream capacity must be ensured by the caller. + * + * @param val Value. + * @param out Output stream. + * @return Number of bytes written. + */ + private static int writeChars(String val, BinaryOutputStream out) { + int len = val.length(); + int utfLen = 0; + + for (int i = 0; i < len; i++) { + char c = val.charAt(i); + + if (c < 0x80) { + out.unsafeWriteByte((byte)c); + + utfLen++; + } + else if (c < 0x800) { + out.unsafeWriteByte((byte)(0xC0 | (c >> 6))); + out.unsafeWriteByte((byte)(0x80 | (c & 0x3F))); + + utfLen += 2; + } + else if (Character.isSurrogate(c)) { + char c2; + + if (Character.isHighSurrogate(c) && i + 1 < len && Character.isLowSurrogate(c2 = val.charAt(i + 1))) { + int cp = Character.toCodePoint(c, c2); + + out.unsafeWriteByte((byte)(0xF0 | (cp >> 18))); + out.unsafeWriteByte((byte)(0x80 | ((cp >> 12) & 0x3F))); + out.unsafeWriteByte((byte)(0x80 | ((cp >> 6) & 0x3F))); + out.unsafeWriteByte((byte)(0x80 | (cp & 0x3F))); + + utfLen += 4; + i++; + } + else { + out.unsafeWriteByte((byte)'?'); + + utfLen++; + } + } + else { + out.unsafeWriteByte((byte)(0xE0 | (c >> 12))); + out.unsafeWriteByte((byte)(0x80 | ((c >> 6) & 0x3F))); + out.unsafeWriteByte((byte)(0x80 | (c & 0x3F))); + + utfLen += 3; + } + } + + return utfLen; + } + + /** + * @param arr Array. + * @return {@code True} if the array contains a byte with the sign bit set. + */ + private static boolean hasNegatives(byte[] arr) { + if (HAS_NEGATIVES != null) { + try { + return (boolean)HAS_NEGATIVES.invokeExact(arr, 0, arr.length); + } + catch (Throwable ignored) { + // TODO LT log here. + // Fall through to the generic implementation. + } + } + + // 8-byte strides with an early exit: measured on par with a branch-free loop for clean arrays + // (the exit branch is never taken there, and neither variant is vectorized by the JIT) + // and far faster when a negative byte occurs early. + int i = 0; + + for (int lim = arr.length - Long.BYTES; i <= lim; i += Long.BYTES) { + if ((GridUnsafe.getLong(arr, GridUnsafe.BYTE_ARR_OFF + i) & NEGATIVE_BYTES_MSK) != 0) + return true; + } + + for (; i < arr.length; i++) { + if (arr[i] < 0) + return true; + } + + return false; + } + + /** + * Encodes a Latin-1 string value to the buffer as UTF-8. Buffer capacity must be ensured by the caller. + * + * @param val Internal Latin-1 array of the string. + * @param buf Buffer. + * @param pos Buffer position to encode to. + * @return Buffer position after the last encoded byte. + */ + private static int encodeLatin1(byte[] val, byte[] buf, int pos) { + if (!hasNegatives(val)) { + // Pure ASCII: UTF-8 representation matches the internal array, copy it as-is. + System.arraycopy(val, 0, buf, pos, val.length); + + return pos + val.length; + } + + // Unsafe writes skip the array bounds checks: capacity is ensured by the caller. + long off = GridUnsafe.BYTE_ARR_OFF + pos; + + for (int i = 0; i < val.length; i++) { + byte b = val[i]; + + if (b >= 0) + GridUnsafe.putByte(buf, off++, b); + else { + int c = b & 0xFF; + + GridUnsafe.putByte(buf, off++, (byte)(0xC0 | (c >> 6))); + GridUnsafe.putByte(buf, off++, (byte)(0x80 | (c & 0x3F))); + } + } + + return (int)(off - GridUnsafe.BYTE_ARR_OFF); + } + + /** + * Encodes string chars to the buffer as UTF-8. Replicates {@code String#getBytes(UTF_8)} behavior exactly, + * including replacement of malformed surrogates with {@code '?'}. Buffer capacity must be ensured by the caller. + * + * @param val Value. + * @param buf Buffer. + * @param pos Buffer position to encode to. + * @return Buffer position after the last encoded byte. + */ + private static int encodeChars(String val, byte[] buf, int pos) { + int len = val.length(); + + // Unsafe writes skip the array bounds checks: capacity is ensured by the caller. + long off = GridUnsafe.BYTE_ARR_OFF + pos; + + for (int i = 0; i < len; i++) { + char c = val.charAt(i); + + if (c < 0x80) + GridUnsafe.putByte(buf, off++, (byte)c); + else if (c < 0x800) { + GridUnsafe.putByte(buf, off++, (byte)(0xC0 | (c >> 6))); + GridUnsafe.putByte(buf, off++, (byte)(0x80 | (c & 0x3F))); + } + else if (Character.isSurrogate(c)) { + char c2; + + if (Character.isHighSurrogate(c) && i + 1 < len && Character.isLowSurrogate(c2 = val.charAt(i + 1))) { + int cp = Character.toCodePoint(c, c2); + + GridUnsafe.putByte(buf, off++, (byte)(0xF0 | (cp >> 18))); + GridUnsafe.putByte(buf, off++, (byte)(0x80 | ((cp >> 12) & 0x3F))); + GridUnsafe.putByte(buf, off++, (byte)(0x80 | ((cp >> 6) & 0x3F))); + GridUnsafe.putByte(buf, off++, (byte)(0x80 | (cp & 0x3F))); + + i++; + } + else + GridUnsafe.putByte(buf, off++, (byte)'?'); + } + else { + GridUnsafe.putByte(buf, off++, (byte)(0xE0 | (c >> 12))); + GridUnsafe.putByte(buf, off++, (byte)(0x80 | ((c >> 6) & 0x3F))); + GridUnsafe.putByte(buf, off++, (byte)(0x80 | (c & 0x3F))); + } + } + + return (int)(off - GridUnsafe.BYTE_ARR_OFF); + } + + /** + * Writes a string through a temporary UTF-8 byte array. + * + * @param val Value. + * @param out Output stream. + */ + static void writeWithTemporaryArray(String val, BinaryOutputStream out) { + byte[] strArr; + + if (BinaryUtils.USE_STR_SERIALIZATION_VER_2) + strArr = BinaryUtils.strToUtf8Bytes(val); + else + strArr = val.getBytes(UTF_8); + + // 1 byte for `GridBinaryMarshaller.STRING` and integer (4 bytes) for length. + out.unsafeEnsure(1 + 4); + out.unsafeWriteByte(GridBinaryMarshaller.STRING); + out.unsafeWriteInt(strArr.length); + + out.writeByteArray(strArr); + } +} diff --git a/modules/commons/src/main/java/org/apache/ignite/IgniteCommonsSystemProperties.java b/modules/commons/src/main/java/org/apache/ignite/IgniteCommonsSystemProperties.java index ec443398ce3e4..450e9d8636eb7 100644 --- a/modules/commons/src/main/java/org/apache/ignite/IgniteCommonsSystemProperties.java +++ b/modules/commons/src/main/java/org/apache/ignite/IgniteCommonsSystemProperties.java @@ -128,6 +128,15 @@ public class IgniteCommonsSystemProperties { public static final String IGNITE_BINARY_MARSHALLER_USE_STRING_SERIALIZATION_VER_2 = "IGNITE_BINARY_MARSHALLER_USE_STRING_SERIALIZATION_VER_2"; + /** + * When set to {@code false}, disables zero-copy UTF-8 serialization of {@link String} values by BinaryMarshaller, + * reverting to serialization through a temporary byte array produced by {@link String#getBytes}. + * Default value is {@code true}. + */ + @SystemProperty(value = "Enables zero-copy UTF-8 serialization of String values by BinaryMarshaller. " + + "When set to false, strings are serialized through a temporary byte array", defaults = "true") + public static final String IGNITE_BINARY_STRING_ZERO_COPY = "IGNITE_BINARY_STRING_ZERO_COPY"; + /** * Enables storage of typed arrays. * The default value is {@code BinaryUtils#DFLT_IGNITE_USE_BINARY_ARRAYS}. diff --git a/modules/core/src/main/java/org/apache/ignite/internal/direct/stream/DirectByteBufferStream.java b/modules/core/src/main/java/org/apache/ignite/internal/direct/stream/DirectByteBufferStream.java index c0752343b5441..21f418e8e373f 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/direct/stream/DirectByteBufferStream.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/direct/stream/DirectByteBufferStream.java @@ -33,6 +33,7 @@ import java.util.function.Supplier; import org.apache.ignite.IgniteCheckedException; import org.apache.ignite.IgniteException; +import org.apache.ignite.internal.binary.StringWriter; import org.apache.ignite.internal.managers.communication.CompressedMessage; import org.apache.ignite.internal.processors.affinity.AffinityTopologyVersion; import org.apache.ignite.internal.processors.cache.CacheObject; @@ -733,8 +734,12 @@ public void writeBooleanArray(boolean[] val) { */ public void writeString(String val) { if (val != null) { - if (curStrBackingArr == null) - curStrBackingArr = val.getBytes(); + if (curStrBackingArr == null) { + curStrBackingArr = StringWriter.latin1Value(val); + + if (curStrBackingArr == null) + val.getBytes(); + } writeByteArray(curStrBackingArr); diff --git a/modules/core/src/test/java/org/apache/ignite/internal/binary/StringWriterSelfTest.java b/modules/core/src/test/java/org/apache/ignite/internal/binary/StringWriterSelfTest.java new file mode 100644 index 0000000000000..be203bbce1af5 --- /dev/null +++ b/modules/core/src/test/java/org/apache/ignite/internal/binary/StringWriterSelfTest.java @@ -0,0 +1,208 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.ignite.internal.binary; + +import java.util.Arrays; +import java.util.Random; +import org.apache.ignite.internal.binary.streams.BinaryOutputStream; +import org.apache.ignite.internal.binary.streams.BinaryStreams; +import org.apache.ignite.internal.binary.streams.BinaryStreamsTestUtils; +import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; +import org.junit.Test; + +import static java.nio.charset.StandardCharsets.UTF_8; + +/** + * Tests that {@link StringWriter} output is byte-identical to serialization of the {@link String#getBytes} result, + * which was used before zero-copy string serialization was introduced. + */ +public class StringWriterSelfTest extends GridCommonAbstractTest { + /** Edge cases for all encoder paths: ASCII bulk copy, Latin-1, generic UTF-16 and malformed surrogates. */ + private static final String[] CORPUS = { + "", + "a", + "?", + "abcdefghijklmnopqrstuvwxyz0123456789", // Long ASCII: exercises the 8-byte stride scan and bulk copy. + "caf\u00e9", // Latin-1 with a negative byte. + "\u00ff\u0080\u00a0", // Latin-1, negative bytes only. + "\u041f\u0440\u0438\u0432\u0435\u0442", // Cyrillic: 2-byte UTF-8 sequences. + "\u0800\u1234\uffff", // 3-byte UTF-8 sequences. + "\ud83d\ude00", // Emoji: valid surrogate pair. + "a\ud83d\ude00b\u00e9\u0416\u0001", // Mixed content. + "\ud800", // Lone high surrogate. + "\udc00", // Lone low surrogate. + "a\ud800", // High surrogate at the end. + "\ud800a", // High surrogate followed by a regular char. + "\ud800\ud800", // Two high surrogates. + "\udc00\ud800", // Low surrogate before a high one. + "\u0000", // NUL char. + "nul\u0000nul" + }; + + /** + * Tests corpus of edge case strings. + */ + @Test + public void testCorpus() { + for (String str : CORPUS) + check(str); + } + + /** + * Randomized differential test against {@link String#getBytes}. + */ + @Test + public void testRandomStrings() { + Random rnd = new Random(4242); + + for (int i = 0; i < 5_000; i++) { + int len = rnd.nextInt(65); + + StringBuilder sb = new StringBuilder(len); + + for (int j = 0; j < len; j++) { + int bucket = rnd.nextInt(100); + + char c; + + if (bucket < 40) + c = (char)rnd.nextInt(0x80); // ASCII. + else if (bucket < 55) + c = (char)(0x80 + rnd.nextInt(0x100 - 0x80)); // Latin-1. + else if (bucket < 65) + c = (char)(0x100 + rnd.nextInt(0x800 - 0x100)); // Other 2-byte chars. + else if (bucket < 75) + c = (char)(0x800 + rnd.nextInt(0xD800 - 0x800)); // 3-byte chars. + else if (bucket < 90) + c = (char)(0xD800 + rnd.nextInt(0xE000 - 0xD800)); // Surrogates, mostly malformed. + else + c = (char)(0xE000 + rnd.nextInt(0x10000 - 0xE000)); // 3-byte chars above the surrogate range. + + sb.append(c); + } + + check(sb.toString()); + } + } + + /** + * Tests that the stream position is correct after a string write, so surrounding values are not corrupted. + */ + @Test + public void testStreamPosition() { + // Small initial capacity to exercise buffer reallocation. + try (BinaryOutputStream out = BinaryStreams.outputStream(2)) { + out.writeInt(0xDEADBEEF); + + StringWriter.write("caf\u00e9", out); + StringWriter.write("\ud83d\ude00", out); + + out.writeInt(0xCAFEBABE); + + byte[] exp = concat( + intLE(0xDEADBEEF), + strBytes("caf\u00e9"), + strBytes("\ud83d\ude00"), + intLE(0xCAFEBABE)); + + assertTrue(Arrays.equals(exp, out.arrayCopy())); + } + } + + /** + * Checks that serialized form of the given string is byte-identical to serialization of + * the {@link String#getBytes} result. + * + * @param str String to check. + */ + private void check(String str) { + byte[] exp = strBytes(str); + + try (BinaryOutputStream out = BinaryStreams.outputStream(1)) { + StringWriter.write(str, out); + + assertSerialized(str, exp, out.arrayCopy()); + } + + try (BinaryOutputStream out = BinaryStreamsTestUtils.offheapOutputStream(1)) { + StringWriter.write(str, out); + + assertSerialized(str, exp, out.arrayCopy()); + } + } + + /** + * @param str Source string. + * @param exp Expected serialized form. + * @param act Actual serialized form. + */ + private void assertSerialized(String str, byte[] exp, byte[] act) { + if (!Arrays.equals(exp, act)) { + fail("String serialization mismatch [str=" + Arrays.toString(str.toCharArray()) + + ", exp=" + Arrays.toString(exp) + ", act=" + Arrays.toString(act) + ']'); + } + } + + /** + * @param str String. + * @return Expected serialized form of the string: flag, UTF-8 length and UTF-8 bytes. + */ + private static byte[] strBytes(String str) { + byte[] utf8 = str.getBytes(UTF_8); + + byte[] res = new byte[5 + utf8.length]; + + res[0] = GridBinaryMarshaller.STRING; + + System.arraycopy(intLE(utf8.length), 0, res, 1, 4); + System.arraycopy(utf8, 0, res, 5, utf8.length); + + return res; + } + + /** + * @param val Value. + * @return Little-endian representation of the value. + */ + private static byte[] intLE(int val) { + return new byte[] {(byte)val, (byte)(val >> 8), (byte)(val >> 16), (byte)(val >> 24)}; + } + + /** + * @param arrs Arrays. + * @return Concatenated arrays. + */ + private static byte[] concat(byte[]... arrs) { + int len = 0; + + for (byte[] arr : arrs) + len += arr.length; + + byte[] res = new byte[len]; + + int pos = 0; + + for (byte[] arr : arrs) { + System.arraycopy(arr, 0, res, pos, arr.length); + + pos += arr.length; + } + + return res; + } +} diff --git a/modules/core/src/test/java/org/apache/ignite/internal/binary/streams/BinaryStreamsTestUtils.java b/modules/core/src/test/java/org/apache/ignite/internal/binary/streams/BinaryStreamsTestUtils.java index 4e6ae07254679..f8d16d30dacaa 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/binary/streams/BinaryStreamsTestUtils.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/binary/streams/BinaryStreamsTestUtils.java @@ -29,4 +29,12 @@ public class BinaryStreamsTestUtils { public static boolean threadLocalIsAcquired() { return THREAD_LOCAL.isAcquired(); } + + /** + * @param cap Initial capacity. + * @return Offheap output stream. + */ + public static BinaryOutputStream offheapOutputStream(int cap) { + return new BinaryOffheapOutputStream(cap); + } } diff --git a/modules/core/src/test/java/org/apache/ignite/testsuites/IgniteBinaryObjectsTestSuite.java b/modules/core/src/test/java/org/apache/ignite/testsuites/IgniteBinaryObjectsTestSuite.java index 69b639df6b1f9..bdc9230a585c8 100644 --- a/modules/core/src/test/java/org/apache/ignite/testsuites/IgniteBinaryObjectsTestSuite.java +++ b/modules/core/src/test/java/org/apache/ignite/testsuites/IgniteBinaryObjectsTestSuite.java @@ -46,6 +46,7 @@ import org.apache.ignite.internal.binary.GridDefaultBinaryMappersBinaryMetaDataSelfTest; import org.apache.ignite.internal.binary.GridSimpleLowerCaseBinaryMappersBinaryMetaDataSelfTest; import org.apache.ignite.internal.binary.RawBinaryObjectExtractorTest; +import org.apache.ignite.internal.binary.StringWriterSelfTest; import org.apache.ignite.internal.binary.builder.BinaryObjectBuilderAdditionalSelfTest; import org.apache.ignite.internal.binary.noncompact.BinaryFieldsHeapNonCompactSelfTest; import org.apache.ignite.internal.binary.noncompact.BinaryFieldsOffheapNonCompactSelfTest; @@ -107,6 +108,7 @@ BinaryTreeSelfTest.class, BinaryMarshallerSelfTest.class, + StringWriterSelfTest.class, BinaryObjectExceptionSelfTest.class, BinarySerialiedFieldComparatorSelfTest.class, From b6feacc14b6edec69d2eef4edba9927c49593b71 Mon Sep 17 00:00:00 2001 From: Nikolay Izhikov Date: Thu, 27 Aug 2026 18:01:45 +0300 Subject: [PATCH 2/9] IGNITE-27088 BinaryWriter should use internal String#value --- .../jmh/binary/JmhHasNegativesBenchmark.java | 143 ++++++++++++++++++ .../streams/JmhBinaryStreamsFactory.java | 31 ++++ 2 files changed, 174 insertions(+) create mode 100644 modules/benchmarks/src/main/java/org/apache/ignite/internal/benchmarks/jmh/binary/JmhHasNegativesBenchmark.java create mode 100644 modules/benchmarks/src/main/java/org/apache/ignite/internal/binary/streams/JmhBinaryStreamsFactory.java diff --git a/modules/benchmarks/src/main/java/org/apache/ignite/internal/benchmarks/jmh/binary/JmhHasNegativesBenchmark.java b/modules/benchmarks/src/main/java/org/apache/ignite/internal/benchmarks/jmh/binary/JmhHasNegativesBenchmark.java new file mode 100644 index 0000000000000..3171f84915a05 --- /dev/null +++ b/modules/benchmarks/src/main/java/org/apache/ignite/internal/benchmarks/jmh/binary/JmhHasNegativesBenchmark.java @@ -0,0 +1,143 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.ignite.internal.benchmarks.jmh.binary; + +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.reflect.Method; +import java.util.Arrays; +import org.apache.ignite.internal.benchmarks.jmh.runner.JmhIdeBenchmarkRunner; +import org.apache.ignite.internal.util.GridUnsafe; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +import static java.util.concurrent.TimeUnit.NANOSECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.openjdk.jmh.annotations.Mode.AverageTime; +import static org.openjdk.jmh.annotations.Scope.Thread; + +/** + * Compares implementations of a "byte array has a negative byte" scan: a branch-free 8-byte stride loop, + * the same loop with an early exit, and the intrinsified {@code java.lang.StringCoding#hasNegatives}. + */ +@State(Thread) +@OutputTimeUnit(NANOSECONDS) +@BenchmarkMode(AverageTime) +@Warmup(iterations = 3, time = 1, timeUnit = SECONDS) +@Measurement(iterations = 5, time = 1, timeUnit = SECONDS) +public class JmhHasNegativesBenchmark { + /** Mask to test 8 bytes for a set sign bit at once. */ + private static final long NEGATIVE_BYTES_MSK = 0x8080808080808080L; + + /** Handle of the intrinsified {@code java.lang.StringCoding#hasNegatives}. */ + private static final MethodHandle HAS_NEGATIVES; + + static { + try { + Method mtd = Class.forName("java.lang.StringCoding") + .getDeclaredMethod("hasNegatives", byte[].class, int.class, int.class); + + mtd.setAccessible(true); + + HAS_NEGATIVES = MethodHandles.lookup().unreflect(mtd); + } + catch (Throwable e) { + throw new ExceptionInInitializerError(e); + } + } + + /** */ + @Param({"8", "64", "512", "4096"}) + private int len; + + /** */ + @Param({"clean", "dirtyStart", "dirtyEnd"}) + private String data; + + /** */ + private byte[] arr; + + /** */ + public static void main(String[] args) throws Exception { + JmhIdeBenchmarkRunner.create() + .forks(1) + .benchmarks(JmhHasNegativesBenchmark.class.getName()) + .run(); + } + + /** */ + @Setup + public void setup() { + arr = new byte[len]; + + Arrays.fill(arr, (byte)'a'); + + if ("dirtyStart".equals(data)) + arr[0] = -1; + else if ("dirtyEnd".equals(data)) + arr[len - 1] = -1; + } + + /** */ + @Benchmark + public boolean branchFree() { + long acc = 0; + + int i = 0; + + for (int lim = arr.length - Long.BYTES; i <= lim; i += Long.BYTES) + acc |= GridUnsafe.getLong(arr, GridUnsafe.BYTE_ARR_OFF + i); + + int tail = 0; + + for (; i < arr.length; i++) + tail |= arr[i]; + + return (acc & NEGATIVE_BYTES_MSK) != 0 || tail < 0; + } + + /** */ + @Benchmark + public boolean earlyExit() { + int i = 0; + + for (int lim = arr.length - Long.BYTES; i <= lim; i += Long.BYTES) { + if ((GridUnsafe.getLong(arr, GridUnsafe.BYTE_ARR_OFF + i) & NEGATIVE_BYTES_MSK) != 0) + return true; + } + + for (; i < arr.length; i++) { + if (arr[i] < 0) + return true; + } + + return false; + } + + /** */ + @Benchmark + public boolean intrinsic() throws Throwable { + return (boolean)HAS_NEGATIVES.invokeExact(arr, 0, arr.length); + } +} diff --git a/modules/benchmarks/src/main/java/org/apache/ignite/internal/binary/streams/JmhBinaryStreamsFactory.java b/modules/benchmarks/src/main/java/org/apache/ignite/internal/binary/streams/JmhBinaryStreamsFactory.java new file mode 100644 index 0000000000000..aefbdf00cf596 --- /dev/null +++ b/modules/benchmarks/src/main/java/org/apache/ignite/internal/binary/streams/JmhBinaryStreamsFactory.java @@ -0,0 +1,31 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.ignite.internal.binary.streams; + +/** + * Exposes package-private binary streams to benchmarks. + */ +public class JmhBinaryStreamsFactory { + /** + * @param cap Initial capacity. + * @return Offheap output stream. + */ + public static BinaryOutputStream offheapOutputStream(int cap) { + return new BinaryOffheapOutputStream(cap); + } +} From 9394099219c911cf849cc64b0b59ca34db5338ab Mon Sep 17 00:00:00 2001 From: Nikolay Izhikov Date: Wed, 2 Sep 2026 13:38:19 +0300 Subject: [PATCH 3/9] IGNITE-27088 BinaryWriter should use internal String#value --- .../binary/JmhBinaryStringWriteBenchmark.java | 15 +- .../jmh/binary/JmhHasNegativesBenchmark.java | 143 ----------- .../streams/JmhBinaryStreamsFactory.java | 31 --- .../ignite/internal/binary/StringWriter.java | 182 +++++++------- .../internal/binary/StringWriterSelfTest.java | 224 +++++++++--------- 5 files changed, 216 insertions(+), 379 deletions(-) delete mode 100644 modules/benchmarks/src/main/java/org/apache/ignite/internal/benchmarks/jmh/binary/JmhHasNegativesBenchmark.java delete mode 100644 modules/benchmarks/src/main/java/org/apache/ignite/internal/binary/streams/JmhBinaryStreamsFactory.java diff --git a/modules/benchmarks/src/main/java/org/apache/ignite/internal/benchmarks/jmh/binary/JmhBinaryStringWriteBenchmark.java b/modules/benchmarks/src/main/java/org/apache/ignite/internal/benchmarks/jmh/binary/JmhBinaryStringWriteBenchmark.java index 6087226381c1d..4dfb15f7118b9 100644 --- a/modules/benchmarks/src/main/java/org/apache/ignite/internal/benchmarks/jmh/binary/JmhBinaryStringWriteBenchmark.java +++ b/modules/benchmarks/src/main/java/org/apache/ignite/internal/benchmarks/jmh/binary/JmhBinaryStringWriteBenchmark.java @@ -21,7 +21,6 @@ import org.apache.ignite.internal.binary.StringWriter; import org.apache.ignite.internal.binary.streams.BinaryOutputStream; import org.apache.ignite.internal.binary.streams.BinaryStreams; -import org.apache.ignite.internal.binary.streams.JmhBinaryStreamsFactory; import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.annotations.BenchmarkMode; import org.openjdk.jmh.annotations.Measurement; @@ -44,15 +43,15 @@ /** * Compares zero-copy string serialization ({@link StringWriter} encoding directly into the stream buffer) with the - * legacy serialization through a temporary array produced by {@link String#getBytes}. The {@code zeroCopy} parameter + * legacy serialization through a temporary array produced by {@link String#getBytes()}. The {@code zeroCopy} parameter * selects the implementation via the {@code IGNITE_BINARY_STRING_ZERO_COPY} system property, which is set before the * first use of {@link StringWriter} in each forked JVM. */ @State(Thread) @OutputTimeUnit(NANOSECONDS) @BenchmarkMode(AverageTime) -@Warmup(iterations = 5, time = 1, timeUnit = SECONDS) -@Measurement(iterations = 5, time = 1, timeUnit = SECONDS) +@Warmup(iterations = 5, time = 5, timeUnit = SECONDS) +@Measurement(iterations = 5, time = 10, timeUnit = SECONDS) public class JmhBinaryStringWriteBenchmark { /** */ @Param({"true", "false"}) @@ -66,10 +65,6 @@ public class JmhBinaryStringWriteBenchmark { @Param({"ascii", "latin1", "cyrillic", "mixed"}) private String content; - /** */ - @Param({"heap", "offheap"}) - private String stream; - /** */ private BinaryOutputStream out; @@ -154,9 +149,7 @@ public void setup() { str = sb.toString(); - out = "offheap".equals(stream) - ? JmhBinaryStreamsFactory.offheapOutputStream(4 * len + 64) - : BinaryStreams.outputStream(4 * len + 64); + out = BinaryStreams.outputStream(4 * len + 64); } /** */ diff --git a/modules/benchmarks/src/main/java/org/apache/ignite/internal/benchmarks/jmh/binary/JmhHasNegativesBenchmark.java b/modules/benchmarks/src/main/java/org/apache/ignite/internal/benchmarks/jmh/binary/JmhHasNegativesBenchmark.java deleted file mode 100644 index 3171f84915a05..0000000000000 --- a/modules/benchmarks/src/main/java/org/apache/ignite/internal/benchmarks/jmh/binary/JmhHasNegativesBenchmark.java +++ /dev/null @@ -1,143 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.apache.ignite.internal.benchmarks.jmh.binary; - -import java.lang.invoke.MethodHandle; -import java.lang.invoke.MethodHandles; -import java.lang.reflect.Method; -import java.util.Arrays; -import org.apache.ignite.internal.benchmarks.jmh.runner.JmhIdeBenchmarkRunner; -import org.apache.ignite.internal.util.GridUnsafe; -import org.openjdk.jmh.annotations.Benchmark; -import org.openjdk.jmh.annotations.BenchmarkMode; -import org.openjdk.jmh.annotations.Measurement; -import org.openjdk.jmh.annotations.OutputTimeUnit; -import org.openjdk.jmh.annotations.Param; -import org.openjdk.jmh.annotations.Setup; -import org.openjdk.jmh.annotations.State; -import org.openjdk.jmh.annotations.Warmup; - -import static java.util.concurrent.TimeUnit.NANOSECONDS; -import static java.util.concurrent.TimeUnit.SECONDS; -import static org.openjdk.jmh.annotations.Mode.AverageTime; -import static org.openjdk.jmh.annotations.Scope.Thread; - -/** - * Compares implementations of a "byte array has a negative byte" scan: a branch-free 8-byte stride loop, - * the same loop with an early exit, and the intrinsified {@code java.lang.StringCoding#hasNegatives}. - */ -@State(Thread) -@OutputTimeUnit(NANOSECONDS) -@BenchmarkMode(AverageTime) -@Warmup(iterations = 3, time = 1, timeUnit = SECONDS) -@Measurement(iterations = 5, time = 1, timeUnit = SECONDS) -public class JmhHasNegativesBenchmark { - /** Mask to test 8 bytes for a set sign bit at once. */ - private static final long NEGATIVE_BYTES_MSK = 0x8080808080808080L; - - /** Handle of the intrinsified {@code java.lang.StringCoding#hasNegatives}. */ - private static final MethodHandle HAS_NEGATIVES; - - static { - try { - Method mtd = Class.forName("java.lang.StringCoding") - .getDeclaredMethod("hasNegatives", byte[].class, int.class, int.class); - - mtd.setAccessible(true); - - HAS_NEGATIVES = MethodHandles.lookup().unreflect(mtd); - } - catch (Throwable e) { - throw new ExceptionInInitializerError(e); - } - } - - /** */ - @Param({"8", "64", "512", "4096"}) - private int len; - - /** */ - @Param({"clean", "dirtyStart", "dirtyEnd"}) - private String data; - - /** */ - private byte[] arr; - - /** */ - public static void main(String[] args) throws Exception { - JmhIdeBenchmarkRunner.create() - .forks(1) - .benchmarks(JmhHasNegativesBenchmark.class.getName()) - .run(); - } - - /** */ - @Setup - public void setup() { - arr = new byte[len]; - - Arrays.fill(arr, (byte)'a'); - - if ("dirtyStart".equals(data)) - arr[0] = -1; - else if ("dirtyEnd".equals(data)) - arr[len - 1] = -1; - } - - /** */ - @Benchmark - public boolean branchFree() { - long acc = 0; - - int i = 0; - - for (int lim = arr.length - Long.BYTES; i <= lim; i += Long.BYTES) - acc |= GridUnsafe.getLong(arr, GridUnsafe.BYTE_ARR_OFF + i); - - int tail = 0; - - for (; i < arr.length; i++) - tail |= arr[i]; - - return (acc & NEGATIVE_BYTES_MSK) != 0 || tail < 0; - } - - /** */ - @Benchmark - public boolean earlyExit() { - int i = 0; - - for (int lim = arr.length - Long.BYTES; i <= lim; i += Long.BYTES) { - if ((GridUnsafe.getLong(arr, GridUnsafe.BYTE_ARR_OFF + i) & NEGATIVE_BYTES_MSK) != 0) - return true; - } - - for (; i < arr.length; i++) { - if (arr[i] < 0) - return true; - } - - return false; - } - - /** */ - @Benchmark - public boolean intrinsic() throws Throwable { - return (boolean)HAS_NEGATIVES.invokeExact(arr, 0, arr.length); - } -} diff --git a/modules/benchmarks/src/main/java/org/apache/ignite/internal/binary/streams/JmhBinaryStreamsFactory.java b/modules/benchmarks/src/main/java/org/apache/ignite/internal/binary/streams/JmhBinaryStreamsFactory.java deleted file mode 100644 index aefbdf00cf596..0000000000000 --- a/modules/benchmarks/src/main/java/org/apache/ignite/internal/binary/streams/JmhBinaryStreamsFactory.java +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.apache.ignite.internal.binary.streams; - -/** - * Exposes package-private binary streams to benchmarks. - */ -public class JmhBinaryStreamsFactory { - /** - * @param cap Initial capacity. - * @return Offheap output stream. - */ - public static BinaryOutputStream offheapOutputStream(int cap) { - return new BinaryOffheapOutputStream(cap); - } -} diff --git a/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/StringWriter.java b/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/StringWriter.java index 91a1de2af0325..b3cd24b2c0ee7 100644 --- a/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/StringWriter.java +++ b/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/StringWriter.java @@ -46,7 +46,7 @@ public final class StringWriter { private static final byte LATIN1 = 0; /** Mask to test 8 bytes for a set sign bit at once. */ - private static final long NEGATIVE_BYTES_MSK = 0x8080808080808080L; + private static final long NEGATIVE_BYTES_MSK = 0b10000000_10000000_10000000_10000000_10000000_10000000_10000000_10000000L; /** Offset of the {@code java.lang.String#value} field, or {@code -1} if the compact string fast path is unavailable. */ private static final long STR_VALUE_OFF; @@ -61,24 +61,20 @@ public final class StringWriter { private static final MethodHandle HAS_NEGATIVES; static { - long valOff = -1; - long coderOff = -1; - MethodHandle hasNegatives = null; if (ZERO_COPY) { try { - Method mtd = Class.forName("java.lang.StringCoding") - .getDeclaredMethod("hasNegatives", byte[].class, int.class, int.class); + Method mtd = Class.forName("java.lang.StringCoding").getDeclaredMethod("hasNegatives", byte[].class, int.class, int.class); // Requires '--add-opens=java.base/java.lang=ALL-UNNAMED' which Ignite scripts pass by default. mtd.setAccessible(true); - MethodHandle candidate = MethodHandles.lookup().unreflect(mtd); + MethodHandle hasNegatives0 = MethodHandles.lookup().unreflect(mtd); - if (!(boolean)candidate.invokeExact(new byte[] {1, 2, 3}, 0, 3) - && (boolean)candidate.invokeExact(new byte[] {1, -2, 3}, 0, 3)) - hasNegatives = candidate; + if (!(boolean)hasNegatives0.invokeExact(new byte[] {1, 2, 3}, 0, 3) + && (boolean)hasNegatives0.invokeExact(new byte[] {1, -2, 3}, 0, 3)) + hasNegatives = hasNegatives0; } catch (Throwable ignored) { // The scalar implementation will be used. @@ -87,6 +83,9 @@ public final class StringWriter { HAS_NEGATIVES = hasNegatives; + long valOff = -1; + long coderOff = -1; + if (ZERO_COPY) { try { Field valField = String.class.getDeclaredField("value"); @@ -126,47 +125,41 @@ private StringWriter() { * @param out Output stream. */ public static void write(@NotNull String val, BinaryOutputStream out) { - int len = val.length(); - - // Worst case is 3 bytes per char: a surrogate pair (2 chars) produces 4 bytes, a lone surrogate 1 byte. - // 1 byte for `GridBinaryMarshaller.STRING` and integer (4 bytes) for length. - int worstLen = 1 + 4 + 3 * len; - - if (worstLen + out.position() > Integer.MAX_VALUE) { - writeWithTemporaryArray(val, out); - - return; - } - - out.unsafeEnsure(worstLen); - - out.unsafeWriteByte(GridBinaryMarshaller.STRING); - - int lenPos = out.position(); - - out.unsafePosition(lenPos + 4); - - byte[] latin1 = latin1Value(val); + int lenPos = writeHeader(out); int writtenBytes; - if (out.hasArray()) { - // Encode into the backing array directly: plain indexed writes are much faster than - // per-byte virtual calls through the stream interface. - int start = lenPos + 4; + byte[] latin1 = latin1Value(val); - int end = latin1 != null - ? encodeLatin1(latin1, out.array(), start) - : encodeChars(val, out.array(), start); + if (latin1 != null) { + if (out.hasArray()) { + // Encode into the backing array directly: plain indexed writes are much faster than + // per-byte virtual calls through the stream interface. + int start = lenPos + 4; + int end = encodeLatin1(latin1, out, start); - writtenBytes = end - start; + writtenBytes = end - start; - out.unsafePosition(end); + out.unsafePosition(end); + } + else + writtenBytes = writeLatin1(latin1, out); } else { - writtenBytes = latin1 != null - ? writeLatin1(latin1, out) - : writeChars(val, out); + // Worst case is 3 bytes per char: a surrogate pair (2 chars) produces 4 bytes, a lone surrogate 1 byte. + out.unsafeEnsure(Math.multiplyExact(3, val.length())); + + if (out.hasArray()) { + // Encode into the backing array directly: plain indexed writes are much faster than + // per-byte virtual calls through the stream interface. + int start = lenPos + 4; + int end = encodeChars(val, out.array(), start); + + writtenBytes = end - start; + + out.unsafePosition(end); + } else + writtenBytes = writeChars(val, out); } out.unsafeWriteInt(lenPos, writtenBytes); @@ -217,7 +210,40 @@ public static byte[] latin1Value(String val) { } /** - * Writes a Latin-1 encoded string value to the stream. Stream capacity must be ensured by the caller. + * @param arr Array. + * @return {@code True} if the array contains a byte with the sign bit set. + */ + private static boolean hasNegatives(byte[] arr) { + if (HAS_NEGATIVES != null) { + try { + return (boolean)HAS_NEGATIVES.invokeExact(arr, 0, arr.length); + } + catch (Throwable ignored) { + // TODO LT log here. + // Fall through to the generic implementation. + } + } + + // 8-byte strides with an early exit: measured on par with a branch-free loop for clean arrays + // (the exit branch is never taken there, and neither variant is vectorized by the JIT) + // and far faster when a negative byte occurs early. + int i = 0; + + for (int lim = arr.length - Long.BYTES; i <= lim; i += Long.BYTES) { + if ((GridUnsafe.getLong(arr, GridUnsafe.BYTE_ARR_OFF + i) & NEGATIVE_BYTES_MSK) != 0) + return true; + } + + for (; i < arr.length; i++) { + if (arr[i] < 0) + return true; + } + + return false; + } + + /** + * Writes a Latin-1 encoded string value to the stream. * * @param val Internal Latin-1 array of the string. * @param out Output stream. @@ -226,11 +252,13 @@ public static byte[] latin1Value(String val) { private static int writeLatin1(byte[] val, BinaryOutputStream out) { if (!hasNegatives(val)) { // Pure ASCII: UTF-8 representation matches the internal array, copy it as-is. - out.write(val, 0, val.length); + out.writeByteArray(val); return val.length; } + out.unsafeEnsure(Math.addExact(val.length, val.length)); + int utfLen = 0; for (int i = 0; i < val.length; i++) { @@ -313,55 +341,27 @@ else if (Character.isSurrogate(c)) { } /** - * @param arr Array. - * @return {@code True} if the array contains a byte with the sign bit set. - */ - private static boolean hasNegatives(byte[] arr) { - if (HAS_NEGATIVES != null) { - try { - return (boolean)HAS_NEGATIVES.invokeExact(arr, 0, arr.length); - } - catch (Throwable ignored) { - // TODO LT log here. - // Fall through to the generic implementation. - } - } - - // 8-byte strides with an early exit: measured on par with a branch-free loop for clean arrays - // (the exit branch is never taken there, and neither variant is vectorized by the JIT) - // and far faster when a negative byte occurs early. - int i = 0; - - for (int lim = arr.length - Long.BYTES; i <= lim; i += Long.BYTES) { - if ((GridUnsafe.getLong(arr, GridUnsafe.BYTE_ARR_OFF + i) & NEGATIVE_BYTES_MSK) != 0) - return true; - } - - for (; i < arr.length; i++) { - if (arr[i] < 0) - return true; - } - - return false; - } - - /** - * Encodes a Latin-1 string value to the buffer as UTF-8. Buffer capacity must be ensured by the caller. + * Encodes a Latin-1 string value to the buffer as UTF-8. * * @param val Internal Latin-1 array of the string. - * @param buf Buffer. + * @param out Output stream. * @param pos Buffer position to encode to. * @return Buffer position after the last encoded byte. */ - private static int encodeLatin1(byte[] val, byte[] buf, int pos) { + private static int encodeLatin1(byte[] val, BinaryOutputStream out, int pos) { if (!hasNegatives(val)) { + out.unsafeEnsure(val.length); + // Pure ASCII: UTF-8 representation matches the internal array, copy it as-is. - System.arraycopy(val, 0, buf, pos, val.length); + System.arraycopy(val, 0, out.array(), pos, val.length); return pos + val.length; } - // Unsafe writes skip the array bounds checks: capacity is ensured by the caller. + out.unsafeEnsure(Math.addExact(val.length, val.length)); + + byte[] buf = out.array(); + long off = GridUnsafe.BYTE_ARR_OFF + pos; for (int i = 0; i < val.length; i++) { @@ -436,7 +436,7 @@ else if (Character.isSurrogate(c)) { * @param val Value. * @param out Output stream. */ - static void writeWithTemporaryArray(String val, BinaryOutputStream out) { + static int writeWithTemporaryArray(String val, BinaryOutputStream out) { byte[] strArr; if (BinaryUtils.USE_STR_SERIALIZATION_VER_2) @@ -444,11 +444,21 @@ static void writeWithTemporaryArray(String val, BinaryOutputStream out) { else strArr = val.getBytes(UTF_8); + out.writeByteArray(strArr); + + return strArr.length; + } + + /** */ + private static int writeHeader(BinaryOutputStream out) { // 1 byte for `GridBinaryMarshaller.STRING` and integer (4 bytes) for length. out.unsafeEnsure(1 + 4); out.unsafeWriteByte(GridBinaryMarshaller.STRING); - out.unsafeWriteInt(strArr.length); - out.writeByteArray(strArr); + int pos = out.position(); + + out.unsafePosition(out.position() + 4); + + return pos; } } diff --git a/modules/core/src/test/java/org/apache/ignite/internal/binary/StringWriterSelfTest.java b/modules/core/src/test/java/org/apache/ignite/internal/binary/StringWriterSelfTest.java index be203bbce1af5..2117ef3385361 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/binary/StringWriterSelfTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/binary/StringWriterSelfTest.java @@ -18,7 +18,7 @@ package org.apache.ignite.internal.binary; import java.util.Arrays; -import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; import org.apache.ignite.internal.binary.streams.BinaryOutputStream; import org.apache.ignite.internal.binary.streams.BinaryStreams; import org.apache.ignite.internal.binary.streams.BinaryStreamsTestUtils; @@ -28,133 +28,165 @@ import static java.nio.charset.StandardCharsets.UTF_8; /** - * Tests that {@link StringWriter} output is byte-identical to serialization of the {@link String#getBytes} result, + * Tests that {@link StringWriter} output is byte-identical to serialization of the {@link String#getBytes()} result, * which was used before zero-copy string serialization was introduced. */ public class StringWriterSelfTest extends GridCommonAbstractTest { - /** Edge cases for all encoder paths: ASCII bulk copy, Latin-1, generic UTF-16 and malformed surrogates. */ - private static final String[] CORPUS = { - "", - "a", - "?", - "abcdefghijklmnopqrstuvwxyz0123456789", // Long ASCII: exercises the 8-byte stride scan and bulk copy. - "caf\u00e9", // Latin-1 with a negative byte. - "\u00ff\u0080\u00a0", // Latin-1, negative bytes only. - "\u041f\u0440\u0438\u0432\u0435\u0442", // Cyrillic: 2-byte UTF-8 sequences. - "\u0800\u1234\uffff", // 3-byte UTF-8 sequences. - "\ud83d\ude00", // Emoji: valid surrogate pair. - "a\ud83d\ude00b\u00e9\u0416\u0001", // Mixed content. - "\ud800", // Lone high surrogate. - "\udc00", // Lone low surrogate. - "a\ud800", // High surrogate at the end. - "\ud800a", // High surrogate followed by a regular char. - "\ud800\ud800", // Two high surrogates. - "\udc00\ud800", // Low surrogate before a high one. - "\u0000", // NUL char. - "nul\u0000nul" - }; + /** */ + public static final int ASCII_MAX = 0x80; - /** - * Tests corpus of edge case strings. - */ + /** */ + public static final int LATIN1_MAX = 0x100; + + /** */ + public static final int TWO_BYTES_MAX = 0x800; + + /** */ + public static final int THREE_BYTES_MAX = 0xD800; + + /** */ + public static final int FOUR_BYTES_MAX = 0xE000; + + /** */ + public static final int FOUR_BYTES_HIGH_BOUND = 0x10000; + + /** Tests for all encoder paths: ASCII bulk copy, Latin-1, generic UTF-16 and malformed surrogates. */ @Test public void testCorpus() { - for (String str : CORPUS) + String[] cases = { + "", + "a", + "?", + "abcdefghijklmnopqrstuvwxyz0123456789", // Long ASCII: exercises the 8-byte stride scan and bulk copy. + "caf\u00e9", // Latin-1 with a negative byte. + "\u00ff\u0080\u00a0", // Latin-1, negative bytes only. + "\u041f\u0440\u0438\u0432\u0435\u0442", // Cyrillic: 2-byte UTF-8 sequences. + "\u0800\u1234\uffff", // 3-byte UTF-8 sequences. + "\ud83d\ude00", // Emoji: valid surrogate pair. + "a\ud83d\ude00b\u00e9\u0416\u0001", // Mixed content. + "\ud800", // Lone high surrogate. + "\udc00", // Lone low surrogate. + "a\ud800", // High surrogate at the end. + "\ud800a", // High surrogate followed by a regular char. + "\ud800\ud800", // Two high surrogates. + "\udc00\ud800", // Low surrogate before a high one. + "\u0000", // NUL char. + "nul\u0000nul" + }; + + for (String str : cases) check(str); } - /** - * Randomized differential test against {@link String#getBytes}. - */ + /** Randomized differential test against {@link String#getBytes()}. */ @Test public void testRandomStrings() { - Random rnd = new Random(4242); - - for (int i = 0; i < 5_000; i++) { - int len = rnd.nextInt(65); + ThreadLocalRandom rnd = ThreadLocalRandom.current(); - StringBuilder sb = new StringBuilder(len); + for (int iter = 0; iter < 100; iter++) { + StringBuilder sb = new StringBuilder(1 + rnd.nextInt(42)); - for (int j = 0; j < len; j++) { + for (int i = 0; i < sb.capacity(); i++) { int bucket = rnd.nextInt(100); char c; if (bucket < 40) - c = (char)rnd.nextInt(0x80); // ASCII. + // ASCII. + c = (char)rnd.nextInt(ASCII_MAX); else if (bucket < 55) - c = (char)(0x80 + rnd.nextInt(0x100 - 0x80)); // Latin-1. + // Latin-1. + c = (char)(ASCII_MAX + rnd.nextInt(LATIN1_MAX - ASCII_MAX)); else if (bucket < 65) - c = (char)(0x100 + rnd.nextInt(0x800 - 0x100)); // Other 2-byte chars. + // Other 2-byte chars. + c = (char)(LATIN1_MAX + rnd.nextInt(TWO_BYTES_MAX - LATIN1_MAX)); else if (bucket < 75) - c = (char)(0x800 + rnd.nextInt(0xD800 - 0x800)); // 3-byte chars. + // 3-byte chars. + c = (char)(TWO_BYTES_MAX + rnd.nextInt(THREE_BYTES_MAX - TWO_BYTES_MAX)); else if (bucket < 90) - c = (char)(0xD800 + rnd.nextInt(0xE000 - 0xD800)); // Surrogates, mostly malformed. + // Surrogates, mostly malformed. + c = (char)(THREE_BYTES_MAX + rnd.nextInt(FOUR_BYTES_MAX - THREE_BYTES_MAX)); else - c = (char)(0xE000 + rnd.nextInt(0x10000 - 0xE000)); // 3-byte chars above the surrogate range. + // 3-byte chars above the surrogate range. + c = (char)(FOUR_BYTES_MAX + rnd.nextInt(FOUR_BYTES_HIGH_BOUND - FOUR_BYTES_MAX)); sb.append(c); } + assertFalse(sb.isEmpty()); + check(sb.toString()); } } /** - * Tests that the stream position is correct after a string write, so surrounding values are not corrupted. + * Tests strings whose UTF-8 form is larger than the stream's minimal capacity, so that the encoder's own capacity + * reservation (rather than the buffer's initial slack) is what keeps the unchecked writes in bounds. Covers every + * encoder path on both heap and offheap streams. */ @Test + public void testLargeStrings() { + int len = 100_000; + + StringBuilder ascii = new StringBuilder(len); + StringBuilder latin1 = new StringBuilder(len); + StringBuilder cyrillic = new StringBuilder(len); + StringBuilder mixed = new StringBuilder(len); + + for (int i = 0; i < len; i++) { + ascii.append((char)('a' + i % 26)); + // Every char is a Latin-1 char with the sign bit set: worst case for the 2-bytes-per-char reservation. + latin1.append((char)(ASCII_MAX + i % (LATIN1_MAX - ASCII_MAX))); + cyrillic.append((char)('\u0410' + i % 32)); + mixed.append((char)('a' + i % 26)).append('\u00e9').append('\u0416').append('\u20ac').append("\ud83d\ude00"); + } + + check(ascii.toString()); + check(latin1.toString()); + check(cyrillic.toString()); + check(mixed.toString()); + } + + /** Tests that the stream position is correct after a string write, so surrounding values are not corrupted. */ + @Test public void testStreamPosition() { - // Small initial capacity to exercise buffer reallocation. + int int1 = 0xDEADBEEF; + String str1 = "caf\u00e9"; + String str2 = "\ud83d\ude00"; + int int2 = 0xCAFEBABE; + + // Small initial capacity to check buffer reallocation. try (BinaryOutputStream out = BinaryStreams.outputStream(2)) { - out.writeInt(0xDEADBEEF); + out.writeInt(int1); + StringWriter.write(str1, out); + StringWriter.write(str2, out); + out.writeInt(int2); - StringWriter.write("caf\u00e9", out); - StringWriter.write("\ud83d\ude00", out); + byte[] strBytes1 = strBytes(str1); + byte[] strBytes2 = strBytes(str2); - out.writeInt(0xCAFEBABE); + byte[] exp = new byte[Integer.BYTES + strBytes1.length + strBytes2.length + Integer.BYTES]; - byte[] exp = concat( - intLE(0xDEADBEEF), - strBytes("caf\u00e9"), - strBytes("\ud83d\ude00"), - intLE(0xCAFEBABE)); + System.arraycopy(intBytes(int1), 0, exp, 0, Integer.BYTES); + System.arraycopy(strBytes1, 0, exp, Integer.BYTES, strBytes1.length); + System.arraycopy(strBytes2, 0, exp, Integer.BYTES + strBytes1.length, strBytes2.length); + System.arraycopy(intBytes(int2), 0, exp, Integer.BYTES + strBytes1.length + strBytes2.length, Integer.BYTES); assertTrue(Arrays.equals(exp, out.arrayCopy())); } } /** - * Checks that serialized form of the given string is byte-identical to serialization of - * the {@link String#getBytes} result. - * + * Checks that serialized form of the given string is byte-identical to serialization of the {@link String#getBytes()} result. * @param str String to check. */ private void check(String str) { - byte[] exp = strBytes(str); - - try (BinaryOutputStream out = BinaryStreams.outputStream(1)) { - StringWriter.write(str, out); - - assertSerialized(str, exp, out.arrayCopy()); - } + for (boolean heapStream : new boolean[] {true, false}) { + try (BinaryOutputStream out = heapStream ? BinaryStreams.outputStream(1) : BinaryStreamsTestUtils.offheapOutputStream(1)) { + StringWriter.write(str, out); - try (BinaryOutputStream out = BinaryStreamsTestUtils.offheapOutputStream(1)) { - StringWriter.write(str, out); - - assertSerialized(str, exp, out.arrayCopy()); - } - } - - /** - * @param str Source string. - * @param exp Expected serialized form. - * @param act Actual serialized form. - */ - private void assertSerialized(String str, byte[] exp, byte[] act) { - if (!Arrays.equals(exp, act)) { - fail("String serialization mismatch [str=" + Arrays.toString(str.toCharArray()) + - ", exp=" + Arrays.toString(exp) + ", act=" + Arrays.toString(act) + ']'); + assertTrue("String serialization mismatch: " + str, Arrays.equals(strBytes(str), out.arrayCopy())); + } } } @@ -163,14 +195,13 @@ private void assertSerialized(String str, byte[] exp, byte[] act) { * @return Expected serialized form of the string: flag, UTF-8 length and UTF-8 bytes. */ private static byte[] strBytes(String str) { - byte[] utf8 = str.getBytes(UTF_8); - - byte[] res = new byte[5 + utf8.length]; + byte[] bytes = str.getBytes(UTF_8); + byte[] res = new byte[Byte.BYTES + Integer.BYTES + bytes.length]; res[0] = GridBinaryMarshaller.STRING; - System.arraycopy(intLE(utf8.length), 0, res, 1, 4); - System.arraycopy(utf8, 0, res, 5, utf8.length); + System.arraycopy(intBytes(bytes.length), 0, res, 1, 4); + System.arraycopy(bytes, 0, res, 5, bytes.length); return res; } @@ -179,30 +210,7 @@ private static byte[] strBytes(String str) { * @param val Value. * @return Little-endian representation of the value. */ - private static byte[] intLE(int val) { + private static byte[] intBytes(int val) { return new byte[] {(byte)val, (byte)(val >> 8), (byte)(val >> 16), (byte)(val >> 24)}; } - - /** - * @param arrs Arrays. - * @return Concatenated arrays. - */ - private static byte[] concat(byte[]... arrs) { - int len = 0; - - for (byte[] arr : arrs) - len += arr.length; - - byte[] res = new byte[len]; - - int pos = 0; - - for (byte[] arr : arrs) { - System.arraycopy(arr, 0, res, pos, arr.length); - - pos += arr.length; - } - - return res; - } } From cbb0052dd3a4a08dcce1ba041c9a328d1e9a7f18 Mon Sep 17 00:00:00 2001 From: Nikolay Izhikov Date: Wed, 2 Sep 2026 14:16:33 +0300 Subject: [PATCH 4/9] IGNITE-27088 BinaryWriter should use internal String#value --- .../internal/binary/BinaryWriterExImpl.java | 17 +- .../ignite/internal/binary/StringWriter.java | 202 +++++++----------- .../ignite/IgniteCommonsSystemProperties.java | 3 +- 3 files changed, 98 insertions(+), 124 deletions(-) diff --git a/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/BinaryWriterExImpl.java b/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/BinaryWriterExImpl.java index 0098bd38d8edd..12122b2acd01c 100644 --- a/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/BinaryWriterExImpl.java +++ b/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/BinaryWriterExImpl.java @@ -41,6 +41,7 @@ import org.apache.ignite.marshaller.Marshallers; import org.jetbrains.annotations.Nullable; +import static java.nio.charset.StandardCharsets.UTF_8; import static org.apache.ignite.IgniteCommonsSystemProperties.IGNITE_BINARY_STRING_ZERO_COPY; import static org.apache.ignite.internal.util.CommonUtils.MAX_ARRAY_SIZE; @@ -739,8 +740,20 @@ void writeBooleanField(@Nullable Boolean val) { out.writeByte(GridBinaryMarshaller.NULL); else if (ZERO_COPY) StringWriter.write(val, out); - else - StringWriter.writeWithTemporaryArray(val, out); + else { + byte[] strArr; + + if (BinaryUtils.USE_STR_SERIALIZATION_VER_2) + strArr = BinaryUtils.strToUtf8Bytes(val); + else + strArr = val.getBytes(UTF_8); + + out.unsafeEnsure(1 + 4); + out.unsafeWriteByte(GridBinaryMarshaller.STRING); + out.unsafeWriteInt(strArr.length); + + out.writeByteArray(strArr); + } } /** {@inheritDoc} */ diff --git a/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/StringWriter.java b/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/StringWriter.java index b3cd24b2c0ee7..6bcdf9a656e19 100644 --- a/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/StringWriter.java +++ b/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/StringWriter.java @@ -24,22 +24,16 @@ import org.apache.ignite.IgniteCommonsSystemProperties; import org.apache.ignite.internal.binary.streams.BinaryOutputStream; import org.apache.ignite.internal.util.GridUnsafe; +import org.apache.ignite.lang.IgniteBiTuple; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; -import static java.nio.charset.StandardCharsets.UTF_8; import static org.apache.ignite.internal.binary.BinaryWriterExImpl.ZERO_COPY; /** - * Writes {@link String} values to a {@link BinaryOutputStream} in UTF-8 without allocation of temporary byte arrays: - * UTF-8 bytes are encoded directly into the stream buffer after a reserved length slot which is patched afterwards. - *

- * On JDKs with compact strings (9+) an additional fast path is used: for Latin-1 strings the internal {@code byte[]} - * value of the string is encoded without per-char conversion, and for pure ASCII strings it is copied into the stream - * as-is, since the UTF-8 representation is identical to the internal one. - *

- * The produced bytes are identical to serialization of {@code val.getBytes(UTF_8)}, including replacement of - * malformed surrogates with {@code '?'}. Zero-copy serialization can be disabled with the - * {@link IgniteCommonsSystemProperties#IGNITE_BINARY_STRING_ZERO_COPY} system property. + * Writes {@link String} values to a {@link BinaryOutputStream} in UTF-8 without allocation of temporary byte arrays. + * + * @see IgniteCommonsSystemProperties#IGNITE_BINARY_STRING_ZERO_COPY */ public final class StringWriter { /** Latin-1 value of the {@code java.lang.String#coder} field. */ @@ -54,63 +48,18 @@ public final class StringWriter { /** Offset of the {@code java.lang.String#coder} field, or {@code -1} if the compact string fast path is unavailable. */ private static final long STR_CODER_OFF; + static { + IgniteBiTuple result = fieldsOffsets(); + + STR_VALUE_OFF = result.get1(); + STR_CODER_OFF = result.get2(); + } + /** * Handle of the intrinsified {@code java.lang.StringCoding#hasNegatives}, or {@code null} if unavailable. * The intrinsic scans the array with SIMD instructions, far faster than any scalar loop. */ - private static final MethodHandle HAS_NEGATIVES; - - static { - MethodHandle hasNegatives = null; - - if (ZERO_COPY) { - try { - Method mtd = Class.forName("java.lang.StringCoding").getDeclaredMethod("hasNegatives", byte[].class, int.class, int.class); - - // Requires '--add-opens=java.base/java.lang=ALL-UNNAMED' which Ignite scripts pass by default. - mtd.setAccessible(true); - - MethodHandle hasNegatives0 = MethodHandles.lookup().unreflect(mtd); - - if (!(boolean)hasNegatives0.invokeExact(new byte[] {1, 2, 3}, 0, 3) - && (boolean)hasNegatives0.invokeExact(new byte[] {1, -2, 3}, 0, 3)) - hasNegatives = hasNegatives0; - } - catch (Throwable ignored) { - // The scalar implementation will be used. - } - } - - HAS_NEGATIVES = hasNegatives; - - long valOff = -1; - long coderOff = -1; - - if (ZERO_COPY) { - try { - Field valField = String.class.getDeclaredField("value"); - Field coderField = String.class.getDeclaredField("coder"); - - // On JDK 8 the value field is a char[], only the generic encoder can be used. - if (valField.getType() == byte[].class && coderField.getType() == byte.class) { - valOff = GridUnsafe.objectFieldOffset(valField); - coderOff = GridUnsafe.objectFieldOffset(coderField); - - if (!probe(valOff, coderOff)) { - valOff = -1; - coderOff = -1; - } - } - } - catch (Throwable ignored) { - valOff = -1; - coderOff = -1; - } - } - - STR_VALUE_OFF = valOff; - STR_CODER_OFF = coderOff; - } + private static final MethodHandle HAS_NEGATIVES = hasNegativesHandle(); /** */ private StringWriter() { @@ -125,7 +74,13 @@ private StringWriter() { * @param out Output stream. */ public static void write(@NotNull String val, BinaryOutputStream out) { - int lenPos = writeHeader(out); + // 1 byte for `GridBinaryMarshaller.STRING` and integer (4 bytes) for length. + out.unsafeEnsure(1 + 4); + out.unsafeWriteByte(GridBinaryMarshaller.STRING); + + int lenPos = out.position(); + + out.unsafePosition(out.position() + 4); int writtenBytes; @@ -158,45 +113,14 @@ public static void write(@NotNull String val, BinaryOutputStream out) { writtenBytes = end - start; out.unsafePosition(end); - } else + } + else writtenBytes = writeChars(val, out); } out.unsafeWriteInt(lenPos, writtenBytes); } - /** - * Checks that the internal layout of {@link String} behaves as the compact string fast path expects. - * - * @param valOff Offset of the {@code value} field. - * @param coderOff Offset of the {@code coder} field. - * @return {@code True} if the fast path can be used. - */ - private static boolean probe(long valOff, long coderOff) { - String probe = "Ignite\u00e9"; - - // Compact strings can be disabled with -XX:-CompactStrings, then all strings are UTF-16 encoded. - if (GridUnsafe.getByteField(probe, coderOff) != LATIN1) - return false; - - Object val = GridUnsafe.getObjectField(probe, valOff); - - if (!(val instanceof byte[])) - return false; - - byte[] arr = (byte[])val; - - if (arr.length != probe.length()) - return false; - - for (int i = 0; i < arr.length; i++) { - if ((arr[i] & 0xFF) != probe.charAt(i)) - return false; - } - - return true; - } - /** * @param val String. * @return Internal Latin-1 array of the string, or {@code null} if the string is UTF-16 encoded or the internal @@ -219,14 +143,11 @@ private static boolean hasNegatives(byte[] arr) { return (boolean)HAS_NEGATIVES.invokeExact(arr, 0, arr.length); } catch (Throwable ignored) { - // TODO LT log here. // Fall through to the generic implementation. } } - // 8-byte strides with an early exit: measured on par with a branch-free loop for clean arrays - // (the exit branch is never taken there, and neither variant is vectorized by the JIT) - // and far faster when a negative byte occurs early. + // 8-byte strides with an early exit. int i = 0; for (int lim = arr.length - Long.BYTES; i <= lim; i += Long.BYTES) { @@ -430,35 +351,76 @@ else if (Character.isSurrogate(c)) { return (int)(off - GridUnsafe.BYTE_ARR_OFF); } + /** */ + private static IgniteBiTuple fieldsOffsets() { + if (!ZERO_COPY) { + try { + Field valField = String.class.getDeclaredField("value"); + Field coderField = String.class.getDeclaredField("coder"); + + // On JDK 8 the value field is a char[], only the generic encoder can be used. + if (valField.getType() == byte[].class && coderField.getType() == byte.class) { + IgniteBiTuple res = new IgniteBiTuple<>( + GridUnsafe.objectFieldOffset(valField), + GridUnsafe.objectFieldOffset(coderField) + ); + + if(probe(res)) + return res; + } + } + catch (Throwable ignored) { + // No-op. + } + } + + return new IgniteBiTuple<>(-1L, -1L); + } + /** - * Writes a string through a temporary UTF-8 byte array. + * Checks that the internal layout of {@link String} behaves as the compact string fast path expects. * - * @param val Value. - * @param out Output stream. + * @param offsets Offsets of methods. + * @return {@code True} if the fast path can be used. */ - static int writeWithTemporaryArray(String val, BinaryOutputStream out) { - byte[] strArr; + private static boolean probe(IgniteBiTuple offsets) { + String probe = "Ignite\u00e9"; - if (BinaryUtils.USE_STR_SERIALIZATION_VER_2) - strArr = BinaryUtils.strToUtf8Bytes(val); - else - strArr = val.getBytes(UTF_8); + // Compact strings can be disabled with -XX:-CompactStrings, then all strings are UTF-16 encoded. + if (GridUnsafe.getByteField(probe, offsets.get1()) != LATIN1) + return false; - out.writeByteArray(strArr); + Object val = GridUnsafe.getObjectField(probe, offsets.get2()); + + if (!(val instanceof byte[] arr)) + return false; - return strArr.length; + if (arr.length != probe.length()) + return false; + + for (int i = 0; i < arr.length; i++) { + if ((arr[i] & 0xFF) != probe.charAt(i)) + return false; + } + + return true; } /** */ - private static int writeHeader(BinaryOutputStream out) { - // 1 byte for `GridBinaryMarshaller.STRING` and integer (4 bytes) for length. - out.unsafeEnsure(1 + 4); - out.unsafeWriteByte(GridBinaryMarshaller.STRING); + private static @Nullable MethodHandle hasNegativesHandle() { + if (ZERO_COPY) { + try { + Method mtd = Class.forName("java.lang.StringCoding").getDeclaredMethod("hasNegatives", byte[].class, int.class, int.class); - int pos = out.position(); + mtd.setAccessible(true); - out.unsafePosition(out.position() + 4); + return MethodHandles.lookup().unreflect(mtd); + } + catch (Throwable ignored) { + // No-op. + } + } - return pos; + return null; } } diff --git a/modules/commons/src/main/java/org/apache/ignite/IgniteCommonsSystemProperties.java b/modules/commons/src/main/java/org/apache/ignite/IgniteCommonsSystemProperties.java index 450e9d8636eb7..275ab07507a96 100644 --- a/modules/commons/src/main/java/org/apache/ignite/IgniteCommonsSystemProperties.java +++ b/modules/commons/src/main/java/org/apache/ignite/IgniteCommonsSystemProperties.java @@ -129,8 +129,7 @@ public class IgniteCommonsSystemProperties { "IGNITE_BINARY_MARSHALLER_USE_STRING_SERIALIZATION_VER_2"; /** - * When set to {@code false}, disables zero-copy UTF-8 serialization of {@link String} values by BinaryMarshaller, - * reverting to serialization through a temporary byte array produced by {@link String#getBytes}. + * When set to {@code false}, disables zero-copy UTF-8 serialization of {@link String} values by BinaryMarshaller. * Default value is {@code true}. */ @SystemProperty(value = "Enables zero-copy UTF-8 serialization of String values by BinaryMarshaller. " + From d36ef1a3df662b979a433f2168eeea278fce152f Mon Sep 17 00:00:00 2001 From: Nikolay Izhikov Date: Fri, 4 Sep 2026 10:12:38 +0300 Subject: [PATCH 5/9] IGNITE-27088 BinaryWriter should use internal String#value --- .../binary/JmhBinaryStringWriteBenchmark.java | 15 +- .../ignite/internal/binary/StringWriter.java | 312 ++++++++---------- .../ignite/IgniteCommonsSystemProperties.java | 5 +- .../direct/stream/DirectByteBufferStream.java | 2 +- .../internal/binary/StringWriterSelfTest.java | 15 +- .../streams/BinaryStreamsTestUtils.java | 8 - 6 files changed, 141 insertions(+), 216 deletions(-) diff --git a/modules/benchmarks/src/main/java/org/apache/ignite/internal/benchmarks/jmh/binary/JmhBinaryStringWriteBenchmark.java b/modules/benchmarks/src/main/java/org/apache/ignite/internal/benchmarks/jmh/binary/JmhBinaryStringWriteBenchmark.java index 4dfb15f7118b9..f029d50c8d76c 100644 --- a/modules/benchmarks/src/main/java/org/apache/ignite/internal/benchmarks/jmh/binary/JmhBinaryStringWriteBenchmark.java +++ b/modules/benchmarks/src/main/java/org/apache/ignite/internal/benchmarks/jmh/binary/JmhBinaryStringWriteBenchmark.java @@ -42,10 +42,8 @@ import static org.openjdk.jmh.annotations.Scope.Thread; /** - * Compares zero-copy string serialization ({@link StringWriter} encoding directly into the stream buffer) with the - * legacy serialization through a temporary array produced by {@link String#getBytes()}. The {@code zeroCopy} parameter - * selects the implementation via the {@code IGNITE_BINARY_STRING_ZERO_COPY} system property, which is set before the - * first use of {@link StringWriter} in each forked JVM. + * Compares zero-copy string serialization with the legacy serialization. + * @see org.apache.ignite.IgniteCommonsSystemProperties#IGNITE_BINARY_STRING_ZERO_COPY */ @State(Thread) @OutputTimeUnit(NANOSECONDS) @@ -71,9 +69,7 @@ public class JmhBinaryStringWriteBenchmark { /** */ private String str; - /** - * @param args Optional values of the {@code stream} parameter to run (e.g. {@code offheap}); all when empty. - */ + /** */ public static void main(String[] args) throws Exception { OptionsBuilder builder = JmhIdeBenchmarkRunner.create() .forks(1) @@ -81,16 +77,13 @@ public static void main(String[] args) throws Exception { .profilers(GCProfiler.class) .optionsBuilder(); - if (args.length > 0) - builder.param("stream", args); - new Runner(builder.build()).run(); } /** */ @Setup public void setup() { - // Must be set before the first use of StringWriter in this JVM: the flag is read on class initialization. + // Must be set before the first use of StringWriter in this JVM. System.setProperty(IGNITE_BINARY_STRING_ZERO_COPY, String.valueOf(zeroCopy)); StringBuilder sb = new StringBuilder(len); diff --git a/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/StringWriter.java b/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/StringWriter.java index 6bcdf9a656e19..7615cf9af265e 100644 --- a/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/StringWriter.java +++ b/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/StringWriter.java @@ -39,9 +39,6 @@ public final class StringWriter { /** Latin-1 value of the {@code java.lang.String#coder} field. */ private static final byte LATIN1 = 0; - /** Mask to test 8 bytes for a set sign bit at once. */ - private static final long NEGATIVE_BYTES_MSK = 0b10000000_10000000_10000000_10000000_10000000_10000000_10000000_10000000L; - /** Offset of the {@code java.lang.String#value} field, or {@code -1} if the compact string fast path is unavailable. */ private static final long STR_VALUE_OFF; @@ -59,7 +56,7 @@ public final class StringWriter { * Handle of the intrinsified {@code java.lang.StringCoding#hasNegatives}, or {@code null} if unavailable. * The intrinsic scans the array with SIMD instructions, far faster than any scalar loop. */ - private static final MethodHandle HAS_NEGATIVES = hasNegativesHandle(); + private static final MethodHandle HAS_NEGATIVES = hasNegatives(); /** */ private StringWriter() { @@ -67,8 +64,7 @@ private StringWriter() { } /** - * Writes a string to the output stream as a {@link GridBinaryMarshaller#STRING} flag followed by UTF-8 length - * (int) and UTF-8 bytes. + * Writes a string to the output stream. * * @param val Value. * @param out Output stream. @@ -82,85 +78,41 @@ public static void write(@NotNull String val, BinaryOutputStream out) { out.unsafePosition(out.position() + 4); - int writtenBytes; + int written; byte[] latin1 = latin1Value(val); if (latin1 != null) { if (out.hasArray()) { - // Encode into the backing array directly: plain indexed writes are much faster than - // per-byte virtual calls through the stream interface. - int start = lenPos + 4; - int end = encodeLatin1(latin1, out, start); + if (!hasNegatives(latin1)) { + out.unsafeEnsure(latin1.length); + // Pure ASCII: UTF-8 representation matches the internal array, copy it as-is. + System.arraycopy(latin1, 0, out.array(), out.position(), latin1.length); - writtenBytes = end - start; + written = latin1.length; + } + else + written = encodeLatin1(latin1, out); - out.unsafePosition(end); + out.unsafePosition(out.position() + written); } else - writtenBytes = writeLatin1(latin1, out); + written = writeLatin1(latin1, out); } else { - // Worst case is 3 bytes per char: a surrogate pair (2 chars) produces 4 bytes, a lone surrogate 1 byte. + // Allocating memory for worst case - 3 bytes per char. out.unsafeEnsure(Math.multiplyExact(3, val.length())); if (out.hasArray()) { - // Encode into the backing array directly: plain indexed writes are much faster than - // per-byte virtual calls through the stream interface. - int start = lenPos + 4; - int end = encodeChars(val, out.array(), start); + written = encodeChars(val, out); - writtenBytes = end - start; - - out.unsafePosition(end); + out.unsafePosition(out.position() + written); } else - writtenBytes = writeChars(val, out); + written = writeChars(val, out); } - out.unsafeWriteInt(lenPos, writtenBytes); - } - - /** - * @param val String. - * @return Internal Latin-1 array of the string, or {@code null} if the string is UTF-16 encoded or the internal - * layout of {@link String} is unknown. - */ - public static byte[] latin1Value(String val) { - if (STR_VALUE_OFF < 0 || GridUnsafe.getByteField(val, STR_CODER_OFF) != LATIN1) - return null; - - return (byte[])GridUnsafe.getObjectField(val, STR_VALUE_OFF); - } - - /** - * @param arr Array. - * @return {@code True} if the array contains a byte with the sign bit set. - */ - private static boolean hasNegatives(byte[] arr) { - if (HAS_NEGATIVES != null) { - try { - return (boolean)HAS_NEGATIVES.invokeExact(arr, 0, arr.length); - } - catch (Throwable ignored) { - // Fall through to the generic implementation. - } - } - - // 8-byte strides with an early exit. - int i = 0; - - for (int lim = arr.length - Long.BYTES; i <= lim; i += Long.BYTES) { - if ((GridUnsafe.getLong(arr, GridUnsafe.BYTE_ARR_OFF + i) & NEGATIVE_BYTES_MSK) != 0) - return true; - } - - for (; i < arr.length; i++) { - if (arr[i] < 0) - return true; - } - - return false; + out.unsafeWriteInt(lenPos, written); } /** @@ -171,13 +123,6 @@ private static boolean hasNegatives(byte[] arr) { * @return Number of bytes written. */ private static int writeLatin1(byte[] val, BinaryOutputStream out) { - if (!hasNegatives(val)) { - // Pure ASCII: UTF-8 representation matches the internal array, copy it as-is. - out.writeByteArray(val); - - return val.length; - } - out.unsafeEnsure(Math.addExact(val.length, val.length)); int utfLen = 0; @@ -191,10 +136,10 @@ private static int writeLatin1(byte[] val, BinaryOutputStream out) { utfLen++; } else { - int c = b & 0xFF; + int c = b & 0b1111_1111; - out.unsafeWriteByte((byte)(0xC0 | (c >> 6))); - out.unsafeWriteByte((byte)(0x80 | (c & 0x3F))); + out.unsafeWriteByte((byte)(0b1100_0000 | (c >> 6))); + out.unsafeWriteByte((byte)(0b1000_0000 | (c & 0b0011_1111))); utfLen += 2; } @@ -203,6 +148,36 @@ private static int writeLatin1(byte[] val, BinaryOutputStream out) { return utfLen; } + /** + * Encodes a Latin-1 string value to the buffer as UTF-8. + * + * @param val Internal Latin-1 array of the string. + * @param out Output stream. + * @return Count of written bytes. + */ + private static int encodeLatin1(byte[] val, BinaryOutputStream out) { + out.unsafeEnsure(Math.addExact(val.length, val.length)); + + byte[] buf = out.array(); + + long off = out.position() + GridUnsafe.BYTE_ARR_OFF; + + for (int i = 0; i < val.length; i++) { + byte b = val[i]; + + if (b >= 0) + GridUnsafe.putByte(buf, off++, b); + else { + int c = b & 0xFF; + + GridUnsafe.putByte(buf, off++, (byte)(0b1100_0000 | (c >> 6))); + GridUnsafe.putByte(buf, off++, (byte)(0b1000_0000 | (c & 0b0011_1111))); + } + } + + return (int)(off - GridUnsafe.BYTE_ARR_OFF - out.position()); + } + /** * Writes string chars UTF-8 encoded to the stream. Replicates {@code String#getBytes(UTF_8)} behavior exactly, * including replacement of malformed surrogates with {@code '?'}. Stream capacity must be ensured by the caller. @@ -224,21 +199,28 @@ private static int writeChars(String val, BinaryOutputStream out) { utfLen++; } else if (c < 0x800) { - out.unsafeWriteByte((byte)(0xC0 | (c >> 6))); - out.unsafeWriteByte((byte)(0x80 | (c & 0x3F))); + out.unsafeWriteByte((byte)(0b11_000000 | (c >> 6))); + out.unsafeWriteByte((byte)(0b10_000000 | (c & 0b00_111111))); utfLen += 2; } - else if (Character.isSurrogate(c)) { + else if (!Character.isSurrogate(c)) { + out.unsafeWriteByte((byte)(0b1110_0000 | (c >> 12))); + out.unsafeWriteByte((byte)(0b1000_0000 | ((c >> 6) & 0b0011_1111))); + out.unsafeWriteByte((byte)(0b1000_0000 | (c & 0b0011_1111))); + + utfLen += 3; + } + else { char c2; if (Character.isHighSurrogate(c) && i + 1 < len && Character.isLowSurrogate(c2 = val.charAt(i + 1))) { int cp = Character.toCodePoint(c, c2); - out.unsafeWriteByte((byte)(0xF0 | (cp >> 18))); - out.unsafeWriteByte((byte)(0x80 | ((cp >> 12) & 0x3F))); - out.unsafeWriteByte((byte)(0x80 | ((cp >> 6) & 0x3F))); - out.unsafeWriteByte((byte)(0x80 | (cp & 0x3F))); + out.unsafeWriteByte((byte)(0b1111_0000 | (cp >> 18))); + out.unsafeWriteByte((byte)(0b1000_0000 | ((cp >> 12) & 0b0011_1111))); + out.unsafeWriteByte((byte)(0b1000_0000 | ((cp >> 6) & 0b0011_1111))); + out.unsafeWriteByte((byte)(0b1000_0000 | (cp & 0b0011_1111))); utfLen += 4; i++; @@ -249,72 +231,25 @@ else if (Character.isSurrogate(c)) { utfLen++; } } - else { - out.unsafeWriteByte((byte)(0xE0 | (c >> 12))); - out.unsafeWriteByte((byte)(0x80 | ((c >> 6) & 0x3F))); - out.unsafeWriteByte((byte)(0x80 | (c & 0x3F))); - - utfLen += 3; - } } return utfLen; } - /** - * Encodes a Latin-1 string value to the buffer as UTF-8. - * - * @param val Internal Latin-1 array of the string. - * @param out Output stream. - * @param pos Buffer position to encode to. - * @return Buffer position after the last encoded byte. - */ - private static int encodeLatin1(byte[] val, BinaryOutputStream out, int pos) { - if (!hasNegatives(val)) { - out.unsafeEnsure(val.length); - - // Pure ASCII: UTF-8 representation matches the internal array, copy it as-is. - System.arraycopy(val, 0, out.array(), pos, val.length); - - return pos + val.length; - } - - out.unsafeEnsure(Math.addExact(val.length, val.length)); - - byte[] buf = out.array(); - - long off = GridUnsafe.BYTE_ARR_OFF + pos; - - for (int i = 0; i < val.length; i++) { - byte b = val[i]; - - if (b >= 0) - GridUnsafe.putByte(buf, off++, b); - else { - int c = b & 0xFF; - - GridUnsafe.putByte(buf, off++, (byte)(0xC0 | (c >> 6))); - GridUnsafe.putByte(buf, off++, (byte)(0x80 | (c & 0x3F))); - } - } - - return (int)(off - GridUnsafe.BYTE_ARR_OFF); - } - /** * Encodes string chars to the buffer as UTF-8. Replicates {@code String#getBytes(UTF_8)} behavior exactly, * including replacement of malformed surrogates with {@code '?'}. Buffer capacity must be ensured by the caller. * * @param val Value. - * @param buf Buffer. - * @param pos Buffer position to encode to. - * @return Buffer position after the last encoded byte. + * @param out Output stream. + * @return Count of written bytes. */ - private static int encodeChars(String val, byte[] buf, int pos) { + private static int encodeChars(String val, BinaryOutputStream out) { + byte[] buf = out.array(); int len = val.length(); // Unsafe writes skip the array bounds checks: capacity is ensured by the caller. - long off = GridUnsafe.BYTE_ARR_OFF + pos; + long off = GridUnsafe.BYTE_ARR_OFF + out.position(); for (int i = 0; i < len; i++) { char c = val.charAt(i); @@ -322,51 +257,93 @@ private static int encodeChars(String val, byte[] buf, int pos) { if (c < 0x80) GridUnsafe.putByte(buf, off++, (byte)c); else if (c < 0x800) { - GridUnsafe.putByte(buf, off++, (byte)(0xC0 | (c >> 6))); - GridUnsafe.putByte(buf, off++, (byte)(0x80 | (c & 0x3F))); + GridUnsafe.putByte(buf, off++, (byte)(0b1100_0000 | (c >> 6))); + GridUnsafe.putByte(buf, off++, (byte)(0b1000_0000 | (c & 0b0011_1111))); } - else if (Character.isSurrogate(c)) { + else if (!Character.isSurrogate(c)) { + GridUnsafe.putByte(buf, off++, (byte)(0b1110_0000 | (c >> 12))); + GridUnsafe.putByte(buf, off++, (byte)(0b1000_0000 | ((c >> 6) & 0b0011_1111))); + GridUnsafe.putByte(buf, off++, (byte)(0b1000_0000 | (c & 0b0011_1111))); + } + else { char c2; if (Character.isHighSurrogate(c) && i + 1 < len && Character.isLowSurrogate(c2 = val.charAt(i + 1))) { int cp = Character.toCodePoint(c, c2); - GridUnsafe.putByte(buf, off++, (byte)(0xF0 | (cp >> 18))); - GridUnsafe.putByte(buf, off++, (byte)(0x80 | ((cp >> 12) & 0x3F))); - GridUnsafe.putByte(buf, off++, (byte)(0x80 | ((cp >> 6) & 0x3F))); - GridUnsafe.putByte(buf, off++, (byte)(0x80 | (cp & 0x3F))); + GridUnsafe.putByte(buf, off++, (byte)(0b1111_0000 | (cp >> 18))); + GridUnsafe.putByte(buf, off++, (byte)(0b1000_0000 | ((cp >> 12) & 0b0011_1111))); + GridUnsafe.putByte(buf, off++, (byte)(0b1000_0000 | ((cp >> 6) & 0b0011_1111))); + GridUnsafe.putByte(buf, off++, (byte)(0b1000_0000 | (cp & 0b0011_1111))); i++; } else GridUnsafe.putByte(buf, off++, (byte)'?'); } - else { - GridUnsafe.putByte(buf, off++, (byte)(0xE0 | (c >> 12))); - GridUnsafe.putByte(buf, off++, (byte)(0x80 | ((c >> 6) & 0x3F))); - GridUnsafe.putByte(buf, off++, (byte)(0x80 | (c & 0x3F))); + } + + return (int)(off - GridUnsafe.BYTE_ARR_OFF - out.position()); + } + + /** + * @param val String. + * @return Internal Latin-1 array of the string, + * or {@code null} if the string is UTF-16 encoded or the internal layout of {@link String} is unknown. + */ + public static byte[] latin1Value(String val) { + if (STR_VALUE_OFF < 0 || GridUnsafe.getByteField(val, STR_CODER_OFF) != LATIN1) + return null; + + return (byte[])GridUnsafe.getObjectField(val, STR_VALUE_OFF); + } + + /** + * @param arr Array. + * @return {@code True} if the array contains a byte with the sign bit set. + */ + private static boolean hasNegatives(byte[] arr) { + if (HAS_NEGATIVES != null) { + try { + return (boolean)HAS_NEGATIVES.invokeExact(arr, 0, arr.length); } + catch (Throwable ignored) { + // Fall through to the generic implementation. + } + } + + // 8-byte strides with an early exit. + int i = 0; + + for (int lim = arr.length - Long.BYTES; i <= lim; i += Long.BYTES) { + long hasNegatives = GridUnsafe.getLong(arr, GridUnsafe.BYTE_ARR_OFF + i) + & 0b10000000_10000000_10000000_10000000_10000000_10000000_10000000_10000000L; + + if (hasNegatives != 0) + return true; } - return (int)(off - GridUnsafe.BYTE_ARR_OFF); + for (; i < arr.length; i++) { + if (arr[i] < 0) + return true; + } + + return false; } /** */ private static IgniteBiTuple fieldsOffsets() { - if (!ZERO_COPY) { + if (ZERO_COPY) { try { Field valField = String.class.getDeclaredField("value"); Field coderField = String.class.getDeclaredField("coder"); // On JDK 8 the value field is a char[], only the generic encoder can be used. if (valField.getType() == byte[].class && coderField.getType() == byte.class) { - IgniteBiTuple res = new IgniteBiTuple<>( + return new IgniteBiTuple<>( GridUnsafe.objectFieldOffset(valField), GridUnsafe.objectFieldOffset(coderField) ); - - if(probe(res)) - return res; } } catch (Throwable ignored) { @@ -377,37 +354,8 @@ private static IgniteBiTuple fieldsOffsets() { return new IgniteBiTuple<>(-1L, -1L); } - /** - * Checks that the internal layout of {@link String} behaves as the compact string fast path expects. - * - * @param offsets Offsets of methods. - * @return {@code True} if the fast path can be used. - */ - private static boolean probe(IgniteBiTuple offsets) { - String probe = "Ignite\u00e9"; - - // Compact strings can be disabled with -XX:-CompactStrings, then all strings are UTF-16 encoded. - if (GridUnsafe.getByteField(probe, offsets.get1()) != LATIN1) - return false; - - Object val = GridUnsafe.getObjectField(probe, offsets.get2()); - - if (!(val instanceof byte[] arr)) - return false; - - if (arr.length != probe.length()) - return false; - - for (int i = 0; i < arr.length; i++) { - if ((arr[i] & 0xFF) != probe.charAt(i)) - return false; - } - - return true; - } - /** */ - private static @Nullable MethodHandle hasNegativesHandle() { + private static @Nullable MethodHandle hasNegatives() { if (ZERO_COPY) { try { Method mtd = Class.forName("java.lang.StringCoding").getDeclaredMethod("hasNegatives", byte[].class, int.class, int.class); diff --git a/modules/commons/src/main/java/org/apache/ignite/IgniteCommonsSystemProperties.java b/modules/commons/src/main/java/org/apache/ignite/IgniteCommonsSystemProperties.java index 275ab07507a96..6bd0f7da69362 100644 --- a/modules/commons/src/main/java/org/apache/ignite/IgniteCommonsSystemProperties.java +++ b/modules/commons/src/main/java/org/apache/ignite/IgniteCommonsSystemProperties.java @@ -129,11 +129,10 @@ public class IgniteCommonsSystemProperties { "IGNITE_BINARY_MARSHALLER_USE_STRING_SERIALIZATION_VER_2"; /** - * When set to {@code false}, disables zero-copy UTF-8 serialization of {@link String} values by BinaryMarshaller. + * Enables zero-copy UTF-8 serialization of {@link String} values. * Default value is {@code true}. */ - @SystemProperty(value = "Enables zero-copy UTF-8 serialization of String values by BinaryMarshaller. " + - "When set to false, strings are serialized through a temporary byte array", defaults = "true") + @SystemProperty(value = "Enables zero-copy UTF-8 serialization of String values", defaults = "true") public static final String IGNITE_BINARY_STRING_ZERO_COPY = "IGNITE_BINARY_STRING_ZERO_COPY"; /** diff --git a/modules/core/src/main/java/org/apache/ignite/internal/direct/stream/DirectByteBufferStream.java b/modules/core/src/main/java/org/apache/ignite/internal/direct/stream/DirectByteBufferStream.java index 21f418e8e373f..419e7835377fe 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/direct/stream/DirectByteBufferStream.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/direct/stream/DirectByteBufferStream.java @@ -738,7 +738,7 @@ public void writeString(String val) { curStrBackingArr = StringWriter.latin1Value(val); if (curStrBackingArr == null) - val.getBytes(); + curStrBackingArr = val.getBytes(); } writeByteArray(curStrBackingArr); diff --git a/modules/core/src/test/java/org/apache/ignite/internal/binary/StringWriterSelfTest.java b/modules/core/src/test/java/org/apache/ignite/internal/binary/StringWriterSelfTest.java index 2117ef3385361..c17219d51fd94 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/binary/StringWriterSelfTest.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/binary/StringWriterSelfTest.java @@ -21,7 +21,6 @@ import java.util.concurrent.ThreadLocalRandom; import org.apache.ignite.internal.binary.streams.BinaryOutputStream; import org.apache.ignite.internal.binary.streams.BinaryStreams; -import org.apache.ignite.internal.binary.streams.BinaryStreamsTestUtils; import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest; import org.junit.Test; @@ -119,11 +118,7 @@ else if (bucket < 90) } } - /** - * Tests strings whose UTF-8 form is larger than the stream's minimal capacity, so that the encoder's own capacity - * reservation (rather than the buffer's initial slack) is what keeps the unchecked writes in bounds. Covers every - * encoder path on both heap and offheap streams. - */ + /** Tests strings whose UTF-8 form is larger than the stream's minimal capacity. */ @Test public void testLargeStrings() { int len = 100_000; @@ -181,12 +176,10 @@ public void testStreamPosition() { * @param str String to check. */ private void check(String str) { - for (boolean heapStream : new boolean[] {true, false}) { - try (BinaryOutputStream out = heapStream ? BinaryStreams.outputStream(1) : BinaryStreamsTestUtils.offheapOutputStream(1)) { - StringWriter.write(str, out); + try (BinaryOutputStream out = BinaryStreams.outputStream(1)) { + StringWriter.write(str, out); - assertTrue("String serialization mismatch: " + str, Arrays.equals(strBytes(str), out.arrayCopy())); - } + assertTrue("String serialization mismatch: " + str, Arrays.equals(strBytes(str), out.arrayCopy())); } } diff --git a/modules/core/src/test/java/org/apache/ignite/internal/binary/streams/BinaryStreamsTestUtils.java b/modules/core/src/test/java/org/apache/ignite/internal/binary/streams/BinaryStreamsTestUtils.java index f8d16d30dacaa..4e6ae07254679 100644 --- a/modules/core/src/test/java/org/apache/ignite/internal/binary/streams/BinaryStreamsTestUtils.java +++ b/modules/core/src/test/java/org/apache/ignite/internal/binary/streams/BinaryStreamsTestUtils.java @@ -29,12 +29,4 @@ public class BinaryStreamsTestUtils { public static boolean threadLocalIsAcquired() { return THREAD_LOCAL.isAcquired(); } - - /** - * @param cap Initial capacity. - * @return Offheap output stream. - */ - public static BinaryOutputStream offheapOutputStream(int cap) { - return new BinaryOffheapOutputStream(cap); - } } From 55feffdeb742fd681f6b489c399c21f0bd332d8d Mon Sep 17 00:00:00 2001 From: Nikolay Izhikov Date: Fri, 4 Sep 2026 16:55:29 +0300 Subject: [PATCH 6/9] IGNITE-27088 BinaryWriter should use internal String#value --- .../test/java/org/apache/ignite/testsuites/ScriptTestSuite.java | 2 +- .../java/org/apache/ignite/IgniteCommonsSystemProperties.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/calcite/src/test/java/org/apache/ignite/testsuites/ScriptTestSuite.java b/modules/calcite/src/test/java/org/apache/ignite/testsuites/ScriptTestSuite.java index fbed0087a3aeb..6c660b8097047 100644 --- a/modules/calcite/src/test/java/org/apache/ignite/testsuites/ScriptTestSuite.java +++ b/modules/calcite/src/test/java/org/apache/ignite/testsuites/ScriptTestSuite.java @@ -72,6 +72,6 @@ * */ @RunWith(ScriptTestRunner.class) -@ScriptRunnerTestsEnvironment(scriptsRoot = "modules/calcite/src/test/sql", timeout = 180000) +@ScriptRunnerTestsEnvironment(scriptsRoot = "modules/calcite/src/test/sql", timeout = 180000, regex = "test_replace") public class ScriptTestSuite { } diff --git a/modules/commons/src/main/java/org/apache/ignite/IgniteCommonsSystemProperties.java b/modules/commons/src/main/java/org/apache/ignite/IgniteCommonsSystemProperties.java index 6bd0f7da69362..ab43cde019ec2 100644 --- a/modules/commons/src/main/java/org/apache/ignite/IgniteCommonsSystemProperties.java +++ b/modules/commons/src/main/java/org/apache/ignite/IgniteCommonsSystemProperties.java @@ -132,7 +132,7 @@ public class IgniteCommonsSystemProperties { * Enables zero-copy UTF-8 serialization of {@link String} values. * Default value is {@code true}. */ - @SystemProperty(value = "Enables zero-copy UTF-8 serialization of String values", defaults = "true") + @SystemProperty(value = "Enables zero-copy UTF-8 serialization of String values", defaults = "false") public static final String IGNITE_BINARY_STRING_ZERO_COPY = "IGNITE_BINARY_STRING_ZERO_COPY"; /** From ccb6f145a13273b450ea3d775fe4d774c75ca986 Mon Sep 17 00:00:00 2001 From: Nikolay Izhikov Date: Fri, 4 Sep 2026 17:25:19 +0300 Subject: [PATCH 7/9] IGNITE-27088 BinaryWriter should use internal String#value --- .../internal/binary/BinaryWriterExImpl.java | 3 +- .../ignite/internal/binary/StringWriter.java | 2 +- .../ignite/IgniteCommonsSystemProperties.java | 5 +- .../direct/stream/DirectByteBufferStream.java | 109 +++++++++--------- 4 files changed, 62 insertions(+), 57 deletions(-) diff --git a/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/BinaryWriterExImpl.java b/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/BinaryWriterExImpl.java index 12122b2acd01c..6c609b88e8435 100644 --- a/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/BinaryWriterExImpl.java +++ b/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/BinaryWriterExImpl.java @@ -42,6 +42,7 @@ import org.jetbrains.annotations.Nullable; import static java.nio.charset.StandardCharsets.UTF_8; +import static org.apache.ignite.IgniteCommonsSystemProperties.DFLT_ZERO_COPY; import static org.apache.ignite.IgniteCommonsSystemProperties.IGNITE_BINARY_STRING_ZERO_COPY; import static org.apache.ignite.internal.util.CommonUtils.MAX_ARRAY_SIZE; @@ -50,7 +51,7 @@ */ class BinaryWriterExImpl implements BinaryWriterEx { /** Zero-copy serialization enabled flag. */ - static final boolean ZERO_COPY = IgniteCommonsSystemProperties.getBoolean(IGNITE_BINARY_STRING_ZERO_COPY, true); + static final boolean ZERO_COPY = IgniteCommonsSystemProperties.getBoolean(IGNITE_BINARY_STRING_ZERO_COPY, DFLT_ZERO_COPY); /** Length: integer. */ private static final int LEN_INT = 4; diff --git a/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/StringWriter.java b/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/StringWriter.java index 7615cf9af265e..e7a4b25ba4a17 100644 --- a/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/StringWriter.java +++ b/modules/binary/impl/src/main/java/org/apache/ignite/internal/binary/StringWriter.java @@ -302,7 +302,7 @@ public static byte[] latin1Value(String val) { * @param arr Array. * @return {@code True} if the array contains a byte with the sign bit set. */ - private static boolean hasNegatives(byte[] arr) { + public static boolean hasNegatives(byte[] arr) { if (HAS_NEGATIVES != null) { try { return (boolean)HAS_NEGATIVES.invokeExact(arr, 0, arr.length); diff --git a/modules/commons/src/main/java/org/apache/ignite/IgniteCommonsSystemProperties.java b/modules/commons/src/main/java/org/apache/ignite/IgniteCommonsSystemProperties.java index ab43cde019ec2..62e4b5f83187d 100644 --- a/modules/commons/src/main/java/org/apache/ignite/IgniteCommonsSystemProperties.java +++ b/modules/commons/src/main/java/org/apache/ignite/IgniteCommonsSystemProperties.java @@ -38,6 +38,9 @@ public class IgniteCommonsSystemProperties { /** Default value of {@link IgniteCommonsSystemProperties#IGNITE_USE_BINARY_ARRAYS}. */ public static final boolean DFLT_IGNITE_USE_BINARY_ARRAYS = false; + /** Default value of {@link IgniteCommonsSystemProperties#IGNITE_BINARY_STRING_ZERO_COPY}. */ + public static final boolean DFLT_ZERO_COPY = true; + /** * Setting to {@code true} enables writing sensitive information in {@code toString()} output. */ @@ -132,7 +135,7 @@ public class IgniteCommonsSystemProperties { * Enables zero-copy UTF-8 serialization of {@link String} values. * Default value is {@code true}. */ - @SystemProperty(value = "Enables zero-copy UTF-8 serialization of String values", defaults = "false") + @SystemProperty(value = "Enables zero-copy UTF-8 serialization of String values", defaults = "" + DFLT_ZERO_COPY) public static final String IGNITE_BINARY_STRING_ZERO_COPY = "IGNITE_BINARY_STRING_ZERO_COPY"; /** diff --git a/modules/core/src/main/java/org/apache/ignite/internal/direct/stream/DirectByteBufferStream.java b/modules/core/src/main/java/org/apache/ignite/internal/direct/stream/DirectByteBufferStream.java index 419e7835377fe..345f03953d049 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/direct/stream/DirectByteBufferStream.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/direct/stream/DirectByteBufferStream.java @@ -17,57 +17,58 @@ package org.apache.ignite.internal.direct.stream; -import java.lang.reflect.Array; -import java.nio.ByteBuffer; -import java.util.ArrayList; -import java.util.BitSet; -import java.util.Collection; -import java.util.EnumSet; -import java.util.HashSet; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.RandomAccess; -import java.util.UUID; -import java.util.function.BooleanSupplier; -import java.util.function.Supplier; -import org.apache.ignite.IgniteCheckedException; -import org.apache.ignite.IgniteException; -import org.apache.ignite.internal.binary.StringWriter; -import org.apache.ignite.internal.managers.communication.CompressedMessage; -import org.apache.ignite.internal.processors.affinity.AffinityTopologyVersion; -import org.apache.ignite.internal.processors.cache.CacheObject; -import org.apache.ignite.internal.processors.cache.KeyCacheObject; -import org.apache.ignite.internal.processors.cache.version.GridCacheVersion; -import org.apache.ignite.internal.processors.cache.version.GridCacheVersionEx; -import org.apache.ignite.internal.processors.cacheobject.IgniteCacheObjectProcessor; -import org.apache.ignite.internal.util.GridLongList; -import org.apache.ignite.internal.util.GridUnsafe; -import org.apache.ignite.internal.util.nio.MessageSerialization; -import org.apache.ignite.internal.util.tostring.GridToStringExclude; -import org.apache.ignite.internal.util.typedef.internal.S; -import org.apache.ignite.internal.util.typedef.internal.U; -import org.apache.ignite.lang.IgniteProductVersion; -import org.apache.ignite.lang.IgniteUuid; -import org.apache.ignite.plugin.extensions.communication.Message; -import org.apache.ignite.plugin.extensions.communication.MessageArrayType; -import org.apache.ignite.plugin.extensions.communication.MessageCollectionType; -import org.apache.ignite.plugin.extensions.communication.MessageEnumType; -import org.apache.ignite.plugin.extensions.communication.MessageFactory; -import org.apache.ignite.plugin.extensions.communication.MessageMapType; -import org.apache.ignite.plugin.extensions.communication.MessageReader; -import org.apache.ignite.plugin.extensions.communication.MessageType; -import org.apache.ignite.plugin.extensions.communication.MessageWriter; -import org.jetbrains.annotations.Nullable; - -import static org.apache.ignite.internal.util.GridUnsafe.BIG_ENDIAN; -import static org.apache.ignite.internal.util.GridUnsafe.BYTE_ARR_OFF; -import static org.apache.ignite.internal.util.GridUnsafe.CHAR_ARR_OFF; -import static org.apache.ignite.internal.util.GridUnsafe.DOUBLE_ARR_OFF; -import static org.apache.ignite.internal.util.GridUnsafe.FLOAT_ARR_OFF; -import static org.apache.ignite.internal.util.GridUnsafe.INT_ARR_OFF; -import static org.apache.ignite.internal.util.GridUnsafe.LONG_ARR_OFF; -import static org.apache.ignite.internal.util.GridUnsafe.SHORT_ARR_OFF; + import java.lang.reflect.Array; + import java.nio.ByteBuffer; + import java.nio.charset.StandardCharsets; + import java.util.ArrayList; + import java.util.BitSet; + import java.util.Collection; + import java.util.EnumSet; + import java.util.HashSet; + import java.util.Iterator; + import java.util.List; + import java.util.Map; + import java.util.RandomAccess; + import java.util.UUID; + import java.util.function.BooleanSupplier; + import java.util.function.Supplier; + import org.apache.ignite.IgniteCheckedException; + import org.apache.ignite.IgniteException; + import org.apache.ignite.internal.binary.StringWriter; + import org.apache.ignite.internal.managers.communication.CompressedMessage; + import org.apache.ignite.internal.processors.affinity.AffinityTopologyVersion; + import org.apache.ignite.internal.processors.cache.CacheObject; + import org.apache.ignite.internal.processors.cache.KeyCacheObject; + import org.apache.ignite.internal.processors.cache.version.GridCacheVersion; + import org.apache.ignite.internal.processors.cache.version.GridCacheVersionEx; + import org.apache.ignite.internal.processors.cacheobject.IgniteCacheObjectProcessor; + import org.apache.ignite.internal.util.GridLongList; + import org.apache.ignite.internal.util.GridUnsafe; + import org.apache.ignite.internal.util.nio.MessageSerialization; + import org.apache.ignite.internal.util.tostring.GridToStringExclude; + import org.apache.ignite.internal.util.typedef.internal.S; + import org.apache.ignite.internal.util.typedef.internal.U; + import org.apache.ignite.lang.IgniteProductVersion; + import org.apache.ignite.lang.IgniteUuid; + import org.apache.ignite.plugin.extensions.communication.Message; + import org.apache.ignite.plugin.extensions.communication.MessageArrayType; + import org.apache.ignite.plugin.extensions.communication.MessageCollectionType; + import org.apache.ignite.plugin.extensions.communication.MessageEnumType; + import org.apache.ignite.plugin.extensions.communication.MessageFactory; + import org.apache.ignite.plugin.extensions.communication.MessageMapType; + import org.apache.ignite.plugin.extensions.communication.MessageReader; + import org.apache.ignite.plugin.extensions.communication.MessageType; + import org.apache.ignite.plugin.extensions.communication.MessageWriter; + import org.jetbrains.annotations.Nullable; + + import static org.apache.ignite.internal.util.GridUnsafe.BIG_ENDIAN; + import static org.apache.ignite.internal.util.GridUnsafe.BYTE_ARR_OFF; + import static org.apache.ignite.internal.util.GridUnsafe.CHAR_ARR_OFF; + import static org.apache.ignite.internal.util.GridUnsafe.DOUBLE_ARR_OFF; + import static org.apache.ignite.internal.util.GridUnsafe.FLOAT_ARR_OFF; + import static org.apache.ignite.internal.util.GridUnsafe.INT_ARR_OFF; + import static org.apache.ignite.internal.util.GridUnsafe.LONG_ARR_OFF; + import static org.apache.ignite.internal.util.GridUnsafe.SHORT_ARR_OFF; /** * Direct marshalling I/O stream. @@ -737,8 +738,8 @@ public void writeString(String val) { if (curStrBackingArr == null) { curStrBackingArr = StringWriter.latin1Value(val); - if (curStrBackingArr == null) - curStrBackingArr = val.getBytes(); + if (curStrBackingArr == null || StringWriter.hasNegatives(curStrBackingArr)) + curStrBackingArr = val.getBytes(StandardCharsets.UTF_8); } writeByteArray(curStrBackingArr); @@ -1367,7 +1368,7 @@ public boolean[] readBooleanArray() { public String readString() { byte[] arr = readByteArray(); - return arr != null ? new String(arr) : null; + return arr != null ? new String(arr, StandardCharsets.UTF_8) : null; } /** From 351fd2bed2eef674c7b9e0ccbd3b890e65280eed Mon Sep 17 00:00:00 2001 From: Nikolay Izhikov Date: Fri, 4 Sep 2026 17:53:23 +0300 Subject: [PATCH 8/9] IGNITE-27088 BinaryWriter should use internal String#value --- .../direct/stream/DirectByteBufferStream.java | 104 +++++++++--------- 1 file changed, 52 insertions(+), 52 deletions(-) diff --git a/modules/core/src/main/java/org/apache/ignite/internal/direct/stream/DirectByteBufferStream.java b/modules/core/src/main/java/org/apache/ignite/internal/direct/stream/DirectByteBufferStream.java index 345f03953d049..d2176dfb6f617 100644 --- a/modules/core/src/main/java/org/apache/ignite/internal/direct/stream/DirectByteBufferStream.java +++ b/modules/core/src/main/java/org/apache/ignite/internal/direct/stream/DirectByteBufferStream.java @@ -17,58 +17,58 @@ package org.apache.ignite.internal.direct.stream; - import java.lang.reflect.Array; - import java.nio.ByteBuffer; - import java.nio.charset.StandardCharsets; - import java.util.ArrayList; - import java.util.BitSet; - import java.util.Collection; - import java.util.EnumSet; - import java.util.HashSet; - import java.util.Iterator; - import java.util.List; - import java.util.Map; - import java.util.RandomAccess; - import java.util.UUID; - import java.util.function.BooleanSupplier; - import java.util.function.Supplier; - import org.apache.ignite.IgniteCheckedException; - import org.apache.ignite.IgniteException; - import org.apache.ignite.internal.binary.StringWriter; - import org.apache.ignite.internal.managers.communication.CompressedMessage; - import org.apache.ignite.internal.processors.affinity.AffinityTopologyVersion; - import org.apache.ignite.internal.processors.cache.CacheObject; - import org.apache.ignite.internal.processors.cache.KeyCacheObject; - import org.apache.ignite.internal.processors.cache.version.GridCacheVersion; - import org.apache.ignite.internal.processors.cache.version.GridCacheVersionEx; - import org.apache.ignite.internal.processors.cacheobject.IgniteCacheObjectProcessor; - import org.apache.ignite.internal.util.GridLongList; - import org.apache.ignite.internal.util.GridUnsafe; - import org.apache.ignite.internal.util.nio.MessageSerialization; - import org.apache.ignite.internal.util.tostring.GridToStringExclude; - import org.apache.ignite.internal.util.typedef.internal.S; - import org.apache.ignite.internal.util.typedef.internal.U; - import org.apache.ignite.lang.IgniteProductVersion; - import org.apache.ignite.lang.IgniteUuid; - import org.apache.ignite.plugin.extensions.communication.Message; - import org.apache.ignite.plugin.extensions.communication.MessageArrayType; - import org.apache.ignite.plugin.extensions.communication.MessageCollectionType; - import org.apache.ignite.plugin.extensions.communication.MessageEnumType; - import org.apache.ignite.plugin.extensions.communication.MessageFactory; - import org.apache.ignite.plugin.extensions.communication.MessageMapType; - import org.apache.ignite.plugin.extensions.communication.MessageReader; - import org.apache.ignite.plugin.extensions.communication.MessageType; - import org.apache.ignite.plugin.extensions.communication.MessageWriter; - import org.jetbrains.annotations.Nullable; - - import static org.apache.ignite.internal.util.GridUnsafe.BIG_ENDIAN; - import static org.apache.ignite.internal.util.GridUnsafe.BYTE_ARR_OFF; - import static org.apache.ignite.internal.util.GridUnsafe.CHAR_ARR_OFF; - import static org.apache.ignite.internal.util.GridUnsafe.DOUBLE_ARR_OFF; - import static org.apache.ignite.internal.util.GridUnsafe.FLOAT_ARR_OFF; - import static org.apache.ignite.internal.util.GridUnsafe.INT_ARR_OFF; - import static org.apache.ignite.internal.util.GridUnsafe.LONG_ARR_OFF; - import static org.apache.ignite.internal.util.GridUnsafe.SHORT_ARR_OFF; +import java.lang.reflect.Array; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.BitSet; +import java.util.Collection; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.RandomAccess; +import java.util.UUID; +import java.util.function.BooleanSupplier; +import java.util.function.Supplier; +import org.apache.ignite.IgniteCheckedException; +import org.apache.ignite.IgniteException; +import org.apache.ignite.internal.binary.StringWriter; +import org.apache.ignite.internal.managers.communication.CompressedMessage; +import org.apache.ignite.internal.processors.affinity.AffinityTopologyVersion; +import org.apache.ignite.internal.processors.cache.CacheObject; +import org.apache.ignite.internal.processors.cache.KeyCacheObject; +import org.apache.ignite.internal.processors.cache.version.GridCacheVersion; +import org.apache.ignite.internal.processors.cache.version.GridCacheVersionEx; +import org.apache.ignite.internal.processors.cacheobject.IgniteCacheObjectProcessor; +import org.apache.ignite.internal.util.GridLongList; +import org.apache.ignite.internal.util.GridUnsafe; +import org.apache.ignite.internal.util.nio.MessageSerialization; +import org.apache.ignite.internal.util.tostring.GridToStringExclude; +import org.apache.ignite.internal.util.typedef.internal.S; +import org.apache.ignite.internal.util.typedef.internal.U; +import org.apache.ignite.lang.IgniteProductVersion; +import org.apache.ignite.lang.IgniteUuid; +import org.apache.ignite.plugin.extensions.communication.Message; +import org.apache.ignite.plugin.extensions.communication.MessageArrayType; +import org.apache.ignite.plugin.extensions.communication.MessageCollectionType; +import org.apache.ignite.plugin.extensions.communication.MessageEnumType; +import org.apache.ignite.plugin.extensions.communication.MessageFactory; +import org.apache.ignite.plugin.extensions.communication.MessageMapType; +import org.apache.ignite.plugin.extensions.communication.MessageReader; +import org.apache.ignite.plugin.extensions.communication.MessageType; +import org.apache.ignite.plugin.extensions.communication.MessageWriter; +import org.jetbrains.annotations.Nullable; + +import static org.apache.ignite.internal.util.GridUnsafe.BIG_ENDIAN; +import static org.apache.ignite.internal.util.GridUnsafe.BYTE_ARR_OFF; +import static org.apache.ignite.internal.util.GridUnsafe.CHAR_ARR_OFF; +import static org.apache.ignite.internal.util.GridUnsafe.DOUBLE_ARR_OFF; +import static org.apache.ignite.internal.util.GridUnsafe.FLOAT_ARR_OFF; +import static org.apache.ignite.internal.util.GridUnsafe.INT_ARR_OFF; +import static org.apache.ignite.internal.util.GridUnsafe.LONG_ARR_OFF; +import static org.apache.ignite.internal.util.GridUnsafe.SHORT_ARR_OFF; /** * Direct marshalling I/O stream. From f7ce522c54014276654c4a60d78b3f67e521dcd9 Mon Sep 17 00:00:00 2001 From: Nikolay Izhikov Date: Fri, 4 Sep 2026 18:23:03 +0300 Subject: [PATCH 9/9] IGNITE-27088 BinaryWriter should use internal String#value --- .../test/java/org/apache/ignite/testsuites/ScriptTestSuite.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/calcite/src/test/java/org/apache/ignite/testsuites/ScriptTestSuite.java b/modules/calcite/src/test/java/org/apache/ignite/testsuites/ScriptTestSuite.java index 6c660b8097047..fbed0087a3aeb 100644 --- a/modules/calcite/src/test/java/org/apache/ignite/testsuites/ScriptTestSuite.java +++ b/modules/calcite/src/test/java/org/apache/ignite/testsuites/ScriptTestSuite.java @@ -72,6 +72,6 @@ * */ @RunWith(ScriptTestRunner.class) -@ScriptRunnerTestsEnvironment(scriptsRoot = "modules/calcite/src/test/sql", timeout = 180000, regex = "test_replace") +@ScriptRunnerTestsEnvironment(scriptsRoot = "modules/calcite/src/test/sql", timeout = 180000) public class ScriptTestSuite { }