Solidity Optimizer Under the Hood

DeFi Security Summit at Devconnect Argentina 2025 · 20 Nov 2025 · 22:36

Vladimir Kumalagov of OpenZeppelin explains what the Solidity optimizer does under the hood at DeFi Security Summit 2025. He compares the legacy and via-IR compilation pipelines, shows how the peephole optimizer and Yul passes remove redundant opcodes and unused stores, and why an external call or inline assembly can block an optimization. He also walks through optimizer bugs found in the wild, and how to write contracts the optimizer can handle well.

Speakers

Topics

From DeFi Security Summit at Devconnect Argentina 2025, hosted by DeFi Security Summit.

Transcript

Hello everyone. Uh, today I want to pull back the curtain on something that most developers hardly ever think about. Uh, Solidity's optimizer. Most of the time we treat it like a black box. We flip the dash optimize switch, put insane number of optimizer runs and hope our transaction get cheaper. But what is it actually doing on chain? Every extra op code, memory touch or unnecessary s load show ups directly in gas cost. And when you your contract runs thousands of times, those small inefficiencies add up fast. And the optimizer job is to maintain the original logic but lower the running cost of it.

By the end of this talk, you will understand how solidity code is compiled through both the legacy and via IR pipelines and what the optimizer actually changes from pip hole optimization to the deeper ule passes. We will also look at the optimizer bugs that have been found in the wild. First uh to understand the optimizer we need to know where it actually sits in the compilation process. So let's start with a quick comparison between the two compilation pipelines solidity offers. The legacy pipeline and via IR pipeline. In the legacy pipeline the flow is pretty direct.

In simple words, compiler takes your uh solid code and turns it into EVM assembly. And uh at that stage the optimizer model runs on that assembly and um applying various transformation to simplify it. Once it finished the assembler turns into the final bite code in the VI pipeline the first step instead of gen generating KVM assembly right away uh the compiler translates the program into inter intermediate representation language called ule. The optimizer then works on that u only that the optimized ule is lowered to EVM assembly. Uh and at that stage um the EVM assembly optimizer is executed on the EVM

assembly followed by uh the actual transformation to the uh EVM bite code. As of today, the default if you don't explicitly pass the D-VIR uh is still the legacy pipeline, but the VIR uh the legacy pipeline, but the VIR has been around for quite a while and solidity team intends to uh make it the default soon. And what we are going to focus on next is what happens inside the optimizer box for both pipelines.

Before we dive into specific optimization, we need to say a few words about ule because it's the heart of the via IR pipeline. Ule is solidity intermediate representation, a small structured language that sits between solidity and EVM bite code. It has functions if for but none of the highle solidity sugar no inheritance modifiers or complex types. Why does this matter? Because ule is optimizer friendly. It has structured control flow. uh explicit jump targets which makes it much easier uh to analyze than row byte code. It enables whole contract optimization across functions and call sites not just local pip hole tricks.

And as a very practical benefit, the via IR pipeline can automatically spill uh your local variables into the memory which is why you see stack to deep error far less often with via IR. So you can think of ule as simplified structure to view of your contract that opens the door to more powerful optimizations. But before all the fancy via IR magic, the there is a old school layer that operates directly on EVM assembly. The idea is simple. Uh piphol optimizer looks at tiny windows of EVM instructions and ask can I do the same thing but with fewer op codes or with cheaper ones? And here is um some classic examples.

And nothing surprising here. If you multiply if you if you multiply by zero, the result is zero. And the optimizer knows this too. Uh so when it sees op code sequences that computes something trivially, it replaces them with the final result. Some patterns are just cheaper ways of expressing the same logic. For example, checking whether a value is zero with equal x and zero is just simple uh the simpler representation is 0x. So the benefit here is simple uh because we put only one variable onto the stack and then perform the op code instead of putting two variables uh two values on the stack and then performing the e op

code. And shifts follow predictable rules as well. uh shifting by zero bits gives you same value and shifting by more than a word size guarantees zero. The piphole optimizer recognize those cases and replaces entire op code sequences with a minimal equivalence. Uh the piphole optimizer also clean up after the stack uh when it sees useless work. Uh if you push a constant onto the stack and immediately pop it, then the optimizer just deletes both instructions. Swapping the same two stacks elements twice gives you the original stack. Uh so those two swaps can vanish completely.

And finally here is an optimization that's very EVM specific. Uh if the code loads it open address and immediately ask for the balance the optimizer can replace that two op code sequences with a single self balance. Uh it does exactly the same thing but in one op code. And here's the interesting thing. Uh some optimizer passes don't just look at opcon in installations. Uh they track what values leave in memory and storage uh at the exact moment. U you may ask why uh because once the compiler knows what sits in the exact storage or memory position uh it can sometimes compute expensive operations ahead of time uh during the compilation. For example, the

