Skip to content

Builtin Pattern Matching - #7852

Open
SeungheonOh wants to merge 13 commits into
masterfrom
sho/builtinMatching
Open

Builtin Pattern Matching#7852
SeungheonOh wants to merge 13 commits into
masterfrom
sho/builtinMatching

Conversation

@SeungheonOh

@SeungheonOh SeungheonOh commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

This PR adds new UPLC AST node Match and DefaultBuiltinPattern for ordered and recursive pattern matching on builtin constants. Match can inspect and capture nested Data as well as nested builtin integers, bytestring, lists and pairs. Match operations 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 Plinthc side to make use of Match, no new optimization passes for performing Match related optimizations, and no changes to any of PlutusTx library to make use of Match.

Backgound / Raitonale

Builtin casing extended Case node to be used with builtin constant scrutinee for integer, list, boolean, and unit. Since Case node 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 of Case node are given fixed meanings; for integer, first branch of Case matches 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 on Data, which is the most often used builtin type for smart contracts, the initial plan was to simply extand the builtin casing capabilities to builtin Data as well(#6602).

In fact, the naive support for builtin Data matching is implemented in #7209. This PR directly assigns each constructors of Data to each branchs of Case node and provide a way to match on different builtin Data values, essentially giving more efficient chooseData builtin function. However, this apporach was rejected due to lacking real world value. As noted by @colll78, chooseData is rarely used on smart contracts. Most of builtin Data deconstruction work is for deconstruction of known data structure(script contexts) which can be done much more efficiently with partial builtin functions like UnConstrData. Having more efficient chooseData merely meant marginal performance improvements on failable Data decoding which will be only useful for UTxO attached datum decoding.

More practically valuable approach proposed was matching on Data.Constr directly when Caseing 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 on Data.Constr, which is heavy on ledger script context. This approach came with a difficult problem on the typesystem of TPLC/PIR. Namely, Data.Constr is 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:

case (Data.Constr 0 [Data.I 1])
  (\x y -> x)

-- > \y -> Data.I 1

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

(\[x y] -> x) [Data.I 1, Data.I 2] -- evaluates
(\[x y] -> x) [Data.I 1]           -- argument mismatch, fails 

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 implement Let AST 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 Let node would require similar efforts but it would have similar question on when to use lambda-apply as let binding as opposed to new Let AST 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 on Data.Constr but also on integer, bytestring, lists, and other constructors of Data values.

Match Node

From AST standpoint, this PR adds two main things: Match node and pat.

-- module UntypedPlutusCore.Core.Type
data Term name uni fun pat ann
  = ...
  | Match !ann !(Term name uni fun pat ann) !(Vector (pat, Term name uni fun pat ann))

-- module PlutusCore.Default.Universe
data DefaultBuiltinPattern
  = DefaultPatternWildcard
  | DefaultPatternCapture
  | DefaultPatternInteger !Int64
  | DefaultPatternByteString !ByteString
  | DefaultPatternBool !Bool
  | DefaultPatternUnit
  | DefaultPatternList
      !DefaultPatternFieldEnd
      !(Vector.Vector DefaultBuiltinPattern)
  | DefaultPatternPair !DefaultBuiltinPattern !DefaultBuiltinPattern
  | DefaultPatternDataConstr
      !Word64
      !DefaultPatternFieldEnd
      !(Vector.Vector DefaultBuiltinPattern)
  | DefaultPatternDataMap
      !DefaultPatternFieldEnd
      !(Vector.Vector DefaultBuiltinPattern)
  | DefaultPatternDataList
      !DefaultPatternFieldEnd
      !(Vector.Vector DefaultBuiltinPattern)
  | DefaultPatternDataI !DefaultBuiltinPattern
  | DefaultPatternDataB !DefaultBuiltinPattern

Match node essentially works like Case but for each branches a pattern, pat, is attached. Match uses each patterns to select which handlers to pick. pat is a universe specific pattern syntax. DefaultBuiltinPattern is defined pat for the default universe. Pattern description should be straight forward as it matches the DefaultUni types. DefaultPatternWildCard and DefaultPatternCapture are 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, DefaultPatternFieldEnd is 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

(program 1.2.0
  (match (con data (Constr 7 [I 1, B #aa, I 9]))
    (pattern
      -- Match on `Data.Constr 8 [<bind>, Data.List [_, _] ...]`
      -- Handler must have type `data -> a`
      (data-constr 8 (prefix (bind) (data-list (wildcard) (wildcard)) (wildcard)))
      (error))
    (pattern
      -- Match on `Data.Constr 7 [Data.I a, rest]`
      -- Handler must have type `int -> list data -> a`
      (prefix (data-constr 7 (data-i (bind))) (bind))
      (lam integerCapture (lam rest rest)))
    (pattern
      -- Match on `[_, <bind>, _, <bind>, ...]`
      -- Handler must have type `b -> b -> a`
      -- where `list b` is the type of given scrutinee
      (prefix (list (wildcard) (bind) (wildcard) (bind) (wildcard)))
      (lam a (lam b a)))      
    (pattern (wildcard) (error))))

Implementation

Match is 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 into Spine) as applications of the selected handler.

The CEK integration is handled by a new FrameMatch frame, analogous to FrameCase. Once the scrutinee has been evaluated, FrameMatch dispatches to the universe-specific matcher. If the selected pattern captures values, the frame pushes them through FrameAwaitFunConN so that they are applied to the corresponding handler. This closely follows the existing implementation of builtin Case and requires no substantial changes to the CEK, unlike alternatives such as atomic multi-lambda application.

newtype PatternMatchM s a = PatternMatchM
  { runPatternMatchM :: (PatternWork -> ST s ()) -> ST s a
  }

class MatchBuiltin uni pat where
  matchBuiltin
    :: Some (ValueOf uni)
    -> Vector (pat, term)
    -> PatternMatchM s (HeadSpine Text term (Some (ValueOf uni)))

Universe-specific matching is provided through the MatchBuiltin type class, in the same way that CaseBuiltin abstracts builtin casing. Unlike CaseBuiltin, matchBuiltin runs in PatternMatchM, allowing the matcher to charge costs incrementally as it traverses patterns. The MatchBuiltin DefaultUni DefaultBuiltinPattern instance performs a straightforward recursive traversal of DefaultBuiltinPattern. When it encounters DefaultPatternCapture, it records the corresponding value in the capture accumulator. If the pattern succeeds, the captures are reversed into source order and passed to FrameAwaitFunConN for 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 Match is costed incrementally. Matching steps are divided into three categories for more accurate costing: pattern, structural, and next.

pattern accounts for individual matching operations, such as matching integers, byte strings, and booleans, as well as processing captures and wildcards.
structural accounts for operations that require recursive matching over values such as lists, Data.List, and Data.Map. Structural steps are more expensive because, unlike pattern steps, they require creating entries on the work stack.
next accounts 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.

data StepKind
  = ...
  | BMatch
  | BPattern -- Root/scalar work, bytestring words, and reached captures
  | BStructural -- Reached child/field edges and their bounded arity probes
  | BMatchNext -- Abandoning a failed alternative and probing the next one

The costing values for BMatch, BPattern, BStructural, and BMatchNext in 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.

Match vs Case

Match as proposed gives everything that builtin casing node does functionally but in a more expressive ways. For instance,

(case 4 (error) (con integer 10) (error) (error) (con integer 20))

can be simplified as

(match 4 (pattern (integer 1) (con integer 10)) (pattern (integer 4) (con integer 20)))

This will reduce the script size since Match allows assignment of specific integer values to each branchs instead of requiring to enumerate from 0.

However, introduction of Match does 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.

class AnnotatePatternBuiltin pat uni where
  annotateCaseBuiltin
    :: UniOf term ~ uni
	=> PatOf term ~ pat
    => Type TyName uni pat ann
    -> [(pat, term)]
    -> Either Text [(term, [Type TyName uni ann])]

Performance

Benchmark: Capturing a single value from inner most position

Input and target Traditional baseline Traditional CEK Match CEK Traditional / Match
Data.Constr, field 1,024 direct UnConstrData 33.628 us 5.119 us 6.50x
guarded with ChooseData 33.479 us 5.230 us 6.38x
Data.List, field 1,024 direct UnListData 33.775 us 5.283 us 6.42x
guarded with ChooseData 33.525 us 5.150 us 6.56x
Builtin list, element 1,024 builtin Case 33.675 us 5.131 us 6.51x
64 nested Data.Constr layers direct destructors 21.719 us 2.240 us 9.70x
guarded with ChooseData 31.068 us 2.297 us 13.74x
64 nested Data.List layers direct destructors 13.108 us 1.969 us 6.67x
guarded with ChooseData 21.523 us 2.001 us 10.67x
64 alternating Constr/List layers direct destructors 17.998 us 2.127 us 8.46x
guarded with ChooseData 26.110 us 2.158 us 12.10x

I 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 on Data it has two cases where one checks the type of Data value using chooseData before running partial unConstr/unList while other cases will assume type and run partial function without running chooseData. 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

Input and target Traditional baseline Traditional CEK Match CEK Traditional / Match
Data.Constr, all 1,024 fields captured direct UnConstrData 35.523 us 20.502 us 1.75x
guarded with ChooseData 35.257 us 20.674 us 1.67x
Data.List, all 1,024 fields captured direct UnListData 34.978 us 20.359 us 1.72x
guarded with ChooseData 35.297 us 20.712 us 1.72x
Builtin list, all 1,024 elements captured builtin Case 34.369 us 20.443 us 1.68x
64 nested Data.Constr layers, 192 captures direct destructors 21.252 us 4.929 us 4.29x
guarded with ChooseData 31.078 us 5.091 us 6.10x
64 nested Data.List layers, 192 captures direct destructors 12.887 us 4.943 us 2.59x
guarded with ChooseData 21.461 us 4.935 us 4.30x
64 alternating Constr/List layers, 192 captures direct destructors 17.663 us 5.075 us 3.48x
guarded with ChooseData 26.182 us 5.041 us 5.21x

These 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 Match yet, 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.

  • Investigate if there's a way to make MatchBuiltinUni depend on DefaultUni. Currently, it is required to add extra pat type argument to Term. I don't think this is necessary and it is better to give one pattern type per universe.
  • See if PatternMatchM can be done more gracefully. PatternMatchM is essentially a wrapper over ST mode used for costing. This makes MatchBuiltin somewhat specific to CEK machine. Of course, this can be solved by introducing extra layer of abstraction but it degraded performance last time I tried.
  • Figure out if it would be better to isolate match related costing from StepKind. Currently, this PR addes four new CEK steps. One for Match node itself, a typical node costing on CEK, but also there's new constructors for steps used within matcher. Perhaps it is better to keep StepKind strictly 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.
  • Expand benchmarking/budget comparison. We need a better CEK calibration suite.

@SeungheonOh
SeungheonOh force-pushed the sho/builtinMatching branch from cda7604 to 1583de5 Compare July 28, 2026 15:27
@SeungheonOh
SeungheonOh force-pushed the sho/builtinMatching branch from 1583de5 to 3406359 Compare July 28, 2026 15:30
@SeungheonOh
SeungheonOh force-pushed the sho/builtinMatching branch from 99f8732 to 6e3e6cd Compare July 30, 2026 17:52
@IntersectMBO IntersectMBO deleted a comment Jul 30, 2026
@SeungheonOh
SeungheonOh force-pushed the sho/builtinMatching branch 2 times, most recently from 0a885d5 to 87ccb2a Compare July 30, 2026 19:39
Add exact four-kind costing and comparison workloads, update the pre-activation costs, and lock the resulting budgets in the Match test suite.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant