Optimizing Gas Fees in Ethereum
Understanding Gas in Ethereum
Ethereum requires the execution of smart contracts and transactions to be paid in gas fees. These fees are critical as they incentivise miners to include transactions in blocks and prevent abuse of network resources. High gas fees can become a bottleneck, and this is particularly so for developers deploying complex smart contracts. In this brief piece, we will explore several techniques to optimise gas fees in Ethereum.
Gas? Yes gas. Gas is a unit of computational work in Ethereum. Every operation executed by the Ethereum Virtual Machine (EVM) has an associated gas cost. The total gas fee for a transaction is determined by the gas price (Gwei) and the gas consumed by the transaction.
Operations on the EVM (Ethereum Virtual Machine) has varying degree gas cost associated with each. For a detailed information on gas costs for specific operations, see the Ethereum Yellow Paper and the Ethereum Gas Costs.
Minimizing Storage Writes
In Ethereum, storage operations are costly in terms of gas. Each write to storage (SSTORE) costs 20,000 gas if you are initialising a new value, and modifying an existing value can be slightly cheaper but still expensive. Reads from storage (SLOAD) are also costly, costing around 800 gas per read. Therefore, minimising storage operations can lead to significant gas savings.
Let’s delve deeper into how using temporary variables can optimise gas consumption.
Storage vs. Memory
Storage: Persistent storage on the blockchain. Data stored here persists between function calls and transactions. Writing to storage is expensive because it involves writing data to the blockchain.
Memory: Temporary storage that is erased between (external) function calls. It is cheaper to read from and write to memory than to storage.
Example Explanation
Consider the following two examples:
Inefficient Example:
pragma solidity ^0.8.0;
contract StorageExample {
uint256 public data;
function setData(uint256 _data) public {
data = _data;
}
}
In this example, each time setData
is called, it writes the value _data
directly to the storage variable data
. This operation is costly because writing to storage (data = _data
) incurs a gas cost of 20,000 gas.
Optimised Example:
pragma solidity ^0.8.0;
contract StorageExample {
uint256 public data;
function setData(uint256 _data) public {
uint256 tempData = _data;
data = tempData;
}
}
In the optimised example, the function first assigns the value _data
to a local variable tempData
in memory. Then it assigns tempData
to the storage variable data
. Although this looks trivial, let’s explore why this can be more efficient:
Benefits of Using Temporary Variables
Reduced Redundant Storage Operations:
Direct Storage Write: In the inefficient example, if
data
was used multiple times within the function, each use would trigger a storage read (SLOAD) which costs 800 gas per read.Temporary Variable: In the optimised example, using
tempData
allows all operations within the function to use this memory variable instead of repeatedly reading from and writing to storage.
Optimised Conditional Logic:
- If your function includes conditional logic that depends on the value of
data
, using a temporary variable means that you only read the value from storage once, rather than multiple times.
- If your function includes conditional logic that depends on the value of
Complex Scenario Example:
Let’s consider a more complex scenario where this optimisation becomes crucial:
Complex Inefficient Example:
pragma solidity ^0.8.0;
contract ComplexStorageExample {
uint256 public data;
function complexOperation(uint256 _data) public {
// Several operations involving storage
if (_data > data) {
data = _data + data;
}
if (_data < data) {
data = data - _data;
}
}
}
In this example, each condition involves reading and writing to data
, causing multiple storage operations.
Optimised Complex Example:
pragma solidity ^0.8.0;
contract ComplexStorageExample {
uint256 public data;
function complexOperation(uint256 _data) public {
uint256 tempData = data;
if (_data > tempData) {
tempData = _data + tempData;
}
if (_data < tempData) {
tempData = tempData - _data;
}
data = tempData;
}
}
In this optimized example:
tempData
holds the value ofdata
in memory.All operations use
tempData
, reducing the number of reads and writes to storage.Finally, the modified
tempData
is written back todata
.
Gas Cost Analysis
To illustrate the gas savings, consider the following:
Gas Costs (Approximate):
SSTORE (writing to storage): 20,000 gas
SLOAD (reading from storage): 800 gas
Memory operations: Significantly cheaper than storage operations.
In a function with multiple conditional statements, each involving storage reads and writes, the gas costs can add up quickly. By using memory variables to hold intermediate values, you minimize the number of expensive storage operations, leading to substantial gas savings.
Using calldata
for Function Parameters
When dealing with external function calls in Solidity, optimising gas usage is crucial, especially when handling large amounts of data or complex transactions. One effective optimisation technique is using calldata
for function parameters instead of memory
. In this section, we will explore calldata
in detail, understand its benefits, and see how it impacts gas costs.
Understanding Data Location in Solidity
In Solidity, there are three primary data locations:
Storage: Persistent data storage on the blockchain. It is the most expensive in terms of gas cost.
Memory: Temporary data storage used within function execution. It is more expensive than
calldata
but cheaper thanstorage
.Calldata: A read-only data location for function parameters in external function calls. It is the cheapest data location for passing data to functions.
Each of these data locations has different gas costs associated with reading and writing operations. Using calldata
can lead to significant gas savings, especially for functions that accept large arrays or complex data structures.
Why calldata
is Cheaper
calldata
is cheaper than memory
because:
Read-only: Data in
calldata
cannot be modified, which simplifies access and reduces gas costs.No Copying: When a function parameter is specified as
calldata
, the data does not need to be copied from the input transaction intomemory
. This reduces the overhead associated with data duplication.SLOAD (800 gas): Reading from storage is expensive because it involves accessing the Ethereum state trie.
MLOAD (3 gas): Reading from memory is cheaper but still involves a slight overhead compared to
calldata
.CALLDATALOAD (3 gas): Reading from
calldata
is the cheapest because it directly accesses the input data without modification.
Practical Example: memory
vs calldata
Consider the following example where we compare the gas costs of a function that processes an array using memory
versus calldata
.
Inefficient Example usingmemory
:
pragma solidity ^0.8.0;
contract MemoryExample {
function processData(uint256[] memory data) public {
for (uint256 i = 0; i < data.length; i++) {
// Process data
}
}
}
In this example, the data
array is copied into memory
when the function is called, which incurs a higher gas cost.
Optimized Example usingcalldata
:
pragma solidity ^0.8.0;
contract CalldataExample {
function processData(uint256[] calldata data) public {
for (uint256 i = 0; i < data.length; i++) {
// Process data
}
}
}
By using calldata
, the array is passed directly to the function without being copied, resulting in lower gas consumption.
Short-Circuit Evaluation in Solidity
Short-circuit evaluation is a technique where logical operators stop evaluating expressions as soon as the outcome is determined. This can save computational resources and, in the context of Ethereum, reduce gas costs. In Solidity, the logical operators &&
(AND) and ||
(OR) support short-circuit evaluation. This section will delve into the mechanics of short-circuit evaluation, its benefits, and practical examples with gas cost analysis.
Understanding Short-Circuit Evaluation
In logical operations:
The
&&
(AND) operator returnstrue
if both operands are true. If the first operand isfalse
, there is no need to evaluate the second operand.The
||
(OR) operator returnstrue
if at least one of the operands is true. If the first operand istrue
, there is no need to evaluate the second operand.
This behavior can be leveraged to write more gas-efficient Solidity code by avoiding unnecessary computations.
Why Short-Circuit Evaluation Saves Gas
Short-circuit evaluation saves gas by reducing the number of operations the EVM must execute. Each operation in the EVM has an associated gas cost. By stopping the evaluation early, we can avoid these extra costs.
EVM Gas Costs for Logical Operations:
JUMP: 8 gas
JUMPI: 10 gas
PUSH: 3 gas per byte
DUP: 3 gas
By reducing the number of these operations, we lower the overall gas consumption.
Practical Examples
Let's explore some practical examples to see how short-circuit evaluation can be applied to save gas.
Example Without Short-Circuiting:
Consider a function that checks two conditions, both of which require significant computation:
pragma solidity ^0.8.0;
contract ShortCircuitExample {
function expensiveOperation1() internal pure returns (bool) {
// Simulate an expensive operation
return true;
}
function expensiveOperation2() internal pure returns (bool) {
// Simulate another expensive operation
return false;
}
function checkConditions() public pure returns (bool) {
if (expensiveOperation1() && expensiveOperation2()) {
return true;
}
return false;
}
}
In this example, both expensiveOperation1
and expensiveOperation2
are evaluated even if the first one returns false
. This leads to unnecessary gas usage.
Example With Short-Circuiting:
By leveraging short-circuit evaluation, we can optimize the function:
pragma solidity ^0.8.0;
contract ShortCircuitExample {
function expensiveOperation1() internal pure returns (bool) {
// Simulate an expensive operation
return false;
}
function expensiveOperation2() internal pure returns (bool) {
// Simulate another expensive operation
return true;
}
function checkConditions() public pure returns (bool) {
return expensiveOperation1() && expensiveOperation2();
}
}
Here, expensiveOperation2
is never evaluated if expensiveOperation1
returns false
, saving gas.
Beyond simple logical expressions, short-circuit evaluation can be combined with other optimization techniques:
Function Inlining: Reduce function call overhead by inlining functions.
Conditional Expressions: Simplify complex conditions to reduce the number of evaluations.
Early Returns: Use early return statements to exit functions as soon as a condition is met.
Using unchecked
Blocks in Solidity for Gas Optimization
In Solidity version 0.8.0 and later, arithmetic operations (such as addition, subtraction, and multiplication) are checked for overflow and underflow by default. While this feature enhances security by preventing common bugs, it also introduces additional gas costs for these checks. For operations where you are confident that overflow or underflow will not occur, you can use unchecked
blocks to bypass these checks and save gas.
Understanding Overflow and Underflow
Overflow occurs when an arithmetic operation exceeds the maximum limit of the data type. Underflow happens when an operation goes below the minimum limit. For example, in an unsigned 8-bit integer (uint8
), the maximum value is 255 and the minimum value is 0:
Overflow:
255 + 1
results in 0.Underflow:
0 - 1
results in 255.
In Solidity 0.7.x and earlier, these wraparounds would occur without any error, often leading to unexpected behavior. To prevent this, Solidity 0.8.x introduced automatic overflow and underflow checks.
Gas Costs of Arithmetic Operations with Checks
Adding overflow checks increases the gas cost of arithmetic operations. Here are the gas costs for different arithmetic operations with and without overflow checks:
Addition (with check): 5 gas for the operation + 30 gas for the check = 35 gas.
Addition (without check): 5 gas.
The additional gas cost for overflow checks can accumulate significantly in complex contracts or loops.
Using unchecked
Blocks
By using unchecked
blocks, you can skip the overflow and underflow checks when you are sure they are not necessary. This reduces the gas cost of arithmetic operations.
pragma solidity ^0.8.0;
contract UncheckedExample {
function safeIncrement(uint256 x) public pure returns (uint256) {
unchecked {
return x + 1;
}
}
}
In this example, the addition inside the unchecked
block does not incur the gas cost of overflow checks.
When to Use unchecked
Blocks
Loops with Arithmetic Operations: If you are iterating over a loop with arithmetic operations, and you can ensure that overflow will not occur, using
unchecked
blocks can save significant gas.Complex Calculations: For contracts performing complex calculations where you have constraints ensuring no overflows,
unchecked
blocks can optimize gas usage.Safety Guarantees: Only use
unchecked
blocks when you are confident that the arithmetic operations will not result in overflow or underflow. This can often be ensured through logic constraints or input validations.
Optimizing Gas-Intensive Operations in Ethereum
Operations like exponentiation (exp
) and loops can be particularly gas-intensive in Ethereum smart contracts. By simplifying calculations and minimizing loop iterations, developers can significantly reduce gas costs. This section will explore techniques to optimize these operations, providing detailed explanations, practical examples, and gas cost analyses.
Understanding Gas Costs of Gas-Intensive Operations
Exponentiation (
exp
): Exponentiation operations are costly because they involve multiple multiplications. In the EVM, the cost of exponentiation is variable and depends on the size of the exponent.Loops: The gas cost of a loop is the sum of the gas costs of the operations inside the loop multiplied by the number of iterations. This can add up quickly, especially if the loop body contains expensive operations or if the loop iterates many times.
Optimizing Exponentiation
Exponentiation is inherently expensive, but there are ways to optimize its usage in smart contracts. One approach is to use iterative multiplication for small exponents, and another is to leverage mathematical properties for optimizations.
Inefficient Example:
pragma solidity ^0.8.0;
contract ExpExample {
function power(uint256 base, uint256 exponent) public pure returns (uint256) {
return base ** exponent;
}
}
Optimized Example Using Iterative Multiplication: For small exponents, iterative multiplication can be more gas-efficient.
pragma solidity ^0.8.0;
contract ExpExample {
function power(uint256 base, uint256 exponent) public pure returns (uint256) {
if (exponent == 0) return 1;
if (exponent == 1) return base;
uint256 result = base;
for (uint256 i = 1; i < exponent; i++) {
result *= base;
}
return result;
}
}
Optimized Example Using Exponentiation by Squaring: For larger exponents, the method of exponentiation by squaring is more efficient.
pragma solidity ^0.8.0;
contract ExpExample {
function power(uint256 base, uint256 exponent) public pure returns (uint256) {
if (exponent == 0) return 1;
uint256 result = 1;
uint256 x = base;
uint256 n = exponent;
while (n > 0) {
if (n % 2 == 1) {
result *= x;
}
x = x * x;
n /= 2;
}
return result;
}
}
Optimizing Loops
Loops can be optimized by minimizing the number of iterations and reducing the complexity of operations within the loop.
Inefficient Example:
pragma solidity ^0.8.0;
contract LoopExample {
function sum(uint256[] memory numbers) public pure returns (uint256) {
uint256 total = 0;
for (uint256 i = 0; i < numbers.length; i++) {
total += numbers[i];
}
return total;
}
}
Optimized Example Using Unchecked Blocks: When we are sure that no overflow or underflow will occur, using unchecked
blocks inside loops can save gas.
pragma solidity ^0.8.0;
contract LoopExample {
function sum(uint256[] memory numbers) public pure returns (uint256) {
uint256 total = 0;
for (uint256 i = 0; i < numbers.length; i++) {
unchecked {
total += numbers[i];
}
}
return total;
}
}
Optimized Example Using Memory Cache: Caching the array length in memory can also reduce gas costs by avoiding repeated SLOAD
operations.
pragma solidity ^0.8.0;
contract LoopExample {
function sum(uint256[] memory numbers) public pure returns (uint256) {
uint256 total = 0;
uint256 length = numbers.length;
for (uint256 i = 0; i < length; i++) {
unchecked {
total += numbers[i];
}
}
return total;
}
}
Combining Optimizations
For even better results, you can combine multiple optimization techniques.
Example Combining Techniques:
pragma solidity ^0.8.0;
contract OptimizedExample {
function sumOfSquares(uint256[] memory numbers) public pure returns (uint256) {
uint256 total = 0;
uint256 length = numbers.length;
for (uint256 i = 0; i < length; i++) {
unchecked {
total += numbers[i] * numbers[i];
}
}
return total;
}
}
In this example, the array length is cached, and unchecked
blocks are used to save gas on overflow checks. Additionally, the multiplication inside the loop is simplified by avoiding complex operations.
Summary
This blog post delves into sone advanced techniques for optimizing gas fees in Ethereum smart contracts, in order to enhance efficiency and reduce transaction costs. Key strategies include:
Minimizing Storage Writes,
Using
calldata
,Short-Circuit Evaluation,
Unchecked Blocks, and
Optimizing Loops and Exponentiation.
These techniques collectively aim to create more cost-effective and scalable smart contracts.
Subscribe to my newsletter
Read articles from Ola_eth directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by