xiangfu0 commented on code in PR #19795:
URL: https://github.com/apache/pinot/pull/19795#discussion_r4223706268
##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/customobject/PercentileTDigestAccumulator.java:
##########
@@ -216,17 +243,27 @@ public void add(TDigest other) {
addAccumulator((PercentileTDigestAccumulator) other);
return;
}
- if (other instanceof MergingDigest && other.size() > Integer.MAX_VALUE) {
- // Centroid.count() exposes an int even though MergingDigest stores
double weights internally. Preserve large
- // pre-aggregated weights by ingesting the serialized double-precision
representation instead.
- addSerializedTDigest(TDigestUtils.serialize(other));
+ if (!other.hasValidStatistics()) {
+ materializePendingSerializedTDigest();
+ SerializedTDigestInput input = getSerializedTDigestInput();
+ input.reset(ByteBuffer.wrap(TDigestUtils.serialize(other)), false);
+ addSerializedTDigest(input);
return;
}
-
- Collection<Centroid> centroids = other.centroids();
materializePendingSerializedTDigest();
- for (Centroid centroid : centroids) {
- bufferIncomingCentroid(centroid.mean(), centroid.count());
+ if (_legacyDegraded) {
+ _legacySerializedDirty = true;
+ _totalWeight += other.getTotalWeight();
+ } else {
+ checkTotalWeight(_totalWeight + _incomingWeight + _numRawValues +
other.getTotalWeight());
+ for (Centroid centroid : other.centroids()) {
+ double weight = centroid.weight();
+ if (!(weight > 0.0) || !Double.isFinite(weight) ||
Double.isNaN(centroid.mean())) {
+ throw new IllegalArgumentException("Invalid TDigest centroid");
Review Comment:
**[code-review]** add(TDigest) throws on zero-weight centroids that the
serialized-bytes path explicitly tolerates and filters
inspectSerialized treats zero-weight centroids as valid (`if (weight == 0.0)
{ hasZeroWeightCentroids = true; continue; }`) and decode() compacts them away,
but the object-merge path throws IllegalArgumentException('Invalid TDigest
centroid') for `!(weight > 0.0)`. The SPI centroids() javadoc imposes no
positive-weight precondition, so a conforming implementation mirroring bytes
Pinot itself deems valid kills the query or segment build when merged as an
object while the identical bytes merge fine. Either skip zero weights here to
match decode(), or document the precondition on the SPI.
##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/customobject/PercentileTDigestAccumulator.java:
##########
@@ -409,17 +409,36 @@ private void
mergeSerializedTDigest(SerializedTDigestInput input) {
_totalWeight = input._totalWeight;
_min = input._min;
_max = input._max;
+ // Stored centroids already went through the writer's public
compression. Reading or repairing their
+ // endpoints needs another K1 pass only when the output compression
differs from the stored compression.
+ _publiclyCompressed = _compression == input._compression;
+ }
+ return;
+ }
+ if (direct) {
+ flush();
+ boolean runBackwards = (_mergeCount++ & 1) != 0;
+ if (!mergeSorted(input._means, input._weights, input._numCentroids,
input._totalWeight,
+ getIncrementalCompression(), runBackwards)) {
+ return;
}
} else {
bufferIncomingCentroids(input._means, input._weights,
input._numCentroids);
- _min = Math.min(_min, input._min);
- _max = Math.max(_max, input._max);
}
+ _min = Math.min(_min, input._min);
Review Comment:
**[code-review]** Buffered merge path applies a serialized input's header
min/max even when the input has zero total mass, unlike the guarded fast path
The hasNoInputs() fast path copies extrema only `if (input._totalWeight !=
0.0)`, but the buffered tail runs `_min = Math.min(_min, input._min)`
unconditionally. An all-zero-weight payload with finite extrema (producible by
tdunning add(x, 0), which legacy code never validated) merged into an
accumulator holding [10,20] pollutes _min to the header value, and quantile(q)
for index<1 returns that value with no sample mass there. Relatedly, a pending
zero-mass digest answers getMin()/centroidCount() from metadata before
materialization and +INF/0 after - order-dependent results from the same state.
Guard the tail with the same totalWeight check.
##########
compatibility-verifier/tdigest-compatibility/GenerateRankErrors.java:
##########
@@ -0,0 +1,154 @@
+/**
+ * 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.
+ */
+
+import com.tdunning.math.stats.Centroid;
+import com.tdunning.math.stats.TDigest;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.SplittableRandom;
+import org.apache.pinot.segment.local.utils.TDigestUtils;
Review Comment:
**[code-review]** The checked-in oracle generator cannot compile against the
tree it lives in, making the rank-error CSV practically unregenerable
GenerateRankErrors imports the pre-PR TDigestUtils API (tdunning TDigest
returns) that this PR removes; nothing in run.sh or CI compiles it, and the
regeneration recipe lives only in the CSV header, pinned to a historical commit
unavailable on shallow clones. When reducerMergeCases changes (new
distribution/fan-in/compression), loadLegacyReducerRankErrors fails demanding a
regenerated oracle, and the path of least resistance becomes hand-editing
tdigest-3.3-k1-rank-errors.csv - silently detaching the 'independent 3.3
oracle' from any real 3.3 measurement. It also duplicates the test's
distributions/merge orders/seeds formula-for-formula, so the two can drift.
##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/customobject/PercentileTDigestAccumulator.java:
##########
@@ -696,34 +775,38 @@ public double getMin() {
@Override
public double getMax() {
if (_pendingSerializedTDigest != null) {
- return
ByteBuffer.wrap(_pendingSerializedTDigest).getDouble(Integer.BYTES +
Double.BYTES);
+ return _pendingSerializedMetadata.max();
}
flush();
return _max;
}
- byte[] serialize() {
- if (_pendingSerializedTDigest != null) {
- getSerializedTotalWeight(_pendingSerializedTDigest);
- if (ByteBuffer.wrap(_pendingSerializedTDigest).getInt() ==
VERBOSE_ENCODING) {
- byte[] serialized =
TDigestUtils.makeLegacyCompatible(_pendingSerializedTDigest);
- return serialized == _pendingSerializedTDigest ? serialized.clone() :
serialized;
+ public byte[] serialize() {
+ if (_legacyDegraded) {
+ if (_pendingSerializedTDigest != null) {
+ return _pendingSerializedTDigest.clone();
+ }
+ if (!_legacySerializedDirty) {
+ return _legacySerializedBytes.clone();
}
- return _pendingSerializedTDigest.clone();
+ // A merge with a historical invalid distribution remains unknown. Keep
that explicit in the legacy
+ // encoding instead of passing its invalid centroids into the normal
sorted K1 merge.
+ return toLegacyDegradedBytes();
}
- compress();
- return TDigestUtils.makeLegacyCompatible(toVerboseBytes());
- }
-
- TDigest toTDigest() {
if (_pendingSerializedTDigest != null) {
- return TDigestUtils.deserialize(_pendingSerializedTDigest);
+ if (_pendingSerializedMetadata.weightedBoundaries() ||
_pendingSerializedMetadata.hasZeroWeightCentroids()) {
+ materializePendingSerializedTDigest();
+ } else {
+ if (ByteBuffer.wrap(_pendingSerializedTDigest).getInt() ==
TDigestUtils.VERBOSE_ENCODING) {
+ byte[] serialized =
TDigestUtils.makeLegacyCompatible(_pendingSerializedTDigest,
_pendingSerializedMetadata);
+ return serialized == _pendingSerializedTDigest ? serialized.clone()
: serialized;
+ }
+ return _pendingSerializedTDigest.clone();
+ }
}
compress();
- if (_numCentroids == 0) {
- return TDigestUtils.createMergingDigest(_compression);
- }
- return
TDigestUtils.deserialize(TDigestUtils.makeLegacyCompatible(toVerboseBytes()));
+ normalizeBoundaryCentroids();
+ return TDigestUtils.makeLegacyCompatible(toVerboseBytes());
Review Comment:
**[code-review]** serialize() pays a redundant O(n) decode+validate pass
over bytes it just wrote; the metadata-carrying overload exists precisely to
skip it
The non-pending path calls single-arg
makeLegacyCompatible(toVerboseBytes()), which re-runs inspectSerialized over
the fresh bytes - recomputing totalWeight, ordering, fractional and boundary
flags the accumulator already knows from its own post-compress,
boundary-normalized state (_totalWeight, _hasFractionalWeights, sorted means).
ObjectSerDeUtils.TDIGEST_SER_DE serializes per group per block, so a 100k-group
query at compression 100 pays tens of millions of redundant getDouble() calls,
roughly doubling intermediate-result serialization CPU. Build the
SerializedTDigestMetadata from in-memory state and call the two-arg overload
(as the pending path at line 801 already does).
##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/aggregator/PercentileTDigestValueAggregator.java:
##########
@@ -128,17 +125,20 @@ public int getMaxAggregatedValueByteSize() {
@Override
public byte[] serializeAggregatedValue(TDigest value) {
- int requiredCapacity =
getMaxVerboseByteSize(getDefaultCentroidCapacity(value.compression()));
+ NonFiniteAwareTDigest wrapped = asNonFiniteAware(value);
+ updateMaxByteSize(wrapped);
+ int requiredCapacity = Math.max(_maxByteSize,
TDigestUtils.VERBOSE_HEADER_SIZE);
if (_serializationBuffer == null || _serializationBuffer.capacity() <
requiredCapacity) {
Review Comment:
**[code-review]** _serializationBuffer is dead weight: the scratch path is
unreachable because the wrapped finite digest is always a Pinot accumulator
TDigestUtils.serialize(tDigest, scratch) ignores the scratch buffer for
PercentileTDigestAccumulator, and NonFiniteAwareTDigest's _finiteDigest always
is one (createMergingDigest/deserializeFinite), so serializeAggregatedValue
allocates and retains a ByteBuffer that grows to _maxByteSize (tens of KB at
high compression, per metric aggregator for the whole star-tree build) and is
never written - the test even asserts it stays untouched.
NonFiniteAwareTDigest.serialize() additionally returns
getSerializedBytes(...).clone(), a full extra copy of bytes usually computed
fresh in the same call; transfer ownership (null the cache) instead. Drop the
buffer and the clone.
##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/customobject/PercentileTDigestAccumulator.java:
##########
@@ -1452,42 +1406,73 @@ static final class SerializedTDigestInput {
private double _totalWeight;
private boolean _decoded;
- void reset(byte[] bytes) {
- _retainedBytes = null;
- ByteBuffer input = ByteBuffer.wrap(bytes);
- int encoding = input.getInt();
- if (encoding != VERBOSE_ENCODING && encoding != SMALL_ENCODING) {
- throw new IllegalStateException("Invalid format for serialized
histogram");
- }
+ private SerializedTDigestMetadata _metadata;
+
+ public void reset(byte[] bytes) {
+ reset(bytes, TDigestUtils.readSerializedHeader(ByteBuffer.wrap(bytes)));
+ }
+
+ /// Owns one snapshot of the encoded digest and advances the source buffer
past it.
+ public void reset(ByteBuffer bytes) {
+ reset(bytes, true);
+ }
+
+ /// Reads a source digest, optionally enforcing the historical decoder's
declared centroid capacity.
+ public void reset(ByteBuffer bytes, boolean checkCapacity) {
+ SerializedTDigestMetadata metadata =
TDigestUtils.readSerializedHeader(bytes, checkCapacity);
+ byte[] snapshot = new byte[metadata.encodedLength()];
+ bytes.get(snapshot);
+ reset(snapshot, metadata);
+ _retainedBytes = snapshot;
+ }
+
+ private void reset(byte[] bytes, SerializedTDigestMetadata metadata) {
+ _metadata = metadata;
_bytes = bytes;
- _min = input.getDouble();
- _max = input.getDouble();
- if (encoding == VERBOSE_ENCODING) {
- _compression = input.getDouble();
- _numCentroids = input.getInt();
- _mainCapacity = 0;
- _bufferCapacity = 0;
- _centroidSize = VERBOSE_CENTROID_SIZE;
- } else {
- _compression = input.getFloat();
- _mainCapacity = input.getShort();
- _bufferCapacity = input.getShort();
- checkSmallArrayCapacities(_mainCapacity, _bufferCapacity);
- _mainCapacity = resolveSmallMainCapacity(_compression, _mainCapacity);
- _numCentroids = input.getShort();
- _centroidSize = SMALL_CENTROID_SIZE;
- }
- checkCentroidCount(_numCentroids);
- checkCentroidsAvailable(input, _numCentroids, _centroidSize);
- if (_centroidSize == VERBOSE_CENTROID_SIZE) {
- checkVerboseCentroidCapacity(_compression, _numCentroids);
- } else {
- checkSmallCentroidCapacity(_mainCapacity, _numCentroids);
- }
- _centroidOffset = input.position();
+ _retainedBytes = null;
+ _totalWeight = metadata.totalWeight();
+ _min = metadata.min();
+ _max = metadata.max();
+ _compression = metadata.compression();
+ _numCentroids = metadata.centroidCount();
+ _mainCapacity = metadata.mainCapacity();
+ _bufferCapacity = metadata.bufferCapacity();
+ _centroidSize = metadata.centroidSize();
+ _centroidOffset = metadata.centroidOffset();
_decoded = false;
}
+ public double getCompression() {
+ return _compression;
+ }
+
+ public int getEncodedLength() {
Review Comment:
**[code-review]** Dead API and duplicated state on the accumulator: three
uncalled SerializedTDigestInput methods, a mirror field, and a no-op NaN ternary
SerializedTDigestInput.getEncodedLength(), hasNonFiniteMeans(), and
needsLegacyFallback() have zero callers in main or test code (all users read
_metadata directly or via getMetadata()). _pendingSerializedTotalWeight is
provably always equal to _pendingSerializedMetadata.totalWeight() at its single
read site (getTotalWeight), so the pair must be kept in lockstep by hand for no
benefit. toLegacyDegradedBytes writes `Double.isNaN(_min) ? Double.NaN : _min`
- an identity ternary that reads as intentional NaN normalization but does
nothing. Delete the methods, read the metadata, and write _min directly.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]