đź“– This EIP is in the review stage. It is subject to changes and feedback is appreciated.

EIP-4200: Static relative jumps Source

RJUMP and RJUMPI instructions with a signed immediate encoding the jump destination

AuthorAlex Beregszaszi, Andrei Maiboroda, Paweł Bylica
Discussions-Tohttps://ethereum-magicians.org/t/eip-3920-static-relative-jumps/7108
StatusReview
TypeStandards Track
CategoryCore
Created2021-07-16
Requires 3540, 3670

Abstract

Two new EVM jump instructions are introduced (RJUMP and RJUMPI) which encode the destination as a signed immediate value. These can be useful in the majority of (but not all) use cases and offer a cost reduction.

Motivation

A recurring discussion topic is that EVM only has a mechanism for dynamic jumps. They provide a very flexible architecture with only 2 (!) instructions. This flexibility comes at a cost however: it makes analysis of code more complicated and it also (partially) resulted in the need to have the JUMPDEST marker.

In a great many cases control flow is actually static and there is no need for any dynamic behaviour, though not every use case can be solved by static jumps.

There are various ways to reduce the need for dynamic jumps, some examples:

  1. With native support for functions / subroutines
  2. A “return to caller” instruction
  3. A “switch-case” table with dynamic indexing

This change does not attempt to solve these, but instead introduces a minimal feature set to allow compilers to decide which is the most adequate option for a given use case. It is expected that compilers will use RJUMP/RJUMPI almost exclusively, with the exception of returning to the caller continuing to use JUMP.

This functionality does not preclude the EVM from introducing other forms of control flow later on. RJUMP/RJUMPI can efficiently co-exists with a higher-level declaration of functions, where static relative jumps should be used for intra-function control flow.

The main benefit of these instruction is reduced gas cost (both at deploy and execution time) and better analysis properties.

Specification

We introduce two new instructions on the same block number EIP-3540 is activated on:

  1. RJUMP (0x5c)
  2. RJUMPI (0x5d)

If the code is legacy bytecode, both of these instructions result in an exceptional halt. (Note: This means no change to behaviour.)

If the code is valid EOF1:

  1. RJUMP relative_offset (0x5c), sets the PC to PC_post_instruction + relative_offset.
  2. RJUMPI relative_offset (0x5d), pops a value (condition) from the stack, and sets the PC to PC_post_instruction + ((condition == 0) ? 0 : relative_offset).

The immediate argument relative_offset is encoded as a 16-bit signed (two’s-complement) big-endian value. Under PC_post_instruction we mean the PC position after the entire immediate value.

We also extend the validation algorithm of EIP-3670 to verify that each RJUMP/RJUMPI has a relative_offset pointing to an instruction. This means it cannot point to an immediate data of PUSHn/RJUMP/RJUMPI. It cannot point outside of code bounds. It is allowed to point to a JUMPDEST, but is not required to.

Because the destinations are validated upfront, the cost of these instructions are less than their dynamic counterparts: RJUMP should cost 5, and RJUMPI should cost 7. This is a reduction of 2 gas, compared to JUMP and JUMPI.

Rationale

Relative addressing

We chose relative addressing in order to support code which is moveable. This also means it can be injected. A technique seen used prior to this EIP to achieve this same goal was to inject code like PUSHn PC ADD JUMPI.

We do not see any significant downside to relative addressing, but it also allows the deprecation of the PC instruction.

Note: EIP-3670 should reject PC.

Immediate size

The signed 16-bit immediate means that the largest jump distance possible is 32767. In the case the bytecode at PC=0 starts with an RJUMP, it will be possible to jump as far as PC=32770.

Given MAX_CODE_SIZE = 24576 (in EIP-170) and MAX_INITCODE_SIZE = 49152 (in EIP-3860), we think the 16-bit immediate is large enough.

A version with an 8-bit immediate would only allow moving PC backward by 125 or forward by 127 bytes. While that seems to be a good enough distance for many for-loops, it is likely not good enough for cross-function jumps, and since the 16-bit immediate is the same size as what a dynamic jump would take in such cases (3 bytes: JUMP PUSH1 n), we think having less instructions is better.

