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

Optimize toString #3573

Merged
merged 18 commits into from
Aug 31, 2022
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
* `ReentrancyGuard`: Reduce code size impact of the modifier by using internal functions. ([#3515](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/3515))
* `SafeCast`: optimize downcasting of signed integers. ([#3565](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/3565))
* `ERC20FlashMint`: add an internal `_flashFee` function for overriding. ([#3551](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/3551))
* `Strings`: optimize `toString`. ([#3573](https://github.com/OpenZeppelin/openzeppelin-contracts/pull/3573))

### Compatibility Note

Expand Down
8 changes: 4 additions & 4 deletions contracts/mocks/StringsMock.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,19 @@ pragma solidity ^0.8.0;
import "../utils/Strings.sol";

contract StringsMock {
function fromUint256(uint256 value) public pure returns (string memory) {
function toString(uint256 value) public pure returns (string memory) {
return Strings.toString(value);
}

function fromUint256Hex(uint256 value) public pure returns (string memory) {
function toHexString(uint256 value) public pure returns (string memory) {
return Strings.toHexString(value);
}

function fromUint256HexFixed(uint256 value, uint256 length) public pure returns (string memory) {
function toHexString(uint256 value, uint256 length) public pure returns (string memory) {
return Strings.toHexString(value, length);
}

function fromAddressHexFixed(address addr) public pure returns (string memory) {
function toHexString(address addr) public pure returns (string memory) {
return Strings.toHexString(addr);
}
}
105 changes: 78 additions & 27 deletions contracts/utils/Strings.sol
Original file line number Diff line number Diff line change
Expand Up @@ -13,42 +13,93 @@ library Strings {
/**
* @dev Converts a `uint256` to its ASCII `string` decimal representation.
*/
function toString(uint256 value) internal pure returns (string memory) {
// Inspired by OraclizeAPI's implementation - MIT licence
// https://github.com/oraclize/ethereum-api/blob/b42146b063c7d6ee1358846c198246239e9360e8/oraclizeAPI_0.4.25.sol
function toString(uint256 value) internal pure returns (string memory result) {
unchecked {
uint256 length = 1;

if (value == 0) {
return "0";
}
uint256 temp = value;
uint256 digits;
while (temp != 0) {
digits++;
temp /= 10;
}
bytes memory buffer = new bytes(digits);
while (value != 0) {
digits -= 1;
buffer[digits] = bytes1(uint8(48 + uint256(value % 10)));
value /= 10;
// compute log10(value), and add it to length
uint256 valueCopy = value;
if (valueCopy >= 10**64) {
valueCopy /= 10**64;
length += 64;
}
if (valueCopy >= 10**32) {
valueCopy /= 10**32;
length += 32;
}
if (valueCopy >= 10**16) {
valueCopy /= 10**16;
length += 16;
}
if (valueCopy >= 10**8) {
valueCopy /= 10**8;
length += 8;
}
if (valueCopy >= 10**4) {
valueCopy /= 10**4;
length += 4;
}
if (valueCopy >= 10**2) {
valueCopy /= 10**2;
length += 2;
}
if (valueCopy >= 10**1) {
length += 1;
}
// now, length is log10(value) + 1

result = new string(length);
/// @solidity memory-safe-assembly
assembly {
let pos := add(result, 32)
let ptr := add(pos, length)

for {

} gt(ptr, pos) {

} {
ptr := sub(ptr, 1)
mstore8(ptr, add(48, mod(value, 10)))
value := div(value, 10)
}
}
}
return string(buffer);
}

/**
* @dev Converts a `uint256` to its ASCII `string` hexadecimal representation.
*/
function toHexString(uint256 value) internal pure returns (string memory) {
if (value == 0) {
return "0x00";
}
uint256 temp = value;
uint256 length = 0;
while (temp != 0) {
length++;
temp >>= 8;
unchecked {
uint256 length = 1;

// compute log256(value), and add it to length
uint256 valueCopy = value;
if (valueCopy >> 128 > 0) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

if (valueCopy >= 1 << 128) { will be equivalent, but should be cheaper, the shifting will be evaluated at compilation time.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I'm not sure the shift is done at compile time. Can you confirm ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

As of 0.8.13, yes, I've changed some constants to 1 << X and the gas usage hasn't changed at all.

Copy link
Contributor

Choose a reason for hiding this comment

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

but should be cheaper

Why? I'm seeing the compiler emit code sequences that cost exactly the same for the two expressions.

PUSH1 0x1 PUSH1 0x80 SHL DUP2 LT
vs
PUSH1 0x80 DUP2 SWAP1 SHR ISZERO

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not sure what opcodes exactly it generates, but it's marginally cheaper, 30 gas in total or 6 gas per if, probably because it doesn't do any bit-shifting at all. I'm surprised that you got any SHL at all, so the compiler must've not precalculated the constants, did you enable the optimizer?

The gas difference is ridiculously low anyway, I don't have a strong opinion about which version is better. The current one (valueCopy >= 1 << N) is a little more consistent with the decimal toString, but that's just a matter of preference. There's no "old version" to revert to, it's a new piece of code, the old implementation was completely different.

Copy link
Contributor

Choose a reason for hiding this comment

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

did you enable the optimizer?

Yes. For constants Solidity will often prioritize code size before runtime cost.

valueCopy >>= 128;
length += 16;
}
if (valueCopy >> 64 > 0) {
valueCopy >>= 64;
length += 8;
}
if (valueCopy >> 32 > 0) {
valueCopy >>= 32;
length += 4;
}
if (valueCopy >> 16 > 0) {
valueCopy >>= 16;
length += 2;
}
if (valueCopy >> 8 > 0) {
valueCopy >>= 8;
length += 1;
}
// now, length is log256(value) + 1

return toHexString(value, length);
}
return toHexString(value, length);
}

/**
Expand Down
69 changes: 43 additions & 26 deletions test/utils/Strings.test.js
Original file line number Diff line number Diff line change
@@ -1,71 +1,88 @@
const { constants, expectRevert } = require('@openzeppelin/test-helpers');
const { BN, constants, expectRevert } = require('@openzeppelin/test-helpers');

const { expect } = require('chai');

const StringsMock = artifacts.require('StringsMock');

contract('Strings', function (accounts) {
beforeEach(async function () {
this.strings = await StringsMock.new();
});
let strings;

describe('from uint256 - decimal format', function () {
it('converts 0', async function () {
expect(await this.strings.fromUint256(0)).to.equal('0');
});

it('converts a positive number', async function () {
expect(await this.strings.fromUint256(4132)).to.equal('4132');
});
before(async function () {
strings = await StringsMock.new();
});

it('converts MAX_UINT256', async function () {
expect(await this.strings.fromUint256(constants.MAX_UINT256)).to.equal(constants.MAX_UINT256.toString());
});
describe('toString', function () {
for (const [ key, value ] of Object.entries([
'0',
'7',
'10',
'99',
'100',
'101',
'123',
'4132',
'12345',
'1234567',
'1234567890',
'123456789012345',
'12345678901234567890',
'123456789012345678901234567890',
'1234567890123456789012345678901234567890',
'12345678901234567890123456789012345678901234567890',
'123456789012345678901234567890123456789012345678901234567890',
'1234567890123456789012345678901234567890123456789012345678901234567890',
].reduce((acc, value) => Object.assign(acc, { [value]: new BN(value) }), {
MAX_UINT256: constants.MAX_UINT256.toString(),
}))) {
it(`converts ${key}`, async function () {
expect(await strings.methods['toString(uint256)'](value)).to.equal(value.toString(10));
});
}
});

describe('from uint256 - hex format', function () {
describe('toHexString', function () {
it('converts 0', async function () {
expect(await this.strings.fromUint256Hex(0)).to.equal('0x00');
expect(await strings.methods['toHexString(uint256)'](0)).to.equal('0x00');
});

it('converts a positive number', async function () {
expect(await this.strings.fromUint256Hex(0x4132)).to.equal('0x4132');
expect(await strings.methods['toHexString(uint256)'](0x4132)).to.equal('0x4132');
});

it('converts MAX_UINT256', async function () {
expect(await this.strings.fromUint256Hex(constants.MAX_UINT256))
expect(await strings.methods['toHexString(uint256)'](constants.MAX_UINT256))
.to.equal(web3.utils.toHex(constants.MAX_UINT256));
});
});

describe('from uint256 - fixed hex format', function () {
describe('toHexString fixed', function () {
it('converts a positive number (long)', async function () {
expect(await this.strings.fromUint256HexFixed(0x4132, 32))
expect(await strings.methods['toHexString(uint256,uint256)'](0x4132, 32))
.to.equal('0x0000000000000000000000000000000000000000000000000000000000004132');
});

it('converts a positive number (short)', async function () {
await expectRevert(
this.strings.fromUint256HexFixed(0x4132, 1),
strings.methods['toHexString(uint256,uint256)'](0x4132, 1),
'Strings: hex length insufficient',
);
});

it('converts MAX_UINT256', async function () {
expect(await this.strings.fromUint256HexFixed(constants.MAX_UINT256, 32))
expect(await strings.methods['toHexString(uint256,uint256)'](constants.MAX_UINT256, 32))
.to.equal(web3.utils.toHex(constants.MAX_UINT256));
});
});

describe('from address - fixed hex format', function () {
describe('toHexString address', function () {
it('converts a random address', async function () {
const addr = '0xa9036907dccae6a1e0033479b12e837e5cf5a02f';
expect(await this.strings.fromAddressHexFixed(addr)).to.equal(addr);
expect(await strings.methods['toHexString(address)'](addr)).to.equal(addr);
});

it('converts an address with leading zeros', async function () {
const addr = '0x0000e0ca771e21bd00057f54a68c30d400000000';
expect(await this.strings.fromAddressHexFixed(addr)).to.equal(addr);
expect(await strings.methods['toHexString(address)'](addr)).to.equal(addr);
});
});
});