diff --git a/yarn-project/scripts/latency-explorer/index.html b/yarn-project/scripts/latency-explorer/index.html index b23f55a818f7..ba3bd77e014c 100644 --- a/yarn-project/scripts/latency-explorer/index.html +++ b/yarn-project/scripts/latency-explorer/index.html @@ -203,6 +203,11 @@

Configuration

Time (s) +
+ + +
@@ -323,9 +332,47 @@

Sequencer Timetable

Zone Start - +
+ Dead + Zone Duration + - +
+ + + +
+ Time + Reserved at End + - +
+
+

Slot Timeline

+

+ Visual breakdown of how the L2 slot is divided into phases. With pipelining, attestation collection and L1 submission extend into the next slot (shown as overflow), and a second diagram shows the cascading dependency between consecutive proposers. +

+
+
+

Transaction Lifecycle

This tool models the user-perceived latency from sending a transaction to seeing its effects in the proposed @@ -337,8 +384,9 @@

Transaction Lifecycle

L2 slot. The TX must wait until the next block building window starts, since the proposer snapshots the TX pool at the beginning of each block.

4. Block execution. The proposer executes the transactions in the block. The actual execution - time depends on block fill (how many and how complex the transactions are). Once done, the block proposal is - broadcast to the network without waiting for the full block window to elapse.

+ time depends on block fill (how many and how complex the transactions are). For non-last blocks, the block + proposal is broadcast immediately. The last block is bundled with the checkpoint proposal, which is broadcast + after checkpoint assembly (adding a small delay).

5. P2P propagation back. The block proposal propagates back through the P2P network to the user's node (another one-way propagation delay).

6. Node re-execution. The user's node re-executes the block to update its local world state @@ -346,6 +394,20 @@

Transaction Lifecycle

proposed chain.

7. Slot wrap. If the TX arrives too late in the slot and misses all block building windows, it must wait for the next slot's proposer to pick it up, adding up to one full slot duration of extra latency.

+

Pipelining. When proposer pipelining is enabled, checkpoint finalization (attestation collection and + L1 publishing) is deferred to the next slot. Only checkpoint assembly and one-way proposal broadcast need + to complete within the build slot. This frees up time for more blocks, significantly shrinking the dead zone and + reducing latency. Validators re-execute the last block and return attestations during a grace period that extends + into the target slot.

+

Cascading pipelining. With pipelining, the next proposer (for slot N+1) also builds + ahead. Non-last blocks from slot N are broadcast individually via P2P, so the next proposer's node syncs and + re-executes them in real time. Only the last block — bundled with the checkpoint proposal — arrives later. + Critically, the next proposer starts re-executing the last block as soon as the checkpoint arrives, + even before its slot officially begins. This overlaps re-execution with the gap before the slot boundary. + The effective init is: max(0, checkpointArrival + lastBlockReExecTime - slotDuration). + If the checkpoint arrives early enough that re-execution completes before the slot starts, effective init is 0. + The "Cascading (early start)" overlay shows what happens if the next proposer begins building blocks immediately + when ready, even before the slot boundary — further reducing the dead zone spike.

Note that this models "proposed chain" visibility -- the TX effects are visible locally before checkpoint confirmation on L1 or epoch proving.

@@ -367,6 +429,8 @@

Transaction Lifecycle

p2pPropagationTime: 2, checkpointInitializationTime: 1, checkpointAssembleTime: 1, + lastBlockDuration: 0, + pipelining: false, }; } @@ -384,6 +448,8 @@

Transaction Lifecycle

p2pPropagationTime: 2, checkpointInitializationTime: 1, checkpointAssembleTime: 1, + lastBlockDuration: 0, + pipelining: false, }; } @@ -415,6 +481,8 @@

Transaction Lifecycle

finite(config.checkpointInitializationTime, defaults.checkpointInitializationTime), ), checkpointAssembleTime: Math.max(0, finite(config.checkpointAssembleTime, defaults.checkpointAssembleTime)), + lastBlockDuration: Math.max(0, finite(config.lastBlockDuration, defaults.lastBlockDuration)), + pipelining: !!config.pipelining, }; } @@ -425,37 +493,116 @@

Transaction Lifecycle

