CIP-0194? | Builtin pattern matching in UPLC - #1236
Conversation
rphair
left a comment
There was a problem hiding this comment.
@SeungheonOh thanks very much for documenting your implementation with a CIP. The in-progress work I think makes it definite we would assign a CIP number in Triage at the next CIP meeting (https://hackmd.io/@cip-editors/140).
@zliu41 @ana-pantilie @colll78 @Quantumplation @fallen-icarus if you could review the CIP technical presentation any time before or after its confirmation, that would be great.
@kwxm it looks like performance is already also well documented here, but please likewise feel free to contribute about that presentation as well.
|
Strongly support this CIP, approach is sound and a massive improvement. |
|
|
||
| ### Costing | ||
|
|
||
| All work performed by `Match` is charged incrementally. Matching steps are divided into four CEK step kinds: |
There was a problem hiding this comment.
This is rather different to how costing works for other terms, where a cost is associated with the reduction rule for a term.
Here we either have one very big reduction rule, or we would need a way to evaluate the match incrementally. But the possibility of backtracking in the matching makes this difficult.
There was a problem hiding this comment.
As implemented, latter is the case. Match is being costed incrementally. Backtracking doesn't really pose any issue since it also increments the costs for failed cases, so it doesn't have to do anything complicated for failed branches.
I wrote this in a confusing way. Not all four steps being proposed are CEK steps. Only one is for CEK itself, and other three are steps used within the matcher.
|
|
||
| ### Evaluation | ||
|
|
||
| `Match` is evaluated as follows: |
There was a problem hiding this comment.
I would like to see a more declarative and less operational description of how this works. Typically we give a reduction rule for terms.
| | DefaultPatternFieldsPrefixWildcard | ||
| | DefaultPatternFieldsPrefixCapture | ||
|
|
||
| data DefaultBuiltinPattern |
There was a problem hiding this comment.
This is a lot of syntax. Probably this increases the size of the base language by 30%+!
| 2. If the result is not a builtin constant, evaluation fails. Otherwise, inspect alternatives in source order. | ||
| 3. Match an alternative depth-first, from left to right, recording captures as they are reached. | ||
| 4. On a mismatch, discard that alternative's pending work and captures, then try the next alternative. | ||
| 5. On success, select that alternative's handler and apply the captured values to it in source order. |
There was a problem hiding this comment.
Are the handlers evaluated strictly before evaluating the match?
There was a problem hiding this comment.
No, none but the matching handler will be evaluated. Only patterns that comes before the matching case will be inspected.
I'll try to clarify this section
For example:
(match (con integer 2)
(pattern (integer 0) (error)) -- Pattern inspected, but not evaluated
(pattern (integer 1) <expensive work>) -- Pattern inspected, but not costed/evaluated
(pattern (integer 2) (con integer 42))
(pattern (integer 3) (error)))
-- > (con integer 42)
|
|
||
| #### A dedicated `Let` term | ||
|
|
||
| A `Let` term could bind a row of values but still requires CEK support for that row and overlaps with lambda/application as a binding mechanism. Like multi-lambda, it does not provide general nested matching. |
There was a problem hiding this comment.
yes, this seems orthogonal
|
|
||
| The following local benchmarks compare `Match` with existing deconstruction using partial builtins, optionally guarded by `chooseData`, or builtin `Case`. They measure CEK wall-clock time rather than calibrated on-chain execution units. | ||
|
|
||
| #### Capturing one deeply positioned value |
There was a problem hiding this comment.
So... why is this faster? Operationally speaking. We are fundamentally doing a very similar process. Is it just that we skip some expensive parts of the builtin machinery? It seems odd that we're able to do the same thing but faster!
There was a problem hiding this comment.
It's mainly the builtin calling overhead. Calling builtin requires several extra CEK frames. In most cases, it needs Apply and Builtin and for some it also requires extra Forces. These need to happen per each layer of nested values and these overheads turns out to be more expensive than actually deconstructing values themselves.
Match removes all of the overhead only running on very lean pattern syntax and doing value deconstruction directly removing significant amount of the overhead. This is exactly the same reason why IfThenElse is slower(almost 80% iirc!) than Case when casing on boolean.
Also, Match gives options to match pattern without capturing value, this reduces CEK steps even more. For instance, if you just want to check if D.I 10 is integer data or not, currently, you'd do chooseData (D.I 10) ... (\i -> ...) ... where builtin not only have to match on the Data constructors, but it also have to capture 10 and apply to the handler even though the value is not needed. This also incurs more extraneous cost. Match on the other hand can do (pattern (data-i (wildcard) <arity 0>) which doesn't have to dispatch apply at all. This is why the performance gap is bigger when fewer captures were performed in the benchmarks
| `Match` is evaluated as follows: | ||
|
|
||
| 1. Evaluate the scrutinee. | ||
| 2. If the result is not a builtin constant, evaluation fails. Otherwise, inspect alternatives in source order. |
There was a problem hiding this comment.
Should we allow matching on other values? Notably, what about con values? If we're going to add pattern-matching it's a shame not to get it on datatype values.
There was a problem hiding this comment.
Do you mean Constrs? I didn't add it because I initially thought it could break typing in TLPC/PIR. However, looking again, it seems reasonable to add something like DefaultPatternConstr Word64 (Vector DefaultBuiltinPattern).
I'm not entirely sure performance implication to adding this. This would definitely complicate the implementation(which can consequently make it slower) because having pattern for Term.Constr means now it needs to carry and match on Term not just values.
| | DefaultPatternByteString !ByteString | ||
| | DefaultPatternBool !Bool | ||
| | DefaultPatternUnit | ||
| | DefaultPatternList |
There was a problem hiding this comment.
Why not?
DefaultPatternList { headPattern :: DefaultBuiltinPattern, tailPattern :: DefaultBuiltinPattern }
you'd need DefaultPatternNil, perhaps, to terminate them. But I'm a bit unsure about using this prefix/exact shape descriptor rather than a pattern AST that follows the shape of the datatypes.
There was a problem hiding this comment.
This would make pattern interpretation slower since for matching long list, it would need to match on DefaultPatternList repeatedly. I figure it's better to prefer structure that is more optimal to run since this won't be user facing interface anyways.
|
The structure is odd compared to the usual pattern matching. Your form: (match scrutinee (pattern pair (pair (bind) (bind)) (bind))) (lam x (lam y (lam z body)))) Usual form: (match scrutinee (pattern pair (pair x y) z)) body) In both cases, the body refers to x, y, z as bound variables. The first form reuses the existing lambda machinery, while the second does not, so I guess the first is slightly easier to implement. But nowhere is the usual form mentioned or compared with. I think there is good reason to believe that the second can be implemented more efficiently (although it may take more work to do so). So a comparison is essential. Typically, a compiler gets rid of nested pattern matching and only uses a shallow case to look at the top-level structure. Your motivation is that the existing mechanism which does this can (if misused) lead to partial applications, but an easier way to fix that is to supply an arity with each case rather than nested pattern matching. Did you compare with that alternative? You need to add this comparison the the CEP to justify the design. Michael noted that for lists matching on cons and nil instead of prefix is standard. Sungheon responded that the prefix design is more efficient, which sounds plausible. But this needs to be documented with performance numbers in the CEP. If match is included it should also apply to sum-of-product types. (I think Michael makes a similar point.) |
rphair
left a comment
There was a problem hiding this comment.
@SeungheonOh this was declared a candidate at the CIP meeting today, continuing the prior confirmations. Going forward we will generally await the settlement of current & future review like what @michaelpj has already provided (please "resolve" the points that seem to be settled, since this might not always be clear to editors).
Once generally settled please feel free to explicitly point this out in a comment, so we can make sure it moves on to final / editorial review. In the meantime please rename the containing directory to CIP-0194 and update the "Rendered" link in your OP. 🎉
|
Thank you, @wadler and @michaelpj, for the review! About binding captures directlyIt seems that a similar design was considered when SOP terms were added in CIP-85, but was ultimately not adopted. CIP-85 mentions that this optimization initially produced an improvement of around 10%. However, another optimization reduced the realized improvement from direct binding to only around 3%, which was considered a small enough difference to justify preferring the simpler implementation. I am not sure exactly what that other optimization was, or whether there were additional reasons for not using direct binding in CIP-85. Maybe @michaelpj can provide more context here. I tested direct binding for About (re)using
|
zliu41
left a comment
There was a problem hiding this comment.
I need to review it in more detail, but here are some initial comments:
- It's worth elaborating why "A handler with the wrong number of arguments may therefore partially apply instead of failing" is problematic
- The baseline in your benchmark should use the new
dropListbuiltin, if not already - It would be interesting to know how much this approach narrows the performance gap between Data encoding and SOP encoding. Can it close the gap entirely? If not, why?
|
Regarding the long list of patterns, I'd suggest shortening them as much as you can.
|
|
What is the reason behind delegating variable binding to the handler instead of using a matching mechanism which constructs substitution contexts? Concretely, if I understand this correctly, in the following example:
However, if Then it would be up to the matching algorithm to produce a valid substitution for I think it would also make matching more expressive: After beta-reduction, the matching algorithm would not be able to find a valid substitution for Of course, my suggestion would require a more complex costing mechanism to account for the substitution construction. But I think that using a proper matching algorithm would make it more future proof, in case we want to add new features to |
Proposal for adding a new UPLC AST node:
Match.Matchenables matching complex and nested builtin value structure, namelyDatavalues like script context, without builtin function invocation overhead.A working prototype has been implemented: IntersectMBO/plutus#7852
Rendered