Cuong Nguyen6 min

The Art of Gas Optimization

EngineeringAug 3, 2022

Gas is expensive. Mainly the real-life one, but this also extends to the hype-driven web3 space, namely to the virtual world of EVM-powered solutions. As engineers, it’s therefore in our interest to make sure that smart contract function calls are cheap within our solution.

…It also makes you a better engineer, so why not. But before we really get down to it, let’s first cover why the following tricks help us save on precious gas.

There’s really no need to remember all of the obscurities and niche problems of a language to become a better programmer, but understanding the notions behind the abstractions will help you — just like you don’t actually remember the answer to 589 + 234 but, since you understand the process of addition, you have the ability to work out the sum of any 2 numbers.

It’s All About Them OPCODES

Those who’ve taken their Computer Architecture 101 classes will likely be very familiar with the idea of the Little Man Computer. It’s basically this small guy who reads very simple, low-level instructions written in “assembly opcodes” and executes them in an isolated computer box. Each of the nine available instructions that he has to read and execute theoretically costs him one unit of computation time.

Any Solidity or Vyper code that you write will eventually be compiled into a bytecode and sent to the Ethereum Virtual Machine (EVM) for execution. We can now imagine the EVM as a box comparable to the Little Man Computer, with its own program counter, stack, memory, and an additional feature that the little man lacks: the gas counter. But what is the bytecode? What gets executed?

Well, it’s important to understand that the EVM does not understand Solidity or Vyper at all. It cannot directly execute it. That’s why we're compiling everything into the bytecode — because it contains the instruction set with relevant opcodes that the EVM understands. Remember that there’s always some associated cost with each and every executed opcode that the little man has to execute inside of the EVM.

To Solidity devs, however, the computation time cost is not as important as the gas cost*, although they are definitely correlated. You’ll be pleased to know that each of the listed opcodes has its defined gas cost and that the gas limit you’re spending per transaction is not randomized gibberish but, rather, a core feature of what makes the EVM a very deterministic machine with very deterministic outcomes and costs.

*Just a reminder that we’re talking about the amount of gas limit needed to spend, not the actual final cost denominated in ETH. That is, about how many liters of gas to pour into the EVM car, and not the fact that it will cost us $20 or $50 at the cashier depending on the market price of the ETH token itself.

With that being said, you can easily find the opcodes and their gas costs anytime.

The_Art_of_Gas_Optimization-2

So, just to sum up our hyper-quick overview:

  1. Unoptimized Solidity or Vyper code will be compiled into an unoptimized bytecode for EVM.
  2. Unoptimized bytecode contains many redundant opcode executions from the instruction set.
  3. Opcodes have pre-determined associated gas costs.
  4. If more opcodes are used, then more gas is needed.

Now that that’s clear, we can take a look at some things that eat up more gas than they should.

Cache Me if You Can

Accessing data structures in stateful smart contracts can be expensive. If you’re iterating through a big array of items, remember to cache the mundane things, such as the array length:

uint256[] arrayOfNumbers = [1, 2, 3, 4, 5];

function iterateNumbers() external {
	for(uint256 i = 0; i < arrayOfNumbers.length; i++) {
		// do something
	}
}

function iterateNumbersEfficiently() external {
	uint256 arrayLength = arrayOfNumbers.length;
	for(uint256 i = 0; i < arrayLength; i++) {
		// do something
	}
}

In the second function, we are saving gas costs by not having to access and read the arrayOfNumbers.length property on each iteration, thereby not calling the “SLOAD” opcode on a storage array.

Byte Shifts Won’t Bite

You can get some pretty good gas savings even on some more simple operations, such as multiplication or division.

function divideByTwo() external {
	uint256 num = 4;
	uint256 result = 4 / 2;
}

function divideByTwoEfficient() external {
	uint256 num = 4;
	uint256 result = 4 >> 1;
}

Instead of plainly dividing by 2, you can use byte shifting and therefore shift all the bytes to the right by 1 place. This will yield the same result as dividing the number by 2. However, the opcode for “SHR” (shift right) is 3 gas, whereas the opcode for “DIV” (division) is 5 gas. Think of all the things you can now buy with the 2 gas units in savings, instead of pouring it into the EVM.

Memory Is Overrated

Do you have an external function call like this one in your contract?

function iterateNumbers(uint256[] memory arrayOfNumbers) external {
	// do something
}

