This is an automated email from the ASF dual-hosted git repository.

Jackie-Jiang pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git


The following commit(s) were added to refs/heads/master by this push:
     new 201e18c8cd7 Avoid per-comparison ByteBuffer allocation in dictionary 
UTF-8 compare (#18732)
201e18c8cd7 is described below

commit 201e18c8cd7c48cef607ac018bc9e5fa665ddb78
Author: Praveen <[email protected]>
AuthorDate: Tue Aug 4 16:30:28 2026 -0700

    Avoid per-comparison ByteBuffer allocation in dictionary UTF-8 compare 
(#18732)
---
 .../pinot/perf/BenchmarkStringInListLookup.java    | 164 +++++++++++++++++++++
 .../local/io/util/ValueReaderComparisons.java      |  49 +++---
 2 files changed, 189 insertions(+), 24 deletions(-)

diff --git 
a/pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkStringInListLookup.java
 
b/pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkStringInListLookup.java
new file mode 100644
index 00000000000..dce972bdd6f
--- /dev/null
+++ 
b/pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkStringInListLookup.java
@@ -0,0 +1,164 @@
+/**
+ * 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.pinot.perf;
+
+import it.unimi.dsi.fastutil.ints.IntOpenHashSet;
+import it.unimi.dsi.fastutil.ints.IntSet;
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Random;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import org.apache.commons.io.FileUtils;
+import 
org.apache.pinot.segment.local.segment.creator.impl.SegmentDictionaryCreator;
+import org.apache.pinot.segment.local.segment.index.readers.StringDictionary;
+import org.apache.pinot.segment.spi.V1Constants;
+import org.apache.pinot.segment.spi.index.reader.Dictionary;
+import org.apache.pinot.segment.spi.memory.PinotDataBuffer;
+import org.apache.pinot.spi.data.DimensionFieldSpec;
+import org.apache.pinot.spi.data.FieldSpec.DataType;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+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.TearDown;
+import org.openjdk.jmh.runner.Runner;
+import org.openjdk.jmh.runner.options.ChainedOptionsBuilder;
+import org.openjdk.jmh.runner.options.OptionsBuilder;
+import org.openjdk.jmh.runner.options.TimeValue;
+
+
+/**
+ * Micro-benchmark for resolving a large {@code IN} list against a 
high-cardinality STRING dictionary whose values are
+ * bare signed 64-bit integers stored as strings (e.g. {@code 
"-7930618564724103528"}) with no shared prefix. This
+ * mirrors the production hot path
+ * {@code PredicateUtils.getDictIdSet -> 
BaseImmutableDictionary.getDictIdsDivideBinarySearch ->
+ * FixedByteValueReaderWriter.compareUtf8Bytes -> 
ValueReaderComparisons.mismatch}.
+ *
+ * Run the same benchmark on the baseline tree and on the patched tree to 
compare throughput and, with
+ * {@code -prof gc}, the per-probe allocation rate.
+ */
+@State(Scope.Benchmark)
+public class BenchmarkStringInListLookup {
+  private static final File INDEX_DIR = new File(FileUtils.getTempDirectory(), 
"BenchmarkStringInListLookup");
+  private static final String COLUMN_NAME = "column";
+  private static final long SEED = 42L;
+
+  @Param({"100000", "1000000"})
+  private int _cardinality;
+
+  @Param({"100", "500"})
+  private int _inListSize;
+
+  private StringDictionary _dictionary;
+  // Sorted (lexicographically) IN-list mixing values present and absent in 
the dictionary, matching the input shape
+  // that PredicateUtils hands to getDictIds(..., DIVIDE_BINARY_SEARCH).
+  private List<String> _inList;
+
+  @Setup
+  public void setUp()
+      throws IOException {
+    FileUtils.deleteDirectory(INDEX_DIR);
+    Random random = new Random(SEED);
+
+    // Generate `_cardinality` unique bare-numeric strings (full signed-long 
range -> no shared prefix).
+    Set<Long> uniqueLongs = new HashSet<>(_cardinality * 2);
+    while (uniqueLongs.size() < _cardinality) {
+      uniqueLongs.add(random.nextLong());
+    }
+    String[] sortedValues = new String[_cardinality];
+    int idx = 0;
+    for (long value : uniqueLongs) {
+      sortedValues[idx++] = Long.toString(value);
+    }
+    // The dictionary stores values sorted lexicographically, so sort the same 
way here.
+    Arrays.sort(sortedValues);
+
+    int maxLength;
+    try (SegmentDictionaryCreator creator = new SegmentDictionaryCreator(
+        new DimensionFieldSpec(COLUMN_NAME, DataType.STRING, true), INDEX_DIR, 
false)) {
+      creator.build(sortedValues);
+      maxLength = creator.getNumBytesPerEntry();
+    }
+    _dictionary = new StringDictionary(
+        PinotDataBuffer.mapReadOnlyBigEndianFile(new File(INDEX_DIR, 
COLUMN_NAME + V1Constants.Dict.FILE_EXTENSION)),
+        _cardinality, maxLength);
+
+    // Build an IN-list: ~half present (sampled from the dictionary), ~half 
absent (random longs not in the dictionary).
+    List<String> inList = new ArrayList<>(_inListSize);
+    int numPresent = _inListSize / 2;
+    for (int i = 0; i < numPresent; i++) {
+      inList.add(sortedValues[random.nextInt(_cardinality)]);
+    }
+    while (inList.size() < _inListSize) {
+      long candidate = random.nextLong();
+      if (!uniqueLongs.contains(candidate)) {
+        inList.add(Long.toString(candidate));
+      }
+    }
+    inList.sort(null);
+    _inList = inList;
+  }
+
+  @TearDown
+  public void tearDown()
+      throws Exception {
+    FileUtils.deleteDirectory(INDEX_DIR);
+  }
+
+  /** End-to-end IN-list resolution as used by the IN predicate evaluator. */
+  @Benchmark
+  @BenchmarkMode(Mode.Throughput)
+  @OutputTimeUnit(TimeUnit.MILLISECONDS)
+  public int benchmarkDivideBinarySearch() {
+    IntSet dictIds = new IntOpenHashSet();
+    _dictionary.getDictIds(_inList, dictIds, 
Dictionary.SortedBatchLookupAlgorithm.DIVIDE_BINARY_SEARCH);
+    return dictIds.size();
+  }
+
+  /** Isolates the comparison primitive: one independent binary search per 
value via indexOf. */
+  @Benchmark
+  @BenchmarkMode(Mode.Throughput)
+  @OutputTimeUnit(TimeUnit.MILLISECONDS)
+  public int benchmarkIndexOf() {
+    int sum = 0;
+    List<String> inList = _inList;
+    for (int i = 0, n = inList.size(); i < n; i++) {
+      sum += _dictionary.indexOf(inList.get(i));
+    }
+    return sum;
+  }
+
+  public static void main(String[] args)
+      throws Exception {
+    ChainedOptionsBuilder opt =
+        new 
OptionsBuilder().include(BenchmarkStringInListLookup.class.getSimpleName()).warmupTime(TimeValue.seconds(3))
+            
.warmupIterations(3).measurementTime(TimeValue.seconds(5)).measurementIterations(5).forks(1)
+            .addProfiler("gc");
+    new Runner(opt.build()).run();
+  }
+}
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/io/util/ValueReaderComparisons.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/io/util/ValueReaderComparisons.java
index a6025b673a8..f774b46aa1f 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/io/util/ValueReaderComparisons.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/io/util/ValueReaderComparisons.java
@@ -18,26 +18,32 @@
  */
 package org.apache.pinot.segment.local.io.util;
 
-import java.nio.ByteBuffer;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.VarHandle;
 import java.nio.ByteOrder;
 import org.apache.pinot.segment.spi.memory.PinotDataBuffer;
 
 
 public class ValueReaderComparisons {
+  // Read 8 bytes at a time straight from the query byte[] instead of wrapping 
it in a ByteBuffer on every comparison.
+  // The byte order is matched to the data buffer's order so the longs are 
directly comparable (see mismatch()).
+  private static final VarHandle LONG_VIEW_LITTLE_ENDIAN =
+      MethodHandles.byteArrayViewVarHandle(long[].class, 
ByteOrder.LITTLE_ENDIAN);
+  private static final VarHandle LONG_VIEW_BIG_ENDIAN =
+      MethodHandles.byteArrayViewVarHandle(long[].class, ByteOrder.BIG_ENDIAN);
+
   private ValueReaderComparisons() {
   }
 
-  private static int mismatch(PinotDataBuffer dataBuffer, long startOffset, 
int length, ByteBuffer buffer) {
+  private static int mismatch(PinotDataBuffer dataBuffer, long startOffset, 
int length, byte[] bytes) {
     boolean littleEndian = dataBuffer.order() == ByteOrder.LITTLE_ENDIAN;
-    if (littleEndian) {
-      buffer.order(ByteOrder.LITTLE_ENDIAN);
-    }
-    int limit = Math.min(length, buffer.limit());
+    VarHandle longView = littleEndian ? LONG_VIEW_LITTLE_ENDIAN : 
LONG_VIEW_BIG_ENDIAN;
+    int limit = Math.min(length, bytes.length);
     int loopBound = limit & ~0x7;
     int i = 0;
     for (; i < loopBound; i += 8) {
       long ours = dataBuffer.getLong(startOffset + i);
-      long theirs = buffer.getLong(i);
+      long theirs = (long) longView.get(bytes, i);
       if (ours != theirs) {
         long difference = ours ^ theirs;
         return i + ((littleEndian ? Long.numberOfTrailingZeros(difference) : 
Long.numberOfLeadingZeros(difference))
@@ -46,7 +52,7 @@ public class ValueReaderComparisons {
     }
     for (; i < limit; i++) {
       byte ours = dataBuffer.getByte(startOffset + i);
-      byte theirs = buffer.get(i);
+      byte theirs = bytes[i];
       if (ours != theirs) {
         return i;
       }
@@ -55,20 +61,15 @@ public class ValueReaderComparisons {
   }
 
   static int compareBytes(PinotDataBuffer dataBuffer, long startOffset, int 
length, byte[] bytes) {
-    // can use MethodHandles.byteArrayViewVarHandle here after dropping JDK8
-    ByteBuffer buffer = ByteBuffer.wrap(bytes);
-    int mismatchPosition = mismatch(dataBuffer, startOffset, length, buffer);
+    int mismatchPosition = mismatch(dataBuffer, startOffset, length, bytes);
     if (mismatchPosition == -1) {
       return length - bytes.length;
     }
-    // can use Byte.compareUnsigned here after dropping JDK8
-    return (dataBuffer.getByte(startOffset + mismatchPosition) & 0xFF) - 
(bytes[mismatchPosition] & 0xFF);
+    return Byte.compareUnsigned(dataBuffer.getByte(startOffset + 
mismatchPosition), bytes[mismatchPosition]);
   }
 
   static int compareUtf8Bytes(PinotDataBuffer dataBuffer, long startOffset, 
int length, boolean padded, byte[] bytes) {
-    // can use MethodHandles.byteArrayViewVarHandle here after dropping JDK8
-    ByteBuffer buffer = ByteBuffer.wrap(bytes);
-    int mismatchPosition = mismatch(dataBuffer, startOffset, length, buffer);
+    int mismatchPosition = mismatch(dataBuffer, startOffset, length, bytes);
     if (mismatchPosition == -1) {
       if (padded && bytes.length < length) {
         // check if the stored string continues beyond the length of the 
parameter
@@ -80,10 +81,10 @@ public class ValueReaderComparisons {
     }
     // we know the position of the mismatch but need to do utf8 decoding 
before comparison
     // to respect collation rules
-    return compareUtf8(dataBuffer, startOffset, buffer, mismatchPosition);
+    return compareUtf8(dataBuffer, startOffset, bytes, mismatchPosition);
   }
 
-  private static int compareUtf8(PinotDataBuffer ourBuffer, long 
ourStartOffset, ByteBuffer theirBuffer,
+  private static int compareUtf8(PinotDataBuffer ourBuffer, long 
ourStartOffset, byte[] theirBytes,
       int mismatchPosition) {
     char ours1 = '\ufffd';
     char ours2 = '\ufffd';
@@ -93,7 +94,7 @@ public class ValueReaderComparisons {
     // 1. seek backwards from mismatch position to find start of each utf8 
sequence
     //    assuming we have valid UTF-8 and knowing that the content before 
mismatchPosition is
     //    identical, we will go back the same distance in each buffer
-    while (mismatchPosition > 0 && 
isUtf8Continuation(theirBuffer.get(mismatchPosition))) {
+    while (mismatchPosition > 0 && 
isUtf8Continuation(theirBytes[mismatchPosition])) {
       mismatchPosition--;
     }
     // 2. decode to get the 1 or 2 characters containing where the mismatch 
lies
@@ -117,17 +118,17 @@ public class ValueReaderComparisons {
       }
     }
     {
-      byte first = theirBuffer.get(mismatchPosition);
+      byte first = theirBytes[mismatchPosition];
       int control = first & 0xF0;
       if (first >= 0) {
         theirs1 = (char) (first & 0xFF);
       } else if (control < 0xE0) {
-        theirs1 = decode(first, theirBuffer.get(mismatchPosition + 1));
+        theirs1 = decode(first, theirBytes[mismatchPosition + 1]);
       } else if (control == 0xE0) {
-        theirs1 = decode(first, theirBuffer.get(mismatchPosition + 1), 
theirBuffer.get(mismatchPosition + 2));
+        theirs1 = decode(first, theirBytes[mismatchPosition + 1], 
theirBytes[mismatchPosition + 2]);
       } else {
-        int codepoint = decode(first, theirBuffer.get(mismatchPosition + 1), 
theirBuffer.get(mismatchPosition + 2),
-            theirBuffer.get(mismatchPosition + 3));
+        int codepoint = decode(first, theirBytes[mismatchPosition + 1], 
theirBytes[mismatchPosition + 2],
+            theirBytes[mismatchPosition + 3]);
         if (Character.isValidCodePoint(codepoint)) {
           theirs1 = Character.highSurrogate(codepoint);
           theirs2 = Character.lowSurrogate(codepoint);


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to