diff --git a/yarn-project/epoch-cache/src/epoch_cache.ts b/yarn-project/epoch-cache/src/epoch_cache.ts index f5e52fe46c04..7c9ef8b2051c 100644 --- a/yarn-project/epoch-cache/src/epoch_cache.ts +++ b/yarn-project/epoch-cache/src/epoch_cache.ts @@ -49,6 +49,7 @@ export interface EpochCacheInterface { /** Returns epoch/slot info for the next L1 slot with pipeline offset applied. */ getTargetEpochAndSlotInNextL1Slot(): EpochAndSlot & { nowSeconds: bigint }; isProposerPipeliningEnabled(): boolean; + pipeliningOffset(): number; isEscapeHatchOpen(epoch: EpochNumber): Promise; isEscapeHatchOpenAtSlot(slot: SlotTag): Promise; getProposerIndexEncoding(epoch: EpochNumber, slot: SlotNumber, seed: bigint): `0x${string}`; @@ -167,6 +168,10 @@ export class EpochCache implements EpochCacheInterface { return this.enableProposerPipelining; } + public pipeliningOffset(): number { + return this.enableProposerPipelining ? PROPOSER_PIPELINING_SLOT_OFFSET : 0; + } + public getSlotNow(): SlotNumber { return this.getEpochAndSlotNow().slot; } diff --git a/yarn-project/epoch-cache/src/test/test_epoch_cache.ts b/yarn-project/epoch-cache/src/test/test_epoch_cache.ts index b9e50a06128f..8900ebac9537 100644 --- a/yarn-project/epoch-cache/src/test/test_epoch_cache.ts +++ b/yarn-project/epoch-cache/src/test/test_epoch_cache.ts @@ -147,6 +147,10 @@ export class TestEpochCache implements EpochCacheInterface { return this.proposerPipeliningEnabled; } + pipeliningOffset(): number { + return this.proposerPipeliningEnabled ? PROPOSER_PIPELINING_SLOT_OFFSET : 0; + } + getEpochAndSlotNow(): EpochAndSlot & { nowMs: bigint } { const epochNow = getEpochAtSlot(this.currentSlot, this.l1Constants); const ts = getTimestampRangeForEpoch(epochNow, this.l1Constants)[0]; diff --git a/yarn-project/p2p/src/test-helpers/testbench-utils.ts b/yarn-project/p2p/src/test-helpers/testbench-utils.ts index 86903280deed..c8d142136278 100644 --- a/yarn-project/p2p/src/test-helpers/testbench-utils.ts +++ b/yarn-project/p2p/src/test-helpers/testbench-utils.ts @@ -287,6 +287,7 @@ export function createMockEpochCache(): EpochCacheInterface { nowMs: 0n, }), isProposerPipeliningEnabled: () => false, + pipeliningOffset: () => 0, computeProposerIndex: () => 0n, getCurrentAndNextSlot: () => ({ currentSlot: SlotNumber.ZERO, nextSlot: SlotNumber.ZERO }), getTargetAndNextSlot: () => ({ targetSlot: SlotNumber.ZERO, nextSlot: SlotNumber.ZERO }), diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index 553c73127851..a7c59bf1e04e 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -123,6 +123,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter TypedEventEmitter ({ proposed: t.proposed, checkpointed: t.checkpointed, pendingCheckpoint: t.pendingCheckpoint })), this.p2pClient.getStatus().then(p2p => p2p.syncedToL2Block), - this.l1ToL2MessageSource.getL2Tips().then(t => t.proposed), + this.l1ToL2MessageSource.getL2Tips().then(t => ({ proposed: t.proposed, checkpointed: t.checkpointed })), this.l2BlockSource.getPendingChainValidationStatus(), this.l2BlockSource.getPendingCheckpoint(), ] as const); - const [worldState, l2Tips, p2p, l1ToL2MessageSource, pendingChainValidationStatus, pendingCheckpointData] = + const [worldState, l2Tips, p2p, l1ToL2MessageSourceTips, pendingChainValidationStatus, pendingCheckpointData] = syncedBlocks; // Handle zero as a special case, since the block hash won't match across services if we're changing the prefilled data for the genesis block, @@ -584,19 +585,25 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter { }); }); }); + + describe('pipelining mode', () => { + const BLOCK_DURATION_MS = 8000; + + it('allows more blocks per slot than non-pipelining with same config', () => { + const baseOpts = { + ethereumSlotDuration: ETHEREUM_SLOT_DURATION, + aztecSlotDuration: AZTEC_SLOT_DURATION, + l1PublishingTime: L1_PUBLISHING_TIME, + blockDurationMs: BLOCK_DURATION_MS, + enforce: ENFORCE_TIMETABLE, + }; + + const withoutPipelining = new SequencerTimetable({ ...baseOpts, pipelining: false }); + const withPipelining = new SequencerTimetable({ ...baseOpts, pipelining: true }); + + expect(withPipelining.maxNumberOfBlocks).toBeGreaterThan(withoutPipelining.maxNumberOfBlocks); + }); + + it('uses entire slot minus init and re-execution for block building', () => { + const tt = new SequencerTimetable({ + ethereumSlotDuration: ETHEREUM_SLOT_DURATION, + aztecSlotDuration: AZTEC_SLOT_DURATION, + l1PublishingTime: L1_PUBLISHING_TIME, + blockDurationMs: BLOCK_DURATION_MS, + enforce: ENFORCE_TIMETABLE, + pipelining: true, + }); + + const blockDuration = BLOCK_DURATION_MS / 1000; + // Reserves one blockDuration for validator re-execution, but no finalization time + const availableTime = AZTEC_SLOT_DURATION - tt.initializationOffset - blockDuration; + expect(tt.maxNumberOfBlocks).toBe(Math.floor(availableTime / blockDuration)); + }); + + it('has later initialize deadline than non-pipelining', () => { + const baseOpts = { + ethereumSlotDuration: ETHEREUM_SLOT_DURATION, + aztecSlotDuration: AZTEC_SLOT_DURATION, + l1PublishingTime: L1_PUBLISHING_TIME, + blockDurationMs: BLOCK_DURATION_MS, + enforce: ENFORCE_TIMETABLE, + }; + + const withoutPipelining = new SequencerTimetable({ ...baseOpts, pipelining: false }); + const withPipelining = new SequencerTimetable({ ...baseOpts, pipelining: true }); + + expect(withPipelining.initializeDeadline).toBeGreaterThan(withoutPipelining.initializeDeadline); + }); + + it('produces expected block count with test config', () => { + // Mimics e2e test config: ethereumSlotDuration=4, aztecSlotDuration=36, blockDuration=8s + const tt = new SequencerTimetable({ + ethereumSlotDuration: 4, + aztecSlotDuration: 36, + l1PublishingTime: 2, + p2pPropagationTime: 0.5, + blockDurationMs: 8000, + enforce: true, + pipelining: true, + }); + + // With pipelining and test config (ethereumSlotDuration < 8): + // init=0.5, reExec=8, available = 36 - 0.5 - 8 = 27.5, floor(27.5/8) = 3 + expect(tt.maxNumberOfBlocks).toBe(3); + }); + + it('produces more blocks with production config where finalization time is large', () => { + // With production-like config, the large finalization time means pipelining saves enough to gain blocks + const baseOpts = { + ethereumSlotDuration: ETHEREUM_SLOT_DURATION, + aztecSlotDuration: 120, + l1PublishingTime: L1_PUBLISHING_TIME, + blockDurationMs: BLOCK_DURATION_MS, + enforce: ENFORCE_TIMETABLE, + }; + + const withoutPipelining = new SequencerTimetable({ ...baseOpts, pipelining: false }); + const withPipelining = new SequencerTimetable({ ...baseOpts, pipelining: true }); + + // Finalization time (1 + 2*2 + 12 = 17s) > blockDuration, so pipelining gains at least one more block + expect(withPipelining.maxNumberOfBlocks).toBeGreaterThan(withoutPipelining.maxNumberOfBlocks); + }); + }); }); diff --git a/yarn-project/sequencer-client/src/sequencer/timetable.ts b/yarn-project/sequencer-client/src/sequencer/timetable.ts index e692fb1a6159..98373bf284da 100644 --- a/yarn-project/sequencer-client/src/sequencer/timetable.ts +++ b/yarn-project/sequencer-client/src/sequencer/timetable.ts @@ -70,6 +70,9 @@ export class SequencerTimetable { /** Maximum number of blocks that can be built in this slot configuration */ public readonly maxNumberOfBlocks: number; + /** Whether pipelining is enabled (checkpoint finalization deferred to next slot). */ + public readonly pipelining: boolean; + constructor( opts: { ethereumSlotDuration: number; @@ -78,6 +81,7 @@ export class SequencerTimetable { p2pPropagationTime?: number; blockDurationMs?: number; enforce: boolean; + pipelining?: boolean; }, private readonly metrics?: SequencerMetrics, private readonly log?: Logger, @@ -88,6 +92,7 @@ export class SequencerTimetable { this.p2pPropagationTime = opts.p2pPropagationTime ?? DEFAULT_P2P_PROPAGATION_TIME; this.blockDuration = opts.blockDurationMs ? opts.blockDurationMs / 1000 : undefined; this.enforce = opts.enforce; + this.pipelining = opts.pipelining ?? false; // Assume zero-cost propagation time and faster runs in test environments where L1 slot duration is shortened if (this.ethereumSlotDuration < 8) { @@ -116,18 +121,23 @@ export class SequencerTimetable { if (!this.blockDuration) { this.maxNumberOfBlocks = 1; // Single block per slot } else { - const timeReservedAtEnd = - this.blockDuration + // Last sub-slot for validator re-execution - this.checkpointFinalizationTime; // Checkpoint finalization + // When pipelining, finalization is deferred to the next slot, but we still need + // a sub-slot for validator re-execution so they can produce attestations. + let timeReservedAtEnd = this.blockDuration; // Validatior re-execution only + if (!this.pipelining) { + timeReservedAtEnd += this.checkpointFinalizationTime; + } + const timeAvailableForBlocks = this.aztecSlotDuration - this.initializationOffset - timeReservedAtEnd; this.maxNumberOfBlocks = Math.floor(timeAvailableForBlocks / this.blockDuration); } - // Minimum work to do within a slot for building a block with the minimum time for execution and publishing its checkpoint - const minWorkToDo = - this.initializationOffset + - this.minExecutionTime * 2 + // Execution and reexecution - this.checkpointFinalizationTime; + // Minimum work to do within a slot for building a block with the minimum time for execution and publishing its checkpoint. + // When pipelining, finalization is deferred, but we still need time for execution and validator re-execution. + let minWorkToDo = this.initializationOffset + this.minExecutionTime * 2; + if (!this.pipelining) { + minWorkToDo += this.checkpointFinalizationTime; + } const initializeDeadline = this.aztecSlotDuration - minWorkToDo; this.initializeDeadline = initializeDeadline; @@ -144,6 +154,7 @@ export class SequencerTimetable { blockAssembleTime: this.checkpointAssembleTime, initializeDeadline: this.initializeDeadline, enforce: this.enforce, + pipelining: this.pipelining, minWorkToDo, blockDuration: this.blockDuration, maxNumberOfBlocks: this.maxNumberOfBlocks, diff --git a/yarn-project/stdlib/src/timetable/index.ts b/yarn-project/stdlib/src/timetable/index.ts index e598b6849afb..eb76fb63f72a 100644 --- a/yarn-project/stdlib/src/timetable/index.ts +++ b/yarn-project/stdlib/src/timetable/index.ts @@ -42,6 +42,7 @@ export function calculateMaxBlocksPerSlot( checkpointAssembleTime?: number; p2pPropagationTime?: number; l1PublishingTime?: number; + pipelining?: boolean; } = {}, ): number { if (!blockDurationSec) { @@ -56,8 +57,12 @@ export function calculateMaxBlocksPerSlot( // Calculate checkpoint finalization time (assembly + round-trip propagation + L1 publishing) const checkpointFinalizationTime = assembleTime + p2pTime * 2 + l1Time; - // Time reserved at end for last sub-slot (validator re-execution) + finalization - const timeReservedAtEnd = blockDurationSec + checkpointFinalizationTime; + // When pipelining, finalization is deferred to the next slot, but we still reserve + // a sub-slot for validator re-execution so they can produce attestations. + let timeReservedAtEnd = blockDurationSec; + if (!opts.pipelining) { + timeReservedAtEnd += checkpointFinalizationTime; + } // Time available for building blocks const timeAvailableForBlocks = aztecSlotDurationSec - initOffset - timeReservedAtEnd; diff --git a/yarn-project/txe/src/state_machine/mock_epoch_cache.ts b/yarn-project/txe/src/state_machine/mock_epoch_cache.ts index 66c0f098222d..cfca8a29605b 100644 --- a/yarn-project/txe/src/state_machine/mock_epoch_cache.ts +++ b/yarn-project/txe/src/state_machine/mock_epoch_cache.ts @@ -33,6 +33,10 @@ export class MockEpochCache implements EpochCacheInterface { return EpochNumber.ZERO; } + pipeliningOffset(): number { + return 0; + } + getEpochAndSlotNow(): EpochAndSlot & { nowMs: bigint } { return { epoch: EpochNumber.ZERO, diff --git a/yarn-project/validator-client/src/block_proposal_handler.ts b/yarn-project/validator-client/src/block_proposal_handler.ts index 7943b68e9fe3..019b366fa6b2 100644 --- a/yarn-project/validator-client/src/block_proposal_handler.ts +++ b/yarn-project/validator-client/src/block_proposal_handler.ts @@ -463,8 +463,9 @@ export class BlockProposalHandler { } // Make a quick check before triggering an archiver sync + // If we are pipelining and have a pending checkpoint number stored, we will allow the block proposal to be for a slot further const syncedSlot = await this.blockSource.getSyncedL2SlotNumber(); - if (syncedSlot !== undefined && syncedSlot + 1 >= slot) { + if (syncedSlot !== undefined && syncedSlot + 1 + this.epochCache.pipeliningOffset() >= slot) { return true; } @@ -473,8 +474,8 @@ export class BlockProposalHandler { return await retryUntil( async () => { await this.blockSource.syncImmediate(); - const syncedSlot = await this.blockSource.getSyncedL2SlotNumber(); - return syncedSlot !== undefined && syncedSlot + 1 >= slot; + const updatedSyncedSlot = await this.blockSource.getSyncedL2SlotNumber(); + return updatedSyncedSlot !== undefined && updatedSyncedSlot + 1 >= slot; }, 'wait for block source sync', timeoutMs / 1000,