Skip to content

Commit 99d05eb

Browse files
authored
LookupCoin benchmark uses log(m) + log(k) (#7412)
* feat: implement additive log size metric for LookupCoin Replace ValueLogOuterOrMaxInner with ValueLogOuterSizeAddLogMaxInnerSize for LookupCoin costing. This change reflects experimental findings that two-level map lookup time scales linearly with log(outerSize) + log(innerSize) rather than max(log(outerSize), log(innerSize)). Implementation: - Add ValueLogOuterSizeAddLogMaxInnerSize type computing sum of log sizes - Update LookupCoin builtin signature to use new size measurement - Implement worst-case benchmark generation targeting max-size inner maps - Optimize size test combinations for BST worst-case coverage (depths 10-18) - Filter out empty values which cannot provide worst-case keys Benchmark strategy: - All lookups target the policy with maximum tokens (worst case) - Test combinations maximize nodes at each depth: (32,32), (64,128), (256,256), (512,512), (1024,128), (2048,64) - Ensures conservative costing that prevents underestimation attacks * feat: update LookupCoin cost model with remote benchmark data Updated benchmark data (206 entries) and cost model parameters for LookupCoin using the new ValueLogOuterSizeAddLogMaxInnerSize metric. Cost model changes: - CPU: linear_in_z (intercept: 204546, slope: 7423) - Memory: constant 10 (updated from 1) Benchmark data sourced from GitHub Actions run 19272430553 using worst-case targeting strategy (max-size inner map lookups). * refactor: use exhaustive power-of-2 grid for LookupCoin benchmarks Replace mixed random/specific test points with systematic exhaustive coverage of all (2^a, 2^b) combinations where a, b ∈ {1..10}. This provides: - 100 deterministic test points (10×10 grid) - Complete coverage of depths 2 to 20 - All distribution patterns (balanced, outer-heavy, inner-heavy) - Reproducible results with no randomness Removed random value generation and extractWorstCaseKeys function which are no longer needed with systematic enumeration. * feat: apply additive log metric to ValueContains and remove old wrapper Update ValueContains to use ValueLogOuterSizeAddLogMaxInnerSize for consistency with LookupCoin. The additive log metric better reflects the algorithmic complexity where each containment check performs multiple two-level lookups, each exhibiting additive depth behavior. Changes: - Update ValueContains builtin signature - Update ValueContains benchmark wrapper - Update golden signature test - Update test generators - Remove ValueLogOuterOrMaxInner (now completely unused) This provides consistent and accurate costing across all Value lookup operations based on the experimental evidence. * feat: update benchmark data for LookupCoin and ValueContains Merged new benchmark results from GitHub Actions using additive log metric: - LookupCoin: 400 entries (power-of-2 grid, run 19294057613) - ValueContains: 100 entries (run 19294814942) Fixed lookupCoin memory cost from 10 to 1 (matches result type: Integer). CPU costs remain from previous remote benchmarking. Total: 500 new benchmark entries with systematic worst-case coverage. * feat: refine LookupCoin and ValueContains cost models with remote benchmarks Updated benchmark data and cost models for LookupCoin and ValueContains based on remote GitHub Actions benchmark run (19295960854). Benchmark changes: - LookupCoin: 400 data points (3 parameters) - ValueContains: 100 data points (2 parameters) Cost model refinements: - lookupCoin: adjusted intercept (204546→252573) and slope (7423→4734) - valueContains: refined slope (96034→94269) Both functions maintain their cost model types: - lookupCoin: linear_in_z - valueContains: linear_in_y * test: update conformance budgets for refined cost models Update expected CPU budgets in conformance test cases to reflect the refined cost models for LookupCoin and ValueContains builtins. These changes align with the additive log metric improvements and remote benchmark calibration applied in recent commits. LookupCoin CPU increased from 338744 to 378875 (both scenarios). ValueContains CPU adjustments range from +108 to +216 depending on the specific test case complexity. * feat(bench): strengthen ValueContains with systematic worst-case benchmarks Replace random sampling approach with systematic power-of-2 grid targeting worst-case BST depth scenarios. Generate ~916 test cases (up from 100) using uniform container distributions and ensuring all lookups succeed by maintaining subset relationship (contained ⊆ container). Key improvements: - Use generateConstrainedValueWithMaxPolicy for worst-case BST structure - Test all combinations of 10 power-of-2 sizes for containers - Vary contained sizes to explore iteration count dimension - Include deepest BST entry in each test to force maximum traversal depth - No early exits as all entries are guaranteed to be found This aligns ValueContains benchmarking with LookupCoin's approach while accounting for its multi-lookup nature (m × log(n) complexity vs single log(n)). * feat(bench): improve ValueContains sample distribution with uniform spacing Replace power-of-2 clustering with uniform linear spacing for contained value sizes to achieve better benchmark coverage in the 0-1000 range. ## Changes - Use uniform linear spacing (10 samples) instead of power-of-2 distribution - Small containers (<10 entries) test all possible sizes - Larger containers sample uniformly from 1 to min(1000, totalEntries) - Maintains 10×10 container grid for BST depth variety - Total benchmarks: ~1023 (close to 1000 target) ## Distribution Improvements - Before: Power-of-2 clustering (2, 4, 8, 16, 32, 64, 128, 256, 512) - After: Uniform spacing appropriate to container size - Log size 10: ~25 unit spacing - Log size 20: ~100 unit spacing - Achieves smooth, uniform distribution across 0-1000 range ## Worst-Case Properties Maintained - Container generated with worst-case BST structure - Subset relationship: contained ⊆ container (all lookups succeed) - Deepest BST entry included in each test (maximum lookup depth) - No early exit conditions (comprehensive cost measurement) * feat(cost): correct ValueContains model to multiplied_sizes The previous linear_in_y model incorrectly ignored container size, only considering the contained value size. This was wrong because ValueContains iterates through every coin in the contained value and performs a lookupCoin on the container for each one, resulting in O(y * log(x)) complexity. The multiplied_sizes model properly accounts for both parameters: - x: log(outer_size) + log(max_inner_size) of container (BST depth) - y: total_size of contained value Cost formula changed from "1000 + 94269 × y" to "1000 + 6548 × (x × y)" picoseconds, reflecting the actual computational complexity. Includes 1023 fresh systematic worst-case benchmark measurements (up from 100 entries) covering full parameter space using power-of-2 grid with uniform spacing, run on GitHub Actions self-hosted runners.
1 parent bfd577b commit 99d05eb

File tree

20 files changed

+5424
-4183
lines changed

20 files changed

+5424
-4183
lines changed
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
({cpu: 338744
1+
({cpu: 378875
22
| mem: 801})
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
({cpu: 338744
1+
({cpu: 378875
22
| mem: 801})
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
({cpu: 269422
1+
({cpu: 269638
22
| mem: 601})
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
({cpu: 269422
1+
({cpu: 269638
22
| mem: 601})
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
({cpu: 269422
1+
({cpu: 269638
22
| mem: 601})
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
({cpu: 269422
1+
({cpu: 269638
22
| mem: 601})
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
({cpu: 175261
1+
({cpu: 175369
22
| mem: 601})

plutus-core/cost-model/budgeting-bench/Benchmarks/Values.hs

Lines changed: 159 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
{-# LANGUAGE LambdaCase #-}
44
{-# LANGUAGE NumericUnderscores #-}
55
{-# LANGUAGE TupleSections #-}
6+
{-# LANGUAGE TypeApplications #-}
67

78
module Benchmarks.Values (makeBenchmarks) where
89

@@ -15,11 +16,12 @@ import Data.Bits (shiftR, (.&.))
1516
import Data.ByteString (ByteString)
1617
import Data.ByteString qualified as BS
1718
import Data.Int (Int64)
19+
import Data.List (find)
1820
import Data.Word (Word8)
1921
import GHC.Stack (HasCallStack)
2022
import PlutusCore (DefaultFun (LookupCoin, UnValueData, ValueContains, ValueData))
2123
import PlutusCore.Builtin (BuiltinResult (BuiltinFailure, BuiltinSuccess, BuiltinSuccessWithLogs))
22-
import PlutusCore.Evaluation.Machine.ExMemoryUsage (ValueLogOuterOrMaxInner (..),
24+
import PlutusCore.Evaluation.Machine.ExMemoryUsage (ValueLogOuterSizeAddLogMaxInnerSize (..),
2325
ValueTotalSize (..))
2426
import PlutusCore.Value (K, Value)
2527
import PlutusCore.Value qualified as Value
@@ -42,67 +44,156 @@ makeBenchmarks gen =
4244
lookupCoinBenchmark :: StdGen -> Benchmark
4345
lookupCoinBenchmark gen =
4446
createThreeTermBuiltinBenchElementwiseWithWrappers
45-
(id, id, ValueLogOuterOrMaxInner) -- Wrap Value argument to report outer/max inner size with log
47+
(id, id, ValueLogOuterSizeAddLogMaxInnerSize) -- Wrap Value argument to report sum of log sizes
4648
LookupCoin -- the builtin fun
4749
[] -- no type arguments needed (monomorphic builtin)
4850
(lookupCoinArgs gen) -- the argument combos to generate benchmarks for
4951

5052
lookupCoinArgs :: StdGen -> [(ByteString, ByteString, Value)]
5153
lookupCoinArgs gen = runStateGen_ gen \(g :: g) -> do
52-
-- Add search keys to common test values
53-
let testValues = generateTestValues gen
54-
commonWithKeys <- mapM (withSearchKeys g . pure) testValues
55-
56-
-- Additional tests specific to lookupCoin
57-
let valueSizes = [(100, 10), (500, 20), (1_000, 50), (2_000, 100)]
58-
additionalTests <-
59-
sequence $
60-
-- Value size tests (number of policies × tokens per policy)
61-
[ withSearchKeys g (generateConstrainedValue numPolicies tokensPerPolicy g)
62-
| (numPolicies, tokensPerPolicy) <- valueSizes
54+
{- Exhaustive power-of-2 combinations for BST worst-case benchmarking.
55+
56+
Tests all combinations of sizes from powers and half-powers of 2.
57+
For each integer n ∈ {1..10}, includes both 2^n and 2^(n+0.5) ≈ 2^n * √2.
58+
59+
This provides:
60+
- 400 total test points (20 × 20 grid)
61+
- Complete systematic coverage of depth range 2 to 21
62+
- Finer granularity between powers of 2
63+
- All distribution patterns (balanced, outer-heavy, inner-heavy)
64+
65+
Size values: 2, 3, 4, 6, 8, 11, 16, 23, 32, 45, 64, 91, 128, 181,
66+
256, 362, 512, 724, 1024, 1448
67+
68+
Depth coverage:
69+
- Minimum depth: log₂(2) + log₂(2) ≈ 2
70+
- Maximum depth: log₂(1448) + log₂(1448) ≈ 21
71+
-}
72+
let
73+
-- Generate powers of 2 and their geometric means (half-powers)
74+
sizes =
75+
[ 2 ^ n -- 2^n
76+
| n <- [1 .. 10 :: Int]
6377
]
64-
-- Additional random tests for parameter spread
65-
<> replicate 100 (withSearchKeys g (generateValue g))
66-
pure $ commonWithKeys ++ additionalTests
67-
68-
-- | Add random search keys to a Value (keys may or may not exist in the Value)
69-
withSearchKeys :: (StatefulGen g m) => g -> m Value -> m (ByteString, ByteString, Value)
70-
withSearchKeys g genValue = do
71-
value <- genValue
72-
key1 <- generateKeyBS g
73-
key2 <- generateKeyBS g
74-
pure (key1, key2, value)
78+
++ [ round @Double (2 ^ n * sqrt 2) -- 2^(n+0.5)
79+
| n <- [1 .. 10 :: Int]
80+
]
81+
82+
sequence
83+
-- Generate worst-case lookups for each size combination
84+
[ withWorstCaseSearchKeys (generateConstrainedValueWithMaxPolicy numPolicies tokensPerPolicy g)
85+
| numPolicies <- sizes
86+
, tokensPerPolicy <- sizes
87+
]
88+
89+
-- | Add worst-case search keys targeting the max-size inner map
90+
withWorstCaseSearchKeys :: (Monad m) => m (Value, K, K) -> m (ByteString, ByteString, Value)
91+
withWorstCaseSearchKeys genValueWithKeys = do
92+
(value, maxPolicyId, deepestToken) <- genValueWithKeys
93+
pure (Value.unK maxPolicyId, Value.unK deepestToken, value)
7594

7695
----------------------------------------------------------------------------------------------------
7796
-- ValueContains -----------------------------------------------------------------------------------
7897

7998
valueContainsBenchmark :: StdGen -> Benchmark
8099
valueContainsBenchmark gen =
81100
createTwoTermBuiltinBenchElementwiseWithWrappers
82-
(ValueLogOuterOrMaxInner, ValueTotalSize)
83-
-- Container: outer/maxInner with log, Contained: totalSize
101+
(ValueLogOuterSizeAddLogMaxInnerSize, ValueTotalSize)
102+
-- Container: sum of log sizes, Contained: totalSize
84103
ValueContains -- the builtin fun
85104
[] -- no type arguments needed (monomorphic builtin)
86105
(valueContainsArgs gen) -- the argument combos to generate benchmarks for
87106

88107
valueContainsArgs :: StdGen -> [(Value, Value)]
89-
valueContainsArgs gen = runStateGen_ gen \g -> replicateM 100 do
90-
-- Generate a random container value
91-
container <- generateValue g
92-
-- Select a random subset of entries from the container to ensure contained ⊆ container
93-
containedSize <- uniformRM (0, Value.totalSize container) g
94-
-- Take the first containedSize entries to ensure contained ⊆ container
95-
let selectedEntries = take containedSize (Value.toFlatList container)
96-
97-
-- Group selected entries back by policy
98-
let contained =
99-
unsafeFromBuiltinResult $
100-
Value.fromList
101-
[ (policyId, [(tokenName, quantity)])
102-
| (policyId, tokenName, quantity) <- selectedEntries
108+
valueContainsArgs gen = runStateGen_ gen \g -> do
109+
{- ValueContains performs multiple LookupCoin operations (one per entry in contained).
110+
Worst case: All lookups succeed at maximum depth with many entries to check.
111+
112+
Strategy:
113+
1. Generate container with worst-case BST structure (uniform, power-of-2 sizes)
114+
2. Select entries FROM container (maintain subset relationship for no early exit)
115+
3. Include deepest entry to force maximum BST traversal
116+
4. Test multiple contained sizes to explore iteration count dimension
117+
118+
Result: ~1000 systematic worst-case benchmarks vs 100 random cases previously
119+
-}
120+
121+
-- Use power-of-2 grid (without half-powers) for systematic coverage
122+
-- ValueContains does multiple lookups, so we don't need as fine-grained
123+
-- size variation as LookupCoin
124+
let containerSizes = [2 ^ n | n <- [1 .. 10 :: Int]] -- [2, 4, 8, 16, 32, 64, 128, 256, 512, 1024]
125+
126+
-- Generate test cases for all container size combinations
127+
concat
128+
<$> sequence
129+
[ do
130+
-- Generate container with worst-case BST structure:
131+
-- - Uniform distribution (all policies have same token count)
132+
-- - Worst-case keys (long common prefix, differ in last 4 bytes)
133+
-- - Returns metadata about the deepest entry
134+
(container, maxPolicyId, deepestToken) <-
135+
generateConstrainedValueWithMaxPolicy numPolicies tokensPerPolicy g
136+
137+
-- Extract all entries from container as a flat list
138+
-- This maintains the subset relationship: contained ⊆ container
139+
let allEntries = Value.toFlatList container
140+
totalEntries = length allEntries
141+
142+
-- Find the worst-case entry (deepest in both BSTs)
143+
-- This entry forces maximum depth lookup:
144+
-- - maxPolicyId: first in outer BST (but all equal size, so any works)
145+
-- - deepestToken: last token in sorted order (maximum inner BST depth)
146+
let worstCaseEntry =
147+
find (\(p, t, _) -> p == maxPolicyId && t == deepestToken) allEntries
148+
149+
-- Generate test cases for different contained sizes (uniform linear distribution)
150+
-- Each size tests the same container with different iteration counts
151+
-- Use uniform spacing from 1 to min(1000, totalEntries) for better distribution
152+
let maxContainedSize = min 1000 totalEntries
153+
numSamples = 10
154+
containedSizes =
155+
if totalEntries < numSamples
156+
then [1 .. totalEntries] -- Test all sizes for small containers
157+
else
158+
let step = maxContainedSize `div` numSamples
159+
in [i * step | i <- [1 .. numSamples], i * step > 0]
160+
++ [maxContainedSize | maxContainedSize `notElem` [i * step | i <- [1 .. numSamples]]]
161+
162+
-- Create one test case per contained size
163+
pure
164+
[ let
165+
-- Select entries ensuring worst-case is included
166+
-- Place worst-case entry at END so it's checked (not early-exit)
167+
selectedEntries =
168+
case worstCaseEntry of
169+
Just worst ->
170+
-- Take (containedSize - 1) entries, then add worst-case
171+
-- This ensures: 1) subset relationship maintained
172+
-- 2) worst-case depth is hit
173+
-- 3) no early exit (all lookups succeed)
174+
let numOthers = min (containedSize - 1) (totalEntries - 1)
175+
others = take numOthers allEntries
176+
in others ++ [worst]
177+
Nothing ->
178+
-- Fallback if worst-case entry somehow not found
179+
-- (shouldn't happen, but defensive programming)
180+
take containedSize allEntries
181+
182+
-- Build contained Value from selected entries
183+
-- This maintains the Value structure while ensuring subset
184+
contained =
185+
unsafeFromBuiltinResult $
186+
Value.fromList
187+
[ (policyId, [(tokenName, quantity)])
188+
| (policyId, tokenName, quantity) <- selectedEntries
189+
]
190+
in
191+
(container, contained)
192+
| containedSize <- containedSizes
103193
]
104-
105-
pure (container, contained)
194+
| numPolicies <- containerSizes
195+
, tokensPerPolicy <- containerSizes
196+
]
106197

107198
----------------------------------------------------------------------------------------------------
108199
-- ValueData ---------------------------------------------------------------------------------------
@@ -158,27 +249,46 @@ generateValueMaxEntries maxEntries g = do
158249

159250
generateConstrainedValue numPolicies tokensPerPolicy g
160251

161-
-- | Generate constrained Value
162-
generateConstrainedValue
252+
-- | Generate constrained Value with information about max-size policy
253+
generateConstrainedValueWithMaxPolicy
163254
:: (StatefulGen g m)
164255
=> Int -- Number of policies
165256
-> Int -- Number of tokens per policy
166257
-> g
167-
-> m Value
168-
generateConstrainedValue numPolicies tokensPerPolicy g = do
258+
-> m (Value, K, K) -- Returns (value, maxPolicyId, deepestTokenInMaxPolicy)
259+
generateConstrainedValueWithMaxPolicy numPolicies tokensPerPolicy g = do
169260
policyIds <- replicateM numPolicies (generateKey g)
170261
tokenNames <- replicateM tokensPerPolicy (generateKey g)
171262

172263
let
173264
qty :: Value.Quantity
174265
qty = case Value.quantity (fromIntegral (maxBound :: Int64)) of
175-
Just q -> q
176-
Nothing -> error "generateConstrainedValue: Int64 maxBound should be valid Quantity"
266+
Just q -> q
267+
Nothing -> error "generateConstrainedValueWithMaxPolicy: Int64 maxBound should be valid Quantity"
177268

178269
nestedMap :: [(K, [(K, Value.Quantity)])]
179270
nestedMap = (,(,qty) <$> tokenNames) <$> policyIds
180271

181-
pure $ unsafeFromBuiltinResult $ Value.fromList nestedMap
272+
value = unsafeFromBuiltinResult $ Value.fromList nestedMap
273+
274+
-- All policies have the same number of tokens in this uniform distribution,
275+
-- so we pick the first policy as the max-size policy for worst-case targeting
276+
maxPolicyId = head policyIds
277+
-- Pick the last token (deepest in binary search tree) for worst-case inner lookup
278+
deepestToken = last tokenNames
279+
280+
pure (value, maxPolicyId, deepestToken)
281+
282+
-- | Generate constrained Value (legacy interface for other builtins)
283+
generateConstrainedValue
284+
:: (StatefulGen g m)
285+
=> Int -- Number of policies
286+
-> Int -- Number of tokens per policy
287+
-> g
288+
-> m Value
289+
generateConstrainedValue numPolicies tokensPerPolicy g = do
290+
(value, _, _) <- generateConstrainedValueWithMaxPolicy numPolicies tokensPerPolicy g
291+
pure value
182292

183293
----------------------------------------------------------------------------------------------------
184294
-- Other Generators --------------------------------------------------------------------------------
@@ -202,14 +312,6 @@ generateKey g = do
202312
Just key -> pure key
203313
Nothing -> error "Internal error: maxKeyLen key should always be valid"
204314

205-
{-| Generate worst-case key as ByteString (for lookup arguments).
206-
207-
Like generateKey, but returns ByteString directly for use in lookup operations.
208-
Uses BS.copy to ensure physical distinctness for worst-case equality testing.
209-
-}
210-
generateKeyBS :: (StatefulGen g m) => g -> m ByteString
211-
generateKeyBS g = BS.copy . mkWorstCaseKey <$> uniformRM (0, maxBound :: Int) g
212-
213315
{-| Helper: Create a worst-case ByteString key from an integer
214316
The key has maxKeyLen-4 bytes of 0xFF prefix, followed by 4 bytes encoding the integer
215317
-}

0 commit comments

Comments
 (0)