Skip to content
On this page

L2-8: Gas Golf (Gas Optimization)

1. Problem

On Ethereum, every transaction execution consumes gas (EVM computational resources priced in gwei). An unoptimized contract might consume 200,000 gas, while after professional optimization it may require only 80,000 gas -- a difference of over 2.5x that directly translates to user transaction costs. For high-frequency scenarios (such as DEX swaps, NFT minting), gas optimization is not a nice-to-have; it is central to product competitiveness.

This challenge (refer to src/level2/GasGolf.sol) requires systematically mastering multiple gas optimization techniques and visually demonstrating the gas savings of each technique through comparative functions. The challenge covers six major technical dimensions: immutable/constant vs storage, variable packing, calldata vs memory, unchecked arithmetic, custom errors vs require strings, and short-circuit evaluation.

2. Why

Gas optimization is fundamentally about cost control at the EVM opcode level. Every SSTORE (storage write) costs 20,000 gas (cold write), while an immutable variable is written only once during construction and reads cost only about 5 gas afterwards. This 4,000x difference illustrates that understanding data storage mechanisms is more important than any code trick.

Gas Golf teaches not only how to save gas, but "what the cost of gas actually is." For example, each non-zero byte of calldata costs 16 gas and zero bytes cost 4 gas -- this explains why address(0) as a parameter is actually cheaper, and why compressing parameter type sizes (uint256 → uint128) saves gas. Similarly, unchecked blocks eliminate the overflow-checking opcode for each arithmetic operation (ADD vs unchecked_add), yielding particularly significant gas savings in loop accumulation scenarios.

More importantly, Gas Golf cultivates a "gas-aware" programming habit -- when writing every line of Solidity code, you subconsciously think "how much gas does this line cost? Is there a cheaper way?" This is the qualitative leap from "it works" to "professional contract development."

3. Solution

The GasGolf contract (refer to src/level2/GasGolf.sol) demonstrates comparative implementations of six gas optimization techniques:

Technique 1: immutable and constant vs storage

solidity
uint256 public constant MAX_SUPPLY = 10_000;   // compile-time inline, ~5 gas/read
uint256 public immutable i_ownerFee;             // set at construction, ~5 gas/read
uint256 public storageFee = 100;                 // SSTORE storage, 2100+ gas/read

function readImmutable() public view returns (uint256) {
    return i_ownerFee + MAX_SUPPLY;  // both are ~5 gas
}

function readStorage() public view returns (uint256) {
    return storageFee + MAX_SUPPLY;  // storageFee requires SLOAD (2100 gas)
}

Principle: constant is directly replaced with a literal at compile time (inlined into the bytecode), immutable is written into a special section of the contract code at deployment time (also only ~5 gas to read), while storage variables require the SLOAD opcode (2100 gas for cold reads).

Technique 2: Variable Packing

solidity
struct PackedData {
    uint128 valueA;
    uint128 valueB;
}  // two uint128 share one 32-byte slot → one SSTORE

struct UnpackedData {
    uint256 valueA;
    uint256 valueB;
}  // two independent slots → two SSTOREs

function writePacked(uint128 _a, uint128 _b) public {
    packed = PackedData(_a, _b);  // 1 × 20000 gas (cold)
}

function writeUnpacked(uint256 _a, uint256 _b) public {
    unpacked = UnpackedData(_a, _b);  // 2 × 20000 gas (cold)
}

Principle: EVM storage slots are aligned to 32 bytes (256 bits). Two uint128 values (16 bytes each) can sit side by side in the same 32-byte slot, and the EVM compiler will merge them into a single SSTORE. Two uint256 values each occupy a full slot, requiring two SSTOREs.

Technique 3: calldata vs memory

solidity
function sumCalldata(uint256[] calldata _data) public pure returns (uint256) {
    uint256 total;
    for (uint256 i; i < _data.length; ++i) {
        total += _data[i];  // read directly from calldata
    }
    return total;
}

function sumMemory(uint256[] memory _data) public pure returns (uint256) {
    uint256 total;
    for (uint256 i; i < _data.length; ++i) {
        total += _data[i];  // read from memory (requires prior copy)
    }
    return total;
}

Principle: memory parameters are fully copied from calldata to memory at function entry -- the larger the array, the higher the copy cost. calldata is read-only and points to the transaction's original input data, requiring no copy at all.

Technique 4: Unchecked Arithmetic

solidity
function sumUnchecked(uint256[] calldata _data) public pure returns (uint256) {
    uint256 total;
    unchecked {
        for (uint256 i; i < _data.length; ++i) {
            total += _data[i];  // skip overflow check
        }
    }
    return total;
}

Principle: Solidity 0.8+ inserts overflow checks for every +, -, * operation by default. Inside loops, you can wrap operations in unchecked to skip these checks -- provided you are confident overflow is impossible (e.g., i can never reach 2^256).

Technique 5: Custom Errors vs require Strings

solidity
error InvalidValue(uint256 value);

function validateWithCustomError(uint256 _value) public pure returns (bool) {
    if (_value == 0) revert InvalidValue(_value);  // only 4-byte selector
    return true;
}

function validateWithRequire(uint256 _value) public pure returns (bool) {
    require(_value != 0, "Value cannot be zero");  // full string stored in bytecode
    return true;
}

Principle: require's error string is stored entirely in the contract bytecode (each character is a byte; long messages can be hundreds of bytes), and is returned entirely to the caller on revert. Custom errors store only a 4-byte function selector + ABI-encoded parameters, significantly reducing bytecode size and return data volume.

Technique 6: Short-Circuit Evaluation

solidity
// Check cheap (constant read) first, then expensive (storage read)
function checkShortCircuit(address _addr, uint256 _amount) public view returns (bool) {
    if (_amount <= MAX_SUPPLY && storageFee > 0 && _addr != address(0)) {
        return true;
    }
    return false;
}

// Bad practice: read storage first
function checkNoShortCircuit(address _addr, uint256 _amount) public view returns (bool) {
    if (storageFee > 0 && _amount <= MAX_SUPPLY && _addr != address(0)) {
        return true;
    }
    return false;
}

Principle: Solidity's && is a short-circuit operator -- if the first condition is false, subsequent conditions are not evaluated. Place the cheapest conditions (constant reads, address(0) comparisons) first and the most expensive (storage reads) last.

4. Pitfalls Encountered

  • constant is not the same as immutable: values assigned to constant must be determined at compile time (cannot depend on constructor parameters), otherwise compilation fails; immutable can be assigned in the constructor
  • Read cost of packed structs: packing saves write gas, but reading an individual field requires additional shift operations (mask + shift), which in some scenarios may increase read cost
  • unchecked causes silent overflow: if overflow occurs inside an unchecked block, it wraps silently rather than reverting -- your contract may continue executing with incorrect state
  • calldata arrays cannot be modified: attempting a write operation on a calldata array (e.g., arr[0] = 5) will fail to compile, as it is a pointer to read-only data
  • Overusing unchecked in non-loop scenarios: for a single a + b operation, the gas saved is minimal (about 3-5 gas), but the overflow risk introduced may be significant

5. Why the Pitfalls Happen

The fundamental difference between constant and immutable lies in assignment timing: constant is evaluated at compile time and must be a compile-time constant expression; immutable is evaluated at construction time and can depend on constructor parameters. Compile-time evaluation means constant variables are directly replaced with literals in the bytecode; construction-time evaluation means immutable variables are written into the contract code segment (akin to ROM) and cannot be modified afterwards. Therefore, constant cannot be address(this) or block.chainid -- values only known at runtime.

The read overhead of packed structs comes from the EVM's word size: 32 bytes. When you read a uint128 packed in the same slot, the EVM first reads the entire 32 bytes (SLOAD), then uses a mask to extract the lower 128 bits and a right shift to extract the upper 128 bits. While SLOAD cost far outweighs AND/SHR operations, if you frequently read packed variables and never write to them, packing brings no benefit.

The reason unchecked overflow doesn't revert is that the Solidity compiler directly uses the EVM's ADD opcode (which has natural overflow wrapping behavior) inside unchecked, rather than ADD + overflow check combination. This is an intentional performance design, but requires developers to guarantee safety themselves.

6. How to Resolve the Pitfalls

Use immutable for fixed values determined at construction time (such as owner fee rate), and constant for values fully determined at compile time (such as fixed supply cap). Their declaration syntax differs:

solidity
uint256 public constant MAX_SUPPLY = 10_000;               // literal
uint256 public immutable i_ownerFee;                        // assigned in constructor
constructor(uint256 _ownerFee) { i_ownerFee = _ownerFee; }

When packing structs, weigh read/write frequency: if individual fields of the struct are read frequently, consider whether packing is worthwhile; for batch initialization with occasional reads, packing is always a win. Use memory caching to amortize read costs:

solidity
function readPacked() public view returns (uint128 a, uint128 b) {
    PackedData memory p = packed;  // one SLOAD, then operate in memory
    return (p.valueA, p.valueB);
}

Inside unchecked blocks, annotate safety reasoning with comments and use Foundry fuzz tests to verify that boundary conditions do not overflow. For loop counters, it is almost always safe (can never reach 2^256), but for accumulators, ensure the maximum input won't overflow.

Always use calldata for reference-type parameters of external functions unless you genuinely need to modify the parameter. For scenarios requiring modification, create a memory copy inside the function first, then modify.

7. Technical Highlights

Key PointDescription
SSTORE (cold 0→non-zero)20,000 gas -- highest cost for initial write
SSTORE (warm)5,000 gas -- non-zero to non-zero modification
SLOAD (cold)2,100 gas -- initial read
immutable/constant~5 gas -- inlined into bytecode, bypasses storage
calldata per non-zero byte16 gas
calldata per zero byte4 gas
Variable packingtwo uint128 = one 32-byte slot → one SSTORE
unchecked arithmeticskips overflow-checking opcodes for ADD/MUL/SUB
Custom error4-byte selector vs require string's full text
Short-circuit evaluation&& and `

Built with AiAda