New Ethereum talks, every Monday. The week's conference uploads by event, in your inbox.

Loading player…

EVM - Some Assembly Required by Alex Bazhenov | Devcon Bogotá

DevconSat, Oct 7, 2023, 12:00 AM

Visit the https://archive.devcon.org/ to gain access to the entire library of Devcon talks with the ease of filtering, playlists, personalized suggestions, decentralized access on Swarm, IPFS and more. https://archive.devcon.org/archive/watch/6/evm-some-assembly-required/ EVM assembly can be quite opaque and intimidating at first glance. In this talk we'll take a look at exactly how EVM assembly works under the hood while analyzing some common functionality we see in smart contracts - as well as showing some optimization techniques you can use by jumping down to assembly in your solidity contracts. Speaker(s): Alex Bazhenov Skill level: Intermediate Track: Developer Infrastructure Keywords: Solidity,Assembly,EVM Follow us: https://twitter.com/efdevcon, https://twitter.com/ethereum Learn more about devcon: https://www.devcon.org/ Learn more about ethereum: https://ethereum.org/ Devcon is the Ethereum conference for developers, researchers, thinkers, and makers. Devcon 6 was held in Bogotá, Colombia on Oct 11 - 14, 2022. Devcon is organized and presented by the Ethereum Foundation, with the support of our sponsors. To find out more, please visit https://ethereum.foundation/

Transcript

Uh hey guys, my name is Alex. I am a lead developer at Tally Ho. Um and I'm here to talk to you about EVM assembly. So, the two things we're going to cover in this talk is first like what is EVM assembly and second how to read the opcodes, how to read opcodes and trace an EVM transaction. But, I think before we jump into that, it's pretty important to tackle a question of why do we care?

Like why would we care about EVM assembly? I think that there's a few reasons. So, any of the code that we write as developers is pretty far abstracted from the code that a machine actually interprets and executes. And the closer we get to this real code and the further we we get from our abstractions, the more we can reinforce our mental model of the of what the machine is actually doing. And I think in general, learning from first principles is a fantastic way to progress as a programmer and to just build solid and bulletproof mental models of what your code is doing.

So, what is EVM assembly? Uh well, let's start with what is the EVM? So, you've probably heard of EVM, ZK-EVM, EVM LS. There's a lot of terminology terminology out there right now. But, at its core, the EVM is just a stack machine.

It's something that uh takes in instructions, builds up a stack, and then operates on that stack. Uh most of the operations consume values from the stack. So, add takes two values from the stack, adds them, you're left with one value. Multiply takes two values from the stack, adds them, you're left with one value, etc. But, there are exceptions to this.

Um I think the most notable one is push. So, we have push one through push 32, which pushes between one and 32 bytes onto the stack uh respectively. Um so, let's go a little bit more in-depth into the EVM stack machine. So, stack machine has a depth of 1,024 items. Um, each item is a 256-bit word.

If you're not sure what a word is, that's okay and it's not really relevant for this talk. It just it is a slot on the stack machine. Um, during execution, the EVM has, uh, memory that does not persist between transactions. It also has storage that does persist between transactions. When we talk about writing to the blockchain, that's what we mean.

We are storing things in the EVM and uh, also compiled smart contract bytecode executes a number of EVM bytecode, uh, opcodes. So, we have some that you might be familiar with if you've looked at x86 assembly like XOR and add and sub. And we also have a number of, uh, blockchain-specific opcodes in the EVM. And we'll dive into more of these a little bit later. And then I think the last important thing about the stack machine is that each operation costs a certain number of gas.

So, when we pay gas for our transactions, we're essentially paying, uh, a little bit or a lot of gas for every single operation that the stack machine executes. All right. So, now that we have some understanding of what an EVM is, uh, what is assembly? So, assembly is something that lives in between the code that we as developers write and between the code that, uh, a machine interprets. So, on one hand, we have Solidity, which is nice, uh, some people think, um, easy-ish to read, easy-ish to reason about.

And then, uh, on the other end of the on the other end of the spectrum, we have bytecode, which is pretty much impossible for humans to read, um, but very easy and very efficient for machines to read. And then in between Solidity and bytecode, we have assembly. So, this is kind of what it looks like and it is a intermediate language or an intermediate representation. Um and what that means is it is something that lives in between the code that we write and bytecode. And the the opcodes that you see there are what are actually running in the EVM.

So, the Solidity or the Viper that we're uh writing is compiled down to opcodes and then running in the EVM. And that's what that pattern is what lets us have multiple smart contract languages that compile down to EVM and then um are able to be executed. Or sorry, they compile down to assembly and then are able to be executed. All right. So, uh let's trace a transaction.

