Skip to content

benchmark pallet emits a nonsensical proof_size when a benchmark touches storage keys with no MaxEncodedLen bound #13066

Description

@nol4lej

Is there an existing issue?

  • I have searched the existing issues

Experiencing problems? Have you tried our Stack Exchange first?

  • This is not a support question.

Description of bug

Summary

For a benchmark that reads many storage keys the runner does not recognise (UNKNOWN KEY in the storage summary), the generated weights.rs can contain a proof_size in the exabyte range while the same block reports a measured size of 85 bytes:

// Proof Size summary in bytes:
//  Measured:  `85`
//  Estimated: `8126544059662763008 + b * (7 ±0)`
Weight::from_parts(17_274_795, 8126544059662763008)

The value is not deterministic. Two runs of the same command on the same machine, same binary, same flags produced:

Run proof_size hex
1 2585700789447993344 0x23e2412b1a7ea800
2 8126544059662763008 0x70c748c417c28400

The plausible value for this benchmark is ~123,750 bytes
(log16(1_000_000) = 5 trie layers × 15 * 33 bytes × ~50 keys), so the emitted number is off by roughly 14 orders of magnitude.

A proof_size this large silently poisons block-weight accounting: a single extrinsic declares more PoV than any block can hold.

Versions

  • frame-benchmarking-cli 58.0.1
  • frame-benchmarking 49.0.0
  • polkadot-sdk 2606.0.0 (crates.io release line)
  • Host: Ubuntu, 16 vCPU / 32 GB (also reproduced on 8 vCPU / 16 GB)

Analysis

The proof_size that lands in the file comes from writer.rs::add_storage_commentsanalysis_function(results, BenchmarkSelector::ProofSize), evaluated per storage prefix:

// frame-benchmarking-cli/src/pallet/writer.rs:308
let proof_size = analysis_function(results, BenchmarkSelector::ProofSize)

and --output-pov-analysis defaults to median-slopes, while the value written for the weight itself goes through min_squares_iqr.

The two analysis functions differ in exactly the way that matters here:

// analysis.rs:338 — median_slopes CLAMPS
let base = selector.scale_and_cast_weight(models[0].0.max(0f64), false);

// analysis.rs:419 — min_squares_iqr does NOT
base: selector.scale_and_cast_weight(intercept, true),

scale_and_cast_weight for a non-ExtrinsicTime selector is a bare cast:

// analysis.rs:59
(value + 0.5) as u128

What was ruled out

Two obvious explanations were tested and do not hold:

  1. Negative intercept cast to unsigned. Rust saturates float→integer casts, so a negative or NaN intercept becomes 0, not a huge number. Verified:

    value=-1.0    → (v+0.5) as u128 = 0
    value=-1e18   → (v+0.5) as u128 = 0
    value=NaN     → (v+0.5) as u128 = 0
    value=+inf    → (v+0.5) as u128 = 340282366920938463463374607431768211455
    

    Only +inf yields a large value, and it yields u128::MAX, not the observed numbers.

  2. The regression itself. Feeding linregress::fit_low_level_regression_model the same shape of data through the same wrapper produces correct results:

    y constant (85):        intercept=85.0       slope≈0
    y = 123500 + 7b:        intercept=123500.0   slope=7.0
    

So the corruption is upstream of the cast and of the regression: the BenchmarkResult values reaching the analysis, or the per-prefix aggregation in process_storage_results, are already wrong. The non-determinism across identical runs points that way too, a deterministic mis-parameterisation would produce the same wrong number every time.

--default-pov-mode measured does not help. It selects a different branch in process_storage_results, but the accumulation at writer.rs:643 (prefix_result.proof_size += pov_overhead * *reads) runs in that branch as well.

Suggested direction

Two things look worth doing independently of the root cause:

  1. Clamp in min_squares_iqr as median_slopes already does — a one-line change at analysis.rs:419. It would not fix the bad input, but a weight file is a consensus artefact: emitting u128-range garbage is worse than emitting a clamped value.

  2. Reject implausible proof_size output. A calculated proof size that exceeds, say, the maximum PoV a block can carry is never a legitimate result and could fail the run instead of being written to a file that compiles.

Happy to test a patch, the reproduction is stable in the sense that 50 steps reliably produces garbage on our benchmark, even though the specific value varies.

Steps to reproduce

The affected benchmark declares one linear component and calls a handler that reads a StorageMap belonging to another pallet which does not implement MaxEncodedLen (pallet-ismp's RequestCommitments, reported as ~50 UNKNOWN KEY entries):

#[benchmark]
fn dispatch_post(b: Linear<0, 8192>) {
    // ... dispatches through the other pallet's `IsmpDispatcher`
}
./node benchmark pallet \
  --chain dev --pallet my_pallet --extrinsic 'dispatch_post' \
  --steps 50 --repeat 20 --wasm-execution=compiled \
  --output weights.rs --template frame-weight-template.hbs

Notably, --steps 3 produces a correct value (3550) and --steps 50 does not, so the number of sampled points decides whether the output is usable.

Metadata

Metadata

Assignees

No one assigned

    Labels

    I10-unconfirmedIssue might be valid, but it's not yet known.I2-bugThe node fails to follow expected behavior.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions