Conversation
Compares the Value builtins (unsafeDataAsValue, lookupCoin, unionValue) against PlutusLedgerApi.V1.Data.Value's valueOf/unionWith across four shapes (S1, S3, S8, S100) at four hit positions for lookup, plus union-then-lookup. 108 goldens under test-ledger-api/. For IntersectMBO/plutus-private#2177.
Value builtins vs. pure Plutus Tx — findings (issue #2177)TL;DRThe lookup path has a crossover around N = 8 total tokens when the lookup key sits at position 0 of the underlying value list. Below that the builtin wins by 2× to 4×. Above it the non-builtin Union has no crossover in this range. The small-value regression Philip reported does not reproduce. Under the Plutus Tx plugin the builtin path compiles to roughly one Important framing:
|
| Shape | Contents | Total tokens |
|---|---|---|
| S1 | 1 policy with 1 token (position 0 = ada in testlib convention) | 1 |
| S3 | 3 policies, each with 1 token | 3 |
| S8 | 8 policies, each with 1 token (crossover) | 8 |
| S100 | 11 policies: one with 1 token, ten with 10 tokens each | 101 |
Lookup keys tested:
first— head of the outer list (position 0).middle— roughly halfway into the outer list (position N/2).last— the final entry of the outer list.miss— a currency symbol that isn't present at all.
Results: lookup CPU
Ratio is non-builtin / builtin. Ratio > 1 means the builtin is cheaper; ratio < 1 means the non-builtin is cheaper.
| Shape | Position | Builtin CPU | Non-builtin CPU | Ratio | Winner |
|---|---|---|---|---|---|
| S1 | first | 895 629 | 3 387 176 | 3.78× | builtin |
| S1 | miss | 895 629 | 1 875 266 | 2.09× | builtin |
| S3 | first | 1 672 681 | 3 387 176 | 2.03× | builtin |
| S3 | middle | 1 672 681 | 5 145 393 | 3.08× | builtin |
| S3 | last | 1 672 681 | 6 581 773 | 3.93× | builtin |
| S3 | miss | 1 672 681 | 4 748 026 | 2.84× | builtin |
| S8 | first | 3 611 149 | 3 387 176 | 0.94× | parity |
| S8 | middle | 3 611 149 | 9 454 533 | 2.62× | builtin |
| S8 | last | 3 611 149 | 13 763 673 | 3.81× | builtin |
| S8 | miss | 3 611 149 | 11 929 926 | 3.30× | builtin |
| S100 | first | 22 108 153 | 3 387 176 | 0.15× | non-builtin by 6.53× |
| S100 | middle | 22 108 153 | 16 636 433 | 0.75× | non-builtin by 1.33× |
| S100 | last | 22 108 153 | 31 000 233 | 1.40× | builtin |
| S100 | miss | 22 108 153 | 16 239 066 | 0.73× | non-builtin by 1.36× |
Mechanical reading
Two observations fall straight out of the numbers.
First, the builtin CPU within a shape does not depend on hit position. The whole cost sits in unsafeDataAsValue, which has to walk the entire Data to validate its shape and reconstruct a BuiltinValue. lookupCoin afterwards is a single CEK step. Builtin cost is therefore O(total value size), and identical for every position within a given shape.
Second, the non-builtin CPU depends on hit position, not on total size. A first-position hit costs 3 387 176 across every shape, because the outer AssocMap.lookup' exits after one cons. A last-position hit scales with outer-list length plus inner-list length. A miss costs a full outer walk.
The builtin CPU scales roughly linearly in the number of data nodes (policies + tokens): around 200K CPU per node at scale, with a ~500K fixed overhead. The non-builtin CPU tracks hit distance, so lookup_Sn_first is flat at 3 387 176 for every n while lookup_Sn_last grows.
The S8 crossover scenario
S8 (8 single-token policies, 8 tokens total) is the shape where the two paths come within 7% of each other at the first-position lookup: builtin 3 611 149 vs non-builtin 3 387 176. At every other position in S8 the builtin still wins by 2.6× to 3.8×, because those positions force the non-builtin to walk further into the outer list.
The crossover is position-specific. For a first-position hit it's near N=8. For a last-position hit it hasn't been reached at N=100. For a miss it sits somewhere between N=8 and N=100. For union it doesn't happen within any size I looked at.
Results: union-then-lookup
The conservation-of-value pattern: union two BuiltinData-encoded values and read the value of some key in the result. The non-builtin path allocates a fresh nested AssocMap; the builtin path calls unionValue and stops.
| Shape | Builtin CPU | Non-builtin CPU | CPU ratio | Builtin Mem | Non-builtin Mem | Mem ratio |
|---|---|---|---|---|---|---|
| S1 | 1 876 591 | 28 679 559 | 15.3× | 2 279 | 110 486 | 48× |
| S3 | 4 131 831 | 87 810 607 | 21.3× | 2 539 | 302 003 | 119× |
| S8 | 9 766 539 | 313 714 512 | 32.1× | 3 189 | 895 158 | 281× |
| S100 | 79 832 775 | 3 460 294 959 | 43.3× | 11 319 | 7 669 071 | 677× |
The S100 non-builtin union costs 3.46 billion CPU units. That's most of a V3 max-budget block spent on a single conservation check. The builtin path stays at 80 M. Memory is 7.67 M vs 11 K.
unionValue only does the work needed to produce the result BuiltinValue. unionWith (+) walks both outer lists, unions the matching inner lists pointwise through the These algebra, and rebuilds the entire nested structure. Nothing about the size regime makes the non-builtin path cheaper, and the gap grows roughly linearly in N.
Interpretation
Ziyang's hypothesis
From the Slack thread:
[The regression] is the conversion cost of
unValueData. For small values, the non-builtin path wins becausevalueOfpattern-matches a few levels into theDataand stops.
The small-value half of this does not reproduce. At S1 the builtin is 3.78× faster, not slower. The plugin emits essentially a single-step builtin invocation.
The large-value half is real, though the mechanism is the opposite of what "the conversion dominates at small sizes" would suggest. unsafeDataAsValue's cost grows with total data size, while valueOf's cost is bounded by hit distance. So the crossover favours the non-builtin only once the full-data traversal has grown to match the short-circuit cost at a given position. For a first-position hit, that's around N=8.
Why Aiken users might still be right
If Aiken's compiler emits UPLC where the builtin path carries extra overhead (thunks, wrappers, non-inlined intermediates), the crossover shifts left and the builtins can look worse at small N. That's the likeliest explanation for Philip's reports. The Plutus Tx plugin doesn't produce that shape. A minimal Aiken reproducer we can compare UPLC-to-UPLC against Plinth would close this out.
Suggested guidance for V3 users (Plutus Tx plugin)
- Lookup on small values, up to about 8 total tokens: builtin wins at every position.
- Lookup on medium values, 8 < N < 100: builtin wins except for the first-position case, where non-builtin edges out past N ≈ 8. The gap is small; prefer builtin unless you've measured your specific shape.
- Lookup on large values, N ≥ 100: non-builtin
valueOfwins for first-position hits, middle-position hits on realistic shapes, and misses. Builtin still wins for last-position hits. If the lookup key is statically known and expected to be near the front (e.g. ada in a sorted Value), prefer non-builtin. - Union or any composition that produces a new value: builtin always. The gap widens with size.
V4 impact
Plutus V4 plans to add a Value constructor to Data, which would make unsafeDataAsValue a no-op. The builtin's per-lookup cost would drop back to a single lookupCoin call. The crossover would disappear entirely and the builtin would win at every size, every position. These goldens become the before-picture for that change.
Adds hand-rolled counterparts that operate directly on raw BuiltinData, bypassing valueOf's newtype/Maybe wrappers and unionWith's These algebra. Hand-rolled union additionally skips the zero-filter, exploiting the ledger invariant that tx-output Values have strictly positive quantities. 18 new bundles across the existing shape matrix (S1, S3, S8, S100): 14 lookup + 4 union-then-lookup, paired with the existing builtin and non-builtin bundles. For IntersectMBO/plutus-private#2177.
Follow-up: hand-rolled variants addedPer the Slack discussion, I added two more paths to the comparison matrix:
18 new bundles on the existing (S1, S3, S8, S100) × (first, middle, last, miss) matrix. Commit Lookup CPU"Handrolled" column added. All numbers in 1 000 CPU units.
Bold marks the winner for that row. A few things to call out:
Union CPU (and memory)
Two observations:
Two caveats on the hand-rolled union:
If Philip's djed library implements a smarter union with invariant tracking, I'll measure that too and update these numbers. Until then, the story for union is clean: builtin wins at every size, full stop. Still open from the thread
|
Isolates the conversion tax from any downstream operation across the four shapes (S1, S3, S8, S100). Enables decomposing the builtin-path cost into `unsafeDataAsValue` + `lookupCoin`. 4 new bundles. Responds to Ziyang's request in the thread. For IntersectMBO/plutus-private#2177.
Follow-up: standalone
|
| Shape | unsafeDataAsValue alone |
lookup_*_ada_builtin |
Delta (lookupCoin alone) |
lookupCoin share |
|---|---|---|---|---|
| S1 | 576 790 | 895 629 | 318 839 | 35.6% |
| S3 | 1 344 398 | 1 672 681 | 328 283 | 19.6% |
| S8 | 3 263 978 | 3 611 149 | 347 171 | 9.6% |
| S100 | 21 732 650 | 22 108 153 | 375 503 | 1.7% |
A few things worth noting:
-
unsafeDataAsValuescales linearly with value size. From S1 to S100 the value contains ~100× more tokens; the cost grows ~38×. The slope is roughly 200 K CPU per additional policy or token entry in the data structure, which matches what the lookup-path numbers already implied. -
lookupCoinon the resultingBuiltinValueis essentially constant at 320–375 K CPU per call, with a tiny upward drift as the value grows (probably a field-access or list-head overhead on the materialisedBuiltinValue). It's noise relative to the conversion cost. -
At S1 the conversion tax is already 64% of the
lookup_adacost. At S100 it's 98%. So the builtin path is essentiallyunsafeDataAsValue+ a constant. -
V4's plan to make
unsafeDataAsValuea no-op would, on these numbers, reducelookup_S100_ada_builtinfrom 22.1 M to ~0.4 M — a 55× speedup on that particular shape. Broadly: every builtin-path CPU number in the matrix becomeslookupCoinorunionValuealone onceunsafeDataAsValueis free.
Memory
Memory for standalone unsafeDataAsValue stays very small (756 at S1, 3 176 at S100). Almost all of the memory in lookup_*_ada_builtin was also from unsafeDataAsValue (1 257 and 3 677 respectively), so lookupCoin contributes ~500 memory units on top. Again: noise relative to unsafeDataAsValue.
Open from the thread
Waiting on Philip's djed library share for a smarter hand-rolled union, and on his chain-stats for typical Value sizes.
What
Adds
Spec.Data.Value.Budgetunderplutus-tx-plugin/test-ledger-api/. The module measures CPU, memory, AST size and flat size for the Value builtins (unsafeDataAsValue,lookupCoin,unionValue) against the pure Plutus Tx Data-backed Value API (valueOf,unionWith). Four shapes, four hit positions each for lookup, plus union-then-lookup:Each
goldenBundleproduces.pir,.uplc, and.eval. 108 goldens total under9.6/. Currency symbols are 28 bytes, token names are 32 bytes.Why
Picks up
IntersectMBO/plutus-private#2177. Aiken community members reported to Philip that switching from pure-Tx Value ops to the new builtins caused regressions on small values. There were no systematic numbers to argue about, so the community had nothing to reproduce.Findings
Full write-up posted as a comment on this PR. Two-line version:
valueOfwins whenever the key is near the front of the list or not present. For last-position hits the builtin keeps winning past N=100.unionValuebeatsunionWithby 15× CPU at S1 and 43× CPU / 677× memory at S100, and the gap grows with N.The specific small-value regression from the reports does not reproduce under the Plutus Tx plugin. The plugin emits essentially a single-step builtin invocation. Most likely the Aiken reports reflect different compiler output; an Aiken vs Plinth UPLC diff would close that out.
Closes plutus-private/#2177