Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Branchless ternary, min and max methods #4976

Merged
merged 20 commits into from
Apr 23, 2024

Conversation

Lohann
Copy link
Contributor

@Lohann Lohann commented Mar 27, 2024

Description

Follow a Branchless Min and Max functions, all of them consumes less gas than the current implementation, and have a constant gas cost independent of the input value.

Motivation

I'm working in the Analog Timechain, which is a cross-chain protocol that allow interoperability between different blockchains.

At some point our protocol needs for a given input calculate the exact execution gas cost, but this was hard due a lot of branching, branching that should not even exist, for example the Math.min function use more or less gas when a < b or a >= b, either compiling in debug and optimized mode:

pragma solidity >=0.7.0 <0.9.0;

contract Math {
    function min(uint256 a, uint256 b) external pure returns (uint256 result) {
        result = a < b ? a : b;
    }
}

a < b consumes 306 gas
a >= b consumes 316 gas

This was annoying because for every change in the contract I had to dive into the EVM assembly to understand how solidity generate different branches to understand how it affect the gas used. So I decided to take a different approach and make everything constant gas cost, as result I implemented the BranchlessMath.sol library, which have a constant gas cost for any input, and surprisingly I also noticed significant gas savings using those methods compared to the OpenZeppeling's Math library, so I decided to make it available to everyone by contributing here.

In comparison the branchless version reduces the final bytecode size, and also consumes less gas:

contract Math {
    function min(uint256 a, uint256 b) external pure returns (uint256 result) {
        assembly ("memory-safe") {
            // min(a,b) = b ^ ((a ^ b) * (a < b))
            result := xor(b, mul(xor(a, b), lt(a, b)))
        }
    }
}

Always consumes 291 gas, independently if a < b or a >= b.

PR Checklist

  • Tests
  • Documentation
  • Changeset entry (run npx changeset add)

Copy link

changeset-bot bot commented Mar 27, 2024

🦋 Changeset detected

Latest commit: dc6abbc

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
openzeppelin-solidity Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copy link
Collaborator

@Amxx Amxx left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello @Lohann and thank you for this PR.

The min/max option is interresting, because branches are indeed notoriously bad. Code readability is not that great though. If we don't have them yet, this would definitelly need to be fuzzed.

I'm not that fan of the rest. The objective of this library has always been security, readability, and auditability. Regardless of what some other project may think, we strongly believe in readability (and documentation through comments) of the code.

Therefore, I'm not leaning toward a full inline assembly version, nor am I willing to remove the documentation comments (that we took so long to make sure are clear)

@Lohann
Copy link
Contributor Author

Lohann commented Mar 27, 2024

@Amxx thanks for the feedback, will remove some assembly at the cost of a consuming a bit more gas, but essentially what the sqrt does is:

function sqrt(uint256 a) public pure returns (uint256) {
  unchecked {
    // Approximate to the closest power of 2 of the square root. This is equivalent to set the MSB of
    // the square root, this reduces Netwon's Method iterations required to get the final result.
    uint256 xn = 2 ** (Math.log2(a) / 2);
    
    // 7 iterations of Netwon's method
    xn = (xn + a / xn) >> 1;
    xn = (xn + a / xn) >> 1;
    xn = (xn + a / xn) >> 1;
    xn = (xn + a / xn) >> 1;
    xn = (xn + a / xn) >> 1;
    xn = (xn + a / xn) >> 1; 
    xn = (xn + a / xn) >> 1;
    
    // Round down
    // Obs: Using `Math.max` is equivalent to round to nearest, not round up.
    xn = Math.min(xn, a / xn);
    
    return xn;
  }
}

Except that:

  • The log2 used in the Sqrt function is a bit more optimized as it ignore the least significant bit, once the result will be divided by 2 anyway.
  • The Math.min(xn, a / xn) is optimized once the difference between xn and a / xn is either 0 or 1, so it can be calculated as xn -= SafeCast.toUint(xn > a/xn);

@Amxx
Copy link
Collaborator

Amxx commented Mar 28, 2024

This is almost exactly what we used to have

It got changed in a long and painfull (to review) PR. This included documenting a lot of math to prove (and not just feel) that the new version is correct (in addition to being cheaper).

If we are to go back on sqrt, I'll do it in a separate issue/PR. I'd rather split the review effort and the frustration. I really see a point to merging min/max decently fast ... and I fear sqrt will take much more discussion.

@Lohann
Copy link
Contributor Author

Lohann commented Mar 28, 2024

@Amxx makes sense, I removed the sqrt changes, I also removed all assembly code maintaining the same gas efficiency to min and max methods.

