Skip to content

perf(evm): single-pass execute and reserve-based simulate gas limit - #62

Open
kyscott18 wants to merge 5 commits into
mainfrom
perf/execute-single-pass
Open

kyscott18 wants to merge 5 commits into
mainfrom
perf/execute-single-pass

Conversation

@kyscott18

@kyscott18 kyscott18 commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Depends on #57. This branch includes that commit. Merge #57 first.

Summary

Execute

  • execute runs once, keeps that post-state, and records the touched accounts and slots as the journal's access list.
  • execute's gas_used is the gas with cold access. No runtime code reads it.

Simulate

  • simulate runs the discovery pass (skipped when the journals give a cached access list), then one pass at max gas with the access list warm. The binary search and the final pass are removed.
  • gas_limit = gas_used + reserve. Each frame reserves 2,301 gas for the SSTORE sentry (EIP-2200), and enough that 63/64 of its remaining gas covers each child's gas plus the child's reserve (EIP-150). Precompiles get no SSTORE reserve. Monad has no refunds, so gas_used is the gas spent.
  • At that limit, the tx takes the same path as at max gas. The limit can be up to about 2,300 gas above the true minimum.
  • Behavior change: if a call halts (for example, a precompile runs out of gas or rejects its input), simulate returns an error. A halted call uses all the gas it gets, so no gas limit that the chain accepts can run the tx.

Other

  • The access list leaves out all precompiles of the active spec. Before, the filter covered only 0x01–0x0a, so P-256 at 0x100 was in the access list.
  • ExecuteResult and SimulateResult are separate types in Rust and TS. SimulateResult no longer has journal_id or slot_writes.
  • harness.rs is simpler: one Journal type writes pre or post images, access lists merge through ordered sets, the tx fields are parsed once per request, and dispatch uses one response helper. The wire format does not change.
  • The typewriter-evm test script now also runs cargo test --lib.

Results

Same machine, PR head before these changes (9008e45) against 4bc8675:

before after
packages/typewriter/test/harness.benchmark.ts (3 runs) 3,765–3,775 mutations/s 3,694–3,847 mutations/s
typewriter-evm simulate, discovered, burn 5000 5.15 ms 3.30 ms
typewriter-evm simulate, cached, burn 5000 3.72 ms 1.43 ms

The harness benchmark shows no measurable change: simulate does not limit it. typewriter-evm execute throughput varied from 99k to 133k calls/s between runs, so it gives no clear result.

Test plan

  • bun run lint, bun run typecheck, bun run test
  • Rust tests: the tx fails at gas_used and succeeds at gas_limit for an SSTORE sentry, precompile calls (0x01, 0x100), and nested calls. A halted call returns an error.
  • The access list leaves out 0x100 and matches eth_createAccessList.

🤖 Generated with Claude Code

RetriggerConfidence Score: 4/5

Fix the outstanding rejection of successful transactions with caught child failures before merging.

Fix All in Claude CodeFindings

  1. P1 Caught calls block submission ▶
  2. P2 Gas tests lose coverage ▶
Fix with agent prompt
### Issue 1
packages/typewriter-evm/src/harness.rs:435-436
`frame_end` marks every halted child as impossible to run at any gas limit. A contract can call a child with a fixed gas budget, catch its `INVALID` or out-of-gas failure, and still succeed. The child consumes only that budget, but `finish` returns an error. This makes `simulate` reject the successful transaction and stops submission.

Track the child's gas budget instead of rejecting every halt.

### Issue 2
packages/typewriter-evm/src/harness.rs:897-900
The gas tracker was replaced, but the tests for `CREATE` and calls that send value were deleted without replacements. The remaining call tests all send zero value, so mistakes in those gas rules could pass unnoticed. Keep tests that run these transactions at the returned `gas_limit` and check that they succeed. This is non-blocking test feedback.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

execute now keeps the state from one cold-access run and saves the touched accounts and slots in its journal.

  • Executed transactions run once and keep the cold-access result.
  • Simulations estimate gas from one warm run and a reserve.
  • Discovered access lists skip precompiles that are already warm.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  E[execute] --> D[Run without an access list]
  D --> A[Collect touched accounts and slots]
  A --> J[Save journal and commit successful state]
  S[simulate] --> U[Undo selected journals]
  U --> C{Cached access list?}
  C -->|No| F[Discover access list and discard state]
  C -->|Yes| W[Run with warm access list]
  F --> W
  W --> G[Return gas used plus frame reserve]
  G --> R[Discard simulated state and replay journals]
Loading

