MLabs City Library | Developer Blog & Learning Center

How Execution Budgeting Works for On-Chain Scripts

Written by Chase Maity | Mar 4, 2024 2:27:40 PM

In this article, we delve into the workings of the Untyped Plutus Core (UPLC) cost model, which is crucial for developers working within the Cardano ecosystem. Understanding this model is key to developing an intuition for how your scripts' execution budget is calculated, which in turn helps you optimize your scripts

The CPU and Memory Costs Are Completely Different From Regular CPU and Memory Usage

The very first thing you have to realize is that the CEK evaluation machine uses a very different yet arguably naive cost model. Although the execution units are named after 'CPU' and 'Memory', they don't work like your typical CPU and Memory! 

For example, memory measurement doesn't rely on a runtime heap (and/or a stack); it isn't 'freed' when a value goes out of scope, and variable references cost the same (or practically more) as constants, no matter the size of the constant. The size of the constant is irrelevant to its individual memory cost altogether! 

This is why it's important to avoid the assumption that these units function the same as traditional CPU and Memory. Many optimizations that make sense in the real world may not make any sense in the context of the CEK evaluation machine!

UPLC Terms Incur a Constant Cost Upon Evaluation

There are only two primary ways your script will consume execution units. The first is the cost for each UPLC term. Here is what the UPLC AST looks like:

data Term name uni fun ann
  = Var !ann !name
  | LamAbs !ann !name !(Term name uni fun ann)
  | Apply !ann !(Term name uni fun ann) !(Term name uni fun ann)
  | Force !ann !(Term name uni fun ann)
  | Delay !ann !(Term name uni fun ann)
  | Constant !ann !(Some (ValueOf uni))
  | Builtin !ann !fun
  | Error !ann

Only the tags and the Term fields are really relevant; you may disregard the rest. As you can see, you have variable references, lambda abstractions, lambda applications, force, delay, constants, and builtin function identifiers. It's very similar to regular untyped lambda calculus.

Whenever one of these terms is encountered (also referred to as 'machine steps') at any point during your script execution, a constant CPU and Memory cost is added. This cost depends on the costing model, but you can see an example of the cost model here:

instancePEqPData where
x#==y =punsafeBuiltinPLC.EqualsData#x#y
{
    "cekStartupCost" : {"exBudgetCPU":     100, "exBudgetMemory": 100},
    "cekVarCost"     : {"exBudgetCPU":   23000, "exBudgetMemory": 100},
    "cekConstCost"   : {"exBudgetCPU":   23000, "exBudgetMemory": 100},
    "cekLamCost"     : {"exBudgetCPU":   23000, "exBudgetMemory": 100},
    "cekDelayCost"   : {"exBudgetCPU":   23000, "exBudgetMemory": 100},
    "cekForceCost"   : {"exBudgetCPU":   23000, "exBudgetMemory": 100},
    "cekApplyCost"   : {"exBudgetCPU":   23000, "exBudgetMemory": 100},
    "cekBuiltinCost" : {"exBudgetCPU":   23000, "exBudgetMemory": 100}
}
As you can see, all terms have the same cost.

Constants Are More Efficient Than Variable References

You'll note here that both Constant and Var terms have the same cost. This is perhaps somewhat unintuitive as a Var will always be accompanied by an Apply and a LamAbs (or a Builtin) - so you always have to pay for those as well. Meanwhile, Constants can be free-standing, and therefore only need to pay the 23000/100 cost.

This effectively means that in many cases, using a Constant (regardless of size) is more efficient than using a Var. Of course, this is still highly impractical - as the script size would increase drastically if big constants were inlined all around scripts!

