-
Notifications
You must be signed in to change notification settings - Fork 710
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
Collation fetching fairness #4880
base: master
Are you sure you want to change the base?
Conversation
polkadot/node/network/collator-protocol/src/validator_side/collation.rs
Outdated
Show resolved
Hide resolved
polkadot/node/network/collator-protocol/src/validator_side/collation.rs
Outdated
Show resolved
Hide resolved
c7f24aa
to
0f28aa8
Compare
polkadot/node/network/collator-protocol/src/validator_side/collation.rs
Outdated
Show resolved
Hide resolved
polkadot/node/network/collator-protocol/src/validator_side/mod.rs
Outdated
Show resolved
Hide resolved
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.
Nice job 🚀 ! The code is a lot more intuitive now. We have now some good foundation for what's next with removing AsyncBackingParams altogether and several ideas for further refactoring.
Only some small things left from my side
polkadot/node/network/collator-protocol/src/validator_side/claim_queue_state.rs
Outdated
Show resolved
Hide resolved
polkadot/node/network/collator-protocol/src/validator_side/claim_queue_state.rs
Show resolved
Hide resolved
let unfulfilled_entries = claim_queue_states | ||
.iter_mut() | ||
.map(|cq| cq.unclaimed_at(relay_parent)) | ||
.max_by(|a, b| a.len().cmp(&b.len())) |
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.
not very clear on the purpose of this max_by
. the unfulfilled claims on different forks can look quite different 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.
First to clear the problem I am trying to solve here.
unfulfilled_claim_queue_entries
is used by get_next_collation_to_fetch
and it's purpose is to produce a Vec
of ParaID
s which can be fetched, ordered by urgency. So a result could look like [A A B]
which means that we can accept collations from both paras A and B but A is strongly preferred in this case.
As you mentioned the claims on different forks can be quite different and from all forks (all claim_queue_states) we need to produce a single Vec
with ParaId
s ordered by priority. Hope we are on the same page so far.
Now why max_by
?
No matter how many forks we have got, they should have the same claims because the claim queue should be the same for them. Let's say we have got two forks - X1 and X2. They should have the same claim queue state ([A A B]
). The difference might be that at X1 first spot might already be claimed while in X2 it might not be claimed. So in this case the unfulfilled entries for X1 will be [AB]
while at X2 - [AAB]
.
For this reason I naively decided to go for the longest unfulfilled entries Vec since it might be the most urgent one to satisfy. Most urgent because if it is the longest one it's first slot is PROBABLY not satisfied. Of course there are a lot of corner cases here. E.g. the second slot might have been claimed at X1 which doesn't make it less urgent than X2.
We can be smarter here and examine what's claimed at each fork and make a more informed decision but I don't think it's worth the complication.
Also it's worth adding this information as a comment in the code because it's not obvious.
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.
(added a comment)
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.
Isn't that essentially incentivising forks? We will attempt to fetch for the fork which the most unfulfilled claims.
Co-authored-by: Alin Dima <[email protected]>
All GitHub workflows were cancelled due to failure one of the required jobs. |
"Checking seconding limit", | ||
); | ||
|
||
for path in paths { |
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've added a todo in #6679 to keep ClaimqueueState
in State
and have it ready for use when we needed instead of rebuilding it each time.
// Returns the claim queue without fetched or pending advertisement. The resulting `Vec` keeps the | ||
// order in the claim queue so the earlier an element is located in the `Vec` the higher its | ||
// priority is. | ||
fn unfulfilled_claim_queue_entries(relay_parent: &Hash, state: &State) -> Result<Vec<ParaId>> { |
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.
Same here: I've added a todo in #6679 to keep ClaimqueueState
in State
and have it ready for use when we needed instead of rebuilding it each time.
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.
Very nice and clear PR description and a much welcome refactor, GJ
I added some comments and questions, but nothing major.
Slot in our claim queue already claimed at future relay parent
CQ @ rp X: [A A A]
Advertisements at X+1 for para A: 1
Advertisements at X+2 for para A: 1
Outcome: at rp X we can accept only 1 advertisement since the slots in our relay parents were already claimed.
For my own curiosity but when would this happen? What order of collator adverts leads to this scenario?
(X->X+2 means collation anchored at rp X but claiming the slot at X+2)
- Collator A builds collations for X->X, X->X+1, X->X+2
- Collator A in time sends the collation X->X to next Collator B
- Collator A then crashes and does not send X->X+1,X->X+2 to Collator B
- Collator A becaue he crashed also does not send his collations to validators
- Collator B since he has not seen X->X+1 or X->X+2, builds his own X+1->X+1, but is lazy so he does not make X+1->X+2
- Collator B sends X+1->X+1 to Collator C and to validators
- Validators fetch X+1->X+1 even though they have not seen any collations for slot X
- Collator C also has not seen X->X+2 but he seen X+1->X+1 so he builds X+2->X+2
- Collator C sends X+2->X+2 to validators
- Validators fetch X+2->X+2
- Collator A awakens from his crash and starts sending his collations X->X, X->X+1, X->X+2 to everyone
- Other collators see it but it is too late already, they built their own for X+1 and X+2
- Validators finally receive X->X but discard X->X+1 and X->X+2 since they already fetched collations X+1->X+1 and X+2->X+2
Is there a simpler scenario? 🤔
/// should be built/fetched/accepted (depending on the context) at each block. | ||
/// | ||
/// Since the claim queue peeks into the future blocks there is a relation between the claim queue | ||
/// state between the current block and the future blocks. Let's see an example: |
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.
/// state between the current block and the future blocks. Let's see an example: | |
/// state between the current block and the future blocks. | |
/// Let's see an example with 2 co-scheduled parachains: |
self.future_blocks.push_back(ClaimInfo { | ||
hash: None, | ||
claim: Some(*expected_claim), | ||
claim_queue_len: 1, |
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 do we set claim_queue_len: 1 for future blocks? How should this valie be interpreted?
For instance ClaimQueueState:
block_state: A
future_blocks: B, C
I'd expect A to have CQ len 3, B 2 and C 1.
} | ||
|
||
#[cfg(test)] | ||
mod test { |
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.
Very nice and clear tests!
I think there is one more test type worth adding. Tests where no claim queue is provided on leaf activation or cases where claim queue provided is smaller than one seen before.
@@ -323,6 +329,205 @@ impl View { | |||
.as_ref() | |||
.map(|mins| mins.allowed_relay_parents_for(para_id, block_info.block_number)) | |||
} | |||
|
|||
/// Returns all paths from a leaf to the last block in state containing `relay_parent`. If no |
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.
/// Returns all paths from a leaf to the last block in state containing `relay_parent`. If no | |
/// Returns all paths from each leaf to the last block in state containing `relay_parent`. If no |
return vec![] | ||
} | ||
|
||
// Find all paths from each outer leaf to `relay_parent`. |
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.
What is an outer leaf? Is it different than a leaf?
} | ||
} | ||
|
||
if !cq_state.can_claim_at(relay_parent, ¶_id) { |
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 piece essentially ensures that there needs to be place in ALL forks/paths. I think this behaviour should be documented above the func ensure_seconding_limit_is_respected
.
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.
Also is it ever possible that on one path the order of paraid claims is different than the other path? i.e.:
0 1 2
A ----> B -> B
\
-> A -> A
If that would ever happen it can cause a deadlock where we ignore all advertisements. All adverts for para B would be rejected because they cannot be claimed in bottom fork while all A adverts rejected because they don't fit into upper fork.
As far as I know this should never happen but just want to make absolutely sure.
if per_relay_parent.collations.is_seconded_limit_reached(relay_parent_mode) { | ||
return Err(AdvertisementError::SecondedLimitReached) | ||
} | ||
ensure_seconding_limit_is_respected(&relay_parent, para_id, state)?; |
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.
ensure_seconding_limit_is_respected
checks if the limit is respected AKA this will error out if it turns out we cannot second the advertised candidate (optimistically since pending are counted).
Few lines below we check can_second
which also has a logic connected to checking if we are able to second that candidate. I haven't fully looked into it but aren't they effectively doing the same?
In what cases will
ensure_seconding_limit_is_respected
pass with no error
while
can_second
result in an error?
let unfulfilled_entries = claim_queue_states | ||
.iter_mut() | ||
.map(|cq| cq.unclaimed_at(relay_parent)) | ||
.max_by(|a, b| a.len().cmp(&b.len())) |
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.
Isn't that essentially incentivising forks? We will attempt to fetch for the fork which the most unfulfilled claims.
//! validator counts all advertisements within its view not just at the relay parent. | ||
//! 3. If the advertisement was accepted, it's queued for fetch (per relay parent). | ||
//! 4. Once it's requested, the collation is said to be Pending. | ||
//! 5. Pending collation becomes Fetched once received, we send it to backing for validation. |
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.
Don't we treat it as Pending at this step as well?
Related to #1797
The problem
When fetching collations in collator protocol/validator side we need to ensure that each parachain has got a fair core time share depending on its assignments in the claim queue. This means that the number of collations fetched per parachain should ideally be equal to (but definitely not bigger than) the number of claims for the particular parachain in the claim queue.
Why the current implementation is not good enough
The current implementation doesn't guarantee such fairness. For each relay parent there is a
waiting_queue
(PerRelayParent -> Collations -> waiting_queue) which holds any unfetched collations advertised to the validator. The collations are fetched on first in first out principle which means that if two parachains share a core and one of the parachains is more aggressive it might starve the second parachain. How? At each relay parent up tomax_candidate_depth
candidates are accepted (enforced infn is_seconded_limit_reached
) so if one of the parachains is quick enough to fill in the queue with its advertisements the validator will never fetch anything from the rest of the parachains despite they are scheduled. This doesn't mean that the aggressive parachain will occupy all the core time (this is guaranteed by the runtime) but it will deny the rest of the parachains sharing the same core to have collations backed.How to fix it
The solution I am proposing is to limit fetches and advertisements based on the state of the claim queue. At each relay parent the claim queue for the core assigned to the validator is fetched. For each parachain a fetch limit is calculated (equal to the number of entries in the claim queue). Advertisements are not fetched for a parachain which has exceeded its claims in the claim queue. This solves the problem with aggressive parachains advertising too much collations.
The second part is in collation fetching logic. The collator will keep track on which collations it has fetched so far. When a new collation needs to be fetched instead of popping the first entry from the
waiting_queue
the validator examines the claim queue and looks for the earliest claim which hasn't got a corresponding fetch. This way the collator will always try to prioritise the most urgent entries.How the 'fair share of coretime' for each parachain is determined?
Thanks to async backing we can accept more than one candidate per relay parent (with some constraints). We also have got the claim queue which gives us a hint which parachain will be scheduled next on each core. So thanks to the claim queue we can determine the maximum number of claims per parachain.
For example the claim queue is [A A A] at relay parent X so we know that at relay parent X we can accept three candidates for parachain A. There are two things to consider though:
There are a few cases worth considering:
CQ @ rp X: [A A A]
Advertisements at X-1 for para A: 2
Advertisements at X-2 for para A: 2
Outcome - at rp X we can accept only 1 advertisement since our slots were already claimed.
CQ @ rp X: [A A A]
Advertisements at X+1 for para A: 1
Advertisements at X+2 for para A: 1
Outcome: at rp X we can accept only 1 advertisement since the slots in our relay parents were already claimed.
The situation becomes more complicated with multiple leaves (forks). Imagine we have got a fork at rp X:
Now when we examine the claim queue at RP X we need to consider both forks. This means that accepting a candidate at X means that we should have a slot for it in BOTH leaves. If for example there are three candidates accepted at rp X+1' we can't accept any candidates at rp X because there will be no slot for it in one of the leaves.
How the claims are counted
There are two solutions for counting the claims at relay parent X:
Solution 1 is hard to implement with forks. There are too many variants to keep track of (different state for each leaf) and at the same time we might never need to use them. So I decided to go with option 2 - building claim queue state on the fly.
To achieve this I've extended
View
from backing_implicit_view to keep track of the outer leaves. I've also added a method which accepts a relay parent and return all paths from an outer leaf to it. Let's call itpaths_to_relay_parent
.So how the counting works for relay parent X? First we examine the number of seconded and pending advertisements (more on pending in a second) from relay parent X to relay parent X-N (inclusive) where N is the length of the claim queue. Then we use
paths_to_relay_parent
to obtain all paths from outer leaves to relay parent X. We calculate the claims at relay parents X+1 to X+N (inclusive) for each leaf and get the maximum value. This way we guarantee that the candidate at rp X can be included in each leaf. This is the state of the claim queue which we use to decide if we can fetch one more advertisement at rp X or not.What is a pending advertisement
I mentioned that we count seconded and pending advertisements at relay parent X. A pending advertisement is:
Any of these is considered a 'pending fetch' and a slot for it is kept. All of them are already tracked in
State
.