-
Notifications
You must be signed in to change notification settings - Fork 48
Optimize Gregorian date conversions with Neri-Schneider algorithm #152
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
…lgorithm Replace floating-point arithmetic and iterative loops with pure integer operations for ~40% faster Date operations. Date.ordinal and Date.commercial are ~2x faster due to O(1) first-day-of-year calculation. Reference: https://arxiv.org/abs/2102.06959
|
This is what I used to benchmark: #!/usr/bin/env ruby
# Simple Date benchmark without external dependencies
require 'date'
ITERATIONS = 500_000
puts "Ruby version: #{RUBY_VERSION}"
puts "Date benchmark - #{ITERATIONS} iterations each"
puts "-" * 60
def measure(name)
GC.start
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
yield
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
printf "%-40s %8.3f seconds\n", name, elapsed
elapsed
end
# Warm up
100.times { Date.civil(2024, 6, 15, Date::GREGORIAN) }
100.times { Date.jd(2451545, Date::GREGORIAN) }
measure("Date.civil (Gregorian)") do
ITERATIONS.times { |i| Date.civil(2024, (i % 12) + 1, (i % 28) + 1, Date::GREGORIAN) }
end
measure("Date.jd (Gregorian)") do
ITERATIONS.times { |i| Date.jd(2451545 + (i % 1000), Date::GREGORIAN) }
end
dates = (1..1000).map { |i| Date.jd(2451545 + i, Date::GREGORIAN) }
measure("Date#year (JD->civil)") do
ITERATIONS.times { |i| dates[i % 1000].year }
end
measure("Date#month") do
ITERATIONS.times { |i| dates[i % 1000].month }
end
measure("Date#day") do
ITERATIONS.times { |i| dates[i % 1000].day }
end
measure("Date.ordinal (Gregorian)") do
ITERATIONS.times { |i| Date.ordinal(2024, (i % 365) + 1, Date::GREGORIAN) }
end
measure("Date#yday") do
ITERATIONS.times { |i| dates[i % 1000].yday }
end
measure("Date.commercial (Gregorian)") do
ITERATIONS.times { |i| Date.commercial(2024, (i % 52) + 1, (i % 7) + 1, Date::GREGORIAN) }
end
measure("Date.civil day=-1 (last day of month)") do
ITERATIONS.times { |i| Date.civil(2024, (i % 12) + 1, -1, Date::GREGORIAN) }
end
measure("Roundtrip: civil->jd->civil") do
ITERATIONS.times do |i|
d = Date.civil(2024, (i % 12) + 1, (i % 28) + 1, Date::GREGORIAN)
Date.jd(d.jd, Date::GREGORIAN)
end
end
puts "-" * 60
puts "Benchmark complete." |
nobu
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you define constants for the magic numbers, such as days in a century, days in 4 years, average days of months, etc?
Done. I also updated benchmarks after all the changes. It's still 1.8-2x improvement for the core flows. |
olleolleolle
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I read it, the comments look good, and the test all pass. Thanks for bringing in modern algorithms. I leave closer reading to others.
|
It seems GH has some issues and runners fail. Does not seem to be related to my code changes. |
This PR implements the Neri-Schneider algorithm to optimize Ruby's
Dateclass for Gregorian calendar operations. The optimization replaces floating-point arithmetic and iterative loops with pure integer operations.Key improvements:
Date.ordinal: ~2x fasterDate.commercial: ~2x fasterDate.civil/Date.jd: ~10-15% fasterBackground
The Neri-Schneider algorithm uses Euclidean affine functions to compute Julian Day Numbers and civil dates. This algorithm has been successfully adopted by:
Reference paper: Euclidean Affine Functions and their Application to Calendar Algorithms
Benchmark Results
Comparison against unmodified Ruby master (same commit) with 2,000,000 iterations:
The key optimizations are working as expected:
The largest improvements come from
Date.ordinalandDate.commercialwhich previously used iterativec_find_fdoy()(up to 30 loop iterations) and now use O(1) calculations.Technical Notes
DIVandMODmacros for correct handling of negative datesuint64_t) for intermediate calculations to prevent overflowNS_GREGORIAN_EPOCHconstant (1721120) represents March 1, Year 0 in proleptic Gregorian calendarsg == -infinity), preserving exact behavior for Julian and reform period datesTime Class Investigation
I also investigated applying similar optimizations to the
Timeclass using Howard Hinnant's algorithms, replacing lookup tables with mathematical formulas. However, benchmarking against unmodified Ruby master showed no improvement.Analysis revealed the bottleneck in
Timeoperations is timezone handling, not date calculation:The ~0.7s timezone overhead dominates and cannot be optimized algorithmically (involves system calls and timezone database lookups). The date calculation part (~0.3s) is already efficient. Therefore, no Time changes are included in this PR.
Sidenote
I am not familiar with the contribution patterns to Ruby, so my code style or approach may not be fully aligned with conventions. I would gladly accept feedback on that matter.