-
Notifications
You must be signed in to change notification settings - Fork 487
[design doc] EXPLAIN in Postgres syntax
#31643
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
ggevay
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wrote some comments.
It looks good; hopefully the Postgres syntax will be more familiar to users.
And I'm happy that we are basing it on LIR!
| completionist output to test our optimizer and debug queries. We must | ||
| be careful to keep these tests while enabling the new behavior. | ||
|
|
||
| [https://github.com/MaterializeInc/materialize/pull/31185 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing ]. Also, maybe you wanted to make this into a sentence?
| | `Get` | `Get::Arrangement l0 (val=...)` | `Index Lookup on l0 using ...` | | ||
| | `Get` | `Get::Collection l0` | `Read l0` | | ||
| | `Mfp` | `MapFilterProject` | `Map/Filter/Project` | | ||
| | `FlatMap` | `FlatMap` | `Flat Map` | |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does Postgres have Flat Map? If not, we could consider Table Function.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not exactly:
michaelgreenberg=# EXPLAIN SELECT DISTINCT l_discount, generate_series(1, 5) from lineitem;
QUERY PLAN
------------------------------------------------------------------------
HashAggregate (cost=23.50..36.00 rows=1000 width=22)
Group Key: l_discount, generate_series(1, 5)
-> ProjectSet (cost=0.00..18.50 rows=1000 width=22)
-> Seq Scan on lineitem (cost=0.00..12.00 rows=200 width=18)
(4 rows)
I'm fine with Table Function or ProjectSet.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I vote Table Function!
| | `FlatMap` | `FlatMap` | `Flat Map` | | ||
| | `Join` | `Join::Differential` | `Differential Join` | | ||
| | `Join` | `Join::Delta` | `Delta Join` | | ||
| | `Reduce` | `Reduce::Distinct` | `Distinct GroupAggregate` | |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why not just Distinct?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just following Postgres, which always calls reduces a GroupAggregate:
EXPLAIN SELECT DISTINCT l_discount FROM lineitem;
QUERY PLAN
------------------------------------------------------------------
HashAggregate (cost=12.50..14.50 rows=200 width=18)
Group Key: l_discount
-> Seq Scan on lineitem (cost=0.00..12.00 rows=200 width=18)
(3 rows)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's fine to show it as Distinct. I'd say this is a clear improvement over Postgres not showing it as Distinct. There is no need to follow Postgres just for familiarity here, because we don't really need to rely on familiarity, as it should be pretty clear for users what Distinct is.
| | `Negate` | `Negate` | `Negate Diffs` | | ||
| | `Threshold` | `Threshold` | `Threshold Diffs` | | ||
| | `Union` | `Union` | `Union` | | ||
| | `Union` | `Union (consolidates output)` | `Consolidating Union` | |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm wondering whether users need to know about whether a Union consolidates. Seems like a somewhat esoteric implementation-detail, so maybe not. Also, I can't really think of a situation where user would need to make some decision based on knowing whether a Union consolidates or not.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Happy to skip it! My understanding was that we sometimes cared about this in accounting for memory footprint, but maybe that's an us/VERBOSE TEXT thing?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, it's more for us, enough in VERBOSE TEXT. (But even I haven't looked at this in a long time. Currently, the heuristic for whether a Union consolidates is very simple: if there is at least 1 negated input, then we consolidate. If we were to make the heuristic more complex then we'd need to more often look at it. There was an abandoned attempt on this here: #30360 )
| | `Constant` | `Constant` | `Constant` | | ||
| | `Get` | `Get::PassArrangements l0` | `Index Scan on l0 using ...` | | ||
| | `Get` | `Get::Arrangement l0 (val=...)` | `Index Lookup on l0 using ...` | | ||
| | `Get` | `Get::Collection l0` | `Read l0` | |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think GetPlan's correspondence to reality is a bit more complicated. I don't have the time today to delve into this, but here are my raw notes on what each of the variants are, and some possible refactorings:
GetPlan is unncessarily hard to understand:
- GetPlan::PassArrangements:
- no MFP
- ! BUT: there might or might not be an input arrangement that we are passing (so it's not always an
Index Scan)
- GetPlan::Arrangement:
- there is an input arrangement
- no output arrangement
- there is an MFP
- we might be seeking a key (but that code path is deprecated, so it occurs very rarely; still occurs in aggregates.slt and is_null_propagation.slt) (so it's usually not an
Index Lookup)
- GetPlan::Collection:
- no input arrangement (and no output arrangement)
- currently, there is always an MFP (but this will change when we change PassArrangements)
possible refactoring (which would be useful even independently from EXPLAIN, because the above state of affairs is IMHO really counter-intuitive):
- GetPlan::PassArrangements -- add an if to only produce it if there is an arrangement
- split off a new lookup variant from GetPlan::Arrangement
- rename GetPlan variants
- Get::keys -- make it clear that these are output arrangements
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's talk about this (maybe with @frankmcsherry?) and find the right way to factor this out so that things are actually clear.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm for sure available to discuss. I think we have a few moments of inherent complexity (from the pov of explain) that is more incidental from the pov of plans. Probably the right thing at the moment is to land a thing that speaks unambiguously about the important details. I'm all for figuring out the complexity here, boiling it away, but .. we'll have to iterate and perhaps the right thing at the moment is eat the fact that the truth is gross, for now.
| `Map/Filter/Project`). | ||
|
|
||
| Arity is included in the Postgres style (cf. "width="), though we will | ||
| hopefully not need it when we have good column names. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, it could be off by default when we have good column names.
|
|
||
| Should we more radically reduce the AST? | ||
|
|
||
| Should we abandon static `EXPLAIN` and encourage `mz_lir_mapping` use? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think that's viable, because there are a lot of minor things to take care of when implementing plan printing, and it would be hard to do all those in SQL.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fair enough!
|
|
||
| ``` | ||
| Finish | ||
| Order by: sum desc nulls_first, o_orderdate |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could leave off nulls_first/nulls_last if they are at their default setting. (Which I think is nulls_first for desc, and nulls_last for asc.
| New Materialize `EXPLAIN`: | ||
|
|
||
| ``` | ||
| Finish |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure what Postgres prints for LIMIT/OFFSET, but we might try to match that too if it makes sense.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
They seem to show LIMIT but not OFFSET:
michaelgreenberg=# EXPLAIN SELECT DISTINCT l_discount FROM lineitem LIMIT 10 OFFSET 15;
QUERY PLAN
------------------------------------------------------------------------
Limit (cost=12.65..12.75 rows=10 width=18)
-> HashAggregate (cost=12.50..14.50 rows=200 width=18)
Group Key: l_discount
-> Seq Scan on lineitem (cost=0.00..12.00 rows=200 width=18)
(4 rows)
| -> Filter (columns=33) | ||
| Predicates: (c_mktsegment = "BUILDING") AND (o_orderdate < 1995-03-15) AND (l_shipdate > 1995-03-15) | ||
| -> Delta Join (columns=33) | ||
| Conditions: c_custkey = o_custkey AND o_orderkey = l_orderkey |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As mentioned elsewhere, we'll have a problem with self-joins: we'll be seeing conditions like x = x, where one x is from one join input, and the other x is from another join input. Even including the table name won't resolve it if it's a self-join. And we have a lot of self-joins due to outer join lowering / subquery lowering creating them.
A way out could be to write something like %1.x = %2.x, where %1, %2, ... are the join inputs.
How does Postgres resolve this btw.?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
They use the relevant aliases:
michaelgreenberg=# explain select l1.l_orderkey, l2.l_orderkey from lineitem l1, lineitem l2 where l1.l_partkey = l2.l_partkey and l1.l_orderkey <> l2.l_orderkey and l1.l_shipdate < l2.l_shipdate;
QUERY PLAN
---------------------------------------------------------------------------------------
Hash Join (cost=14.50..35.00 rows=66 width=8)
Hash Cond: (l1.l_partkey = l2.l_partkey)
Join Filter: ((l1.l_orderkey <> l2.l_orderkey) AND (l1.l_shipdate < l2.l_shipdate))
-> Seq Scan on lineitem l1 (cost=0.00..12.00 rows=200 width=12)
-> Hash (cost=12.00..12.00 rows=200 width=12)
-> Seq Scan on lineitem l2 (cost=0.00..12.00 rows=200 width=12)
(6 rows)
I don't think it's possible to have a self-join without such aliases, but I'm not 100% on that.
michaelgreenberg=# explain select * from lineitem join lineitem on (l_partkey);
ERROR: table name "lineitem" specified more than once
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(Discussed in the Optimizer sync meeting: Our lowering for outer joins / subqueries also creates self-joins. The solution will probably be to make the lowering think up synthetic aliases.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The changes in #31878 should make it so we have table aliases in joins:
materialize/src/sql/src/plan/query.rs
Lines 6625 to 6633 in 8e29cdc
| pub fn intern_scope_item(&mut self, item: &ScopeItem) -> Arc<str> { | |
| if let Some(table_name) = &item.table_name { | |
| // In order to avoid clutter, we're just going to use the table name (not database or schema) | |
| self.intern(format!("{}.{}", table_name.item, item.column_name)) | |
| } else { | |
| self.intern(item.column_name.as_str()) | |
| } | |
| } | |
| } |
|
Okay, I tried to incorporate these changes---please take a look! |
| -> Index Scan using pk_customer_custkey on customer (columns=8) | ||
| Delta join first input (full scan): pk_customer_custkey | ||
| -> Index Scan using pk_orders_orderkey, fk_orders_custkey on orders (columns=9) | ||
| Delta join lookup: pk_orders_orderkey (%1), fk_orders_custkey (%0, %2) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do the % numbers mean which join path uses the index? (If yes, then there seems to be a mixup: %0 and %2 user different indexes.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's meant to indicate the join path, yeah... I might have messed it up manually coming up with this!
| | `Negate` | `Negate` | `Negate Diffs` | | ||
| | `Threshold` | `Threshold` | `Threshold Diffs` | | ||
| | `Union` | `Union` | `Union` | | ||
| | `Union` | `Union (consolidates output)` | `Consolidating Union` | |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We might want to leave off the Consolidating case.
Edit: Oh, I see we discussed this already.
ggevay
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great, thanks!
antiguru
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(I don't have a strong opinion as I lack some background in the area, so take my words with a grain of salt.)
I think this proposal is fine, as in it moves us from one potentially confusing representation to something that users might be more familiar with. I like the way it maps LIR nodes to more practical terms (what does Basic even mean?), it should make it easier to follow for users who aren't familiar with the internal workings of Materialize.
The flipside is that I'm not sure by how much we'll improve user experience, and for how long the proposal will make sense in the future. While LIR is closest to what we're executing, it doesn't need to stay like this forever, or we might change LIR to something that is more amenable to dataflow rendering, i.e., different MirScalarExpr evaluation. Basing our explains off something that is even more tuned for a computer to be interpreted than a human might have the effect that explaining will need to recover information that is otherwise not available.
The other part I'm missing is whether a tree-based representation is the best we can do. We've had different approaches in the past, including the current tree-based one, and they all had trade-offs. A stack-machine-like syntax is much closer to what we're rendering (we're not rendering trees), but confusing for people who assume the database follows some volcano-style execution pattern. The tree-based variant is in some way a mis-representation of what's happening, but caters to people who've used explain in the past.
What I mean to say is that this proposal seems like a step in one direction, and fine in the small, but I'm missing the bigger picture -- what is the best way to represent what essentially boils down to a program?
|
Thank you for the review! I'm going to merge this---not as a commitment to do exactly what I describe here, but to record that this is the best revision of Other databases use treelike or tabular formats. The only exception I can think of is Soufflé, which compiles Datalog programs to the RAM (relational abstract machine, IIRC). In principle you could use the RAM to debug your programs, though it doesn't seem like Soufflé makes that easy. Even so, RAM programs are tree structured (but some of the nodes are loops!). Our |
What I feel is the essence of this design doc is that we want to do the following two things:
I'd say that even if in the future we make some big changes to LIR, we'll still want to keep doing the above 1. and 2.
:10000: I think that's the long-term solution to the "how to lay it out on the screen?" question. Non -tree DAGs due to Lets? No problem, we can just draw those non-tree edges. Cycles due to WMR? No problem we can just draw those edges too! Btw., the other long-term thing that would hugely help EXPLAIN's readability is to do away with the minimalism of MIR: introduce explicit representations at the MIR/LIR levels for outer joins, some of the subquery machinery (shared with outer joins), window functions, and possibly even global aggregates. The minimalism might have made sense at an early-stage startup, but it seems it requires too much cleverness from the optimizer to robustly deal with the convoluted plans that we currently get from outer joins, subqueries, and window functions, plus it makes EXPLAIN hard to read. |
|
Random thought about MFP fusion. Maybe we could print fused MFP as a separate operator, similarly to our current MIR EXPLAIN, but add a note that it's fused. For example, in the following example it's critically important that the MFP is fused into the FlatMap, because otherwise And it could be something like: And maybe we could do the same also for MFPs that are pushed into joins: put the MFP on top of the join, but add a note that it's fused/pushed into the join. |
|
If the new explain (after #31878 lands) will be using LIR, I think we'll be presenting things closer to this way anyway! When I have a prototype for that new EXPLAIN, let's revisit this and make sure we're happy with what we're producing. |
Introduces new default syntax for `EXPLAIN`, such that now (1) `EXPLAIN` by default explains LIR plans, which have unambiguous interpretations (unlike MIR plans), and (2) `EXPLAIN` shows information in a Postgres-like syntax, and significantly less information than it used to for `EXPLAIN PHYSICAL PLAN FOR` (i.e., for LIR). You can still explain MIR plans with the old syntax using `EXPLAIN OPTIMIZED PLAN FOR`. You can still explain LIR plans with the old, very verbose syntax using `EXPLAIN PHYSICAL PLAN AS VERBOSE TEXT FOR`. Remaining TODOs: - [x] Update docs for `mz_lir_mapping` to describe new operator names. - [x] Write docs for new default syntax. - [x] Write test for new default syntax. - [x] Write changelog post. MaterializeInc/www#1457 Some remaining questions should be resolved by follow-up PRs, per conversation with @ggevay. MaterializeInc/database-issues#9375 MaterializeInc/database-issues#9376 MaterializeInc/database-issues#9377 ### Motivation * This PR adds a known-desirable feature. #31643 MaterializeInc/database-issues#8889
An outline of how to implement the last stage of https://github.com/MaterializeInc/database-issues/issues/8889.
Rendered document.
Motivation
Tips for reviewer
Checklist
$T ⇔ Proto$Tmapping (possibly in a backwards-incompatible way), then it is tagged with aT-protolabel.