config = validateConfig(config); const initializationOffset = config.checkpointInitializationTime; + // Resolve effective last block duration: 0 or >= standard block duration means "same as standard" + const lastBlockDur = config.lastBlockDuration > 0 && config.lastBlockDuration < config.l2BlockDuration + ? config.lastBlockDuration + : config.l2BlockDuration; + const checkpointFinalizationTime = config.checkpointAssembleTime + 2 * config.p2pPropagationTime + config.l1PublishingTime; - const timeReservedAtEnd = config.l2BlockDuration + checkpointFinalizationTime; + // When pipelining, checkpoint finalization (attestation collection + L1 publishing) is + // deferred to the next slot. Only assembly + one-way broadcast need to fit in the build slot. + // Without pipelining, we also reserve a full block duration for validator re-execution + // plus the entire checkpoint finalization time. + let timeReservedAtEnd; + if (config.pipelining) { + timeReservedAtEnd = config.checkpointAssembleTime + config.p2pPropagationTime; + } else { + timeReservedAtEnd = config.l2BlockDuration + checkpointFinalizationTime; + } + const timeAvailableForBlocks = config.l2SlotDuration - initializationOffset - timeReservedAtEnd; const maxBlocks = Math.max(1, Math.floor(timeAvailableForBlocks / config.l2BlockDuration)) || 1; + // Build block windows: last block uses lastBlockDur instead of l2BlockDuration const blockWindows = []; for (let i = 0; i < maxBlocks; i++) { - blockWindows.push({ - start: initializationOffset + i * config.l2BlockDuration, - end: initializationOffset + (i + 1) * config.l2BlockDuration, - }); + const dur = (i === maxBlocks - 1) ? lastBlockDur : config.l2BlockDuration; + const start = initializationOffset + i * config.l2BlockDuration; + blockWindows.push({ start, end: start + dur }); } + // When pipelining, attestation collection extends into the target slot. + // Grace period = validator re-execution (last block duration) + one-way attestation return. + const pipeliningAttestationGracePeriod = config.pipelining + ? lastBlockDur + config.p2pPropagationTime + : 0; + + // When pipelining, compute the effective init offset for the NEXT proposer. + // Non-last blocks are broadcast individually, so the next proposer's node syncs + // them as they arrive. Only the last block (bundled with the checkpoint) needs + // re-execution after the checkpoint arrives. + // + // The next proposer starts re-executing the last block as soon as the checkpoint + // arrives — even if that's before the slot boundary. Re-execution overlaps with + // the remaining time before the slot officially starts. + // + // pipeliningEffectiveInit: delay from slot boundary until the next proposer can + // start building. If checkpoint processing finishes before the slot starts, this is 0. + // + // pipeliningEarlyStartInit: if the next proposer could start building blocks as + // soon as checkpoint processing completes (before the slot boundary), this is the + // offset from when it starts building. Can be negative (meaning it starts early). + let pipeliningEffectiveInit = undefined; + let pipeliningEarlyStartInit = undefined; + if (config.pipelining) { + // Checkpoint broadcast: (maxBlocks-1) standard blocks + 1 short last block + assembly + const checkpointBroadcastTime = initializationOffset + + (maxBlocks - 1) * config.l2BlockDuration + lastBlockDur + + config.checkpointAssembleTime; + const checkpointArrival = checkpointBroadcastTime + config.p2pPropagationTime; + const arrivalRelativeToNextBuild = checkpointArrival - config.l2SlotDuration; + const lastBlockReExecTime = lastBlockDur * config.l2BlockFillPercent / 100; + + // Re-execution starts at checkpoint arrival, finishes lastBlockReExecTime later. + // The delay relative to slot boundary is (arrival + reExec - slotBoundary). + // If negative, the next proposer is ready before the slot starts → effectiveInit = 0. + pipeliningEffectiveInit = Math.max(0, arrivalRelativeToNextBuild + lastBlockReExecTime); + + // Early start: the next proposer begins building as soon as it finishes + // processing the checkpoint, even if that's before the slot boundary. + // This value is the time from checkpoint processing completion to the first block. + // When positive, it means a delay after the slot boundary (same as effectiveInit). + // When zero or negative, the proposer starts building early. + pipeliningEarlyStartInit = arrivalRelativeToNextBuild + lastBlockReExecTime; + } + + // Dead zone excess: how much extra peak latency a wrapping TX experiences + // compared to a normal block cycle. When <= 0, there's no meaningful dead zone spike. + const wrappedInit = (config.pipelining && pipeliningEarlyStartInit !== undefined) + ? pipeliningEarlyStartInit + : initializationOffset; + const deadZoneExcess = config.l2SlotDuration + wrappedInit - initializationOffset - maxBlocks * config.l2BlockDuration; + return { initializationOffset, checkpointFinalizationTime, timeAvailableForBlocks, + timeReservedAtEnd, maxBlocks, blockWindows, + lastBlockDur, + deadZoneExcess, + pipelining: config.pipelining, + pipeliningAttestationGracePeriod, + pipeliningEffectiveInit, + pipeliningEarlyStartInit, }; } /** * Computes the user-perceived latency for a single transaction sent at a given * time within an L2 slot. + * + * Non-last blocks are broadcast individually right after execution. + * The last block is bundled with the checkpoint proposal, so it incurs an extra + * checkpointAssembleTime delay before its proposal is broadcast. */ function computeLatency(config, timetable, txSendTime) { const arrivalAtProposer = txSendTime + config.p2pPropagationTime; const executionTime = config.l2BlockDuration * config.l2BlockFillPercent / 100; + const lastBlockExecTime = timetable.lastBlockDur * config.l2BlockFillPercent / 100; let blockIndex; if (arrivalAtProposer <= timetable.initializationOffset) { @@ -464,22 +611,55 @@

