xiangfu0 commented on code in PR #19795:
URL: https://github.com/apache/pinot/pull/19795#discussion_r4215198569
##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/customobject/PercentileTDigestAccumulator.java:
##########
@@ -1144,7 +1113,7 @@ private void normalizeBoundaryCentroids() {
if (_numCentroids == 1) {
double weight = _centroidWeights[0];
- if (weight <= 1.0) {
+ if (weight < 2.0) {
Review Comment:
**[code-review] The new `weight < 2.0` early-return (also line 1166) leaves
a single centroid with fractional weight in (1.0, 2.0) unrepaired, creating a
rolling-upgrade asymmetry and a repeated useless lossy re-encode.**
Reproduced with the real t-digest 3.3 jar: bytes {count=1, weight=1.5}
round-trip through deserialize/serialize emitting boundary weight 1.5; an old
3.3 server running with -ea that merges this intermediate dies with
AssertionError (unit-boundary invariant), and an old server's <=1.0 repair of
the same bytes manufactures a negative-weight (-0.5) centroid that the new
validateSerialized then hard-rejects in the reverse direction. Additionally
hasWeightedBoundaryCentroids keeps reporting such pending state as
repair-needed, so every serialize() does a lossy materialize+recompress yet
still emits a weighted boundary. The old <=1.0 behavior was itself broken
(negative residual), so this is an incomplete fix: fractional sub-2 boundary
weights should be either normalized differently or explicitly
rejected/documented.
##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/customobject/PercentileTDigestAccumulator.java:
##########
@@ -1287,12 +1256,10 @@ private void ensureOutputCapacity(int capacity) {
}
}
- private static double getTotalWeight(double[] weights, int count) {
- double totalWeight = 0.0;
- for (int i = 0; i < count; i++) {
- totalWeight += weights[i];
+ private static void checkTotalWeight(double totalWeight) {
Review Comment:
**[code-review] New hard cap rejecting total weight >= 2^63
(checkTotalWeight, and validateSerialized) turns previously-working huge
aggregations and stored digests into failures.**
Pre-PR, weights were plain doubles and only size()'s long cast saturated;
merging rollup-of-rollup digests (or NonFiniteAwareTDigest states whose
infinity weights are longs up to Long.MAX_VALUE each) whose cumulative weight
crosses 2^63 completed with correct double-precision quantiles. Post-PR the
same broker reduce / segment build aborts mid-aggregation with
IllegalArgumentException('Invalid TDigest total weight') - the checks at the
add/merge entry points fire before the merge, so a single over-limit group
fails the whole query, and validateSerialized rejects such stored digests
outright. Edge-case trigger, but a hard regression with no graceful degradation
when hit.
##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/customobject/PercentileTDigestAccumulator.java:
##########
@@ -216,20 +219,9 @@ 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));
- return;
- }
-
- Collection<Centroid> centroids = other.centroids();
- materializePendingSerializedTDigest();
- for (Centroid centroid : centroids) {
- bufferIncomingCentroid(centroid.mean(), centroid.count());
- }
- _min = Math.min(_min, other.getMin());
- _max = Math.max(_max, other.getMax());
+ // The centroid API exposes int counts for compatibility. The legacy
verbose representation preserves
+ // fractional and large double weights from other implementations.
+ addSerializedTDigest(TDigestUtils.serialize(other));
Review Comment:
**[code-review] add(TDigest) now unconditionally round-trips non-accumulator
sources through TDigestUtils.serialize -> makeLegacyCompatible, which silently
pre-merges (reduceVerboseCentroids) any source with more centroids than the
legacy 2*ceil(compression)+10 capacity before the accumulator ever sees them.**
The base add(TDigest) iterated other.centroids() and buffered every centroid
exactly (serialized fallback only for size > Integer.MAX_VALUE). Post-PR, a
TDigest implementation whose centroidCount exceeds the legacy capacity - e.g.
the benchmark's FixedTDigest FIXED_VERBOSE sources with thousands of singleton
centroids, or an out-of-tree implementation - has interior centroids merged
away during ingestion even though the accumulator itself can represent the
larger count via capacity preservation: silent resolution loss in quantiles,
plus a full verbose encode/validate/decode allocation per add(TDigest) call.
##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/TDigestUtils.java:
##########
@@ -207,331 +183,144 @@ public static TDigest deserialize(byte[] bytes) {
return deserialize(ByteBuffer.wrap(bytes));
}
- /// Validates the structure and declared capacities of a serialized merging
digest.
- public static void validateSerialized(byte[] bytes) {
- validateEncodedDigest(ByteBuffer.wrap(bytes));
+ /// Validates serialized lengths and centroid values without allocating
centroid arrays.
+ public static double validateSerialized(byte[] bytes) {
+ return validateSerialized(ByteBuffer.wrap(bytes));
}
- /// Deserializes a finite-valued digest into a merging digest with the
temporary-buffer footprint used by 3.2.
+ /// Validates one legacy digest without changing `input` and returns its
total centroid weight.
///
- /// Unlike the standard verbose decoder, this method does not retain the
substantially larger default 3.3
- /// temporary arrays. It also repairs legacy weighted boundary centroids
before the first 3.3 merge.
- public static TDigest deserializeFiniteWithLegacyBuffer(byte[] bytes) {
- ByteBuffer input = ByteBuffer.wrap(bytes);
- validateEncodedDigest(input.duplicate());
-
- int encoding = input.getInt();
- double min = input.getDouble();
- double max = input.getDouble();
- double compression;
- int centroidCount;
- boolean verbose;
- if (encoding == VERBOSE_ENCODING) {
- compression = input.getDouble();
- centroidCount = input.getInt();
- verbose = true;
- } else if (encoding == SMALL_ENCODING) {
- compression = input.getFloat();
- input.getShort();
- input.getShort();
- centroidCount = input.getShort();
- verbose = false;
- } else {
- throw new IllegalStateException("Invalid format for serialized
histogram");
- }
-
- if (centroidCount == 0) {
- return createMergingDigestWithLegacyBuffer(compression);
- }
- double[] means = new double[centroidCount + 2];
- double[] weights = new double[centroidCount + 2];
- for (int i = 0; i < centroidCount; i++) {
- double weight = verbose ? input.getDouble() : input.getFloat();
- double mean = verbose ? input.getDouble() : input.getFloat();
- if (!(weight > 0.0) || !Double.isFinite(weight)) {
- throw new IllegalArgumentException("Invalid TDigest centroid weight: "
+ weight);
- }
- if (!Double.isFinite(mean)) {
- throw new IllegalArgumentException("Expected a finite TDigest centroid
mean: " + mean);
- }
- weights[i] = weight;
- means[i] = mean;
- }
-
- int normalizedCount = weights[0] != 1.0 || weights[centroidCount - 1] !=
1.0
- ? normalizeBoundaries(means, weights, centroidCount, min, max) :
centroidCount;
- long addCount = 0L;
- for (int i = 0; i < normalizedCount; i++) {
- double weight = weights[i];
- if (weight != Math.rint(weight) || weight >= 0x1p63) {
- return copyIntoLegacyBuffer(
- deserialize(toVerboseBytes(min, max, compression, means, weights,
normalizedCount)), normalizedCount);
- }
- long integralWeight = (long) weight;
- addCount = Math.addExact(addCount, 1L + (integralWeight - 1L) /
Integer.MAX_VALUE);
- }
-
- int minimumMainCapacity = Math.addExact(Math.toIntExact(addCount), 2);
- TDigest digest = createMergingDigestWithLegacyBuffer(compression,
minimumMainCapacity);
- for (int i = 0; i < normalizedCount; i++) {
- long remainingWeight = (long) weights[i];
- while (remainingWeight > 0L) {
- int weight = (int) Math.min(remainingWeight, Integer.MAX_VALUE);
- digest.add(means[i], weight);
- remainingWeight -= weight;
- }
- }
- return digest;
+ /// Trailing bytes remain available to the caller for intermediate values
containing several objects.
+ public static double validateSerialized(ByteBuffer input) {
+ return validateSerialized(input, true);
}
- /// Deserializes a digest and advances `input` past its encoded bytes.
- public static TDigest deserialize(ByteBuffer input) {
- ByteBuffer encoded = input.slice();
- validateEncodedDigest(encoded.duplicate());
-
+ private static double validateSerialized(ByteBuffer input, boolean
checkCapacity) {
+ ByteBuffer encoded = input.slice().order(ByteOrder.BIG_ENDIAN);
int encoding = encoded.getInt();
+ if (encoding != VERBOSE_ENCODING && encoding != SMALL_ENCODING) {
+ throw new IllegalStateException("Invalid format for serialized
histogram");
+ }
double min = encoded.getDouble();
double max = encoded.getDouble();
double compression;
int mainCapacity;
- int bufferCapacity;
int centroidCount;
int centroidSize;
if (encoding == VERBOSE_ENCODING) {
compression = encoded.getDouble();
+ validateCompression(compression);
+ mainCapacity = Math.max(getDefaultCentroidCapacity(compression),
getLegacyDefaultCentroidCapacity(compression));
centroidCount = encoded.getInt();
- mainCapacity = getDefaultCentroidCapacity(compression);
- bufferCapacity = 5 * mainCapacity;
centroidSize = VERBOSE_CENTROID_SIZE;
- } else if (encoding == SMALL_ENCODING) {
+ } else {
compression = encoded.getFloat();
+ validateCompression(compression);
mainCapacity = encoded.getShort();
- bufferCapacity = encoded.getShort();
- centroidCount = encoded.getShort();
- centroidSize = SMALL_CENTROID_SIZE;
- } else {
- // MergingDigest.fromBytes() has already produced the canonical
exception for this case.
- throw new IllegalStateException("Invalid format for serialized
histogram");
- }
- int centroidOffset = encoded.position();
- if (centroidCount == 0) {
- return configureScaleFunction(MergingDigest.fromBytes(input));
- }
-
- double firstWeight = readWeight(encoded, centroidOffset, centroidSize);
- double lastWeight = readWeight(encoded, centroidOffset + (centroidCount -
1) * centroidSize, centroidSize);
- int defaultMainCapacity = getDefaultCentroidCapacity(compression);
- boolean needsBoundaryRepair = firstWeight != 1.0 || lastWeight != 1.0;
- boolean exceedsDefaultVerboseCapacity = encoding == VERBOSE_ENCODING &&
centroidCount > defaultMainCapacity;
- if (!needsBoundaryRepair && !exceedsDefaultVerboseCapacity) {
- return configureScaleFunction(MergingDigest.fromBytes(input));
- }
-
- double[] means = new double[centroidCount + 2];
- double[] weights = new double[centroidCount + 2];
- encoded.position(centroidOffset);
- for (int i = 0; i < centroidCount; i++) {
- if (centroidSize == VERBOSE_CENTROID_SIZE) {
- weights[i] = encoded.getDouble();
- means[i] = encoded.getDouble();
- } else {
- weights[i] = encoded.getFloat();
- means[i] = encoded.getFloat();
- }
- }
- int encodedLength = encoded.position();
- int normalizedCount = needsBoundaryRepair
- ? normalizeBoundaries(means, weights, centroidCount, min, max) :
centroidCount;
- byte[] normalizedBytes = toVerboseBytes(min, max, compression, means,
weights, normalizedCount);
- MergingDigest result;
- if (normalizedCount <= defaultMainCapacity) {
- result = MergingDigest.fromBytes(ByteBuffer.wrap(normalizedBytes));
- } else {
- int resolvedMainCapacity = mainCapacity == -1 ? defaultMainCapacity :
mainCapacity;
- int resolvedBufferCapacity = bufferCapacity == -1
- ? DEFAULT_MERGE_BUFFER_MULTIPLIER * resolvedMainCapacity :
bufferCapacity;
- int smallMainCapacity = Math.max(resolvedMainCapacity, normalizedCount);
- long smallBufferCapacity = Math.max(Math.max((long)
resolvedBufferCapacity, smallMainCapacity + 1L),
- 2L * smallMainCapacity + 1L);
- if ((double) (float) compression == compression && normalizedCount <=
Short.MAX_VALUE
- && smallMainCapacity <= Short.MAX_VALUE && smallBufferCapacity <=
Short.MAX_VALUE
- && areFloatExact(means, weights, normalizedCount)) {
- ByteBuffer normalized = ByteBuffer.allocate(SMALL_HEADER_SIZE +
SMALL_CENTROID_SIZE * normalizedCount);
- normalized.putInt(SMALL_ENCODING);
- normalized.putDouble(min);
- normalized.putDouble(max);
- normalized.putFloat((float) compression);
- normalized.putShort((short) smallMainCapacity);
- normalized.putShort((short) smallBufferCapacity);
- normalized.putShort((short) normalizedCount);
- for (int i = 0; i < normalizedCount; i++) {
- normalized.putFloat((float) weights[i]);
- normalized.putFloat((float) means[i]);
- }
- normalized.flip();
- result = MergingDigest.fromBytes(normalized);
- } else {
- result = MergingDigest.fromBytes(
- ByteBuffer.wrap(reduceVerboseCentroids(normalizedBytes,
defaultMainCapacity)));
- }
- }
- input.position(Math.addExact(input.position(), encodedLength));
- return configureScaleFunction(result);
- }
-
- private static TDigest configureScaleFunction(MergingDigest digest) {
- digest.setScaleFunction(ScaleFunction.K_1);
- return digest;
- }
-
- private static TDigest copyIntoLegacyBuffer(TDigest source, int
centroidCount) {
- TDigest digest = createMergingDigestWithLegacyBuffer(source.compression(),
Math.addExact(centroidCount, 2));
- digest.add(java.util.List.of(source));
- return digest;
- }
-
- private static boolean areFloatExact(double[] means, double[] weights, int
count) {
- for (int i = 0; i < count; i++) {
- if ((double) (float) weights[i] != weights[i] || (double) (float)
means[i] != means[i]) {
- return false;
- }
- }
- return true;
- }
-
- private static byte[] toVerboseBytes(double min, double max, double
compression, double[] means, double[] weights,
- int count) {
- ByteBuffer normalized = ByteBuffer.allocate(VERBOSE_HEADER_SIZE +
VERBOSE_CENTROID_SIZE * count);
- normalized.putInt(VERBOSE_ENCODING);
- normalized.putDouble(min);
- normalized.putDouble(max);
- normalized.putDouble(compression);
- normalized.putInt(count);
- for (int i = 0; i < count; i++) {
- normalized.putDouble(weights[i]);
- normalized.putDouble(means[i]);
- }
- return normalized.array();
- }
-
- private static double readWeight(ByteBuffer encoded, int offset, int
centroidSize) {
- return centroidSize == VERBOSE_CENTROID_SIZE ? encoded.getDouble(offset) :
encoded.getFloat(offset);
- }
-
- private static void validateEncodedDigest(ByteBuffer input) {
- int encoding = input.getInt();
- if (encoding != VERBOSE_ENCODING && encoding != SMALL_ENCODING) {
- throw new IllegalStateException("Invalid format for serialized
histogram");
- }
- input.getDouble();
- input.getDouble();
- double compression;
- int mainCapacity;
- int centroidCount;
- int centroidSize;
- if (encoding == VERBOSE_ENCODING) {
- compression = input.getDouble();
- mainCapacity = getDefaultCentroidCapacity(compression);
- centroidCount = input.getInt();
- centroidSize = VERBOSE_CENTROID_SIZE;
- } else {
- compression = input.getFloat();
- mainCapacity = input.getShort();
- int bufferCapacity = input.getShort();
+ int bufferCapacity = encoded.getShort();
checkSmallArrayCapacity(mainCapacity);
checkSmallArrayCapacity(bufferCapacity);
if (mainCapacity == -1) {
mainCapacity = getDefaultCentroidCapacity(compression);
}
- centroidCount = input.getShort();
+ centroidCount = encoded.getShort();
centroidSize = SMALL_CENTROID_SIZE;
}
if (centroidCount < 0) {
throw new IllegalArgumentException("Invalid negative TDigest centroid
count: " + centroidCount);
}
- if (centroidCount > input.remaining() / centroidSize) {
+ if (centroidCount > encoded.remaining() / centroidSize) {
throw new BufferUnderflowException();
}
- int readableMainCapacity = mainCapacity;
- if (encoding == VERBOSE_ENCODING && compression > 0.0 &&
Double.isFinite(compression)) {
- readableMainCapacity = Math.max(mainCapacity,
getLegacyDefaultCentroidCapacity(compression));
+ if (checkCapacity && centroidCount > mainCapacity) {
+ throw new IllegalArgumentException("TDigest centroid count exceeds
capacity: " + centroidCount);
}
- if (centroidCount > readableMainCapacity) {
- throw new ArrayIndexOutOfBoundsException(
- "Index " + readableMainCapacity + " out of bounds for length " +
readableMainCapacity);
+ if (Double.isNaN(min) || Double.isNaN(max) || (centroidCount > 0 && min >
max)) {
+ throw new IllegalArgumentException("Invalid TDigest extrema: " + min +
", " + max);
}
- }
-
- private static void checkSmallArrayCapacity(int capacity) {
- if (capacity < -1) {
- throw new NegativeArraySizeException(Integer.toString(capacity));
- }
- }
-
- private static int normalizeBoundaries(double[] means, double[] weights, int
count, double min, double max) {
- if (count == 1) {
- double weight = weights[0];
- if (weight <= 1.0) {
- return count;
+ double totalWeight = 0.0;
+ double previousMean = Double.NEGATIVE_INFINITY;
+ for (int i = 0; i < centroidCount; i++) {
+ double weight = centroidSize == VERBOSE_CENTROID_SIZE ?
encoded.getDouble() : encoded.getFloat();
+ double mean = centroidSize == VERBOSE_CENTROID_SIZE ?
encoded.getDouble() : encoded.getFloat();
+ if (!(weight > 0.0) || !Double.isFinite(weight) || Double.isNaN(mean) ||
mean < previousMean) {
+ throw new IllegalArgumentException("Invalid TDigest centroid: mean=" +
mean + ", weight=" + weight);
}
- double mean = means[0];
- means[0] = min;
- weights[0] = 1.0;
- if (weight == 2.0) {
- means[1] = max;
- weights[1] = 1.0;
- return 2;
+ totalWeight += weight;
+ if (!Double.isFinite(totalWeight) || totalWeight >= 0x1p63) {
+ throw new IllegalArgumentException("TDigest total weight exceeds the
supported range");
}
- means[1] = residualMean(mean, weight, min, max, weight - 2.0, min, max);
- weights[1] = weight - 2.0;
- means[2] = max;
- weights[2] = 1.0;
- return 3;
+ previousMean = mean;
}
+ return totalWeight;
+ }
- if (weights[0] > 1.0) {
- System.arraycopy(means, 1, means, 2, count - 1);
- System.arraycopy(weights, 1, weights, 2, count - 1);
- double weight = weights[0];
- double mean = means[0];
- means[0] = min;
- weights[0] = 1.0;
- means[1] = residualMean(mean, weight, min, 0.0, weight - 1.0, min,
means[2]);
- weights[1] = weight - 1.0;
- count++;
+ /// Deserializes a finite-valued digest into Pinot's buffered implementation.
+ public static TDigest deserializeFiniteWithLegacyBuffer(byte[] bytes) {
+ validateSerialized(bytes);
+ ByteBuffer input = ByteBuffer.wrap(bytes);
+ int encoding = input.getInt();
+ input.position(Integer.BYTES + 2 * Double.BYTES);
+ int count;
+ boolean verbose = encoding == VERBOSE_ENCODING;
+ if (verbose) {
+ input.getDouble();
+ count = input.getInt();
+ } else {
+ input.getFloat();
+ input.getShort();
+ input.getShort();
+ count = input.getShort();
}
- int lastIndex = count - 1;
- if (weights[lastIndex] > 1.0) {
- double weight = weights[lastIndex];
- double mean = means[lastIndex];
- means[lastIndex] = residualMean(mean, weight, max, 0.0, weight - 1.0,
means[lastIndex - 1], max);
- weights[lastIndex] = weight - 1.0;
- means[count] = max;
- weights[count] = 1.0;
- count++;
+ for (int i = 0; i < count; i++) {
+ if (verbose) {
+ input.getDouble();
+ } else {
+ input.getFloat();
+ }
+ double mean = verbose ? input.getDouble() : input.getFloat();
+ if (!Double.isFinite(mean)) {
+ throw new IllegalArgumentException("Expected a finite TDigest centroid
mean: " + mean);
+ }
}
- return count;
+ TDigest digest = deserialize(bytes);
+ digest.compress();
Review Comment:
**[code-review] deserializeFiniteWithLegacyBuffer now ends with
digest.compress(), which unconditionally runs a lossy
recompressCentroids(_compression) merge pass over the freshly-decoded stored
centroids on every star-tree/rollup read; the base implementation buffered the
centroids without an immediate destructive pass, and no comment explains why
the eager compress is needed.**
PercentileTDigestValueAggregator.deserializeAggregatedValue calls this once
per pre-aggregated cell during star-tree builds, realtime ingestion
aggregation, and merge-rollup; each read-modify-write generation applies one
extra merge pass versus the base (alternating merge direction plus boundary
normalization can coalesce previously-separate centroids), mildly degrading
stored percentile accuracy per rollup generation. Error stays within the K1
bound so it does not compound unboundedly, but if the compress() is not
required for correctness it should be removed; if it is (e.g. memory bounding),
a comment should say so.
##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/customobject/PercentileTDigestAccumulator.java:
##########
@@ -204,6 +204,9 @@ public void add(double value, int weight) {
if (Double.isNaN(value)) {
throw new IllegalArgumentException("Cannot add NaN to t-digest");
}
+ if (weight <= 0) {
Review Comment:
**[code-review] The new `weight <= 0` throw in add(double, int) combines
with centroids()' (int) cast of fractional weights (0 < w < 1 -> count 0) so
centroid-replay paths now throw where they previously degraded silently.**
Boundary normalization of a legacy digest whose boundary weight is in (1,2)
leaves a residual centroid with weight in (0,1); centroids() casts it to count
0, and NonFiniteAwareTDigest.add(TDigest)'s non-wrapper branch for digests with
infinite extrema (PercentileTDigestValueAggregator.java ~329,
add(centroid.mean(), centroid.count())) then hits
IllegalArgumentException('TDigest weight must be positive: 0'), failing the
star-tree build or query. Pre-PR the zero-weight add was absorbed harmlessly.
Either skip count-0 centroids at the replay sites or saturate the cast at 1.
##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/customobject/PercentileTDigestAccumulator.java:
##########
@@ -1452,7 +1387,8 @@ static final class SerializedTDigestInput {
private double _totalWeight;
private boolean _decoded;
- void reset(byte[] bytes) {
+ public void reset(byte[] bytes) {
+ _totalWeight = TDigestUtils.validateSerialized(bytes);
Review Comment:
**[code-review] validateSerialized is a full O(centroids) walk and is now
layered on every decode path without threading its result through, so hot paths
scan the same bytes 2-6 times and copy them twice.**
Per input row, SerializedTDigestInput.reset() full-walks the payload and
decode() re-walks it re-summing the total weight reset already computed (2x per
row in aggregate/aggregateGroupBySV over pre-aggregated BYTES columns). Per
wire digest, TDigestUtils.deserialize validates, copies the bytes, then
addSerializedTDigest validates again and clones again, and first use validates
a third time via reset (3 walks + 2 copies); readCompression full-walks the
payload to read an 8-byte field; serialize()'s pending path calls
getSerializedTotalWeight and discards the result. Cheaper: validate once where
untrusted bytes enter and pass {compression, totalWeight} through (the fields
_pendingSerializedTotalWeight and SerializedTDigestInput._totalWeight already
exist for this).
##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/customobject/PercentileTDigestAccumulator.java:
##########
@@ -1041,22 +976,56 @@ private void ensureIncomingCapacity(int capacity) {
private void ensureIncomingSortCapacity(int capacity) {
ensureSortedIncomingCapacity(capacity);
if (_incomingOrder == null || capacity > _incomingOrder.length) {
- int newCapacity = _incomingOrder == null ? Math.max(capacity,
_centroidCapacity)
+ int newCapacity = _incomingOrder == null
+ ? Math.max(capacity, Math.min(_centroidCapacity,
_pendingCentroidCapacity))
: Math.max(capacity, Math.multiplyExact(_incomingOrder.length, 2));
_incomingOrder = new int[newCapacity];
+ _incomingOrderScratch = new int[newCapacity];
}
}
+ // Stable, primitive merge sort bounds the work to O(n log n), including
repeated and infinite means.
+ private void stableSortIncoming(int count) {
Review Comment:
**[code-review] stableSortIncoming hand-rolls a ~30-line bottom-up stable
merge sort with a permanently retained second scratch array
(_incomingOrderScratch), when fastutil - already used for exactly this pattern
in this module (RangeIndexCreator.java:257) - provides a stable
it.unimi.dsi.fastutil.Arrays.mergeSort(from, to, comparator, swapper).**
Group-by holds one accumulator per group (commonly 100K+ per
segment-thread); any group that receives unsorted incoming centroids allocates
both int arrays (up to 10K ints = 40KB each) and never releases them, doubling
sort-index memory for the accumulator's lifetime versus the replaced
Sort.stableSort which used no retained scratch. Either use the existing
fastutil stable sort, or allocate the merge-sort scratch transiently inside
stableSortIncoming (it is only live during the sort).
##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/utils/TDigestUtils.java:
##########
@@ -48,35 +45,34 @@ public final class TDigestUtils {
private static final int SMALL_HEADER_SIZE = 30;
private static final int SMALL_CENTROID_SIZE = 8;
private static final int DEFAULT_MERGE_BUFFER_MULTIPLIER = 5;
+ private static final double MAX_COMPRESSION = 1_000_000.0;
private TDigestUtils() {
}
- /// Creates a merging digest with Pinot's accuracy-preserving scale function.
+ /// Creates a digest using Pinot's accuracy-preserving K1 implementation.
public static TDigest createMergingDigest(double compression) {
- return configureScaleFunction(new MergingDigest(compression));
+ return PercentileTDigestAccumulator.forLegacyAggregation(compression);
}
- /// Creates a merging digest with the temporary-buffer footprint used by
t-digest 3.2.
- ///
- /// Segment aggregation can keep many digests live at once. T-digest 3.3
more than doubled the default temporary
- /// buffer to support two-level compression, adding about 11 KiB to every
compression-100 digest. This factory keeps
- /// the 3.3 main-buffer capacity and Pinot's K1 policy, but restores the
smaller temporary buffer for that
- /// high-cardinality use case.
+ /// Creates Pinot's buffered digest for segment aggregation.
public static TDigest createMergingDigestWithLegacyBuffer(double
compression) {
return createMergingDigestWithLegacyBuffer(compression, 0);
}
- /// Creates a reduced-buffer merging digest whose main array can hold at
least `minimumMainCapacity` centroids.
+ /// Creates a digest whose dynamically grown arrays can hold the requested
centroid capacity.
public static TDigest createMergingDigestWithLegacyBuffer(double
compression, int minimumMainCapacity) {
Review Comment:
**[code-review] createMergingDigestWithLegacyBuffer(compression,
minimumMainCapacity) now validates and then ignores minimumMainCapacity
entirely - all three factory variants are aliases of createMergingDigest, but
the name and doc still promise capacity pre-sizing.**
The sole production caller, NonFiniteAwareTDigest.copy()
(PercentileTDigestValueAggregator.java:205), computes
Math.addExact(centroidCount(), 2) - which forces a flush/compress of the
source's buffered state - purely to produce a discarded argument, and future
readers will assume pre-allocation happens. Delete the two-arg overload and the
centroidCount() computation at the call site, and collapse the aliases.
##########
pinot-segment-local/src/main/java/org/apache/pinot/segment/local/customobject/TDigest.java:
##########
@@ -0,0 +1,70 @@
+/**
+ * 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.segment.local.customobject;
+
+import java.nio.ByteBuffer;
+import java.util.Collection;
+import java.util.List;
+
+/// Pinot's mutable t-digest contract using the existing K1 aggregation
behavior and legacy byte encodings.
+///
+/// Implementations are not safe for concurrent mutation; callers must
serialize access to each aggregation state.
+public abstract class TDigest {
+ public static TDigest createMergingDigest(double compression) {
Review Comment:
**[code-review] The abstract contract class carries a static factory that
hard-codes its concrete subclass
(PercentileTDigestAccumulator.forLegacyAggregation), duplicating
TDigestUtils.createMergingDigest exactly - two identical public factories
introduced in the same PR, with callers split arbitrarily.**
Base-to-derived circular reference defeats the contract abstraction, and a
future behavior change applied to one factory silently diverges query-time
digests from test/benchmark digests (production aggregation functions use the
TDigestUtils one; tests and pinot-perf use this one). Keep one factory and
delegate or migrate the other's call sites.
##########
pom.xml:
##########
@@ -253,7 +253,6 @@
<hadoop-shaded-protobuf_3_25.version>1.5.0</hadoop-shaded-protobuf_3_25.version>
<clearspring-stream-lib.version>2.9.8</clearspring-stream-lib.version>
<datasketches-java.version>9.0.0</datasketches-java.version>
- <t-digest.version>3.3</t-digest.version>
Review Comment:
**[code-review] Removing com.tdunning from the runtime classpath while
changing public API signatures (TDigestUtils factories/serde,
ObjectSerDeUtils.TDIGEST_SER_DE, ValueAggregator type parameters) from
com.tdunning.math.stats.TDigest to
org.apache.pinot.segment.local.customobject.TDigest is a binary-incompatible
break for out-of-tree plugins.**
A third-party plugin AggregationFunction compiled against a prior release
that calls TDigestUtils.createMergingDigest(...) or consumes TDIGEST_SER_DE
throws NoSuchMethodError / NoClassDefFoundError:
com/tdunning/math/stats/TDigest at runtime after a server upgrade. Inherent to
the PR's goal, but it needs the backward-incompatible label and a release note
stating that plugins must migrate to the new type or bundle t-digest themselves.
##########
pinot-core/src/test/java/org/apache/pinot/core/query/aggregation/function/PercentileTDigestAggregationFunctionTest.java:
##########
@@ -1005,59 +1006,29 @@ public void testReducerMergeStructureAndAccuracy(int
fanIn, int compression, Red
sourceValues[valueIndex] = value;
}
sources[digestIndex] = createReducerSource(sourceValues, compression,
reducerInput);
- referenceSources[digestIndex] =
createReferenceReducerSource(sourceValues, compression, reducerInput);
}
PercentileTDigestAggregationFunction function =
new PercentileTDigestAggregationFunction(EXPRESSION, 75.0,
compression, false);
TDigest result = TDigest.createMergingDigest(compression);
- TDigest referenceResult = TDigest.createMergingDigest(compression);
- AssertionError referenceFailure = null;
for (int sourceIndex : mergeOrder.sourceIndexes(fanIn)) {
result = function.merge(result, sources[sourceIndex]);
- if (referenceFailure == null) {
- try {
- if (referenceResult.size() == 0L) {
- referenceResult = referenceSources[sourceIndex];
- } else {
- referenceResult.add(referenceSources[sourceIndex]);
- }
- } catch (AssertionError e) {
- // t-digest 3.3 can violate its own singleton-endpoint assertion
when many duplicate-valued digests merge.
- referenceFailure = e;
- }
- }
}
Arrays.sort(rawValues);
String caseDescription =
fanIn + "/" + compression + "/" + distribution + "/" + mergeOrder +
"/" + reducerInput;
- // Interpolation between large equal-valued centroid runs can land inside
the empirical CDF jump. Keep a fixed
- // envelope for that discontinuous distribution instead of requiring every
merge order to match the reference's
- // particular centroid partition.
- double frozenRankError = compression == 20 || distribution ==
ReducerDistribution.DUPLICATE_HEAVY ? 0.06 : 0.02;
- double[] referenceRankErrors = null;
- if (referenceFailure == null) {
- try {
- referenceRankErrors = getReducerRankErrors(referenceResult, rawValues,
caseDescription + "/reference");
- } catch (AssertionError e) {
- referenceFailure = e;
- }
- }
- double[] maxRankErrors = new double[6];
- if (referenceFailure == null) {
- for (int i = 0; i < referenceRankErrors.length; i++) {
- maxRankErrors[i] = Math.max(frozenRankError, referenceRankErrors[i] +
1e-12);
- }
- } else {
- Arrays.fill(maxRankErrors, compression == 20 ? 0.25 : compression == 100
? 0.06 : 0.02);
- }
- assertValidReducerResult(result, rawValues, caseDescription,
maxRankErrors);
+ // Validate against the empirical CDF. The former 3.3 reference allowed
0.25 after duplicate-boundary
+ // assertions at compression 20; preserve that historical envelope for
this discontinuous distribution.
+ double maxRankError = compression == 20 && distribution ==
ReducerDistribution.DUPLICATE_HEAVY
Review Comment:
**[code-review] Deleting the t-digest 3.3 reference implementation removed
the reducer test's only independent accuracy oracle, replaced by frozen
self-referential envelopes (rank error up to 0.25 for compression-20
duplicate-heavy); TDigestUtilsTest.java:271 similarly weakened the
capacity-reduction guard from an exact count (211) to a near-vacuous `<=` bound
against the input's own count.**
A future merge-path regression that degrades mid-quantile rank error from
~0.06 to 0.24 on skewed data, or one that stops reducing oversized payloads to
the legacy capacity entirely, passes CI - the accuracy contract of Pinot's now
self-owned t-digest is checked only against constants derived from the old
library's worst observed behavior. Keep an independent oracle: exact order
statistics with K1-theory per-quantile bounds, or a golden corpus of serialized
reference digests from the 3.3 build.
--
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]