Add a ConstraintAnalysis pass#8853
Conversation
Co-authored-by: Thomas Lively <tlively123@gmail.com>
Co-authored-by: Thomas Lively <tlively123@gmail.com>
Co-authored-by: Thomas Lively <tlively123@gmail.com>
Co-authored-by: Thomas Lively <tlively123@gmail.com>
tlively
left a comment
There was a problem hiding this comment.
Comments on code so far. Will look at tests next.
| // We now know the values at the end of the block. If something changed, | ||
| // flow it onward. | ||
| if (constraints != block->contents.endConstraints) { | ||
| block->contents.endConstraints = std::move(constraints); |
There was a problem hiding this comment.
Can we prove this will actually converge? Can we create a pathological case where the analysis alternates between two different constraint sets forever?
There was a problem hiding this comment.
It must converge now because we simply drop extra things in approximateAnd. If we did something more complex, we'd need to be careful and define a total order, I think.
There was a problem hiding this comment.
I'm worried that a sequence of ORs (control flow merges) and ANDs (from the contents of blocks) could change the order of constraints so that the one that is dropped is not stable.
| // For each local index, we track the constraints we know about it. We only do | ||
| // so at the end of each block, which is enough for the analysis below. | ||
| LocalConstraintMap endConstraints; |
There was a problem hiding this comment.
Why not keep the beginning constraints instead? Then when we can get to the end of a block, we can merge the current constraints (and eventually any additional constraints due to the specific control flow edge) into the beginning constraints of each successor block and only process that successor block again if its starting constraints are different.
In contrast, the current approach may reprocess successor blocks even if they don't learn anything new from the single predecessor that was updated.
Storing the beginning constraints would also let us avoid re-merging all the predecessors for each block in the optimization phase.
This would also help prove convergence. If we can show that the merge operation on constraint sets is monotonic on some partial order on constraint sets and converges after a bounded number of steps, then we will know the analysis will converge. In contrast, it's hard to say anything about how the end constraints will change over time because they are the result of non-monotonic meet operations over the course of the block.
There was a problem hiding this comment.
Interesting, yeah, storing the start might have benefits. It does mean adding a bottom element though, so we can merge incrementally like that.
However, about the very last point: storing the beginning or the end is NFC, so I don't see how it helps with convergence?
There was a problem hiding this comment.
Instead of a bottom element it might be cleaner to use std::optional in the pass. That is, there is no logical (as in mathematical logic) meaning to the bottom element - it is not a logical constraint - so a "null" can be handled by the users, rather than inside constraint.h.
However - thinking more on this, I'm not sure it's right. We can't simply keep merging in content as it flows around. The input to a block is, effectively, X || Y || Z, and it matters which of those we update. If we update X three times with a == 10 that is very different than if we update X, Y, Z to that constraint - only then we can apply something.
So I think it is best to do this as it is written: merge the inputs in a loop, seeing them all at once.
There was a problem hiding this comment.
Instead of a bottom element it might be cleaner to use
std::optionalin the pass. That is, there is no logical (as in mathematical logic) meaning to the bottom element - it is not a logical constraint - so a "null" can be handled by the users, rather than insideconstraint.h.
Sure, bottom is not literally a bounded set of constraints like other values would be, but it is certainly a meaningful point in the space of possible known constraints. Representing it in constraint.h keeps the analysis code simpler while ensuring that we can never "forget" that we have observed a contradiction. It also lets us write unit tests for it.
If we update
Xthree times witha == 10that is very different than if we updateX, Y, Zto that constraint - only then we can apply something.
I'm not sure what you mean here.
There was a problem hiding this comment.
If we receive data from blocks X, Y, and Z, then we only have a valid state after seeing all of X, Y, and Z. That is, if our state starts at some null/bottom, and X arrives, we cannot flow X onward.
Concretely, if X has a == 10 but we haven't seen Y or Z yet, then we don't know if a == 10 is true in this block, and it would be invalid to apply a == 10 in the block and/or to flow it onward.
We only find the valid state of inputs to the block after merging X, Y, and Z. Doing so at once is the simplest way to get the valid state.
| if (pred == *block->in.begin()) { | ||
| // This is the first. Just copy. | ||
| constraints = predConstraints; | ||
| } else { | ||
| // Merge in subsequent ones. | ||
| constraints.approximateOr(predConstraints); | ||
| } |
There was a problem hiding this comment.
If we had a bottom value to initialize constraints to, then we wouldn't have to distinguish the first constraints like this.
There was a problem hiding this comment.
True, yeah. Maybe worth adding, though this might be the only place it helps?
| // If we parsed something using two locals, like x != y, we can also look | ||
| // for the flipped condition among y's constraints TODO |
There was a problem hiding this comment.
Probably better to canonicalize to have e.g. the lower local index on the LHS of the constraint.
There was a problem hiding this comment.
Yeah, I was thinking about that too. A later PR will add these local-local operations, we can pick the best thing there.
| } | ||
|
|
||
| std::optional<LocalConstraint> LocalConstraint::parse(Expression* curr) { | ||
| auto parseEqZ = [&](Expression* value) -> std::optional<LocalConstraint> { |
There was a problem hiding this comment.
| auto parseEqZ = [&](Expression* value) -> std::optional<LocalConstraint> { | |
| auto parseEqZArgument = [&](Expression* value) -> std::optional<LocalConstraint> { |
These lambdas can also be pulled out as helpers in an anonymous namespace.
These might also be nice places to use match.h.
There was a problem hiding this comment.
Sorry, I'm not following this - why is "argument" improving the name?
I don't feel strongly about moving these out to a namespace, but I like that they are enclosed here, which is the one place they might ever be used?
There was a problem hiding this comment.
I experimented with match.h but it didn't really shorten much.
There was a problem hiding this comment.
Sorry, I'm not following this - why is "argument" improving the name?
Based on the current name I was expecting the lambda to look for an eqz node and I was confused when it didn't. What it's doing is parsing the argument to an eqz (or similar) node.
I don't feel strongly about moving these out to a namespace, but I like that they are enclosed here, which is the one place they might ever be used?
I think it would help the reader keep less in mind at once, but I don't feel too strongly about this instance. It's fine as-is.
There was a problem hiding this comment.
Oh, I see. Yes, you're right, argument does help here, I renamed this and parseBinaryArgument.
| auto value = Literal::makeZero(get->type); | ||
| return LocalConstraint{get->index, Constraint{Abstract::Eq, {value}}}; | ||
| } | ||
| return {}; |
This comment was marked as resolved.
This comment was marked as resolved.
Sorry, something went wrong.
This simply parses IR into constraints, flows them around, and sees
if we can prove things.
This is a minimal version, without conditional propagation etc.