Recommended resources
The freely available resources this lesson will direct you to.
Hello! Welcome back to your course on Ethereum development.
Introduction
In our last lesson, we focused on high-level gas optimization techniques: storage packing and the efficient use of calldata. We saw how careful data arrangement and location choices can lead to significant gas savings.
Today, we're diving deeper down the stack. This lesson addresses the learning outcome: "Rewrite a Solidity function using a Yul assembly block to reduce gas costs for complex operations."
We will explore how to "drop down" from Solidity into inline assembly to gain fine-grained control over the EVM. This is where the most significant optimizations can be found, but it comes with the trade-off of increased complexity and responsibility. Your background in electronics and low-level systems will be an asset here, as the concepts are analogous to working closer to the hardware.
By the end of this lesson, you will understand:
- The relationship between Solidity, inline assembly (Yul), and EVM bytecode.
- When and why to use inline assembly for optimization.
- Key patterns for rewriting Solidity logic in Yul to save gas, focusing on memory management and arithmetic.
Let's begin by framing where inline assembly fits into the Ethereum ecosystem.
Solidity Inline Assembly & Yul
This video from the Ethereum Engineering Group provides an excellent overview of the continuum from high-level Solidity down to low-level bytecode, and explains the motivations for using inline assembly.
Watch the first two segments, 'Introduction to Solidity, Yul, and Bytecode Continuum' (01:07 - 04:37) and 'Reasons for Using Yul/Inline Assembly' (04:37 - 05:07). Focus on the trade-offs between gas efficiency, transparency, and development speed.
As the video highlights, we use assembly for two main reasons: to do things that are impossible in plain Solidity, or to do things more efficiently. Today, our focus is squarely on efficiency.
What is Inline Assembly and Yul?
When you open an assembly { ... } block in Solidity, you are not writing raw EVM opcodes. Instead, you are writing in Yul, an intermediate language designed to be a common compilation target for different high-level languages. It's a structured, low-level language that gives you direct access to EVM instructions but with helpful features like variables, functions, and loops.
Advanced Solidity: How to Program in Pure Yul
To quickly see this connection, this short clip from Jesper Kristensen makes it clear that the code inside an assembly block is, in fact, Yul.
Watch from the beginning to 02:57. The key takeaway is understanding that 'assembly' is the keyword Solidity uses for a Yul block.
Working in Yul means you are responsible for managing memory, manipulating the stack, and calling opcodes directly. This is powerful but requires a precise understanding of how the EVM works.
Key Optimization Patterns with Yul
The Solidity compiler is highly optimized, but there are specific scenarios where manual intervention with Yul can yield substantial gas savings. Let's explore some of the most common and impactful patterns. The following article from RareSkills will be our main reference.
The RareSkills Book of Solidity Gas Optimization: 80+ Tips
The 'RareSkills Book of Solidity Gas Optimization' is an excellent resource. We will focus on the 'Assembly tricks' section, which provides clear, practical examples of Yul optimizations.
Familiarize yourself with the list of tricks in the 'Assembly tricks' section. We will be diving into several of these in detail.
1. Efficient Memory Management
One of the most significant areas for optimization is memory management. Solidity's compiler tends to be very conservative, often allocating new memory when it's not strictly necessary, which costs gas due to memory expansion.
The EVM provides a "scratch space" — the first 64 bytes of memory (0x00 to 0x3f) — that can be used for temporary operations without causing memory expansion. Yul allows us to use this space directly.
Let's look at two powerful examples of this from the RareSkills article.
The RareSkills Book of Solidity Gas Optimization: 80+ Tips
These sections demonstrate how to use assembly for more efficient memory operations, which is often where the biggest gas savings are found.
Please read the following two sections from the article: Section 7: 'Use assembly to perform operations on data of size 96 bytes or less...' Focus on the CheapLogger example. Notice how mstore is used to write directly to memory addresses 0x00, 0x20, and 0x40 before calling log1. This avoids memory expansion that an emit statement in Solidity would cause. Section 8: 'Use assembly to reuse memory space when making more than one external call.' Study the Assembly contract example. The key insight is that both external calls reuse the same memory locations for arguments and return data, preventing the memory from expanding on the second call. Pay close attention to the gas cost comparisons provided.
The core principle is that you are manually taking over memory layout. When doing so, especially if you return to Solidity code after your assembly block, it's critical to manage the free memory pointer (located at 0x40). The CheapHasher example in Section 7 shows the pattern for this: save the pointer to the stack, use the memory, and then restore the pointer before exiting the assembly block.
2. Branchless Arithmetic
Conditional logic in the EVM is implemented with JUMP and JUMPI opcodes. These opcodes are more expensive than standard arithmetic opcodes. For some common mathematical operations, we can use clever bitwise logic to achieve the same result without a conditional jump. This is known as "branchless" programming.
A classic example is finding the maximum of two numbers.
The RareSkills Book of Solidity Gas Optimization: 80+ Tips
This section from the RareSkills article shows a common and elegant assembly optimization for a max function.
Read Section 3, 'Common math operations, like min and max have gas efficient alternatives'. Compare the unoptimized Solidity version using a ternary operator with the optimized assembly version. The assembly code might look cryptic, but it's a sequence of bitwise operations that avoids a JUMPI.
Let's break down the assembly code for max(x, y):z := xor(x, mul(xor(x, y), gt(y, x)))
gt(y, x): Returns1ify > x, otherwise0. This is our "condition".xor(x, y): Calculates the bitwise difference betweenxandy.mul(...): Multiplies the difference by the result ofgt(y, x).- If
y > x, this isxor(x, y) * 1, which isxor(x, y). - If
y <= x, this isxor(x, y) * 0, which is0.
- If
xor(x, ...): The finalxor.- If
y > x, this becomesxor(x, xor(x, y)), which simplifies toy. - If
y <= x, this becomesxor(x, 0), which isx.
- If
The result is the maximum value, achieved without any branching.
Practice Walkthrough: Rewriting a Function
Let's combine these ideas to optimize a function.
Original Solidity Function:
This function finds the larger of two deposits and then calls an external contract to validate that amount.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IValidator {
function validate(uint256 amount) external view returns (bool);
}
contract Unoptimized {
// Gas cost is ~7000-8000 depending on compiler
function processDeposits(address validator, uint128 depositA, uint128 depositB) external view returns (bool) {
uint128 largerDeposit;
if (depositA > depositB) {
largerDeposit = depositA;
} else {
largerDeposit = depositB;
}
// External call using Solidity interface
return IValidator(validator).validate(largerDeposit);
}
}
Optimized with Yul:
Now, let's rewrite processDeposits using inline assembly.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Optimized {
// Gas cost is ~5000-6000 depending on compiler
function processDeposits(address validator, uint128 depositA, uint128 depositB) external view returns (bool) {
bool success;
assembly {
// 1. Calculate branchless max for the two deposits
// We use `depositA` and `depositB` directly from the stack
let largerDeposit := xor(depositA, mul(xor(depositA, depositB), gt(depositB, depositA)))
// 2. Prepare calldata for the external call in scratch space
// Function selector for `validate(uint256)` is 0x5a2d12c3
mstore(0x00, 0x5a2d12c300000000000000000000000000000000000000000000000000000000)
// Place the `largerDeposit` argument at offset 4
mstore(0x04, largerDeposit)
// 3. Make the external call using `staticcall`
// staticcall(gas, addr, in, insize, out, outsize)
// - in: 0x00 (start of calldata in memory)
// - insize: 0x24 (36 bytes: 4 for selector + 32 for argument)
// - out: 0x00 (write the return value back to the start of scratch space)
// - outsize: 0x20 (32 bytes for the returned bool)
let result := staticcall(gas(), validator, 0x00, 0x24, 0x00, 0x20)
// 4. Check for call success and load the return value
if iszero(result) {
revert(0, 0) // Revert if the call itself failed
}
success := mload(0x00)
}
return success;
}
}
In this optimized version, we've applied two patterns:
- Branchless Math: We replaced the
if/elseblock with thexor/mul/gtpattern. - Efficient Memory Use: We built the calldata for the
validatefunction directly in the scratch space (0x00to0x24) and made the external call withstaticcall. This avoids the memory allocation and overhead that the high-level Solidity callIValidator(validator).validate(...)incurs.
The result is a function that is roughly 25-30% cheaper in gas.
Conclusion
You have now learned how to wield one of the most powerful tools for gas optimization: inline assembly. While not something to be used lightly, it is indispensable for performance-critical code paths, especially in competitive environments like DeFi and MEV.
Key Takeaways:
- Inline
assemblyblocks in Solidity use the Yul language to give you direct access to EVM opcodes and memory. - This is a trade-off: you gain gas efficiency at the cost of increased code complexity and security risk. A small mistake in assembly can have critical consequences.
- The most significant gains often come from manual memory management: using the scratch space and reusing memory to avoid costly memory expansion.
- Branchless arithmetic is another key pattern, replacing expensive conditional jumps with cheaper arithmetic and bitwise opcodes.
- Always benchmark! The Solidity compiler is constantly improving. An assembly optimization that works today might be unnecessary tomorrow. Always test your assembly code against the latest compiler's output.
Next Lesson Preview:
We have now concluded our deep dive into on-chain optimization. In the next lesson, we'll shift our focus to a critical part of the development lifecycle: deployment. We will learn how to "Author a reusable deployment script (e.g., using Hardhat-deploy) for multiple networks." This will equip you with the tools to manage your contract deployments in a professional, repeatable, and network-agnostic way.