Skip to content

Masking Bytecode Generation Improvements #16716

Description

@DanielVF

Abstract

In looking at deployed smart contracts on ethereum, 12%-16%+ of all generated solidity bytecode is masking operations. This is because the stack is 32 bytes wide, and safely using values smaller than that from untrusted or from packed sources require a cleaning operation.

Improvements here would be impactful, since bytecode size limits are a critical constraint for product development and working around them adds a lot of complication to the work of major protocols. Bytecode size also directly affects deployment costs.

Possible Methods

Here's a survey of different methods. I'll use masking addresses as examples, since they are the most common masking size.

Push

PUSH20 AND 22 bytes, 6 gas

This is the default output when the optimizer is off. It is gas optimal but it uses increasing amounts of bytecode as the mask gets bigger.

SHL/SUB

PUSH1 PUSH1 SHL PUSH1 SUB AND 9 bytes, 18 gas

This is an alternative used selectively by solidity with the optimizer on. This pushs a single bit, shifts that bit left to one bit above the mask target size, and then subtracts one to fill all bits below with ones and create the mask.

# push 1
0x000000000000000001
# <-- shift left
0x000001000000000000
# sub1
0x000000FFFFFFFFFFFF

This is not Pareto optimal - there are other ways that use both less gas and less bytes.

SHL/SHR

PUSH1 SHL PUSH1 SHR 6 bytes, 12 gas

This works by shifting left, to remove bytes to the left, then shifting right again to return the kept bits to their original position.

0x123456aaaaaaaaaaaa
# <-- shift left
0xaaaaaaaaaaaa000000
# --> shift rightt
0x000000aaaaaaaaaaaa

While it is more gas efficient than the next method, it does not easily compose with the existing optimization pipeline in solidity.

Negated(0) and SHIFT

ZERO NOT PUSH1 SHR AND 6 bytes , 15 gas

There is a stalled PR at #15935 that implements this method. There is also a rework of the same approach applied to a recent commit at #16729

MLOAD

PUSH1 MLOAD AND 4 bytes, 9 gas

By adding a 32 byte slot of ones after the solidity zero address, and moving where the solidity memory pointer points to, extremely efficient constants and masks can be created by just reading from the correct memory location.

The default memory now looks like:

SCRATCH
SCRATCH
FREEPOINTER
ZEROS
ONES

Then constants can be cheaply created by loading a memory region:

000000000000FFFFFFFFFFFF
         |----------|
    |----------|

A concept PR for this has been created #16708. This shows improvements of about 5% of total contact bytecode size vs the current SHL/SUB style size optimization, as well as corresponding gas improvements.

This method requires that the zeros and ones memory regions be untouched.

It also imposes a slight overhead of 5 bytes, and 15 gas. The bytecode overhead is more than paid back by the space savings throughout the rest of the contract. The gas overhead may result in a small additional gas usage for minimal contract methods that do not do masking.

Masking opcode

PUSH1 MASKBITS 3 bytes, 6 gas

The EVM could support a custom opcode for masking. I've written a draft EIP for a MASKBITS opcode that would be Pareto optimal choice for all but the smallest masks if implemented. The primary concern with this approach is if any code alters the reserved memory regions, then the compiled code will no longer behave as expected.

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions