Pack Electra attestations by marginal proposer reward - #17416
Conversation
On devnets Prysm consistently packed 8 attestations per block while every other client packed 1-2. Eight is exactly MAX_ATTESTATIONS_ELECTRA, so the proposer was saturating the cap with aggregates that earned it nothing. Three things combined. onChainAggregates emits one candidate per attestation data per layer, layer 0 from each committee's best aggregate, layer 1 from each committee's second best, and so on unbounded; layers past the first cover largely the same validators. dedup cannot remove them because it buckets by an ID that includes the committee bits, and layer-k aggregates usually have a different committee-bit set, so they were never compared. Finally sortOnChainAggregates scored every candidate with GetProposerRewardNumerator against the pre-block state. That correctly ignores votes already on chain, but it has no idea which validators an earlier attestation in the same block already credited, so every near duplicate scored near-maximum and limitToMaxAttestations took the top 8. Nothing dropped zero-value candidates either, which is how attestations already included in an imported block got packed again. Replace the sort and truncate with selectByMarginalReward, a lazy greedy over a mutable shadow of the state's participation bits. Each round takes the candidate adding the most new (validator, flag) pairs, marks them covered, and stops as soon as the best remaining candidate adds nothing. Committees are resolved once per slot via BeaconCommittees and flags once per attestation data, so re-scoring is arithmetic over cached slices. aggregation.MaxCover cannot be reused here: it requires uniform bitlist lengths and Electra on-chain aggregates span a variable number of committees, which is why it was dropped for Electra in 961d8e1. The right currency is (validator, flag), not bit position. Also cap onChainAggregates at MaxAttestationsElectra layers, since no further layer can ever be selected. Benchmark_packAttestations_Electra panicked on develop: it overrides to the mainnet preset inside a minimal-tagged file, so SlotsPerHistoricalRoot does not fit the compiled-in field parameters. It now uses the minimal preset at 65536 validators, giving four full 2048-validator committees. Before 7333458 ns/op, after 7334427 ns/op. TestPackAttestations_ElectraOnChainAggregates asserts order-independent invariants because the packed count depends on tie-breaking in aggregate profitability, which follows map iteration order. Under the old selection its packed attestation #3 onward earns zero reward, so it does guard the fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| var numerator uint64 | ||
| for _, index := range c.attestingIndices { | ||
| if index >= uint64(len(participation)) { | ||
| return 0, errors.Errorf("index %d exceeds participation length %d", index, len(participation)) |
There was a problem hiding this comment.
before these kinds of errors just meant 0, but i think now it bubbles them up
old
r, err := electra.GetProposerRewardNumerator(ctx, st, att, totalBalance)
if err != nil {
log.WithError(err).Debug("Failed to get proposer reward numerator")
return 0
}
| if missing == 0 { | ||
| continue | ||
| } | ||
| baseReward, err := altair.BaseRewardWithTotalBalance(st, primitives.ValidatorIndex(index), totalBalance) |
There was a problem hiding this comment.
because we are looping here
for round := 1; uint64(len(selected)) < limit && h.Len() > 0; round++ {
var best *attCandidate
for h.Len() > 0 {
c := heap.Pop(&h).(*attCandidate)
if c.scoredRound != round {
c.score, err = c.marginalReward(st, participationFor(c), totalBalance)
i think this is getting calculated more times than necessary. not sure how much of an issue this is
There was a problem hiding this comment.
We called BaseRewardWithTotalBalance per attesting index on every re-score, reworked it by getting all effective balances and total active balance outside of the loop
Review feedback on the marginal reward packing. A base reward only depends on the validator's effective balance and on the total active balance, neither of which selection changes, yet marginalReward called BaseRewardWithTotalBalance for every attesting index on every re-score. The lazy greedy re-scores a candidate each round it is popped, so a single validator was read out of the state up to MAX_ATTESTATIONS_ELECTRA times per candidate. Resolve base rewards once in newAttCandidates, cached across candidates by validator index, and hand each candidate a slice parallel to its attesting indices. Re-scoring is now the arithmetic over cached slices the previous commit message claimed it was. Indices the participation bits already credit for every flag of their attestation are dropped while resolving. Selection only ever sets participation bits, so such an index can never earn a marginal reward later and marking it covered is a no-op. Candidates left with no indices, which is what an attestation from an already-imported block looks like, never reach the heap. The other half of the feedback: marginalReward and markCovered returned errors that selectByMarginalReward propagated to packAttestations, which fails the whole block build. The old sortOnChainAggregates swallowed the equivalent errors from GetProposerRewardNumerator and scored the attestation 0, so a single bad attestation in the pool went from costing one attestation to costing the proposal. Validating the attesting indices while resolving base rewards lets both drop their error returns entirely, and the remaining per-attestation failures in newAttCandidates, hashing the data and resolving committees, now log and skip like the ones around them. selectByMarginalReward only errors on failures that say nothing about an individual attestation. Benchmark_packAttestations_Electra converges after a couple of rounds and is dominated by aggregation and signature verification, so it does not move: 6355531 ns/op before, 6329223 ns/op after. Benchmark_selectByMarginalReward covers what it misses, 64 candidates with heavily overlapping votes and close scores, which is the case that makes the loop pop and re-score many candidates per round. Before 3660357 ns/op, after 1578391 ns/op. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| maxLayers := int(params.BeaconConfig().MaxAttestationsElectra) // lint:ignore uintcast -- always small. | ||
| idx := 0 | ||
| for { | ||
| for ; idx < maxLayers; idx++ { |
There was a problem hiding this comment.
nit since idx is declared outside why not just do idx < maxLayers and do idx ++ at the end
|
|
||
| // Mirrors electra.GetProposerRewardNumerator, but scored against participation as the block | ||
| // being built would leave it rather than against the untouched pre-block state. | ||
| func (c *attCandidate) marginalReward() uint64 { |
There was a problem hiding this comment.
Could we link the consensus-spec process_attestation here? This mirrors proposer_reward_numerator; renaming it to marginalRewardNumerator might also clarify that the denominator is intentionally omitted.
| } | ||
|
|
||
| idx++ | ||
| if idx == maxLayers { |
There was a problem hiding this comment.
ai keeps flagging this as a bug to remove this and the cap above on max layers.
here is an associated test
// Layer generation must feed every pool aggregate to reward-aware selection; capping layers at
// the block limit would let max-cover's bit-count order discard the only profitable aggregate.
func TestOnChainAggregates_KeepsLayersForMarginalScoring(t *testing.T) {
ctx := t.Context()
params.SetupTestConfigCleanup(t)
cfg := params.BeaconConfig().Copy()
cfg.ElectraForkEpoch = 1
params.OverrideBeaconConfig(cfg)
key, err := blst.RandKey()
require.NoError(t, err)
sig := key.Sign([]byte{'X'})
st, _ := util.DeterministicGenesisStateElectra(t, 1024)
require.NoError(t, st.SetSlot(params.BeaconConfig().SlotsPerEpoch+1))
committees, err := helpers.BeaconCommittees(ctx, st, 0)
require.NoError(t, err)
committee := committees[0]
require.Equal(t, true, len(committee) >= 18) // The largest aggregate below sets bit 17.
// The state is one epoch past these slot-0 attestations, so they target the previous epoch.
// Mark every committee vote as already counted except position 9.
participation, err := st.PreviousEpochParticipation()
require.NoError(t, err)
for position, validatorIndex := range committee {
if position != 9 {
participation[validatorIndex] = 0b111
}
}
require.NoError(t, st.SetPreviousParticipationBits(participation))
committeeBits := primitives.NewAttestationCommitteeBits()
committeeBits.SetBitAt(0, true)
data := util.HydrateAttestationData(ðpb.AttestationData{})
newAggregate := func(positions ...uint64) *ethpb.AttestationElectra {
bits := bitfield.NewBitlist(uint64(len(committee)))
for _, position := range positions {
bits.SetBitAt(position, true)
}
return ðpb.AttestationElectra{
AggregationBits: bits,
CommitteeBits: committeeBits,
Data: data,
Signature: sig.Marshal(),
}
}
// Every aggregate shares bit 0 and owns bits no other has, so none is a subset of another
// and max-cover keeps all nine, sorting these eight 3-bit aggregates ahead of the 2-bit one.
networkAggregates := make([]ethpb.Att, 0, 9)
for i := range uint64(8) {
networkAggregates = append(networkAggregates, newAggregate(0, i+1, i+10))
}
// The ninth layer is smaller, but validator position 9 is the only uncovered vote.
networkAggregates = append(networkAggregates, newAggregate(0, 9))
candidates, err := onChainAggregates(map[ethpbattestation.Id][]ethpb.Att{{}: networkAggregates})
require.NoError(t, err)
// All nine layers must survive generation; enforcing the block limit is selection's job.
require.Equal(t, 9, len(candidates))
// The eight covered aggregates score zero, so only the fresh-vote aggregate is packed.
selected, err := candidates.selectByMarginalReward(ctx, st, params.BeaconConfig().MaxAttestationsElectra)
require.NoError(t, err)
require.Equal(t, 1, len(selected))
require.DeepEqual(t, networkAggregates[8].GetAggregationBits(), selected[0].GetAggregationBits())
}
Replace attestation packing by choosing the most profitable attestations instead of the ones with most bits.
There's a reason why Prysm was packing 8 attestations on devnets instead of just 1 or 2 like other clients, we put many attestations that are worthless since the intersection with others are high.