Optimizing Gas Fees in Ethereum

Ola_ethOla_eth
13 min read

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

  1. 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.

  2. 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.

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 of data in memory.

  • All operations use tempData, reducing the number of reads and writes to storage.

  • Finally, the modified tempData is written back to data.

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:

  1. Storage: Persistent data storage on the blockchain. It is the most expensive in terms of gas cost.

  2. Memory: Temporary data storage used within function execution. It is more expensive than calldata but cheaper than storage.

  3. 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 into memory. 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 returns true if both operands are true. If the first operand is false, there is no need to evaluate the second operand.

  • The || (OR) operator returns true if at least one of the operands is true. If the first operand is true, 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:

  1. Function Inlining: Reduce function call overhead by inlining functions.

  2. Conditional Expressions: Simplify complex conditions to reduce the number of evaluations.

  3. 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

  1. 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.

  2. 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:

  1. Minimizing Storage Writes,

  2. Usingcalldata,

  3. Short-Circuit Evaluation,

  4. Unchecked Blocks, and

  5. Optimizing Loops and Exponentiation.

These techniques collectively aim to create more cost-effective and scalable smart contracts.

0
Subscribe to my newsletter

Read articles from Ola_eth directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ola_eth
Ola_eth