Skip to content

test(gnovm): bench the gas inputs a caller controls - #5994

Draft
davd-gzl wants to merge 4 commits into
gnolang:masterfrom
davd-gzl:fix/ibc-crypto-gas-calibration
Draft

test(gnovm): bench the gas inputs a caller controls#5994
davd-gzl wants to merge 4 commits into
gnolang:masterfrom
davd-gzl:fix/ibc-crypto-gas-calibration

Conversation

@davd-gzl

@davd-gzl davd-gzl commented Jul 21, 2026

Copy link
Copy Markdown
Member

The gas table for native functions is generated from these benchmarks, so a constant can only describe an input shape the benchmark actually measured. Two entries never measured theirs.

innerHash was benched once, at 32 bytes per side. One data point fits only as a flat charge, so the table bills 7513 whatever the size, while both arguments accept a slice of any length.

32 B per side       3899 ns
4096 B per side    69476 ns     same 7513 charge

modExp ties base, exponent and modulus to one size, so it only ever samples len(exp) == len(mod). Nothing makes a caller respect that.

256 B modulus,   32 B exponent     301974 ns
256 B modulus, 1024 B exponent    9105730 ns     same charge

Now benched across sizes, and off the diagonal, so the fit can see what it is pricing.

Also gives the BigDec subtraction benchmark distinct operands; it was measuring x - x. That one is hygiene rather than a live problem: measured either way the difference is small, so it is not what produced the current constant.

No table values change here. Regenerating the table needs the reference hardware its own header already calls for.

The IBC gas table is generated by gen_native_table.py from these
benchmarks, so a constant can only describe a shape the benchmark
actually sampled. Two entries never sampled theirs.

innerHash was benched once, with two fixed 32-byte digests. One point
fits only as SizeFlat, so the table charges 7513 regardless of size while
both arguments accept []byte of any length. Measured through the same
dispatch harness, 4096 bytes per side costs 69476ns against that flat
7513, and it keeps growing. Now benched at 32/256/1024/4096 so the fitter
can see both arguments.

modExp ties base, exponent and modulus to a single n, sampling only the
diagonal len(exp) == len(mod). Its own comment says cost is len(exp) *
len(mod), but nothing makes a caller respect the diagonal: with the
modulus held at the 256-byte ceiling, raising the exponent from 32 to
1024 bytes takes 301974ns to 9105730ns, none of which the fit can see.
Adds off-diagonal rows that vary the exponent alone.

No table values change here. The table must be regenerated through the
fitter on reference hardware, which its own header already requires
before any consensus-relevant deployment.
@Gno2D2

Gno2D2 commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

🛠 PR Checks Summary

All Automated Checks passed. ✅

Manual Checks (for Reviewers):
  • IGNORE the bot requirements for this PR (force green CI check)
Read More

🤖 This bot helps streamline PR reviews by verifying automated checks and providing guidance for contributors and reviewers.

✅ Automated Checks (for Contributors):

🟢 Maintainers must be able to edit this pull request (more info)
🟢 Pending initial approval by a review team member, or review from tech-staff

☑️ Contributor Actions:
  1. Fix any issues flagged by automated checks.
  2. Follow the Contributor Checklist to ensure your PR is ready for review.
    • Add new tests, or document why they are unnecessary.
    • Provide clear examples/screenshots, if necessary.
    • Update documentation, if required.
    • Ensure no breaking changes, or include BREAKING CHANGE notes.
    • Link related issues/PRs, where applicable.
☑️ Reviewer Actions:
  1. Complete manual checks for the PR, including the guidelines and additional checks if applicable.
📚 Resources:
Debug
Automated Checks
Maintainers must be able to edit this pull request (more info)

If

🟢 Condition met
└── 🟢 And
    ├── 🟢 The base branch matches this pattern: ^master$
    └── 🟢 The pull request was created from a fork (head branch repo: davd-gzl/gno)

Then

🟢 Requirement satisfied
└── 🟢 Maintainer can modify this pull request

Pending initial approval by a review team member, or review from tech-staff

If

🟢 Condition met
└── 🟢 And
    ├── 🟢 The base branch matches this pattern: ^master$
    └── 🟢 Not (🔴 Pull request author is a member of the team: tech-staff)

Then

🟢 Requirement satisfied
└── 🟢 If
    ├── 🟢 Condition
    │   └── 🟢 Or
    │       ├── 🔴 At least one of these user(s) reviewed the pull request: [aronpark1007 davd-gzl jefft0 notJoon omarsy MikaelVallenet] (with state "APPROVED")
    │       ├── 🔴 At least 1 user(s) of the team tech-staff reviewed pull request
    │       └── 🟢 This pull request is a draft
    └── 🟢 Then
        └── 🟢 Not (🔴 This label is applied to pull request: review/triage-pending)

