Skip to content

Conversation

@mensfeld
Copy link
Contributor

@mensfeld mensfeld commented Dec 18, 2025

This PR implements the Neri-Schneider algorithm to optimize Ruby's Date class for Gregorian calendar operations. The optimization replaces floating-point arithmetic and iterative loops with pure integer operations.

Key improvements:

  • Date.ordinal: ~2x faster
  • Date.commercial: ~2x faster
  • Date.civil / Date.jd: ~10-15% faster
  • Overall: ~40% faster

Background

The Neri-Schneider algorithm uses Euclidean affine functions to compute Julian Day Numbers and civil dates. This algorithm has been successfully adopted by:

  • Linux kernel's FAT filesystem
  • GCC's libstdc++
  • Elixir
  • .NET Runtime

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:

Operation Baseline Optimized Speedup
Date.civil (Gregorian) 0.106s 0.122s ~same
Date.jd (Gregorian) 0.108s 0.120s ~same
Date#year (JD->civil) 0.043s 0.042s ~same
Date#month 0.038s 0.040s ~same
Date#day 0.037s 0.040s ~same
Date.ordinal 0.210s 0.112s 1.88x faster
Date#yday 0.046s 0.048s ~same
Date.commercial 0.260s 0.144s 1.81x faster
Date.civil day=-1 0.101s 0.112s ~same
Roundtrip 0.214s 0.229s ~same

The key optimizations are working as expected:

  • Date.ordinal: 1.88x faster
  • Date.commercial: 1.81x faster

The largest improvements come from Date.ordinal and Date.commercial which previously used iterative c_find_fdoy() (up to 30 loop iterations) and now use O(1) calculations.

Technical Notes

  • Uses Euclidean (floor) division via Ruby's DIV and MOD macros for correct handling of negative dates
  • 64-bit arithmetic (uint64_t) for intermediate calculations to prevent overflow
  • NS_GREGORIAN_EPOCH constant (1721120) represents March 1, Year 0 in proleptic Gregorian calendar
  • Fast paths only activate for pure Gregorian mode (sg == -infinity), preserving exact behavior for Julian and reform period dates

Time Class Investigation

I also investigated applying similar optimizations to the Time class 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 Time operations is timezone handling, not date calculation:

Operation Time
Time.utc (no timezone) 0.31s
Time.new (with timezone) 1.01s
Time.local (with timezone) 0.89s

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.

…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
@mensfeld mensfeld changed the title [date] Optimize Gregorian date conversions with Neri-Schneider algorithm Optimize Gregorian date conversions with Neri-Schneider algorithm Dec 18, 2025
@mensfeld
Copy link
Contributor Author

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."

Copy link
Member

@nobu nobu left a 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?

@mensfeld
Copy link
Contributor Author

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.

@mensfeld mensfeld requested a review from nobu December 18, 2025 11:44
Copy link
Contributor

@olleolleolle olleolleolle left a 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.

@mensfeld
Copy link
Contributor Author

It seems GH has some issues and runners fail. Does not seem to be related to my code changes.

@nobu nobu merged commit 0bec399 into ruby:master Dec 18, 2025
71 of 84 checks passed
@mensfeld mensfeld deleted the neri-schneider-optimization branch December 18, 2025 16:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants