Techniques to Cut Gas Costs for Your Dapps

Gas costs play a significant role in the long run for each Ethereum-based Dapp. We share several hands-on techniques, by example, to help your Dapps cut the associated gas costs. We would love to hear your feedback if any.

Terms we use in the article:

  • SC: Smart Contract
  • GC: Gas Cost
  • SOLC: Solidity Compiler
  • EVM: Ethereum Virtual Machine

Overall Approach: Feedback-based solution.

We tried to optimize the design and implementation of our SC the way it is expected from SOLC and EVM. We verified the ideas by performing GC regression tests and apply the feedback back to the design and implementation.

Techniques we used to cut gas costs (note that we use SOLC version 0.4.21 for discussion purpose):

  • Bit-compaction

Bit-compaction format for external function parameters helps cut gas costs because it minimizes the amount of input data sent to the Ethereum blockchain. Please note that it introduces certain small extra gas costs to unpack the data. However, the savings usually weigh out the extra costs.

Take the following code snippet for example:

pragma solidity ^0.4.21;
contract bitCompaction {
function oldExam(uint64 a, uint64 b, uint64 c, uint64 d) public {
}
function newExam(uint256 packed) public {
}
}

Its corresponding assembly code shows that the oldExam function incurs 4 `CALLDATALOAD` operations once it is called, and each `CALLDATALOAD` operation triggers one memory allocation operation in Ethereum, while the newExam function incurs only 1 instead. Note that the assembly code is too large for us to present here, but one can easily generate it by performing (for example) `solc — asm — optimize — optimize-runs 200 bitCompaction.sol`.

In the experiment, we have the following call data for each function:

oldExam call data: ([]uint8) (len=132 cap=132) {
00000000 3e f2 62 fd 00 00 00 00 00 00 00 00 00 00 00 00
00000010 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00000020 00 00 00 01 00 00 00 00 00 00 00 00 00 00 00 00
00000030 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00000040 00 00 00 01 00 00 00 00 00 00 00 00 00 00 00 00
00000050 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00000060 00 00 00 01 00 00 00 00 00 00 00 00 00 00 00 00
00000070 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00000080 00 00 00 01
}
newExam call data: ([]uint8) (len=36 cap=36) {
00000000 83 ba 6e 5a 00 00 00 00 00 00 00 01 00 00 00 00
00000010 00 00 00 01 00 00 00 00 00 00 00 01 00 00 00 00
00000020 00 00 00 01
}

As we can see, those uint64 parameters in the oldExam function are first converted to uint256 ones once being called. Gas costs for the above two are 22235 and 21816 respectively, meaning the newExam function saves 419 gas costs. The more scattered the parameters are, the more savings you cut by adopting the bit compaction method.

  • Batching

Batching cuts gas cost because it reduces common data processing. Take the following code snippet for example:

Old: func once(uint256 header, uint256 val...) x N
New: func batch(uint256 header, uint256[] val... x N)

Executing the old function N times processes the common `header` field N times, as well as calling the function N times. However, by batching, the function is called only one time and the common `header` field is processed only one time. Therefore, it saves gas costs by reducing `CALLDATALOAD`, memory allocation, and function call operations. The larger the N, the larger the savings by adopting the batching mechanism. The batching gas cost effectiveness of DEx.top is already published here.

  • Separating writes to storage struct

Separating writes to struct object that is defined in storage, cuts gas cost in many cases. Take the following code snippet for example:

pragma solidity ^0.4.21;
contract structWrite {
struct Object {
uint64 v1;
uint64 v2;
uint64 v3;
uint64 v4;
}
  Object obj;
  function StructWrite() public {
obj.v1 = 1;
obj.v2 = 1;
obj.v3 = 1;
obj.v4 = 1;
}
  function oldExam(uint64 a, uint64 b) public {
uint a0; uint a1; uint a2; uint a3; uint a4; uint a5; uint a6;
uint b0; uint b1; uint b2; uint b3; uint b4; uint b5;
    obj.v1 = a + b;
obj.v2 = a - b;
obj.v3 = a * b;
obj.v4 = a / (b + 1);
}
  function setObject(uint64 v1, uint64 v2, uint64 v3, uint64 v4) private {
obj.v1 = v1;
obj.v2 = v2;
obj.v3 = v3;
obj.v4 = v4;
}
  function newExam(uint64 a, uint64 b) public {
uint a0; uint a1; uint a2; uint a3; uint a4;
uint b0; uint b1; uint b2; uint b3;
setObject(a + b, a - b, a * b, a / (b + 1));
}
}

Once the optimize option is turned on, for the above example, SOLC compiles storage struct writes in such a way that the oldExam function would incur 1 EVM `SSTORE` operation (`SSTORE` is the most gas costly operation) for each of the struct field in Object since the current implementation of SOLC could not optimize the `SSTORE` operations when there is not enough stack space (there is stack space for only 16 local variables). However, with the new approach, it has ample stack space such that the current compiler can optimize the struct accesses by performing, in total, only 1 `SSTORE` operation after merging writes to the struct. This can be easily verified against by looking at the corresponding assembly code. This is restricted to the current implementation of SOLC, and is subject to change in the future. For the above example, the gas costs are 58140 and 27318 respectively, indicating a gas cost saving of 30822 for the newExam function. When the stack space is not enough, the more fields the struct has, the more gas cut you achieve if the new approach is leveraged.

  • uint256 and direct memory access

Calculation unit in SOLC is uint256. Hence other types (e.g., uint8) require type conversions before calculations are applied. This incurs extra gas costs. Moreover, direct memory access is more GC-efficient than direct storage access, and more GC-efficient than struct pointer based access. These smaller tricks can be represented as:

uint8 data;                  =>   uint256 data;
uint256 val = storageData;   =>   uint256 memoryData = storageData;
(N Times) uint256 val = memoryData;
uint64 val = obj.v1;         =>   uint64 val = val1;
  • Assembly optimization

When compiling your SOLC code, make sure to perform GC experiments with the SOLC `optimize — runs` option to figure out the best GC-efficient assembly code to run in EVM.

Reference

  • Gas Costs from Yellow Paper. Link.