Um I have a website up here www.evm.codes. You might find it useful. You don't need it to follow along, but it's a fantastic reference that has every single opcode in the EVM, how much gas it costs, and what it does.

All right. So, here I've written this uh incredibly useful smart contract. Um we have uh one storage variable, total supply, and we have a public function that anybody can call that sets uh total supply to eight. So, very simple smart contract, not much going on here, not even sure why anybody would use it, right? So, uh the opcodes representing this contract should be pretty simple, right?

Um well, we can take a look. So, solc provides us with a handy little command where we can pass a contract to solc with the opcodes flag and it will output a opcode representation of that contract. So, our simple contract becomes that, which is kind of unreadable. But, by the end of this presentation uh or this talk, you guys will be able to read some parts of it. Um some that are common to all transactions that we see uh in the all transactions that we see in the EVM and some that are specific to the contract that we wrote.

So, let's go ahead and trace a specific transaction. Imagine that we've have our contract um at some address and then we are using ethers to call ultrasoundmoney.settotalsupply the transaction that we defined in the beginning. Great. We submit to the transaction.

It's been mined. It's part of the blockchain. Let's take a look at it. So uh geth has this very handy command called debug trace transaction. If you hear for the previous call, you're probably more familiar familiar with it than most and what we can do is we can pass in the transaction hash where we sent where we invoke that sent total supply function and get an opcode trace as well as a like a historic representation of the stack of the transaction.

So basically find out everything that happened in it. Um so let's trace the opcodes of that transaction. Um you'll see here that there are some lines between these opcodes and I've divided them into four sections. So that is more so just for clarity because I want to focus on three parts that are common to every transaction and one part that is common to ours. And I also want to note that what I'm going to show you is not necessarily a one-to-one representation of what you'll see when you send debug trace transaction.

For example, we're going to be converting hexadecimal numbers to decimals because hexadecimals are hard for humans to read. We're going to be showing booleans as true or false, but that's just so that what's happening here is so it's easier to follow along with what's happening here. So let's start with the top, this little section. So we have push 10x80. What we're doing here is we're pushing 128 onto the stack.

128 Sorry, 0x80 is represented is the hexadecimal representation of 128. Um then we're going to push 64 on the stack and then we're going to call a function called MSTORE which is going to store the value 128 at the offset 64 in memory. So what's going on here? We don't have 128, we don't have 64 on the stack, or we don't have 128 or 64 in our contract. So, why is this happening?

Um well, what's happening is Solidity uses the memory area between address zero and address zero x7f or 127 for internal purposes and stores data starting at address uh 0xa0 or 128. So, this is Solidity doing some boilerplate internal memory management for us. Fantastic. We don't have to worry about this as Solidity developers. This is just a straight win.

Wonderful. All right. So, now we're going to jump to line uh 25 in our list of opcodes. Between line 3 and 25, there's some more boilerplate stuff. There's uh validation of uh message.

value and uh making sure like you can't send Ether to a non-payable function, but that is not really relevant to us because our function isn't payable. So, we're going to go ahead and skip that. Um great. So, uh on 25, we're going to push four onto the stack. Once again, where is this four coming from?

And then 26, we're going to encounter our first blockchain-specific uh opcode. And what we're doing here is we're pushing the size of the input data onto the stack. And as you can see, we pushed four onto the stack. Now we have four on the stack again. So, that means our input data size is four bytes.

But, where did this come from? Um we didn't send any Ether, and we didn't send any uh arguments along with our function call cuz there were no parameters. It was just a public function that we invoked. So, why is our call data size four? Well, the answer to this is that when we call uh get total supply in Ethers, under the hood, Ethers is going to uh hash that get total supply function into its function signature and send that along with the input data when we send that transaction.

All right. So, we know that we have four, and we know that our call data is four, what's going on next. Well, LT, as you may have guessed, checks if input data if the input data is less than four and what's actually happening is it's looking at the top value of the stack, looking at the second from top value of the stack, and seeing if the second is less than the top value. In our case, it's not because four is not less than four. So, we have we push false onto the stack.

Remember, this would be represented as a zero or one. And then we push 0x280 or 38 onto the stack. We'll come back to that in just a second. The next instruction that we see is jump I, and I think of that as jump if. So, what that instruction is telling us is if the second from the top value is true, jump to the program counter represented by the top value on the stack.

A simple way to think about that is if the second from the top value is true, jump to line 38. Um, since the second from the top value is not true, we don't jump anywhere, and this like check executes. And essentially, what's happening here is that since function signatures are four bytes in length, length, if the call data size is less than four bytes, we know that we can't possibly be calling a valid function, and then the function gets reverted if the call data size is size is less than four. So, with that idea of function signatures in mind, let's take a look at the next section. So, call data load is another blockchain specific op code, similar to call data size, except instead of pushing the size of the call data onto the stack, it pushes pushes the actual call data onto the stack.

Remember that the size of our call data was four bytes, so here we have four-byte hexadecimal value on on stack that seems relatively arbitrary, but some of you are probably correctly guessing that we are actually pushing our function function signature onto the stack. Uh on the next line, we have push four and we push that same exact function function signature. What's happening here is uh the contract is checking, "Okay, I'm being sent this function signature. Does it match uh does it match this function signature?" And if the answer is yes, uh represented by the opcode equal, so equal takes two values from the stack and returns Sorry, the top two values from the stack and returns true or false if they're equal.

Uh we get a one or a zero and we In our case, it's true. So, once again, uh we're in that jump I situation. So, we have true on the stack. We push 45 onto the stack. And now on line 36, we are Excuse me.

On line 36, we're going to jump to line 45 because uh line 32 was equal to line 33 and hey, uh we know that this is the function um this is the function that we wrote because it's the function that we tried to invoke. Fantastic. And like this is how the EVM determines which functions to call. If you have many functions in your smart contract, you're going to have many lines of like of call data load, does it equal uh this function signature, does it equal this function signature, does it equal this one? And then if it matches one, it'll jump to that line and if it doesn't, it'll revert revert because you cannot call a function uh that doesn't exist.

Awesome. Okay, so now we're finally in that very simple function that we wrote. If you recall, it was total supply equals eight. Um what we see on line 45 is a common command called jump dest and all that does is it marks that line 45 is a valid destination to jump to. You cannot jump to any arbitrary destination.

You need to jump to a valid one or you'll revert. So, this just uh lets us know that, "Hey, we can jump here." What's the next thing we see? Push eight onto the stack. All right, awesome.

This is definitely code that we wrote. Total supply equals eight. I remember eight. This is us. Cool.

But, after that, we push zero onto the stack. So, where does the zero come from? What are we talking about? Well, if you recall in our very simple contract, we only have one storage variable. And that storage variable, because it's the only one defined, is in the first storage slot.

So, the first storage slot or the zeroth storage slot via array indexing, right? So, this is awesome. We have everything that we need. We have eight. We have zero.

All we have to do now is save this and we're done, right? But, wait a second. We see that in our transaction, we duplicated we ran dup two, so we duplicated the second from the top word of the stack. Okay. Then we swapped the first and second words on the stack.

Okay, interesting. And then, all right, great. So, now we ran sstore. So, sstore store in storage. We're saving eight to the zeroth storage slot.

That's what we initially wanted to do. But, then we also have a pop after that because we have this extra eight on here. And like, what is this eight doing? Why are Why is it here? This doesn't make a lot of sense, right?

Like, why are we duplicating and swapping here? Why are we popping at the end? The Solidity is doing extra memory management that we don't want, right? Because we want to be as gas efficient as possible and be as Yeah, be as gas efficient as possible. So, we should jump down into Yul and try to optimize this code, right?

Well, let's talk about gas optimization using Yul. If you're not familiar, Yul is simply a language that we use to write assembly code in Solidity. It's not exactly like writing opcodes, not exactly like writing EVM assembly. Um it has four loops, um if and switch statements, and it also disallows some commands uh like jump statements because they quickly become very, very difficult to reason about. Um and yeah.

All right, so we added Yul. We have this optimized set total supply function. It's awesome. Uh we have an SSTORE with a zero and an eight, so we're saving eight to the zeroth slot. This is great.

This is going to be way better, right? Uh go ahead and Let's go ahead and measure our um our contract. So once again, or let's go ahead and look at the opcode representation of our contract first. So once again, uh we take a look at the opcodes, and we see that in fact uh optimized set total supply does have less opcodes than set total supply. This is fantastic.

We know that opcodes cost gas. We see that uh set total supply and optimized set total supply have the same opcodes except set total supply has more. Uh we just like saved a lot of time and money, right? Uh but let's like measure it to be extra, extra sure. Um so here uh we uh this is I think Hardhat gas reporter, and we've ran the set total supply uh function 100 times, and we see that the average gas cost is 23,599.

Cool. And then, hey, now we ran optimized set total supply, and we see that the uh gas cost is 23,591. Awesome. Eight gas. We have just saved our users like an enormous amount of money, right?

Well, uh not really. I kind of rugged you guys here. Um so uh if you notice optimizer enabled is equal to false. If you're not familiar with optimizer, it's something that uh you can use in Hardhat and other tools when you're deploying smart contracts. So let's go ahead and enable our optimizer in Hardhat.