classic one is with Kchak 256. Um uh here we uh place the 0x FF value into the uh memory slot zero. Then we compute the K check of the uh zero of the value that's placed in the zero slot. And here's what the optimized assembly blocks uh becomes. Uh so what happened? Uh the optimizer mentally keeps a little notebook and uh recognize that okay memory at address zero now contains the value uh 0x FFF and nobody overrides this region before the hash since everything is now at compile time. This optimi the optimizer simply computes the

K chuck itself and replaces the runtime hashing by the push 32 with the uh final digest. Now let's zoom out and talk about the ule optimizer which kick in when uh you compile with via via IR enabled and the optimize flag. Compared to the upcot level optimizer uh this one much more powerful because it works on the intermediate representation instead of uh looking at two three instruction at a time and potentially it can apply big changes and optimizations.

Under the hood, the ule optimizer is a pipeline of multi multiple passes, each transforming the code while keeping the program uh semantically identical. Every pass tries to make the code smaller, simpler, or or at least cleaner in a way that it unlocks further optimizations for further passes. And here's a interesting detail. The order of these passes matters. Some optimizations rely on assumptions created by earlier ones. So the sequence is carefully designed. The solidity team ships the default sequence uh of passes that works well in in practice. But if you really want you can uh introduce your own sequence and compile with with

this uh default u optimization sequence. Uh let's explore some of the ule optimizations. Um and first one is the load resolver. The idea is simple. If the value already read from storage or memory and it's still onto on the stack, reuse it instead of reading it again because reading from the storage and memory is more expensive than just uh executing dupe or swap op code uh on the on the stack values. Uh here is a small example. Uh we have a storage variable a and in do stuff we use it twice.

uh once to compute uh a plus b and store it in the local variable c and once more then uh when we call the receive call with a and c. Now let's look at the via r our optimized code for this. What's happening here? Uh a is loaded once uh from storage with s load zero uh and stored in the local variable a. the same value is reused to compute C and then when we uh prepare the call data for the external call. So instead of doing the uh uh two separate s loads we only pay for one uh load resolver notice that uh there uh there is a a variable already on on the

stack and we can reuse it instead of uh loading it again from the storage. Uh okay let's move to the dead code eliminator. Uh the name is self-explanatory. Uh it removes code that can never be reached at runtime. If the optimizer can prove that uh a piece of code will never execute, it can just remove it uh from the bite code. Here is a small example similar to our earlier contracts. Um and here we place return uh before the uh external call.

uh because we return uh before the external call the external call is unreachable and the optimizer is free to remove it from the bite code. Uh here's a vir optimized version. Uh so so far so good. Uh we have u u the external uh we don't have the external call here. Uh the function now returns immediately. But you may ask uh why is a plus b still there if the result is never used? The answer uh the compiler must still generate the overflow check for the addition because this is part of the solidity safety guarantees. Uh even if the function returns earlier,

removing the overflow check uh would change the contract behavior. An external call would suddenly be able to trigger and overflow overflow silently. Uh and that would be incorrect. Now let's see what happens if we put the addition inside the unchecked block. Uh and now the addition disappears entirely uh we don't see it in the intermediate representation. We just return uh after the initial checks uh generated by the compiler uh because uh in uh unchecked block a plus b has no side effects and the result isn't used.

So it becomes pure date that code and it's it was removed. And the same sync applies not only for the return but also for other control flow uh patterns uh for example revert continue and break in the loops, self-destruct etc. Next up is uh unus store eliminator. This one is also pretty intuitive. If the compiler sees multiple source or m stores operation writing to the same location and it uh can prove that the earlier uh values are never used it can just u remain the latest uh store or m store and here is example uh in dust we assign

uh twice to the variable a first it's a b and then c and the same works for the memory slot zero uh we put the value of b to the zero slot and then see uh but in both cases the earlier rides are never used uh and the via IR optimized version looks like this uh what happened only one store remains storing C into A and only one M store remains also storing C into slot zero all the earlier rightes got optimized away because uh they were just never used but now let's add an interesting twist what happens if We place the external call between two uh s stores.

Uh now we see two stores again. Why? Because the compiler must assume that external call to fu might read a of the same contract. So the first write is no longer unused. The colleague could depend on it and therefore it can be removed cannot be removed. And uh the last thing that we will explore today uh from the UL optimizations uh is function specializer. One of the more surprising sometimes uh optimization because uh it can expand your bite code uh in a in a big way. Uh the idea is this. If you call a function with a literal argument and the optimizer u is allowed to create

a specialized version of that function, a new function where those literal values are hardcoded directly in the function body. Uh and therefore the specialized function takes less arguments. And uh let's look at a more complex example. Uh here we have a function fu that takes three arguments and hashes them together. Uh and also we have do stuff that calls function fu twice but with literal value of 10 uh as the last argument and we have the do stuff function that just passes whatever the user provides.

Uh if we look at the via r output something uh we can spot something interesting. Uh here we have a uh a new function called funfu that takes only two arguments instead of three. And we see that uh sort m store places uh the hard-coded value 10 to the memory position. Uh and uh the do stuff function that is in the middle just reuses to uh the fun fu twice. And the do stuff two has uh the uh fu function inlined uh into the implementation instead of using some specialized

version of it. Um so the specialization opens the door for additional optimizations uh and sometimes even reduces the bite code size but in other situation it just can expand your bite code and sometimes you don't want it um and uh there is some tricks uh of course you can just disable this uh optimization pass uh in the uh comp when you pass like uh the compiler input. Uh but uh if you want to preserve the uh default optimization sequence, you can put uh the trick into your code.

Um so here we have a stubborn function that takes u uh an argument and it returns actually the same argument uh if the call data is uh non zero and uh the usage is quite simple. You just call this function and pass the result of the function to um whatever function you want. But the interesting thing is that uh we just afuscated the logic for the compiler and and the optimizer. So therefore it will not be able to apply the function specialization. Uh so we don't have uh the function specialized. Therefore our bite code is uh smaller uh than we than

uh it would be with the function specialization. And of course there there are some trade-offs uh and there is no free lunch even with optimization. Uh you trade one resource to another and we don't we will not cover like uh compilation time. Yeah it's true that it will be much longer with via pipeline. Uh but there's also other consideration like potent uh like ones that uh you can potentially introduce back into your code. Uh but first let's cover the trade-offs.

There is infamous optimizer runs parameter uh that controls uh that actually says how many times uh you expect each op code uh being run during the uh contract's life and if you put the high number of runs uh you say I expect this code to run end times and the optimizer is free to uh expand the bite code potentially but lower the gas cost. Uh and the same thing works uh for the lower runs. If you play slower runs, the bite code will be smaller but the execution will be uh more expensive.

Yeah, there is like hard cap for the contract size 24 kilobytes and also we have uh uh 200 gas uh per one bite of deployed contract charge uh that we need to remember and most of the time if you deploy a big protocol that will be used like very frequently. uh it doesn't make sense for you to uh put the lower number of runs because uh your users will spend more uh and it's much easier to pay 200 gas per bite of the deployed contract.

Uh and now on the security considerations um key rule for the uh optimizer uh that it should not change the logic of your contract. It should only remove or simplify things that are equivalent. However, historically there were cases when uh the optimizer introduced some bugs uh into the users uh code bases. So let's cover the first one. Uh it uh this issue was in the unused store eliminator that we covered today.

Uh and the bug u was present in the special conditions. Uh so first uh you need uh you had to write to the storage variable then you call some function on you have another code block uh that might exit earlier from the call using inline assembly like return or stop uh the return uh the return in the assembly block returns completed from the call not just from the function. uh and uh then uh the same function uh also has a path that returns normally and then write to the same storage slot. So here we have uh

assignment to x uh then we call the function g that has uh the condition that we just covered. It has a return from the function in case of a equals to true and there is an assembly return uh from the call complete uh from the call. And uh the optimizer looked at this and said like okay well after that call we eventually override the storage anyway. So the first write must be unused and we can remove it. But actually it was a bug because uh if we if you actually return from the from the call there was no right to the storage and storage uh remain unchanged.

And the second bug uh sounds simple. The optim optimizer incorrectly deletes uh memory rights inside an inline assembly block. uh the core assumption was uh if memory written in in line assembly block is never read at the same block then uh this memory is unused and we can just remove this uh m store. Uh however uh the problem arise when you have another assembly block that reads the uh exact memory location. So it's unusual pattern uh but sometimes developers use hard-coded memory offsets uh to place some values uh instead of using uh for example local variables uh to preserve it across the function calls

uh and uh yeah the problem was that u optimizer just uh was removing the first uh m store and therefore the value in the second assembly block was uninitialized or or if you had like uh some logic. For example, if you had a catch check in this uh call before the assembly block, uh it could use the scratch space. So, you would get the incorrect value instead of the uh 0x42. Uh to to wrap up, uh here is a big things to remember. U the optimizer isn't magic. is just a set of well- definfined rules and transformations. Uh

sometimes they are tiny like removeant op codes. Uh something huge sometimes huge. Uh and it's important to understand uh how the optimizer works to uh write uh good code for for the optimizer. Uh and also u optimizer is extremely helpful but it will not fix your bad algorithms. It can remove an extra s load but it will not turn uh big O of N squ loop into constant magic uh constant time magic. This part is on you. Yeah. Thank you.

[applause]