Skip to content

ESQL: Make field fusion generic#137382

Merged
nik9000 merged 37 commits intoelastic:mainfrom
nik9000:esql_fuse_length
Nov 10, 2025
Merged

ESQL: Make field fusion generic#137382
nik9000 merged 37 commits intoelastic:mainfrom
nik9000:esql_fuse_length

Conversation

@nik9000
Copy link
Member

@nik9000 nik9000 commented Oct 30, 2025

Speeds up queries like

FROM foo
| STATS SUM(LENGTH(field))

by fusing the LENGTH into the loading of the field if it has doc values. Running a fairly simple test:
https://gist.github.com/nik9000/9dac067f8ce29875a4fb0f0359a75091 I'm seeing that query drop from 48ms to 28ms. So, like, 40% faster.

More importantly, this makes the mechanism for fusing functions into field loading generic. All you have to do is implement BlockLoaderExpression on your expression and return non-null from tryFuse.

Speeds up queries like
```
FROM foo
| STATS SUM(LENGTH(field))
```
by fusing the `LENGTH` into the loading of the `field` if it has doc
values. Running a fairly simple test:
https://gist.github.com/nik9000/9dac067f8ce29875a4fb0f0359a75091
I'm seeing that query drop from 48ms to 28ms. So, like, 40% faster.

More importantly, this makes the mechanism for fusing functions into
field loading generic. All you have to do is implement
`BlockLoaderExpression` on your expression and return non-null from
`tryFuse`.
@elasticsearchmachine
Copy link
Collaborator

Hi @nik9000, I've created a changelog YAML for you.

* "fusing" the expression into the load. Or null if the fusion isn't possible.
*/
@Nullable
Fuse tryFuse(SearchStats stats);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's try to find another name - we already have Fuse as a command. ExpressionFieldLoader?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is FusedExpression ok? Or still too indicative?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naming... 😅

I come from staring at FUSE enough that it carries a lot of weight.

For me, this feature involves BlockLoaders. And Expressions that are applied to them. I understand that fuse means getting together those two, but it's not something I would think of immediately without more context.

I'd prefer to be overly explicit here, and call this BlockLoaderExpression or something similar that helps me bridge those two concepts together. But, naming...

BlockLoaderExpression.Fuse fuse
) {
// Only replace if exactly one side is a literal and the other a field attribute
if ((similarityFunction.left() instanceof Literal ^ similarityFunction.right() instanceof Literal) == false) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! It's much better to let the Expression deal with the details and make this generic 👍

*/
public boolean pushable() {
return true;
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bothers me. I needed this because without it we'd try to push this:

FROM foo
| WHERE LENGTH(kwd) < 10

to the index. Now, we might be able to do that with a specialized lucene query. But we don't have one of those. Without those change instead what happens is:

  1. LENGTH(kwd) becomes $$kwd$length$hash$.
  2. We identify $$kwd$length$hash$ < 10 as pushable.

This tells us we can't push it. But it's kind of picky. If SearchStats took EsField it could check this easy enough. That might be a good solution to this.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MultiTypeEsField is created with aggregatable=false, so that predicates on it don't get pushed down incorrectly.

Adding pushable should also work.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding pushable should also work.

I'm going to see if I can do aggregatable=false

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just setting aggregatable to false doesn't do it. But I can return false from getExactInfo which seems to do the trick. I'm not entirely sure it's the best solution, but it doesn't invent a new thing.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But! I'm not sure that's right either. exact seems to be a concept we use at type resolution time - but I'm not sure why. It's a left-over from old QL that had a more useful meaning there.

I wonder if it'd be better to keep pushable and maybe rename to existsInEsIndex or something.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've flipped this to using exact and that does seem to work. Not sure if I like it more.

nik9000 added a commit to nik9000/elasticsearch that referenced this pull request Oct 31, 2025
Adds special purpose `BlockLoader` implementations for the `MV_MIN` and
`MV_MAX` functions for `keyword` fields with doc values. These are a
noop for single valued keywords but should be *much* faster for
multivalued keywords.

These aren't plugged in yet. We can plug them in and performance test
them in elastic#137382. And they give us two more functions we can use to
demonstrate elastic#137382.
}

public void testLengthInWhereAndEval() {
assumeFalse("fix me", true);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QL friends: This one looks fun!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason that we get duplicated reference attributes here is that when PushExpressionsToFieldLoad creates a new FunctionEsField in EsRelation, it was generated under a specific command context, and it doesn't look at the the whole query plan level. So when the same LENGTH(last_name) is referenced in multiple commands in the query, duplicated FunctionEsFields are added into EsRelation.

ResolveUnionTypes has a very similar workflow. It iterates through the entire query plan to prepare the attributes added into EsRelation

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

++, I'm rewriting this to look more like ResolveUnionTypes in #137392

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

++, I'm rewriting this to look more like ResolveUnionTypes in #137392

Should I wait for you to do that rewrite before merging this PR? Or will should I merge first and then you'll fix it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Up to you! I'm addressing in #137564, but it still has to be reviewed. Feel free to merge this and I'll deal with integrating it.

@nik9000 nik9000 marked this pull request as ready for review October 31, 2025 19:52
@nik9000 nik9000 requested a review from carlosdelest October 31, 2025 19:52
@elasticsearchmachine elasticsearchmachine added the Team:Analytics Meta label for analytical engine team (ESQL/Aggs/Geo) label Oct 31, 2025
@julian-elastic
Copy link
Contributor

I am done with my first round of code review, overall looks pretty good!
I think we should also add some csv cases to actually verify the data is correct with the push down.
And maybe a nightly performance test for both length and dense vector to demonstrate the improvement.

@nik9000
Copy link
Member Author

nik9000 commented Nov 6, 2025

I think we should also add some csv cases to actually verify the data is correct with the push down.

I believe we already have these tests, but I'll double check and add a few more out of paranoia.

And maybe a nightly performance test for both length and dense vector to demonstrate the improvement.

We have it for vectors. I'll look at it for string length.

We'll want it for aggregate_metric_double which we're going to use this on soon. And MV_MIN and MV_MAX, I think.

phananh1010 added a commit to phananh1010/elasticsearch that referenced this pull request Nov 6, 2025
BASE=d657f7bef51da69d79134325ab5c3c5352ddf264
HEAD=05af8536e27b1e0c2d03d418fa19dc43f13b01e6
Branch=main
@nik9000
Copy link
Member Author

nik9000 commented Nov 6, 2025

Hey folks. This is ready for another round. I'm going to add some more csv-spec tests this afternoon.

I decided to go with @julian-elastic's first approach using the enum. It's probably the wrong approach, but I think some kind of big interface or strings are worse. But only marginally. I think we won't know a nice approach until we have a dozen of these things and we start to hate the enum. But we can change it then.

@nik9000
Copy link
Member Author

nik9000 commented Nov 7, 2025

| 90th percentile service time | esql-avg-message-length | 11078 | 6670 | -4407.95 | ms | -39.79% |
phananh1010 added a commit to phananh1010/elasticsearch that referenced this pull request Nov 7, 2025
BASE=f08e7317360562458eec6fc609df81184ae53a9a
HEAD=8504ed04897b23fa6781f37dd80f059965c6cd14
Branch=main
nik9000 added a commit to elastic/rally-tracks that referenced this pull request Nov 7, 2025
In elastic/elasticsearch#137382 we're pushing
functions into field loading and using LENGTH as an example. This adds a
rally track to demonstrate the performance difference:
```
| 90th | esql-avg-message-length | 11078 | 6670 | -4407.95 | ms | -39.79% |
```
nik9000 added a commit to elastic/rally-tracks that referenced this pull request Nov 10, 2025
In elastic/elasticsearch#137382 we're pushing
functions into field loading and using LENGTH as an example. This adds a
rally track to demonstrate the performance difference:
```
| 90th | esql-avg-message-length | 11078 | 6670 | -4407.95 | ms | -39.79% |
```

Filter filter = as(eval2.child(), Filter.class);
And and = as(filter.condition(), And.class);
GreaterThan left = as(and.left(), GreaterThan.class);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it should be 2? I made one of the pushdowns on first name :) Just to let you know so you don't spend extra time debugging when working on #137679. You don't need to change to 2 right for this PR.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it.

);
}

public void testLengthNotPushedToText() throws IOException {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why can't this optimization work with Text?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll push a comment to explain it but the sort version is that we haven't written the code yet. Text fields are loaded from _source and we've only implemented this optimization for loading from doc values. Worse, we've only implemented it for the particular kind of doc values that keyword uses. wildcard fields don't use the same encoding. We'd have to write another push down implementation for those.

Copy link
Contributor

@julian-elastic julian-elastic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! Thank you for addressing my concerns! I left a few more small comments, but they can be addressed in the next PR.

@nik9000
Copy link
Member Author

nik9000 commented Nov 10, 2025

I'll merge this now and open a follow up with some instructions and explanations based on @julian-elastic's last comments.

@nik9000 nik9000 merged commit 97f96b4 into elastic:main Nov 10, 2025
34 checks passed
nik9000 added a commit that referenced this pull request Nov 11, 2025
Implements most remaining block loaders for MV_MIN and MV_MAX. Once #137382 is in we can push MV_MIN and MV_MAX into the block loaders for most field types. This is compelling it significantly reduces the amount of data loaded when using MV_MIN and MV_MAX.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

:Analytics/ES|QL AKA ESQL >enhancement Team:Analytics Meta label for analytical engine team (ESQL/Aggs/Geo) v9.3.0

5 participants