promql: Implement </ and >/ operators for trimming native histograms.#16330
promql: Implement </ and >/ operators for trimming native histograms.#16330sujalshah-bit wants to merge 2 commits into
Conversation
dd12362 to
e3db1b9
Compare
|
Before leaving the real review to @beorn7 the native histogram expert, a few quick observations / questions:
|
24a997a to
75abf11
Compare
|
Thanks for the feedback, @juliusv! I've made the following changes:
Let me know if you have any further suggestions! |
75abf11 to
72cebc7
Compare
|
I'll have a look at this ASAP. Also, @NeerajGartia21 might help with the reviewing. He has worked a lot on histogram functions recently and has become one of the domain experts. |
|
Thanks for the PR, @sujalshah-bit! I’ll review it in detail in 1–2 days. In the meantime, please add tests for the engine, lexer, and parser to ensure everything is working correctly. You can refer to this for adding tests to the PromQL engine. |
NeerajGartia21
left a comment
There was a problem hiding this comment.
The parser-side changes look good, but I think the trimming logic needs improvement:
-
There's a mismatch between the operator name and its behavior. In #14651 (comment) , @beorn7 mentioned that
histogram_metric </ 0.1should remove observations greater than0.1. This means it trims the right part of the histogram, so logically it should be calledRTRIM. However, the PR description currently says that</removes observations below the threshold, which contradicts both the discussion and the current implementation. Would be good to clarify this, but I’ll leave the final word to @beorn7. -
Right now, when the trim value lies between a bucket’s bounds, the entire bucket is discarded. But ideally, we should estimate (via interpolation) how many observations to remove from that bucket.
The current logic only works correctly when the bucket boundary exactly matches the trim parameter.
For example, if the trim value is0.8, schema is0, and all observations lie in[0.5, 1), applyingLTRIMcurrently gives no observations, but we should actually retain some.Also, make sure this works with native histograms with custom buckets, and handle zero buckets correctly as well.
For reference, you can look at the interpolation logic in the histogram_quantile and histogram_fraction functions here.
| return 0, hlhs.Copy().Div(rhs).Compact(0), true, nil | ||
| case parser.LTRIM: | ||
| trimmedHist := *hlhs | ||
| trimBuckets(&trimmedHist, hlhs, rhs, "LTRIM") |
There was a problem hiding this comment.
Instead of passing the histogram and copying the buckets inside trimBuckets, we can directly pass a copy using hlhs.Copy(). This makes the code clearer. The same can be done for RTRIM as well.
| } | ||
|
|
||
| // Helper function to trim native histogram buckets. | ||
| func trimBuckets(trimmedHist, hlhs *histogram.FloatHistogram, rhs float64, mode string) { |
There was a problem hiding this comment.
Consider renaming the trimBuckets function to trimHistogram for better consistency with the LTRIM and RTRIM operators. Additionally, since the function always operates on a copy of the original histogram, we can simplify the signature by removing the second hlhs argument and directly using the copied histogram inside the function.
|
|
||
| for i, iter := 0, hlhs.PositiveBucketIterator(); iter.Next(); i++ { | ||
| bucket := iter.At() | ||
| if (mode == "LTRIM" && bucket.Upper > rhs) || (mode == "RTRIM" && bucket.Lower < rhs) { |
There was a problem hiding this comment.
This trimming logic might not handle all cases properly. Please check my main comment above for details. In short, fully removing a bucket just because the trim value falls inside it can be inaccurate. Using interpolation to estimate and remove only part of the bucket would give better results.
| } | ||
| } | ||
| if parser.LTRIM == op || parser.RTRIM == op { | ||
| panic(fmt.Errorf("operator %q is only supported for native histograms and scalars, not standard vector operations", op)) |
There was a problem hiding this comment.
We probably shouldn't panic in these cases. There could be situations where this operation is done between a histogram and a vector that has a mix of floats and histograms. In such cases, the user might expect partial results instead of a full failure. It would be better to return an info annotation here rather than panicking.
| case parser.LTRIM: | ||
| trimmedHist := *hlhs | ||
| trimBuckets(&trimmedHist, hlhs, rhs, "LTRIM") | ||
| return 0, &trimmedHist, true, nil |
There was a problem hiding this comment.
We should also consider how this behaves when the bool modifier is used.
@beorn7, can you please clarify what the expected output should be in this case?
There was a problem hiding this comment.
I don't think the </ and >/ operators should be allowed with the bool modifier. It's a syntax error.
There was a problem hiding this comment.
Yeah, so we should disable it. Currently, it does not throw any syntax error.
| } | ||
| } | ||
|
|
||
| for i, iter := 0, hlhs.NegativeBucketIterator(); iter.Next(); i++ { |
There was a problem hiding this comment.
Instead of iterating through positive and negative buckets separately, we could simplify the code by using AllBucketIterator to iterate over both at once. This might make the logic cleaner and easier to maintain.
While I can logically understand either variant, the second one feels way more intuitive to me when taking into account that traditional filter operators like |
|
Thanks for the great discussions. I'll try to answer all the questions ASAP. (Still swamped with things after KubeCon.) |
Makes sense. We could also call them
Might make things clearer. |
Yeah, I think that kind of naming would make sense, it would mirror what e.g. Go does with |
|
Just to confirm my understanding: The intended behavior is that histogram_metric </ rhs keeps only values less than rhs (i.e., trims away values ≥ rhs), and >/ rhs keeps only values greater than rhs (i.e., trims away values ≤ rhs). |
Correct! |
|
High level answers (I have not yet looked at the code): WRT direction: I think the discussion above came to the right conclusion: WRT interpolation: I believe we should do the interpolation. Linear interpolation for NHCB and in the zero bucket, and exponential interpolation for the standard exponential schemas. As @NeerajGartia21 said:
WRT
|
|
@sujalshah-bit do you feel with the collected comments that you can go into the next iteration of working on the code? My impression is that a code-level review only makes sense once you have worked in the high level notes above. If you need some code-level assistance, please let me know. (Note that @NeerajGartia21 already gave valuable code level comments.) Also, of course, feel free to ask any questions you might still have to fully understand what we want to implement here. |
|
(Side note: When mentioning the geometric mean for |
|
Thanks a lot for the detailed discussion above — I’ve learned a lot, and will be updating the changes soon! I'm trying to generate a customized bucket for native histograms for testing this feature, but it's not working as expected. By expected I mean: CustomValues []float64 // this is empty and,
FloatHistogram.UsesCustomBuckets() // this is returning falseHere's the code I'm using to define the histogram: customBuckets = promauto.NewHistogram(prometheus.HistogramOpts{
Name: "custom_bucketss",
Help: "Histogram with manually defined custom buckets",
Buckets: []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10},
NativeHistogramBucketFactor: 1.1,
})
rng = rand.New(rand.NewPCG(uint64(time.Now().UnixNano()), 1))
customValue := rng.Float64() * 5.0
customBuckets.Observe(customValue)Am I missing anything required to make it a proper NHCB (Native Histogram with Custom Buckets) for testing purposes? |
| h_test {{schema:0 count:34 z_bucket:1 z_bucket_w:0.001 buckets:[2 4 8 16] n_buckets:[1 2]}} | ||
| h_test_2 {{schema:2 count:27 z_bucket:1 z_bucket_w:0.001 buckets:[1 2 4 7 3] n_buckets:[1 5 3 1]}} | ||
| cbh {{schema:-53 sum:5 count:15 custom_values:[5 10 15 20] buckets:[1 6 4 3 1]}} | ||
| zero_bucket {{schema:0 z_bucket:5 z_bucket_w:0.01 buckets:[2 3] n_buckets:[1 2 3]}} |
There was a problem hiding this comment.
To elaborate on my comment:
These should all have sum:... fields with plausible values.
cbd already has a sum:... field, but its value is not plausible. If we assume that the observations are more or less in the middle of each bucket (and around 2.5 for the first bucket and not much above 20 for the +Inf bucket), the sum should be around 170.
2b339fe to
d557626
Compare
krajorama
left a comment
There was a problem hiding this comment.
huge work, thanks for taking this on! I'm late to the party, but adding some comments.
| eval instant at 1m h_test_2 </ -1.3 | ||
| {__name__="h_test_2"} {{schema:2 count:2.45786052095524 sum:-16.03281816946792 z_bucket_w:0.001 n_offset:2 n_buckets:[1.45786052095524 1]}} | ||
|
|
||
| # Native Histogram: Linear Bucket Trimming Tests |
There was a problem hiding this comment.
| # Native Histogram: Linear Bucket Trimming Tests | |
| # Exponential buckets: trim on bucket boundary uses no interpolation |
| cbh {{schema:-53 sum:172.5 count:15 custom_values:[5 10 15 20] buckets:[1 6 4 3 1]}} | ||
| zero_bucket {{schema:0 sum:-6.75 z_bucket:5 z_bucket_w:0.01 buckets:[2 3] n_buckets:[1 2 3]}} | ||
|
|
||
| # Native Histogram: Exponential Bucket Interpolation Tests |
There was a problem hiding this comment.
| # Native Histogram: Exponential Bucket Interpolation Tests | |
| # Exponential buckets: trim uses exponential interpolation if cutoff is inside a bucket |
| eval instant at 1m h_test </ -1 | ||
| {__name__="h_test"} {{count:2 sum:2.834740417100363 z_bucket_w:0.001 n_offset:1 n_buckets:[2]}} | ||
|
|
||
| # Custom Buckets: Trim Operation Tests |
There was a problem hiding this comment.
| # Custom Buckets: Trim Operation Tests | |
| # Custom buckets: trim on bucket boundary without interpolation |
| eval instant at 1m h_test_2 </ 1.13 | ||
| {__name__="h_test_2"} {{schema:2 count:13.410582181123704 sum:-9.282809901015558 z_bucket:1 z_bucket_w:0.001 buckets:[1 1.410582181123704] n_buckets:[1 5 3 1]}} |
There was a problem hiding this comment.
For all test cases.
No need to use __name__ label, you can just write the name of the metric as h_test_2{}.
Also please add some indent on the result lines.
| eval instant at 1m h_test_2 </ 1.13 | |
| {__name__="h_test_2"} {{schema:2 count:13.410582181123704 sum:-9.282809901015558 z_bucket:1 z_bucket_w:0.001 buckets:[1 1.410582181123704] n_buckets:[1 5 3 1]}} | |
| eval instant at 1m h_test_2 </ 1.13 | |
| h_test_2{} {{schema:2 count:13.410582181123704 sum:-9.282809901015558 z_bucket:1 z_bucket_w:0.001 buckets:[1 1.410582181123704] n_buckets:[1 5 3 1]}} |
| var keepCount, bucketMidpoint float64 | ||
| keepCount, bucketMidpoint = computeBucketTrim(op, bucket, rhs, true, isCustomBucket) |
There was a problem hiding this comment.
nit:
| var keepCount, bucketMidpoint float64 | |
| keepCount, bucketMidpoint = computeBucketTrim(op, bucket, rhs, true, isCustomBucket) | |
| keepCount, bucketMidpoint := computeBucketTrim(op, bucket, rhs, true, isCustomBucket) |
| var keepCount, bucketMidpoint float64 | ||
| keepCount, bucketMidpoint = computeBucketTrim(op, bucket, rhs, false, isCustomBucket) |
There was a problem hiding this comment.
nit:
| var keepCount, bucketMidpoint float64 | |
| keepCount, bucketMidpoint = computeBucketTrim(op, bucket, rhs, false, isCustomBucket) | |
| keepCount, bucketMidpoint := computeBucketTrim(op, bucket, rhs, false, isCustomBucket) |
| keepCount = bucket.Count | ||
| } else { | ||
| // Trim point is within bucket or below, keep none | ||
| keepCount = 0 |
There was a problem hiding this comment.
We definitely need at least a test case for this.
I can imagine two ways of handling this:
- just do the bucket.Count*0.5 as for +Inf.
- do bucket.Count*0.5 if the bucket.Upper is <= 0.0 otherwise assume this histogram is for positive values only (which we assume in histogram_quantile as well) and treat bucket.Lower as 0, which means if rhs < 0 then keepCount == 0 otherwise 0 <= rhs < bucket.Upper so we can do linear interpolation.
40a5452 to
48987c0
Compare
|
@krajorama can you kindly review :) |
krajorama
left a comment
There was a problem hiding this comment.
I think this is close to being merge-able. And maybe we should merge it to get a good baseline. I made one comment about TRIM_LOWER vs +Inf bucket which I think we should fix before merging. And more comments about exponential buckets and general structure which we could push later. wdyt @beorn7 ?
| if rhs <= bucket.Lower { | ||
| keepCount = bucket.Count | ||
| } else { | ||
| keepCount = 0 |
There was a problem hiding this comment.
isn't this the same case as line 2956, so h </ 50 where 50 is in the highest +Inf bucket?
| keepCount = 0 | |
| keepCount = bucket.Count * 0.5 |
| func computeExponentialTrim(bucket histogram.Bucket[float64], rhs float64, isPositive bool, op parser.ItemType) (float64, float64) { | ||
| var fraction, bucketMidpoint, keepCount float64 | ||
|
|
||
| logLower := math.Log2(math.Abs(bucket.Lower)) |
There was a problem hiding this comment.
Technically exponential buckets can also have -Inf and +Inf lower and upper bounds. See
https://prometheus.io/docs/specs/native_histograms/#buckets
and
prometheus/model/histogram/generic.go
Line 470 in ffcba01
| } | ||
|
|
||
| // Helper function to trim native histogram buckets. | ||
| func trimHistogram(trimmedHist *histogram.FloatHistogram, rhs float64, op parser.ItemType) { |
There was a problem hiding this comment.
This function and the compute*Trim functions got a little bit complicated. Also as I note later, the computeExponentialTrim function is missing handling of overflow -Inf, +Inf buckets.
I'd like to suggest a simplification (we can discuss putting it in a follow-up PR)
let's have a function that has this signature:
computeSplit(bucket histogram.Bucket[float64], le float64, isPostive, isCustomBucket bool) (underCount, bucketMidPoint)
This would return the portion of the bucket count that's under or equal to the le and the midPoint of the bucket values (if possible).
Note that this doesn't have to take the Op. Since the result is directly usable as keepCount for TRIM_UPPER and you just need to subtract the underCount from the bucket count to get the keepCount value for TRIM_LOWER.
Also this would make the algorithm closer to
Line 386 in ffcba01
Also also, the special handling for (-Inf, positive] bucket for custom buckets will never kick in for exponential histograms, since they don't have such bucket.
The resulting function should be possible to reuse here easily.
|
Thanks, everyone. I'll give this a thorough review ASAP (hopefully tomorrow). |
|
Should I go ahead and address this comment and this one in this PR, or should I work on those as a follow-up instead? Either way, I’m happy to take care of it. Please let me know what works best. Aside from those, I noticed only one other minor fix remaining (just have to push). |
|
Sorry for further delay. I'm still trying to do a thorough review this week, but there are other deadlines looming… @sujalshah-bit in the meantime, please feel free to push the minor fix you were talking about above. Also, on first glance, it seems useful to incorporate @krajorama's ideas about treating ±Inf edge cases, especially because things might actually get more straight forward after it. @sujalshah-bit your call. If you think it's a good idea, do it, but if you think it's better to work on it in another PR, that's also fine. (Modulo concerns I might find after my thorough review.) |
This implements the TRIM_UPPER (</) and TRIM_LOWER (>/) operators that allow removing observations below or above a threshold from a histogram. The implementation zeros out buckets outside the desired range. It also recalculates the sum, including only bucket counts within the specified threshold range. Fixes prometheus#14651. Signed-off-by: sujal shah <[email protected]>
48987c0 to
85f5fc3
Compare
This implements the TRIM_UPPER (</) and TRIM_LOWER (>/) operators that allow removing observations below or above a threshold from a histogram. The implementation zeros out buckets outside the desired range. It also recalculates the sum, including only bucket counts within the specified threshold range. Fixes prometheus#14651. Signed-off-by: sujal shah <[email protected]>
85f5fc3 to
31d30ae
Compare
|
I was back in action for a few days, but now I'm going on vacations… @krajorama @sujalshah-bit if you find the time, please finish this without me. I'm taking a note that I'll check the logic of this in any case before we declare NH stable. |
|
@krajorama I have made the changes you can have look. |
krajorama
left a comment
There was a problem hiding this comment.
I think we're getting close. I have a bunch of nitpicks, a comment on the hasPositive/hasNegative calc and potentially an issue with the sum / midpoint calc.
| hasPositive = true | ||
| bucket := iter.At() |
There was a problem hiding this comment.
Knowing if we have negative observations is a bit more complicated for custom bucket histograms. All buckets , even the ones with negative threshold are stored in the positive side. See in https://prometheus.io/docs/specs/native_histograms/#buckets. Also a bucket can have a lower bound < 0 and upper bound > 0.
The upshot is that we need to look at the actual Upper and Lower threshold to know what kind of bucket this is for custom histograms. For a bucket that falls bellow 0 , or completely above 0 ,it's easy. For buckets that straddle 0, I'd just be permissive and say it can have both values and not clamp. After all we use liner interpolation so errors cannot be that bad (I hope).
ALSO, as far as I see all calculations come out to 0 or no change if the bucket count is zero, so I'd do an early return, do you agree?
The upshot is that this line should be replaced with something like:
| hasPositive = true | |
| bucket := iter.At() | |
| bucket := iter.At() | |
| if bucket.Count == 0 { | |
| continue | |
| } | |
| if isCustomBucket { | |
| if bucket.Upper <= 0 { | |
| hasNegative = true | |
| } else { | |
| if bucket.Lower < 0 { | |
| hasNegative = true | |
| } | |
| hasPositive = true | |
| } | |
| } else { | |
| hasPositive = true | |
| } |
| hasNegative = true | ||
| bucket := iter.At() |
There was a problem hiding this comment.
I think we can do this:
| hasNegative = true | |
| bucket := iter.At() | |
| bucket := iter.At() | |
| if bucket.Count == 0 { | |
| continue | |
| } | |
| hasNegative = true |
| hasPositive = true | ||
| bucket := iter.At() |
There was a problem hiding this comment.
| hasPositive = true | |
| bucket := iter.At() | |
| bucket := iter.At() | |
| if bucket.Count == 0 { | |
| continue | |
| } | |
| if isCustomBucket { | |
| if bucket.Upper <= 0 { | |
| hasNegative = true | |
| } else { | |
| if bucket.Lower < 0 { | |
| hasNegative = true | |
| } | |
| hasPositive = true | |
| } | |
| } else { | |
| hasPositive = true | |
| } |
| hasNegative = true | ||
| bucket := iter.At() |
There was a problem hiding this comment.
| hasNegative = true | |
| bucket := iter.At() | |
| bucket := iter.At() | |
| if bucket.Count == 0 { | |
| continue | |
| } |
| } | ||
|
|
||
| // computeSplit calculates the portion of the bucket's count <= le (trim point). | ||
| func computeSplit(b histogram.Bucket[float64], le float64, isPositive, isCustom bool) float64 { |
There was a problem hiding this comment.
Let's rename le to rhs to be consistent. Easier to read.
| removedMid := bucketMidpoint | ||
| removedSum += removedCount * removedMid |
There was a problem hiding this comment.
| removedMid := bucketMidpoint | |
| removedSum += removedCount * removedMid | |
| removedSum += removedCount * bucketMidpoint |
| removedMid := bucketMidpoint | ||
| removedSum += removedCount * removedMid |
There was a problem hiding this comment.
Seems like removedMid isn't used anywhere else.
| removedMid := bucketMidpoint | |
| removedSum += removedCount * removedMid | |
| removedSum += removedCount * bucketMidpoint |
| removedMid := bucketMidpoint | ||
| removedSum += removedCount * removedMid |
There was a problem hiding this comment.
| removedMid := bucketMidpoint | |
| removedSum += removedCount * removedMid | |
| removedSum += removedCount * bucketMidpoint |
| product := math.Abs(b.Lower) * math.Abs(rhs) | ||
| underCount := computeSplit(b, rhs, isPositive, isCustomBucket) | ||
| if op == parser.TRIM_UPPER { | ||
| return underCount, computeMidpoint(b, product, isCustomBucket, isPositive) |
There was a problem hiding this comment.
It seems very suspicious that for custom buckets we use the Lower and Upper bound for the mid point, but for exponential buckets we go from the lower bound to the rhs (or rhs to upper bound) for the mid point calculation.
Feels like one of those is wrong.
There was a problem hiding this comment.
@krajorama yes, I also struggle to justify this (esp. the fact that for exponential, we are only using a single bucket bound to interpolate). After some digging, it seems that the original author based this implementation on this comment by @beorn7 : #16330 (comment)
So I think it would be useful to get additional feedback from @beorn7 on this interpolation formula.
| trimmedHist.Compact(0) | ||
| } | ||
|
|
||
| func computeMidpoint(b histogram.Bucket[float64], product float64, isCustom, isPositive bool) float64 { |
There was a problem hiding this comment.
Following from my comment above I would expect this function to simply look like this:
func computeMidpoint(a, b float64, isLinear, isPositive bool) float64 {
if isLinear {
return (b + a) / 2
}
if isPositive {
return math.Sqrt(math.Abs(a) * math.Abs(b))
}
return -math.Sqrt(math.Abs(a) * math.Abs(b))
}
But I could not get every test to pass. So I think that question needs answering about why we use different kind of ranges when calling this function. Bucket lower/upper bound for custom buckets, but range adjusted with rhs for the exponential buckets.
There was a problem hiding this comment.
Let's contain this discussion in the related comment above: #16330 (comment)
|
@sujalshah-bit just to let you know: @krajorama is on vacations for the rest of the month, but I'll continue the review once you have addressed @krajorama's latest comments. Just leave a note here once the PR is ready for the next round of review. |
|
@sujalshah-bit are you still planning to address @krajorama's comments? |
|
Yes, I am planning to do it, but not any time soon. If someone wants to pick the issue, please feel free to take the PR forward. |
@sujalshah-bit (also @beorn7 and @krajorama ) I see no progress on this PR for almost 6 months now. If there are no objections, I can pick it up and try taking it across the finish line. |
@krajorama I have addressed this whole set of your comments (except for the interpolation question in #16330 (comment) which I think will require some further discussion). I don't have push rights in the original author's repository, so I have pushed my changes (all in a separate commit) to my own repo. Would appreciate if one of the maintainers could update this PR to point to the branch on my repo ( |
|
@beorn7 @krajorama I had no way of pushing the changes into this original branch/PR so I have opened a new PR including all the original work plus my current addition (addressing @krajorama 's comments). Can we move further discussion to #17904 (and perhaps close this one or convert it to draft to avoid confusion)? |
Implement
</and>/operators for trimming native histogramsThis PR implements the
</and>/operators for trimming observations from native histograms.</(TRIM_UPPER): Removes all observations above a threshold value>/(TRIM_LOWER): Removes all observations below a threshold valueImplementation Details
The implementation adds support for these operations by:
TRIM_UPPERandTRIM_LOWER) to the parsertrimHistogramhelper function to zero out buckets that should be excludedvectorElemBinopto handle these new operationsUsage Examples
Postive histogram
request_duration_seconds.mp4
Negative histogram
negative_value_histogram.mp4
Mixed value histogram
mixed_value_histogram.mp4
Important Notes on Behavior
The trimming operators work by zeroing out buckets that are fully outside the desired range. When the threshold falls within a bucket, the entire bucket is preserved or removed based on the comparison with the bucket boundary.
Limitations
It's important to note that while these operators can trim histograms at specific thresholds, they cannot be combined with the standard comparison operators (
<,>,<=,>=) as those do not currently support histogram-to-float comparisons. Attempting operations like:Will result in an error:
incompatible sample types encountered for binary operator "<": histogram < floatFuture Considerations
As discussed in Comment by @beorn7 in #14651, if comparison operators between histograms and floats are implemented in the future, there could be potential semantic inconsistencies between the behavior of trimming and comparison operations, particularly when thresholds don't align with bucket boundaries.
For example, if both operators were supported, a query like
(histogram </ 0.15) < 0.15might not behave as intuitively expected due to how histogram buckets and interpolation work.Fixes #14651.