A shift-right operation sometimes consumes more gas than the equivalent division
- Dominant language
- C++
- Stars
- 25.7k
- Forks
- 6.2k
- Avg merge
- 1d 11h
- Merged PRs (30d)
- 21
Description
## Description
I have the following two contracts:
```
contract Test1 {
uint256 public gasUsed;
function func(uint256 x) external returns (uint256 y) {
unchecked {
uint256 gasLeft = gasleft();
y = x / 0x80000000000000000000000000000000;
gasUsed = gasLeft - gasleft();
}
}
}
contract Test2 {
uint256 public gasUsed;
function func(uint256 x) external returns (uint256 y) {
unchecked {
uint256 gasLeft = gasleft();
y = x >> 127;
gasUsed = gasLeft - gasleft();
}
}
}
```
I am observing that `Test1.func` consumes 23 gas units and `Test2.func` consumes 24 gas units.
Replacing `x` with `x * x` in each one of these functions, `Test1.func` consumes 31 gas units and `Test2.func` consumes 29 gas units.
In other words, a difference of 1 gas unit in favor of `Test1.func` is replaced with a difference of 2 gas units in favor of `Test2.func`.
This **difference** is reproducible also when measuring the gas-consumption "from the outside", i.e., by observing the gas used in each one of the two transaction receipts.
I understand that due to opcode-ordering decisions made by the compiler, the gas consumption is not guaranteed to increase by the exact same amount in each function.
But as it currently stands, the challenge of determining ahead of time which one of the two options (division vs right-shift) is preferable becomes a lot harder when larger pieces of code are involved.
One would expect right-shift to always be equal to or better than division, and the fact that it is not always the case implies that perhaps the compiler's opcode-ordering decisions are sometimes suboptimal.
## Environment
- Compiler version: 0.8.28 (optimizer enabled, runs = 20000)
- Compilation pipeline: legacy as far as I understand
- Target EVM version: paris
- Framework/IDE: Truffle-based Hardhat
- Operating system: MacOS
## Steps to Reproduce
1. Create the three files below inside a clean folder
2. Run `npm install` or `yarn install` or `pnpm install`
3. Run `npm test` or `yarn test` or `pnpm test`; expected printout is `23 24`
4. In the solidity file, in each one of the two functions, change `x` to `x * x`
5. Run `npm test` or `yarn test` or `pnpm test`; expected printout is `31 29`
**The files to create are**:
File `package.json`:
```json
{
"scripts": {
"test": "hardhat test"
},
"devDependencies": {
"@nomiclabs/hardhat-truffle5": "2.0.7",
"@nomiclabs/hardhat-web3": "2.0.0",
"hardhat": "2.22.17"
}
}
```
File `Test.sol`:
```solidity
// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity 0.8.28;
contract Test1 {
uint256 public gasUsed;
function func(uint256 x) external returns (uint256 y) {
unchecked {
uint256 gasLeft = gasleft();
y = x / 0x80000000000000000000000000000000;
gasUsed = gasLeft - gasleft();
}
}
}
contract Test2 {
uint256 public gasUsed;
function func(uint256 x) external returns (uint256 y) {
unchecked {
uint256 gasLeft = gasleft();
y = x >> 127;
gasUsed = gasLeft - gasleft();
}
}
}
```
File `Test.js`:
```javascript
const Test1 = artifacts.require("Test1");
const Test2 = artifacts.require("Test2");
describe("", () => {
it("", async () => {
const testContract1 = await Test1.new();
const testContract2 = await Test2.new();
await testContract1.func(42);
await testContract2.func(42);
const gasUsed1 = await testContract1.gasUsed();
const gasUsed2 = await testContract2.gasUsed();
console.log(gasUsed1.toString(), gasUsed2.toString());
});
});
```
BTW, comparing between the assemblies, I can see that when changing `x` to `x * x`, the exact same sequence of opcodes, namely `dup5` followed by `mul`, is added for each function:
```
+--------------------------------+------------------------------------+-----------------------+---------------------------+
| y = x / 0x8000... | y = x * x / 0x8000... | y = x >> 127 | y = x * x >> 127 |
|--------------------------------|------------------------------------|-----------------------|---------------------------|
| 0x8000... | 0x8000... | 0x7f // 127 | 0x7f // 127 |
| dup4 // x | dup4 // x | dup4 // x | dup4 // x |
| | dup5 // x | | dup5 // x |
| | mul // x * x | | mul // x * x |
| dup2 // x / 0x8000... | dup2 // x * x / 0x8000... | swap1 // x >> 127 | swap1 // x * x >> 127 |
| tag_1 | tag_1 | | |
| jumpi | jumpi | | |
| tag_2 | tag_2 | | |
| tag_3 | tag_3 | | |
| jump | jump | | |
| tag_2 | tag_2 | | |
| tag_1 | tag_1 | | |
| div // y = x / 0x8000... | div // y = x * x / 0x8000... | shr // y = x >> 127 | shr // y = x * x >> 127 |
+--------------------------------+------------------------------------+-----------------------+---------------------------+
```
So it is not really clear to me why `Test1.func` increases by 8 gas units (from 23 to 31), while `Test2.func` increases by 5 gas units (from 24 to 29).
The difference between the code which uses division and the code which uses right-shift, regardless of whether we apply the operation on `x` or on `x * x`, seems to be `dup2` and `div` in the case of division, vs `swap1` and `shr` in the case of right-shift.
On the target EVM (paris):
- `div` consumes 5 gas units
- `shr` consumes 3 gas units
- `mul` consumes 5 gas units
- `dup` consumes 3 gas units
- `swap` consumes 3 gas units
I would therefore expect to see in **both** cases:
- An increase of exactly 2 gas units when changing from ` >> ` to ` / `, since "dup + div - (swap + shr) = 8 - 6 = 2"
- An increase of exactly 8 gas units when changing from `x` to `x * x`, since "dup + mul = 3 + 5 = 8"
However, instead of that, I am observing:
- One case where changing from ` >> ` to ` / ` decreases the cost by 1 gas unit instead of increasing it by 2 gas units
- One case where changing from `x` to `x * x` increases the cost by 5 gas units instead of increasing it by 8 gas units
It seems that if `y = x >> 127` costed 21 gas units instead of 23, then both of the "discrepancies" above were resolved:
- `y = x * x >> 127` costs 29 gas units, which can be inferred from 21 + 8
- `y = x / 0x8000...` costs 23 gas units, which can be inferred from 21 + 2
- `y = x * x / 0x8000...` costs 31 gas units, which can be inferred from 21 + 8 + 2
Contributor guide
Research direction
Start with the reproducible files package.json, Test.sol, and Test.js, and run the stated Hardhat test using Solidity 0.8.28, the legacy pipeline, and the Paris target. Then inspect the compiler's legacy optimizer or code-generation behavior for the differing opcode costs. Done means explaining the discrepancy and adding a regression test if the compiler behavior is confirmed as a bug.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- cpp, solidity
- Domain
- blockchain, compilers
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Mostly clear
- Newbie friendliness
- 30/100