Profiling Cardano Smart Contracts
Scalus includes a built-in CEK machine profiler that shows where execution budget is spent — by source location and by builtin function.
Quick Start
Replace evaluateDebug with evaluateProfile to collect profiling data:
import scalus.uplc.eval.{ProfileFormatter, PlutusVM}
given PlutusVM = PlutusVM.makePlutusV3VM()
val result = compiled.toUplc().evaluateProfile
// result.isSuccess, result.budget, etc. work as usual
assert(result.isSuccess)
// Access profiling data
result.profile.foreach { profile =>
println(ProfileFormatter.toText(profile))
}Output
ProfileFormatter.toText produces two tables:
=== Profile by Source Location ===
location count mem cpu
MyValidator.scala:42 153 153000 24480000
Prelude/List.scala:903 87 87000 13920000
=== Profile by Function ===
function count mem cpu
HeadList 74755 2392160 6215878250
UnIData 45025 1440800 933998600
MkCons 42126 1348032 3048321612
Total: mem=185927967 cpu=49131853260By Source Location shows which lines of your Scala source code are most expensive. By Function shows which UPLC builtin functions consume the most budget.
Both tables are sorted by memory descending.
HTML Output
For a richer view with heat-bar visualization:
import java.nio.file.{Files, Paths}
result.profile.foreach { profile =>
Files.writeString(Paths.get("profile.html"), ProfileFormatter.toHtml(profile))
}Machine-Readable Output (profile.json + manifest)
PlutusScriptEvaluator can render profiles to files on every evaluation — that covers everything
that runs through the ledger: Emulator, TxBuilder, transaction validation. (For tests that
evaluate a compiled program directly, see Profiling Direct UPLC Runs
below.) The easiest switch is the environment variable (see
Project Commands):
SCALUS_PROFILE=full sbt testFor each profiled script run this writes, into the report output directory (SCALUS_DUMP_DIR,
default: target/scalus):
<scriptHash>-<tag>-<index>.profile.html– the interactive report<scriptHash>-<tag>-<index>.profile.json– machine-readable data ("schemaVersion": 1)<scriptHash>-<tag>-<index>.profile.csv– flat table for spreadsheetsprofile-manifest.json– the discovery entry point listing every run
File names are stable per (scriptHash, redeemer tag, index), so repeated evaluations (e.g.
during fee balancing) overwrite rather than accumulate. Manifest entries are merged on that same
key: a re-evaluated script updates its entry in place, while every other run already in the
directory is preserved. The manifest maps each run to its files:
{
"schemaVersion": 1,
"runs": [
{ "scriptHash": "ab12…", "language": "PlutusV3",
"redeemer": { "tag": "Spend", "index": 0 },
"budget": { "mem": 185927967, "cpu": 49131853260 },
"files": [ { "format": "html", "file": "ab12…-Spend-0.profile.html" },
{ "format": "json", "file": "ab12…-Spend-0.profile.json" },
{ "format": "csv", "file": "ab12…-Spend-0.profile.csv" } ] }
]
}Tools should start from profile-manifest.json and check schemaVersion (currently 1 for
both the manifest and profile.json). The Scalus VS Code extension consumes profile.json to
annotate source lines with per-line cost.
The report directory lives under target/, which is git-ignored. Editor tooling that discovers
files through the workspace search index will not see it — VS Code’s findFiles honours ignore
files. The Scalus Profiler extension handles this from 0.2.2 onwards; older versions find
nothing unless you point SCALUS_DUMP_DIR at a directory that is not ignored.
Profiling Direct UPLC Runs
Suites that evaluate a compiled program themselves — runScript, evaluateDebug,
runWithDebug — never reach PlutusScriptEvaluator, so SCALUS_PROFILE=full writes no files for
them. ScalusTest provides runWithProfileReport for that case: it evaluates with profiling on
and emits the same renderings, into the same directory, indexed in the same manifest.
class VestingValidatorTest extends AnyFunSuite, ScalusTest {
test("Successful multiple partial withdrawals") {
val result = compiled.scriptV3().runWithProfileReport(scriptContext)
assert(result.isSuccess)
}
}The redeemer tag in the file name follows the script purpose in the ScriptContext, so a spending
validator lands in <scriptHash>-Spend-0.profile.* just as it would from the ledger.
Calling the method is itself the request for a report, so it writes the full set by default.
SCALUS_PROFILE, SCALUS_PROFILE_OUT and SCALUS_DUMP_DIR override that exactly as they do for
the ledger — SCALUS_PROFILE=off silences the output without touching the test. Because manifest
entries merge, ledger- and test-produced runs accumulate side by side and the extension lists them
together.
Toggle Profiling in Tests
Add a flag to switch between evaluateDebug and evaluateProfile without changing test assertions:
class MyValidatorTest extends AnyFunSuite, ScalusTest {
val profilingEnabled = false // flip to true when needed
extension (term: scalus.uplc.Term)
private def eval(using PlutusVM): Result =
if profilingEnabled then
val result = term.evaluateProfile
result.profile.foreach(p => info(ProfileFormatter.toText(p)))
result
else term.evaluateDebug
test("validator budget") {
val result = compiled.toUplc().eval // uses evaluateDebug or evaluateProfile
assert(result.isSuccess)
}
}The result.profile.foreach line is safe to leave permanently — when profiling is off, profile is None.
This prints to the test log. When you want files an editor can read instead, use
runWithProfileReport.
PlutusVM Methods
For script-level profiling (with CIP-117 validation):
val result = vm.evaluateScriptProfile(program.deBruijnedProgram)
// result.profile contains profiling dataPerformance
Profiling adds ~40% overhead to CEK evaluation (measured on the Knights benchmark: 6s vs 8.4s). When profiling = false (the default), there is zero overhead.
See Also
- JS/TS Profiling — Collect profiling data (JSON) from JavaScript/TypeScript with
Scalus.evaluateScriptProfile - Debugging — IDE debugging, logging, error traces
- Unit Testing — Test validators with budget assertions