The removal of the experimental EVM

And optimisations done to the byte-code interpreter

Jeffrey Wilcke
Jan 23, 2017 · 4 min read

A few months ago I wrote (1, 2) about the work being done on a new EVM for the go-ethereum project. While being somewhat successful it got removed in Geth 1.5.6; it was a burden to keep up to date with the conventional byte code interpreter, it had a known risk with program caching and could be exploited where compile time > runtime. With this information I decided to remove the experimental EVM and use the knowledge gained to further improve the conventional byte-code interpreter.

Simplified Run Loop Instruction Calling

From my experience and optimising the experimental EVM I noticed a significant increase in performance in calling large programs (= long running). After investigation I found out that the removal of the op code instruction switch was taking a lot of credit for this. It appears large switches are pretty “awful” in Go.

Our EVM contained two main switch statements. One for parsing and executing special opcodes (regular, non-returning or jumping opcodes were already removed from the switch) and another for calculating the gas cost. Gas counting being the largest one I set out the optimise that one first. And after some tinkering I figured it was time to rethink what an instruction is and what it is made out of (in terms of code design).

So what is an EVM instruction? When we are calling an EVM instruction we can split it up in 4 simple steps:

  • Stack validation: do we have enough stack items to execute the next instruction;
  • Memory expansion: calculate the size of EVM memory the instruction needs to operate under;
  • Gas calculation and validation: how much we charge and do we have enough gas to pay for the execution;
  • Execute the instruction if previous checks went OK.

EVM instructions in the go-ethereum repository are now refactored in to proper types and setup prior to execution:

type operation struct {
execute executionFunc
gasCost gasFunc
validateStack stackValidationFunc
memorySize memorySizeFunc
}

An operation contains the 4 steps listed above that take care of a successful operation of the opcode, leading to concise and precise code and removes the need for the two switch statements. This leading to an impressive performance boost:

benchmark                         old ns/op     new ns/op     delta
BenchmarkVmFibonacci16Tests-4 37668307 21323060 -43.39%

In addition the EVM gained the ability to run unmetered. Obviously this can’t be used for consensus execution (block transitions) but may be used for RPC requests like eth_call leading to faster EVM executions when doing “offline” calls:

benchmark                         old ns/op     new ns/op     delta
BenchmarkVmFibonacci16Tests-4 37668307 14204094 -62.29%

Upcoming changes

In the upcoming EVM changes I’ve looked in to optimising the gas instructions and integer allocations.

EVM 64 bit gas instructions

The experimental EVM had an additional improvement over the byte-code interpreter: 64-bit gas instructions (more info: Optimising the EVM). While this was still a work in progress and contained bugs it certainly did much better on the ackerman and fib benchmarks.

This has successfully been ported over to the byte-code EVM, boosting the EVM’s performance.

Integer Allocations

All stack items, be it addresses, hashes or numbers are Go *big.Ints. This means that for each new item that’s being pushed to the stack a new integer is allocated. This is wasteful because many of the pushed items are thrown away once used by an instruction causing the GC to reap it.

Instead of letting the GC collect the integers the EVM contains a pool of integers that may contain an arbitrary amount of allocated integers. The items in this pool can be popped at any given time when the EVM requires a new integer (e.g. when we PUSH to the stack) and used items, stack or otherwise, can be pushed back at any given time. This will save us about 11% allocations, which isn’t what I’d hoped for but significant enough to keep.

benchmark                         old allocs     new allocs     delta
BenchmarkVmAckermann32Tests-4 39283 34590 -11.95%
BenchmarkVmFibonacci16Tests-4 228242 203284 -10.93%
benchmark old bytes new bytes delta
BenchmarkVmAckermann32Tests-4 1478686 1340155 -9.37%
BenchmarkVmFibonacci16Tests-4 8444223 7935287 -6.03%

Conclusion

These changes are low hanging fruit and are certainly worth the time and effort as you don’t get to do 40 to 60% optimisations every day — please note that 40–60% isn’t overall, it’s the time spent in the EVM that has been improved. Running the code with the live network shows that it can handle about the double amount of million gas per second which shows about a 15% performance gain.

Further investigation will have to determine whether it’s worth pursuing op-code optimisations or 64bit arithmitic. Apart from the optimisations the changes to the codebase cleaned up a lot of tech-debt we had in the vm package by structuring and simplifying the code.

Jeffrey Wilcke

Written by

Ethereum Founder, Ether hacker & Gopher

More From Medium

Top on Medium

Mar 25 · 22 min read

27K

Top on Medium

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade