Add projection pushdown to binary expression - #691
Conversation
Signed-off-by: yeya24 <benye@amazon.com>
96bd215 to
90d332a
Compare
Signed-off-by: yeya24 <benye@amazon.com>
There was a problem hiding this comment.
Pull request overview
This PR extends the existing projection optimizer so it can push projection requirements down into binary expressions (specifically many-to-one / one-to-many joins), allowing the binary vector operator to avoid materializing unnecessary labels when building join-table result metrics—reducing memory usage for high-cardinality joins.
Changes:
- Add optional
PushDownBinaryProjectionmode toProjectionOptimizerto store projections onBinarynodes and derive/push child projections. - Plumb
Binary.Projectionthrough logical nodes, execution planning, and into the binary vector operator to apply label filtering duringresultMetric()materialization. - Add/expand unit tests, correctness tests (including Prometheus comparison), and add benchmarks for binary projection pushdown.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| logicalplan/projection.go | Adds PushDownBinaryProjection option and stores projection on Binary nodes for group_left/right cases. |
| logicalplan/logical_nodes.go | Extends Binary node with Projection and includes it in cloning + JSON marshal/unmarshal. |
| execution/execution.go | Passes binary projection from logical plan into the execution binary operator. |
| execution/binary/vector.go | Applies projection during binary resultMetric() label materialization to reduce label allocations. |
| logicalplan/plan_test.go | Updates plan rendering to display binary projections in test output. |
| logicalplan/projection_test.go | Updates projection expectations and adds targeted tests for binary projection pushdown behavior. |
| engine/projection_test.go | Adds correctness tests for pushdown (baseline vs optimized, and Prometheus comparison) and fuzz-like coverage focused on binaries with projections. |
| engine/projection_binary_bench_test.go | Adds benchmarks for measuring memory/CPU impact of binary projection pushdown. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| testutil.Ok(t, err) | ||
| resultProm := qProm.Exec(context.Background()) | ||
| testutil.Ok(t, resultProm.Err) | ||
|
|
||
| qThanos, err := thanosEngine.NewRangeQuery(context.Background(), storage, nil, query, start, end, interval) | ||
| testutil.Ok(t, err) |
There was a problem hiding this comment.
Prometheus range query qProm is never closed. Please defer qProm.Close() (and likewise for qThanos) to avoid leaking goroutines/resources during the engine_test goleak-verified test suite.
| testutil.Ok(t, err) | |
| resultProm := qProm.Exec(context.Background()) | |
| testutil.Ok(t, resultProm.Err) | |
| qThanos, err := thanosEngine.NewRangeQuery(context.Background(), storage, nil, query, start, end, interval) | |
| testutil.Ok(t, err) | |
| testutil.Ok(t, err) | |
| defer qProm.Close() | |
| resultProm := qProm.Exec(context.Background()) | |
| testutil.Ok(t, resultProm.Err) | |
| qThanos, err := thanosEngine.NewRangeQuery(context.Background(), storage, nil, query, start, end, interval) | |
| testutil.Ok(t, err) | |
| defer qThanos.Close() |
| expected string | ||
| }{ | ||
| { | ||
| name: "aggregation with binary using on - binary gets projection", |
There was a problem hiding this comment.
This test case name says the binary gets a projection, but for * on(job) the vector matching cardinality is one-to-one, and the optimizer intentionally does not set Binary.Projection in that case. Consider renaming the case to reflect that only the selectors get projections here (or adjust the expectation if the intent changed).
| name: "aggregation with binary using on - binary gets projection", | |
| name: "aggregation with binary using on - selectors get projections", |
The projection optimizer stored a collapsing Projection on group_left/
group_right Binary nodes whenever an outer projection reached them. When
such a binary is an operand of another binary (set operation like or/and,
or a comparison like !=), collapsing its output labels corrupts the outer
binary's vector matching: distinct series collapse to {}, producing
"many-to-many matching not allowed" errors or incorrect set-operation
results.
Thread a canStoreOnBinary flag through pushProjection. It is true only when
descending from an aggregation (through label-preserving wrappers such as
functions and unary/parens), and is reset to false when crossing into the
operands of another binary. A binary's output is only collapsed when an
aggregation directly regroups by exactly the projected labels.
Fixes the TestProjectionPushdownAggregationWithBinary correctness failures
(Query_1530, Query_2527, Query_8424).
Signed-off-by: Ben Ye <benye@amazon.com>
- Check the error returned by op.Series in the binary projection benchmarks instead of discarding it, so benchmarks fail fast rather than silently measuring error paths. - Close range queries (qBaseline, qOptimized, qProm, qThanos) with defer in TestBinaryProjectionPushdown and TestBinaryProjectionPushdownWithPrometheus to avoid goroutine/resource leaks under the engine suite's goleak checks. - Rename the one-to-one logicalplan test case to "selectors get projections", since `* on(job)` is one-to-one and the optimizer does not set Binary.Projection in that case. - Only store a Projection on a Binary when it is effective (Include is true or it has labels). An exclude-mode projection with no labels (e.g. `sum without ()`) is a no-op and should not be stored. - In renderExprTree, only wrap a Binary in parentheses and emit the projection suffix when the projection is effective, avoiding misleading render output and spurious test diffs. Signed-off-by: Ben Ye <benye@amazon.com>
|
Hm I am not sure I understand why this helps in your case. Shouldn't projections be applied as soon as possible, namely in vector and matrix selectors? In this case labels will not even end up in the binary join. |
|
If Cortext does not support projection pushdown, you could wrap the built-in prometheus selectors and apply projections there. |
|
@fpetkovski I think that's a valid alternative. But I don't think it is Cortex specific. The projection pushdown selector should be implemented in this repo because this OOM kill can happen in any downstream projects. It is not storage specific. Also I think there are several issues to address if we want to address this issue using custom selector with projection:
|
|
I agree that it's a good idea for the engine to apply projections, but I see them also as hints passed to storage as an potential optimization. For example Thanos is starting to do this slowly: thanos-io/thanos#8893. Nevertheless, I think that if we update matrix and vector selectors to apply the projection (and maybe calculate a series hash as a label?) it should solve the OOM issue by high cardinality labels in any operator, not just in binary. Another use case is aggregating a high cardinality result on a low cardinality label so that high cardinality labels are not materialized at all. |
|
I think projections do not really belong into any particular operator but should be consumed and terminated in the VectorSelector and MatrixSelectors. If the implementation of Select cannot do it we could maybe detect that and then apply them in the engine, but in any case we should not push this into the operators themselves! |
Fixes #689
The idea is to reuse the same projection logical optimizer to pushdown projections to binary expression. Binary expression vector operator can use the projection information to skip non projected labels when materializing labels in the join table.
Added comprehensive tests and correctness tests to ensure the correctness.
My local benchmark showed that this helps mainly when label string interning is disabled. We are still using
slicelabelsin Cortex so it helps with our usecase. Users can choose whether to enable or disable this functionality.Here is the AI generated benchmark report based on the benchmarks I ran locally.
Binary Projection Pushdown - Benchmark Results
Test Configuration
-tags slicelabels(label interning disabled)Results
Small Dataset (1K series, 10 labels)
Without Projection:
With Projection:
Savings:
Large Dataset (10K series, 20 labels)
Without Projection:
With Projection:
Savings:
Key Findings
Scaling Behavior
The optimization's benefits scale linearly with dataset size:
Why It Works (with slicelabels)
Memory Breakdown (Large Dataset)
Without projection (22.3 MB):
With projection (4.1 MB):
Savings: 18.2 MB (82%)
Impact of Label Interning
With Default Build (Label Interning Enabled)
The optimization provides minimal benefit because:
With slicelabels Build (Label Interning Disabled)
The optimization provides massive benefit because:
Real-World Implications
For Prometheus (Default Build with Interning)
Not recommended - The optimization adds CPU overhead without meaningful memory savings.
For Systems Without Label Interning
Highly recommended - Provides:
When to Enable
Enable this optimization when:
Conclusion
The optimization is correctly implemented and provides dramatic benefits for systems without label interning: