Skip to content
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

[Staking] Split candidate state for PoV optimization #1117

Merged
merged 39 commits into from
Jan 26, 2022

Conversation

4meta5
Copy link
Contributor

@4meta5 4meta5 commented Dec 23, 2021

What does it do?

Split candidate state such that we do not read any unnecessary storage when we do the delegations snapshot for each selected collator at round transitions and also for any delegation changes in general.

This does NOT change any extrinsics.

  • update migration to revoke all old bottom delegations that will not fit into new bounded delegations
  • configure runtimes and update integration tests
  • add more unit tests (migration, new metadata fields whenever they are supposed to change)
  • update benchmarking -> weights

Associated Constant Changes

  1. MaxDelegatorsPerCandidate -> MaxTopDelegationsPerCandidate = 300 in all runtimes
  2. _unbounded_bottom -> MaxBottomDelegationsPerCandidate = 50 in all runtimes

Storage and Type Changes

CandidateState(key: AccountId, value: CollatorCandidate) is split into 3 maps:

  1. CandidateInfo(key: AccountId, value: CandidateMetadata)
  2. TopDelegations(key: AccountId, value: Delegations)
  3. BottomDelegations(key: AccountId, value: Delegations)

What important points reviewers should know?

Enforced First Come First Serve For Delegations of Same Amount

This was not enforced in the code prior to this PR even though we thought it was.

CandidateInfo designed to limit getting Top || Bottom delegations

When Can A Delegation Be Kicked

The lowest bottom delegation can be kicked (force revoked) in 2 scenarios. Both scenarios only occur when the top and bottom delegations are full.

  1. new top delegation inserted which pushes lowest top to the bottom => kicks lowest bottom
  2. new bottom delegation greater than lowest bottom => kicks lowest bottom

Is there something left for follow-up PRs?

Similar optimizations should be made to DelegatorState. We should store the delegations themselves separate from the metadata (ie delegation_count, total_amount, etc). This will be done in the same PR that removes the dependency on OrderedSet altogether and fixes the PartialEq impl for Bond.

What alternative implementations were considered?

Are there relevant PRs or issues in other repositories (Substrate, Polkadot, Frontier, Cumulus)?

#1105 was a hotfix patch but the design in this PR was preferred and the vuln was determined to not be significant enough to prefer the hotfix solution

What value does it bring to the blockchain users?

Transaction fees decreased for join_candidates, schedule_leave_candidaets, cancel_leave_candidates, go_online, go_offline, candidate_bond_more, schedule_candidate_bond_less, execute_candidate_bond_less, cancel_candidate_bond_less, delegate, schedule_leave_delegators, cancel_leave_delegators, delegator_bond_more.

Transaction fees (weights) increased for execute_leave_delegators, execute_leave_candidates.

The weight hint CandidateDelegationCount was also added to execute_leave_candidates to make the cost more accurately be proportional to the number of delegations for the candidate.

/// Candidate
CandidateWentOffline(T::AccountId),
/// Candidate
CandidateBackOnline(T::AccountId),
Copy link
Contributor

Choose a reason for hiding this comment

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

Why remove RoundIndex?

Copy link
Contributor Author

@4meta5 4meta5 Jan 4, 2022

Choose a reason for hiding this comment

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

It is an unnecessary GET, we will always know the round in which the event is emitted because we know the block it happened.

Copy link
Contributor

Choose a reason for hiding this comment

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

As a client receiving this event, wouldn't I either need to track which round I'm in or make an extra query to ask for it?

Copy link
Contributor

Choose a reason for hiding this comment

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

As @librelois pointed out, this change reduces blockspace slightly. I no longer have a strong opinion about this...

Copy link
Collaborator

@librelois librelois Jan 7, 2022

Choose a reason for hiding this comment

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

I think that in the absolute, the blocks must contain the strictly necessary and sufficient data, the content of the blocks does not have for role to simplify the life of the users, it's the role of the indexers like subsquid, subscan, etc.

@@ -223,6 +224,857 @@ pub mod pallet {
pub state: CollatorStatus,
}

