Builtin Pattern Matching - #7852
Open
SeungheonOh wants to merge 13 commits into
Open
Conversation
SeungheonOh
force-pushed
the
sho/builtinMatching
branch
from
July 28, 2026 15:27
cda7604 to
1583de5
Compare
SeungheonOh
force-pushed
the
sho/builtinMatching
branch
from
July 28, 2026 15:30
1583de5 to
3406359
Compare
SeungheonOh
force-pushed
the
sho/builtinMatching
branch
from
July 30, 2026 17:52
99f8732 to
6e3e6cd
Compare
SeungheonOh
force-pushed
the
sho/builtinMatching
branch
2 times, most recently
from
July 30, 2026 19:39
0a885d5 to
87ccb2a
Compare
SeungheonOh
force-pushed
the
sho/builtinMatching
branch
from
July 31, 2026 02:51
87ccb2a to
387bb76
Compare
Add exact four-kind costing and comparison workloads, update the pre-activation costs, and lock the resulting budgets in the Match test suite.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This PR adds new UPLC AST node
MatchandDefaultBuiltinPatternfor ordered and recursive pattern matching on builtin constants.Matchcan inspect and capture nestedDataas well as nested builtin integers, bytestring, lists and pairs.Matchoperations are costed incrementally according to the complexity of the pattern.The changes in this PR covers the syntax, serialization, CEK modifications, costing and conformance tests; also, minor changes were made to optimizer behaviors and the plugin to handle newly added AST node and make everything buildable. This PR does not cover any changes outside of UPLC and CEK; this means no changes TPLC, PIR, and
Plinthcside to make use ofMatch, no new optimization passes for performingMatchrelated optimizations, and no changes to any of PlutusTx library to make use ofMatch.Backgound / Raitonale
Builtin casing extended
Casenode to be used with builtin constant scrutinee for integer, list, boolean, and unit. SinceCasenode does not provide a way to provide additional information, or patterns, on each branch handlers, the expressiveness of the implemented builtin casing is limited. Each handlers ofCasenode are given fixed meanings; for integer, first branch ofCasematches for integer value 0, second for 1, third for 2 and so on. For integer, list, and boolean, this limited casing with fixed semantics given to each handlers still gave significant performance improvements since it removed all overheads of callig builtin functions. Expecting similar performance improvement for casing onData, which is the most often used builtin type for smart contracts, the initial plan was to simply extand the builtin casing capabilities to builtinDataas well(#6602).In fact, the naive support for builtin
Datamatching is implemented in #7209. This PR directly assigns each constructors ofDatato each branchs ofCasenode and provide a way to match on different builtinDatavalues, essentially giving more efficientchooseDatabuiltin function. However, this apporach was rejected due to lacking real world value. As noted by @colll78,chooseDatais rarely used on smart contracts. Most of builtinDatadeconstruction work is for deconstruction of known data structure(script contexts) which can be done much more efficiently with partial builtin functions likeUnConstrData. Having more efficientchooseDatamerely meant marginal performance improvements on failableDatadecoding which will be only useful for UTxO attached datum decoding.More practically valuable approach proposed was matching on
Data.Constrdirectly whenCaseing on builtin data(see "Iteration 2, Data" on #6602). This idea gave bigger practical performance improvements on real world smart contracts by allowing direct casing onData.Constr, which is heavy on ledger script context. This approach came with a difficult problem on the typesystem of TPLC/PIR. Namely,Data.Constris untyped runtime value(in a sense that the typesystem doesn't know how many element it's carrying) hence it's not possible to make it possible to catch argument length mismatch, like so:This "invalid" casing will successfully evaluated. Proposed solution(#5777 and #6225) is the introduction of multi-lambda/multi-application which can add atomic saturation of all arguments, for example like
Multi-Lambda would require addition of new AST node(s); new, very specific CEK frame for handling multi-lambda/apply; and after all of these intense changes, it only gives limited matching on
Data.Constr. Similar, but more contained idea was to implementLetAST node(#6602, "Iteration2, Data") which still requires extra CEK frame for handling "row" of values and also limited semantic impressibility just like existing builtin casing.All of the previous ideas would require a new dedicated AST node, comes with a relatively high implementation complexity, and overlapping functionalities. Multi-lambda/apply would not only require implementing two new AST node and figure out how CEK must behave with application of multi-lambda but also asks an annoying question of distinguishing nested regular lambdas from multi-lambda when generating into UPLC. A dedicated
Letnode would require similar efforts but it would have similar question on when to use lambda-apply as let binding as opposed to newLetAST node. Between all of possible direction, all seems to require addition of new AST node, so I explored design what would minimize big modificaiton of core CEK and overlapping functionalities with other AST nodes. This led to a more powerful genenral pattern matching node that would not only work onData.Constrbut also on integer, bytestring, lists, and other constructors ofDatavalues.MatchNodeFrom AST standpoint, this PR adds two main things:
Matchnode andpat.Matchnode essentially works likeCasebut for each branches a pattern,pat, is attached.Matchuses each patterns to select which handlers to pick.patis a universe specific pattern syntax.DefaultBuiltinPatternis definedpatfor the default universe. Pattern description should be straight forward as it matches the DefaultUni types.DefaultPatternWildCardandDefaultPatternCaptureare for capturing values from the given builtin values. Wildcard allows patterns to progress without matching value at the given point and capture records the value which will be passed to the handler. Respectively, they roughly aligns to_and variable bindings in Haskell's patterns. For types like list, Data.Constr, Data.List, and Data.Map,DefaultPatternFieldEndis used to decide rather to match strictly--like\[a, b, c] -> ...--or to match first few--like\(a:b:c:rest) -> ....With these addition, one can write
Implementation
Matchis implemented as a costed depth-first traversal. The matcher maintains an explicit work stack for pending sibling and field matches, together with a separate accumulator for captured values. Alternatives are attempted in source order. On mismatch, the current alternative’s work stack and captures are discarded in constant time. On success, the captures are materialized(reversed and constructed intoSpine) as applications of the selected handler.The CEK integration is handled by a new
FrameMatchframe, analogous toFrameCase. Once the scrutinee has been evaluated,FrameMatchdispatches to the universe-specific matcher. If the selected pattern captures values, the frame pushes them throughFrameAwaitFunConNso that they are applied to the corresponding handler. This closely follows the existing implementation of builtinCaseand requires no substantial changes to the CEK, unlike alternatives such as atomic multi-lambda application.Universe-specific matching is provided through the
MatchBuiltintype class, in the same way thatCaseBuiltinabstracts builtin casing. UnlikeCaseBuiltin,matchBuiltinruns inPatternMatchM, allowing the matcher to charge costs incrementally as it traverses patterns. TheMatchBuiltin DefaultUni DefaultBuiltinPatterninstance performs a straightforward recursive traversal ofDefaultBuiltinPattern. When it encountersDefaultPatternCapture, it records the corresponding value in the capture accumulator. If the pattern succeeds, the captures are reversed into source order and passed toFrameAwaitFunConNfor application to the selected handler. If the pattern later fails, those captures are discarded together with the remaining work for that alternative and match proceeds with the next pattern.Each step of the pattern matching is incrementally cost-able. When the single unit of pattern is being matched, like matching integer or matching an element of a list, cost counter gets incremented directly per action. Each alternatives works the same way: when pattern fails and matcher proceed to the next alternative pattern, it increments the cost accordingly. This approach allows complex and arbirary patterns with no arbitrary bounds as such patterns will be accounted for through the costing directly.
Alternative costing strategies were also explored. One notable approach was to compute the size of each pattern upfront, rather than charging incrementally during traversal. This improved performance by eliminating the overhead of invoking the costing increments at every matching step. However, the approach either depended on the Flat decoder injecting the encoded size of each pattern directly into the AST or having small uncosted "look ahead" work that would scan the size of pattern before actually matching anything. This would make the decoder part of the trusted costing path or introduce uncosted work. Requirements from either approach are hard to justify without a significant performance benefit; the approach was rejected in favor of incremental costing derived directly from the matching work performed at runtime.
Costing
All matching work performed by
Matchis costed incrementally. Matching steps are divided into three categories for more accurate costing:pattern,structural, andnext.patternaccounts for individual matching operations, such as matching integers, byte strings, and booleans, as well as processing captures and wildcards.structuralaccounts for operations that require recursive matching over values such as lists,Data.List, andData.Map. Structural steps are more expensive because, unlike pattern steps, they require creating entries on the work stack.nextaccounts for abandoning a failed pattern and proceeding to the next alternative.This separation provides more granular control over the cost assigned to each kind of pattern-matching work.
The costing values for
BMatch,BPattern,BStructural, andBMatchNextin this PR are rough estimates based on measurements from my local machine. From what I can tell, they are conservative in most cases and allow even complex patterns to terminate comfortably within the on-chain evaluation-time limit.MatchvsCaseMatchas proposed gives everything that builtin casing node does functionally but in a more expressive ways. For instance,can be simplified as
This will reduce the script size since
Matchallows assignment of specific integer values to each branchs instead of requiring to enumerate from 0.However, introduction of
Matchdoes not suggest deprecation of existing builtin value casing. For shallow cases, like unconsing list or matching on boolean, existing builtin casing would be more performant since dispatching branchs on builtin casing requires significantly less work for spinning of matcher and running patterns.PIR/TPLC
What is on this section is not yet been implemented.
The capture-argument types of each Match handler can be derived from the pattern and the normalized type of the scrutinee. Given the result type of the Match expression, TPLC and PIR can therefore check each handler against the function type formed by the capture types followed by the result type. This can be implemented similarly to builtin casing, provided that the static pattern annotator implemented according to the runtime matcher. There is no need for exhaustiveness checking as no matching pattern results in explicit failures in the CEK machine.
Performance
Benchmark: Capturing a single value from inner most position
MatchCEKMatchData.Constr, field 1,024UnConstrDataChooseDataData.List, field 1,024UnListDataChooseDataCaseData.ConstrlayersChooseDataData.ListlayersChooseDataChooseDataI ran basic benchmarks to compare performance of deconstructing
Data.Constr,Data.List, and builtin list. For each variable, It's testing matching on wide structure and nested structure. For matching onDatait has two cases where one checks the type ofDatavalue usingchooseDatabefore running partialunConstr/unListwhile other cases will assume type and run partial function without runningchooseData. All test cases are capturing the inner most value; for list that would be the last element on the list and for nested types that would be the inner most nested value. On all cases, we are seeing at least 6x performance implements.Benchmark: Caputing multiple values
MatchCEKMatchData.Constr, all 1,024 fields capturedUnConstrDataChooseDataData.List, all 1,024 fields capturedUnListDataChooseDataCaseData.Constrlayers, 192 capturesChooseDataData.Listlayers, 192 capturesChooseDataChooseDataThese are results for capturing all values stored in the value. The performance gaps have closed up quite a bit here since capturing operations are more costly as it needs to store bound values to be applied to handler. It's still giving over 60% improvemal overall. Since the costing parameters are not fully calibrated for
Matchyet, it's not really useful to compare execution cost here yet. But, to include for the sake of completeness, both MEM and CPU improved from 10% to 90% based on how much variable capture it's performing using my conservative cost parameters. Typically, when used in script context, it is more often only few fields are used, so with the current costing parameters, actual improvements will be around 40% to 60%.TODO
These are some ideas within this PR to improve.
pattype argument toTerm. I don't think this is necessary and it is better to give one pattern type per universe.PatternMatchMcan be done more gracefully.PatternMatchMis essentially a wrapper overSTmode used for costing. This makesMatchBuiltinsomewhat specific to CEK machine. Of course, this can be solved by introducing extra layer of abstraction but it degraded performance last time I tried.StepKind. Currently, this PR addes four new CEK steps. One forMatchnode itself, a typical node costing on CEK, but also there's new constructors for steps used within matcher. Perhaps it is better to keepStepKindstrictly for AST node costing and move the pattern stepping mechanism somewhere else for better isolation. This isn't really difficult, but making everything performant is key challenge.