B003: Maximal Gas Optimizations

In the past, projects used to not care about gas-optimized code and instead focused their code reviews entirely on security. However, with gas spikes becoming more prevalent than ever, gas optimizations are becoming a trending topic that a lot of folks are interested in.

In this article we will attempt to explain some hidden gas costs the EVM incurs and how to avoid them by coding in a gas-eccentric way. Before we move forward with explaining these costs, we need to shed a little bit more light on how gas exactly operates in Ethereum as well as give a brief explanation about why it is necessary.

Transaction Fees

While the gas consumed by a transaction is important, alone it does not serve as an indicator of the actual fee of a transaction. To calculate the final fee of a transaction, the gas consumed by it needs to be multiplied by the gas cost of the transaction which is defined by the transaction’s submitter.

The above combination leads to a dynamic market forming that defines what the ideal gas cost for a transaction should be for it to be accepted by the blockchain miner network. As miners are directly awarded the transaction fees, they are incentivized to include transactions with the highest gas cost first.

Although the above auction-style system is meant to change soon, the core idea will remain the same whereby each unit of gas needs to be paid for using actual funds.

The rationale behind gas cost is simple; a complex transaction performs state changes on the blockchain that are expensive to execute in hardware terms and as such, an organic throttling mechanism needs to be in place to prevent denial-of-service attacks, etc.

Compilation Optimizer

The optimizer attempts to simplify the opcode representations by reducing the number of instructions required to reach the same final result and additionally removes unused code. As the optimizer does not operate at the code level, it will not be able to pick up redundancies such as reading the length value of a storage array during a for loop.

The “runs” value is meant to represent how many times each code segment is meant to be run and represents the trade-off between contract bytecode size (low value) and contract runtime efficiency (high value). Due to this, a lot of people insert a ridiculously large “runs” value but this is not necessarily correct.

A value of 400–1000 is usually ideal given that the numbers above this range may actually result in a higher gas cost due to having expanded the code to too many EVM statements. On the other hand, a low value will not have expanded the code at all leading to small bytecode size but unoptimized gas consumption.

Overall, there is no “golden number” and projects should play around with this number along with their gas-cost unit tests to identify the ideal trade-off that should be specified for their particular needs.

Costly Notions

Looks simple, right?

The above code segment, although completely valid, contains a lot of redundancy that can significantly reduce its gas cost. Broken down, these are the hidden gas costs of the above segment:

  • Contains three mapping lookups
  • Reads a full storage slot three times
  • Upcasts a bytes8 three times

Mapping Lookups

Redundant Lookups Eliminated

Storage Access

One of those is Solidity’s inherent tight-packing mechanism. When variables are declared at a contract-level or a struct-level, their declaration order impacts the way they will be packed under the same storage slot. Overall, if two variables can fit in a single 32-byte (256-bit) storage slot, they will be packed into one by the compiler automatically.

This may not always lead to a reduction in gas cost which will be explained in the next chapter, however, in this instance we already have an address type declared in our struct that contains 160-bits. As timestamp is meant to represent a Unix timestamp, it is safe to use the remaining 96-bits for storing the timestamp.

Variable Tight Packing

As a final optimization, we will change our local Entry declaration from storage to memory. When a struct is declared as memory, all its variables are immediately read from storage and copied to memory thus minimizing storage reads if the whole struct is meant to be utilized.

Struct Stored in Memory

Type Upcasting

As a result of these specialized operations, all operations will cost more when performed on less-than-256-bit data types. In most contract systems I have observed, the usage of smaller data types is usually unwarranted and done for verbosity reasons; however, I personally advise against that as gas optimizations should take precedence to code verbosity.

To further optimize our code segment, the bytes8 identifier can be simply set to bytes32 thus preventing the upcasting operations and ensuring that the gas cost consumed by processing it in the keccak256 operation of the mapping lookups is as small as it can be.

Optimized Data Types

Comparison

  • Contains three mapping lookups — Contains a single mapping lookup
  • Reads a full storage slot three times — Reads a full storage slot once
  • Upcasts a bytes8 three times — Contains no upcasting

Conclusion

In this article we showcased three ways code can be optimized, however, there are numerous ways code can be optimized in Solidity. If more interest is garnered, I will create a follow-up post that dives straight into optimizations and showcases some more hidden ones, such as using access control guarantees or avoiding library redundancy.

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store