Manual Checks
**IGNORE** the bot requirements for this PR (force green CI check)

If

🟢 Condition met
└── 🟢 On every pull request

Can be checked by

  • Any user with comment edit permission

makeBigDec is deterministic, so calling it twice with the same argument
produced two equal values and the benchmark measured x - x rather than a
general subtraction.

This is hygiene, not the explanation for the current constants. Measured
either way the difference is small (872ns equal, 756ns distinct at 1000
digits), so the degenerate input is not what produced OpCPUSlopeBigDecSub.

What the measurement does show is that the two constants disagree with the
work. Across 100 to 10000 digits, subtraction costs 0.675 ns/digit and
addition 0.694, essentially identical, while the table charges 0.20 and
3.75 respectively. The ratio is hardware-independent even though the
absolute numbers are not, and an 18.75x gap between two operations that
measure the same is not supported by any input shape I could construct.

Both constants need refitting on reference hardware over a range where the
slope is visible: at 10 and 100 digits both operations sit at their base
cost, so a fit weighted to small inputs describes noise.
@davd-gzl
davd-gzl force-pushed the fix/ibc-crypto-gas-calibration branch from ca00395 to a8faf20 Compare July 21, 2026 18:45
davd-gzl added 2 commits July 21, 2026 21:23
The gas table in gnovm/stdlibs/native_gas.go is generated by
gen_native_table.py, whose spec list could only describe one variable
argument per native: a single (slope_idx, kind) pair, or nothing at all.
Two IBC crypto natives need two, and the spec list silently priced them
wrong.

crypto/merkle.innerHash(left, right []byte) hashes both arguments, but
was declared "Flat". A flat charge prices megabytes at the cost of the
64-byte digest case it was calibrated on.

crypto/modexp.modExp(base, exp, modulus []byte) was declared with a
slope on argument 2, the modulus. The exponent drives the work and was
charged nothing, so a small modulus with a huge exponent is cheap to buy
and expensive to run.

Neither could be corrected by editing the spec list, because the schema
had nowhere to put the second argument. The Go side already summed
Slope and Slope2 over independent indices and kinds; only the Python
side could not express it.

Add NATIVE_SPECS_2ARG, carrying two (index, kind) pairs and a regex that
captures both sizes, fit as cost = base + s1*N1 + s2*N2 and emitted as
Slope/SlopeIdx/SlopeKind plus Slope2/Slope2Idx/Slope2Kind. This is not
NATIVE_SPECS_2D, which measures one argument two ways and gives both its
slopes the same index. fit_2d is generalized into fit_plane, which both
forms now share; 2-D keeps the count*perElem product its second column
always was.

Move innerHash and modExp onto the new form. Their old regexes matched
benchmark names that no longer exist, so the fitter was not even reading
their data: innerHash dropped out of the table entirely and modExp
demoted to a flat 24327ns against calls measured at 19ms.

Re-grid the benchmarks so the two arguments vary independently. On the
diagonal N1 == N2 the design columns are identical and the split between
the slopes is arbitrary, so innerHash gains off-diagonal L/R pairs and
modExp sweeps the exponent with the modulus pinned and vice versa. The
fitter now reports when a grid is collinear rather than emitting an
unidentified split.

Modular exponentiation is superlinear in both lengths and an additive
model cannot express that, so the fitter also replays the emitted
integers against the measurements and warns when the entry undercharges
its own worst sampled point. modExp trips it at 14x; innerHash fits at
R²=0.998, as it should, being a single sha256 over the concatenation.

Constants in native_gas.go are unchanged: they must be measured on the
reference hardware named in that file's header.
…g ones

The undercharge replay only ran on the two-slope path, so the shape that
actually shipped the bad prices was unchecked. Worse, a poor linear fit
was silently demoted to a flat charge: R^2 is low precisely when cost is
superlinear, so the weakest possible pricing got chosen exactly when the
input was least safe to leave unpriced. That is how innerHash came to
bill 7513 for a slice of any length.

Replay the emitted integers for one-parameter and flat entries too, and
report the measured spread when demoting, so a correct demotion (params
setters move 0.9x over N=1..1000) reads differently from a dangerous one
(pairingCheck moves 2.3x over N=1..4).

Also corrects the modExp spec comment. It claimed the fit was
trustworthy over the benched range; the generator measures a 14-18x
undercharge at 512-byte exponent against 512-byte modulus, which is a
benched point, and X_modExp enforces no ceiling at all.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

📦 🤖 gnovm Issues or PRs gnovm related

Projects

Development

Successfully merging this pull request may close these issues.

2 participants