diff --git a/docs/website/contents/howtos/benchmarks.md b/docs/website/contents/howtos/benchmarks.md new file mode 100644 index 0000000000..94ce7fc0c7 --- /dev/null +++ b/docs/website/contents/howtos/benchmarks.md @@ -0,0 +1,32 @@ +# Consensus benchmarks + +We are in the process of adding component level microbenchmarks for Consensus. + +We check for regressions in performance on CI. + +## Mempool Benchmark + +We started with microbenchmarks for adding transactions to the mempool. The +mempool benchmarks can be run using the following command. + +```sh +cabal new-run ouroboros-consensus:mempool-bench +``` + +## ChainSync Client Benchmark + +To aid the refactoring of the ChainSync client, we added a benchmark for it in [PR#823](https://github.com/IntersectMBO/ouroboros-consensus/pull/823). The benchmark could be invoked as follows: + +```sh +cabal new-run ouroboros-consensus:ChainSync-client-bench -- 10 10 +``` + +## PerasCertDB Benchmark + +We have a microbenchmark for the boosted chain fragment weight calculation, which could be run as follows: + +```sh +cabal run ouroboros-consensus:PerasCertDB-bench -- +RTS -T -A32m -RTS +``` + +We request GHC runtime system statistics with `-T` to get a memory usage estimate, and also request a large nursery with `-A32m` to minimise garbage collection. See `tasty-bench` [documentation](https://github.com/Bodigrim/tasty-bench?tab=readme-ov-file#troubleshooting) for more tips. diff --git a/docs/website/contents/references/glossary.md b/docs/website/contents/references/glossary.md index 3035ccae69..f547be3d1f 100644 --- a/docs/website/contents/references/glossary.md +++ b/docs/website/contents/references/glossary.md @@ -472,6 +472,19 @@ These kinds are maintained by the Networking layer: - [Public root peers](#public-root-peers). - [Shared peers](#shared-peers). +## ;Peras ;weight ;boost + +Peras is an extension of Praos enabling faster settlement under optimistic conditions. +To this end, Peras can result in a block `B` receiving a *boost*, which means that any chain containing `B` gets additional weight when being compared to other chains. + +Consider a chain fragment `F`: + +- Its ;*weight boost* is the sum of all boosts received by points on this fragment (excluding the anchor). Note that the same point can be boosted multiple times. + +- Its ;*total weight* is its tip block number plus its weight boost. + +Note that these notions are always relative to a particular anchor, so different chain fragments must have the same anchor when their total weight is to be compared. + ## ;Phases Byron, Shelley, Goguen (current one as of August 2023), Basho, Voltaire. diff --git a/ouroboros-consensus/bench/PerasCertDB-bench/Main.hs b/ouroboros-consensus/bench/PerasCertDB-bench/Main.hs new file mode 100644 index 0000000000..c869365158 --- /dev/null +++ b/ouroboros-consensus/bench/PerasCertDB-bench/Main.hs @@ -0,0 +1,118 @@ +{-# LANGUAGE ImportQualifiedPost #-} +{-# LANGUAGE LambdaCase #-} + +-- | This module contains benchmarks for Peras chain weight calculation as +-- implemented by the by the +-- 'Ouroboros.Consensus.Peras.Weight.weightBoostOfFragment' function. +-- +-- We benchmark the calculation on a static sequence of chain fragments of +-- increasing length, ranging from 0 to 'fragmentMaxLength', with a step size +-- of 'fragmentLengthStepSize'. The chain fragments are instantiated with +-- 'TestBlock', and every 'boostedBlockGap' blocks there is a booster block +-- with weight 'boostWeight'. All parameters are set in 'benchmarkParams'. +module Main (main) where + +import Data.List (iterate') +import Data.Word (Word64) +import Numeric.Natural (Natural) +import Ouroboros.Consensus.Block (PerasWeight (PerasWeight), SlotNo (..)) +import Ouroboros.Consensus.Peras.Weight + ( PerasWeightSnapshot + , mkPerasWeightSnapshot + , weightBoostOfFragment + ) +import Ouroboros.Network.AnchoredFragment qualified as AF +import Test.Ouroboros.Storage.TestBlock (TestBlock (..), TestBody (..), TestHeader (..)) +import Test.Ouroboros.Storage.TestBlock qualified as TestBlock +import Test.Tasty.Bench + +data BenchmarkParams = BenchmarkParams + { slotGap :: Word64 + -- ^ The slot gap between blocks on the fragments, ie the inverse of the + -- active slot coefficient. Measured in slots. + , fragmentLengthStepSize :: Natural + -- ^ Step size for the fragment lengths between different benchmarks, in + -- blocks. + , fragmentMaxLength :: Natural + -- ^ The maximum length of a fragment, in blocks. + , boostedBlockGap :: Natural + -- ^ How often boosted blocks occur, in blocks. + , boostWeight :: PerasWeight + -- ^ The weight of the boost. + } + +benchmarkParams :: BenchmarkParams +benchmarkParams = + BenchmarkParams + { -- On Cardano mainnet, the active slot coefficient f=1/20, so there are 20 + -- slots between blocks on average assuming nominal chain density. + slotGap = 20 + , -- Represents a decent balance between the number of benchmarks we run and + -- the granularity at which we can observe results. + fragmentLengthStepSize = 100 + , -- This is the maximum size of header fragments while syncing (the current + -- selection (k) plus one forecast window under nominal chain density + -- (3k), where k=2160 on Cardano mainnet). + fragmentMaxLength = 2160 + 3 * 2160 + , -- A plausible value for the Peras round length is 90 slots, which means + -- that we expect to see 4-5 blocks per Peras round (and therefore between + -- boosted blocks) on mainnet where the active slot coefficient f=1/20. + boostedBlockGap = 5 + , -- This is a plausible mainnet value (the exact value does not impact the + -- benchmark). + boostWeight = PerasWeight 15 + } + +main :: IO () +main = + Test.Tasty.Bench.defaultMain $ map benchWeightBoostOfFragment inputs + where + -- NOTE: we do not use the 'env' combinator to set up the test data since + -- it requires 'NFData' for 'AF.AnchoredFragment'. While the necessary + -- instances could be provided, we do not think is necessary for this + -- benchmark, as the input data is rather small. + inputs :: [(Natural, (PerasWeightSnapshot TestBlock, AF.AnchoredFragment TestBlock))] + inputs = + getEveryN (fragmentLengthStepSize benchmarkParams) $ + take (fromIntegral $ fragmentMaxLength benchmarkParams) $ + zip [0 ..] $ + zip (map uniformWeightSnapshot fragments) fragments + +benchWeightBoostOfFragment :: + (Natural, (PerasWeightSnapshot TestBlock, AF.AnchoredFragment TestBlock)) -> Benchmark +benchWeightBoostOfFragment (i, (weightSnapshot, fragment)) = + bench ("weightBoostOfFragment of length " <> show i) $ + whnf (weightBoostOfFragment weightSnapshot) fragment + +-- | An infinite list of chain fragments +fragments :: [AF.AnchoredFragment TestBlock] +fragments = iterate' addSuccessorBlock genesisFragment + where + genesisFragment :: AF.AnchoredFragment TestBlock + genesisFragment = AF.Empty AF.AnchorGenesis + + addSuccessorBlock :: AF.AnchoredFragment TestBlock -> AF.AnchoredFragment TestBlock + addSuccessorBlock = \case + AF.Empty _ -> (AF.Empty AF.AnchorGenesis) AF.:> (TestBlock.firstBlock 0 dummyBody) + (xs AF.:> x) -> + let nextBlockSlot = SlotNo (slotGap benchmarkParams) + thSlotNo (testHeader x) + in (xs AF.:> x) AF.:> TestBlock.mkNextBlock x nextBlockSlot dummyBody + + dummyBody :: TestBody + dummyBody = TestBody{tbForkNo = 0, tbIsValid = True} + +-- | Given a chain fragment, construct a weight snapshot where there's a boosted block every 90 slots +uniformWeightSnapshot :: AF.AnchoredFragment TestBlock -> PerasWeightSnapshot TestBlock +uniformWeightSnapshot fragment = + let pointsToBoost = + map snd + . getEveryN (boostedBlockGap benchmarkParams) + . zip [0 ..] + . map AF.blockPoint + . AF.toOldestFirst + $ fragment + weights = repeat (boostWeight benchmarkParams) + in mkPerasWeightSnapshot $ pointsToBoost `zip` weights + +getEveryN :: Natural -> [(Natural, a)] -> [(Natural, a)] +getEveryN n = filter (\(i, _) -> (i `mod` n) == 0) diff --git a/ouroboros-consensus/changelog.d/20250916_104805_thomas.bagrel_basic_peras_types.md b/ouroboros-consensus/changelog.d/20250916_104805_thomas.bagrel_basic_peras_types.md new file mode 100644 index 0000000000..c78b957eef --- /dev/null +++ b/ouroboros-consensus/changelog.d/20250916_104805_thomas.bagrel_basic_peras_types.md @@ -0,0 +1,19 @@ + + +### Breaking + +- Introduce `Ouroboros.Consensus.Block.SupportsPeras` with types related to Peras. + - All new types are re-exported through `Ouroboros.Consensus.Block`. +- Introduce `Ouroboros.Consensus.Peras.Weight` with weight computation related types and functions for chains and fragments. +- Introduce a new benchmark suite `PerasCertDB-bench` +- Add property tests and benchmarks for weight computation on chain and fragments diff --git a/ouroboros-consensus/ouroboros-consensus.cabal b/ouroboros-consensus/ouroboros-consensus.cabal index 0314c178bd..b8097608ed 100644 --- a/ouroboros-consensus/ouroboros-consensus.cabal +++ b/ouroboros-consensus/ouroboros-consensus.cabal @@ -83,6 +83,7 @@ library Ouroboros.Consensus.Block.RealPoint Ouroboros.Consensus.Block.SupportsDiffusionPipelining Ouroboros.Consensus.Block.SupportsMetrics + Ouroboros.Consensus.Block.SupportsPeras Ouroboros.Consensus.Block.SupportsProtocol Ouroboros.Consensus.Block.SupportsSanityCheck Ouroboros.Consensus.BlockchainTime @@ -197,6 +198,7 @@ library Ouroboros.Consensus.Node.Run Ouroboros.Consensus.Node.Serialisation Ouroboros.Consensus.NodeId + Ouroboros.Consensus.Peras.Weight Ouroboros.Consensus.Protocol.Abstract Ouroboros.Consensus.Protocol.BFT Ouroboros.Consensus.Protocol.LeaderSchedule @@ -596,6 +598,7 @@ test-suite consensus-test Test.Consensus.MiniProtocol.ChainSync.CSJ Test.Consensus.MiniProtocol.ChainSync.Client Test.Consensus.MiniProtocol.LocalStateQuery.Server + Test.Consensus.Peras.WeightSnapshot Test.Consensus.Util.MonadSTM.NormalForm Test.Consensus.Util.Versioned @@ -827,6 +830,19 @@ benchmark ChainSync-client-bench unstable-consensus-testlib, with-utf8, +benchmark PerasCertDB-bench + import: common-bench + type: exitcode-stdio-1.0 + hs-source-dirs: bench/PerasCertDB-bench + main-is: Main.hs + other-modules: + build-depends: + base, + ouroboros-consensus, + ouroboros-network-api, + tasty-bench, + unstable-consensus-testlib, + test-suite doctest import: common-test main-is: doctest.hs diff --git a/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/Block.hs b/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/Block.hs index 0ee718be4a..7c8b020e33 100644 --- a/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/Block.hs +++ b/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/Block.hs @@ -8,5 +8,6 @@ import Ouroboros.Consensus.Block.NestedContent as X import Ouroboros.Consensus.Block.RealPoint as X import Ouroboros.Consensus.Block.SupportsDiffusionPipelining as X import Ouroboros.Consensus.Block.SupportsMetrics as X +import Ouroboros.Consensus.Block.SupportsPeras as X import Ouroboros.Consensus.Block.SupportsProtocol as X import Ouroboros.Consensus.Block.SupportsSanityCheck as X diff --git a/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/Block/SupportsPeras.hs b/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/Block/SupportsPeras.hs new file mode 100644 index 0000000000..bdfd9c826c --- /dev/null +++ b/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/Block/SupportsPeras.hs @@ -0,0 +1,185 @@ +{-# LANGUAGE DeriveAnyClass #-} +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE DerivingVia #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE FlexibleInstances #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# LANGUAGE MultiParamTypeClasses #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TypeApplications #-} +{-# LANGUAGE TypeFamilies #-} +{-# LANGUAGE UndecidableInstances #-} + +module Ouroboros.Consensus.Block.SupportsPeras + ( PerasRoundNo (..) + , PerasWeight (..) + , boostPerCert + , BlockSupportsPeras (..) + , PerasCert (..) + , ValidatedPerasCert (..) + , makePerasCfg + , HasPerasCert (..) + , getPerasCertRound + , getPerasCertBoostedBlock + , getPerasCertBoost + + -- * Ouroboros Peras round length + , PerasRoundLength (..) + , defaultPerasRoundLength + ) where + +import Codec.Serialise (Serialise (..)) +import Codec.Serialise.Decoding (decodeListLenOf) +import Codec.Serialise.Encoding (encodeListLen) +import Data.Monoid (Sum (..)) +import Data.Proxy (Proxy (..)) +import Data.Word (Word64) +import GHC.Generics (Generic) +import NoThunks.Class +import Ouroboros.Consensus.Block.Abstract +import Ouroboros.Consensus.Util +import Ouroboros.Consensus.Util.Condense +import Quiet (Quiet (..)) + +newtype PerasRoundNo = PerasRoundNo {unPerasRoundNo :: Word64} + deriving Show via Quiet PerasRoundNo + deriving stock Generic + deriving newtype (Enum, Eq, Ord, NoThunks, Serialise) + +instance Condense PerasRoundNo where + condense = show . unPerasRoundNo + +instance ShowProxy PerasRoundNo where + showProxy _ = "PerasRoundNo" + +newtype PerasWeight = PerasWeight {unPerasWeight :: Word64} + deriving Show via Quiet PerasWeight + deriving stock Generic + deriving newtype (Eq, Ord, NoThunks) + deriving (Semigroup, Monoid) via Sum Word64 + +instance Condense PerasWeight where + condense = show . unPerasWeight + +-- | TODO: this will become a Ledger protocol parameter +-- see https://github.com/tweag/cardano-peras/issues/119 +boostPerCert :: PerasWeight +boostPerCert = PerasWeight 15 + +-- TODO using 'Validated' for extra safety? Or some @.Unsafe@ module? +data ValidatedPerasCert blk = ValidatedPerasCert + { vpcCert :: !(PerasCert blk) + , vpcCertBoost :: !PerasWeight + } + deriving stock (Show, Eq, Ord, Generic) + deriving anyclass NoThunks + +{------------------------------------------------------------------------------- + Ouroboros Peras round length +-------------------------------------------------------------------------------} + +newtype PerasRoundLength = PerasRoundLength {unPerasRoundLength :: Word64} + deriving stock (Show, Eq, Ord) + deriving newtype (NoThunks, Num) + +-- | See the Protocol parameters section of the Peras design report: +-- https://tweag.github.io/cardano-peras/peras-design.pdf#section.2.1 +-- TODO: this will become a Ledger protocol parameter +-- see https://github.com/tweag/cardano-peras/issues/119 +defaultPerasRoundLength :: PerasRoundLength +defaultPerasRoundLength = 90 + +class + ( Show (PerasCfg blk) + , NoThunks (PerasCert blk) + ) => + BlockSupportsPeras blk + where + data PerasCfg blk + + data PerasCert blk + + data PerasValidationErr blk + + validatePerasCert :: + PerasCfg blk -> + PerasCert blk -> + Either (PerasValidationErr blk) (ValidatedPerasCert blk) + +-- TODO: degenerate instance for all blks to get things to compile +-- see https://github.com/tweag/cardano-peras/issues/73 +instance StandardHash blk => BlockSupportsPeras blk where + newtype PerasCfg blk = PerasCfg + { -- TODO: eventually, this will come from the + -- protocol parameters from the ledger state + -- see https://github.com/tweag/cardano-peras/issues/119 + perasCfgWeightBoost :: PerasWeight + } + deriving stock (Show, Eq) + + data PerasCert blk = PerasCert + { pcCertRound :: PerasRoundNo + , pcCertBoostedBlock :: Point blk + } + deriving stock (Generic, Eq, Ord, Show) + deriving anyclass NoThunks + + -- TODO: enrich with actual error types + -- see https://github.com/tweag/cardano-peras/issues/120 + data PerasValidationErr blk + = PerasValidationErr + deriving stock (Show, Eq) + + -- TODO: perform actual validation against all + -- possible 'PerasValidationErr' variants + -- see https://github.com/tweag/cardano-peras/issues/120 + validatePerasCert cfg cert = + Right + ValidatedPerasCert + { vpcCert = cert + , vpcCertBoost = perasCfgWeightBoost cfg + } + +instance ShowProxy blk => ShowProxy (PerasCert blk) where + showProxy _ = "PerasCert " <> showProxy (Proxy @blk) + +instance Serialise (HeaderHash blk) => Serialise (PerasCert blk) where + encode PerasCert{pcCertRound, pcCertBoostedBlock} = + encodeListLen 2 + <> encode pcCertRound + <> encode pcCertBoostedBlock + decode = do + decodeListLenOf 2 + pcCertRound <- decode + pcCertBoostedBlock <- decode + pure $ PerasCert{pcCertRound, pcCertBoostedBlock} + +-- | Derive a 'PerasCfg' from a 'BlockConfig' +-- +-- TODO: this currently doesn't depend on 'BlockConfig' at all, but likely will +-- depend on it in the future +-- see https://github.com/tweag/cardano-peras/issues/73 +makePerasCfg :: Maybe (BlockConfig blk) -> PerasCfg blk +makePerasCfg _ = + PerasCfg + { perasCfgWeightBoost = boostPerCert + } + +class StandardHash blk => HasPerasCert cert blk where + getPerasCert :: cert blk -> PerasCert blk + +instance StandardHash blk => HasPerasCert PerasCert blk where + getPerasCert = id + +instance StandardHash blk => HasPerasCert ValidatedPerasCert blk where + getPerasCert = vpcCert + +getPerasCertRound :: HasPerasCert cert blk => cert blk -> PerasRoundNo +getPerasCertRound = pcCertRound . getPerasCert + +getPerasCertBoostedBlock :: HasPerasCert cert blk => cert blk -> Point blk +getPerasCertBoostedBlock = pcCertBoostedBlock . getPerasCert + +getPerasCertBoost :: ValidatedPerasCert blk -> PerasWeight +getPerasCertBoost = vpcCertBoost diff --git a/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/Peras/Weight.hs b/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/Peras/Weight.hs new file mode 100644 index 0000000000..5e2da40bb7 --- /dev/null +++ b/ouroboros-consensus/src/ouroboros-consensus/Ouroboros/Consensus/Peras/Weight.hs @@ -0,0 +1,302 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE DeriveGeneric #-} +{-# LANGUAGE DerivingVia #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TypeOperators #-} + +-- | Data structure for tracking the weight of blocks due to Peras boosts. +module Ouroboros.Consensus.Peras.Weight + ( -- * 'PerasWeightSnapshot' type + PerasWeightSnapshot + + -- * Construction + , emptyPerasWeightSnapshot + , mkPerasWeightSnapshot + + -- * Conversion + , perasWeightSnapshotToList + + -- * Insertion + , addToPerasWeightSnapshot + + -- * Pruning + , prunePerasWeightSnapshot + + -- * Query + , weightBoostOfPoint + , weightBoostOfFragment + , totalWeightOfFragment + ) where + +import Data.Foldable as Foldable (foldl') +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as Map +import GHC.Generics (Generic) +import NoThunks.Class +import Ouroboros.Consensus.Block +import Ouroboros.Network.AnchoredFragment (AnchoredFragment) +import qualified Ouroboros.Network.AnchoredFragment as AF + +-- | Data structure for tracking the weight of blocks due to Peras boosts. +newtype PerasWeightSnapshot blk = PerasWeightSnapshot + { getPerasWeightSnapshot :: Map (Point blk) PerasWeight + } + deriving stock Eq + deriving Generic + deriving newtype NoThunks + +instance StandardHash blk => Show (PerasWeightSnapshot blk) where + show = show . perasWeightSnapshotToList + +-- | An empty 'PerasWeightSnapshot' not containing any boosted blocks. +emptyPerasWeightSnapshot :: PerasWeightSnapshot blk +emptyPerasWeightSnapshot = PerasWeightSnapshot Map.empty + +-- | Create a weight snapshot from a list of boosted points with an associated +-- weight. In case of duplicate points, their weights are combined. +-- +-- >>> :{ +-- weights :: [(Point Blk, PerasWeight)] +-- weights = +-- [ (BlockPoint 2 "foo", PerasWeight 2) +-- , (GenesisPoint, PerasWeight 3) +-- , (BlockPoint 3 "bar", PerasWeight 2) +-- , (BlockPoint 2 "foo", PerasWeight 2) +-- ] +-- :} +-- +-- >>> snap = mkPerasWeightSnapshot weights +-- >>> snap +-- [(Point Origin,PerasWeight 3),(Point (At (Block {blockPointSlot = SlotNo 2, blockPointHash = "foo"})),PerasWeight 4),(Point (At (Block {blockPointSlot = SlotNo 3, blockPointHash = "bar"})),PerasWeight 2)] +mkPerasWeightSnapshot :: + StandardHash blk => + [(Point blk, PerasWeight)] -> + PerasWeightSnapshot blk +mkPerasWeightSnapshot = + Foldable.foldl' + (\s (pt, weight) -> addToPerasWeightSnapshot pt weight s) + emptyPerasWeightSnapshot + +-- | Return the list of boosted points with their associated weight, sorted +-- based on their point. Does not contain duplicate points. +-- +-- >>> :{ +-- weights :: [(Point Blk, PerasWeight)] +-- weights = +-- [ (BlockPoint 2 "foo", PerasWeight 2) +-- , (GenesisPoint, PerasWeight 3) +-- , (BlockPoint 3 "bar", PerasWeight 2) +-- , (BlockPoint 2 "foo", PerasWeight 2) +-- ] +-- :} +-- +-- >>> snap = mkPerasWeightSnapshot weights +-- >>> perasWeightSnapshotToList snap +-- [(Point Origin,PerasWeight 3),(Point (At (Block {blockPointSlot = SlotNo 2, blockPointHash = "foo"})),PerasWeight 4),(Point (At (Block {blockPointSlot = SlotNo 3, blockPointHash = "bar"})),PerasWeight 2)] +perasWeightSnapshotToList :: PerasWeightSnapshot blk -> [(Point blk, PerasWeight)] +perasWeightSnapshotToList = Map.toAscList . getPerasWeightSnapshot + +-- | Add weight for the given point to the 'PerasWeightSnapshot'. If the point +-- already has some weight, it is added on top. +-- +-- >>> :{ +-- weights :: [(Point Blk, PerasWeight)] +-- weights = +-- [ (BlockPoint 2 "foo", PerasWeight 2) +-- , (GenesisPoint, PerasWeight 3) +-- ] +-- :} +-- +-- >>> snap0 = mkPerasWeightSnapshot weights +-- >>> snap0 +-- [(Point Origin,PerasWeight 3),(Point (At (Block {blockPointSlot = SlotNo 2, blockPointHash = "foo"})),PerasWeight 2)] +-- +-- >>> snap1 = addToPerasWeightSnapshot (BlockPoint 3 "bar") (PerasWeight 2) snap0 +-- >>> snap1 +-- [(Point Origin,PerasWeight 3),(Point (At (Block {blockPointSlot = SlotNo 2, blockPointHash = "foo"})),PerasWeight 2),(Point (At (Block {blockPointSlot = SlotNo 3, blockPointHash = "bar"})),PerasWeight 2)] +-- +-- >>> snap2 = addToPerasWeightSnapshot (BlockPoint 2 "foo") (PerasWeight 2) snap1 +-- >>> snap2 +-- [(Point Origin,PerasWeight 3),(Point (At (Block {blockPointSlot = SlotNo 2, blockPointHash = "foo"})),PerasWeight 4),(Point (At (Block {blockPointSlot = SlotNo 3, blockPointHash = "bar"})),PerasWeight 2)] +addToPerasWeightSnapshot :: + StandardHash blk => + Point blk -> + PerasWeight -> + PerasWeightSnapshot blk -> + PerasWeightSnapshot blk +addToPerasWeightSnapshot pt weight = + PerasWeightSnapshot . Map.insertWith (<>) pt weight . getPerasWeightSnapshot + +-- | Prune the given 'PerasWeightSnapshot' by removing the weight of all blocks +-- strictly older than the given slot. +-- +-- This function is used to get garbage-collect boosted blocks blocks which are +-- older than our immutable tip as we will never adopt a chain containing them. +-- +-- >>> :{ +-- weights :: [(Point Blk, PerasWeight)] +-- weights = +-- [ (BlockPoint 2 "foo", PerasWeight 2) +-- , (GenesisPoint, PerasWeight 3) +-- , (BlockPoint 3 "bar", PerasWeight 2) +-- , (BlockPoint 2 "foo", PerasWeight 2) +-- ] +-- :} +-- +-- >>> snap = mkPerasWeightSnapshot weights +-- +-- >>> prunePerasWeightSnapshot (SlotNo 2) snap +-- [(Point (At (Block {blockPointSlot = SlotNo 2, blockPointHash = "foo"})),PerasWeight 4),(Point (At (Block {blockPointSlot = SlotNo 3, blockPointHash = "bar"})),PerasWeight 2)] +-- +-- >>> prunePerasWeightSnapshot (SlotNo 3) snap +-- [(Point (At (Block {blockPointSlot = SlotNo 3, blockPointHash = "bar"})),PerasWeight 2)] +prunePerasWeightSnapshot :: + SlotNo -> + PerasWeightSnapshot blk -> + PerasWeightSnapshot blk +prunePerasWeightSnapshot slot = + PerasWeightSnapshot . Map.dropWhileAntitone isTooOld . getPerasWeightSnapshot + where + isTooOld :: Point blk -> Bool + isTooOld pt = pointSlot pt < NotOrigin slot + +-- | Get the weight boost for a point, or @'mempty' :: 'PerasWeight'@ otherwise. +-- +-- >>> :{ +-- weights :: [(Point Blk, PerasWeight)] +-- weights = +-- [ (BlockPoint 2 "foo", PerasWeight 2) +-- , (GenesisPoint, PerasWeight 3) +-- , (BlockPoint 3 "bar", PerasWeight 2) +-- , (BlockPoint 2 "foo", PerasWeight 2) +-- ] +-- :} +-- +-- >>> snap = mkPerasWeightSnapshot weights +-- +-- >>> weightBoostOfPoint snap (BlockPoint 2 "foo") +-- PerasWeight 4 +-- +-- >>> weightBoostOfPoint snap (BlockPoint 2 "baz") +-- PerasWeight 0 +weightBoostOfPoint :: + forall blk. + StandardHash blk => + PerasWeightSnapshot blk -> Point blk -> PerasWeight +weightBoostOfPoint (PerasWeightSnapshot weightByPoint) pt = + Map.findWithDefault mempty pt weightByPoint + +-- | Get the weight boost for a fragment, ie the sum of all +-- 'weightBoostOfPoint' for all points on the fragment (excluding the anchor). +-- +-- Note that this quantity is relative to the anchor of the fragment, so it +-- should only be compared against other fragments with the same anchor. +-- +-- >>> :{ +-- weights :: [(Point Blk, PerasWeight)] +-- weights = +-- [ (BlockPoint 2 "foo", PerasWeight 2) +-- , (GenesisPoint, PerasWeight 3) +-- , (BlockPoint 3 "bar", PerasWeight 2) +-- , (BlockPoint 2 "foo", PerasWeight 2) +-- ] +-- :} +-- +-- >>> :{ +-- snap = mkPerasWeightSnapshot weights +-- foo = HeaderFields (SlotNo 2) (BlockNo 1) "foo" +-- bar = HeaderFields (SlotNo 3) (BlockNo 2) "bar" +-- frag0 :: AnchoredFragment (HeaderFields Blk) +-- frag0 = Empty AnchorGenesis :> foo :> bar +-- :} +-- +-- >>> weightBoostOfFragment snap frag0 +-- PerasWeight 6 +-- +-- Only keeping the last block from @frag0@: +-- +-- >>> frag1 = AF.anchorNewest 1 frag0 +-- >>> weightBoostOfFragment snap frag1 +-- PerasWeight 2 +-- +-- Dropping the head from @frag0@, and instead adding an unboosted point: +-- +-- >>> frag2 = AF.dropNewest 1 frag0 :> HeaderFields (SlotNo 4) (BlockNo 2) "baz" +-- >>> weightBoostOfFragment snap frag2 +-- PerasWeight 4 +weightBoostOfFragment :: + forall blk h. + (StandardHash blk, HasHeader h, HeaderHash blk ~ HeaderHash h) => + PerasWeightSnapshot blk -> + AnchoredFragment h -> + PerasWeight +weightBoostOfFragment weightSnap frag + | Map.null $ getPerasWeightSnapshot weightSnap = + mempty + | otherwise = + -- TODO: think about whether this could be done in sublinear complexity + -- see https://github.com/IntersectMBO/ouroboros-consensus/pull/1613 + foldMap + (weightBoostOfPoint weightSnap . castPoint . blockPoint) + (AF.toOldestFirst frag) + +-- | Get the total weight for a fragment, ie the length plus the weight boost +-- ('weightBoostOfFragment') of the fragment. +-- +-- Note that this quantity is relative to the anchor of the fragment, so it +-- should only be compared against other fragments with the same anchor. +-- +-- >>> :{ +-- weights :: [(Point Blk, PerasWeight)] +-- weights = +-- [ (BlockPoint 2 "foo", PerasWeight 2) +-- , (GenesisPoint, PerasWeight 3) +-- , (BlockPoint 3 "bar", PerasWeight 2) +-- , (BlockPoint 2 "foo", PerasWeight 2) +-- ] +-- :} +-- +-- >>> :{ +-- snap = mkPerasWeightSnapshot weights +-- foo = HeaderFields (SlotNo 2) (BlockNo 1) "foo" +-- bar = HeaderFields (SlotNo 3) (BlockNo 2) "bar" +-- frag0 :: AnchoredFragment (HeaderFields Blk) +-- frag0 = Empty AnchorGenesis :> foo :> bar +-- :} +-- +-- >>> totalWeightOfFragment snap frag0 +-- PerasWeight 8 +-- +-- Only keeping the last block from @frag0@: +-- +-- >>> frag1 = AF.anchorNewest 1 frag0 +-- >>> totalWeightOfFragment snap frag1 +-- PerasWeight 3 +-- +-- Dropping the head from @frag0@, and instead adding an unboosted point: +-- +-- >>> frag2 = AF.dropNewest 1 frag0 :> HeaderFields (SlotNo 4) (BlockNo 2) "baz" +-- >>> totalWeightOfFragment snap frag2 +-- PerasWeight 6 +totalWeightOfFragment :: + forall blk h. + (StandardHash blk, HasHeader h, HeaderHash blk ~ HeaderHash h) => + PerasWeightSnapshot blk -> + AnchoredFragment h -> + PerasWeight +totalWeightOfFragment weightSnap frag = + weightLength <> weightBoost + where + weightLength = PerasWeight $ fromIntegral $ AF.length frag + weightBoost = weightBoostOfFragment weightSnap frag + +-- $setup +-- >>> import Cardano.Ledger.BaseTypes +-- >>> import Ouroboros.Consensus.Block +-- >>> import Ouroboros.Network.AnchoredFragment (AnchoredFragment, AnchoredSeq(..), Anchor(..)) +-- >>> import qualified Ouroboros.Network.AnchoredFragment as AF +-- >>> :set -XDataKinds -XTypeApplications -XTypeFamilies +-- >>> data Blk = Blk +-- >>> type instance HeaderHash Blk = String +-- >>> instance StandardHash Blk diff --git a/ouroboros-consensus/src/unstable-consensus-testlib/Test/Util/Orphans/ToExpr.hs b/ouroboros-consensus/src/unstable-consensus-testlib/Test/Util/Orphans/ToExpr.hs index 6830141290..e5560f70f8 100644 --- a/ouroboros-consensus/src/unstable-consensus-testlib/Test/Util/Orphans/ToExpr.hs +++ b/ouroboros-consensus/src/unstable-consensus-testlib/Test/Util/Orphans/ToExpr.hs @@ -119,6 +119,14 @@ instance ToExpr FsError where deriving instance ToExpr a => ToExpr (LoE a) +deriving anyclass instance ToExpr PerasRoundNo + +deriving anyclass instance ToExpr PerasWeight + +deriving anyclass instance ToExpr (HeaderHash blk) => ToExpr (PerasCert blk) + +deriving anyclass instance ToExpr (HeaderHash blk) => ToExpr (ValidatedPerasCert blk) + {------------------------------------------------------------------------------- si-timers --------------------------------------------------------------------------------} diff --git a/ouroboros-consensus/test/consensus-test/Main.hs b/ouroboros-consensus/test/consensus-test/Main.hs index 88681b82fa..beddd1f7d2 100644 --- a/ouroboros-consensus/test/consensus-test/Main.hs +++ b/ouroboros-consensus/test/consensus-test/Main.hs @@ -16,6 +16,7 @@ import qualified Test.Consensus.MiniProtocol.BlockFetch.Client (tests) import qualified Test.Consensus.MiniProtocol.ChainSync.CSJ (tests) import qualified Test.Consensus.MiniProtocol.ChainSync.Client (tests) import qualified Test.Consensus.MiniProtocol.LocalStateQuery.Server (tests) +import qualified Test.Consensus.Peras.WeightSnapshot (tests) import qualified Test.Consensus.Util.MonadSTM.NormalForm (tests) import qualified Test.Consensus.Util.Versioned (tests) import Test.Tasty @@ -43,6 +44,7 @@ tests = , Test.Consensus.Mempool.Fairness.tests , Test.Consensus.Mempool.StateMachine.tests ] + , Test.Consensus.Peras.WeightSnapshot.tests , Test.Consensus.Util.MonadSTM.NormalForm.tests , Test.Consensus.Util.Versioned.tests , testGroup diff --git a/ouroboros-consensus/test/consensus-test/Test/Consensus/Peras/WeightSnapshot.hs b/ouroboros-consensus/test/consensus-test/Test/Consensus/Peras/WeightSnapshot.hs new file mode 100644 index 0000000000..21c84f7050 --- /dev/null +++ b/ouroboros-consensus/test/consensus-test/Test/Consensus/Peras/WeightSnapshot.hs @@ -0,0 +1,191 @@ +{-# LANGUAGE BlockArguments #-} +{-# LANGUAGE CPP #-} +{-# LANGUAGE DerivingStrategies #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TupleSections #-} +{-# LANGUAGE TypeApplications #-} + +#if __GLASGOW_HASKELL__ >= 910 +{-# OPTIONS_GHC -Wno-x-partial #-} +#endif + +-- | Test that 'PerasWeightSnapshot' can correctly compute the weight of points +-- and fragments. +module Test.Consensus.Peras.WeightSnapshot (tests) where + +import Data.Containers.ListUtils (nubOrd) +import Data.Map.Strict (Map) +import qualified Data.Map.Strict as Map +import Data.Maybe (catMaybes) +import Data.Traversable (for) +import Ouroboros.Consensus.Block +import Ouroboros.Consensus.Peras.Weight +import Ouroboros.Consensus.Util.Condense +import Ouroboros.Network.AnchoredFragment (AnchoredFragment) +import qualified Ouroboros.Network.AnchoredFragment as AF +import Ouroboros.Network.Mock.Chain (Chain) +import qualified Ouroboros.Network.Mock.Chain as Chain +import Test.QuickCheck +import Test.Tasty +import Test.Tasty.QuickCheck +import Test.Util.Orphans.Arbitrary () +import Test.Util.QuickCheck +import Test.Util.TestBlock + +tests :: TestTree +tests = + testGroup + "PerasWeightSnapshot" + [ testProperty "correctness" prop_perasWeightSnapshot + ] + +prop_perasWeightSnapshot :: TestSetup -> Property +prop_perasWeightSnapshot testSetup = + tabulate "logâ‚‚ # of points" [show $ round @Double @Int $ logBase 2 (fromIntegral (length tsPoints))] + . counterexample ("PerasWeightSnapshot: " <> show snap) + $ conjoin + [ conjoin + [ counterexample ("Incorrect weight for " <> condense pt) $ + weightBoostOfPointReference pt =:= weightBoostOfPoint snap pt + | pt <- tsPoints + ] + , conjoin + [ conjoin + [ counterexample ("Incorrect weight for " <> condense frag) $ + weightBoostOfFragmentReference frag =:= weightBoostOfFragment snap frag + , counterexample ("Weight not inductively consistent for " <> condense frag) $ + prop_fragmentInduction snap frag + ] + | frag <- tsFragments + ] + ] + where + TestSetup + { tsWeights + , tsPoints + , tsFragments + } = testSetup + + snap = mkPerasWeightSnapshot $ Map.toList tsWeights + + weightBoostOfPointReference :: Point TestBlock -> PerasWeight + weightBoostOfPointReference pt = Map.findWithDefault mempty pt tsWeights + + weightBoostOfFragmentReference :: AnchoredFragment TestBlock -> PerasWeight + weightBoostOfFragmentReference frag = + foldMap + (weightBoostOfPointReference . blockPoint) + (AF.toOldestFirst frag) + +-- | Test that the weight of a fragment is equal to the weight of its +-- first\/last point plus the weight of the remaining suffix\/infix. +prop_fragmentInduction :: + PerasWeightSnapshot TestBlock -> + AnchoredFragment TestBlock -> + Property +prop_fragmentInduction snap = + \frag -> fromLeft frag .&&. fromRight frag + where + fromLeft :: AnchoredFragment TestBlock -> Property + fromLeft frag = case frag of + AF.Empty _ -> + weightBoostOfFragment snap frag === mempty + b AF.:< frag' -> + weightBoostOfFragment snap frag + === weightBoostOfPoint snap (blockPoint b) <> weightBoostOfFragment snap frag' + + fromRight :: AnchoredFragment TestBlock -> Property + fromRight frag = case frag of + AF.Empty _ -> + weightBoostOfFragment snap frag === mempty + frag' AF.:> b -> + weightBoostOfFragment snap frag + === weightBoostOfPoint snap (blockPoint b) <> weightBoostOfFragment snap frag' + +data TestSetup = TestSetup + { tsWeights :: Map (Point TestBlock) PerasWeight + , tsPoints :: [Point TestBlock] + -- ^ Check the weight of these points. + , tsFragments :: [AnchoredFragment TestBlock] + -- ^ Check the weight of these fragments. + } + deriving stock Show + +instance Arbitrary TestSetup where + arbitrary = do + -- Generate a block tree rooted at Genesis. + tree :: BlockTree <- arbitrary + + let + -- Points for all blocks in the block tree. + tsPoints :: [Point TestBlock] + tsPoints = nubOrd $ GenesisPoint : (blockPoint <$> treeToBlocks tree) + + -- Chains from Genesis to all leaves of the block tree. + treeChains :: [Chain TestBlock] + treeChains = treeToChains tree + + -- Randomly boost some points. This might need to be refined in the future + -- (as per https://github.com/tweag/cardano-peras/issues/124). + tsWeights :: Map (Point TestBlock) PerasWeight <- + Map.fromList . catMaybes <$> for tsPoints \pt -> + fmap (pt,) <$> genWeightBoost + + -- Generate a list of fragments as random infixes of the @treeChains@. + tsFragments <- + for treeChains genInfixFragment + + pure + TestSetup + { tsWeights + , tsPoints + , tsFragments + } + where + -- Generate a weight boost (for some point). + genWeightBoost :: Gen (Maybe PerasWeight) + genWeightBoost = + frequency + [ (3, pure Nothing) + , (1, Just . PerasWeight <$> choose (1, 10)) + ] + + -- Given a chain, generate an infix fragment of that chain. + genInfixFragment :: Chain TestBlock -> Gen (AnchoredFragment TestBlock) + genInfixFragment chain = do + let lenChain = Chain.length chain + fullFrag = Chain.toAnchoredFragment chain + nTakeNewest <- choose (0, lenChain) + nDropNewest <- choose (0, nTakeNewest) + pure $ + AF.dropNewest nDropNewest $ + AF.anchorNewest (fromIntegral nTakeNewest) fullFrag + + shrink ts = + concat + [ [ ts{tsWeights = Map.fromList tsWeights'} + | tsWeights' <- + shrinkList + (\(pt, w) -> (pt,) <$> shrinkWeight w) + $ Map.toList tsWeights + ] + , [ ts{tsPoints = tsPoints'} + | tsPoints' <- shrinkList (\_pt -> []) tsPoints + ] + , [ ts{tsFragments = tsFragments'} + | tsFragments' <- shrinkList (\_frag -> []) tsFragments + ] + ] + where + -- Decrease by @1@, unless this would mean that it is non-positive. + shrinkWeight :: PerasWeight -> [PerasWeight] + shrinkWeight (PerasWeight w) + | w >= 1 = [PerasWeight (w - 1)] + | otherwise = [] + + TestSetup + { tsWeights + , tsPoints + , tsFragments + } = ts