Reviews (3) · Last reviewed commit: "refactor(evm): replace the gas tracker w..." · Reviewed by Greptile

Execute ran pass 1, pass 2, a gas-limit binary search (~15 runs), and a
final pass: about 18 EVM runs per accepted mutation. Only simulate needs
a gas limit, because it builds the onchain transaction. Execute now runs
pass 1 and pass 2 only, and its result has no gas_limit. Simulate returns
the new SimulateResult type, which keeps gas_limit.

Counter benchmark: evm execute 1.13ms -> 0.20ms per mutation.
Harness benchmark: 2,236 -> 3,026 mutations/s.
Execute ran pass 1 to discover the access list, then pass 2 with that
access list pre-warmed. Both passes run with the maximum gas limit, so
they produce the same state. Pass 2 only changed gas_used, which the
runtime does not read from execute. Execute now runs once, keeps that
post-state, and records the touched accounts and slots as the journal's
access list. Simulate keeps both passes and the gas-limit search.

typewriter-evm execute benchmark: erc20 transfer 15.2us -> 12.1us.
Counter benchmark: 32 in flight 2,827 -> 3,264-3,493 mutations/s;
open loop at 4,000/s now keeps up (3,970/s, p50 ~280ms -> 10-21ms).
Simulate now computes the smallest gas limit that keeps the max-gas path
in one traced pass, instead of a binary search and a final rerun. The
tracer applies the EIP-150 63/64 rule to each call or create and the
EIP-2200 SSTORE sentry. Monad has no refunds. A call that halts uses all
of its gas, so simulate now returns an error for it.

- Split the execute and simulate result types in Rust and TS.
- Parse the tx fields once per request.
- Filter precompiles from the access list with the spec's precompile set,
  so P-256 at 0x100 is no longer included.
- Add Rust tests that check each traced limit is the minimum, and run them
  in the package test script.
- Add tests for simulate with a cached journal and for the P-256 access list.
Typewriter apps do not make external calls, and the ERC20 journal test
already covers simulate with a cached journal.
Comment thread packages/typewriter-evm/src/harness.rs Outdated
Comment on lines +435 to +436
if !result.result.is_ok_or_revert() {
self.halted = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Caught calls block submission

frame_end marks every halted child as impossible to run at any gas limit. A contract can call a child with a fixed gas budget, catch its INVALID or out-of-gas failure, and still succeed. The child consumes only that budget, but finish returns an error. This makes simulate reject the successful transaction and stops submission.

Track the child's gas budget instead of rejecting every halt.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/typewriter-evm/src/harness.rs
Line: 435-436

Comment:
**Caught calls block submission**

`frame_end` marks every halted child as impossible to run at any gas limit. A contract can call a child with a fixed gas budget, catch its `INVALID` or out-of-gas failure, and still succeed. The child consumes only that budget, but `finish` returns an error. This makes `simulate` reject the successful transaction and stops submission.

Track the child's gas budget instead of rejecting every halt.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

Simulate now sets the gas limit to gas_used plus a reserve. Each frame
reserves 2,301 gas for the SSTORE sentry and enough that 63/64 of its
remaining gas covers each child's gas plus the child's reserve. This
removes the per-opcode gas tracking. A call that halts is still an error.

Also simplify harness.rs: one Journal type writes pre or post images,
access lists merge through ordered sets, and dispatch uses one response
helper. The wire format does not change.
@kyscott18 kyscott18 changed the title perf(evm): run execute in a single pass perf(evm): single-pass execute and reserve-based simulate gas limit Oct 13, 2026
Comment on lines +897 to +900
fn reserve_covers_nested_calls() {
let outer = require_success(call(CALL, CALLEE, GAS));
let middle = require_success(call(CALL, INNER, GAS));
assert_reserve_needed(&[(CONTRACT, &outer), (CALLEE, &middle), (INNER, COUNTER)]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Gas tests lose coverage

The gas tracker was replaced, but the tests for CREATE and calls that send value were deleted without replacements. The remaining call tests all send zero value, so mistakes in those gas rules could pass unnoticed. Keep tests that run these transactions at the returned gas_limit and check that they succeed. This is non-blocking test feedback.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/typewriter-evm/src/harness.rs
Line: 897-900

Comment:
**Gas tests lose coverage**

The gas tracker was replaced, but the tests for `CREATE` and calls that send value were deleted without replacements. The remaining call tests all send zero value, so mistakes in those gas rules could pass unnoticed. Keep tests that run these transactions at the returned `gas_limit` and check that they succeed. This is non-blocking test feedback.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Claude Code

This branch has not been deployed

No deployments
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.

1 participant