Mastering Rails time operations

Rails ActiveSupport time methods are slow. Let’s take beginnig_of_month as an example and look at the benchmarks comparing it with a pure Ruby implementation (Rails 6.1.4, Ruby 2.6.6):

time = Time.now.utc
n = 1_000_000
Benchmark.bm do |x|
x.report do
n.times do
time.beginning_of_month
end
end
x.report do
n.times do
Time.utc(time.year, time.month)
end
end
end
user system total real
5.256222 0.012179 5.268401 ( 5.280565)
0.614967 0.004348 0.619315 ( 0.620292)

The difference in performance is quite noticeable, ActiveSupport is almost 9 times slower. The same situation appears with other ActiveSupport methods, such as beginnig_of_day, beginning_of_quarter , end_of_month , etc. which are not so easily replaceable with quick pure Ruby versions.

So, why does ActiveSupport take so much time?
First of all, the problem persists for both DateTime::Calculations and Date::Calculations modules, though they are affected in varying degrees.
Internally ActiveSupport adds a lot of complexity, checks multiple options, handles timezones and conversions, and thus causes performance degradation. Yet it can be avoided by relying on native Ruby solutions where it makes sense.

I’ve run into the ActiveSupport date methods performance issue while trying to speed up a code iterating through a huge collection of entities with dates in their attributes. The entities were aggregated and modified depending on the result of beginning_of_quarter and end_of_quartermethods. And it turned out that a significant improvement could be achieved by simply using a cache. Classic.

Here goes a class wrapping the dates cache implementation. Be careful, It’ll help only in case the date range is limited, and the dates are repeated within the given loop.

class MemoizedDates
attr_accessor :cache
MDate = Struct.new(:beginning_of_quarter, :end_of_quarter)
def initialize
@cache = {}
end
# handles cache misses
def find(date)
cache[date] || memoize(date)
end
private def memoize(date)
cache[date] = MDate.new(
date.beginning_of_quarter,
date.end_of_quarter
)
end
end
### sample usagememoized_dates = MemoizedDates.new
collection.each do |entry|
date = memoized_dates.find(entry.date)
if date.beginning_of_quarter > n
# do stuff
end
end
### benchmarktime = Time.now.utc
memoized_cache = MemoizedDates.new
n = 1_000_000
Benchmark.bm do |x|
x.report do
n.times do
rand_date = time - rand(1000).days
rand_date.beginning_of_quarter
rand_date.end_of_quarter
end
end
x.report do
n.times do
rand_date = time - rand(1000).days
mem_date = memoized_cache.find(rand_date)
mem_date.beginning_of_quarter
mem_date.end_of_quarter
end
end
end
user system total real
39.541243 0.210272 39.751515 ( 39.920949)
10.769937 0.065638 10.835575 ( 10.891969)

Happy coding!