You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@lucene.apache.org by dw...@apache.org on 2026/06/29 07:04:59 UTC
(lucene) branch branch_10x updated: Single-pass writeString fast path for short strings in ByteBuffersDataOutput (#16280)
This is an automated email from the ASF dual-hosted git repository.
dweiss pushed a commit to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/lucene.git
The following commit(s) were added to refs/heads/branch_10x by this push:
new fb98fa6ecd1 Single-pass writeString fast path for short strings in ByteBuffersDataOutput (#16280)
fb98fa6ecd1 is described below
commit fb98fa6ecd13b51218f351e78fab25287f656663
Author: neoReMinD <xu...@gmail.com>
AuthorDate: Mon Jun 29 15:03:16 2026 +0800
Single-pass writeString fast path for short strings in ByteBuffersDataOutput (#16280)
* Apply fast path for short strings (charCount <= 42) writeString in ByteBuffersDataOutput
* Update CHANGES.txt
* Cleanup of repeatable code.
---------
Co-authored-by: Dawid Weiss <da...@carrotsearch.com>
---
lucene/CHANGES.txt | 2 +-
.../ByteBuffersDataOutputWriteStringBenchmark.java | 222 +++++++++++++++++++++
.../apache/lucene/store/ByteBuffersDataOutput.java | 21 +-
.../java/org/apache/lucene/util/UnicodeUtil.java | 3 +
4 files changed, 246 insertions(+), 2 deletions(-)
diff --git a/lucene/CHANGES.txt b/lucene/CHANGES.txt
index 27bd76547d6..821d34f79d4 100644
--- a/lucene/CHANGES.txt
+++ b/lucene/CHANGES.txt
@@ -19,7 +19,7 @@ Improvements
Optimizations
---------------------
-(No changes)
+* GITHUG#16280: Single-pass writeString fast path for short strings in ByteBuffersDataOutput (neoremind)
Bug Fixes
---------------------
diff --git a/lucene/benchmark-jmh/src/java/org/apache/lucene/benchmark/jmh/ByteBuffersDataOutputWriteStringBenchmark.java b/lucene/benchmark-jmh/src/java/org/apache/lucene/benchmark/jmh/ByteBuffersDataOutputWriteStringBenchmark.java
new file mode 100644
index 00000000000..0b0361e1fb6
--- /dev/null
+++ b/lucene/benchmark-jmh/src/java/org/apache/lucene/benchmark/jmh/ByteBuffersDataOutputWriteStringBenchmark.java
@@ -0,0 +1,222 @@
+/*
+ * 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.lucene.benchmark.jmh;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Random;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Supplier;
+import org.apache.lucene.store.ByteBuffersDataOutput;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Warmup;
+import org.openjdk.jmh.infra.Blackhole;
+
+@BenchmarkMode(Mode.Throughput)
+@OutputTimeUnit(TimeUnit.SECONDS)
+@State(Scope.Benchmark)
+@Warmup(iterations = 5, time = 3)
+@Measurement(iterations = 5, time = 3)
+@Fork(
+ value = 3,
+ jvmArgsAppend = {"-Xmx1g", "-Xms1g", "-XX:+AlwaysPreTouch"})
+public class ByteBuffersDataOutputWriteStringBenchmark {
+
+ private static final int STRING_POOL_SIZE = 8192;
+
+ @Param({
+ "ascii_1",
+ "ascii_10",
+ "ascii_20",
+ "ascii_30",
+ "ascii_40",
+ "ascii_medium",
+ "ascii_long",
+ "ascii_vlarge",
+ "cjk_1",
+ "cjk_10",
+ "cjk_20",
+ "cjk_30",
+ "cjk_40",
+ "cjk_medium",
+ "cjk_long",
+ "cjk_vlarge",
+ "latin_ext_1",
+ "latin_ext_10",
+ "latin_ext_20",
+ "latin_ext_30",
+ "latin_ext_40",
+ "latin_ext_medium",
+ "latin_ext_long",
+ "latin_ext_vlarge",
+ "mixed"
+ })
+ public String stringType;
+
+ /** Target bytes to write per invocation. */
+ @Param({"81920", "491520", "2097152"})
+ public int targetBytes;
+
+ /** Pre-generated strings to write. */
+ private String[] testStrings;
+
+ /** Number of strings to write per invocation to reach targetBytes total output. */
+ private int stringsPerInvocation;
+
+ private ByteBuffersDataOutput reusableOutput;
+
+ @Setup(Level.Trial)
+ public void setup() {
+ Random random = new Random(42);
+ testStrings = new String[STRING_POOL_SIZE];
+
+ Supplier<String> generator =
+ switch (stringType) {
+ case "ascii_1" -> () -> randomAscii(random, 1);
+ case "ascii_10" -> () -> randomAscii(random, 8 + random.nextInt(5));
+ case "ascii_20" -> () -> randomAscii(random, 18 + random.nextInt(5));
+ case "ascii_30" -> () -> randomAscii(random, 28 + random.nextInt(5));
+ case "ascii_40" -> () -> randomAscii(random, 38 + random.nextInt(5));
+ case "ascii_medium" -> () -> randomAscii(random, 50 + random.nextInt(100));
+ case "ascii_long" -> () -> randomAscii(random, 900 + random.nextInt(250));
+ case "ascii_vlarge" -> () -> randomAscii(random, 7000 + random.nextInt(2400));
+ case "cjk_1" -> () -> randomCjk(random, 1);
+ case "cjk_10" -> () -> randomCjk(random, 8 + random.nextInt(5));
+ case "cjk_20" -> () -> randomCjk(random, 18 + random.nextInt(5));
+ case "cjk_30" -> () -> randomCjk(random, 28 + random.nextInt(5));
+ case "cjk_40" -> () -> randomCjk(random, 38 + random.nextInt(5));
+ case "cjk_medium" -> () -> randomCjk(random, 50 + random.nextInt(100));
+ case "cjk_long" -> () -> randomCjk(random, 400 + random.nextInt(200));
+ case "cjk_vlarge" -> () -> randomCjk(random, 5500 + random.nextInt(1000));
+ case "latin_ext_1" -> () -> randomLatinExtended(random, 1);
+ case "latin_ext_10" -> () -> randomLatinExtended(random, 8 + random.nextInt(5));
+ case "latin_ext_20" -> () -> randomLatinExtended(random, 18 + random.nextInt(5));
+ case "latin_ext_30" -> () -> randomLatinExtended(random, 28 + random.nextInt(5));
+ case "latin_ext_40" -> () -> randomLatinExtended(random, 38 + random.nextInt(5));
+ case "latin_ext_medium" -> () -> randomLatinExtended(random, 50 + random.nextInt(100));
+ case "latin_ext_long" -> () -> randomLatinExtended(random, 400 + random.nextInt(200));
+ case "latin_ext_vlarge" -> () -> randomLatinExtended(random, 5500 + random.nextInt(1000));
+ case "mixed" ->
+ () -> {
+ // Varying lengths
+ int roll = random.nextInt(100);
+ if (roll < 50) {
+ return randomAscii(random, 3 + random.nextInt(30));
+ } else if (roll < 65) {
+ return randomAscii(random, 50 + random.nextInt(100));
+ } else if (roll < 75) {
+ return randomAscii(random, 500 + random.nextInt(500));
+ } else if (roll < 85) {
+ return randomCjk(random, 5 + random.nextInt(20));
+ } else if (roll < 95) {
+ return randomLatinExtended(random, 20 + random.nextInt(60));
+ } else {
+ return randomCjk(random, 200 + random.nextInt(300));
+ }
+ };
+ default -> throw new IllegalArgumentException("Unknown stringType: " + stringType);
+ };
+
+ long totalBytes = 0;
+ for (int i = 0; i < STRING_POOL_SIZE; i++) {
+ testStrings[i] = generator.get();
+ totalBytes += testStrings[i].getBytes(StandardCharsets.UTF_8).length;
+ }
+ int avgBytesPerString = Math.max(1, (int) (totalBytes / STRING_POOL_SIZE));
+
+ stringsPerInvocation = targetBytes / avgBytesPerString;
+
+ reusableOutput = ByteBuffersDataOutput.newResettableInstance();
+ }
+
+ private ByteBuffersDataOutput getOutput() {
+ reusableOutput.reset();
+ return reusableOutput;
+ }
+
+ @Benchmark
+ public void writeString(Blackhole bh) {
+ ByteBuffersDataOutput output = getOutput();
+ for (int i = 0; i < stringsPerInvocation; i++) {
+ output.writeString(testStrings[i % STRING_POOL_SIZE]);
+ }
+ bh.consume(output.size());
+ }
+
+ private String randomAscii(Random random, int length) {
+ char[] chars = new char[length];
+ for (int i = 0; i < length; i++) {
+ chars[i] = (char) (32 + random.nextInt(95));
+ }
+ return new String(chars);
+ }
+
+ /**
+ * Generates realistic CJK text: ~90% CJK Unified Ideographs (3-byte UTF-8), ~9% ASCII digits
+ * (1-byte), ~1% surrogate pairs (emoji, rare CJK-B characters, 4-byte UTF-8).
+ */
+ private String randomCjk(Random random, int length) {
+ char[] chars = new char[length + 1]; // +1 room for potential surrogate pair expansion
+ int pos = 0;
+ for (int i = 0; i < length && pos < chars.length - 1; i++) {
+ int roll = random.nextInt(100);
+ if (roll < 90) {
+ // CJK Unified Ideographs: U+4E00–U+9FFF (3 bytes in UTF-8)
+ chars[pos++] = (char) (0x4E00 + random.nextInt(0x9FFF - 0x4E00));
+ } else if (roll < 99) {
+ // ASCII digits
+ chars[pos++] = (char) (0x30 + random.nextInt(10)); // 0-9
+ } else {
+ // Surrogate pair, 4 bytes UTF-8
+ if (pos < chars.length - 1) {
+ chars[pos++] = (char) (0xD800 + random.nextInt(0x400)); // high surrogate
+ chars[pos++] = (char) (0xDC00 + random.nextInt(0x400)); // low surrogate
+ } else {
+ chars[pos++] = (char) (0x4E00 + random.nextInt(0x9FFF - 0x4E00));
+ }
+ }
+ }
+ return new String(chars, 0, pos);
+ }
+
+ private String randomLatinExtended(Random random, int length) {
+ char[] chars = new char[length];
+ for (int i = 0; i < length; i++) {
+ int roll = random.nextInt(100);
+ if (roll < 80) {
+ // 2-byte UTF-8
+ chars[i] = (char) (0x0080 + random.nextInt(0x0700));
+ } else if (roll < 95) {
+ // ASCII
+ chars[i] = (char) (0x41 + random.nextInt(26)); // A-Z
+ } else {
+ // 3-byte UTF-8
+ chars[i] = (char) (0x2000 + random.nextInt(0xBFF));
+ }
+ }
+ return new String(chars);
+ }
+}
diff --git a/lucene/core/src/java/org/apache/lucene/store/ByteBuffersDataOutput.java b/lucene/core/src/java/org/apache/lucene/store/ByteBuffersDataOutput.java
index 1c6bcd63629..33972d2959c 100644
--- a/lucene/core/src/java/org/apache/lucene/store/ByteBuffersDataOutput.java
+++ b/lucene/core/src/java/org/apache/lucene/store/ByteBuffersDataOutput.java
@@ -415,9 +415,28 @@ public final class ByteBuffersDataOutput extends DataOutput implements Accountab
public void writeString(String v) {
try {
final int charCount = v.length();
+ ByteBuffer currentBlock = this.currentBlock;
+
+ // Fast path for short strings (charCount <= 42): the VInt length prefix is guaranteed to be 1
+ // byte,
+ // so we can encode directly and backfill the length without computing the UTF-8 byte count
+ // upfront.
+ if (charCount <= UnicodeUtil.MAX_CHARS_FOR_1_BYTE_VINT
+ && currentBlock.hasArray()
+ && currentBlock.remaining() >= 1 + charCount * UnicodeUtil.MAX_UTF8_BYTES_PER_CHAR) {
+ byte[] array = currentBlock.array();
+ int startingPos = currentBlock.position();
+ int off = currentBlock.arrayOffset() + startingPos;
+ int encodedEnd = UnicodeUtil.UTF16toUTF8(v, 0, charCount, array, off + 1);
+ int byteLen = encodedEnd - (off + 1);
+ array[off] = (byte) byteLen;
+ currentBlock.position(startingPos + 1 + byteLen);
+ return;
+ }
+
final int byteLen = UnicodeUtil.calcUTF16toUTF8Length(v, 0, charCount);
writeVInt(byteLen);
- ByteBuffer currentBlock = this.currentBlock;
+ currentBlock = this.currentBlock;
if (currentBlock.hasArray() && currentBlock.remaining() >= byteLen) {
int startingPos = currentBlock.position();
UnicodeUtil.UTF16toUTF8(
diff --git a/lucene/core/src/java/org/apache/lucene/util/UnicodeUtil.java b/lucene/core/src/java/org/apache/lucene/util/UnicodeUtil.java
index dcf6b0c26cb..bee930edffe 100644
--- a/lucene/core/src/java/org/apache/lucene/util/UnicodeUtil.java
+++ b/lucene/core/src/java/org/apache/lucene/util/UnicodeUtil.java
@@ -126,6 +126,9 @@ public final class UnicodeUtil {
/** Maximum number of UTF8 bytes per UTF16 character. */
public static final int MAX_UTF8_BYTES_PER_CHAR = 3;
+ /** Max charCount where the string's UTF-8 byte count is guaranteed to need only a 1-byte VInt */
+ public static final int MAX_CHARS_FOR_1_BYTE_VINT = 127 / MAX_UTF8_BYTES_PER_CHAR;
+
/**
* Encode characters from a char[] source, starting at offset for length chars. It is the
* responsibility of the caller to make sure that the destination array is large enough.