Should there be a need to have immediate encodings of other size (such as 8-bits, 24-bits or 32-bits), it would be possible to introduce new opcodes, similarly to how multiple PUSH instructions exist.

PUSHn JUMP sequences

If we chose absolute addressing, then RJUMP could be viewed similar to the sequence PUSHn JUMP (and RJUMPI similar to PUSHn RJUMPI). In that case one could argue that instead of introducing a new instruction, such sequences should get a discount, because EVMs could optimise them.

We think this is a bad direction to go:

  1. It further complicates the already complex rules of gas calculation.
  2. And it either requires a consensus defined internal representation for EVM code, or forces EVM implementations to do optimisations on their own.

Both of these are risky. Furthermore we think that EVM implementations should be free to chose what optimisations they apply, and the savings do not need to be passed down at all cost.

Additionally it requires a potentially significant change to the current implementations which depend on a streaming one-by-one execution without a lookahead.

Relation to dynamic jumps

The goal was not to completely replace the current control flow system of the EVM, but to augment it. There are many cases where dynamic jumps are useful, such as returning to the caller.

It is possible to introduce a new mechanism for having a pre-defined table of valid jump destinations, and dynamically supplying the index within this table to accomplish some form of dynamic jumps. This is very useful for efficiently encoding a form of “switch-cases” statements. It could also be used for “return to caller” cases, however it is likely inefficient or awkward.

Lack of JUMPDEST

JUMPDEST serves two purposes:

  1. To efficiently partition code – this can be useful for pre-calculating total gas usage for a given block (i.e. instructions between JUMPDESTs), and for JIT/AOT translation.
  2. To explicitly show valid locations (otherwise any non-data location would be valid).

This functionality is not needed for static jumps, as the analysers can easily tell destinations from the static jump immediates during jumpdest-analysis.

There are two benefits here:

  1. Not wasting a byte for a JUMPDEST also means a saving of 200 gas during deployment, for each jump destination.
  2. Saving an extra 1 gas per jump during execution, given JUMPDEST itself cost 1 gas and is “executed” during jumping.

Reference Implementation

# The below are ranges as specified in the Yellow Paper.
# Note: range(s, e) excludes e, hence the +1
valid_opcodes = [
    *range(0x00, 0x0b + 1),
    *range(0x10, 0x1d + 1),
    0x20,
    *range(0x30, 0x3f + 1),
    *range(0x40, 0x48 + 1),
    *range(0x50, 0x5d + 1),
    *range(0x60, 0x6f + 1),
    *range(0x70, 0x7f + 1),
    *range(0x80, 0x8f + 1),
    *range(0x90, 0x9f + 1),
    *range(0xa0, 0xa4 + 1),
    # Note: 0xfe is considered assigned.
    *range(0xf0, 0xf5 + 1), 0xfa, 0xfd, 0xfe, 0xff
]

# STOP, RETURN, REVERT, INVALID, SELFDESTRUCT
terminating_opcodes = [ 0x00, 0xf3, 0xfd, 0xfe, 0xff ]

immediate_sizes = []
for opcode in range(0x100):
    # PUSH1..PUSH32
    if opcode >= 0x60 and opcode <= 0x7f:
        immediate_sizes.append(opcode - 0x60 + 1)
    # RJUMP and RJUMPI
    elif opcode == 0x5c or opcode == 0x5d:
        immediate_sizes.append(2)
    else:
        immediate_sizes.append(0)