Transaction Lifecycle

blockIndex = Math.ceil((arrivalAtProposer - timetable.initializationOffset) / config.l2BlockDuration); } + // Check if TX fits in the shorter last block window + if (blockIndex === timetable.maxBlocks - 1) { + const lastBlockEnd = timetable.initializationOffset + (timetable.maxBlocks - 1) * config.l2BlockDuration + timetable.lastBlockDur; + if (arrivalAtProposer > lastBlockEnd) { + blockIndex = timetable.maxBlocks; // will trigger wrap + } + } + let wrapsToNextSlot = false; let blockEnd; + let isLastBlock = false; + let wrappedNextSlotInit; if (blockIndex >= timetable.maxBlocks) { wrapsToNextSlot = true; - blockIndex = 0; - blockEnd = config.l2SlotDuration + timetable.initializationOffset + executionTime; + // With pipelining, the next proposer starts building as soon as it processes the + // previous checkpoint — potentially before the slot boundary. + wrappedNextSlotInit = (timetable.pipelining && timetable.pipeliningEarlyStartInit !== undefined) + ? timetable.pipeliningEarlyStartInit + : timetable.initializationOffset; + const nextSlotBuildStart = config.l2SlotDuration + wrappedNextSlotInit; + if (arrivalAtProposer <= nextSlotBuildStart) { + blockIndex = 0; + } else { + blockIndex = Math.ceil((arrivalAtProposer - nextSlotBuildStart) / config.l2BlockDuration); + blockIndex = Math.min(blockIndex, timetable.maxBlocks - 1); + } + isLastBlock = blockIndex === timetable.maxBlocks - 1; + const execTime = isLastBlock ? lastBlockExecTime : executionTime; + blockEnd = nextSlotBuildStart + blockIndex * config.l2BlockDuration + execTime; } else { const blockStart = timetable.initializationOffset + blockIndex * config.l2BlockDuration; - blockEnd = blockStart + executionTime; + isLastBlock = blockIndex === timetable.maxBlocks - 1; + const execTime = isLastBlock ? lastBlockExecTime : executionTime; + blockEnd = blockStart + execTime; } - const proposalArrival = blockEnd + config.p2pPropagationTime; - const effectsVisible = proposalArrival + executionTime; + // The last block is bundled with the checkpoint proposal, so its broadcast is + // delayed by checkpoint assembly time. Non-last blocks are broadcast immediately. + const assemblyDelay = isLastBlock ? config.checkpointAssembleTime : 0; + const proposalBroadcast = blockEnd + assemblyDelay; + const proposalArrival = proposalBroadcast + config.p2pPropagationTime; + // Re-execution time depends on block size: shorter last block → faster re-exec + const reExecTime = isLastBlock ? lastBlockExecTime : executionTime; + const effectsVisible = proposalArrival + reExecTime; const latency = effectsVisible - txSendTime; + const blockExecTime = isLastBlock ? lastBlockExecTime : executionTime; + // Build human-readable timeline const timeline = []; timeline.push({ time: txSendTime, event: 'TX sent to node' }); @@ -493,10 +673,11 @@

