Okay, thank you.
So our next speaker is Jesus, we've been talking a few times in the GoDev room about everything
that has to do deeply within the language and today he's going to talk to us about what's
going on in functions.
A round of applause.
Okay.
Hello, everybody.
Well, my name is Jesus.
I'm software engineer and I'm going to talk about squeezing a Go function.
So what is optimization?
I think it's important to know that optimization is not being faster or consuming less memory,
it depends on your needs.
So it's better for squeeze use, probably everybody will say yes, but it depends if you are looking
for convenience or for something that lasts forever.
So in that case, it's not the best option.
Optimizing is about what you need and trying to address that.
It's important to optimize at the right level.
You can buy the best car, you can get an F1 car and it's not going to be fast if this
is the road.
So try to optimize always at the upper level because this kind of optimization, the ones
that we are going to see in this talk are micro optimizations that probably are not
the first place that you should be starting.
Optimize what you need and when you need it.
It's not about taking a Go function and try to optimize forever and try to make that run
super efficiently and scratch every single nanosecond because probably the bottleneck
is no longer there.
You have to search for the bottleneck, you have to optimize where the bottleneck is and
then look again if the bottleneck is still there because if it's no longer there, you
are over-optimizing that function without much gain.
So just take that into consideration, optimizing is an interactive cycle and you need to keep
moving and keep searching for the bottleneck.
Do not guess, please.
Yeah, I know everybody has instincts and all that stuff but guessing about performance
is an awful thing because there's so many things that comes into play that is just impossible.
There's the operating system, the compiler, the optimizations of the compiler, if you
are in the cloud, maybe a noisy neighbor, all that stuff comes into play with performance.
So you have to, you are not good at guessing almost for sure in performance.
So just measure everything.
The important thing here is try to measure everything and work with that data.
Probably is what, probably the talk that is after the next one is about.
So I will suggest to go there also because it probably is a very interesting talk.
So let's talk about benchmarks.
The way that you measure performance in micro-optimization, so micro-benchmarks, is through Go benchmarks.
Go benchmark is a tool that comes with Go and is similar to the testing framework that
comes in Go but very focused on benchmark.
In this case, we can see here an example to have two benchmark, one for MD5SAM and one
for SHA256SAM.
That's it.
It's just a function that starts with benchmark.
I'm going to receive a testing.b argument and that's this four, I have this four loop
inside.
And that is going to do all the job to give you the numbers and I show you now the numbers.
If I run this with Go bench, we got this dash bench dot.
The dot is a regular expression that means everything.
So you can use like the Go test run a regular expression for only executing certain benchmarks.
And here you can see that MD5SAM is around twice time faster per operation than SHA.
So well, just a number.
It's that important.
It depends.
If you need more security, probably MD5 is not the best option.
So it depends on your needs.
Another interesting thing is the allocations.
One thing that you maybe have heard is about counting allocations.
Counting allocations, why is that important?
It's because every time we allocate something, when we talk allocation, we're talking about
allocation in the heap.
If every time we allocate something in the heap, allocating that is going to introduce
an overhead.
And not only that, it's going to add more pressure to the garbage collector.
That's why it's important to count the allocations when you are talking about performance.
If you are not worried about performance at that point, don't count the allocation.
It's not that important and you are not going to gain a massive amount of performance from
there if you are not in that point there.
Okay.
Let's see an example here in MD5 and SHA SAMs.
We have zero allocations.
So well, this data is not very useful for us now.
So let's use another thing.
Let's open a file.
Let's open a file thousands of times and see how it goes.
Now I see that every single operation of opening a file, just opening the file, is going to
generate three allocations.
And it's going to consume 120 bytes per operation.
Interesting.
So now you are measuring things.
You are measuring how much time it takes, how much time is gone in processing something,
is going in allocating things, how much memory is gone there.
So let's talk about profiling because once you, well, actually normally you do the profiling
first to find your bottleneck and then you do the benchmark to tune that bottleneck.
But I'm playing with the fact that I already have the benchmark and I'm going to do the
profiling on top of the benchmark.
So I'm going to execute the gobench, I'm going to pass the mem profile, I'm going to generate
the mem profile and I'm going to use the people of tool.
The people of tool is going to allow me to analyze that profile.
In this case, I'm just asking for a text output and that text output is going to show me the
top consumers of memory in this case.
And I can see there that 84% of the memory is gone in OS new file.
Okay, let's see what happened, okay, it's that file but I need more information, well,
it's that function, sorry, I need more information.
Actually I cannot like this output but if you don't like this output, you can, for example,
use SVG and you are going to get something like this that is very visual and actually
is kind of obvious that where is the bottleneck there and in this case, again, is OS new file.
If I go to the people of tool again and instead of that, I use the list of a function and
I'm seeing here where is the memory going by line and here I can see that in the line 127
of the file, fileunix.go, I'm consuming the memory.
Actually there you see 74 megabytes, that is because it's counting all the allocation
and aggregating all the allocations, it's not, every operation here is consuming only 120
bytes.
Okay, the same with CPU profile, in this case, this is generating the most of the CPU consumption
is in Cisco 6, I can see in SVG, this time it's more scattered, so the CPU is consuming
in way more places but still the Cisco 6 is the biggest one.
So I'm going to list that and I see some assembly code, probably you are not going to optimize
more this function, so probably this is not the place that you should be looking for optimizations
anyway, this is an example of getting to the root cause during the profiling.
Okay, this talk is going to be more by examples, I'm going to try to show you some examples
of optimizations, it's just to show you the process more than the specific optimization,
I expect you learn something in between but it's more about the process, okay.
One of the things that you can do is reducing the CPU usage, this is a kind of silly example,
you have a fine function that have a needle and a high stack and just go through the high
stack and search for that needle and give you the result.
This is looping over the whole string or the whole slice, I'm going to do a benchmark,
the first thing, I'm going to do the benchmark, I'm going to generate a lot of strings and
I'm going to do a benchmark looking for something around in the middle, it's not exactly in
the middle but it's around there and the benchmark is saying that it's taking nearly 300 nanoseconds.
If I just early return that is just a kind of silly optimization, it's not super smart
or something like that, I'm going to save basically almost the half of the performance,
this is because the benchmark is doing something really silly and it can vary depending on
the data that it inputs but it's an optimization is just doing less, that is one of the best
ways of optimizing things.
Reducing allocations, one of the classic example of reducing allocations is when you are dealing
with slices, when you have a slice, for example this is a common way of constructing a slice,
I create a slice, I loop over this, generate a loop and start appending things to that
slice, okay fine, I'm going to do a benchmark for checking that and it's taking 39 allocations
and around 41 megabytes per operation, okay sounds like a lot, okay let's do it, let's
do this, let's build the slice but we are going to give an initial size of a million
and the time I'm just setting that, the final result is exactly the same but now we have
one allocation and we have consumed only one megabyte and actually if you see there
is around 800 microseconds and here you have around 10 milliseconds, so it's a lot of time
actually, a lot of CPU time too but you can squeeze it more, if you know that at compile
time, if you know exactly the size that you want to have at compile time, you can build
an array, it's faster than any slice actually, so if I build an array I'm now doing zero
allocation, zero heap allocations, it's going to go in the stack or in binary somehow, whatever
but it's not consuming my heap allocations and this time is 300 microseconds approximately,
so an interesting thing if you know that information at compile time, okay another thing is packing,
if you are concerned about memory you can build this struct and say okay I have a Boolean,
I have a float, I have an N32 and the goal compiler is going to align my struct to make
it more efficient and work better with the CPU and all that stuff and in this case it's
just adding seven bytes between the Boolean and the float and four bytes after the integer
to get everything aligned, okay I built a slice and initialized a slice and I'm allocating
one time because that's what the slice is doing and I'm consuming around 24 megabytes
per operation, if I just organize the struct, in this case I put the float at the beginning
then the integer 32 and then the Boolean, the compiler is only going to add three bytes
so the whole structure is going to be smaller in memory and in this case now is 16 megabytes
per operation, so this kind of optimization is not going to save your day, if you are
just creating some structs but if you are creating millions of instances of an struct
it can be a significant amount of memory.
Function in lining, function in lining is something that the goal compiler does for us
is just taking a function and replacing any call to that function with the code that is
generated by the function.
I'm going to show you a very damn example, I'm not inlining this function explicitly
and I'm using the inlined version that is going to be inlined by the compiler because
it's simple enough and then I'm going to execute that, I'm saving a whole nanosecond there,
so yeah it's not a great optimization to be honest, probably you don't care about that
nanosecond but we are going to see why that is important later, not because of the nanosecond.
I'm going to talk now about escape analysis, escape analysis is another thing that the
compiler does for us and basically analyzes our variables and decides when a variable
escapes from the context of the stack, it's something that is no longer able to get the
information from the stack or store the information from the stack and be accessible where it
needs to be accessible so it needs to escape to the heap, so it's what generates that allocations
and we have seen that allocations have certain implications, so let's see an example here,
this is another inline function that returns a pointer that is going to generate an allocation,
this is something that returns by value, a value is going to copy the value to the stack
of the caller so it's not going to generate allocations, so we can see that in the benchmark
that is saying the first version have one allocation and it's allocating 8 bytes and
the second one have 0 allocations and actually you can see there is one allocation and it's
taking 10 times more to do that, 10 times more in this case is around 12 nanoseconds
that is not a lot but everything adds up at the end especially when you are calling millions
of times of things, okay and one interesting thing is escape analysis plus inlining, why?
Well imagine this situation you have a struct, a function that generates or instantiate that
struct and the constructor of that extract, okay, the constructor returns me a pointer
and do all the stuff that it needs, okay great, it is generating 3 allocations and it's consuming
56 bytes per operation, okay, what happen if I just move the logic of that initialization
process into a different function, if we do that suddenly the new document is simple enough
to be inlined and because it's inlined it's no longer escaped so it's no longer needed
that allocation, something that simple allows you to just reduce the number of allocations
of certain types when you have a constructor, what I would suggest is just keep your constructor
as simple as possible and if you have to do certain complex logic do it in an initialization
function, well if that doesn't hurt the readability, okay, let's see here we have less allocations,
we have now 2 allocations and 32 bytes per operation and the time consumed is you are
saving 50 nanoseconds every time you instantiate that, so this is a good chunk, okay, well
this is optimization sometimes it's a matter of trade-offs, sometimes you just can do less,
like less allocations, less CPU work, less garbage collector pressure, all that stuff
is things that you can be done, but sometimes it's not about doing less, it's about consuming
different kind of resources, I care less about memory and I care more about CPU or all the
way around, so concurrency is one of the cases where you need to decide what you want to
consume because go-routines are really cheap but are not free at all, so let's see an example
with IO, this is two functions that I created, one is a fake IO that is going to generate
some kind of IO simulation by time-sleep and then you have the fake IO parallel that received
the number of go-routines and it's doing basically the same but distributing all that hundred
cycles between different go-routines and I built a benchmark to do that using three
different approaches, one is serial one, the non-concurrency, the other one is concurrency
using the number of CPUs in my machine and the other one is using the number of tasks
that I have, and because this is IO, this is the result, I'm going to see that if I
create one go-routine per job, the number of bytes per operation and the number of allocation
is going to spike but the time that is going to be consumed is going to be way lower, actually
I'm able to execute hundred times this function using this one go-routine per job approach
and only 12 using one CPU per job because this is IO, so let's see what happens if I
do that with CPU. Using the CPU, this is to simulate some CPU load and using MD5 sum
and it's more or less the same approach as we saw in the fake IO, the benchmark is exactly
the same approach, we are using the number of jobs and the number of CPUs and using no
go-routines and here is interesting because if you use the number of CPUs and this is
a CPU workload, that is what is going to do the best efficiency. You can see here that
executing one go-routine per job is going to be even slower than executing that in
serial and actually you have the worst of both worlds. You have plenty of allocations,
plenty of memory consumption, plenty of time consumption and you are not gaining anything.
In the case of CPU, you are consuming more memory and you are getting better CPU performance
because you are basically spreading the job all over your physical CPUs and the serial
one is just doing everything and is using only one core of your CPU. Whenever you want
to optimize using concurrency, you have to take in consideration what the kind of workload
that you are using is the CPU workload, is your workload, do you care about memory, do
you care about CPU, what do you care about? That is the whole idea. I just want to explain
that all this is about measuring everything, measuring all this, doing all these benchmarks,
doing all these kind of experiments to see if you are getting improvement on the performance
and iterate over that. That is the main idea. I show some examples of how you can improve
things and some of them can be applied in general basics like using the, try to keep
constructors small or using the constructor for slices when you know the size and things
like that. Some references. Efficient Go is a really book that is really, really interesting.
If you are really interested into efficiency, Bartolome Plocca wrote that book and actually
is going to give a talk after the next one. I am sure it is going to be super interesting.
High-performance workshop from Dave Cheney. There is a lot of documentation about that
workshop that Dave Cheney did and it is really interesting also. The Go Perf book is a good
lecture also. An Ultimate Go course from Ardon Labs is also an interesting course because
it is giving you a lot of foundation and the course takes a lot of, cares a lot about hardware
sympathy and all that stuff. Well, some creative common, all the images are creative common
so I put the reference here because it is creative common. Thank you. That is it.
Thank you.