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

xiangfu0 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 cd95aef7f68 Render MAP projections straight from the serialized frame 
(#19169)
cd95aef7f68 is described below

commit cd95aef7f687a2d59dca90d482d4452f39c0a4e5
Author: Xiang Fu <[email protected]>
AuthorDate: Thu Aug 13 13:02:29 2026 -0700

    Render MAP projections straight from the serialized frame (#19169)
    
    Projecting a whole MAP column as a string parsed every entry into a
    HashMap and then serialized that map back to JSON, once per row. Both
    steps are avoidable: the frame already stores each value as the JSON
    bytes serializeMap produced, so the values can be copied through
    verbatim and only the keys need quoting.
    
    Add MapUtils#frameToJsonString and a ForwardIndexReader#getMapAsJsonString
    hook, overridden by the three readers that hold the map as a frame. The
    default still materializes the map, so a reader that keeps the map
    columnar-decomposed is unaffected.
    
    Output is unchanged. Both forward-index write paths - ForwardIndexCreator
    at segment build and MutableSegmentImpl while consuming - frame maps
    through the key-sorting serializeMap, and nested values are sorted by the
    same writer, so emitting in frame order reproduces what
    toString(deserializeMap(frame)) produced. A test pins that equivalence
    over scalars, nesting, unicode, and keys needing escapes.
    
    Isolated JMH, JDK 25:
    
      entries  shape   before    after    before B/op  after B/op
      4        flat     0.866    0.258         4768        768
      4        nested   1.312    0.274         7320        848
      16       flat     3.261    1.018        16936       2688
      16       nested   5.352    1.049        26968       3008
      64       flat    14.111    3.885        65656      10464
      64       nested  24.071    4.227       104312      11744
    
    3.2-5.7x faster, 6-9x less garbage. Nested values cost the old path 71%
    more than flat ones at 64 entries because Jackson materializes a
    container per value; the new path is within 9% of flat since it never
    looks inside a value.
    
    Co-authored-by: Claude Opus 5 <[email protected]>
---
 .../apache/pinot/perf/BenchmarkMapProjection.java  |  92 +++++++++++++++++
 .../impl/forward/VarByteSVMutableForwardIndex.java |   5 +
 .../forward/VarByteChunkForwardIndexReaderV4.java  |   5 +
 .../forward/VarByteChunkSVForwardIndexReader.java  |   5 +
 .../spi/index/reader/ForwardIndexReader.java       |  15 ++-
 .../java/org/apache/pinot/spi/utils/MapUtils.java  | 110 +++++++++++++++++++++
 .../org/apache/pinot/spi/utils/MapUtilsTest.java   |  58 +++++++++++
 7 files changed, 289 insertions(+), 1 deletion(-)

diff --git 
a/pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkMapProjection.java 
b/pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkMapProjection.java
new file mode 100644
index 00000000000..a3e7d41e18b
--- /dev/null
+++ b/pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkMapProjection.java
@@ -0,0 +1,92 @@
+/**
+ * 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 java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import org.apache.pinot.spi.utils.MapUtils;
+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.runner.Runner;
+import org.openjdk.jmh.runner.options.ChainedOptionsBuilder;
+import org.openjdk.jmh.runner.options.CommandLineOptions;
+import org.openjdk.jmh.runner.options.OptionsBuilder;
+
+
+/// Measures projecting a whole MAP column as a JSON string - the `SELECT 
attributes` / `LASTWITHTIME(attributes)`
+/// shape - rendering straight from the serialized frame versus deserializing 
into a map and serializing it again.
+@BenchmarkMode(Mode.AverageTime)
+@OutputTimeUnit(TimeUnit.MICROSECONDS)
+@Fork(value = 2)
+@Warmup(iterations = 3, time = 1)
+@Measurement(iterations = 5, time = 1)
+@State(Scope.Thread)
+public class BenchmarkMapProjection {
+
+  public static void main(String[] args)
+      throws Exception {
+    ChainedOptionsBuilder opt = new OptionsBuilder().parent(new 
CommandLineOptions(args))
+        .include(BenchmarkMapProjection.class.getSimpleName());
+    new Runner(opt.build()).run();
+  }
+
+  @Param({"4", "16", "64"})
+  private int _numEntries;
+
+  /// `flat` models scalar attribute maps; `nested` models object-valued 
entries, where the deserializing path pays
+  /// to materialize a container per value.
+  @Param({"flat", "nested"})
+  private String _valueShape;
+
+  private byte[] _serialized;
+
+  @Setup(Level.Trial)
+  public void setUp() {
+    boolean nested = "nested".equals(_valueShape);
+    Map<String, Object> map = new LinkedHashMap<>();
+    for (int i = 0; i < _numEntries; i++) {
+      String value = "value-with-enough-bytes-to-exercise-json-parsing-" + i;
+      map.put(String.format("k8s.attribute.%03d.name", i), nested ? 
Map.of("value", value) : value);
+    }
+    _serialized = MapUtils.serializeMap(map);
+  }
+
+  /// The existing path: parse every entry into a map, then serialize the map 
back to JSON.
+  @Benchmark
+  public String deserializeThenSerialize() {
+    return MapUtils.toString(MapUtils.deserializeMap(_serialized));
+  }
+
+  /// The optimized path: copy the already-JSON value bytes through and quote 
only the keys.
+  @Benchmark
+  public String renderFromFrame() {
+    return MapUtils.frameToJsonString(_serialized);
+  }
+}
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/impl/forward/VarByteSVMutableForwardIndex.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/impl/forward/VarByteSVMutableForwardIndex.java
index 6672e392e0f..cb2bc35f9da 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/impl/forward/VarByteSVMutableForwardIndex.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/realtime/impl/forward/VarByteSVMutableForwardIndex.java
@@ -108,6 +108,11 @@ public class VarByteSVMutableForwardIndex implements 
MutableForwardIndex {
     return 
MapUtils.deserializeMapEntryValue(_byteArrayStore.getByteBuffer(docId), key);
   }
 
+  @Override
+  public String getMapAsJsonString(int docId, ForwardIndexReaderContext 
context) {
+    return MapUtils.frameToJsonString(getBytes(docId));
+  }
+
   @Override
   public void setBigDecimal(int docId, BigDecimal value) {
     setBytes(docId, BigDecimalUtils.serialize(value));
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/forward/VarByteChunkForwardIndexReaderV4.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/forward/VarByteChunkForwardIndexReaderV4.java
index 2e60c6cd916..0d65fba1b26 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/forward/VarByteChunkForwardIndexReaderV4.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/forward/VarByteChunkForwardIndexReaderV4.java
@@ -138,6 +138,11 @@ public class VarByteChunkForwardIndexReaderV4
     return MapUtils.deserializeMap(context.getValue(docId));
   }
 
+  @Override
+  public String getMapAsJsonString(int docId, ReaderContext context) {
+    return MapUtils.frameToJsonString(context.getValue(docId));
+  }
+
   @Override
   public int getIntMV(int docId, int[] valueBuffer, 
VarByteChunkForwardIndexReaderV4.ReaderContext context) {
     return 
ArraySerDeUtils.deserializeIntArrayWithLength(context.getValue(docId), 
valueBuffer);
diff --git 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/forward/VarByteChunkSVForwardIndexReader.java
 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/forward/VarByteChunkSVForwardIndexReader.java
index 28b79581a88..b732e483243 100644
--- 
a/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/forward/VarByteChunkSVForwardIndexReader.java
+++ 
b/pinot-segment-local/src/main/java/org/apache/pinot/segment/local/segment/index/readers/forward/VarByteChunkSVForwardIndexReader.java
@@ -130,6 +130,11 @@ public final class VarByteChunkSVForwardIndexReader 
extends BaseChunkForwardInde
     return MapUtils.deserializeMap(getBytes(docId, context));
   }
 
+  @Override
+  public String getMapAsJsonString(int docId, ChunkReaderContext context) {
+    return MapUtils.frameToJsonString(getBytes(docId, context));
+  }
+
   /// Helper method to read BYTES value from the compressed index.
   private byte[] getBytesCompressed(int docId, ChunkReaderContext context) {
     int chunkRowId = docId % _numDocsPerChunk;
diff --git 
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/reader/ForwardIndexReader.java
 
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/reader/ForwardIndexReader.java
index 66749669d94..1a9c37b0f7c 100644
--- 
a/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/reader/ForwardIndexReader.java
+++ 
b/pinot-segment-spi/src/main/java/org/apache/pinot/segment/spi/index/reader/ForwardIndexReader.java
@@ -385,7 +385,7 @@ public interface ForwardIndexReader<T extends 
ForwardIndexReaderContext> extends
         break;
       case MAP:
         for (int i = 0; i < length; i++) {
-          values[i] = MapUtils.toString(getMap(docIds[i], context));
+          values[i] = getMapAsJsonString(docIds[i], context);
         }
         break;
       default:
@@ -486,6 +486,19 @@ public interface ForwardIndexReader<T extends 
ForwardIndexReaderContext> extends
     return getMap(docId, context).get(key.getKey());
   }
 
+  /// Reads the MAP type single-value at the given document id, rendered as a 
JSON object string.
+  ///
+  /// Readers that store the map as a serialized frame should override this to 
render straight from those bytes
+  /// (see [MapUtils#frameToJsonString(byte\[\])]); the default here 
materializes the map first and serializes it
+  /// again, which is what a reader holding the map columnar-decomposed has to 
do anyway.
+  ///
+  /// @param docId Document id
+  /// @param context Reader context
+  /// @return MAP type single-value at the given document id, as JSON
+  default String getMapAsJsonString(int docId, T context) {
+    return MapUtils.toString(getMap(docId, context));
+  }
+
   default int get32BitsMurmur3Hash(int docId, T context) {
     return MurmurHashFunctions.murmurHash3X64Bit32(getBytes(docId, context), 
0);
   }
diff --git a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/MapUtils.java 
b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/MapUtils.java
index a6e5011e384..26362b9f22e 100644
--- a/pinot-spi/src/main/java/org/apache/pinot/spi/utils/MapUtils.java
+++ b/pinot-spi/src/main/java/org/apache/pinot/spi/utils/MapUtils.java
@@ -19,6 +19,7 @@
 package org.apache.pinot.spi.utils;
 
 import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.io.JsonStringEncoder;
 import com.fasterxml.jackson.databind.ObjectWriter;
 import com.fasterxml.jackson.databind.SerializationFeature;
 import com.google.common.base.Preconditions;
@@ -28,6 +29,7 @@ import java.io.OutputStream;
 import java.nio.BufferUnderflowException;
 import java.nio.ByteBuffer;
 import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Map;
@@ -299,6 +301,114 @@ public class MapUtils {
     }
   }
 
+  /// Renders a serialized MAP frame as a JSON object without materializing 
the map.
+  ///
+  /// The frame already stores each value as the JSON bytes that 
[#serializeMap] produced, so the values are copied
+  /// through verbatim and only the keys are quoted. That skips the 
parse-into-`HashMap`-then-serialize-again round
+  /// trip [#toString(Map)] performs, and it skips Jackson entirely.
+  ///
+  /// Entries are emitted in frame order. Both forward-index write paths 
(`ForwardIndexCreator#putValue` at segment
+  /// build and `MutableSegmentImpl` while consuming) frame maps through the 
key-sorting [#serializeMap(Map)], so for
+  /// those frames this is byte-identical to 
`toString(deserializeMap(frame))`. A frame written through
+  /// [#serializeMap(Map, boolean)] with `sortByKey = false` renders in its 
own insertion order instead.
+  public static String frameToJsonString(byte[] bytes) {
+    return frameToJsonString(ByteBuffer.wrap(bytes));
+  }
+
+  /// Variant of [#frameToJsonString(byte\[\])] reading from the buffer's 
current position.
+  public static String frameToJsonString(ByteBuffer byteBuffer) {
+    byteBuffer.order(ByteOrder.BIG_ENDIAN);
+    int size = byteBuffer.getInt();
+    if (size == 0) {
+      return "{}";
+    }
+    // Quoting a key adds 2 bytes and the separators add 2, while the two 
length prefixes it replaces free up 8, so
+    // an unescaped rendering never exceeds the remaining frame bytes. 
Escaping is the only path that can grow.
+    JsonBuilder builder = new JsonBuilder(byteBuffer.remaining() + 2);
+    builder.append((byte) '{');
+    for (int i = 0; i < size; i++) {
+      if (i > 0) {
+        builder.append((byte) ',');
+      }
+      int keyLength = byteBuffer.getInt();
+      checkLength(byteBuffer, keyLength);
+      builder.appendQuotedKey(byteBuffer, keyLength);
+      byteBuffer.position(byteBuffer.position() + keyLength);
+      builder.append((byte) ':');
+      int valueLength = byteBuffer.getInt();
+      checkLength(byteBuffer, valueLength);
+      builder.appendRaw(byteBuffer, valueLength);
+      byteBuffer.position(byteBuffer.position() + valueLength);
+    }
+    builder.append((byte) '}');
+    return builder.toUtf8String();
+  }
+
+  /// Growable byte sink for [#frameToJsonString]. Assembling UTF-8 bytes and 
decoding once at the end avoids
+  /// decoding every key individually, which is what makes the common 
all-ASCII frame allocation-light.
+  private static final class JsonBuilder {
+    private byte[] _bytes;
+    private int _length;
+
+    private JsonBuilder(int capacity) {
+      _bytes = new byte[capacity];
+    }
+
+    private void ensure(int extra) {
+      if (_length + extra > _bytes.length) {
+        _bytes = Arrays.copyOf(_bytes, Math.max(_length + extra, _bytes.length 
* 2));
+      }
+    }
+
+    private void append(byte b) {
+      ensure(1);
+      _bytes[_length++] = b;
+    }
+
+    /// Copies `length` bytes from the buffer's current position without 
advancing it.
+    private void appendRaw(ByteBuffer byteBuffer, int length) {
+      ensure(length);
+      int offset = byteBuffer.position();
+      for (int i = 0; i < length; i++) {
+        _bytes[_length++] = byteBuffer.get(offset + i);
+      }
+    }
+
+    /// Writes the key as a JSON string. Keys needing no escaping - the 
overwhelming majority - are copied as raw
+    /// UTF-8; anything else falls back to decoding the key and letting 
Jackson escape it.
+    private void appendQuotedKey(ByteBuffer byteBuffer, int length) {
+      int offset = byteBuffer.position();
+      boolean clean = true;
+      for (int i = 0; i < length; i++) {
+        byte b = byteBuffer.get(offset + i);
+        // Signed bytes below 0x20 are control characters; continuation bytes 
of multi-byte UTF-8 are negative and
+        // need no escaping, so only the ASCII range is inspected.
+        if ((b >= 0 && b < 0x20) || b == '"' || b == '\\') {
+          clean = false;
+          break;
+        }
+      }
+      append((byte) '"');
+      if (clean) {
+        appendRaw(byteBuffer, length);
+      } else {
+        byte[] keyBytes = new byte[length];
+        for (int i = 0; i < length; i++) {
+          keyBytes[i] = byteBuffer.get(offset + i);
+        }
+        byte[] escaped = 
JsonStringEncoder.getInstance().quoteAsUTF8(Utf8Utils.decode(keyBytes));
+        ensure(escaped.length);
+        System.arraycopy(escaped, 0, _bytes, _length, escaped.length);
+        _length += escaped.length;
+      }
+      append((byte) '"');
+    }
+
+    private String toUtf8String() {
+      return new String(_bytes, 0, _length, StandardCharsets.UTF_8);
+    }
+  }
+
   public static String toString(Map<String, Object> map) {
     return toString(map, true);
   }
diff --git 
a/pinot-spi/src/test/java/org/apache/pinot/spi/utils/MapUtilsTest.java 
b/pinot-spi/src/test/java/org/apache/pinot/spi/utils/MapUtilsTest.java
index 4b90ba8ee97..b67edb879b3 100644
--- a/pinot-spi/src/test/java/org/apache/pinot/spi/utils/MapUtilsTest.java
+++ b/pinot-spi/src/test/java/org/apache/pinot/spi/utils/MapUtilsTest.java
@@ -381,4 +381,62 @@ public class MapUtilsTest {
     map.put("nested", nested);
     assertEquals(MapUtils.toString(map), 
"{\"nested\":{\"d\":\"2022-02-08\"}}");
   }
+
+  /// The contract that lets the forward-index read path swap 
`toString(deserializeMap(frame))` for
+  /// `frameToJsonString(frame)`: for any frame written through the 
key-sorting [MapUtils#serializeMap(Map)] - which
+  /// is what both forward-index write paths use - the two must produce 
identical output.
+  @Test
+  void testFrameToJsonStringMatchesToString() {
+    Map<String, Object> nested = new LinkedHashMap<>();
+    nested.put("z", 1);
+    nested.put("a", List.of(1, 2, 3));
+
+    Map<String, Object> map = new LinkedHashMap<>();
+    map.put("k8s.workload.name", "pinot-server");
+    map.put("int", 42);
+    map.put("long", 9999999999L);
+    map.put("double", 1.5);
+    map.put("bool", true);
+    map.put("nullValue", null);
+    map.put("nested", nested);
+    map.put("list", List.of("a", "b"));
+    map.put("emptyString", "");
+    map.put("unicodeValue", "çöğüşÇÖĞÜŞéÉ");
+    map.put("命名空间", "namespace");
+    map.put("date", LocalDate.of(2022, 2, 8));
+    map.put("quote\"key", "quoted");
+    map.put("back\\slash", "escaped");
+    map.put("tab\tkey", "control");
+    map.put("value with \"quotes\" and \\ backslash", "in value");
+
+    assertEquals(MapUtils.frameToJsonString(MapUtils.serializeMap(map)), 
MapUtils.toString(map));
+  }
+
+  @Test
+  void testFrameToJsonStringHandlesEmptyMap() {
+    assertEquals(MapUtils.frameToJsonString(MapUtils.serializeMap(Map.of())), 
"{}");
+    assertEquals(MapUtils.frameToJsonString(MapUtils.serializeMap(Map.of())), 
MapUtils.toString(Map.of()));
+  }
+
+  /// Rendering must round-trip back through the JSON reader to the same map, 
independent of the string comparison
+  /// above - that guards against two implementations agreeing on malformed 
output.
+  @Test
+  void testFrameToJsonStringRoundTrips() {
+    Map<String, Object> map = new LinkedHashMap<>();
+    map.put("a", "value");
+    map.put("b", List.of(1, 2));
+    map.put("çö", Map.of("inner", true));
+    
assertEquals(MapUtils.fromString(MapUtils.frameToJsonString(MapUtils.serializeMap(map))),
+        MapUtils.deserializeMap(MapUtils.serializeMap(map)));
+  }
+
+  /// An off-heap forward-index view arrives in the platform's native byte 
order while the frame is written
+  /// big-endian, so the renderer has to force the order rather than trust the 
buffer.
+  @Test
+  void testFrameToJsonStringForcesBigEndian() {
+    byte[] serialized = MapUtils.serializeMap(Map.of("k8s.workload.name", 
"pinot-server"));
+    ByteBuffer littleEndian = 
ByteBuffer.wrap(serialized).order(ByteOrder.LITTLE_ENDIAN);
+
+    assertEquals(MapUtils.frameToJsonString(littleEndian), 
"{\"k8s.workload.name\":\"pinot-server\"}");
+  }
 }


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

Reply via email to