Advanced gas optimization tips for Solidity
Juan Xavier Valverde
Posted on June 14, 2022
Layer 2 solutions for Ethereum are growing quite rapidly, and have helped in minimizing (or even eliminating) the sometimes exorbitant gas costs of transactions on Ethereum's main network. They have also allowed to have more speedy transactions, as well as more possible transactions per second (solving scalability issues).
If this is the case and gas costs can be greatly reduced, why would learning about gas optimizations be a good idea? Well, even when we execute transactions on other chains/layer 2 solutions, transactions can add up, and their costs will too. Besides, we must remember that layer 2 solutions (Optimism, Polygon, Arbitrum, etc) were designed for scalability purposes but are still working on top of Ethereum’s basic layer, so it’s essential to know how the gas works at the base layer, and how the Solidity compiler and the EVM interpret its value.
It’s also important to take into consideration that some gas fees are so low on certain layer 2 chains because of the level of adoption/use of the layer. If any layer 2 begins to be used heavily, the gas fees will also increase due to the basic principle of supply and demand.
Hopefully, by reading through this article you will get a solid understanding of some important topics on how to implement gas optimizations in Solidity, which can be very beneficial for any project.
Gas
Ethereum is a Turing-complete system. Turing-complete systems face the challenge of the halting problem i.e. given an arbitrary program and its input, it is not solvable to determine whether the program will eventually stop running. So Ethereum cannot predict if a smart contract will terminate, or how long it will run. Therefore, to constrain the resources used by a smart contract, Ethereum introduces a metering mechanism called gas. So, gas is the unit used in Ethereum for measuring and limiting computations per block.
As of June 2022, and referring to block size and gas, in the Ethereum documentation about gas it’s explained that: "Each block has a target size of 15 million gas, but the size of blocks will increase or decrease in accordance with network demand, up until the block limit of 30 million gas (2x the target block size)."
For more detailed information, feel free to reach out section 5 of Ethereum’s Yellow Paper, corresponding to gas specifications.
Function names
Solidity compiler reads and execute function names by their selector. The selector of a function is made up of the first four bytes of the keccak256 hash of the function signature (function name and parameters type). For example:
function tryThis(uint256 _value, string[] memory _names) external {}
In this case:
- Function signature = "tryThis(uint256,string[])"
- Function selector = keccak256(signature) = 0x7f6ca090.
The Solidity compiler will sort all the functions in a contract by their selector (in hexadecimal order) and will go through each of them when executing any function call to check which is the function selector called. Going through each of the function on a contract will cost 22 gas.
In this example, there are six (unsorted) functions named after colors. If we sort their selectors by hexadecimal order, we’d get:
red() => 2930cf24
white() => a0811074
yellow() => be9faf13
blue() => ed18f0a7
purple() => ed44cd44
green() => f2f1e132
By this example, calling the green() function will cost 110 more gas (5x22) than the red() function just because of its name.
Take this into consideration when naming a function which will be used heavily along the project's lifetime, trying to place its selector close to the top. Also, remember this while benchmarking for optimizing gas cost so that gas fluctuations are due to implementation differences only and not function name change.
Cold access vs warm access
As you can see on the table of gas costs, accessing a variable for the first time (Gcoldsload) costs 2,100 gas, while accessing it for the second time and further (Gwarmaccess), costs 100 gas. This difference in gas cost can turn into a big deal, specially if looping through a dynamically sized array with a large amount of items. Here’s an example:
Caching the data inside a function in Solidity can result in lower gas usage, even if it needs more lines of code. In this simple example, almost 2,000 gas was saved when caching the data.
Zero vs non-zero values and gas refunds
Changing a value from 0 to non-zero on Ethereum blockchain is expensive (Gsset = 20,000 gas), while changing a value from non-zero to 0, can give you a refund in gas value (Rsclear) (translated into a discount on execution price). It’s important to note that one can only get refunded by up to a maximum of 20% of the total transaction cost, meaning that one will only get a refund if the transaction costs a minimum of 24,000 gas. The according formula for gas refunds can be found in the Yellow Paper:
These zero and non-zero values being discussed could refer (in a more practical sense) to an account balance representing an amount of tokens, a boolean value, an integer, etc. Take the following case comparison as example:
CASE 1
Alice has 10 tokens and Bob has 0 tokens. Alice will send 5 tokens to Bob. This will change Alice balance from a non-zero value (10) to another non-zero (5), and it will change Bob’s balance from 0 to non-zero (10 tokens).
- Non-zero to non-zero (5,000 gas*) + zero to non-zero (20,000 gas) = 25,000 gas
CASE 2
Alice has 10 tokens, Bob has 0 tokens. Alice will send all her 10 tokens to Bob. This will change Alice balance from a non-zero value to zero, and Bob’s balance from non-zero (0) to non-zero (10).
- Non-zero to zero (5,000 gas*) + zero to non-zero (20,000 gas) - Refund (4,800 gas) = 21,200 gas
*2,100 (Gcolssload) + 2,900 (Gsreset) = 5,000 gas
Clearly the gas cost of the transaction in case 2 is cheaper due to the refund amount rewarded for changing Alice's balance value from non-zero to 0. This should help you note that for every non-zero to 0 operation, it’s a good idea to spend at least 24,000 gas elsewhere in the transaction (if it works within the project’s workflow).
IMPORTANT NOTE: As of the publication date of this article, Ethereum’s official Yellow Paper has still not updated its refund values according to EIP-3529 (which was published in April 2021). The current value for gas refunds (Rsclear) is 4,800 gas (previously 15,000 gas) and the max gas refunded percentage is 20% (previously 50%).
Memory vs calldata
Storing information inside calldata is always less expensive than storing it on memory, but it has a clear downside to it. When calldata is used, the value stored in it can't be mutated during the function execution. So, if you need to alter the data of a variable when executing a function call, use memory location instead. If you only need to read the data, you can save some gas by storing it in calldata.
Incrementing/Decrementing by 1
There are four different ways to increment or decrement by 1 using Solidity in the following example:
As you can imagine, this is because different op-codes are needed for each one of this different functions (which all achieve the exact same result). The most commonly used is probably the one from V1 contract, but it’s also the most expensive out of the four.
A good recommendation could be to prefer using the pre-increment expression (++number), so it increments the value before evaluating, saving some gas in the process.
Unchecked blocks: overflow/underflow
Since the release of Solidity 0.8.0, arithmetic overflow and underflow are taken care of by the Solidity compiler. In this sense, the contracts are more secure (from the arithmetic perspective) but a bit more expensive, in terms of gas. This is because behind the curtains there are op-codes checking if the number obtained post-operation makes sense (in an addition, for example, the result must be bigger than at least one of the terms).
But why would someone want to use an unchecked block running the risk of an overflow/underflow? For example, in a case where the contract has a function which increments just by one when called (and is preferably not called so frequently), or if the contract imports Open Zeppelin’s Counters library. This is safe because the probability of causing an overflow/underflow by incrementing just by one on each transaction is quite close to 0 due to all the time and gas needed for reaching the 2^256 number.
Knowing this, we can make use of unchecked blocks of code to save gas when we know the architectural flow of the contract implementation will not allow to cause an overflow or underflow under the unchecked conditions.
Optimizer
Solidity Optimizer can have an effect on two aspects of smart contacts: deployment cost or function call cost. The less “runs” set in the optimizer (let’s say 200), the more effect it will have on deployment cost. On the other hand, the more runs (let’s say 10,000), the more effect it will have on function call costs. Here’s a clear and simple example of three different optimizer settings:
For optimizing gas costs, always use Solidity optimizer. It’s a good practice to set the optimizer as high as possible just until it no longer helps reducing gas costs on function calls. This can be recommended because function calls are intended to be executed way more times than the deployment of the contract, which happens just once.
But, if the project you’re working on is really sensitive about deployment cost, playing with low optimizer values just until it no longer helps reducing the deployment cost should help.
Payable vs non-payable
Payable functions are cheaper than non-payable ones because for the non-payable ones, the contracts need to have some extra op-codes to be ready to check if another contract or an external account is trying to send ETH to it, and if so, revert the transaction. Payable functions don’t have those extra op-codes, allowing the function to receive ETH and making them (counter-intuitively) cheaper.
Memory expansion cost
When a contract call needs to use more than 32 kilobytes of memory storage in a single transaction, the memory cost will enter into a quadratic section of the following expression (equation 326 of the Yellow Paper):
Here's a clear example:
As you may see, the cost of adding 10,000 uint256 to memory is close to ten times the cost of adding 1,000 uint256 to memory (276,761 gas vs 29,261 gas), which makes sense because 10,000 is ten times 1,000. Up to this point, memory usage is still in the linear part of the equation.
But, the cost of adding 20,000 uint256 to memory (922,855 gas), does not cost just double, but nearly 4 times the 10,000 uint256 gas value. In the "TwentyK" contract, the amount of kilobytes used in memory reached the second part of the equation.
To avoid this memory cost explosion, try breaking down the implementation of a transaction into pieces and also, do not populate arrays with enormous amounts of items in a single transaction.
Less/greater than or equal to
In Solidity, there is no single op-code for ≤ or ≥ expressions. What happens under the hood is that the Solidity compiler executes the LT/GT (less than/greater than) op-code and afterwards it executes an ISZERO op-code to check if the result of the previous comparison (LT/ GT) is zero and validate it or not. Example:
The gas cost between these contract differs by 3 which is the cost executing the ISZERO op-code, making the use of < and > cheaper than ≤ and ≥.
Operators: and/or
When having a require statement with 2 or more expressions needed, place the expression that cost less gas first. So, in require statements with && or || operators, place the cheapest expression first for execution, so that the second and most expensive expression can (sometimes) be bypassed. For example:
require ( A || B ): If a is true, Solidity compiler won’t check the next statement because it’s not needed.
require ( A && B ): If a is false, Solidity compiler won’t check the next statement because it’s not needed.
In this examples, the best approach would be placing the cheapest expression as A.
Bonus takeaways for gas savings
- Always try reverting transactions as early as possible when using require statements (most preferably before writing in storage). In case a transaction revert occurs, the user will pay the gas up until the revert was executed (not afterwards).
- Although counter-intuitive, using uint256 is generally cheaper than uint8, uint16, etc. Except is you are manipulating them in the same transaction, requiring 32 bytes or less.
- When you transfer ETH, it will always cost at least 21,000 gas (Gtransaction). Gas price changes depending on network conditions and ETH value in relation to USD.
- The smaller the contract (the less and least expensive op-codes), the less gas needed for deployment.
- During contract function calls, using fewer or cheaper op-codes saves gas. Evidently, the larger the transaction data or the more non zero bytes, the more expensive it will be.
- The more memory you allocate (even if you don’t use it), the more gas you pay.
- The more storage used, the more gas used, so, the more expensive.
- Counting down is more efficient than counting up. At least the last countdown, when converting a non-zero value to 0.
- Immutable and constant variables are cheaper on gas. If the state variables of a contract won't change during its lifetime, declare them as immutable or constant.
Conclusion
In this article, we've covered some really interesting aspects on how the Solidity compiler behaves related to gas, and how we can make use of this knowledge as developers for optimizing costs either for investors, users or both. Besides, this information can help create smart contracts with less computation costs which can help not only from a financial perspective, but also help develop the most eco-friendly projects possible.
Bibliography and references
https://www.udemy.com/course/advanced-solidity-understanding-and-optimizing-gas-costs/
https://ethereum.github.io/yellowpaper/paper.pdf
https://eips.ethereum.org/EIPS/eip-1559
Posted on June 14, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.