-
Notifications
You must be signed in to change notification settings - Fork 482
[Builtin] Define 'caseList' in terms of 'chooseList' #4119
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[Builtin] Define 'caseList' in terms of 'chooseList' #4119
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! This all looks good. I plugged this into my benchmarking code, and using the new folds is about 20% faster for summing up lists than the versions using ifThenElse. That's better than I was expecting, and it'll give us a fairer comparison against the compiled-from-Haskell versions.
|
/benchmark plutus-benchmark:validation |
The main change is replacing
```haskell
data BuiltinRuntime val
= BuiltinCostedResult ExBudgetStream ~(BuiltinResult val)
| <...>
```
with
```haskell
data BuiltinRuntime val
= BuiltinCostedResult ExBudgetStream ~(BuiltinResult (HeadSpine val))
| <...>
```
where `HeadSpine` is a fancy way of saying `NonEmpty`:
```haskell
-- | A non-empty spine. Isomorphic to 'NonEmpty', except is strict and is defined as a single
-- recursive data type.
data Spine a
= SpineLast a
| SpineCons a (Spine a)
-- | The head-spine form of an iterated application. Provides O(1) access to the head of the
-- application. Isomorphic to @nonempty@, except is strict and the no-spine case is made a separate
-- constructor for performance reasons (it only takes a single pattern match to access the head when
-- there's no spine this way, while otherwise we'd also need to match on the spine to ensure that
-- it's empty -- and the no-spine case is by far the most common one, hence we want to optimize it).
data HeadSpine a
= HeadOnly a
| HeadSpine a (Spine a)
```
(we define a separate type, because we want strictness, and you don't see any bangs, because it's in a module with `StrictData` enabled).
The idea is that a builtin application can return a function applied to a bunch of arguments, which is exactly what we need to be able to express `caseList`
```haskell
caseList xs0 f z = case xs0 of
[] -> z
x:xs -> f x xs
```
as a builtin:
```haskell
-- | Take a function and a list of arguments and apply the former to the latter.
headSpine :: Opaque val asToB -> [val] -> Opaque (HeadSpine val) b
headSpine (Opaque f) = Opaque . \case
[] -> HeadOnly f
x0 : xs ->
-- It's critical to use 'foldr' here, so that deforestation kicks in.
-- See Note [Definition of foldl'] in "GHC.List" and related Notes around for an explanation
-- of the trick.
HeadSpine f $ foldr (\x2 r x1 -> SpineCons x1 $ r x2) SpineLast xs x0
instance uni ~ DefaultUni => ToBuiltinMeaning uni DefaultFun where
<...>
toBuiltinMeaning _ver CaseList =
let caseListDenotation
:: Opaque val (LastArg a b)
-> Opaque val (a -> [a] -> b)
-> SomeConstant uni [a]
-> BuiltinResult (Opaque (HeadSpine val) b)
caseListDenotation z f (SomeConstant (Some (ValueOf uniListA xs0))) = do
case uniListA of
DefaultUniList uniA -> pure $ case xs0 of
[] -> headSpine z [] -- [1]
x : xs -> headSpine f [fromValueOf uniA x, fromValueOf uniListA xs] -- [2]
_ ->
-- See Note [Structural vs operational errors within builtins].
throwing _StructuralUnliftingError "Expected a list but got something else"
{-# INLINE caseListDenotation #-}
in makeBuiltinMeaning
caseListDenotation
(runCostingFunThreeArguments . unimplementedCostingFun)
```
Being able to express [1] (representing `z`) and [2] (representing `f x xs`) is precisely what this PR enables.
Adding support for the new functionality to the CEK machine is trivial. All we need is a way to push a `Spine` of arguments onto the context:
```haskell
-- | Push arguments onto the stack. The first argument will be the most recent entry.
pushArgs
:: Spine (CekValue uni fun ann)
-> Context uni fun ann
-> Context uni fun ann
pushArgs args ctx = foldr FrameAwaitFunValue ctx args
```
and a `HeadSpine` version of `returnCek`:
```haskell
-- | Evaluate a 'HeadSpine' by pushing the arguments (if any) onto the stack and proceeding with
-- the returning phase of the CEK machine.
returnCekHeadSpine
:: Context uni fun ann
-> HeadSpine (CekValue uni fun ann)
-> CekM uni fun s (Term NamedDeBruijn uni fun ())
returnCekHeadSpine ctx (HeadOnly x) = returnCek ctx x
returnCekHeadSpine ctx (HeadSpine f xs) = returnCek (pushArgs xs ctx) f
```
Then replacing
```haskell
BuiltinSuccess x ->
returnCek ctx x
```
with
```haskell
BuiltinSuccess fXs ->
returnCekHeadSpine ctx fXs
```
(and similarly for `BuiltinSuccessWithLogs`) will do the trick.
We used to define `caseList` in terms of `IfThenElse`, `NullList` and either `HeadList` or `TailList` depending on the result of `NullList`, i.e. three builtin calls in the worst and in the best case. Then we introduced `ChooseList`, which replaced both `IfThenElse` and `NullList` in `caseList` thus bringing total amount of builtin calls down to 2 in all cases. This turned out to have a [substantial](#4119 (review)) impact on performance. This PR allows us to bring total number of builtin calls per `caseList` invokation down to 1 -- the `CaseList` builtin itself.
The main change is replacing
```haskell
data BuiltinRuntime val
= BuiltinCostedResult ExBudgetStream ~(BuiltinResult val)
| <...>
```
with
```haskell
data BuiltinRuntime val
= BuiltinCostedResult ExBudgetStream ~(BuiltinResult (HeadSpine val))
| <...>
```
where `HeadSpine` is a fancy way of saying `NonEmpty`:
```haskell
-- | A non-empty spine. Isomorphic to 'NonEmpty', except is strict and is defined as a single
-- recursive data type.
data Spine a
= SpineLast a
| SpineCons a (Spine a)
-- | The head-spine form of an iterated application. Provides O(1) access to the head of the
-- application. Isomorphic to @nonempty@, except is strict and the no-spine case is made a separate
-- constructor for performance reasons (it only takes a single pattern match to access the head when
-- there's no spine this way, while otherwise we'd also need to match on the spine to ensure that
-- it's empty -- and the no-spine case is by far the most common one, hence we want to optimize it).
data HeadSpine a
= HeadOnly a
| HeadSpine a (Spine a)
```
(we define a separate type, because we want strictness, and you don't see any bangs, because it's in a module with `StrictData` enabled).
The idea is that a builtin application can return a function applied to a bunch of arguments, which is exactly what we need to be able to express `caseList`
```haskell
caseList xs0 f z = case xs0 of
[] -> z
x:xs -> f x xs
```
as a builtin:
```haskell
-- | Take a function and a list of arguments and apply the former to the latter.
headSpine :: Opaque val asToB -> [val] -> Opaque (HeadSpine val) b
headSpine (Opaque f) = Opaque . \case
[] -> HeadOnly f
x0 : xs ->
-- It's critical to use 'foldr' here, so that deforestation kicks in.
-- See Note [Definition of foldl'] in "GHC.List" and related Notes around for an explanation
-- of the trick.
HeadSpine f $ foldr (\x2 r x1 -> SpineCons x1 $ r x2) SpineLast xs x0
instance uni ~ DefaultUni => ToBuiltinMeaning uni DefaultFun where
<...>
toBuiltinMeaning _ver CaseList =
let caseListDenotation
:: Opaque val (LastArg a b)
-> Opaque val (a -> [a] -> b)
-> SomeConstant uni [a]
-> BuiltinResult (Opaque (HeadSpine val) b)
caseListDenotation z f (SomeConstant (Some (ValueOf uniListA xs0))) = do
case uniListA of
DefaultUniList uniA -> pure $ case xs0 of
[] -> headSpine z [] -- [1]
x : xs -> headSpine f [fromValueOf uniA x, fromValueOf uniListA xs] -- [2]
_ ->
-- See Note [Structural vs operational errors within builtins].
throwing _StructuralUnliftingError "Expected a list but got something else"
{-# INLINE caseListDenotation #-}
in makeBuiltinMeaning
caseListDenotation
(runCostingFunThreeArguments . unimplementedCostingFun)
```
Being able to express [1] (representing `z`) and [2] (representing `f x xs`) is precisely what this PR enables.
Adding support for the new functionality to the CEK machine is trivial. All we need is a way to push a `Spine` of arguments onto the context:
```haskell
-- | Push arguments onto the stack. The first argument will be the most recent entry.
pushArgs
:: Spine (CekValue uni fun ann)
-> Context uni fun ann
-> Context uni fun ann
pushArgs args ctx = foldr FrameAwaitFunValue ctx args
```
and a `HeadSpine` version of `returnCek`:
```haskell
-- | Evaluate a 'HeadSpine' by pushing the arguments (if any) onto the stack and proceeding with
-- the returning phase of the CEK machine.
returnCekHeadSpine
:: Context uni fun ann
-> HeadSpine (CekValue uni fun ann)
-> CekM uni fun s (Term NamedDeBruijn uni fun ())
returnCekHeadSpine ctx (HeadOnly x) = returnCek ctx x
returnCekHeadSpine ctx (HeadSpine f xs) = returnCek (pushArgs xs ctx) f
```
Then replacing
```haskell
BuiltinSuccess x ->
returnCek ctx x
```
with
```haskell
BuiltinSuccess fXs ->
returnCekHeadSpine ctx fXs
```
(and similarly for `BuiltinSuccessWithLogs`) will do the trick.
We used to define `caseList` in terms of `IfThenElse`, `NullList` and either `HeadList` or `TailList` depending on the result of `NullList`, i.e. three builtin calls in the worst and in the best case. Then we introduced `ChooseList`, which replaced both `IfThenElse` and `NullList` in `caseList` thus bringing total amount of builtin calls down to 2 in all cases. This turned out to have a [substantial](IntersectMBO#4119 (review)) impact on performance. This PR allows us to bring total number of builtin calls per `caseList` invokation down to 1 -- the `CaseList` builtin itself.
As per @kwxm's request.