Um we will set runs to 200, enable it, and re-measure. And in fact, when we have the optimizer enabled, um it looks like the two functions cost approximately the same amount of gas, right? So, all our time uh spent studying Yul and learning it and learning about SSTORE uh was wasted, right? Well, uh I wouldn't argue that exactly, but I will say that optimizing smart contracts is hard and chances are that you're not going to do a better job than the compiler unless you really know what you're doing. Um contracts containing assembly are generally harder to reason about and harder to audit than contracts written in Solidity and Viper.

So, what you might gain in gas optimization, you will probably be making a trade-off in uh contract or user security. Um and the other thing that's important is if you're writing your own assembly code, always measure and make sure that your implementation is better than the compiler's because chances are there are some very, very, very smart people working on the compilers and optimizers that know something that you don't when it comes to memory management or safety. Um and I guess that's essentially my last point. Remember a lot of the memory management stuff Solidity does under the hood is there for safety reasons and just because an op code looks like it's unnecessary, it doesn't mean that it actually is. That being said, optimizing in Yul and in assembly is definitely something that uh is needed and useful, especially in DeFi and on mainnet where uh gas costs are, you know, lower now, but quite higher than a lot of the other chains.

Um but yeah, I guess do so at your own risk. So, uh thank you guys. Once again, my name's Alex. I'm an early developer at Tally Ho and we are hiring Solidity and TypeScript developers. So, if this kind of stuff is interesting to you and you're looking for a change, uh please reach out.

And then finally, I want to give a big thanks to Gilbert Garza. He had a lecture at Zero X Macra that inspired this talk and there are uh a few resources for uh resources on here if you would like to dig deeper on any of the topics uh covered today. Thanks so much. Thank you. We would have some time for questions if you like.

Why the optimized function is spending the same amount of gas if it has more sorry, less opcodes? So, the opcodes that we were looking at were for the for the unoptimized functions. And we were looking at them because it is like actually it's easier and possible to dissect them in a talk. So, when we saw those two opcode I guess of like optimized what was it set total supply and set total supply? Those are the unoptimized opcodes.

And then and then when uh after the optimizer runs, the opcode representation of those two functions actually becomes the same. What were the those two opcodes two extra opcodes doing the swaps were doing on the unoptimized compiled version? The the optimized compiled version um was a a effectively the same as our opcodes. Um here, I can show you. So, here we have uh our unoptimized set total supply and our optimized set total supply.

In our code where the optimizer has not run before, after the optimizer runs, uh both of these functions um have this have the same exact opcode. And in fact, even though we have two functions in our contract, the optimizer is smart enough to know that they're doing basically the same thing. So, the when we were talking earlier about uh these selectors over here, right? Um so, this selector jumps to a destination and then there would be a different function selector for another function. Um for for the other function, they would actually both jump to the same destination because effectively what they are doing is the same.

What would be some good use cases for managing the assembly code within contracts and do you recommend any good resources for learning Yul? Yeah, so I think that the the best use case that I have seen is a fairly common one where if you're adding two numbers that you are 100% sure will not overflow the the integer limit then in assembly you can you can add them with a there's a unchecked flag which basically tells the the compiler to not check for integer overflow. I believe that in solidity 9. something in some fairly recent version of solidity safe map safe map became um I I guess like there are a lot of checks that run under the hood. You can turn off those checks if you're sure that you don't need them but and that'll save gas but once again that can be very dangerous.

And as far as resources for learning Yul I think just like reading smart contracts and trying to figure out what's going on there is the best way to do it. There are unfortunately no good resources to you are to learn Yul or really EVM assembly and which is what that I wanted this talk just like um yeah. Just to like follow up on that have you heard of trim? Huh? Yeah.

Uh-huh. Yeah. Okay so my question is this right? Like say for instance if I'm writing trim and I'm basically you know trans transpiling my smart contract into into trim like um what are the I guess like the pros and cons to that as opposed to doing it you know Yul style like within solidity and then using Yul? Unfortunately I can't I can't answer that question because I'm not familiar enough with trim.

I do believe that Gilbert was the person that I mentioned at the end of my talk was the person that developed trim or one of the developers and I would encourage you to reach out to him on Twitter, but I'm not familiar enough with shrim to to answer that. Hey, thanks for the talk. I saw somebody asked about Yul. Today there was an awesome workshop about that. It's like 2 hours long.

You should watch that. And then I had a question for optimizer runs. What number do you recommend? What number? I just choose 200 because it's standard.

I know that there's I know that there is a or just a standard. It's just like standard in what I've written. I know that there is a there's eventually a trade-off size between contract size and and gas efficiency that you get if you set your optimizer to like, I don't know, 500,000, a million, or maybe less. But I generally go with 200. I don't work in the DeFi space, so I'm not like too concerned with ultra ultra ultra optimization, but 200 seems to be a a good number from what I'm seeing.

If that was it, thank you so much. Thank you, guys.

Automatic transcript — names and jargon may be misspelled.