As an example, I compared the two scenarios:


  1. Working with a constant inlined everywhere.

    ex: <huge constant> == <huge constant>

    Apply ()
    (Apply () (Builtin () PLC.EqualsData) (Constant () (PLC.Some $ PLC.ValueOf PLC.DefaultUniData $ Constr 0 [ I 50000000 , B "abcdDASDAWDASDdadlkwqajkjdjhajkhsdiouahwiduhasiuhdikajwhwdkjahskjsdhkajwhdas" , I 32135165131 ] ) ) (Constant () (PLC.Some $ PLC.ValueOf PLC.DefaultUniData $ Constr 0 [ I 50000000 , B "abcdDASDAWDASDdadlkwqajkjdjhajkhsdiouahwiduhasiuhdikajwhwdkjahskjsdhkajwhdas" , I 32135165131 ] ) )
  2. ex: (\x -> x == x) <huge constant>

    Apply ()
      (LamAbs () (DeBruijn 0)
        (Apply ()
          (Apply ()
            (Builtin () PLC.EqualsData)
              (Var () (DeBruijn 1))
          )
          (Var () (DeBruijn 1))
        )
      )
      (Constant ()
        (PLC.Some $ PLC.ValueOf PLC.DefaultUniData $ Constr 0
          [ I 50000000
          , B "abcdDASDAWDASDdadlkwqajkjdjhajkhsdiouahwiduhasiuhdikajwhwdkjahskjsdhkajwhdas"
          , I 32135165131
          ]
        )
      )
    

Example 2, using references, consumes exBudgetCPU = ExCPU 668284, exBudgetMemory = ExMemory 901 , whereas example 1, using only constants, consumes: exBudgetCPU = ExCPU 578965, exBudgetMemory = ExMemory 601.

NOTE: The cost model used here may be different from the example cost model linked above. However, the relativity between scenarios will never change based on different valid cost models. Specifically, I used PlutusCore.defaultCostModel from plutus commit rev 96a00d6e813546e8b5a85fc9d745844979815b07 to measure this.

Indeed, although the variable references themselves cost the same as the constants (in terms of CPU and Mem), the mandatory Apply, alongside the LamAbs drives up the costs.

To reiterate: 3 variable references cost the same as 3 constants (no matter the size) on their own. However, each variable reference must accompany a set of Apply + LamAbs (or Builtin)!

All Built-In Function Calls Have a Cost

Now, we come to the second and final major way your script can consume execution units: builtin function calls. These function calls are the primitives of the UPLC language and, as a result, form the backbone of everything you do.

Builtin functions have a more involved costing model, comparatively speaking. Although some builtin functions have a constant cost across both CPU and Memory (such as ConstrData, MapData, etc.), many have their cost associated with their argument(s), with a defined relationship.

You can find the example cost model here.

Understanding the Different Models

You might be confused by all the different terminology listed within and may ask how they actually come into calculation.

For example, you might be asking:

What does it mean for the type to be max_size? What is the intercept? What is the slope? What does this have to do with adding 1 and 1????

In general, most of the models are just providing arguments for a simple formula - if you're a data science wizard, you probably already figured out all the formulae for each model type. But I'm not a data science wizard, so I usually glance at the formulae from the docs.

You'll notice how ModelMaxSize is described:

-- | s * max(x, y) + I

Indeed, the s is the slope, whereas the I is the intercept.

NOTE: Whenever the cost models make a reference to "size", they are talking about the size of the actual underlying value - not the term. Whether you're using a variable reference or a constant as an argument to a builtin function that does costing relative to size - the size used for calculation is the same.

But what exactly is 'size'? What is that measured with and how? Well, the size is simply the number of words a value takes in memory. Finally, we have a real-world measurement. Indeed, a word is 64 bits for the conventional CEK evaluation machine. As a result, the integer 18446744073709551615 (maximum value of a 64-bit unsigned integer) has size one. The very next integer: 18446744073709551616 has size two.

Example Analysis with AddInteger

Let's analyze a simple example: the AddInteger builtin:

"addInteger": {
    "cpu": {
        "arguments": {
            "intercept": 205665,
            "slope": 812
        },
        "type": "max_size"
    },
    "memory": {
        "arguments": {
            "intercept": 1,
            "slope": 1
        },
        "type": "max_size"
    }
}

Here's what it says, given 2 integer arguments: x and y, of size m and n respectively - the CPU cost is linear in max(m, n) (whichever size is bigger). The exact slope and intercept are also given for the linear graph.

Similarly, the memory cost is also linear in the max size between m and n, just the intercept and slope are different.

So, given 2 integers, one with size 2 words, and another with size 3 words: the CPU cost of just the builtin function execution would be: 812 * 3 + 205665, or 208101. Similarly, the memory cost would be 1 * 3 + 1, or 4.

Hands-on Example

This is all great, but why don't we test it out? I'll be using PlutusCore.defaultCostModel from plutus commit rev 96a00d6e813546e8b5a85fc9d745844979815b07 to test out an example. This cost model is slightly different, let's look at it briefly:

The machine costs:

{
    "cekStartupCost" : {"exBudgetCPU":     100, "exBudgetMemory": 100},
    "cekVarCost"     : {"exBudgetCPU":   29773, "exBudgetMemory": 100},
    "cekConstCost"   : {"exBudgetCPU":   29773, "exBudgetMemory": 100},
    "cekLamCost"     : {"exBudgetCPU":   29773, "exBudgetMemory": 100},
    "cekDelayCost"   : {"exBudgetCPU":   29773, "exBudgetMemory": 100},
    "cekForceCost"   : {"exBudgetCPU":   29773, "exBudgetMemory": 100},
    "cekApplyCost"   : {"exBudgetCPU":   29773, "exBudgetMemory": 100},
    "cekBuiltinCost" : {"exBudgetCPU":   29773, "exBudgetMemory": 100}
}

The AddInteger cost:

"addInteger": {
    "cpu": {
        "arguments": {
            "intercept": 197209,
            "slope": 0
        },
        "type": "max_size"
    },
    "memory": {
        "arguments": {
            "intercept": 1,
            "slope": 1
        },
        "type": "max_size"
    }
}

Aside: Yes, the slope is 0 on this cost model. Wait, doesn't that make the CPU cost effectively constant? Yes, yes it does. Don't ask me why it's like this!

Alright, with that out of the way, let's write and evaluate a UPLC term:

Apply ()
  (Apply ()
    (Builtin () PLC.AddInteger)
    (Constant () $ PLC.Some $ PLC.ValueOf PLC.DefaultUniInteger 1)
  )
  (Constant () $ PLC.Some $ PLC.ValueOf PLC.DefaultUniInteger 1)

Starting with a good ol' philosophical "what is one plus one really?" never hurts.

Here's the execution cost I got for that: exBudgetCPU = ExCPU 346174, exBudgetMemory = ExMemory 602

Alright! So let's get dissecting. First off, we can take out 100 CPU and 100 Mem right out since that's the startup cost, giving us:

CPU Mem
346,074 502

Now, we have 5 terms - it's obvious that each of them is only evaluated once, so we can multiply the constant cost for each term (29,773 CPU; 100 Mem) by 5 and subtract that out, which gives us:

CPU Mem
197,209 2


Hmm, that's a familiar number. 197,209 is exactly the amount the AddInteger execution should consume! Don't believe me?

= slope * max(m, n) + intercept
= 0 * max(m, n) + 197209 [∵ See `AddInteger` cost model above]
= 197209

During high school exams, this sort of correspondence was usually a sign of all your calculations being correct, allowing you to breathe a sigh of relief. It's probably not too different in this case.

What about the memory? Well, the memory follows the same formula with different slope and intercept amounts:

= slope * max(m, n) + intercept
= 1 * max(m, n) + 1 [∵ See `AddInteger` cost model above]
= 1 * 1 + 1 [∵ The integer `1` consumes one word in memory, ∴ size = 1]
= 2

And it all plays out nicely.

Let's try replacing the 1 + 1 with 52154512154012152215121 + 1!

Apply ()
  (Apply ()
    (Builtin () PLC.AddInteger)
    (Constant () $ PLC.Some $ PLC.ValueOf PLC.DefaultUniInteger 52154512154012152215121)
  )
  (Constant () $ PLC.Some $ PLC.ValueOf PLC.DefaultUniInteger 1)

Result: exBudgetCPU = ExCPU 346174, exBudgetMemory = ExMemory 603.

Of course, 52154512154012152215121 takes two words in memory, and as a result, the max(m,n) has changed.

The CPU should have changed in usual scenarios, but this cost model has the slope for AddInteger set to 0! So the CPU cost doesn't depend on the argument sizes at all.

The memory, on the other hand, has expectedly increased by one

= slope * max(m, n) + intercept
= 1 * max(m, n) + 1 [∵ See `AddInteger` cost model above]
= 1 * 2 + 1 [∵ The integer `52154512154012152215121` consumes two words in memory, ∴ size = 2]
= 3

Remember: This cost is Only for the Builtin Function Execution!

Recall that you pay execution cost for evaluating each UPLC term (also known as CEK machine steps) and builtin function execution. It's important to realize that the cost you calculate for just the builtin function execution (e.g. AddInteger 1 2) is not the only cost you pay for that expression. In fact, you're still paying for the builtin function term, the application, the constants/variable references etc. Some builtin functions even need to be accompanied with a Force before they can be applied, driving up the tertiary costs even higher (albeit by a constant amount)!

The CEK machine has a Constant Startup Cost

This one will be brief. There is a small, constant startup cost associated with each script. This will be added to each script execution no matter what - so it is important you're aware of it before doing reverse engineering calculations on the execution cost. For the example cost model we're using here, it is simply 100 CPU and 100 Memory units.

You Only Pay for What You Look At

You may have realized that due to how the cost model works, due to the fact that Memory isn't really a measure of memory, costing for script arguments (script context, datum, redeemer) is very much ad-hoc.

Indeed, if your script receives a massive ScriptContext with loads of data, but you never look at most of it, you don't pay the execution units for it. No CPU, No Memory.

Feels a bit unintuitive? Well, that's not unexpected - but if you forget everything about how real CPU and Memory works, and simply walk the fully laid out and naive UPLC costing model path - you'll realize why this is the case.

Aside: Are you already familiar with how builtin data and data deconstruction works in UPLC? Awesome! If not, you should familiarize yourself with it before moving forward in this specific section. Refer to the builtin-data guide on Plutonomicon

First, let's talk about the function application. Surely, the massive script context is applied to your script, right? Well, what does this cost? The ScriptContext being applied will be a Constant term - this is pre-known. But recall that there's a constant cost associated with the Apply, and the Constant - and the size of the constant doesn't matter! So there you go, you don't pay anything extra for large script contexts during the initial application.

Ok, but what next? Surely the first thing you do with the ScriptContext is call UnConstrData on it, to obtain its two fields. Won't UnConstrData consume extra execution units depending on size?

As a matter of fact, no! It has a constant cost:

"unConstrData": {
    "cpu": {
        "arguments": 32696,
        "type": "constant_cost"
    },
    "memory": {
        "arguments": 32,
        "type": "constant_cost"
    }
}

Well, that's quite nice! But now, you want to look into the TxInfo - which, in our example scenario, is huge. TxInfo is the first field in ScriptContext, so we need to get the head of the returned list of fields.

That means we have to do a HeadList call, maybe this has extra cost depending on si-….. no, it doesn't:

"headList": {
    "cpu": {
        "arguments": 43249,
        "type": "constant_cost"
    },
    "memory": {
        "arguments": 32,
        "type": "constant_cost"
    }
}

And then you go on to UnConstrData on TxInfo and the cycle continues! Indeed, most builtin construction and deconstruction functions are constant cost (only exceptions being bytestring cons, append, and slice). As a result, even if your script gets a massive script context - you don't pay the full cost for it if you don't go looking in every nook and cranny for it!

What This Means for PlutusTx and Plutarch

For PlutusTx, this hardly means anything. You see, PlutusTx compiled code decodes all the arguments it receives from the chain. Indeed, the ScriptContext, the Datum, the Redeemer - everything goes through a structure validation and decoding process to be converted to Scott encoding. This effectively means that you always pay the full cost.

Unless, of course, you use the Spooky technique - which basically delegates the decoding by using BuiltinData for all field types, forcing the PlutusTx compiler to not insert massive decoding and validation steps (everything is kept as BuiltinData). Later on, it is the user's responsibility to decode only the fields they want/need.

As you may expect, Plutarch fares a whole lot better. Plutarch made a conscious decision to never implicitly decode anything. Your PAsData arguments stay data encoded, they're not decoded in any way.

You can, of course, choose to validate them using PTryFrom - which is often necessary for avoiding security holes. Indeed, you may not be able to trust the datums/redeemers provided to the script depending on your spec/protocol.

In these cases, you must note that PTryFrom is supposed to do a deep validation. In other words, it does look at every nook and cranny. Of course, there is no reason to do this on a ScriptContext. But you may be forced to do it on your datum/redeemer . As a result, you end up paying the cost upfront for validating large datums/redeemers even if you're not interested in all its fields.

Memory Calculation is Often Unfair and Inaccurate

As showcased above, due to how the CEK evaluation machine works: memory consumption of a script may sometimes feel unfair and downright illogical - with no real idiomatic way to optimize it. Unfortunately, it seems like there has not been much attention paid to the memory costing in particular. This implies that memory budgeting isn't all too relevant - yet it could hardly be removed as a limit altogether. As such, for large, complex scripts - memory costs often end up becoming a big problem. In this case, it is recommended that you try and simplify the logic of the script - and potentially break it up into different scripts, and perhaps even different transactions.

Where is the Real Cost Model?

As far as I know, you can find the real cost model from the protocol parameters, which you can query via cardano-cli.

Navigating Cardano's Complexity with MLabs

MLabs has extensively explored the Untyped Plutus Core (UPLC) cost model - and every other corner of the Cardano ecosystem - through numerous client and internal projects. Harness our expertise to transform your scripts into optimized, cost-effective solutions, and galvanize your project's performance.

At MLabs, we pride ourselves on meeting clients where they are and fostering innovation through collaboration. Ensure your vision becomes a tangible reality and reach out today. We will work closely to craft tailored solutions with precision and foresight. 

References and useful links

  1. Plutonomicon builtin-functions guide
  2. Plutonomicon builtin-data guide
  3. The example cost model from the plutus-core repo