function iterateNumbersEfficiently(uint256[] calldata arrayOfNumbers) external {
	// do something
}

Since this is a function marked with the external modifier, the array in the function parameters is likely to be read-only. If you’re marking it with memory, as depicted by the first function, then the instruction set that is used by EVM is “CALLDATALOAD” and “MSTORE”, which store it into the memory. Subsequently, if you were to access the elements in the array using a for loop, then the EVM would have to call the “MLOAD” opcode on each iteration.

On the other hand, the second function forgoes the “MSTORE” and “MLOAD” operations completely. By marking it as calldata in the function parameter, the EVM is only obliged to load the provided array once using the previously mentioned “CALLDATALOAD” opcode.

Not All Uints Are Made Equal

The EVM operates on “256-bit words” — that is, a 256-bit uint will take 1 storage slot, and two uint128s will also take 1 storage slot. This is all useful for when storage and the so-called tight-packing are really essential in your smart contract (maybe you have a lot of stateful variables and data structures).

However, in certain cases where you need to work with the data, the EVM will have to downscale to lower bits from its native 256-bits, which can prompt more gas usage. Take a look at the following example, kindly borrowed from Solmate:

bool private locked = false;
modifier nonReentrant() {
	require(locked == false, "REENTRANCY");
	locked = true;
	_;
	locked = false;
}

uint256 private locked = 1;
modifier nonReentrant() {
	require(locked == 1, "REENTRANCY");
	locked = 2;
	_;
	locked = 1;
}

Boolean values in the EVM are actually 8-bit so, essentially, they are uint8 integers; but, logically, they can only tell you two states: true or false. You can therefore employ a more gas-efficient version by using the native uint256 to run the same business logic. Therefore, this is a case where the highly frequent but cheap variable read is more appreciated than an efficient storage space using a smaller data structure.

Unchecked Lifestyle

Solidity operates on “checked” arithmetic when performing mathematical operations. As briefly mentioned above, the integer population within Solidity can be declared using a specific number of bits. However, this will also affect the “range” of the maximum or minimum integer, and can cause the infamous overflows or underflows, respectively.

But Solidity knows this, which is why operations such as integer increments in loops are first checked for overflow or underflow issues before the actual increment takes place.

function iterateNumbers() external {
	for(uint256 i = 0; i < arrayOfNumbers.length; i++) {
		// do something
	}
}

As you can probably tell, it’s inefficient. Who would have thought? But here’s something fun to think about: We know that the for loop is constrained by the second statement condition i < arrayOfNumbers.length and the variable i will never exceed the number which defines the length of the array. Therefore, if we follow this logical assumption, we’ll soon arrive at the conclusion that the variable i cannot certainly overflow if its value will always be 1 fewer than the array’s supposed length. This makes the “checked'' arithmetic very redundant in the third statement of the loop, the i++.

Don’t worry, Solidity will allow its developers to risk it for the biscuit when it’s necessary.

function iterateNumbersEfficiently() external {
	for(uint256 i = 0; i < arrayOfNumbers.length; i = uncheckedAdd(i)) {
		// do something, but don’t adjust the value of i
	}
}

function uncheckedAdd(uint i) returns (uint256) {
	unchecked {
		return i + 1;
	}
}

By adding the new uncheckedAdd function — which uses the unchecked block — we’re able to perform a clean add operation and, once again, save up on tons of gas when looping through the really big arrays. Just make sure to not alter the value i within the body of your actual function and you’ll be golden.

Conclusion

There are many more neat tricks to save on tons of gas and make your dApp users happy, but they’d take too long to compile. However, I hope that you’re starting to see the pattern within these optimization tips — it’s always about reducing the number of opcodes or using cheaper alternative opcodes for the same job to be done.

For some of these, the compiler is often not smart enough to optimize the code for you and therefore requires an understanding of the opcodes and how instruction sets are executed on a much lower level of virtual machines. Maybe some of these tricks are not even worth the hassle if you’re working in a team, as they can significantly reduce your code readability. It’s really up to you and what your priorities are, but knowing that you have the option and that such things even exist is bound to make you a better Solidity engineer with an expanded arsenal of gas-saving tricks for any situation you may encounter.

Also, 589 + 234 is 823. But you’d work it out anyway, since you already know the principles of summation.

Share this article


What to see next...


Sign up to our newsletter

Monthly updates, real stuff, our views. No BS.