Replies: 2 comments 1 reply
-
Thanks for performing and sharing this great analysis @enitrat. I was able to shed a notable amount of gas from Loot Survivor using this knowledge: BibliothecaDAO/loot-survivor@d9be998 I appreciate you 🙏 |
Beta Was this translation helpful? Give feedback.
0 replies
-
I'm wondering in the sierra code if |
Beta Was this translation helpful? Give feedback.
1 reply
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
-
How the compiler optimizes structs modifications
Introduction
This report highlights how the compiler optimizes structs modified in function in order to minimize the amount of cells written to each time a struct field is updated. This work was conducted using scarb 0.6.2 and cairo: 2.1.1
Methodology
We define a file with a multi-field struct
MyStruct
, a main function that creates an instance ofMyStruct
and calls a functionupdate_one
that updates a single field of the struct, and a functionupdate_many
that updates multiple fields of the struct. We then compile this file to sierra and analyze the Sierra code to see how the compiler optimizes the struct modifications.In the middle of the
calling_bar
andcalling_bar_inlined
functions, we add a call to a function that takesMyStruct
as parameter to show how function inlining affects the optimization.The following file is the source file used for this insvestigation:
Results
Let's start by analyzing the behavior of each function one by one, looking at Sierra code snippets
update_one
:Nothing particular here, but let's highlight the important parts of this code to make it easier to understand what's happenning in the next snippets.
[1]
is dropped, and a new variable with id[6]
is created, whose value is1
[6]
for the first member, keeping the previous value fort the other membersupdate_many
calling_bar
bar
which takesMyStruct
as an parameter, we need to reconstruct the struct before calling bar, and then deconstruct it again after the call tobar
to perform the following modifications. This is because the functionbar
could modify the struct, and we need to make sure that the struct is in a consistent state after the call tobar
. This is why we have the following instructions:calling_bar_inlined
bar_inlined
is inlined, the compiler still needs to reconstruct the struct before the call tobar_inlined
and deconstruct it after the call tobar_inlined
. This could probably be optimized by the compiler - because we're just callingstruct_construct
to callstruct_deconstruct
right after - but it's not the case for now.store_temp
(which supposedly stores the struct in memory prior to the function call) returns the same variable ID, meaning that this is actually a no-op.Overall, the compiler optimizes structs modifications as we would expect a performant compiler to - and it's a feature that is very welcomed compared to the previous way of doing things in Cairo 0, where we had to explicitly define functions to update structs that would manually reconstruct the struct after each update.
What if we looked at Cairo Assembly?
Sierra is an intermediate representation of Cairo code, and it's not the code that is actually executed by the VM. The VM executes Cairo Assembly, which is a lower-level representation of Cairo code. Let's look at the Cairo Assembly generated by the compiler for the same file as above.
Remember that Cairo only has three registers: ap, fp, and pc, and a single memory. Structs are a high-level representation of packed data, but they have no representation in Cairo Assembly. Instead, structs data are represented as a sequence of cells in memory, and the compiler keeps track of the offset of each field of the struct in memory.
Inlined functions
While not the primary objective of this study, it's interesting to see how the compiler optimizes the code when the function is inlined. Let's look at the Cairo Assembly generated for both
calling_bar
calling_bar_inlined
:calling_bar
calling_bar_inlined
The first noticeable thing here is that inlined function only runs one extra instruction, the
call rel 11
, but otherwise both run the same amount of instructions. In the inlined version, all the fields ofmy_struct
are written to memory before the execution of the inlined bar function. The normal version is similar, except that it uses acall
instruction to execute the non-inlined version of thebar
function. Both versions execute the same amount of instructionsBack to understanding how structs are handled
Let's look at the Cairo Assembly generated for the
update_many
andcalling_bar
functions. The compiled code forcalling_bar
is available above.update_many
This one is pretty straightforward - we just write the values of the struct fields to memory, and since no other operation is performed in between, we can just return them as is (as our function returns
my_struct
passed byref
).calling_bar
The main difference here is that we need to store the struct in memory before calling
bar
, and then store it in memory again it after the call tobar
to return it from thecalling_bar
function.Basically, what happens here is that we write the values of the struct fields to memory (in order to call the function
bar
), and oncebar
returns we still have to update the last two fields. Becausemy_struct
is passed asref
, it is returned from the function - therefore, the last instructions in our function are to write all the fields ofmy_struct
to memory. Given that we already updated the values ofa,b
, we just refer to the values stored in memory previously for these fields, and we write the new values ford
ande
to memory.Why you should minimize the amount of functions called
Every time you make a function call, you need to write the arguments to memory and if that functions returns something, you need to push it back to memory before returning.
To illustrate this more accurately, let's focus on this specific case: using multiple setters instead of updating the struct directly. Let's look at the following code:
Let's see how the following compiles to CASM:
Look at how the following is way more optimized:
Good programming practices would expect you to use getters and setters to modify/access struct members, as it makes refactoring easier. But if you're looking into optimizations, it might not be the best idea...
One last thing: What if our getters/setters were inlined?
Let's look at the following code:
Surely, we would expect the compiler to behave the same way as what we noticed before, right? Well, not exactly. Let's look at the CASM generated for this code:
It's strictly equivalent to the code generated when we directly update the struct members. So the solution might actually be to use inlined methods!
One weird edge case: inlining free functions
We would expect it to be the same result as with methods, but it's not the case! However, the difference is actually not that big: When we look at the main function (the last one in this block, what the program really executes), it's the same - the only difference is that the CASM codes contain the code of the inlined functions, but they're not called in this specific case.
Here's the output
Conclusion
This study gave us more insights into how the compiler optimizes struct modifications. We can draw one very valuable conclusion: what is actually expensive is to have multiple functions call and pass around the struct as an argument, which requires writing the struct to memory before each function call and reading it back from memory after each function call.
We highlighted the fact that methods and free functions are not treated the same way by the compiler and that inlining free functions can actually be a good idea if you're looking for performance.
If you want to combine best programming practices and good optimizations, then consider defining methods on your type and inlining them. This way, you can still refactor your code easily, and you can benefit from the optimizations of the compiler.
Beta Was this translation helpful? Give feedback.
All reactions