#[derive(Clone, Default, Encode, Decode, RuntimeDebug, TypeInfo)]
/// Type for top and bottom delegation storage item
pub struct Delegations<AccountId, Balance> {
Copy link
Contributor

Choose a reason for hiding this comment

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

I find this struct (or its impl) a bit confusing. It seems to be either a top list or a bottom list depending on how it's called. Maybe making separate types for the two would help.

Copy link
Contributor Author

@4meta5 4meta5 Jan 4, 2022

Choose a reason for hiding this comment

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

Why create separate types if they require the same functionality? Then I have to define the same methods on each struct or write functions that work on either type.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

They are already different storage items with different names TopDelegations and BottomDelegations. That should alleviate your concern.

Copy link
Contributor

Choose a reason for hiding this comment

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

Initially I didn't realize that we are getting rid of the reverse-sorted bottom delegations (right?) which makes sense, I like that change. Given that, having different subclasses seems unnecessary.

My main concern with the design is that, as a "layer of abstraction," this partially bleeds the details of the pallet itself.

Practically speaking, this struct could be used inconsistently by its callers as both a bottom and top. insert_sorted_greatest_to_least makes no assumptions about capacity (it's up to the caller to do that in that case), yet 'top_capacity' and 'bottom_capacity' both make assumptions about capacity. This is especially tricky because both of the capacity functions test equality ('==' rather than '>='). So if we ever accidentally go past our bounds, the struct will continually tell us that we are CapacityStatus::Partial, which would probably trigger the caller to keep inserting more delegations.

In terms of making this struct "difficult to use incorrectly," I think we could simply add the capacity itself to the struct. This would have some advantages:

  • top_capacity and bottom_capacity collapse into one fn
  • this capacity fn can convert to >= (that could happen regardless, I suppose)
  • insert_sorted_greatest_to_least can do proper bounds checking
  • unit testing becomes trivial and is fully decoupled from the pallet itself
  • a resize fn could be added (again, that could probably happen regardless).

Potentially, the capacity could be generic, although that would have implications on changing things at runtime.

Copy link
Contributor Author

@4meta5 4meta5 Jan 10, 2022

Choose a reason for hiding this comment

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

Initially I didn't realize that we are getting rid of the reverse-sorted bottom delegations (right?) which makes sense, I like that change. Given that, having different subclasses seems unnecessary.

Yes, exactly

Practically speaking, this struct could be used inconsistently by its callers as both a bottom and top. insert_sorted_greatest_to_least makes no assumptions about capacity (it's up to the caller to do that in that case), yet 'top_capacity' and 'bottom_capacity' both make assumptions about capacity.

Yes, but this is always enforced by the caller in the code prior to the call. Moreover, there is a capacity check in each of the caller functions that checks capacity.

In terms of making this struct "difficult to use incorrectly," I think we could simply add the capacity itself to the struct.

There is no need to do this because the Config::MaxTopDelegationsPerCandidate and Config::MaxTopDelegationsPerCandidate are constants so there is no cost to getting them. Inside the methods on CandidateMetadata that edit the delegations, we get these constants and check capacity to ensure correctness. Please audit those methods on CandidateMetadata and the use of the constants inside of them to check capacity.

Comment on lines -2101 to +2991
<CandidateState<T>>::insert(&collator, state);
Self::deposit_event(Event::CandidateBackOnline(
<Round<T>>::get().current,
collator,
));
<CandidateInfo<T>>::insert(&collator, state);
Self::deposit_event(Event::CandidateBackOnline(collator));
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is a small optimization that was unrelated to this PR but included anyway. There is no need to emit round number in event.

@@ -387,13 +389,13 @@ benchmarks! {
)?;
} verify {
assert!(
Pallet::<T>::candidate_state(&caller).unwrap().request.is_none()
Pallet::<T>::candidate_info(&caller).unwrap().request.is_none()
);
}

delegate {
Copy link
Contributor

Choose a reason for hiding this comment

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

Per our conversation this morning:

delegate() is now either cheap (doesn't touch bottom delegations) or expensive (it does). We should do some measurement of this, but assuming it's a drastic difference, we have a few options:

  1. Make two extrinsics (probably a bad option)
  2. Add a hint like we have elsewhere. I'm not quite sure that this will work well with benchmarking, however. We may at least need to create multiple benchmarks for this to work.
  3. Assume worst-case for weight charging and refund. This is unprecedented in our codebase, so it might be a worthwhile experiment in any case.

Copy link
Contributor Author

@4meta5 4meta5 Jan 10, 2022

Choose a reason for hiding this comment

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

delegate() is now either cheap (doesn't touch bottom delegations) or expensive (it does).

No, it is cheap when it only touches TOP XOR BOTTOM. There are cases when it touches the bottom and not top which are just as cheap as if it only touched the top (assuming they have the same size which is an assumption which could be challenged). So the weight hint would need to represent this.

From Elois, we should add a bool weight hint that represents whether or not it touches the top && bottom. I think it needs to represent whether it touches the top && bottom as well as the total delegations it searched before insertion i.e. if it just inserts into top, then weight hint also uses length of top (or at least the max)

The hard part is that if it pushes the lowest bottom to the top, then it touches the bottom as well. So the implementation would need to cover that edge case.

I'll think about it, but it may be better suited as a follow up.

@4meta5
Copy link
Contributor Author

4meta5 commented Jan 16, 2022

I've tested enough to feel confident marking this as ready for review. I'm still writing more tests though and then updating the benchmarking -> weights.

TODO:

  • test all CandidateMetadata fields are updated correctly whenever they ought to change
  • test migration correctly revokes all bottom delegations that don't fit into new bottom bounded delegations
  • update benchmarking -> weights

@4meta5 4meta5 mentioned this pull request Jan 24, 2022
32 tasks
Comment on lines 151 to 157
fn execute_leave_candidates(x: u32) -> Weight {
(0 as Weight) // Standard Error: 8_000
.saturating_add((27_557_000 as Weight).saturating_mul(x as Weight))
.saturating_add(T::DbWeight::get().reads(6 as Weight))
.saturating_add(T::DbWeight::get().reads((2 as Weight).saturating_mul(x as Weight)))
.saturating_add(T::DbWeight::get().writes(3 as Weight))
.saturating_add(T::DbWeight::get().writes((2 as Weight).saturating_mul(x as Weight)))
Copy link
Contributor

Choose a reason for hiding this comment

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

This change makes it quite expensive to leave if you have a lot of delegators (something you don't directly control), but that seems perfectly reasonable; you're affecting a lot of other people.

One thing we should keep in mind is that there is some point where this is so "heavy" that it can't be executed in one block.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This was actually not yet changed in this PR. It was in #1207 , so I do need to rerun this and a few other benchmarks.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It became even more expensive

Copy link
Contributor

@notlesh notlesh left a comment

Choose a reason for hiding this comment

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

I left a lot of comments, but most were about unsafe math and there were a few minor questions / suggestions. I had at least one concerning question though.

pallets/parachain-staking/src/lib.rs Show resolved Hide resolved
pallets/parachain-staking/src/lib.rs Outdated Show resolved Hide resolved
pallets/parachain-staking/src/lib.rs Outdated Show resolved Hide resolved
pallets/parachain-staking/src/lib.rs Outdated Show resolved Hide resolved
pallets/parachain-staking/src/lib.rs Outdated Show resolved Hide resolved
pallets/parachain-staking/src/lib.rs Outdated Show resolved Hide resolved
Comment on lines 977 to 980
.delegations
.clone()
.into_iter()
.filter_map(|d| {
Copy link
Contributor

Choose a reason for hiding this comment

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

This pattern is repeated a lot, it could abstracted

let highest_bottom_delegation = bottom_delegations.delegations.remove(0);
bottom_delegations.total -= highest_bottom_delegation.amount;
// insert highest bottom into top
top_delegations.insert_sorted_greatest_to_least(highest_bottom_delegation);
Copy link
Contributor

Choose a reason for hiding this comment

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

Here you could probably have taken note of the index from which you removed earlier to avoid a sorted insert

Copy link
Contributor Author

Choose a reason for hiding this comment

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

it is not guaranteed to be in the same position as the one removed

// insert highest bottom into top
top_delegations.insert_sorted_greatest_to_least(highest_bottom_delegation);
// insert previous top into bottom
bottom_delegations.insert_sorted_greatest_to_least(delegation);
Copy link
Contributor

Choose a reason for hiding this comment

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

This should always be going into the top, right?

In fact, even if there is a tie for the top, you want to make sure it goes at the beginning of the identical bonds -- not the end (otherwise we fail to preserve insertion order fairness) -- right?

Copy link
Contributor Author

@4meta5 4meta5 Jan 25, 2022

Choose a reason for hiding this comment

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

The highest bottom is going into the top and the changed delegation is going into the bottom.

The condition for this branch to execute is bond_after_less_than_highest_bottom and it is a strict less than so we definitely want to insert the decreased top delegation into the bottom and pop the highest bottom into the top.

pallets/parachain-staking/src/lib.rs Outdated Show resolved Hide resolved
pallets/parachain-staking/src/benchmarks.rs Show resolved Hide resolved
pallets/parachain-staking/src/lib.rs Outdated Show resolved Hide resolved
pallets/parachain-staking/src/lib.rs Show resolved Hide resolved
pallets/parachain-staking/src/lib.rs Outdated Show resolved Hide resolved
pallets/parachain-staking/src/lib.rs Outdated Show resolved Hide resolved
pallets/parachain-staking/src/lib.rs Outdated Show resolved Hide resolved
pallets/parachain-staking/src/lib.rs Outdated Show resolved Hide resolved
pallets/parachain-staking/src/lib.rs Outdated Show resolved Hide resolved
pallets/parachain-staking/src/lib.rs Outdated Show resolved Hide resolved
@4meta5 4meta5 requested a review from girazoki January 26, 2022 16:35
Copy link
Collaborator

@girazoki girazoki left a comment

Choose a reason for hiding this comment

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

My main concerns have been solved so its an approval for me.

@4meta5 4meta5 added A8-mergeoncegreen Pull request is reviewed well. and removed A0-pleasereview Pull request needs code review. labels Jan 26, 2022
@4meta5 4meta5 merged commit 5e62387 into master Jan 26, 2022
@4meta5 4meta5 deleted the amar-staking-split-candidate-state-for-pov-optimization branch January 26, 2022 18:50
@notlesh notlesh added D1-audited👍 PR contains changes to fund-managing logic that has been properly reviewed and externally audited and removed D9-needsaudit👮 PR contains changes to fund-managing logic that should be properly reviewed and externally audited labels Feb 9, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A8-mergeoncegreen Pull request is reviewed well. B7-runtimenoteworthy Changes should be noted in any runtime-upgrade release notes D1-audited👍 PR contains changes to fund-managing logic that has been properly reviewed and externally audited
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants