Ruby Memory Profiling in Practice
When I only started programming, I loved tasks related to profiling and optimization. However, my knowledge on this subject was very limited and I desperately searched for articles with some tips and tricks on how to profile properly. I thought there were some secrets or techniques, that I should know. A few years forward, I can say there a none, really 🤷♂️.
But here are some tips to give you confidence.
Basic steps
Profiling itself is very easy and consists of four basic steps
Profile
Find bottleneck
Fix bottleneck
Profile again
How to profile
Ruby has a rich set of decent profiling tools. Some would disagree, but in my opinion, they are good enough.
Depending on a type of problem you have, you’ll need a different type of profiler. We are talking about memory here, so we’ll take memory_profiler.
Splitting the work
Profiling itself requires a lot of memory. Therefore, if you start to profile the whole process, you’ll probably run out of memory pretty quick.
There is a general solution to this. Divide process into few major parts and profile each of them one by one.
It may seem obvious, but I feel like it’s an important thing to note.
How to find and fix a bottleneck
Look at the report.
Find the code that takes the most memory.
Look at the code. Does it create any unnecessary objects? Can you rewrite it to allocate less memory?
Optimize it if it’s possible. If not, go to the next piece of memory-heavy code in the report.
Any unnecessary objects?
Here is a couple of examples to illustrate what I mean. I recently had a problem with middleman. It constantly used more memory than my 512 MB dyno allowed. So I had to find a solution.
Middleman
Middleman created extra array each time I ignored the object.
- resources.map do |r|
+ resources.each do |r|
if ignored?(r.normalized_path)
r.ignore!
elsif !r.is_a?(ProxyResource) && r.file_descriptor && ignored?(r.file_descriptor.normalized_relative_path)
r.ignore!
end
- r
end
Seems like a very small thing. However, I had thousands of ignored objects. And those little arrays accounted for 10% of memory used during initialization. Here is a link to pr.
Usually, this is a small win and it doesn’t help much, what you’re looking for is a big win, like the one below.
Middleman S3 Sync
Gem middleman_s3_sync
first created sync objects and then ignored ones that don’t need to be synced. The strategy is ok most of the time, but not in my case of hundreds or even thousands ignored resources. It’s very unwise use of resources.
Before
def manipulate_resource_list(mm_resources)
::Middleman::S3Sync.mm_resources = mm_resources
end
After
def manipulate_resource_list(resources)
::Middleman::S3Sync.mm_resources = resources.each_with_object([]) do |resource, list|
next if resource.ignored?
list << resource
list << resource.target_resource if resource.respond_to?(:target_resource)
end
resources
end
This 8 lines of code freed up 200MB of memory. Here is a link to this little pr
In a report everything is fine. What should I do?
These a couple of tips that really helped me during profiling.
If the report is fine. Double check that data you profile with is the same used in production.
Try to disable parts of the code in staging and check if used memory dropped significantly.
No bottlenecks. My code is perfect. Third-party code is perfect. But it takes SO MUCH MEMORY.
Well, if you can’t find a room for optimization and everything is fine. The only solution is to rethink the whole approach. Find a different solution to the same problem and rewrite this functionality altogether.
Many people jump to above method without attempting to find an actual problem in their code. I don’t really like to do so. Profiling isn’t hard and once you find a problem it’s usually easy to fix it.
Rewrites are usually taking so much more time. And when you don’t try to find problems in your code, you’re bound to make the same mistakes again. Optimization tasks are a great way to learn and grow as an engineer.