-
Notifications
You must be signed in to change notification settings - Fork 337
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
[Staking] Split candidate state for PoV optimization #1117
Conversation
…ntaining Total storage item and im unsure we need it
…increase and decrease delegations
/// Candidate | ||
CandidateWentOffline(T::AccountId), | ||
/// Candidate | ||
CandidateBackOnline(T::AccountId), |
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.
Why remove RoundIndex
?
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.
It is an unnecessary GET, we will always know the round in which the event is emitted because we know the block it happened.
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.
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?
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.
As @librelois pointed out, this change reduces blockspace slightly. I no longer have a strong opinion about this...
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.
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> { |
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.
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.
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.
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.
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.
They are already different storage items with different names TopDelegations
and BottomDelegations
. That should alleviate your concern.
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.
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
andbottom_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.
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.
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.
<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)); |
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.
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 { |
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.
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:
- Make two extrinsics (probably a bad option)
- 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.
- Assume worst-case for weight charging and refund. This is unprecedented in our codebase, so it might be a worthwhile experiment in any case.
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.
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.
…gations with same amount
…he configured max bottom delegations per candidate
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:
|
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))) |
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.
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.
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.
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.
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.
It became even more expensive
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.
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
Outdated
.delegations | ||
.clone() | ||
.into_iter() | ||
.filter_map(|d| { |
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.
This pattern is repeated a lot, it could abstracted
pallets/parachain-staking/src/lib.rs
Outdated
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); |
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.
Here you could probably have taken note of the index from which you removed earlier to avoid a sorted insert
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.
it is not guaranteed to be in the same position as the one removed
pallets/parachain-staking/src/lib.rs
Outdated
// 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); |
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.
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?
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.
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.
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.
My main concerns have been solved so its an approval for me.
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.
Associated Constant Changes
Storage and Type Changes
CandidateState(key: AccountId, value: CollatorCandidate) is split into 3 maps:
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.
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?
#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 toexecute_leave_candidates
to make the cost more accurately be proportional to the number of delegations for the candidate.