Skip to content

perf(blocksync): verify a commit once, not again as the next block's LastCommit - #1427

Open
PastaPastaPasta wants to merge 1 commit into
v1.7-devfrom
perf/verify-commit-once
Open

perf(blocksync): verify a commit once, not again as the next block's LastCommit#1427
PastaPastaPasta wants to merge 1 commit into
v1.7-devfrom
perf/verify-commit-once

Conversation

@PastaPastaPasta

Copy link
Copy Markdown
Member

Block sync threshold-verifies each commit twice.

blockApplier.verify verifies the commit for block N against state.Validators. One height later the identical commit arrives again as block N+1's LastCommit and validateBlock verifies it a second time against state.LastValidators. A BLS threshold verification costs about 1.9 ms, so every block pays for one it has already done.

The applier now records what it verified through a new Executor.NoteVerifiedCommit, and validateBlock takes a lastCommitVerified hint.

Why the skip is safe

ValidatorSet.verifyCommit reads exactly: chainID, height (against commit.Height), blockID (against commit.BlockID), vals.QuorumType, vals.QuorumHash, vals.ThresholdPublicKey, and the commit itself — the canonical vote and the quorum signs are both derived from it.

The memo pins every one of those, and the skip fires only on a full match: same chain ID, height, block ID, quorum type, quorum hash, threshold public key, and a byte-identical marshalled commit. Anything short of that falls through to a real verification.

Marshalling the commit rather than using Commit.Hash() is deliberate: that hash only covers ThresholdBlockSignature, so it would not notice a different Round or different vote extensions.

Ordering

The note is stored after ApplyBlock returns, not before. Both places that would re-verify this commit run while the next block is applied, so writing it earlier would overwrite the entry they are still reading for the current block and the skip would never fire. The validator set is captured before ApplyBlock, which reassigns e.state.

Consensus is unaffected

The consensus path never calls NoteVerifiedCommit, so the memo stays nil there and validateBlock behaves exactly as before. The memo is an atomic.Pointer written and read whole, never read-modify-write, and blockApplier.Apply is serialised under its own mutex.

Measurements

Replaying mainnet history from a local peer, 3,000-block window at height 190k, v1.7-dev against this branch, back to back:

ms/block verify stage
v1.7-dev 12.81 3.911 ms
this branch 10.31 2.003 ms

−1.91 ms off the verify stage, −19.5% off the block.

Numbers taken with tenderdash's per-block fsync removed, because Go's File.Sync() on macOS issues F_FULLFSYNC (6.78 ms here versus 0.23 ms for a plain fsync) and would otherwise swamp everything else. Linux nodes do not pay that.

Tests

internal/state/..., internal/blocksync/..., internal/consensus, types/... all pass.

🤖 Generated with Claude Code

…LastCommit

Block sync threshold-verifies the commit for block N in blockApplier.verify, and
one height later the same commit comes back as block N+1's LastCommit and is
verified a second time inside validateBlock. Every commit was verified twice, a
block apart, at roughly 1.9 ms each.

The applier now records what it verified through a new Executor.NoteVerifiedCommit
and validateBlock takes a lastCommitVerified hint. The skip fires only on an exact
match of every input ValidatorSet.verifyCommit reads: chain ID, height, block ID,
quorum type, quorum hash, threshold public key, and a byte-identical marshalled
commit. Marshalling rather than using Commit.Hash() matters, because that hash
only covers ThresholdBlockSignature and would miss Round and the vote extensions.
Anything short of a full match falls through to a real verification.

The note is stored after ApplyBlock returns, not before: both places that would
re-verify this commit run while the *next* block is applied, so writing it earlier
would overwrite the entry they are still reading for the current block and the
skip would never fire. The validator set is captured before ApplyBlock, which
reassigns e.state.

The consensus path never calls NoteVerifiedCommit, so the memo stays nil there and
nothing changes. It is an atomic.Pointer written and read whole, never
read-modify-write, and blockApplier.Apply is serialised under its own mutex.

Replaying mainnet, the applier's verify stage goes from 3.61 ms to 1.84 ms a block.
@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: fadd1b14-b39f-4436-a30e-97dd39c10d35

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@thepastaclaw

thepastaclaw commented Aug 31, 2026

Copy link
Copy Markdown

🔍 Review in progress — actively reviewing now (commit f30ba09)
Stage: GLM technical fallback — full Sol review + final verification
ETA: complete ~20:55 UTC (median 1h 4m across 30 recent reviews)
Running 3h 24m · Last checked: 2026-08-31 20:50 UTC

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