Skip to content

Pack Electra attestations by marginal proposer reward - #17416

Open
potuz wants to merge 2 commits into
developfrom
potuz/marginal-reward-attestation-packing
Open

Pack Electra attestations by marginal proposer reward#17416
potuz wants to merge 2 commits into
developfrom
potuz/marginal-reward-attestation-packing

Conversation

@potuz

@potuz potuz commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

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.

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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
		}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in d9e005e

if missing == 0 {
continue
}
baseReward, err := altair.BaseRewardWithTotalBalance(st, primitives.ValidatorIndex(index), totalBalance)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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>
@potuz
potuz requested a review from a team as a code owner August 29, 2026 12:27
maxLayers := int(params.BeaconConfig().MaxAttestationsElectra) // lint:ignore uintcast -- always small.
idx := 0
for {
for ; idx < maxLayers; idx++ {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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(&ethpb.AttestationData{})

	newAggregate := func(positions ...uint64) *ethpb.AttestationElectra {
		bits := bitfield.NewBitlist(uint64(len(committee)))
		for _, position := range positions {
			bits.SetBitAt(position, true)
		}
		return &ethpb.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())
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants