Optimize Memory

High memory consumption is what makes Ruby slow. Therefore, to optimize we need to reduce the memory footprint. This will, in turn, reduce the time for garbage collection.

You might ask, why don’t we disable GC altogether? That is rarely a good thing to do. Turning off GC significantly increases peak memory consumption. The operating system may run out of memory or start swapping. Both results will hit performance much harder than Ruby GC itself.

So let’s get back to our example and think how we can reduce memory consumption. We know that we use 2 GB of memory to process 1 GB of data. So we’ll need to look at where that extra memory is used.

chp1/example_annotated.rb
Line 1 
require ​"benchmark"
num_rows = 100000
num_cols = 10
data = Array.new(num_rows) { Array.new(num_cols) { ​"x"​*1000 } }
time = Benchmark.realtime ​do
csv = data.map ​do​ |row|
row.join(​","​)
10 
end​.join(​" "​)
end
puts time.round(2)

I made the map block more verbose to show you where the problem is. The CSV rows that we generate inside that block are actually intermediate results stored into memory until we can finally join them by the newline character. This is exactly where we use that extra 1 GB of memory.

Let’s rewrite this in a way that doesn’t store any intermediate results. For that I’ll explicitly loop over rows with a nested loop over columns and store results as I go into the csv.

chp1/example_optimized.rb
 
require ​"benchmark"
 
 
num_rows = 100000
 
num_cols = 10
 
data = Array.new(num_rows) { Array.new(num_cols) { ​"x"​*1000 } }
 
 
time = Benchmark.realtime ​do
 
csv = ​''
 
num_rows.times ​do​ |i|
 
num_cols.times ​do​ |j|
 
csv << data[i][j]
 
csv << ​","​ ​unless​ j == num_cols - 1
 
end
 
csv << ​" "​ ​unless​ i == num_rows - 1
 
end
 
end
 
 
puts time.round(2)

The code got uglier, but how fast is it now? Let’s run it and compare it with the unoptimized version.

1.9.32.02.12.2
GC enabled 9.1811.422.652.43
GC disabled 1.141.151.191.16
Optimized1.011.061.051.09

These are great results! Our simple changes got rid of the GC overhead. The optimized program is even faster than the original with no GC. And if you run the optimized version with the GC disabled, you’ll find out that its GC time is merely a 10% of total execution time. Because of this, our program performs the same in all Ruby versions.

By making simple changes, we got from 2.5 to 10 times performance improvement. Doing so required us merely to look through the code and think how much memory each line and function call takes. Once you catch memory copying, or extra memory allocation, or another case of a memory-inefficient operation, you rewrite the code to avoid that. Simple, isn’t it?

Actually, it is. It turns out that to get significant speedup you might not need code profiling. Memory optimization is easier: just review, think, and rewrite. Only when you are sure that the code spends a reasonable time in GC should you look further and try to locate algorithmic complexity or other sources of poor performance.

But in my experience there’s often no need to optimize anything other than memory. For me the following 80-20 rule of Ruby performance optimization is always true: 80% of performance improvements come from memory optimization, the remaining 20% from everything else.

Review, think, and rewrite. Maybe we should think about thinking. If optimizing memory requires rethinking what the code does, then what exactly should we think about? We’ll talk about that in the next section, but first let’s review what we’ve learned so far.

Takeaways

  • The 80-20 rule of Ruby performance optimization: 80% of performance improvements come from memory optimization, so optimize memory first.

  • A memory-optimized program has the same performance in any modern Ruby versions.

  • Ruby 2.1 is not a silver performance bullet; it just minimizes losses.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.119.106.237