Skip to content
This repository has been archived by the owner on Nov 15, 2023. It is now read-only.

contracts: Lazy storage removal #7740

Merged
19 commits merged into from
Jan 4, 2021
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions bin/node/runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ pub use pallet_transaction_payment::{Multiplier, TargetedFeeAdjustment, Currency
use pallet_session::{historical as pallet_session_historical};
use sp_inherents::{InherentData, CheckInherentsResult};
use static_assertions::const_assert;
use pallet_contracts::WeightInfo;

#[cfg(any(feature = "std", test))]
pub use sp_runtime::BuildStorage;
Expand Down Expand Up @@ -714,6 +715,12 @@ parameter_types! {
pub const MaxDepth: u32 = 32;
pub const StorageSizeOffset: u32 = 8;
pub const MaxValueSize: u32 = 16 * 1024;
pub DeletionWeightLimit: Weight = Perbill::from_percent(20) *
RuntimeBlockWeights::get().max_block;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder how this can collide with staking implementation of OffchainSolutionWeightLimit.

Basically staking offchain worker will use the maximum weight available for normal extrinsic. This maximum is computed using AVERAGE_ON_INITIALIZE_RATIO. If the on_initialize weight is more than AVERAGE_ON_INITIALIZE_RATIO for all the time where offchain worker needs to submit election result. Then a less good on-chain election will happen (I'm not sure how much this should be avoided).

So having underestimated AVERAGE_ON_INITIALIZE_RATIO under the offchain election is open can be a problem maybe, WDYT @kianenigma

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having multiple on_initialize functions that want to take some non-substantial amounts of weight from the block are a problem. They need to be coordinated somehow. Moving the lazy removal from on_initialize to some potential on_idle could be a solution.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That said, I am open to change those numbers here to whatever works.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess one way could be to have DeletionWeightLimitRatio < AVERAGE_ON_INITIALIZE_RATIO. so that even if the chain is busy removing lots of tries, election is still fine.

But I agree on_idle could be helpful, maybe staking could notify on_idle that it needs some space somehow

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess one way could be to have DeletionWeightLimitRatio < AVERAGE_ON_INITIALIZE_RATIO. so that even if the chain is busy removing lots of tries, election is still fine.

But this assumes that this would be the only on_initialize in the block which it isn't. The AVERAGE_ON_INITIALIZE_RATIO shared between all of them.

Don't we already have this problem because of this code already:

parameter_types! {
pub OffencesWeightSoftLimit: Weight = Perbill::from_percent(60) *
RuntimeBlockWeights::get().max_block;
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tuned it down to AVERAGE_ON_INITIALIZE_RATIO. With that setting we get the following throughput of the lazy removal:

Lazy deletion throughput per block (empty queue, full queue): 1954, 1564

In order to increase this throughput in the future we should look into on_idle or off chain workers.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current OffchainSolutionWeightLimit is a bit tricky because it essential to the system to work properly, yet it is an unsigned transaction, so it is always likely that sum of on_initialize causes it to fail, in which case we do the notorious on-chain election. This will be fixed with #7319 which is far from being shipped yet, but almost ready. Once this is merged, we do hope to do most of the solution processing in on_initialize as well. In which case, the competition becomes fair. Something like staking, because it is critical to the system, would stay in on_initialize, and this can move to lazy removal.

A little brain dump about that: Let's say that a block has 100 weight units. Usually, 10 is consumed in on_initialize, and 40 in extrinsic, leaving 50 unused. Spreading this into the on_idle cases resembleas a thread/process scheduling situation. Two important notes come to mind:

  1. Can we enforce the weight of each on_idle {} call? No, the runtime should have trust within it and the on_idle call of a module should simply return the weight that it has consumed, similar to on_initialize.
  2. How can we ensure that the idle-scheduling is fair? This can be tricky but I guess a number of policies can be used, and ideally we can have it configurable. Naively, we can start by running the on_idle of each pallet until there is no weight left. Else, we can do it in a ring fashion, where if pallet at indices {0..3} got to execute their on_idle on this block, on the next block we start from {4..}.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we discuss this in the on_idle issue? Or do you think the lazy deletion would essentially break elections and therefore we need to implement on_idle first?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added the following issue to keep track of this issue: #7817

That said, I will merge in the current form because:

  • This configuration is only for the dev node and neither kusama nor polkadot have it deployed
  • This is only an issue when contracts are heavily utilized the time an offchain election happens
  • Having an on-chain election will not break the chain
  • Decouple Staking and Election #7319 will most likely be merged before it gets important
  • on_idle will most likely be available before it gets important

Copy link
Contributor

@kianenigma kianenigma Jan 8, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have any objections with merging this as it is. Thanks for making the issue!

About

Or do you think the lazy deletion would essentially break elections and therefore we need to implement on_idle first?

Yes, it could break it, but I don't think it is a showstopper because afaik there are not mainnets with both pallets. Just once you are setting the value of maximum weight that you can consume for this operation in node/runtime/src/lib, for the sake of prosperity, document that fact that the value shoudl be chosen with care and with care.

pub DeletionQueueDepth: u32 = ((DeletionWeightLimit::get() / (
<Runtime as pallet_contracts::Config>::WeightInfo::on_initialize_per_queue_item(1) -
<Runtime as pallet_contracts::Config>::WeightInfo::on_initialize_per_queue_item(0)
)) / 5) as u32;
athei marked this conversation as resolved.
Show resolved Hide resolved
}

impl pallet_contracts::Config for Runtime {
Expand All @@ -732,6 +739,8 @@ impl pallet_contracts::Config for Runtime {
type MaxValueSize = MaxValueSize;
type WeightPrice = pallet_transaction_payment::Module<Self>;
type WeightInfo = pallet_contracts::weights::SubstrateWeight<Self>;
type DeletionQueueDepth = DeletionQueueDepth;
type DeletionWeightLimit = DeletionWeightLimit;
}

impl pallet_sudo::Config for Runtime {
Expand Down
77 changes: 61 additions & 16 deletions frame/contracts/src/benchmarking/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ use self::{
use frame_benchmarking::{benchmarks, account, whitelisted_caller};
use frame_system::{Module as System, RawOrigin};
use parity_wasm::elements::{Instruction, ValueType, BlockType};
use sp_runtime::traits::{Hash, Bounded};
use sp_runtime::traits::{Hash, Bounded, Zero};
use sp_std::{default::Default, convert::{TryInto}, vec::Vec, vec};
use pallet_contracts_primitives::RentProjection;

Expand Down Expand Up @@ -209,37 +209,52 @@ where
}
}

/// A `Contract` that was evicted after accumulating some storage.
/// A `Contract` that contains some storage items.
///
/// This is used to benchmark contract resurrection.
struct Tombstone<T: Config> {
/// This is used to benchmark contract destruction and resurection. Those operations'
/// weight depend on the amount of storage accumulated.
struct ContractWithStorage<T: Config> {
/// The contract that was evicted.
contract: Contract<T>,
/// The storage the contract held when it was avicted.
storage: Vec<(StorageKey, Vec<u8>)>,
}

impl<T: Config> Tombstone<T>
impl<T: Config> ContractWithStorage<T>
where
T: Config,
T::AccountId: UncheckedFrom<T::Hash> + AsRef<[u8]>,
{
/// Create and evict a new contract with the supplied storage item count and size each.
/// Same as [`Self::with_code`] but with dummy contract code.
fn new(stor_num: u32, stor_size: u32) -> Result<Self, &'static str> {
let contract = Contract::<T>::new(WasmModule::dummy(), vec![], Endow::CollectRent)?;
Self::with_code(WasmModule::dummy(), stor_num, stor_size)
}

/// Create and evict a new contract with the supplied storage item count and size each.
fn with_code(code: WasmModule<T>, stor_num: u32, stor_size: u32) -> Result<Self, &'static str> {
let contract = Contract::<T>::new(code, vec![], Endow::CollectRent)?;
let storage_items = create_storage::<T>(stor_num, stor_size)?;
contract.store(&storage_items)?;
System::<T>::set_block_number(
contract.eviction_at()? + T::SignedClaimHandicap::get() + 5u32.into()
);
Rent::<T>::collect(&contract.account_id);
contract.ensure_tombstone()?;

Ok(Tombstone {
Ok(Self {
contract,
storage: storage_items,
})
}

/// Increase the system block number so that this contract is eligible for eviction.
fn set_block_num_for_eviction(&self) -> Result<(), &'static str> {
System::<T>::set_block_number(
self.contract.eviction_at()? + T::SignedClaimHandicap::get() + 5u32.into()
);
Ok(())
}

/// Evict this contract.
fn evict(&mut self) -> Result<(), &'static str> {
self.set_block_num_for_eviction()?;
Rent::<T>::snitch_contract_should_be_evicted(&self.contract.account_id, Zero::zero())?;
self.contract.ensure_tombstone()
}
}

/// Generate `stor_num` storage items. Each has the size `stor_size`.
Expand Down Expand Up @@ -270,6 +285,30 @@ benchmarks! {
_ {
}

// The base weight without any actual work performed apart from the setup costs.
on_initialize {}: {
Storage::<T>::process_deletion_queue_batch(Weight::max_value())
}

on_initialize_per_trie_key {
let k in 0..1024;
let instance = ContractWithStorage::<T>::new(k, T::MaxValueSize::get())?;
Storage::<T>::queue_trie_for_deletion(&instance.contract.alive_info()?)?;
}: {
Storage::<T>::process_deletion_queue_batch(Weight::max_value())
}

on_initialize_per_queue_item {
let q in 0..1024.min(T::DeletionQueueDepth::get());
for i in 0 .. q {
let instance = Contract::<T>::with_index(i, WasmModule::dummy(), vec![], Endow::Max)?;
Storage::<T>::queue_trie_for_deletion(&instance.alive_info()?)?;
ContractInfoOf::<T>::remove(instance.account_id);
}
}: {
Storage::<T>::process_deletion_queue_batch(Weight::max_value())
}

// This extrinsic is pretty much constant as it is only a simple setter.
update_schedule {
let schedule = Schedule {
Expand Down Expand Up @@ -650,7 +689,8 @@ benchmarks! {
// Restore just moves the trie id from origin to destination and therefore
// does not depend on the size of the destination contract. However, to not
// trigger any edge case we won't use an empty contract as destination.
let tombstone = Tombstone::<T>::new(10, T::MaxValueSize::get())?;
let mut tombstone = ContractWithStorage::<T>::new(10, T::MaxValueSize::get())?;
tombstone.evict()?;

let dest = tombstone.contract.account_id.encode();
let dest_len = dest.len();
Expand Down Expand Up @@ -723,7 +763,8 @@ benchmarks! {

seal_restore_to_per_delta {
let d in 0 .. API_BENCHMARK_BATCHES;
let tombstone = Tombstone::<T>::new(0, 0)?;
let mut tombstone = ContractWithStorage::<T>::new(0, 0)?;
tombstone.evict()?;
let delta = create_storage::<T>(d * API_BENCHMARK_BATCH_SIZE, T::MaxValueSize::get())?;

let dest = tombstone.contract.account_id.encode();
Expand Down Expand Up @@ -2394,6 +2435,10 @@ mod tests {
}
}

create_test!(on_initialize);
create_test!(on_initialize_per_trie_key);
create_test!(on_initialize_per_queue_item);

create_test!(update_schedule);
create_test!(put_code);
create_test!(instantiate);
Expand Down
29 changes: 16 additions & 13 deletions frame/contracts/src/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -267,12 +267,12 @@ where
Err(Error::<T>::MaxCallDepthReached)?
}

// Assumption: `collect` doesn't collide with overlay because
// `collect` will be done on first call and destination contract and balance
// cannot be changed before the first call
// We do not allow 'calling' plain accounts. For transfering value
// `seal_transfer` must be used.
let contract = if let Some(ContractInfo::Alive(info)) = Rent::<T>::collect(&dest) {
// This charges the rent and denies access to a contract that is in need of
// eviction by returning `None`. We cannot evict eagerly here because those
// changes would be rolled back in case this contract is called by another
// contract.
// See: https://github.com/paritytech/substrate/issues/6439#issuecomment-648754324
let contract = if let Ok(Some(ContractInfo::Alive(info))) = Rent::<T>::charge(&dest) {
info
} else {
Err(Error::<T>::NotCallable)?
Expand Down Expand Up @@ -574,13 +574,16 @@ where
value,
self.ctx,
)?;
let self_trie_id = self.ctx.self_trie_id.as_ref().expect(
"this function is only invoked by in the context of a contract;\
a contract has a trie id;\
this can't be None; qed",
);
Storage::<T>::destroy_contract(&self_id, self_trie_id);
Ok(())
if let Some(ContractInfo::Alive(info)) = ContractInfoOf::<T>::take(&self_id) {
Storage::<T>::queue_trie_for_deletion(&info)?;
athei marked this conversation as resolved.
Show resolved Hide resolved
Ok(())
} else {
panic!(
"this function is only invoked by in the context of a contract;\
this contract is therefore alive;\
qed"
);
}
}

fn call(
Expand Down
49 changes: 44 additions & 5 deletions frame/contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,12 @@ pub trait Config: frame_system::Config {
/// Describes the weights of the dispatchables of this module and is also used to
/// construct a default cost schedule.
type WeightInfo: WeightInfo;

/// The maximum number of tries that can be queued for deletion.
type DeletionQueueDepth: Get<u32>;

/// The maximum amount of weight that can be consumed per block for lazy trie removal.
type DeletionWeightLimit: Get<Weight>;
}

decl_error! {
Expand Down Expand Up @@ -386,6 +392,17 @@ decl_error! {
TooManyTopics,
/// The topics passed to `seal_deposit_events` contains at least one duplicate.
DuplicateTopics,
/// Removal of a contract failed because the deletion queue is full.
///
/// This can happen when either calling [`Module::claim_surcharge`] or `seal_terminate`.
/// The queue is filled by deleting contracts and emptied by a fixed amount each block.
/// Trying again during another block is the only way to resolve this issue.
DeletionQueueFull,
/// A contract could not be evicted because it has enough balance to pay rent.
///
/// This can be returned from [`Module::claim_surcharge`] because the target
/// contract has enough balance to pay for its rent.
ContractNotEvictable,
}
}

Expand Down Expand Up @@ -439,8 +456,19 @@ decl_module! {
/// The maximum size of a storage value in bytes. A reasonable default is 16 KiB.
const MaxValueSize: u32 = T::MaxValueSize::get();

/// The maximum number of tries that can be queued for deletion.
const DeletionQueueDepth: u32 = T::DeletionQueueDepth::get();

/// The maximum amount of weight that can be consumed per block for lazy trie removal.
const DeletionWeightLimit: Weight = T::DeletionWeightLimit::get();

fn deposit_event() = default;

fn on_initialize() -> Weight {
Storage::<T>::process_deletion_queue_batch(T::DeletionWeightLimit::get())
athei marked this conversation as resolved.
Show resolved Hide resolved
.unwrap_or_else(T::WeightInfo::on_initialize)
}

/// Updates the schedule for metering contracts.
///
/// The schedule must have a greater version than the stored schedule.
Expand Down Expand Up @@ -539,10 +567,14 @@ decl_module! {
/// Allows block producers to claim a small reward for evicting a contract. If a block producer
/// fails to do so, a regular users will be allowed to claim the reward.
///
/// If contract is not evicted as a result of this call, no actions are taken and
/// the sender is not eligible for the reward.
/// If contract is not evicted as a result of this call, [`Error::ContractNotEvictable`]
/// is returned and the sender is not eligible for the reward.
#[weight = T::WeightInfo::claim_surcharge()]
fn claim_surcharge(origin, dest: T::AccountId, aux_sender: Option<T::AccountId>) {
pub fn claim_surcharge(
origin,
dest: T::AccountId,
aux_sender: Option<T::AccountId>
) -> DispatchResult {
let origin = origin.into();
let (signed, rewarded) = match (origin, aux_sender) {
(Ok(frame_system::RawOrigin::Signed(account)), None) => {
Expand All @@ -564,8 +596,10 @@ decl_module! {
};

// If poking the contract has lead to eviction of the contract, give out the rewards.
if Rent::<T>::snitch_contract_should_be_evicted(&dest, handicap) {
T::Currency::deposit_into_existing(&rewarded, T::SurchargeReward::get())?;
if Rent::<T>::snitch_contract_should_be_evicted(&dest, handicap)? {
T::Currency::deposit_into_existing(&rewarded, T::SurchargeReward::get()).map(|_| ())
} else {
Err(Error::<T>::ContractNotEvictable.into())
}
}
}
Expand Down Expand Up @@ -723,6 +757,11 @@ decl_storage! {
///
/// TWOX-NOTE: SAFE since `AccountId` is a secure hash.
pub ContractInfoOf: map hasher(twox_64_concat) T::AccountId => Option<ContractInfo<T>>;
/// Evicted contracts that await child trie deletion.
///
/// Child trie deletion is a heavy operation depending on the amount of storage items
/// stored in said trie. Therefore this operation is performed lazily in `on_initialize`.
pub DeletionQueue: Vec<storage::DeletedContract>;
}
}

Expand Down
Loading