# Fails with assertion on invalid code
def validate_code(code: bytes):
    # Note that EOF1 already asserts this with the code section requirements
    assert(len(code) > 0)

    opcode = 0
    pos = 0
    rjumpdests = set()
    immediates = set()
    while pos < len(code):
        # Ensure the opcode is valid
        opcode = code[pos]
        pos += 1
        assert(opcode in valid_opcodes)

        if opcode == 0x5c or opcode == 0x5d:
            assert(pos + 2 <= len(code))
            offset = int.from_bytes(code[pos:pos+2], byteorder = "big", signed = True)

            rjumpdest = pos + 2 + offset
            assert(rjumpdest >= 0 and rjumpdest < len(code))

            rjumpdests.add(rjumpdest)

        # Save immediate value positions
        immediates.update(range(pos, pos + immediate_sizes[opcode]))
        # Skip immediates
        pos += immediate_sizes[opcode]

    # Ensure last opcode's immediate doesn't go over code end
    assert(pos == len(code))

    # opcode is the *last opcode*
    assert(opcode in terminating_opcodes)

    # Ensure relative jump destinations don't target immediates
    assert(rjumpdests.isdisjoint(immediates))

Comparing to validation code of EIP-3670:

 # The below are ranges as specified in the Yellow Paper.
 # Note: range(s, e) excludes e, hence the +1
 valid_opcodes = [
     *range(0x00, 0x0b + 1),
     *range(0x10, 0x1d + 1),
     0x20,
     *range(0x30, 0x3f + 1),
     *range(0x40, 0x48 + 1),
-    *range(0x50, 0x5b + 1),
+    *range(0x50, 0x5d + 1),
     *range(0x60, 0x6f + 1),
     *range(0x70, 0x7f + 1),
     *range(0x80, 0x8f + 1),
     *range(0x90, 0x9f + 1),
     *range(0xa0, 0xa4 + 1),
     # Note: 0xfe is considered assigned.
     *range(0xf0, 0xf5 + 1), 0xfa, 0xfd, 0xfe, 0xff
 ]

 # STOP, RETURN, REVERT, INVALID, SELFDESTRUCT
 terminating_opcodes = [ 0x00, 0xf3, 0xfd, 0xfe, 0xff ]

 immediate_sizes = []
 for opcode in range(0x100):
     # PUSH1..PUSH32
     if opcode >= 0x60 and opcode <= 0x7f:
         immediate_sizes.append(opcode - 0x60 + 1)
+    # RJUMP and RJUMPI
+    elif opcode == 0x5c or opcode == 0x5d:
+        immediate_sizes.append(2)
     else:
         immediate_sizes.append(0)

 # Fails with assertion on invalid code
 def validate_code(code: bytes):
     # Note that EOF1 already asserts this with the code section requirements
     assert(len(code) > 0)

     opcode = 0
     pos = 0
+    rjumpdests = set()
+    immediates = set()
     while pos < len(code):
         # Ensure the opcode is valid
         opcode = code[pos]
         pos += 1
         assert(opcode in valid_opcodes)

+        if opcode == 0x5c or opcode == 0x5d:
+            assert(pos + 2 <= len(code))
+            offset = int.from_bytes(code[pos:pos+2], byteorder = "big", signed = True)
+
+            rjumpdest = pos + 2 + offset
+            assert(rjumpdest >= 0 and rjumpdest < len(code))
+
+            rjumpdests.add(rjumpdest)
+
+        # Save immediate value positions
+        immediates.update(range(pos, pos + immediate_sizes[opcode]))
         # Skip immediates
         pos += immediate_sizes[opcode]

     # Ensure last opcode's immediate doesn't go over code end
     assert(pos == len(code))

     # opcode is the *last opcode*
     assert(opcode in terminating_opcodes)

+    # Ensure relative jump destinations don't target immediates
+    assert(rjumpdests.isdisjoint(immediates))

Backwards Compatibility

This change poses no risk to backwards compatibility, as it is introduced at the same time EIP-3540 is. The new instructions are not introduced for legacy bytecode (code which is not EOF formatted).

Security Considerations

TBA

Copyright and related rights waived via CC0.

Citation

Please cite this document as:

Alex Beregszaszi, Andrei Maiboroda, Paweł Bylica, "EIP-4200: Static relative jumps," Ethereum Improvement Proposals, no. 4200, July 2021. [Online serial]. Available: https://eips.ethereum.org/EIPS/eip-4200.