Transaction Lifecycle

time: arrivalAtProposer, event: `TX too late for current slot (missed all ${timetable.maxBlocks} blocks)`, }); - const blockStart = config.l2SlotDuration + timetable.initializationOffset; + const blockStart = config.l2SlotDuration + wrappedNextSlotInit + blockIndex * config.l2BlockDuration; + const earlyNote = wrappedNextSlotInit < 0 ? ` (proposer starts ${Math.abs(wrappedNextSlotInit).toFixed(1)}s early via pipelining)` : ''; timeline.push({ time: blockStart, - event: `TX picked up for inclusion in block ${blockIndex} of next slot (block starts)`, + event: `TX picked up for inclusion in block ${blockIndex} of next slot${earlyNote}`, }); } else { const blockStart = timetable.initializationOffset + blockIndex * config.l2BlockDuration; @@ -505,23 +686,38 @@

Transaction Lifecycle

timeline.push({ time: blockEnd, - event: `Block ${blockIndex} built, proposal broadcast (after ${executionTime}s execution)`, + event: `Block ${blockIndex} built (after ${blockExecTime}s execution)`, }); + + if (isLastBlock) { + timeline.push({ + time: proposalBroadcast, + event: `Last block — checkpoint assembled and proposal broadcast (after ${config.checkpointAssembleTime}s assembly)`, + }); + } else { + timeline.push({ + time: proposalBroadcast, + event: `Block proposal broadcast immediately`, + }); + } + timeline.push({ time: proposalArrival, event: `Proposal reaches node (after ${config.p2pPropagationTime}s P2P propagation)`, }); timeline.push({ time: effectsVisible, - event: `Node re-executes block, effects visible (after ${executionTime}s re-execution)`, + event: `Node re-executes block, effects visible (after ${reExecTime}s re-execution)`, }); return { latency, wrapsToNextSlot, blockIndex, + isLastBlock, arrivalAtProposer, blockEnd, + proposalBroadcast, proposalArrival, effectsVisible, timeline, @@ -588,6 +784,7 @@

Transaction Lifecycle

'p2pPropagationTime', 'checkpointInitializationTime', 'checkpointAssembleTime', + 'lastBlockDuration', ]; const inputs = {}; @@ -595,11 +792,14 @@

Transaction Lifecycle

inputs[key] = document.getElementById(key); } + const pipeliningCheckbox = document.getElementById('pipelining'); + function readConfig() { const config = {}; for (const key of configKeys) { config[key] = parseFloat(inputs[key].value); } + config.pipelining = pipeliningCheckbox.checked; return config; } @@ -607,6 +807,7 @@

Transaction Lifecycle

for (const key of configKeys) { inputs[key].value = config[key]; } + pipeliningCheckbox.checked = !!config.pipelining; } function formatSeconds(val) { @@ -617,13 +818,52 @@

Transaction Lifecycle

let latencyChart = null; let cdfChart = null; - function buildLatencyChart(latencies, timetable, slotDuration, allLatencies) { + function buildLatencyChart(latencies, timetable, slotDuration, allLatencies, config, cascadingLatencies, zeroInitLatencies) { const ctx = document.getElementById('latencyChart').getContext('2d'); const chartData = latencies.map(d => ({ x: d.txSendTime, y: d.latency })); const annotations = {}; + // Dead zone: shaded region where TXs wrap to next slot + // Only render when wrapping causes a meaningful latency spike (excess > 1s) + const hasWindows = timetable.blockWindows.length > 0; + const lastBlockStart = hasWindows + ? timetable.blockWindows[timetable.blockWindows.length - 1].start + : timetable.initializationOffset; + const deadZoneStart = lastBlockStart - config.p2pPropagationTime; + + if (timetable.deadZoneExcess > 1) { + // Dead zone in slot 1 + annotations['deadZone1'] = { + type: 'box', + xMin: deadZoneStart, + xMax: slotDuration, + backgroundColor: 'rgba(255,99,132,0.08)', + borderColor: 'rgba(255,99,132,0.3)', + borderWidth: 1, + label: { + display: true, + content: 'Dead zone', + position: { x: 'center', y: 'start' }, + color: 'rgba(255,99,132,0.7)', + font: { size: 10 }, + backgroundColor: 'rgba(34,34,64,0.85)', + padding: 3, + }, + }; + + // Dead zone in slot 2 + annotations['deadZone2'] = { + type: 'box', + xMin: slotDuration + deadZoneStart, + xMax: 2 * slotDuration, + backgroundColor: 'rgba(255,99,132,0.08)', + borderColor: 'rgba(255,99,132,0.3)', + borderWidth: 1, + }; + } + // Add a prominent vertical line at the slot boundary annotations['slotBoundary'] = { type: 'line', @@ -643,21 +883,53 @@

Transaction Lifecycle

}, }; + const datasets = [ + { + label: 'Latency', + data: chartData, + borderColor: '#5b8def', + backgroundColor: 'rgba(91,141,239,0.15)', + pointRadius: 2, + pointHoverRadius: 5, + borderWidth: 2, + tension: 0, + }, + ]; + + if (cascadingLatencies) { + const cascadingData = cascadingLatencies.map(d => ({ x: d.txSendTime, y: d.latency })); + datasets.push({ + label: 'Cascading (slot boundary start)', + data: cascadingData, + borderColor: '#4bc0c0', + backgroundColor: 'transparent', + pointRadius: 0, + pointHoverRadius: 4, + borderWidth: 2, + borderDash: [6, 3], + tension: 0, + }); + } + + if (zeroInitLatencies) { + const zeroData = zeroInitLatencies.map(d => ({ x: d.txSendTime, y: d.latency })); + datasets.push({ + label: 'Zero init (theoretical best)', + data: zeroData, + borderColor: '#f0c040', + backgroundColor: 'transparent', + pointRadius: 0, + pointHoverRadius: 4, + borderWidth: 2, + borderDash: [3, 2], + tension: 0, + }); + } + const chartConfig = { type: 'line', data: { - datasets: [ - { - label: 'Latency', - data: chartData, - borderColor: '#5b8def', - backgroundColor: 'rgba(91,141,239,0.15)', - pointRadius: 2, - pointHoverRadius: 5, - borderWidth: 2, - tension: 0, - }, - ], + datasets, }, options: { responsive: true, @@ -683,7 +955,8 @@

Transaction Lifecycle

}, plugins: { legend: { - display: false, + display: !!(cascadingLatencies || zeroInitLatencies), + labels: { color: '#9a9ab0', font: { size: 11 } }, }, annotation: { annotations, @@ -893,6 +1166,8 @@

Transaction Lifecycle

const lastBlockStart = hasWindows ? timetable.blockWindows[timetable.blockWindows.length - 1].start : timetable.initializationOffset; const deadZoneStart = lastBlockStart - config.p2pPropagationTime; + const deadZoneDuration = config.l2SlotDuration - deadZoneStart; + document.getElementById('ttMaxBlocks').textContent = timetable.maxBlocks; document.getElementById('ttInitOffset').textContent = formatSeconds(timetable.initializationOffset); document.getElementById('ttCheckpointFinal').textContent = formatSeconds(timetable.checkpointFinalizationTime); @@ -901,15 +1176,295 @@

Transaction Lifecycle

document.getElementById('ttLastBlockEnd').textContent = formatSeconds(lastBlockEnd); document.getElementById('ttExecTime').textContent = formatSeconds(execTimePerBlock); document.getElementById('ttBlockWindows').textContent = blockWindowsStr || '-'; - document.getElementById('ttDeadZone').textContent = formatSeconds(deadZoneStart); + document.getElementById('ttDeadZone').textContent = timetable.deadZoneExcess > 1 ? formatSeconds(deadZoneStart) : 'None'; + document.getElementById('ttDeadZoneDuration').textContent = timetable.deadZoneExcess > 1 ? formatSeconds(deadZoneDuration) : 'None'; + document.getElementById('ttTimeReserved').textContent = formatSeconds(timetable.timeReservedAtEnd); + + // Pipelining-specific fields + const gracePeriodItem = document.getElementById('ttGracePeriodItem'); + const effectiveInitItem = document.getElementById('ttEffectiveInitItem'); + const earlyStartItem = document.getElementById('ttEarlyStartItem'); + if (timetable.pipelining) { + gracePeriodItem.style.display = ''; + document.getElementById('ttGracePeriod').textContent = formatSeconds(timetable.pipeliningAttestationGracePeriod); + effectiveInitItem.style.display = ''; + document.getElementById('ttEffectiveInit').textContent = formatSeconds(timetable.pipeliningEffectiveInit); + earlyStartItem.style.display = ''; + const earlyVal = timetable.pipeliningEarlyStartInit; + document.getElementById('ttEarlyStart').textContent = + earlyVal <= 0 ? `${formatSeconds(Math.abs(earlyVal))} early` : formatSeconds(earlyVal); + } else { + gracePeriodItem.style.display = 'none'; + effectiveInitItem.style.display = 'none'; + earlyStartItem.style.display = 'none'; + } // Build charts const medianElapsed = findPercentile(cdfData, 0.5); const p95Elapsed = findPercentile(cdfData, 0.95); document.getElementById('infoP95Latency').textContent = formatSeconds(p95Elapsed); - buildLatencyChart(latencies, timetable, config.l2SlotDuration, latencies); + buildLatencyChart(latencies, timetable, config.l2SlotDuration, latencies, config, null, null); buildCdfChart(cdfData, medianElapsed, p95Elapsed); + buildSlotTimeline(config, timetable); + } + + /** + * Renders a horizontal bar-style timeline showing how the slot is divided into phases. + * With pipelining, attestation collection and L1 submission overflow into the next slot. + */ + function buildSlotTimeline(config, timetable) { + const container = document.getElementById('slotTimeline'); + const slot = config.l2SlotDuration; + const lastBlockEnd = timetable.blockWindows.length > 0 + ? timetable.blockWindows[timetable.blockWindows.length - 1].end + : timetable.initializationOffset; + + // Phase timings within (and possibly beyond) the build slot + const initEnd = timetable.initializationOffset; + const blocksEnd = lastBlockEnd; + const assemblyEnd = blocksEnd + config.checkpointAssembleTime; + const proposalArrival = assemblyEnd + config.p2pPropagationTime; // one-way broadcast + + let attestationEnd, l1SubmitEnd; + if (config.pipelining) { + // Re-execution happens on validators (last block duration), then attestation return (p2p) + attestationEnd = proposalArrival + timetable.lastBlockDur + config.p2pPropagationTime; + l1SubmitEnd = attestationEnd + config.l1PublishingTime; + } else { + // Attestation: proposal P2P + re-exec (last block) + attestation P2P back + attestationEnd = proposalArrival + timetable.lastBlockDur + config.p2pPropagationTime; + l1SubmitEnd = attestationEnd + config.l1PublishingTime; + } + + // Total width represents max(slotDuration, l1SubmitEnd) + const totalTime = Math.max(slot, l1SubmitEnd); + + const phases = [ + { label: 'Init', start: 0, end: initEnd, color: '#4a4a80' }, + { label: `Blocks (${timetable.maxBlocks})`, start: initEnd, end: blocksEnd, color: '#5b8def' }, + { label: 'Assembly', start: blocksEnd, end: assemblyEnd, color: '#8b6fcf' }, + { label: 'Broadcast', start: assemblyEnd, end: proposalArrival, color: '#cf8b6f' }, + { label: 'Re-exec + Attestations', start: proposalArrival, end: attestationEnd, color: '#4bc0c0' }, + { label: 'L1 Publish', start: attestationEnd, end: l1SubmitEnd, color: '#ff6384' }, + ]; + + // Build HTML + const barHeight = 32; + const labelHeight = 18; + const slotMarkerX = (slot / totalTime) * 100; + + let html = `
`; + + // Slot boundary labels + html += `
0s
`; + html += `
${slot}s (slot end)
`; + if (totalTime > slot) { + html += `
${formatSeconds(totalTime)}
`; + } + + // Phase bars + html += `
`; + + for (const phase of phases) { + const left = (phase.start / totalTime) * 100; + const width = ((phase.end - phase.start) / totalTime) * 100; + if (width <= 0) continue; + + const overflow = phase.end > slot; + const border = overflow ? '2px dashed rgba(255,255,255,0.3)' : 'none'; + + html += `
`; + } + + // Slot boundary vertical line + if (totalTime > slot) { + html += `
`; + } + + html += `
`; + + // Phase legend + html += `
`; + for (const phase of phases) { + const left = (phase.start / totalTime) * 100; + const width = ((phase.end - phase.start) / totalTime) * 100; + if (width <= 0) continue; + html += `
`; + html += `
`; + html += `${phase.label} (${formatSeconds(phase.start)}–${formatSeconds(phase.end)})`; + html += `
`; + } + if (config.pipelining && totalTime > slot) { + html += `
⟵ Dashed phases overflow into target slot
`; + } + html += `
`; + + html += `
`; + + // When pipelining, show a second timeline for the next proposer's cascading dependency + if (config.pipelining && timetable.pipeliningEffectiveInit !== undefined) { + html += buildCascadingTimeline(config, timetable); + } + + container.innerHTML = html; + } + + /** + * Renders a two-slot cascading pipelining visualization showing how the next + * proposer's build window overlaps with the current slot's checkpoint broadcast. + */ + function buildCascadingTimeline(config, timetable) { + const slot = config.l2SlotDuration; + const initOffset = timetable.initializationOffset; + + // Key timings for Slot N (current proposer) + const lastBlockDur = timetable.lastBlockDur; + const checkpointBroadcast = initOffset + (timetable.maxBlocks - 1) * config.l2BlockDuration + lastBlockDur + config.checkpointAssembleTime; + const checkpointArrival = checkpointBroadcast + config.p2pPropagationTime; + const lastBlockReExecTime = lastBlockDur * config.l2BlockFillPercent / 100; + const effectiveInit = timetable.pipeliningEffectiveInit; + const earlyStartInit = timetable.pipeliningEarlyStartInit; + + // Re-execution starts at checkpoint arrival, which may be before the slot boundary + const reExecStart = checkpointArrival; // absolute time + const reExecEnd = checkpointArrival + lastBlockReExecTime; // when proposer is ready + + // We show two slots: Slot N (0..slot) and Slot N+1 (slot..2*slot) + const totalTime = 2 * slot; + const barHeight = 28; + const rowGap = 8; + const labelHeight = 18; + const legendHeight = 28; + + let html = `
`; + html += `

Cascading Pipelining

`; + html += `

+ The next proposer starts re-executing the last block as soon as the checkpoint arrives — even before the slot boundary. + Re-execution overlaps with the remaining time in Slot N. + ${earlyStartInit <= 0 + ? `The next proposer is ready ${formatSeconds(Math.abs(earlyStartInit))} before its slot starts and begins building blocks immediately.` + : `The next proposer is ready ${formatSeconds(earlyStartInit)} after its slot starts.`} +

`; + + html += `
`; + + // Time markers + html += `
0s (Slot N start)
`; + const slotBoundaryX = (slot / totalTime) * 100; + html += `
${slot}s (Slot N+1 start)
`; + html += `
${2 * slot}s
`; + + // --- Slot N bar (current proposer) --- + const slotNTop = labelHeight; + html += `
Slot N (proposer)
`; + html += `
`; + + // Slot N phases + const slotNPhases = [ + { label: 'Init', start: 0, end: initOffset, color: '#4a4a80' }, + ]; + if (timetable.maxBlocks > 1) { + slotNPhases.push({ label: `Blocks 1..${timetable.maxBlocks - 1}`, start: initOffset, end: initOffset + (timetable.maxBlocks - 1) * config.l2BlockDuration, color: '#5b8def' }); + } + const lastBlockStart = initOffset + (timetable.maxBlocks - 1) * config.l2BlockDuration; + const lastBlockEndTime = lastBlockStart + lastBlockDur; + slotNPhases.push( + { label: 'Last Block', start: lastBlockStart, end: lastBlockEndTime, color: '#3a7bdf' }, + { label: 'Assembly', start: lastBlockEndTime, end: lastBlockEndTime + config.checkpointAssembleTime, color: '#8b6fcf' }, + { label: 'Broadcast', start: lastBlockEndTime + config.checkpointAssembleTime, end: checkpointArrival, color: '#cf8b6f' }, + ); + + for (const phase of slotNPhases) { + const left = (phase.start / totalTime) * 100; + const width = ((phase.end - phase.start) / totalTime) * 100; + if (width <= 0) continue; + html += `
`; + } + + // Slot boundary line + html += `
`; + html += `
`; + + // --- Slot N+1 bar (next proposer) --- + const slotN1Top = slotNTop + barHeight + rowGap; + html += `
Slot N+1 (next proposer)
`; + html += `
`; + + // The next proposer starts re-executing at checkpoint arrival (may be before slot boundary). + // With early building, blocks start as soon as re-execution completes. + const buildStart = earlyStartInit < 0 ? reExecEnd : slot + effectiveInit; + const nextBlocksEnd = buildStart + timetable.maxBlocks * config.l2BlockDuration; + + // Show re-execution starting at checkpoint arrival (may be before slot boundary) + const slotN1Phases = []; + if (reExecStart < slot) { + // Re-execution starts before the slot boundary — show it spanning the boundary + slotN1Phases.push({ label: 'Last block re-exec', start: reExecStart, end: reExecEnd, color: '#4bc0c0' }); + } else { + // Checkpoint arrives after slot boundary — show wait then re-exec + slotN1Phases.push({ label: 'Waiting', start: slot, end: reExecStart, color: '#3a3a5c' }); + slotN1Phases.push({ label: 'Last block re-exec', start: reExecStart, end: reExecEnd, color: '#4bc0c0' }); + } + + // Block building starts as soon as re-exec completes (early building) or at slot boundary + slotN1Phases.push({ label: `Blocks (${timetable.maxBlocks})`, start: buildStart, end: Math.min(nextBlocksEnd, 2 * slot), color: '#5b8def' }); + + const showEarlyStart = earlyStartInit < 0; + + for (const phase of slotN1Phases) { + const left = (phase.start / totalTime) * 100; + const width = ((phase.end - phase.start) / totalTime) * 100; + if (width <= 0) continue; + html += `
`; + } + + // If early start, show a marker where blocks begin (before slot boundary) + if (showEarlyStart) { + const earlyX = (buildStart / totalTime) * 100; + html += `
`; + } + + // Slot boundary line + html += `
`; + html += `
`; + + // --- Connecting arrow from checkpoint arrival to re-exec start --- + const arrowStartX = (checkpointArrival / totalTime) * 100; + const arrowEndX = (reExecStart / totalTime) * 100; + const arrowStartY = slotNTop + barHeight; + const arrowEndY = slotN1Top; + + html += ``; + html += ``; + html += ``; + html += ``; + + // Legend + const legendTop = slotN1Top + barHeight + 10; + html += `
`; + const legendItems = [ + { color: '#4bc0c0', label: `Re-exec last block (${formatSeconds(lastBlockReExecTime)})` }, + { color: '#5b8def', label: 'Block building' }, + { color: '#cf8b6f', label: 'Checkpoint broadcast + P2P' }, + ]; + if (showEarlyStart) { + legendItems.push({ color: '#f0c040', label: `Early start (${formatSeconds(Math.abs(earlyStartInit))} before slot)` }); + } + for (const item of legendItems) { + html += `
`; + html += `
`; + html += `${item.label}`; + html += `
`; + } + html += `
${earlyStartInit < 0 + ? `Early building: proposer starts ${formatSeconds(Math.abs(earlyStartInit))} before slot boundary` + : `Effective init: ${formatSeconds(effectiveInit)} (re-exec overlaps with gap before slot)`}
`; + html += `
`; + + html += `
`; + html += `
`; + return html; } // Debounce helper @@ -927,15 +1482,20 @@

Transaction Lifecycle

for (const key of configKeys) { inputs[key].addEventListener('input', debouncedUpdate); } + pipeliningCheckbox.addEventListener('change', update); - // Preset buttons + // Preset buttons (preserve current pipelining toggle) document.getElementById('optimisticBtn').addEventListener('click', () => { + const pipelining = pipeliningCheckbox.checked; populateInputs(getOptimisticConfig()); + pipeliningCheckbox.checked = pipelining; update(); }); document.getElementById('conservativeBtn').addEventListener('click', () => { + const pipelining = pipeliningCheckbox.checked; populateInputs(getConservativeConfig()); + pipeliningCheckbox.checked = pipelining; update(); }); @@ -946,4 +1506,4 @@

Transaction Lifecycle

- \ No newline at end of file +