Using newer compiler versions and the optimizer gives gas optimizations and additional safety checks for free!
The advantages of versions 0.8.*
over <0.8.0
are:
- Safemath by default from
0.8.0
(can be more gas efficient than some library based safemath). - Low level inliner from
0.8.2
, leads to cheaper runtime gas. Especially relevant when the contract has small functions. For example, OpenZeppelin libraries typically have a lot of small helper functions and if they are not inlined, they cost an additional 20 to 40 gas because of 2 extrajump
instructions and additional stack operations needed for function calls. - Optimizer improvements in packed structs: Before
0.8.3
, storing packed structs, in some cases used an additional storage read operation. After EIP-2929, if the slot was already cold, this means unnecessary stack operations and extra deploy time costs. However, if the slot was already warm, this means additional cost of100
gas alongside the same unnecessary stack operations and extra deploy time costs. - Custom errors from
0.8.4
, leads to cheaper deploy time cost and run time cost. Note: the run time cost is only relevant when the revert condition is met. In short, replace revert strings by custom errors.
Consider a generic example of an array arr
and the following loop:
for (uint i = 0; i < arr.length; i++) {
// do something that doesn't change arr.length
}
In the above case, the solidity compiler will always read the length of the array during each iteration. That is,
- if it is a
storage
array, this is an extrasload
operation (100 additional extra gas (EIP-2929) for each iteration except for the first), - if it is a
memory
array, this is an extramload
operation (3 additional gas for each iteration except for the first), - if it is a
calldata
array, this is an extracalldataload
operation (3 additional gas for each iteration except for the first)
This extra costs can be avoided by caching the array length (in stack):
uint length = arr.length;
for (uint i = 0; i < length; i++) {
// do something that doesn't change arr.length
}
In the above example, the sload
or mload
or calldataload
operation is only called once and subsequently replaced by a cheap
dupN
instruction. Even though mload
, calldataload
and dupN
have the same gas cost, mload
and calldataload
needs an
additional dupN
to put the offset in the stack, i.e., an
extra 3 gas.
This optimization is especially important if it is a storage array or if it is a lengthy for loop.
Note that the Yul based optimizer (not enabled by default; only
relevant if you are using --experimental-via-ir
or the equivalent
in standard JSON) can sometimes do this caching automatically.
However, this is likely not the case in your project. Reference. Also see this.
In some cases, having function arguments in calldata
instead of
memory
is more optimal.
Consider the following generic example:
contract C {
function add(uint[] memory arr) external returns (uint sum) {
uint length = arr.length;
for (uint i = 0; i < arr.length; i++) {
sum += arr[i];
}
}
}
In the above example, the dynamic array arr
has the storage
location memory
. When the function gets called externally, the
array values are kept in calldata
and copied to memory
during
ABI decoding (using the opcode calldataload
and mstore
). And
during the for loop, arr[i]
accesses the value in memory using a
mload
. However, for the above example this is inefficient.
Consider the following snippet instead:
contract C {
function add(uint[] calldata arr) external returns (uint sum) {
uint length = arr.length;
for (uint i = 0; i < arr.length; i++) {
sum += arr[i];
}
}
}
In the above snippet, instead of going via memory, the value is
directly read from calldata
using calldataload
. That is, there
are no intermediate memory operations that carries this value.
Gas savings: In the former example, the ABI decoding begins with
copying value from calldata
to memory
in a for loop. Each
iteration would cost at least 60 gas. In the latter example, this
can be completely avoided. This will also reduce the number of
instructions and therefore reduces the deploy time cost of the
contract.
In short, use calldata
instead of memory
if the function
argument is only read.
Note that in older Solidity versions, changing some function
arguments from memory
to calldata
may cause “unimplemented
feature error”. This can be avoided by using a newer (0.8.*
)
Solidity compiler.
Solidity 0.6.5 introduced immutable
as a major feature. It allows
setting contract-level variables at construction time which gets
stored in code rather than storage.
Consider the following generic example:
contract C {
/// The owner is set during contruction time, and never changed afterwards.
address public owner = msg.sender;
}
In the above example, each call to the function owner()
reads from
storage, using a sload
. After EIP-2929, this costs 2100 gas cold
or 100 gas warm. However, the following snippet is more gas
efficient:
contract C {
/// The owner is set during contruction time, and never changed afterwards.
address public immutable owner = msg.sender;
}
In the above example, each storage read of the owner
state
variable is replaced by the instruction push32 value
, where
value
is set during contract construction time. Unlike the last
example, this costs only 3 gas.
Consider the following require statement:
// condition is boolean
// str is a string
require(condition, str)
The string str
is split into 32-byte sized chunks and then stored
in memory using mstore
, then the memory offsets are provided to
revert(offset, length)
. For chunks shorter than 32 bytes, and for
low --optimize-runs
value (usually even the default value of 200),
instead of push32 val
, where val
is the 32 byte hexadecimal
representation of the string with 0
padding on the least
significant bits, the solidity compiler replaces it by shl(value,
short-value))
. Where short-value
does not have any 0
padding.
This saves the total bytes in the deploy code and therefore saves
deploy time cost, at the expense of extra 6
gas during runtime.
This means that shorter revert strings saves deploy time costs of
the contract. Note that this kind of saving is not relevant for high
values of --optimize-runs
as push32 value
will not be replaced
by a shl(..., ...)
equivalent by the Solidity compiler.
Going back, each 32 byte chunk of the string requires an extra
mstore
. That is, additional cost for mstore
, memory expansion
costs, as well as stack operations. Note that, this runtime cost is
only relevant when the revert condition is met.
Overall, shorter revert strings can save deploy time as well as runtime costs.
Note that if your contracts already allow using at least Solidity
0.8.4
, then consider using Custom errors. This is more gas
efficient, while allowing the developer to describe the errors in
detail using NatSpec. A disadvantage to this approach is that, some
tooling may not have proper support for this.
(This is only relevant if you are using the default solidity checked arithmetic.)
Consider the following generic for loop:
for (uint i = 0; i < length; i++) {
// do something that doesn't change the value of i
}
In this example, the for loop post condition, i.e., i++
involves
checked arithmetic, which is not required. This is because the value
of i
is always strictly less than length <= 2**256 - 1
.
Therefore, the theoretical maximum value of i
to enter the
for-loop body is 2**256 - 2
. This means that the i++
in the for
loop can never overflow. Regardless, the overflow checks are
performed by the compiler.
Unfortunately, the Solidity optimizer is not smart enough to detect this and remove the checks. One can manually do this by:
for (uint i = 0; i < length; i = unchecked_inc(i)) {
// do something that doesn't change the value of i
}
function unchecked_inc(uint i) returns (uint) {
unchecked {
return i + 1;
}
}
Note that it’s important that the call to unchecked_inc
is
inlined. This is only possible for solidity versions starting from
0.8.2
.
Gas savings: roughly speaking this can save 30-40 gas per loop iteration. For lengthy loops, this can be significant!
Solidity 0.8.4 introduced custom errors. They are more gas efficient than revert strings, when it comes to deploy cost as well as runtime cost when the revert condition is met. Use custom errors instead of revert strings for gas savings.
@misirov About the for loop, both are the same.
Regarding constants: yeah, they should be the same with the optimizer. A lot of times, the constants are only known at deploy time, for example in a factory contract.