Skip to Content
Scalus Club is now open! Join us to get an early access to new features 🎉

UPLC Optimiser Pipeline

Scalus optimizes Untyped Plutus Core (UPLC) scripts to reduce script size and execution costs. toUplcOptimized() enables optimization. Options.default retains error traces and leaves optimizeUplc disabled; set it to true for an optimized traced build. Use Options.release for an optimized build with traces removed. Trace retention can affect script size and which expressions can be folded.

Optimization Pipeline

The default V3 optimizer runs these phases:

  1. EtaReduce and Inliner run three times to simplify wrappers, bindings and constant computations.
  2. StrictIf converts eligible conditionals to strict evaluation, then ForcedBuiltinsExtractor hoists shared forced builtins.
  3. CommonSubexpressionElimination (CSE) alternates with the Inliner, twice by default.
  4. CommonContextExtraction (CCE) optionally shares one-hole application templates, followed by the Inliner and, when CSE is enabled, another CSE/Inliner cleanup.
  5. CaseConstrApply rewrites multi-argument applications as Case/Constr nodes. Optional let-chain regrouping runs immediately before this phase.

Options.cceEnabled defaults to false. Setting Options.cseIterations = 0 disables CSE, including cleanup after CCE. The V1/V2 optimizer stops after StrictIf and forced-builtin extraction.

import scalus.*, scalus.compiler.*, scalus.uplc.Term val sir = compile: (x: BigInt) => x + BigInt(1) // Option 1: use toUplcOptimized() — optimization always on val optimized: Term = sir.toUplcOptimized() // Option 2: use toUplc() with explicit control val unoptimized: Term = sir.toUplc(optimizeUplc = false) // Option 3: apply individual passes manually import scalus.uplc.transform.* val manual: Term = sir.toUplc(optimizeUplc = false) |> EtaReduce.apply |> Inliner.apply

EtaReduce

Eliminates unnecessary lambda abstractions. Transforms λx. f x into f when x appears only as a direct argument and f is pure (won’t crash if evaluated eagerly).

Inliner

The Inliner is the core optimization pass. It performs:

  • Beta-reduction(λx. body) argbody[x := arg] when safe
  • Identity elimination(λx. x) tt
  • Dead code elimination(λx. body) argbody when x is unused and arg is pure
  • Force/Delay cancellationForce(Delay(t))t
  • Constant folding — closed subexpressions are evaluated at compile time via the CEK machine

Inlining Safety

The Inliner uses occurrence analysis to decide when inlining is safe:

OccurrencesAction
Zero (unused)Eliminate the argument if it’s pure (can’t crash)
Once, directAlways inline — evaluation timing is unchanged
Once, guarded (under lambda/delay)Inline only if the argument is a value (no side effects to defer)
MultipleDuplicate only variables, constants or builtins, and only when their estimated sharing saving is zero or negative

The repeated-value decision uses the same fee estimate as CSE: saved reference-script bits are compared with the execution cost of introducing a binding. It counts all uses, including guarded uses, and excludes uses of shadowing binders. There is no fixed 64-bit constant threshold.

Compile-time Partial Evaluation

When the Inliner encounters a closed subexpression (no free variables) that contains a reducible operation (function application, Force, or Case), it runs the CEK machine to evaluate it at compile time. Successful constant results are candidates for replacing the expression. The Inliner also tracks constants in enclosing bindings, so it can evaluate a subexpression without duplicating those constants throughout the body.

This means arithmetic on constants, pattern matching on known constructors, and even multi-step computations are all folded away during compilation:

import scalus.*, scalus.compiler.* // addInteger(2, 3) is folded to 5 at compile time val sir1 = compile: BigInt(2) + BigInt(3) // Case/Constr on known tag is eliminated at compile time val sir2 = compile: val pair = (BigInt(1), BigInt(2)) pair._1 + pair._2 // folded to 3

Partial evaluation has a budget cap to prevent slow compilation. If evaluation exceeds the budget or fails, the original term is kept unchanged. Expressions containing Trace are never folded because trace has a logging side effect.

Closed Functions as Compile-time Macros

Any function that doesn’t depend on external (runtime) variables is effectively a compile-time macro. When such a function is applied to constant arguments inside compile, the entire computation — including recursion — is evaluated at compile time by the Inliner’s partial evaluator. The result is a single constant embedded in the final script.

This is powerful for precomputing values that would be expensive to calculate on-chain:

import scalus.*, scalus.compiler.* import scala.annotation.tailrec @Compile object Fibonacci { def fib(n: BigInt): BigInt = @tailrec def f(n: BigInt, x: BigInt, y: BigInt): BigInt = if n > 1 then f(n - 1, y, x + y) else y f(n, 0, 1) } // fib(100) is fully evaluated at compile time! // The recursive computation runs during compilation, // and the final script contains just the constant. val sir = compile(Fibonacci.fib(100)) val uplc = sir.toUplcOptimized() // uplc is now: (Const Integer 354224848179261915075)

Here fib is a closed function — it only uses its parameters and local bindings, with no references to runtime state. When called with 100, the Inliner:

  1. Beta-reduces the function application, substituting 100 for n
  2. Recognizes the result as a closed, reducible expression
  3. Runs the CEK machine to fully evaluate the tail-recursive loop
  4. Replaces the entire expression with the constant 354224848179261915075

Practical Uses

This pattern works for any closed computation over constants:

  • Precomputed lookup tables — encode Fibonacci numbers, CRC tables, or fee schedules as a ByteString at compile time, then slice at runtime for O(1) lookups
  • Derived configuration — compute thresholds, epoch boundaries, or protocol parameters from base constants
  • Hash preimages — precompute hashes of known values so the on-chain code only needs to compare
  • Mathematical constants — compute precision values, powers, or factorials once at compile time

Closed computations with constant inputs can therefore avoid on-chain work when they fit the partial evaluator’s budget and pass the folding checks.

What’s Next?

Last updated on