Skip to content
LogoLogo

Bytecode size and gas

Lithe's restrictions make its programs easier to compile, as well as easier to review. Across the benchmark suite, Lithe's runtime bytecode is about 32% smaller than the best of five optimized solc builds of the equivalent Solidity. Every one of the 51 contracts comes in smaller, most by 20–45%.

Runtime bytecode

Runtime bytecode size in bytes. Smaller is better. solc is 0.8.36, and each column is one optimizer configuration:

contractlithesolc ir r1solc ir r200solc ir r10ksolc ir r1Msolc r200lithe vs best solc
base6489113011322165516551170-23.8%
book116012471251176617661585-7.0%
codec259626572652304936193117-2.1%
collections62710851098138017751392-42.2%
counter198331336489489408-40.2%
date_time116612251272249227921860-4.8%
deployers150319161903261729172877-21.0%
dialer140118031841231524952229-22.3%
env81128128213213137-36.7%
erc1155501366106788931599757072-24.2%
erc20224430903135443951593056-26.6%
erc20_full73821051210747136121602110226-27.8%
erc4626307536833735475253524408-16.5%
erc6909332645584653565865654587-27.0%
erc721307447594837718884385055-35.4%
erc721_full56398611873911869140908421-33.0%
exchange417614615822822850-32.1%
executor80714391440202620261798-43.9%
fixed_point283135263553428547403683-19.7%
flash_borrower82210131056144614461225-18.9%
layout70510931094130816681447-35.5%
lists162423572352278531752698-31.0%
logs277369371565565529-24.9%
loops87113921401171720171759-37.4%
math4246726728099891063-36.9%
names131227432805301137082869-52.2%
numerics167931763185419549153764-47.1%
overloads4587157238491089906-35.9%
ownable_roles95212851296169521151455-25.9%
profile5861135116215151515866-32.3%
reasons84413121312188420641808-35.7%
receiver45469173710941094885-34.3%
receiver115592616111770237223721806-42.5%
records152033983455448954074127-55.3%
registry103421902240253332001699-39.1%
relay188630813147349440402800-32.6%
safe_erc20543655655955955757-17.1%
selectors39962062212141214769-35.6%
shapes130617901800247426842227-27.0%
solady_erc1155270734293474376543654568-21.1%
solady_erc20139520832109238828082317-33.0%
solady_erc721211027172745346540393138-22.3%
staking248632563263411848383352-23.6%
strings172423542389289032542362-26.8%
tally439599641823823618-26.7%
texts410564776633709286666431-36.2%
token357566571834834765-36.9%
traits468647651979979905-27.7%
uniswap_v2_factory603897079787123591374710120-37.8%
uniswap_v2_pair5073768077139634107828300-33.9%
vault387618620951951856-37.4%
total89342130526132496169644193227143122-31.6%

The builds are --via-ir with 1, 200, 10,000, and 1,000,000 optimizer runs, and the legacy pipeline with 200 runs. The high run counts trade size for gas, so they set the bar for the gas comparison below. All are compiled --no-cbor-metadata, so metadata doesn't count against Solidity.

Smaller code means cheaper deployment, and more room under the EIP-170 contract size limit of 24,576 bytes. A contract that still doesn't fit can run modules from shared facets. Deployment gas falls with it: about 19% across the suite, 22% for the ERC-20, 31% for the ERC-721, and 36% for the Uniswap v2 factory. Each contract is set up as it deploys, by a deploy in Lithe and a constructor in Solidity, with the same arguments.

Gas per transaction

Storage reads and writes dominate the cost of most transactions, and both compilers touch the same slots, so what separates them is everything else a transaction runs: dispatch, argument decoding, arithmetic, loops, memory, hashing, and encoding. On that part Lithe spends about a third less gas than the best solc build of each contract, and about 6% less in total execution gas, or 2% leaving out the pairs the Uniswap factory deploys. Of the 1,577 transactions and calls in the suite, Lithe costs less than every solc build on 1,253, the same on 195, and more on 129, most of those by under 2%. The compiler weighs each choice between smaller and faster code by an estimate of how often the code runs, so a hot path can spend a few bytes to save gas on every call.

Some transactions still cost more than their Solidity versions. A transition that defers an external call runs its storage writes first, so when the call then reverts, those writes were paid for. A Solidity contract that makes the call before writing reverts sooner.

The comparison is like for like

Most of the benchmark contracts are real programs, not microbenchmarks:

  • erc20: OpenZeppelin's wizard token, burnable, pausable, and ownable, on the upgradeable contracts deployed without a proxy
  • erc20_full: the wizard token with every common add-on: roles for minting and pausing, a cap, permits, votes with delegation and a checkpointed history, and flash loans through a borrower contract
  • erc721: OpenZeppelin's wizard NFT with the same extensions, plus safeTransferFrom against a receiver contract
  • erc721_full: the wizard NFT with every common add-on: enumeration, URI storage with a base URI, burning, pausing, auto-increment minting, and ERC-2981 royalties
  • erc1155: OpenZeppelin's wizard multi-token, burnable, pausable, and ownable, with supply tracking and an updatable URI
  • erc4626: OpenZeppelin's ERC4626 vault
  • erc6909: OpenZeppelin's ERC6909 with its token supply, metadata, and content URI extensions, built from the standard library's modules, whose storage sits at their own ERC-7201 roots, so its slots are left out like Solady's
  • staking: Synthetix's StakingRewards on OpenZeppelin's Ownable and Initializable
  • uniswap_v2_factory and uniswap_v2_pair: Uniswap v2-core, ported to Solidity 0.8, with the factory creating each pair with CREATE2. A pair lands at a different address in each build, so the comparison names it by role, and swaps both ways, liquidity, the protocol fee, and a signed permit replay on each
  • fixed_point, date_time, and base64: Solady's FixedPointMathLib, DateTimeLib, and Base64, next to Lithe's standard library
  • deployers and safe_erc20: OpenZeppelin's Clones and SafeERC20, and Solady's SSTORE2 and CREATE3
  • executor and exchange: calls whose results feed continuations
  • records and layout: packed records, fixed lists, and tuples in storage
  • solady_erc20, solady_erc721, and solady_erc1155: Solady's hand-written assembly tokens, with EIP-2612 permits and the Permit2 allowance on the ERC-20, and safe transfers against receiver contracts on the others, next to plain Lithe with the same errors, events, and order of checks

The rest each exercise one part of the language: strings, lists, records, logs, loops, ABI encoding, low-level calls, and errors.

Each Solidity version uses OpenZeppelin where the original does. The benchmark deploys every build of every contract to a local chain and replays the same transactions against each one. It then checks that statuses, return data, logs, and every storage slot agree across all six builds. Lithe lays out storage exactly as Solidity does, so the raw slots have to match byte for byte. Solady's tokens are the exception: they put each value at a hashed slot of their own, which no Solidity layout produces, so their slots are left out and everything else is still compared.

Reproducing

The benchmark needs Foundry (anvil and cast) and solc. Set SOLC to pick a specific binary. From the repository root:

cargo build --release
python3 bench/run.py            # every contract
python3 bench/run.py erc20      # one contract

The Lithe and Solidity sources are side by side in bench/: erc20.lithe next to erc20.sol, and so on. A contract answers ERC-165 only where it implements it, in both languages: erc721, erc1155, erc6909, and erc20_full list their standards in supports, and erc721_full writes its own supportsInterface to add ERC-4906.

These numbers come from the compiler version this site was built with. Rerun the benchmark for current figures.