Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions yarn-project/epoch-cache/src/epoch_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>;
isEscapeHatchOpenAtSlot(slot: SlotTag): Promise<boolean>;
getProposerIndexEncoding(epoch: EpochNumber, slot: SlotNumber, seed: bigint): `0x${string}`;
Expand Down Expand Up @@ -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;
}
Expand Down
4 changes: 4 additions & 0 deletions yarn-project/epoch-cache/src/test/test_epoch_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
1 change: 1 addition & 0 deletions yarn-project/p2p/src/test-helpers/testbench-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
17 changes: 12 additions & 5 deletions yarn-project/sequencer-client/src/sequencer/sequencer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
p2pPropagationTime: this.config.attestationPropagationTime,
blockDurationMs: this.config.blockDurationMs,
enforce: this.config.enforceTimeTable,
pipelining: this.epochCache.isProposerPipeliningEnabled(),
},
this.metrics,
this.log,
Expand Down Expand Up @@ -571,32 +572,38 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter<Sequ
.getL2Tips()
.then(t => ({ 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,
// as the world state can compute the new genesis block hash, but other components use the hardcoded constant.
// TODO(palla/mbps): Fix the above. All components should be able to handle dynamic genesis block hashes.
const result =
(l2Tips.proposed.number === 0 &&
l2Tips.checkpointed.block.number === 0 &&
l2Tips.checkpointed.checkpoint.number === 0 &&
worldState.number === 0 &&
p2p.number === 0 &&
l1ToL2MessageSource.number === 0) ||
l1ToL2MessageSourceTips.proposed.number === 0 &&
l1ToL2MessageSourceTips.checkpointed.block.number === 0 &&
l1ToL2MessageSourceTips.checkpointed.checkpoint.number === 0) ||
(worldState.hash === l2Tips.proposed.hash &&
p2p.hash === l2Tips.proposed.hash &&
l1ToL2MessageSource.hash === l2Tips.proposed.hash);
l1ToL2MessageSourceTips.proposed.hash === l2Tips.proposed.hash &&
l1ToL2MessageSourceTips.checkpointed.block.hash === l2Tips.checkpointed.block.hash &&
l1ToL2MessageSourceTips.checkpointed.checkpoint.hash === l2Tips.checkpointed.checkpoint.hash);

if (!result) {
this.log.debug(`Sequencer sync check failed`, {
worldState,
l2BlockSource: l2Tips.proposed,
p2p,
l1ToL2MessageSource,
l1ToL2MessageSourceTips,
});
return undefined;
}
Expand Down
84 changes: 84 additions & 0 deletions yarn-project/sequencer-client/src/sequencer/timetable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,4 +438,88 @@ describe('sequencer-timetable', () => {
});
});
});

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);
});
});
});
27 changes: 19 additions & 8 deletions yarn-project/sequencer-client/src/sequencer/timetable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -78,6 +81,7 @@ export class SequencerTimetable {
p2pPropagationTime?: number;
blockDurationMs?: number;
enforce: boolean;
pipelining?: boolean;
},
private readonly metrics?: SequencerMetrics,
private readonly log?: Logger,
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down
9 changes: 7 additions & 2 deletions yarn-project/stdlib/src/timetable/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function calculateMaxBlocksPerSlot(
checkpointAssembleTime?: number;
p2pPropagationTime?: number;
l1PublishingTime?: number;
pipelining?: boolean;
} = {},
): number {
if (!blockDurationSec) {
Expand All @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions yarn-project/txe/src/state_machine/mock_epoch_cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ export class MockEpochCache implements EpochCacheInterface {
return EpochNumber.ZERO;
}

pipeliningOffset(): number {
return 0;
}

getEpochAndSlotNow(): EpochAndSlot & { nowMs: bigint } {
return {
epoch: EpochNumber.ZERO,
Expand Down
7 changes: 4 additions & 3 deletions yarn-project/validator-client/src/block_proposal_handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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,
Expand Down
Loading