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:
- EtaReduce and Inliner run three times to simplify wrappers, bindings and constant computations.
- StrictIf converts eligible conditionals to strict evaluation, then ForcedBuiltinsExtractor hoists shared forced builtins.
- CommonSubexpressionElimination (CSE) alternates with the Inliner, twice by default.
- CommonContextExtraction (CCE) optionally shares one-hole application templates, followed by the Inliner and, when CSE is enabled, another CSE/Inliner cleanup.
- CaseConstrApply rewrites multi-argument applications as
Case/Constrnodes. 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.applyEtaReduce
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) arg→body[x := arg]when safe - Identity elimination —
(λx. x) t→t - Dead code elimination —
(λx. body) arg→bodywhenxis unused andargis pure - Force/Delay cancellation —
Force(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:
| Occurrences | Action |
|---|---|
| Zero (unused) | Eliminate the argument if it’s pure (can’t crash) |
| Once, direct | Always inline — evaluation timing is unchanged |
| Once, guarded (under lambda/delay) | Inline only if the argument is a value (no side effects to defer) |
| Multiple | Duplicate 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 3Partial 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:
- Beta-reduces the function application, substituting
100forn - Recognizes the result as a closed, reducible expression
- Runs the CEK machine to fully evaluate the tail-recursive loop
- 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?
- Measuring Performance — quantify the impact of each optimiser pass on real transaction fees.
- Deploying Contracts — once the optimised script is final, publish it as a reference script UTxO.
- Smart Contract Optimisations — back to the index for the full set of techniques.