I made it a bit more generic by introducing the choice function, which is the core principle on how the branchless min/max works, it can be used to replace any simple ternary operations.
Because it is a new method, I incremented the minor instead of the patch, but if you think we should not expose this function, I can make it private instead internal, even so I think it is quite useful.

@Lohann Lohann changed the title Branchless gas efficient Square Root, Log2, min and max methods Branchless choice, min and max methods Mar 28, 2024
@Amxx
Copy link
Collaborator

Amxx commented Mar 28, 2024

Because it is a new method, I incremented the minor instead of the patch.

Anythink that is not a security fix goes into a minor anyway.

but if you think we should not expose this function, I can make the choice private, even so I think it is quite useful.

We'll discuss that.

Thank you for your effort ... though please don't be frustrated if this takes time to merge. Our processes are ususally considered slow by external contributors, but we fell that is how we achieve maximum quality/security.

contracts/utils/math/Math.sol Outdated Show resolved Hide resolved
contracts/utils/math/Math.sol Outdated Show resolved Hide resolved
contracts/utils/math/Math.sol Outdated Show resolved Hide resolved
@Lohann
Copy link
Contributor Author

Lohann commented Apr 8, 2024

When you do a ternary choice, the compiler can "optimize" it so that only one branch is computer. However, when you use a dedicated function, values for both side must be computed. This remove the opportunity for lazy evaluation.

I can make the select method private or use it only in simple ternary operations, if we are unsure about expose it or not.

@Amxx
Copy link
Collaborator

Amxx commented Apr 8, 2024

I don't think it should be private. We want to be able to use it in different places/libraries without re-declaring it.

I think it comes down to documentation that it is not always cheaper than a ternary, and that both branches are always evaluate.

@Lohann Lohann changed the title Branchless choice, min and max methods Branchless select, min and max methods Apr 9, 2024
@ernestognw
Copy link
Member

Catching up with this. I'd like to highlight that @Lohann started this looking for constant cost in math operations. Although it's not the kind of priorities OpenZeppelin Contracts has, it's an honest requirement. I also agree with @Amxx with that we prefer more readable code.

I would consider creating an issue for branchless math if this requirement of constant costs ever comes back.

I'll be leaving comments while I read the conversation.

@ernestognw
Copy link
Member

ernestognw commented Apr 22, 2024

When you do a ternary choice, the compiler can "optimize" it so that only one branch is computer. However, when you use a dedicated function, values for both side must be computed. This remove the opportunity for lazy evaluation.

Is this true? I see there's an open issue about it:
ethereum/solidity#12930

Copy link
Member

@ernestognw ernestognw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also have mixed feelings regarding compiler branchless optimization, but I think it's not implemented and, in general, Solidity developers complain about obvious optimizations not performed, so I see value in giving some control to the user.

contracts/utils/math/SignedMath.sol Outdated Show resolved Hide resolved
contracts/utils/math/SignedMath.sol Outdated Show resolved Hide resolved
contracts/utils/math/Math.sol Outdated Show resolved Hide resolved
contracts/utils/math/Math.sol Outdated Show resolved Hide resolved
@Lohann Lohann changed the title Branchless select, min and max methods Branchless ternary, min and max methods Apr 22, 2024
@Lohann Lohann requested review from Amxx and ernestognw April 22, 2024 18:58
Copy link
Member

@ernestognw ernestognw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small nit with the changeset

.changeset/spotty-falcons-explain.md Outdated Show resolved Hide resolved
Copy link
Member

@ernestognw ernestognw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried looking for ternary branchless optimization in Solidity without luck, so I'd expect such optimization to not exist. Regardless, we can't ensure it'll be more efficient in the future, so I focused the changeset on the new ternary function and its "constant gas" property.

For the controversial part of Math.sol, the discussion will continue back and forth without strong evidence of the distribution in applications out there, so I wouldn't focus on it if the result is more-less 20 gas units.

LGTM, but let's wait on @Amxx since the name change to ternary wasn't agreed with him.

@Amxx Amxx merged commit 4032b42 into OpenZeppelin:master Apr 23, 2024
18 checks passed
Copy link

gitpoap-bot bot commented Apr 23, 2024

Congrats, your important contribution to this open-source project has earned you a GitPOAP!

GitPOAP: 2024 OpenZeppelin Contracts Contributor:

GitPOAP: 2024 OpenZeppelin Contracts Contributor GitPOAP Badge

Head to gitpoap.io & connect your GitHub account to mint!

Learn more about GitPOAPs here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants