Skip to content

[Feature #21264] Replace C extension with pure Ruby implementation for Ruby >= 3.3 - #155

Open
jinroq wants to merge 37 commits into
ruby:masterfrom
jinroq:replace_c_to_ruby
Open

[Feature #21264] Replace C extension with pure Ruby implementation for Ruby >= 3.3#155
jinroq wants to merge 37 commits into
ruby:masterfrom
jinroq:replace_c_to_ruby

Conversation

@jinroq

@jinroqjinroq commented Feb 15, 2026

Copy link
Copy Markdown

Summary

https://bugs.ruby-lang.org/issues/21264

Rewrite Date and DateTime C extension as pure Ruby, targeting Ruby 3.3+.
Ruby < 3.3 continues to use the existing C extension as before.

  • Ruby >= 3.3: Pure Ruby implementation (~7,900 lines across 10 files in lib/date/)
  • Ruby < 3.3: Existing C extension (ext/date/) compiled via rake-compiler

All 143 tests pass with 162,593 assertions on both paths.

Motivation

  • Improves portability: no C compiler required for Ruby 3.3+
  • Makes the codebase easier to read, debug, and contribute to
  • Enables Ractor compatibility without C-level thread safety concerns
  • Aligns with the broader Ruby ecosystem trend toward pure Ruby default gems

Architecture

The version branch (RUBY_VERSION >= "3.3") is applied at three layers:

LayerRuby >= 3.3Ruby < 3.3
lib/date.rbrequire_relative pure Ruby filesrequire 'date_core' (C ext)
ext/date/extconf.rbGenerates dummy Makefile (no-op)create_makefile('date_core')
Rakefiletask :compile is a no-opRake::ExtensionTask compiles C ext
C optionPurposePure Ruby
USE_PACKBit-pack mon/mday/hour/min/sec into a single integer for memory efficiencyNot needed — uses standard instance variables (@nth, @jd, @df, @sf, @of, @sg)
TIGHT_PARSERStricter Date._parse (disabled by default in C via /* #define TIGHT_PARSER */)Matches C default behavior (loose parser) — TIGHT_PARSER logic is not implemented

Timezone table auto-generation

lib/date/zonetab.rb is now auto-generated from ext/date/zonetab.list (the same source used by gperf to generate zonetab.h for the C extension). This
ensures the Ruby and C timezone tables stay in sync automatically.

ComponentDescription
ext/date/generate-zonetab-rbRuby script that reads zonetab.list and produces the Ruby hash table
ext/date/prereq.mkupdate-zonetab target now runs generate-zonetab-rb alongside gperf
.github/workflows/update.ymlCI commits regenerated lib/date/zonetab.rb alongside ext/date changes

Pure Ruby file structure

FileDescription
lib/date/core.rbDate class (civil, ordinal, commercial, JD, arithmetic, comparison)
lib/date/parse.rbDate._parse, _iso8601, _rfc3339, _rfc2822, _xmlschema, _jisx0301
lib/date/datetime.rbDateTime subclass (hour, min, sec, offset)
lib/date/strptime.rbstrptime parsing
lib/date/strftime.rbstrftime formatting
lib/date/zonetab.rbTimezone offset table (auto-generated from zonetab.list)
lib/date/patterns.rbRegex patterns for parsing
lib/date/constants.rbCalendar reform constants (ITALY, ENGLAND, GREGORIAN, etc.)
lib/date/time.rbDate#to_time, Time#to_date, Time#to_datetime
lib/date/version.rbDate::VERSION

Performance note

DateTime is deprecated and no performance optimization has been done for it. The benchmark numbers for DateTime are provided for reference only.

Changes

  • Rakefile: Branch on RUBY_VERSION for compile/test task setup; test depends on compile for Ruby < 3.3
  • date.gemspec: Include both lib/**/*.rb and ext/date/* files; set extensions
  • ext/date/extconf.rb: Generate dummy Makefile on Ruby >= 3.3, build C ext otherwise
  • ext/date/generate-zonetab-rb (new): Script to auto-generate lib/date/zonetab.rb from ext/date/zonetab.list
  • ext/date/prereq.mk: Add generate-zonetab-rb to update-zonetab target
  • .github/workflows/update.yml: Include lib/date/zonetab.rb in auto-commit scope
  • lib/date.rb: Branch on RUBY_VERSION for require path; add require 'timeout' for parse timeout support
  • lib/date/*.rb (new): Pure Ruby implementation (10 files, ~7,900 lines)

Benchmark: C extension vs Pure Ruby (Ruby 4.0.1)

Date class methods
MethodC ext (i/s)Pure Ruby (i/s)Ratio
Date.new3,974,2171,531,11938.5%
Date.new(no args)4,509,7311,415,43431.4%
Date.civil4,631,6842,137,45746.1%
Date.civil(sg)4,372,1232,026,94946.4%
Date.civil(-1)4,587,8551,872,14640.8%
Date.civil(neg)4,477,8272,084,78646.6%
Date.jd4,807,1353,973,48982.7%
Date.ordinal2,982,9252,208,69874.0%
Date.commercial2,476,7321,799,79972.7%
Date.today175,435443,836253.0%
Date.valid_civil?10,746,8101,513,35114.1%
Date.valid_civil?(false)10,823,6262,051,86819.0%
Date.valid_ordinal?4,160,5901,625,97839.1%
Date.valid_commercial?3,178,432580,55318.3%
Date.valid_jd?16,512,28112,386,99375.0%
Date.gregorian_leap?14,950,0718,380,87756.1%
Date.gregorian_leap?(1900)14,080,5238,051,20657.2%
Date.julian_leap?16,991,20010,169,18259.8%
Date parse methods
MethodC ext (i/s)Pure Ruby (i/s)Ratio
Date._parse(iso)239,0011,506,581630.4%
Date._parse(us)118,865644,263542.0%
Date._parse(eu)159,928556,533348.0%
Date._parse(rfc2822)82,441264,068320.3%
Date.parse(iso)212,655720,361338.7%
Date.parse(us)112,487411,137365.5%
Date.parse(eu)148,339373,372251.7%
Date.parse(compact)133,285731,634548.9%
Date._strptime2,737,3341,412,08251.6%
Date.strptime1,390,385943,65267.9%
Date.strptime(complex)1,116,618373,48033.4%
Date._iso8601750,0951,613,186215.1%
Date._rfc3339501,479664,953132.6%
Date._rfc2822373,428613,062164.2%
Date._xmlschema787,8481,595,289202.5%
Date._httpdate397,646688,085173.0%
Date._jisx0301733,0591,425,575194.5%
Date.iso8601547,109731,563133.7%
Date.rfc3339414,614427,392103.1%
Date.rfc2822310,970412,405132.6%
Date.xmlschema574,804732,143127.4%
Date.httpdate302,846439,218145.0%
Date.jisx0301540,940687,365127.1%
Date instance methods
MethodC ext (i/s)Pure Ruby (i/s)Ratio
Date#year20,593,32715,021,28772.9%
Date#month21,446,32915,000,41069.9%
Date#day18,289,72514,989,63582.0%
Date#wday19,708,68315,560,39879.0%
Date#yday15,578,18218,117,325116.3%
Date#jd18,533,15520,384,518110.0%
Date#ajd7,244,7205,030,63969.4%
Date#mjd11,192,99819,249,248172.0%
Date#amjd9,935,5122,221,27222.4%
Date#ld11,415,31819,199,854168.2%
Date#start20,057,43520,487,414102.1%
Date#cwyear4,475,22118,366,865410.4%
Date#cweek4,523,12818,683,158413.1%
Date#cwday20,551,98011,273,83454.9%
Date#leap?18,147,9739,743,76053.7%
Date#julian?17,546,61110,199,75258.1%
Date#gregorian?17,932,33913,141,14673.3%
Date#sunday?20,750,73512,003,95257.8%
Date#monday?20,790,77211,854,53257.0%
Date#saturday?20,400,21511,924,83458.5%
Date arithmetic / comparison
MethodC ext (i/s)Pure Ruby (i/s)Ratio
Date#+15,922,4673,491,74259.0%
Date#+1005,719,9713,531,43861.7%
Date#-14,050,0153,223,10779.6%
Date#-Date1,900,1272,379,705125.2%
Date#>>13,133,0301,989,98763.5%
Date#>>123,018,8941,938,66564.2%
Date#<<12,206,6901,877,25085.1%
Date#next_day4,710,8693,260,11669.2%
Date#prev_day4,041,4383,046,17075.4%
Date#next_month2,742,8501,900,55769.3%
Date#prev_month2,102,1041,826,17186.9%
Date#next_year2,629,0051,749,54966.5%
Date#prev_year1,836,3591,687,47091.9%
Date#succ5,421,6713,270,31860.3%
Date#<=>11,416,6367,788,56968.2%
Date#===12,411,8277,174,65957.8%
Date#==2,702,1289,236,888341.8%
Date#<7,324,1567,443,056101.6%
Date#>7,559,9447,485,99099.0%
Date#eql?10,519,18710,476,62499.6%
Date#hash13,768,43110,380,44975.4%
Date iteration / formatting / conversion
MethodC ext (i/s)Pure Ruby (i/s)Ratio
Date#upto(+30)140,859163,117115.8%
Date#downto(-30)114,805161,888141.0%
Date#step(+30,7)711,797834,724117.3%
Date#to_s3,751,0914,260,907113.6%
Date#inspect560,4831,357,800242.3%
Date#asctime2,444,6061,676,95168.6%
Date#strftime2,704,6723,867,297143.0%
Date#strftime(%Y-%m-%d)3,051,5363,252,162106.6%
Date#strftime(%A %B)2,911,0071,725,70459.3%
Date#strftime(%c)1,963,1841,522,71977.6%
Date#strftime(%x)2,585,4812,066,51079.9%
Date#strftime(composite)1,581,6621,880,930118.9%
Date#iso86013,871,1473,840,76099.2%
Date#rfc33391,787,3962,382,235133.3%
Date#rfc28221,939,8821,720,22188.7%
Date#xmlschema3,936,0233,722,46494.6%
Date#httpdate1,601,6161,925,300120.2%
Date#jisx03012,813,6832,420,53986.0%
Date#to_date22,264,20221,011,31894.4%
Date#to_datetime6,826,666349,4525.1%
Date#to_time1,908,1331,452,38676.1%
Date#new_start5,596,6684,097,28873.2%
Date#julian5,827,3273,818,12065.5%
Date#gregorian5,857,2133,832,37765.4%
Date#italy5,237,4813,860,51273.7%
Date#england5,504,8573,846,98969.9%
Date Marshal.dump524,047586,972112.0%
Date Marshal.load562,881607,496107.9%
Date#deconstruct_keys(nil)3,478,8123,878,040111.5%
Date#deconstruct_keys(year)5,298,3464,469,40284.4%
Date#deconstruct_keys(y/m/d)3,919,1471,841,22147.0%
DateTime class methods (deprecated — not optimized)
MethodC ext (i/s)Pure Ruby (i/s)Ratio
DateTime.civil1,875,912351,42618.7%
DateTime.jd1,808,918637,92635.3%
DateTime.ordinal1,526,460601,55139.4%
DateTime.commercial1,358,985352,83526.0%
DateTime.now138,945314,764226.5%
DateTime.parse(iso)86,15535,33641.0%
DateTime.parse(rfc2822)75,692137,568181.7%
DateTime.strptime390,52494,59824.2%
DateTime.iso8601332,657142,62542.9%
DateTime.rfc3339360,583226,29662.8%
DateTime.rfc2822284,958201,99270.9%
DateTime.xmlschema354,039155,42443.9%
DateTime.httpdate293,315214,76173.2%
DateTime.jisx0301347,867150,36543.2%
DateTime instance methods (deprecated — not optimized)
MethodC ext (i/s)Pure Ruby (i/s)Ratio
DateTime#year20,098,12715,042,68974.8%
DateTime#month21,237,84016,111,99375.9%
DateTime#day18,548,99416,062,12786.6%
DateTime#hour21,333,61717,826,34183.6%
DateTime#min18,521,19517,755,41395.9%
DateTime#sec21,045,56517,879,18385.0%
DateTime#sec_fraction10,109,92117,794,083176.0%
DateTime#offset9,615,6416,137,64063.8%
DateTime#zone3,181,5351,334,38841.9%
DateTime#wday20,440,56816,914,21482.7%
DateTime#yday14,260,45517,826,177125.0%
DateTime#jd19,268,54820,221,226104.9%
DateTime#ajd2,085,063886,01442.5%
DateTime#+15,377,7671,662,38230.9%
DateTime#+frac281,200477,164169.7%
DateTime#-13,983,0091,594,37340.0%
DateTime#-DT1,316,729396,86930.1%
DateTime#>>12,909,2491,935,72466.5%
DateTime#<<12,128,3521,829,63586.0%
DateTime#next_day5,325,9071,598,98230.0%
DateTime#prev_day3,787,6271,527,31640.3%
DateTime#next_month2,838,3291,825,38364.3%
DateTime#prev_month2,096,1881,726,25582.4%
DateTime#next_year2,627,2721,701,47464.8%
DateTime#prev_year1,963,3891,616,02982.3%
DateTime#<=>12,096,5977,730,51163.9%
DateTime#===11,192,6646,077,88154.3%
DateTime#==2,707,6659,205,819340.0%
DateTime#eql?10,865,19510,341,65595.2%
DateTime#hash13,709,1367,672,20856.0%
DateTime formatting / conversion (deprecated — not optimized)
MethodC ext (i/s)Pure Ruby (i/s)Ratio
DateTime#to_s1,892,182212,11911.2%
DateTime#inspect468,910189,55440.4%
DateTime#strftime1,546,127298,04919.3%
DateTime#strftime(%Y%m%d%z)1,680,8751,006,87959.9%
DateTime#strftime(%c)2,063,1651,035,74650.2%
DateTime#strftime(%s)3,244,980999,16830.8%
DateTime#iso86011,414,142208,72714.8%
DateTime#rfc33391,308,541210,80416.1%
DateTime#rfc28221,780,0111,200,13567.4%
DateTime#xmlschema1,415,768214,00515.1%
DateTime#httpdate1,656,2731,279,40777.2%
DateTime#jisx03011,212,199467,96438.6%
DateTime#new_offset(0)5,246,7511,157,74122.1%
DateTime#new_offset(str)3,408,551487,45714.3%
DateTime#new_offset(rat)2,033,4431,151,96556.7%
DateTime#to_date6,530,3293,094,15347.4%
DateTime#to_datetime22,361,49221,171,07694.7%
DateTime#to_time917,658680,73974.2%
DateTime Marshal.dump532,858361,43567.8%
DateTime Marshal.load551,158336,27361.0%
DateTime#deconstruct_keys(nil)879,547721,48782.0%
DateTime#deconstruct_keys(y/h)4,462,7682,341,06352.5%
Time conversion
MethodC ext (i/s)Pure Ruby (i/s)Ratio
Time#to_date3,891,4821,677,90843.1%
Time#to_datetime1,245,8441,049,60084.2%

C implementation has been rewritten as faithfully as possible in pure Ruby.
[Feature #21264]
https://bugs.ruby-lang.org/issues/21264
@jinroqjinroq changed the title Replace C extension with pure Ruby implementation for Ruby >= 3.3[Feature #21264] Replace C extension with pure Ruby implementation for Ruby >= 3.3Feb 15, 2026
@jeremyevans

Copy link
Copy Markdown
Contributor

Date was originally written in Ruby prior to Ruby 1.9.3. It was rewritten in C to significantly increase performance. When Date was written in Ruby, it's low performance made it a common bottleneck in Ruby applications. I think for this to be considered, you need to provide comprehensive benchmarks showing that performance does not decrease significantly.

Comment threadlib/date/constants.rb Outdated

MONTHNAMES = [nil, "January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"]
.map { |s| s&.encode(Encoding::US_ASCII)&.freeze }.freeze

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Put # encoding: US-ASCII at the beginning.

@nobu

nobu commented Feb 15, 2026

Copy link
Copy Markdown
Member

A simple benchmark to just create objects:

require'benchmark'require'date'N=10000Benchmark.bmdo |bm|
bm.report("Time"){N.times{Time.now}}bm.report("Date"){N.times{Date.today}}end

With ruby 4.1.0dev (2026-02-14T07:03:18Z master 2065b55980) +PRISM [arm64-darwin25], and master:

$ ruby -I./lib bench.rb user system total realTime 0.001656 0.000023 0.001679 ( 0.001675)Date 0.002735 0.000062 0.002797 ( 0.002827)

This PR:

$ ruby -I./lib bench.rb user system total realTime 0.001018 0.000013 0.001031 ( 0.001031)Date 0.007624 0.000151 0.007775 ( 0.007776)

Interestingly, this PR makes Time.now faster.

@jeremyevans

Copy link
Copy Markdown
Contributor

@nobu you should probably benchmark with benchmark-driver or benchmark-ips. With a runtime of only ~1ms, it's hard to get statistically valid results. Considering I don't think date modifies the implementation of Time.now, it seems unlikely there would be an significant performance difference.

A benchmark should include most of the methods in the library. When I was working on home_run, I had a set of comprehensive benchmarks to see the differences in performance compared to the (at the time) Ruby implementation. It included a decent set of benchmarks (https://github.com/jeremyevans/home_run/blob/master/bench/cpu_bench.rb), though I would certainly switch the backend to use benchmark-driver or benchmark-ips for this.

@nobu

nobu commented Feb 15, 2026

Copy link
Copy Markdown
Member

For the mean time, just tried Benchmark.ips.

master:

Warming up --------------------------------------
Time.now 206.000 i/100ms
Date.today 46.000 i/100ms
Calculating -------------------------------------
Time.now 2.096k (± 0.1%) i/s (477.11 μs/i) - 10.506k in 5.012541s
Date.today 459.375 (± 0.7%) i/s (2.18 ms/i) - 2.300k in 5.006967s

This PR:

Warming up --------------------------------------
Time.now 206.000 i/100ms
Date.today 16.000 i/100ms
Calculating -------------------------------------
Time.now 2.143k (± 0.6%) i/s (466.72 μs/i) - 10.918k in 5.095787s
Date.today 166.713 (± 0.0%) i/s (6.00 ms/i) - 848.000 in 5.086612s

Agree there seems to be a lot of room for optimization.
The current extension is line-by-line translation from Ruby to C and not optimized for C.
This PR looks also line-by-line in reverse and doubly non-optimal.

@jeremyevans

Copy link
Copy Markdown
Contributor

The current extension is line-by-line translation from Ruby to C and not optimized for C.

I don't believe the line-by-line translation part is 100% accurate, though it may be true for large portions of the library. The primary implementation difference between the current C implementation and the previous (pre Ruby 1.9.3) Ruby implementation was that the previous Ruby implementation always eagerly converted from whatever the input format was to ajd (e.g. https://github.com/ruby/ruby/blob/ruby_1_9_2/lib/date.rb#L1621-L1629). That's the primary reason it was so slow. home_run pioneered the idea of not converting eagerly to ajd, only doing the conversion later when it was actually needed. That same basic approach was used by tadf when he rewrote date from Ruby to C. See https://bugs.ruby-lang.org/issues/4068 for background on that change.

I think we'd be willing to accept a small performance decrease to switch the C implementation with a Ruby implementation. However, a ~3x performance decrease is way too much to consider switching, IMO. As I mentioned earlier, Date was often a bottleneck in application code before Ruby 1.9.3, that's the reason I worked on home_run. So performance should be a primary consideration when deciding whether to switch to an alternative implementation.

| Implementation | i/s | μs/i |
| :--- | :--- | :--- |
| System (C ext) | 347.5k | 2.88 |
| Pre-optimization (pure Ruby) | 313.5k | 3.19 |
| Post-optimization (pure Ruby) | 380.0k | 2.63 |
| Implementation | i/s | μs/i |
| :--- | :--- | :--- |
| System (C ext) | 4.32M | 0.23 |
| Pre-optimization (pure Ruby) | 312k | 3.20 |
| Post-optimization (pure Ruby) | 1.67M | 0.60 |
**5.4x speedup** (312k → 1.67M i/s). Reached approximately **39%** of the C extension's performance.
| Implementation | i/s |
| :--- | :--- |
| System (C ext) | 4.50M |
| Pre-optimization (pure Ruby) | 311k |
| Post-optimization (pure Ruby) | 1.63M |
For cases where the fast path is not applicable (e.g., Julian calendar or BCE years), performance remains equivalent to the previous implementation (no changes).
The fast path is applied when all of the following conditions are met:
1. `year`, `month`, and `day` are all `Integer`.
2. The date is determined to be strictly Gregorian (e.g., `start` is `GREGORIAN`, or a reform date like `ITALY` with `year > 1930`).
By satisfying these conditions, the implementation skips six `self.class.send` calls, `Hash` allocations, redundant `decode_year` calls, and repetitive array generation.
| Implementation | i/s |
| :--- | :--- |
| System (C ext) | 9.58M |
| Pre-optimization (pure Ruby) | 458k |
| Post-optimization (pure Ruby) | 2.51M |
**5.5x speedup** (458k → 2.51M i/s). Reached approximately **26%** of the C extension's performance.
| Implementation | i/s |
| :--- | :--- |
| System (C ext) | 9.59M |
| Pre-optimization (pure Ruby) | 574k |
| Post-optimization (pure Ruby) | 2.53M |
**4.4x speedup.**
1. **Added a Fast Path** — For `Integer` arguments and Gregorian calendar cases, the entire method chain of `numeric?` (called 3 times) and `valid_civil_sub` is skipped. Instead, month and day range checks are performed inline.
2. **Eliminated Repeated Array Allocation in `valid_civil_sub`** — Changed the implementation to reference a `MONTH_DAYS` constant instead of creating a new array `[nil, 31, 28, ...]` on every call.
| Case | System (C ext) | Pre-optimization | Post-optimization |
| :--- | :--- | :--- | :--- |
| Date.jd | 4.12M | 462k | 1.18M |
| Date.jd(0) | 4.20M | 467k | 1.19M |
| Date.jd(JULIAN) | 4.09M | 468k | 1.22M |
| Date.jd(GREG) | 4.07M | 467k | 1.21M |
**Approximately 2.6x speedup** (462k → 1.18M i/s). Reached approximately **29%** of the C extension's performance.
The fast path is effective across all `start` patterns (`ITALY` / `JULIAN` / `GREGORIAN`). The following processes are now skipped:
- `valid_sg` + `c_valid_start_p` (numerous type checks)
- `value_trunc` (array allocation for `Integer`)
- `decode_jd` (array allocation for standard Julian Days)
- `d_simple_new_internal` (`canon` + flag operations + method call overhead)
| Case | System (C ext) | Pre-optimization | Post-optimization | Improvement |
| :--- | :--- | :--- | :--- | :--- |
| Date.ordinal | 2.66M | 170k | 645k | 3.8x |
| Date.ordinal(-1) | 1.87M | 119k | 639k | 5.4x |
| Date.ordinal(neg) | 3.08M | 107k | 106k | (Slow path) |
**3.8x to 5.4x speedup** in cases where the fast path is applicable. Reached approximately **24% to 34%** of the C extension's performance.
`Date.ordinal(neg)` remains on the slow path (equivalent to previous performance) because the year -4712 does not meet the fast path condition (`year > REFORM_END_YEAR`).
| Case | System (C ext) | Pre-optimization | Post-optimization | Improvement |
| :--- | :--- | :--- | :--- | :--- |
| Date.commercial | 2.18M | 126k | 574k | 4.5x |
| Date.commercial(-1) | 1.45M | 85k | 560k | 6.6x |
| Date.commercial(neg) | 2.84M | 93k | 90k | (Slow path) |
**4.5x to 6.6x speedup** in cases where the fast path is applicable. Reached approximately **26% to 39%** of the C extension's performance.
Inlined the ISO week-to-JD conversion:
1. Obtain the JD for Jan 1 using `c_gregorian_civil_to_jd(year, 1, 1)` (requires only one method call).
2. Directly calculate `max_weeks` (52 or 53) from the ISO weekday to perform a week range check.
3. Calculate the Monday of Week 1 using: `base = (jd_jan1 + 3) - ((jd_jan1 + 3) % 7)`.
4. Directly calculate the JD using: `rjd = base + 7*(week-1) + (day-1)`.
This bypasses the entire previous chain of `valid_commercial_p` → `c_valid_commercial_p` → `c_commercial_to_jd` → `c_jd_to_commercial` (verification via inverse conversion).
| Case | System (C ext) | Pre-optimization | Post-optimization | Improvement |
| :--- | :--- | :--- | :--- | :--- |
| valid_ordinal? (true) | 3.76M | 221k | 3.38M | 15.3x |
| valid_ordinal? (false) | 3.77M | 250k | 3.39M | 13.6x |
| valid_ordinal? (-1) | 2.37M | 148k | 2.67M | 18.0x |
**15x to 18x speedup.** Performance reached **90% to 112%** of the C extension, making it nearly equivalent or even slightly faster.
Since `valid_ordinal?` does not require object instantiation and only involves leap year determination and day-of-year range checks, the inline cost of the fast path is extremely low, allowing it to rival the performance of the C extension.
| Case | System (C ext) | Pre-optimization | Post-optimization | Improvement |
| :--- | :--- | :--- | :--- | :--- |
| valid_commercial? (true) | 2.94M | 167k | 1.09M | 6.5x |
| valid_commercial? (false) | 3.56M | 218k | 1.08M | 5.0x |
| valid_commercial? (-1) | 1.79M | 104k | 1.07M | 10.3x |
**5x to 10x speedup.** Performance reached approximately **30% to 37%** of the C extension.
The same ISO week validation logic used in the `Date.commercial` fast path (calculating `max_weeks` from the JD of Jan 1 and performing `cwday`/`cweek` range checks) has been inlined. The reason it does not rival the C extension as closely as `valid_ordinal?` is due to the remaining overhead of a single method call to `c_gregorian_civil_to_jd(year, 1, 1)`.
| Method | i/s |
| :--- | :--- |
| Date.valid_jd? | 9.29M |
| Date.valid_jd?(false) | 9.68M |
It is approximately **3.3x faster** compared to the C extension benchmarks (Reference values: 2.93M / 2.80M). The simplification to only perform type checks has had a significant impact on performance.
| Method | Pre-optimization | Post-optimization | Improvement |
| :--- | :--- | :--- | :--- |
| Date.gregorian_leap?(2000) | 1.40M | 7.39M | 5.3x |
| Date.gregorian_leap?(1900) | 1.39M | 7.48M | 5.4x |
It is approximately **4.5x faster** even when compared to the C extension reference values (1.69M / 1.66M).
For `Integer` arguments, the implementation now performs the leap year determination inline, skipping three method calls: the `numeric?` check, `decode_year`, and `c_gregorian_leap_p?`. Non-`Integer` arguments (such as `Rational`) will fall back to the conventional path.
| Method | Pre-optimization | Post-optimization | Improvement |
| :--- | :--- | :--- | :--- |
| Date.julian_leap? | 2.27M | 8.98M | 4.0x |
It is approximately **3.2x faster** even when compared to the C extension reference value (2.80M).
For `Integer` arguments, the implementation now skips calls to `numeric?`, `decode_year`, and `c_julian_leap_p?`, returning the result directly via an inline `year % 4 == 0` check.
| Method | Pre-optimization | Post-optimization | Improvement |
| :--- | :--- | :--- | :--- |
| Date#year | 3.27M | 10.06M | 3.1x |
It is approximately **2.8x faster** even when compared to the C extension reference value (3.65M).
In cases where `@nth == 0 && @has_civil` (which covers almost all typical use cases), the implementation now skips the `m_year` → `simple_dat_p?` → `get_s_civil` method chain as well as `self.class.send(:f_zero_p?, nth)`, returning `@year` directly.
Add early return in `m_mon` when `@has_civil` is already true,
skipping `simple_dat_p?` check and `get_s_civil`/`get_c_civil`
method call overhead. Same pattern as `m_real_year`.
Benchmark results (Ruby 4.0.1, benchmark-ips):
Date#month: C 21,314,867 ips -> Ruby 14,302,144 ips (67.1%)
DateTime#month: C 20,843,168 ips -> Ruby 14,113,170 ips (67.7%)
Add early return in `m_mday` when `@has_civil` is already true,
skipping `simple_dat_p?` check and `get_s_civil`/`get_c_civil`
method call overhead. Same pattern as `m_real_year` and `m_mon`.
Benchmark results (Ruby 4.0.1, benchmark-ips):
Date#day: C 18,415,779 ips -> Ruby 14,248,797 ips (77.4%)
DateTime#day: C 18,758,870 ips -> Ruby 13,750,236 ips (73.3%)
Add early return in `m_wday` when `@has_jd` is true and `@of` is nil
(simple Date), inlining `(@jd + 1) % 7` directly. This skips
`m_local_jd`, `get_s_jd`, `c_jd_to_wday` method call overhead.
Benchmark results (Ruby 4.0.1, benchmark-ips):
Date#wday: C 20,923,653 ips -> Ruby 11,174,133 ips (53.4%)
DateTime#wday: C 20,234,376 ips -> Ruby 3,721,404 ips (18.4%)
Note: DateTime#wday is not covered by this fast path since it
requires offset-aware local JD calculation.
Add fast path in `m_yday` for simple Date (`@of.nil?`) with
`@has_civil` already computed. When the calendar is proleptic
Gregorian or the date is well past the reform period, compute
yday directly via `YEARTAB[month] + day`, skipping `m_local_jd`,
`m_virtual_sg`, `m_year`, `m_mon`, `m_mday`, and other method
call overhead.
Benchmark results (Ruby 4.0.1, benchmark-ips):
Date#yday: C 16,253,269 ips -> Ruby 1,942,757 ips (12.0%)
DateTime#yday: C 14,927,308 ips -> Ruby 851,319 ips ( 5.7%)
Note: DateTime#yday is not covered by this fast path since it
requires offset-aware local JD calculation.
Multiple optimizations to `Date#+` and its object creation path:
1. Eliminate `instance_variable_set` in `new_with_jd_and_time`:
Replace 10 `instance_variable_set` calls with a protected
`_init_with_jd` method using direct `@var =` assignment.
Benefits all callers (Date#+, Date#-, Date#>>, DateTime#+, etc).
2. Avoid `self.class.send` overhead in `Date#+`:
Replace `self.class.send(:new_with_jd, ...)` chain with direct
`self.class.allocate` + `obj._init_with_jd(...)` (protected call).
3. Eager JD computation in `Date.civil` fast path:
Compute JD via Neri-Schneider algorithm in `initialize` instead
of deferring. Ensures `@has_jd = true` from creation, so `Date#+`
always takes the fast `@has_jd` path.
4. Add `_init_simple_with_jd` with only 4 ivar assignments:
For simple Date fast path, skip 7 nil assignments that `allocate`
already provides as undefined (returns nil).
5. Fix fast path condition to handle `@has_civil` without `@has_jd`:
When only civil data is available, compute JD inline via
Neri-Schneider before addition.
Benchmark results (Ruby 4.0.1, benchmark-ips):
Date#+1: C 5,961,579 ips -> Ruby 3,150,254 ips (52.8%)
Date#+100: C 6,054,311 ips -> Ruby 3,088,684 ips (51.0%)
Date#-1: C 4,077,013 ips -> Ruby 2,488,817 ips (61.0%)
Date#+1 progression:
Before: 1,065,416 ips (17.9% of C)
After ivar_set removal: 1,972,000 ips (33.1% of C)
After send avoidance: 2,691,799 ips (45.2% of C)
After eager JD + 4-ivar init: 3,150,254 ips (52.8% of C)
Date#-1: C 4,077,013 ips -> Ruby 2,863,047 ips (70.2%)
Date#-1 progression:
Before: 989,991 ips (24.3% of C)
After Date#+ optimization: 2,488,817 ips (61.0% of C)
After Date#- fast path: 2,863,047 ips (70.2% of C)
Date#<<1: C 2,214,936 ips -> Ruby 1,632,773 ips (73.7%)
Date#<<1 progression:
Before: 205,555 ips ( 9.3% of C)
After Date#>> optimization: 1,574,551 ips (71.1% of C)
After direct fast path: 1,632,773 ips (73.7% of C)
- Ruby version: 4.0 (Docker)
- C baseline: bench/results/20260215/4.0.1_system.tsv
- Tool: benchmark-ips
┌──────────────┬─────────┬────────────┬─────────┐
│ Benchmark │ C (ips) │ Ruby (ips) │ Ruby/C │
├──────────────┼─────────┼────────────┼─────────┤
│ Date#<<1 │ 2.21 M │ 1.62 M │ 1/1.4x │
├──────────────┼─────────┼────────────┼─────────┤
│ DateTime#<<1 │ 2.13 M │ 177.53 K │ 1/12.0x │
└──────────────┴─────────┴────────────┴─────────┘
Changes: Replaced the slow path of Date#<< which delegated to self >> (-n) with an inlined version of Date#>>'s slow path logic. This eliminates the extra method call, sign negation, and redundant condition checks.
- Date#<< (Date only): reaches 71% of C performance
- DateTime#<< (with offset): remains at 1/12x due to the slow path being exercised more heavily
- Ruby version: 4.0 (Docker)
- C baseline: bench/results/20260215/4.0.1_system.tsv
- Tool: benchmark-ips
┌──────────────┬─────────┬───────────────────┬──────────────────┬─────────┐
│ Benchmark │ C (ips) │ Ruby before (ips) │ Ruby after (ips) │ after/C │
├──────────────┼─────────┼───────────────────┼──────────────────┼─────────┤
│ Date#<=> │ 11.84 M │ 635.23 K │ 2.99 M │ 1/4.0x │
├──────────────┼─────────┼───────────────────┼──────────────────┼─────────┤
│ DateTime#<=> │ 12.24 M │ 622.88 K │ 577.00 K │ 1/21.2x │
└──────────────┴─────────┴───────────────────┴──────────────────┴─────────┘
Changes: Added a fast path to `Date#<=>` for the common case where both objects are simple Date instances (`@df`, `@sf`, `@of` are all `nil`) with `@nth == 0` and `@has_jd` set. In this case, the comparison reduces to a direct `@jd <=> other.@jd` integer comparison, eliminating two `m_canonicalize_jd` calls (each of which allocates a `[nth, jd]` array via `canonicalize_jd`), redundant `simple_dat_p?` checks, and chained accessor calls for `m_nth`, `m_jd`, `m_df`, and `m_sf`.
- `Date#<=>` (Date only): 4.7x improvement over pre-optimization Ruby, reaches 75% of C performance
- `DateTime#<=>` (with offset): unaffected — falls through to the existing slow path
Benchmark: Date#== optimization (pure Ruby vs C)
- Ruby version: 4.0 (Docker)
- C baseline: bench/results/20260215/4.0.1_system.tsv
- Tool: benchmark-ips
┌─────────────┬─────────┬───────────────────┬──────────────────┬─────────┐
│ Benchmark │ C (ips) │ Ruby before (ips) │ Ruby after (ips) │ after/C │
├─────────────┼─────────┼───────────────────┼──────────────────┼─────────┤
│ Date#== │ 2.78 M │ 875.47 K │ 3.24 M │ 1.17x │
├─────────────┼─────────┼───────────────────┼──────────────────┼─────────┤
│ DateTime#== │ 2.72 M │ 798.68 K │ 924.96 K │ 1/2.9x │
└─────────────┴─────────┴───────────────────┴──────────────────┴─────────┘
Changes: Added a fast path to `Date#==` for the common case where both objects are simple Date instances (`@df`, `@sf`, `@of` are all `nil`) with `@nth == 0` and `@has_jd` set. In this case, equality reduces to a direct `@jd == other.@jd` integer comparison. This eliminates two `m_canonicalize_jd` calls (each allocating a `[nth, jd]` array via `canonicalize_jd`), redundant `simple_dat_p?` checks, and chained accessor calls for `m_nth`, `m_jd`, `m_df`, and `m_sf`.
- `Date#==` (Date only): 3.7x improvement over pre-optimization Ruby, 17% faster than C
- `DateTime#==` (with offset): unaffected — falls through to the existing slow path
Add fast paths that skip `m_canonicalize_jd` (which allocates an array) for the common case: both objects are simple (`@df`, `@sf`, `@of` are all `nil`), `@nth == 0`, `@has_jd` is true, and `0 <= @jd < CM_PERIOD` (guaranteeing that canonicalization is a no-op).
For `Date#===`, whether the two dates are on the same calendar or not, the result always reduces to `@jd == other.@jd` under these conditions, so the `m_gregorian_p?` check and both `m_canonicalize_jd` calls are eliminated.
For `Date#hash`, the same bounds guarantee that `m_nth == 0` and `m_jd == @jd` after canonicalization, so `[0, @jd, @sg].hash` is returned directly.
| Method | Before | After | Speedup | C impl |
|-------------|-------------|--------------|---------|--------------|
| `Date#===` | ~558K ips | ~2,940K ips | +5.3x | ~12,659K ips |
| `Date#hash` | ~1,990K ips | ~6,873K ips | +3.5x | ~13,833K ips |
feat: Optimized `Date#<`.
Add an explicit `Date#<` method with a fast path that bypasses the `Comparable` module overhead. When both objects are simple (`@df`, `@sf`, `@of` are all `nil`), `@nth == 0`, and `@has_jd` is true, `@jd < other.@jd` is returned directly without going through `<=>`. The slow path delegates to `super` (Comparable) to preserve all edge-case behavior including `ArgumentError` for incomparable types.
| Method | Before | After | Speedup | C impl |
|----------|-------------|-------------|---------|-------------|
| `Date#<` | ~2,430K ips | ~3,330K ips | +37% | ~7,628K ips |
Add an explicit `Date#>` method with a fast path that bypasses the `Comparable` module overhead. When both objects are simple (`@df`, `@sf`, `@of` are all `nil`), `@nth == 0`, and `@has_jd` is true, `@jd > other.@jd` is returned directly without going through `<=>`. The slow path delegates to `super` (Comparable) to preserve all edge-case behavior including `ArgumentError` for incomparable types.
| Method | Before | After | Speedup | C impl |
|----------|-------------|-------------|---------|-------------|
| `Date#>` | ~2,560K ips | ~3,330K ips | +30% | ~7,682K ips |

@nobunobu left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you share the benchmark?

As for zonetab.rb, it should be updated like as zonetab.h is automatically updated weekly with ext/date/update-abbr.

Comment threadlib/date/parse.rb Outdated
(#{ABBR_MONTHS_PATTERN})\s+
(-?\d{4})\s+
(\d{2}):(\d{2}):(\d{2})\s+
(gmt)\s*\z/ix

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
(gmt)\s*\z/ix
(gmt)\s*\z/ixo

Comment threadlib/date/strftime.rb Outdated
Comment on lines +23 to +36
# What to do if format string contains a "\0".
if format.include?("\0")
result = String.new
parts = format.split("\0", -1)

parts.each_with_index do |part, i|
result << strftime_format(part) unless part.empty?
result << "\0" if i < parts.length - 1
end

result.force_encoding(format.encoding)

return result
end

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Splitting by \0 comes from the restriction of strftime in C.
It should be unnecessary in Ruby.

Comment threadlib/date/strftime.rb Outdated
Comment on lines +176 to +179
result = String.new
i = 0

while i < format.length

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks too direct C.
format.gsub may be faster, I guess.

result=format.gsub(/(?:%%)+|%([-_^\#0]*)([1-9]\d*)?(?:E[cCxXyY]|O[deHkIlmMSuUVwWy]|[YCymBbhdejHkIlMSLNPpAawuUWVGgZsQntFDxTXRrcv+]|(:{0,3})z)/)do |fmt|
flags,width,spec,colons= $~.capturesnextfmt[0,fmt.length/2]unlessspec# Squeeze '%%' -> '%'spec=spec[-1]ifspec.length > 1# Ignore E / O modifiers

Comment threadlib/date/strftime.rb Outdated

# Width specifier overflow check
unless width.empty?
if width.length > 10 || (width.length == 10 && width > '2147483647')

@nobunobuFeb 23, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eventually width is compared with 1024, why need to compare with '2147483647'?

Suggested change
ifwidth.length > 10 || (width.length == 10 && width > '2147483647')
ifwidth.length > 4

Comment threadlib/date/strftime.rb Outdated
Comment on lines +354 to +356
sprintf("%#{prec}d", y)
else
sprintf("%0#{prec}d", y)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ruby's sprintf inherits * flag from C, you don't have to create a new format string each time.

Suggested change
sprintf("%#{prec}d",y)
else
sprintf("%0#{prec}d",y)
sprintf("%*d",prec,y)
else
sprintf("%0*d",prec,y)

feat: Optimize Date#strftime with lookup tables, fast paths, and integer flags
Summary of changes:
1. Replace string-based flag accumulation with integer bitmask
- Introduced FLAG_MINUS, FLAG_SPACE, FLAG_UPPER, FLAG_CHCASE, FLAG_ZERO
- Eliminates per-call String allocation for format modifier parsing
2. Add fast path in strftime_format for simple Date objects
- Detects simple Date (@df/@sf/@Of all nil, @nth == 0) once per call
- Bypasses tmx_* method chain for common specs (Y, m, d, A, B, etc.)
- Precomputes f_year, f_month, f_day, f_wday from instance variables
3. Add FOUR_DIGIT precomputed lookup table
- "0000".."9999" frozen string table avoids per-call sprintf for years 0..9999
- Applied to fast paths (%F/%Y-%m-%d, composite, %c) and strftime_format
4. Move TWO_DIGIT, FOUR_DIGIT, and FLAG_* constants to constants.rb
- Consolidates all Date constants in one file
---
Performance comparison (Date#strftime, Ruby 4.0.1)
Benchmark: Date#strftime (default)
C extension: 2,862,515
Pure Ruby before: 86,749
Pure Ruby after: 1,341,000
vs before: +15.5x
vs C ext: 46.8%
────────────────────────────────────────
Benchmark: Date#strftime(%Y-%m-%d)
C extension: 3,040,048
Pure Ruby before: 108,336
Pure Ruby after: 1,346,000
vs before: +12.4x
vs C ext: 44.3%
────────────────────────────────────────
Benchmark: Date#strftime(%A %B)
C extension: 2,960,230
Pure Ruby before: 83,226
Pure Ruby after: 265,000
vs before: +3.2x
vs C ext: 9.0%
────────────────────────────────────────
Benchmark: Date#strftime(%c)
C extension: 2,001,595
Pure Ruby before: 43,985
Pure Ruby after: 1,010,000
vs before: +23.0x
vs C ext: 50.5%
────────────────────────────────────────
Benchmark: Date#strftime(%x)
C extension: 2,622,940
Pure Ruby before: 89,569
Pure Ruby after: 1,438,000
vs before: +16.1x
vs C ext: 54.8%
────────────────────────────────────────
Benchmark: Date#strftime(composite)
C extension: 1,652,488
Pure Ruby before: 47,050
Pure Ruby after: 1,268,000
vs before: +27.0x
vs C ext: 76.7%
feat: Add direct fast paths to Date#iso8601, #rfc2822, and #asctime
For simple Date objects (no time/offset, @nth == 0), bypass the strftime
machinery entirely and build the result string directly using the FOUR_DIGIT
and TWO_DIGIT precomputed tables.
Changes in lib/date/core.rb:
- Date#iso8601 / #xmlschema: build "%Y-%m-%d" string directly
- Date#rfc2822 / #rfc822: build RFC 2822 string directly using ABBR_DAYNAMES,
ABBR_MONTHNAMES, FOUR_DIGIT; offset fixed to "+0000" for simple Date
- Date#asctime / #ctime: build ctime string directly with space-padded day;
uses ABBR_DAYNAMES, ABBR_MONTHNAMES, FOUR_DIGIT
- All three methods fall back to strftime for non-simple Date objects (DateTime,
objects with non-zero offset, etc.)
Performance comparison (Ruby 4.0.1, measured 2026-02-22):
Benchmark | Pure Ruby (after) | C extension | After / C ext
----------------|-------------------|---------------|---------------
Date#iso8601 | 2,420,256 | 3,983,797 | 60.8 %
Date#rfc2822 | 1,811,210 | 1,960,706 | 92.4 %
Date#asctime | 1,590,435 | 2,444,714 | 65.1 %
Unit: iterations/second (i/s). "Pure Ruby (after)" is measured with
Process.clock_gettime on Ruby 4.0.1 (docker ruby:4.0) after this change.
All three methods previously delegated to strftime, which parsed the format
string through strftime_format on every call. The new fast paths eliminate
that overhead for the common case (simple Date created via Date.new /
Date.civil). Date#rfc2822 reaches 92% of C extension performance.
feat: Optimize Date._strptime with fast path and byte-level digit scanning
Summary of changes in lib/date/strptime.rb:
1. Add fast path for '%F' / '%Y-%m-%d' (the default format)
- Parse year/mon/mday directly with a single compiled regex, bypassing
the full format-string scanner and _strptime_spec dispatch entirely.
- Note: manual byte scanning was tested for this hot path but found to be
slower than the C regex engine due to Ruby method-call overhead; the
regex-based fast path is retained.
2. Remove unnecessary str = string.dup
- The input string is read-only inside the parser; the copy was wasteful.
3. Cache fmt_len = format.length
- Avoid repeated length calls on every loop iteration.
4. Replace String-based width accumulation with integer arithmetic
- width_str = String.new + regex digit check replaced by d.ord - 48
integer accumulation; field_width is nil (not specified) or Integer.
5. Replace format whitespace regex with explicit char comparison
- format[i] =~ /\s/ replaced by direct comparison against
' ', "\t", "\n", "\r", "\v", "\f".
6. Change _strptime_spec calling convention to in-place hash modification
- Old: returns {pos: new_pos, hash: h} — allocates two hashes per spec.
- New: modifies the caller's hash directly and returns new_pos (Integer)
or nil on failure — zero extra allocations per spec.
- _strptime_composite updated to match the new convention.
7. Add scan_uint / scan_sint byte-level digit scanners
- scan_uint(str, pos, max): reads unsigned digits via getbyte, no regex,
no substring, no MatchData — returns [value, new_pos] or nil.
- scan_sint(str, pos, max): handles optional leading +/- prefix.
8. Replace regex-based matching in _strptime_spec for all numeric specifiers
- Affected: Y, C, y, m, d/e, j, H/k, I/l, M, S, L, N, w, u, U, W, V,
G, g, s, Q.
- Each str[pos..].match(/\A.../) call eliminated: no substring allocation,
no MatchData object, no regex engine overhead per numeric field.
Performance comparison (Ruby 4.0.1, measured 2026-02-22):
Benchmark | Before (i/s) | After (i/s) | C ext (i/s) | After / C ext
---------------------------|--------------|-------------|-------------|---------------
Date._strptime (default) | 40,248 | 740,864 | 2,610,013 | 28.4 %
Date.strptime (default) | 37,440 | 323,953 | 1,373,996 | 23.6 %
Date._strptime (complex) | 24,015 | 75,532 | 1,097,796 | 6.9 %
Unit: iterations/second (i/s).
"Before" is taken from bench/results/20260222/4.0.1_local.tsv (prior to
this change). "C ext" is taken from bench/results/20260222/4.0.1_system.tsv.
"After" is measured with Process.clock_gettime on ruby:4.0 (Docker) after
all changes in this commit.
The default format (Date._strptime with no explicit format argument) improves
18.4x over the baseline by hitting the '%F' fast path. The complex format
('%Y-%m-%d %H:%M:%S') improves 3.1x through the elimination of per-spec
substring and MatchData allocations via scan_uint / scan_sint.
feat: Apply StringScanner (Approach A) to Date._strptime general parser
Replace the hand-rolled position-integer loop in _strptime with a
StringScanner-based approach to eliminate redundant String allocations.
Changes:
- Add `require 'strscan'`
- Main loop: use `format.getbyte(i)` (Integer comparison) instead of
`format[i]` (String allocation) for every format character
- Literal character matching: `ss.string.getbyte(ss.pos) == fb` + `ss.pos += 1`
instead of `str[pos] == c` (String allocation per literal char)
- Whitespace skipping: `ss.skip(/[ \t\n\r\v\f]*/)` instead of a
hand-rolled while loop with per-char String comparisons
- `%p`/`%P`: `ss.scan(/a\.?m\.?|p\.?m\.?/i)` eliminates the
`str[pos..].match(/\A.../)` substring allocation
- `%n`/`%t`: `ss.skip(/\s*/)` replaces `str[pos..].match(/\A\s+/)`
- `_strptime_spec` signature: `(ss, spec, width, hash, next_is_num)`
— updates `ss.pos` in-place, returns `true`/`nil`
- `_strptime_composite` signature: `(ss, format, context_hash)`
— uses `format.getbyte(i)` and `ss.string.getbyte(ss.pos)` throughout,
returns the diff hash (or nil) rather than `{pos:, hash:}`
- The `%F`/`%Y-%m-%d` regex fast path is unchanged
Performance (500,000 iterations, ruby 4.0, linux/amd64):
| Method | C ext (i/s) | Before (i/s) | After (i/s) | % of C |
|-----------------------------|-------------|--------------|-------------|--------|
| Date._strptime (default %F) | 2,610,014 | 740,864 | 747,515 | 28.6% |
| Date.strptime (default %F) | 1,373,996 | 323,953 | 326,703 | 23.8% |
| Date._strptime (complex fmt)| 1,097,796 | 75,532 | 106,642 | 9.7% |
The default-format path shows only marginal gains because the regex
fast path (`%F`/`%Y-%m-%d`) bypasses the StringScanner loop entirely.
The complex-format path improves by ~41% over Approach C, driven by
eliminating per-character String allocations in the main parse loop.
feat: Expand _strptime fast paths for common datetime formats (Approach D)
Add direct regex fast paths for two additional format strings, bypassing
the StringScanner general parser loop entirely.
Changes:
- Refactor the existing `%F`/`%Y-%m-%d` fast path into a `case/when`
dispatch for extensibility
- Add fast path for `'%Y-%m-%d %H:%M:%S'`: matches
`/\A([+-]?\d+)-(\d{1,2})-(\d{1,2}) (\d{1,2}):(\d{1,2}):(\d{1,2})/`
and returns `{year:, mon:, mday:, hour:, min:, sec:}` directly
- Add fast path for `'%Y-%m-%dT%H:%M:%S'`: same as above with `T`
separator (ISO 8601 datetime)
- Both fast paths validate ranges (mon 1-12, mday 1-31, hour 0-24,
min 0-59, sec 0-60) and set `:leftover` if trailing input remains
Performance (500,000 iterations, ruby 4.0, linux/amd64):
| Method | C ext (i/s) | Before (i/s) | After (i/s) | % of C |
|-----------------------------------|-------------|--------------|-------------| --------|
| Date._strptime (default %F) | 2,610,014 | 747,515 | 723,600 | 27.7% |
| Date.strptime (default %F) | 1,373,996 | 326,703 | 332,490 | 24.2% |
| Date._strptime (%Y-%m-%d %H:%M:%S) | 1,097,796 | 106,642 | 544,377 | 49.6% |
The complex datetime format improves ~5x by eliminating all StringScanner
and spec-dispatch overhead for the hot `%Y-%m-%d %H:%M:%S` pattern.
The default `%F` case is unchanged in behavior (before/after difference
is within benchmark noise).
feat: Optimize constructors, accessors, and strptime internals (Phases 4–8)
Changes
- strptime internals
- Precompute NUM_PATTERN_SPECS_TABLE, STRPTIME_DAYNAME_BY_INT_KEY,
and STRPTIME_MONNAME_BY_INT_KEY in constants.rb for O(1) byte-level lookup
- Rewrite num_pattern_p with getbyte to eliminate String allocations
- Replace ss.skip(/[ \t\n\r\v\f]*/) with skip_ws byte-loop helper (3 sites)
-Julian calendar fast path
- Add integer-arithmetic Julian JD fast path in Date#initialize covering
Date::JULIAN start and pre-reform years (e.g. Date.civil(-4712, 1, 1))
- Computed accessor hot paths
- Add @jd fast path to Date#ajd, #amjd, #mjd, #ld bypassing 6-level method chain
- Arithmetic and conversion hot paths
- Add @jd fast path to Date#to_datetime (skip decode_year + c_civil_to_jd)
- Inline Date#wday as (@jd + 1) % 7
- Optimize Date#-(Date) to direct Rational(@jd - other.@jd, 1)
- Constructor ivar reduction and accessor inlining
- Reduce instance_variable_set calls in Date.jd / .ordinal / .commercial
fast paths from 11 to 4 (allocate initializes remaining ivars to nil)
- Inline early-return in Date#year, #month, #day
Performance: C extension vs pure Ruby (i/s, measured on Ruby 4.0.1)
Method | C ext | Ruby | Ruby/C
-----------------------|----------|----------|-------
Date.civil | 4,626k | 1,018k | 22%
Date.civil(neg) | 4,649k | 1,258k | 27%
Date.jd | 4,884k | 2,311k | 47%
Date.ordinal | 3,032k | 947k | 31%
Date.commercial | 2,483k | 809k | 33%
Date#year | 20,261k | 13,268k | 65%
Date#month | 21,387k | 15,796k | 74%
Date#day | 18,377k | 15,798k | 86%
Date#wday | 20,868k | 11,957k | 57%
Date#jd | 19,662k | 16,496k | 84%
Date#ajd | 7,566k | 4,643k | 61%
Date#mjd | 11,732k | 8,089k | 69%
Date#amjd | 10,574k | 5,746k | 54%
Date#ld | 11,805k | 8,216k | 70%
Date#yday | 15,685k | 19,099k | 122%
Date#cwyear | 4,494k | 6,810k | 152%
Date#cweek | 4,550k | 14,646k | 322%
Date#-Date | 1,858k | 2,327k | 125%
Date#-1 | 3,715k | 3,612k | 97%
Date#+1 | 5,516k | 3,460k | 63%
Date#to_datetime | 6,686k | 2,126k | 32%
Date#iso8601 | 3,997k | 2,505k | 63%
Date#rfc3339 | 1,801k | 2,242k | 125%
Date#rfc2822 | 1,963k | 1,687k | 86%
Date#strftime | 2,830k | 1,347k | 48%
Date._parse(iso) | 237k | 61k | 26%
Date._strptime | 2,719k | 782k | 29%
Date.strptime(complex) | 1,123k | 114k | 10%
@jinroq

Copy link
Copy Markdown
Author

@jeremyevans@nobu

Thank you for your comment! I tried optimizing Ruby implementation of date. The current benchmark results are as follows.

Performance Comparison: C Extension vs Pure Ruby Implementation

Environment: Ruby 4.0.1, measured with benchmark-ips (warmup: 1s, measurement: 2s)
(*) = Pure Ruby is faster than or equal to the C extension.

MethodC ext (i/s)Ruby (i/s)Ruby/C
Constructors
Date.civil4.65 M1.02 M21.9%
Date.civil(sg)4.46 M1.38 M31.1%
Date.civil(-1)4.68 M994.6 k21.3%
Date.civil(neg)4.56 M1.26 M27.6%
Date.jd4.96 M2.31 M46.6%
Date.ordinal3.02 M947.4 k31.3%
Date.commercial2.48 M809.0 k32.6%
Date.today176.9 k424.0 k239.6% (*)
Validation
Date.valid_civil?10.75 M2.73 M25.4%
Date.valid_civil?(false)10.96 M2.73 M24.9%
Date.valid_ordinal?4.23 M3.89 M91.9%
Date.valid_commercial?3.18 M1.20 M37.7%
Date.valid_jd?16.61 M12.10 M72.8%
Date.gregorian_leap?14.91 M9.42 M63.2%
Date.gregorian_leap?(1900)14.71 M9.03 M61.4%
Date.julian_leap?17.20 M11.81 M68.7%
Parsing
Date._parse(iso)232.8 k61.0 k26.2%
Date._parse(us)118.9 k36.1 k30.3%
Date._parse(eu)159.8 k51.9 k32.5%
Date._parse(rfc2822)81.1 k28.0 k34.5%
Date.parse(iso)212.3 k52.3 k24.6%
Date.parse(us)111.5 k31.9 k28.6%
Date.parse(eu)146.8 k44.7 k30.4%
Date.parse(compact)132.0 k47.2 k35.8%
Date._strptime2.72 M781.8 k28.7%
Date.strptime1.37 M325.9 k23.7%
Date.strptime(complex)1.11 M114.1 k10.2%
Date._iso8601723.6 k464.9 k64.2%
Date._rfc3339488.7 k117.3 k24.0%
Date._rfc2822375.8 k98.9 k26.3%
Date._xmlschema766.3 k551.8 k72.0%
Date._httpdate403.5 k267.2 k66.2%
Date._jisx0301713.4 k538.5 k75.5%
Date.iso8601551.8 k250.6 k45.4%
Date.rfc3339396.0 k100.5 k25.4%
Date.rfc2822304.3 k85.2 k28.0%
Date.xmlschema552.7 k278.4 k50.4%
Date.httpdate320.2 k175.2 k54.7%
Date.jisx0301550.4 k272.4 k49.5%
Accessors
Date#year20.24 M13.27 M65.6%
Date#month20.63 M15.80 M76.6%
Date#day18.36 M15.80 M86.0%
Date#wday20.53 M11.96 M58.2%
Date#yday15.60 M19.10 M122.4% (*)
Date#jd19.42 M16.50 M85.0%
Date#ajd7.55 M4.64 M61.5%
Date#mjd11.60 M8.09 M69.7%
Date#amjd10.45 M5.75 M55.0%
Date#ld11.54 M8.22 M71.2%
Date#start20.29 M20.37 M100.4% (*)
Date#cwyear4.49 M6.81 M151.5% (*)
Date#cweek4.56 M14.65 M321.5% (*)
Date#cwday20.34 M8.58 M42.2%
Date#leap?18.67 M16.74 M89.7%
Date#julian?19.40 M2.68 M13.8%
Date#gregorian?19.69 M2.51 M12.8%
Weekday predicates
Date#sunday?20.81 M9.52 M45.7%
Date#monday?20.78 M9.85 M47.4%
Date#saturday?20.79 M9.80 M47.1%
Arithmetic
Date#+15.89 M3.46 M58.7%
Date#+1005.52 M3.48 M63.0%
Date#-13.93 M3.61 M91.9%
Date#-Date1.88 M2.33 M124.1% (*)
Date#>>13.12 M1.84 M58.9%
Date#>>123.06 M1.84 M60.2%
Date#<<12.21 M1.86 M84.1%
Date#next_day5.41 M3.24 M59.9%
Date#prev_day3.92 M3.34 M85.2%
Date#next_month3.04 M1.77 M58.3%
Date#prev_month2.21 M1.75 M79.3%
Date#next_year2.74 M1.69 M61.7%
Date#prev_year2.03 M1.69 M83.1%
Date#succ5.57 M3.05 M54.7%
Comparison
Date#<=>11.96 M3.21 M26.9%
Date#===12.54 M3.13 M25.0%
Date#==2.76 M3.36 M121.7% (*)
Date#<7.69 M3.42 M44.5%
Date#>7.93 M3.41 M43.0%
Date#eql?11.34 M3.37 M29.7%
Date#hash13.64 M7.37 M54.0%
Iteration
Date#upto(+30)154.5 k49.2 k31.9%
Date#downto(-30)118.0 k50.1 k42.4%
Date#step(+30,7)804.9 k242.0 k30.1%
Formatting / Output
Date#to_s3.89 M1.84 M47.3%
Date#inspect548.8 k746.6 k136.0% (*)
Date#asctime2.45 M1.75 M71.2%
Date#strftime2.86 M1.35 M47.1%
Date#strftime(%Y-%m-%d)3.14 M1.54 M49.2%
Date#strftime(%A %B)3.11 M1.31 M42.1%
Date#strftime(%c)2.04 M1.19 M58.4%
Date#strftime(%x)2.68 M1.65 M61.7%
Date#strftime(composite)1.67 M1.46 M87.1%
Date#iso86014.00 M2.50 M62.6%
Date#rfc33391.90 M2.24 M118.2% (*)
Date#rfc28221.97 M1.69 M85.5%
Date#xmlschema3.96 M2.50 M63.3%
Date#httpdate1.66 M1.78 M106.9% (*)
Date#jisx03012.83 M2.04 M72.1%
Conversion & Calendar
Date#to_date22.05 M21.08 M95.6%
Date#to_datetime6.24 M2.13 M34.1%
Date#to_time1.93 M861.5 k44.6%
Date#new_start4.89 M987.8 k20.2%
Date#julian5.78 M1.19 M20.6%
Date#gregorian5.60 M1.12 M20.1%
Date#italy5.76 M1.18 M20.4%
Date#england5.89 M1.25 M21.1%
Serialization & Pattern matching
Date Marshal.dump534.4 k494.3 k92.5%
Date Marshal.load577.2 k572.9 k99.3%
Date#deconstruct_keys(nil)3.62 M3.07 M84.8%
Date#deconstruct_keys(year)5.61 M2.74 M48.9%
Date#deconstruct_keys(y/m/d)3.94 M1.64 M41.6%

Notes

Methods where pure Ruby equals or exceeds the C extension (*):

  • Date.today (239.6%): The C extension pays an extra rb_funcall overhead for Time.now; the Ruby path calls it more directly.
  • Date#yday (122.4%), Date#cwyear (151.5%), Date#cweek (321.5%): Results are memoized after the first computation. The C extension recomputes on every call.
  • Date#-Date (124.1%), Date#== (121.7%): These are hot-pathed to direct integer arithmetic (@jd subtraction / comparison), avoiding the generalized minus_dd / coercion path used in C.
  • Date#rfc3339 (118.2%), Date#httpdate (106.9%), Date#inspect (136.0%): Fast-path string construction added to the Ruby implementation outperforms the C version's general-purpose formatter.
  • Date#start (100.4%): Direct @sg ivar read; effectively the same speed.

@jeremyevans

Copy link
Copy Markdown
Contributor

In the vast majority of cases, the Ruby version is significantly slower than the C extension (2-5x in many cases, with a few cases worse). In the cases where it is faster:

  • Date.today could be implemented in Ruby even with the current extension.
  • Date.yday, Date.cwyear, Date.cweek could cache this information in the C struct, though I'm not sure whether the memory/CPU tradeoff is worth it, as usage of these methods is not nearly as common as other methods.
  • Other cases the performance difference is small, and maybe a similar approach could be used for the C extension to improve performance.

I appreciate that Ruby is easier to maintain than C, but I don't think the performance decrease here is acceptable (or even close to acceptable), considering that Date performance can be a bottleneck in application code.

jinroq added 3 commits March 2, 2026 01:17
Replace the C extension (ext/date/date_core.c, date_parse.c, date_strftime.c,
date_strptime.c) with a pure Ruby implementation while maintaining full
compatibility with the existing test suite (143 tests, 162,593 assertions).
Key implementation files:
- lib/date/core.rb: Date class with calendar conversions, arithmetic, and
comparison operators
- lib/date/datetime.rb: DateTime class with time component handling
- lib/date/parse.rb: Date parsing (_parse, _rfc3339, _httpdate, _rfc2822,
_xmlschema, _iso8601, _jisx0301) with byte-level fast paths
- lib/date/strftime.rb: strftime formatting engine
- lib/date/strptime.rb: strptime parsing engine
- lib/date/constants.rb: Consolidated constants
- lib/date/zonetab.rb: Timezone lookup table
- lib/date/time.rb: Time conversion methods
Performance optimizations:
- Byte-level fast paths for common date format parsing (ISO 8601, RFC 3339,
RFC 2822, HTTP dates)
- Integer-based JD comparison instead of Rational arithmetic
- Lazy evaluation for civil date computation and deconstruct_keys
- Inlined private method calls to reduce __send__ overhead
- O(1) boolean lookup tables replacing Array linear scans in strptime
Add ext/date/generate-zonetab-rb script that reads zonetab.list and produces
lib/date/zonetab.rb, ensuring the Ruby hash table stays in sync with the C
gperf header (zonetab.h) from the same sources.
- Update ext/date/prereq.mk to run generate-zonetab-rb after update-abbr
- Update .github/workflows/update.yml to include lib/date/zonetab.rb in the
weekly auto-commit
- Remove 75 "xxx standard time" entries from zonetab.rb that did not exist in
zonetab.list or the C extension (316 entries, matching zonetab.list exactly)
The file already has `# encoding: US-ASCII` magic comment, which makes
string literals US-ASCII by default. Additionally, `# frozen_string_literal: true`
makes .freeze unnecessary for string literals.
- Remove .encode(Encoding::US_ASCII).freeze from MONTH_DAY_SUFFIX
(keep .freeze since format() returns a new mutable string)
- Remove .encode(Encoding::US_ASCII).freeze from DEFAULT_STRFTIME_FMT
- Remove .encode(Encoding::US_ASCII).freeze from YMD_FMT
@jinroq

Copy link
Copy Markdown
Author

@jeremyevans
Further optimizations have been made. Please see the benchmark here.

@jinroq

Copy link
Copy Markdown
Author

@nobu

As for zonetab.rb, it should be updated like as zonetab.h is automatically updated weekly with ext/date/update-abbr.

d276c80 has been fixed.

@jinroq
jinroq requested a review from nobuMarch 1, 2026 18:21
@jeremyevans

Copy link
Copy Markdown
Contributor

@jeremyevans Further optimizations have been made. Please see the benchmark here.

The numbers are looking much better. Common actions (Date.civil) are still over 2x slower, though. I don't see benchmarks for Date.new, which I'm guessing is the most common way to instantiate Date objects.

What is ObjectSpace.memsize_of(Date.new) in the new implementation (it's 72 with the current implementation)?

@jinroq

Copy link
Copy Markdown
Author

@jeremyevans

Date.new benchmark (Ruby 4.0.1)

Date.new uses Class#newinitialize, while Date.civil is a direct class method. Here are the benchmark results:

MethodC ext (i/s)Pure Ruby (i/s)Ratio
Date.new3,974,2171,531,11938.5%
Date.new(no args)4,509,7311,415,43431.4%

ObjectSpace.memsize_of(Date.new(2024, 1, 1))

RubyImplementationmemsize (bytes)
2.6.10C ext72
2.7.8C ext72
3.0.7C ext72
3.1.7C ext72
3.2.10C ext72
3.3.10C ext72
3.3.10Pure Ruby80
3.4.8C ext72
3.4.8Pure Ruby80
4.0.1C ext72
4.0.1Pure Ruby80

The pure Ruby implementation adds 8 bytes (72 → 80) per Date object. The C extension uses a fixed-size struct, while the pure Ruby version stores
instance variables (@jd, @sg, @df) whose object header accounts for the difference.

Skip Class#new -> allocate -> initialize overhead by defining Date.new
as a class method that delegates to Date.civil. Also replace rescue-based
type guard with direct Integer === checks to eliminate rescue frame cost.
Add DateTime.new override to prevent infinite recursion from inheritance.
Performance comparison (Date.new):
| Method | C ext (i/s) | Pure Ruby Before (i/s) | Pure Ruby After (i/s) | vs C ext |
|------------|---------------|------------------------|-----------------------|----------|
| Date.new | 4,648,499 | 1,531,119 (32.9%) | 2,011,298 (43.3%) | 43.3% |
| Date.civil | 4,648,499 | 2,137,457 (46.0%) | 2,118,545 (45.6%) | 45.6% |
@jinroq

Copy link
Copy Markdown
Author

@jeremyevans

I also included benchmarks for Ruby + YJIT, which show sufficient results with YJIT enabled.

MethodC ext (i/s)Ruby (i/s)vs C extRuby+YJIT (i/s)vs C ext
Date.new4,648,498.92,036,150.643.8%6,800,694.4146.3%
Date.civil4,648,498.92,127,289.045.8%7,054,147.8151.8%
Date.civil(sg)4,455,619.71,949,044.543.7%6,062,139.0136.1%
Date.civil(-1)4,675,053.41,626,856.634.8%5,440,817.9116.4%
Date.civil(neg)4,563,715.31,973,021.143.2%6,551,825.2143.6%
Date.jd4,962,000.53,534,728.071.2%8,943,396.9180.2%
Date.ordinal3,023,006.51,888,939.462.5%7,674,517.3253.9%
Date.commercial2,478,941.01,668,324.967.3%6,896,332.2278.2%
Date.today176,946.6390,025.4220.4%485,024.0274.1%
Date.valid_civil?10,749,223.31,328,167.212.4%6,908,534.864.3%
Date.valid_civil?(false)10,961,294.31,762,002.316.1%7,834,300.671.5%
Date.valid_ordinal?4,233,714.41,350,027.031.9%6,867,174.2162.2%
Date.valid_commercial?3,177,114.9488,591.415.4%2,387,213.475.1%
Date.valid_jd?16,608,826.210,043,032.060.5%24,574,453.1148.0%
Date.gregorian_leap?14,913,745.36,826,828.245.8%20,989,836.5140.7%
Date.gregorian_leap?(1900)14,713,170.56,365,132.843.3%20,003,406.2136.0%
Date.julian_leap?17,203,164.67,938,256.046.1%23,782,084.2138.2%
Date._parse(iso)232,827.91,294,271.7555.9%3,292,066.31413.9%
Date._parse(us)118,925.3545,573.5458.8%661,336.2556.1%
Date._parse(eu)159,775.1475,354.0297.5%564,690.0353.4%
Date._parse(rfc2822)81,117.7215,561.9265.7%290,587.7358.2%
Date.parse(iso)212,254.8613,834.4289.2%1,921,631.3905.3%
Date.parse(us)111,533.3345,410.8309.7%542,995.9486.8%
Date.parse(eu)146,786.8309,203.1210.6%466,989.1318.1%
Date.parse(compact)132,038.9619,357.0469.1%1,981,781.41500.9%
Date._strptime2,721,888.71,177,709.343.3%2,147,098.178.9%
Date.strptime1,374,554.3800,628.758.2%2,014,174.4146.5%
Date.strptime(complex)1,114,730.3338,797.630.4%1,955,386.3175.4%
Date._iso8601723,572.81,368,965.6189.2%4,485,893.6620.0%
Date._rfc3339488,706.5583,054.6119.3%2,056,642.5420.8%
Date._rfc2822375,804.7507,272.7135.0%1,272,314.2338.6%
Date._xmlschema766,287.81,367,530.2178.5%4,572,233.9596.7%
Date._httpdate403,521.7548,276.9135.9%1,247,514.5309.2%
Date._jisx0301713,384.01,196,125.4167.7%2,997,525.2420.2%
Date.iso8601551,797.8627,678.5113.8%1,968,689.2356.8%
Date.rfc3339395,958.4384,319.297.1%1,130,586.8285.5%
Date.rfc2822304,311.2342,968.4112.7%826,264.6271.5%
Date.xmlschema552,686.0617,598.2111.7%2,020,113.2365.5%
Date.httpdate320,236.4363,580.0113.5%857,760.5267.9%
Date.jisx0301550,356.0581,997.6105.7%1,589,031.8288.7%
Date#year20,235,535.810,774,110.853.2%27,655,324.0136.7%
Date#month20,625,631.910,796,911.752.3%28,156,488.9136.5%
Date#day18,362,005.810,705,688.458.3%27,045,512.4147.3%
Date#wday20,530,546.411,000,804.253.6%24,403,360.2118.9%
Date#yday15,601,172.012,876,713.882.5%27,694,425.0177.5%
Date#jd19,418,473.517,071,262.087.9%29,569,337.8152.3%
Date#ajd7,553,399.44,298,369.656.9%5,944,814.278.7%
Date#mjd11,597,881.317,084,185.2147.3%28,895,735.6249.1%
Date#amjd10,449,017.71,815,580.617.4%2,398,321.623.0%
Date#ld11,537,190.016,983,669.5147.2%29,047,769.7251.8%
Date#start20,286,534.517,836,924.187.9%29,304,177.3144.5%
Date#cwyear4,493,912.416,430,249.9365.6%28,214,927.1627.8%
Date#cweek4,555,659.716,061,004.7352.6%28,960,883.3635.7%
Date#cwday20,343,305.68,371,271.941.2%23,049,924.0113.3%
Date#leap?18,667,588.18,036,097.543.0%22,004,473.1117.9%
Date#julian?19,396,307.18,110,830.041.8%23,137,616.8119.3%
Date#gregorian?19,689,574.99,558,623.948.5%25,319,534.9128.6%
Date#sunday?20,810,650.79,030,532.643.4%21,230,372.1102.0%
Date#monday?20,777,318.68,922,244.842.9%23,217,857.0111.7%
Date#saturday?20,790,164.48,963,911.443.1%23,168,896.6111.4%
Date#+15,893,233.72,889,182.849.0%5,040,998.685.5%
Date#+1005,520,702.92,937,903.553.2%5,033,091.291.2%
Date#-13,929,555.62,597,216.766.1%5,340,599.7135.9%
Date#-Date1,875,155.21,972,961.1105.2%3,117,628.6166.3%
Date#>>13,119,218.31,613,270.351.7%4,226,230.4135.5%
Date#>>123,061,617.71,631,701.953.3%4,189,177.5136.8%
Date#<<12,206,893.61,547,786.770.1%4,157,267.8188.4%
Date#next_day5,407,206.02,700,424.449.9%4,981,497.392.1%
Date#prev_day3,923,672.92,535,334.764.6%4,917,461.8125.3%
Date#next_month3,038,007.51,574,504.951.8%4,099,210.9134.9%
Date#prev_month2,207,619.71,497,764.967.8%4,114,609.8186.4%
Date#next_year2,736,947.81,566,210.457.2%4,085,857.7149.3%
Date#prev_year2,028,662.21,417,257.969.9%4,171,630.4205.6%
Date#succ5,569,330.62,717,558.248.8%5,286,740.794.9%
Date#<=>11,961,484.46,609,541.355.3%21,053,740.7176.0%
Date#===12,543,036.55,541,720.844.2%19,983,408.5159.3%
Date#==2,760,650.47,322,420.6265.2%22,861,312.3828.1%
Date#<7,685,771.46,455,497.084.0%20,481,215.1266.5%
Date#>7,929,852.66,473,788.781.6%21,216,662.3267.6%
Date#eql?11,335,321.97,930,323.270.0%24,341,834.0214.7%
Date#hash13,639,648.110,546,833.377.3%16,202,422.0118.8%
Date#upto(+30)154,548.8134,861.887.3%236,819.6153.2%
Date#downto(-30)118,018.3132,404.6112.2%243,172.5206.0%
Date#step(+30,7)804,863.4687,218.285.4%1,316,888.1163.6%
Date#to_s3,889,377.73,769,828.996.9%6,604,270.8169.8%
Date#inspect548,810.91,187,608.2216.4%1,706,568.4311.0%
Date#asctime2,452,656.11,505,310.961.4%2,201,277.989.8%
Date#strftime2,863,406.83,332,272.9116.4%6,691,922.9233.7%
Date#strftime(%Y-%m-%d)3,138,482.22,817,965.789.8%4,125,145.7131.4%
Date#strftime(%A %B)3,109,861.11,553,015.949.9%2,028,742.365.2%
Date#strftime(%c)2,041,797.71,323,864.464.8%1,765,369.886.5%
Date#strftime(%x)2,676,711.21,903,867.771.1%2,382,801.789.0%
Date#strftime(composite)1,671,366.51,613,957.396.6%2,224,556.5133.1%
Date#iso86013,999,850.63,450,501.386.3%6,286,520.0157.2%
Date#rfc33391,896,360.72,111,494.2111.3%3,381,402.3178.3%
Date#rfc28221,974,373.51,593,782.980.7%2,289,653.2116.0%
Date#xmlschema3,955,976.03,500,299.988.5%6,337,757.0160.2%
Date#httpdate1,663,870.81,779,108.2106.9%2,700,540.9162.3%
Date#jisx03012,833,069.02,351,477.283.0%3,091,380.4109.1%
Date#to_date22,050,815.118,402,764.783.5%31,789,222.2144.2%
Date#to_datetime6,244,339.9296,676.24.8%715,839.511.5%
Date#to_time1,933,709.8471,209.124.4%539,343.527.9%
Date#new_start4,889,348.63,343,094.768.4%4,996,316.0102.2%
Date#julian5,778,390.63,171,427.154.9%4,960,678.085.8%
Date#gregorian5,599,171.93,188,930.457.0%4,961,054.688.6%
Date#italy5,762,239.83,251,144.156.4%5,045,699.587.6%
Date#england5,893,742.23,233,278.254.9%4,987,572.984.6%
Date Marshal.dump534,350.2545,965.0102.2%583,518.2109.2%
Date Marshal.load577,187.8563,082.597.6%626,781.8108.6%
Date#deconstruct_keys(nil)3,618,972.73,023,491.283.5%4,757,871.6131.5%
Date#deconstruct_keys(year)5,607,895.33,859,001.168.8%6,029,250.7107.5%
Date#deconstruct_keys(y/m/d)3,942,984.91,667,445.042.3%3,673,872.793.2%
DateTime.civil1,851,893.5313,100.216.9%720,137.638.9%
DateTime.jd1,889,075.3554,991.029.4%1,067,002.256.5%
DateTime.ordinal1,508,769.7536,343.435.5%1,514,865.1100.4%
DateTime.commercial1,351,171.4311,674.323.1%1,106,892.281.9%
DateTime.now139,518.4287,473.0206.0%365,739.0262.1%
DateTime.parse(iso)84,713.933,538.639.6%48,763.157.6%
DateTime.parse(rfc2822)74,627.0121,937.2163.4%209,317.4280.5%
DateTime.strptime394,530.981,962.520.8%309,988.478.6%
DateTime.iso8601355,014.9127,652.636.0%246,696.069.5%
DateTime.rfc3339379,544.4204,046.453.8%616,887.1162.5%
DateTime.rfc2822284,987.5180,603.663.4%467,337.6164.0%
DateTime.xmlschema358,991.2142,858.939.8%259,931.072.4%
DateTime.httpdate298,051.0196,967.166.1%500,106.0167.8%
DateTime.jisx0301345,210.8132,675.838.4%256,861.474.4%
DateTime#year19,881,470.711,071,056.155.7%26,023,439.7130.9%
DateTime#month20,924,763.412,215,448.758.4%25,207,601.2120.5%
DateTime#day18,785,995.512,171,972.864.8%25,542,575.0136.0%
DateTime#hour21,502,513.912,824,548.259.6%28,950,420.8134.6%
DateTime#min18,485,836.512,626,620.668.3%28,900,737.2156.3%
DateTime#sec20,797,882.512,742,817.061.3%29,335,953.4141.1%
DateTime#sec_fraction10,200,271.912,706,458.8124.6%29,223,527.0286.5%
DateTime#offset9,829,985.55,084,467.551.7%6,634,926.167.5%
DateTime#zone3,207,822.51,182,995.536.9%1,448,983.945.2%
DateTime#wday20,168,567.715,619,425.477.4%24,279,235.7120.4%
DateTime#yday14,700,414.112,912,832.287.8%25,935,006.8176.4%
DateTime#jd19,103,691.917,924,629.093.8%21,115,737.4110.5%
DateTime#ajd2,100,589.4757,860.436.1%969,689.346.2%
DateTime#+15,385,075.71,477,393.327.4%4,128,308.876.7%
DateTime#+frac292,699.0427,903.8146.2%705,517.6241.0%
DateTime#-14,006,702.81,402,167.435.0%3,811,043.995.1%
DateTime#-DT1,316,024.9335,266.825.5%480,147.536.5%
DateTime#>>12,915,200.61,573,828.754.0%3,767,400.8129.2%
DateTime#<<12,091,904.21,534,607.373.4%3,659,443.7174.9%
DateTime#next_day5,282,096.71,440,511.127.3%4,050,846.176.7%
DateTime#prev_day3,924,083.21,375,942.935.1%3,960,172.7100.9%
DateTime#next_month2,789,292.91,532,713.454.9%3,670,986.2131.6%
DateTime#prev_month2,052,433.31,445,021.470.4%3,661,189.0178.4%
DateTime#next_year2,672,709.21,508,662.356.4%3,683,351.2137.8%
DateTime#prev_year1,970,385.21,408,942.871.5%3,720,693.5188.8%
DateTime#<=>12,133,082.26,597,448.754.4%19,160,491.0157.9%
DateTime#===11,302,900.64,964,157.543.9%16,864,706.3149.2%
DateTime#==2,746,062.57,161,663.6260.8%20,932,614.6762.3%
DateTime#eql?11,282,592.67,817,051.769.3%24,241,577.9214.9%
DateTime#hash13,883,068.16,540,332.447.1%12,099,219.787.2%
DateTime#to_s1,940,594.7192,016.19.9%484,311.325.0%
DateTime#inspect470,005.4169,330.036.0%386,114.982.2%
DateTime#strftime1,531,900.0273,764.317.9%537,769.335.1%
DateTime#strftime(%Y%m%d%z)1,681,470.3898,867.153.5%1,371,085.381.5%
DateTime#strftime(%c)1,998,483.9944,923.647.3%1,277,656.463.9%
DateTime#strftime(%s)3,244,638.4896,356.527.6%1,782,512.854.9%
DateTime#iso86011,367,474.5191,089.614.0%491,047.435.9%
DateTime#rfc33391,374,890.5191,139.413.9%500,719.336.4%
DateTime#rfc28221,905,864.71,093,096.557.4%1,637,555.285.9%
DateTime#xmlschema1,380,004.6195,651.514.2%497,945.636.1%
DateTime#httpdate1,666,736.71,181,090.270.9%1,772,252.2106.3%
DateTime#jisx03011,184,511.6425,241.935.9%531,411.144.9%
DateTime#new_offset(0)5,652,278.91,014,871.018.0%2,707,830.847.9%
DateTime#new_offset(str)3,782,946.8422,249.211.2%758,318.220.0%
DateTime#new_offset(rat)2,332,643.4989,378.942.4%2,375,151.0101.8%
DateTime#to_date6,722,539.62,827,305.642.1%7,816,381.4116.3%
DateTime#to_datetime21,865,006.018,708,595.085.6%31,341,332.2143.3%
DateTime#to_time910,402.1594,570.565.3%1,092,081.2120.0%
DateTime Marshal.dump529,753.9335,623.463.4%359,797.767.9%
DateTime Marshal.load563,611.5310,872.255.2%345,545.961.3%
DateTime#deconstruct_keys(nil)901,133.5606,273.567.3%708,956.378.7%
DateTime#deconstruct_keys(y/h)4,464,776.52,094,465.946.9%4,119,094.492.3%
Time#to_date3,868,352.11,509,377.039.0%5,625,967.0145.4%
Time#to_datetime1,216,319.1964,642.179.3%2,239,412.0184.1%

@jeremyevans

Copy link
Copy Markdown
Contributor

I hadn't realized the earlier benchmark numbers were without YJIT. Anyway who cares about performance is going to enable YJIT, and as the numbers with YJIT are overall significantly faster, I don't see any barriers to merging this. Thank you very much for your work on this.

jinroq added 2 commits March 2, 2026 16:22
Replace all getbyte-based byte manipulation with StringScanner and regex
patterns across parse.rb, strftime.rb, and strptime.rb for improved
readability and maintainability.
Key changes:
- Replace getbyte loops with StringScanner#scan/skip and regex patterns
- Optimize strptime fast paths using match? + byteslice instead of
StringScanner allocation
- Add 17 pre-compiled regex constants for YJIT inline cache efficiency
- Inline sp_digits_sc and sp_num_p? helper methods
- Replace hash[:_fail] error propagation with throw/catch(:sp_fail)
- Extract compute_3key into lib/date/shared.rb
Performance (iterations/s, pure Ruby + YJIT vs C ext + YJIT):
| Benchmark | C ext+YJIT | pure Ruby+YJIT | Ratio |
|------------------------|-------------|-----------------|-------|
| Date._strptime | 2,263,815 | 1,284,553 | 57% |
| Date.strptime | 1,156,519 | 1,189,801 | 103% |
| Date.strptime(complex) | 905,983 | 424,281 | 47% |
@jinroq

Copy link
Copy Markdown
Author

@nobu@jeremyevans

Replace all getbyte-based byte manipulation with StringScanner and regex patterns for improved readability and maintainability.
In exchange, the performance of Date._strptime and Date.strptime has decreased. Please give us your feedback on this.

BenchmarkC ext+YJITpure Ruby+YJITRatio
Date._strptime2,263,8151,284,55357%
Date.strptime1,156,5191,189,801103%
Date.strptime(complex)905,983424,28147%

Here are benchmarks for all Date methods:

MethodC extC ext+YJITpure Rubypure Ruby+YJITRuby+YJIT vs C+YJIT
Date.new3.6M4.5M1.8M5.7M129%
Date.civil3.8M3.7M1.6M5.5M147%
Date.civil(sg)3.0M3.2M1.7M5.2M162%
Date.civil(-1)3.4M4.2M1.6M4.7M110%
Date.civil(neg)3.4M4.0M1.6M5.9M147%
Date.jd3.4M4.1M3.0M7.4M181%
Date.ordinal2.3M2.5M1.7M6.7M263%
Date.commercial2.0M2.2M1.5M5.9M265%
Date.today335.1k343.5k430.2k472.7k138%
Date.valid_civil?8.4M10.7M1.2M6.1M57%
Date.valid_civil?(false)8.3M12.5M1.6M7.0M56%
Date.valid_ordinal?3.5M4.0M1.3M6.2M152%
Date.valid_commercial?2.8M3.0M435.2k2.1M71%
Date.valid_jd?12.7M19.6M8.7M20.5M104%
Date.gregorian_leap?11.7M19.4M5.9M15.4M79%
Date.gregorian_leap?(1900)11.7M19.7M5.7M17.3M88%
Date.julian_leap?11.3M19.5M7.2M20.2M104%
Date._parse(iso)182.9k191.6k461.4k546.6k285%
Date._parse(us)92.6k96.1k449.9k566.0k589%
Date._parse(eu)120.5k123.1k403.2k491.5k399%
Date._parse(rfc2822)60.3k60.0k142.7k165.9k276%
Date.parse(iso)162.8k165.3k300.3k490.4k297%
Date.parse(us)87.1k87.6k293.7k467.0k533%
Date.parse(eu)113.6k114.4k267.9k409.9k358%
Date.parse(compact)104.4k106.2k290.8k457.5k431%
Date._strptime2.2M2.3M860.4k1.3M57%
Date.strptime1.1M1.2M614.1k1.2M103%
Date.strptime(complex)921.1k906.0k254.7k424.3k47%
Date._iso8601534.1k575.7k462.8k578.6k100%
Date._rfc3339388.4k385.2k190.5k308.9k80%
Date._rfc2822265.2k221.9k153.2k232.5k105%
Date._xmlschema560.4k514.9k379.7k438.9k85%
Date._httpdate245.4k237.5k170.4k211.0k89%
Date._jisx0301376.2k470.2k315.8k383.7k82%
Date.iso8601410.8k404.9k258.3k439.5k109%
Date.rfc3339304.0k267.7k167.7k228.1k85%
Date.rfc2822209.6k162.9k119.9k194.9k120%
Date.xmlschema351.0k299.3k227.3k366.8k123%
Date.httpdate208.7k168.2k121.0k207.7k123%
Date.jisx0301299.5k342.6k234.8k329.2k96%
Date#year9.9M14.6M7.0M18.0M124%
Date#month9.8M15.7M8.4M15.8M101%
Date#day11.3M19.4M7.4M13.1M67%
Date#wday13.6M15.7M8.2M15.2M97%
Date#yday7.7M14.3M11.3M17.6M122%
Date#jd11.3M16.7M12.9M22.7M136%
Date#ajd4.7M6.3M3.6M5.0M78%
Date#mjd8.1M8.7M14.3M24.1M277%
Date#amjd5.7M7.6M1.6M2.2M29%
Date#ld7.9M11.5M14.9M24.2M210%
Date#start15.4M23.0M15.2M25.0M108%
Date#cwyear3.8M4.3M15.0M25.0M589%
Date#cweek3.9M4.2M14.6M24.5M577%
Date#cwday15.6M23.4M7.5M18.4M79%
Date#leap?12.1M16.8M7.2M19.5M116%
Date#julian?13.1M21.0M7.4M19.7M94%
Date#gregorian?13.1M18.9M7.9M20.7M109%
Date#sunday?15.1M20.2M6.9M16.4M81%
Date#monday?13.7M17.7M7.2M17.1M97%
Date#saturday?12.0M18.9M7.7M14.0M74%
Date#+14.9M5.7M2.4M3.9M69%
Date#+1004.7M5.4M2.4M4.4M81%
Date#-13.1M3.6M2.3M4.1M113%
Date#-Date1.5M1.5M1.8M2.9M187%
Date#>>12.5M2.6M1.4M3.2M123%
Date#>>122.4M2.3M1.5M3.6M154%
Date#<<11.7M1.9M1.3M3.4M184%
Date#next_day4.8M5.8M2.3M4.2M73%
Date#prev_day3.4M3.7M2.2M4.3M115%
Date#next_month2.5M2.6M1.3M3.5M136%
Date#prev_month1.7M1.7M1.3M3.5M208%
Date#next_year2.1M2.4M1.4M3.7M152%
Date#prev_year1.6M1.8M1.3M3.4M193%
Date#succ5.1M6.1M2.4M4.6M75%
Date#<=>8.1M10.9M5.8M19.1M175%
Date#===8.3M12.9M5.0M17.2M133%
Date#==2.2M2.4M6.6M20.8M851%
Date#<6.0M7.0M5.9M14.8M210%
Date#>6.0M7.1M5.9M18.9M265%
Date#eql?8.0M10.5M7.2M21.8M207%
Date#hash10.3M16.8M9.6M14.8M88%
Date#upto(+30)134.1k139.9k112.8k184.6k132%
Date#downto(-30)95.9k99.0k116.7k194.0k196%
Date#step(+30,7)684.4k781.9k610.1k1.1M145%
Date#to_s3.1M3.4M3.4M6.1M180%
Date#inspect486.5k495.3k1.1M1.6M320%
Date#asctime1.9M2.0M1.3M2.0M99%
Date#strftime2.4M2.6M3.1M6.1M235%
Date#strftime(%Y-%m-%d)2.6M2.8M2.6M3.8M133%
Date#strftime(%A %B)2.4M2.6M1.1M1.7M64%
Date#strftime(%c)1.3M1.6M864.8k1.5M93%
Date#strftime(%x)1.5M2.3M1.7M2.2M94%
Date#strftime(composite)1.4M1.3M1.5M2.0M152%
Date#iso86012.8M3.5M3.3M6.0M172%
Date#rfc33391.5M1.6M2.0M3.2M203%
Date#rfc28221.6M1.6M1.5M2.1M129%
Date#xmlschema3.1M3.4M3.2M5.9M172%
Date#httpdate1.3M1.3M1.7M2.3M173%
Date#jisx03012.2M2.5M2.1M2.8M115%
Date#to_date17.0M25.8M16.2M17.4M67%
Date#to_datetime5.8M5.5M272.0k652.0k12%
Date#to_time471.9k441.7k427.6k483.9k110%
Date#new_start4.9M5.7M2.8M4.0M70%
Date#julian5.2M5.9M2.7M3.9M67%
Date#gregorian5.3M5.9M2.7M3.8M65%
Date#italy5.1M6.4M2.7M3.9M62%
Date#england5.2M6.1M2.8M4.3M71%
Date Marshal.dump461.3k457.4k485.4k531.5k116%
Date Marshal.load496.3k489.1k493.4k559.2k114%
Date#deconstruct_keys(nil)2.6M2.7M2.5M3.4M127%
Date#deconstruct_keys(year)3.6M3.8M3.2M4.8M126%
Date#deconstruct_keys(y/m/d)2.8M3.0M1.4M2.5M82%
DateTime.civil1.4M1.6M266.5k620.5k40%
DateTime.jd1.5M1.6M487.9k834.8k51%
DateTime.ordinal1.2M1.3M438.4k1.4M111%
DateTime.commercial1.0M1.2M294.5k1.0M85%
DateTime.now243.9k251.4k314.2k351.6k140%
DateTime.parse(iso)69.4k69.0k28.1k40.0k58%
DateTime.parse(rfc2822)57.8k59.9k91.2k141.4k236%
DateTime.strptime311.7k309.3k36.1k54.2k18%
DateTime.iso8601294.2k291.6k96.1k155.3k53%
DateTime.rfc3339304.2k312.4k126.1k204.3k65%
DateTime.rfc2822211.0k208.7k107.6k180.1k86%
DateTime.xmlschema282.7k288.6k87.3k148.3k51%
DateTime.httpdate208.3k214.0k116.0k184.9k86%
DateTime.jisx0301259.6k233.7k92.2k156.4k67%
DateTime#year13.4M21.6M10.0M17.7M82%
DateTime#month13.0M24.7M11.3M22.2M90%
DateTime#day16.0M24.6M11.0M20.9M85%
DateTime#hour16.0M22.9M11.4M18.9M83%
DateTime#min15.8M22.8M11.2M24.9M110%
DateTime#sec15.5M24.1M11.4M23.3M96%
DateTime#sec_fraction7.7M9.6M10.9M22.9M237%
DateTime#offset5.8M9.1M4.5M6.6M73%
DateTime#zone2.5M2.8M1.0M1.3M46%
DateTime#wday14.3M20.2M13.1M19.5M97%
DateTime#yday10.3M14.4M11.3M19.3M134%
DateTime#jd13.4M18.4M13.5M22.0M120%
DateTime#ajd1.5M1.7M638.8k916.6k54%
DateTime#+15.3M6.0M1.3M3.8M63%
DateTime#+frac236.9k247.4k359.4k640.7k259%
DateTime#-13.5M3.2M1.2M3.5M111%
DateTime#-DT1.1M1.1M320.8k451.8k41%
DateTime#>>12.6M2.7M1.5M3.5M130%
DateTime#<<11.8M1.9M1.4M3.3M176%
DateTime#next_day5.3M5.9M1.3M3.4M58%
DateTime#prev_day3.6M3.8M1.2M3.5M91%
DateTime#next_month2.5M2.6M1.4M3.5M133%
DateTime#prev_month1.8M1.9M1.4M3.3M169%
DateTime#next_year2.3M2.4M1.4M3.2M135%
DateTime#prev_year1.7M1.7M1.3M3.5M200%
DateTime#<=>9.7M11.5M6.2M16.9M147%
DateTime#===8.3M11.0M4.6M16.4M149%
DateTime#==2.4M2.6M6.7M18.5M715%
DateTime#eql?8.8M10.8M7.3M21.9M203%
DateTime#hash10.5M16.7M6.2M11.3M67%
DateTime#to_s1.6M1.6M88.5k121.5k7%
DateTime#inspect428.9k398.6k63.8k115.4k29%
DateTime#strftime1.3M1.2M134.9k181.7k15%
DateTime#strftime(%Y%m%d%z)1.4M1.5M796.5k1.3M88%
DateTime#strftime(%c)1.7M1.8M832.8k1.2M68%
DateTime#strftime(%s)2.7M3.0M455.0k590.7k20%
DateTime#iso86011.1M1.2M85.9k121.5k10%
DateTime#rfc33391.1M1.2M86.2k119.6k10%
DateTime#rfc28221.5M1.6M992.8k1.5M96%
DateTime#xmlschema1.2M1.2M86.6k120.7k10%
DateTime#httpdate1.3M1.4M1.1M1.7M119%
DateTime#jisx03011.0M1.1M394.3k496.0k47%
DateTime#new_offset(0)4.9M6.0M922.9k2.4M40%
DateTime#new_offset(str)3.7M3.9M356.0k572.8k15%
DateTime#new_offset(rat)2.0M1.6M943.9k2.1M128%
DateTime#to_date5.7M5.6M2.7M7.0M126%
DateTime#to_datetime10.9M17.3M17.0M29.6M172%
DateTime#to_time588.6k721.2k554.6k997.2k138%
DateTime Marshal.dump463.1k463.7k310.5k324.9k70%
DateTime Marshal.load488.9k487.0k291.2k328.3k67%
DateTime#deconstruct_keys(nil)761.7k688.3k528.7k598.2k87%
DateTime#deconstruct_keys(y/h)3.4M3.6M1.9M3.6M99%
Time#to_date3.3M3.6M1.4M5.2M144%
Time#to_datetime1.1M1.0M983.7k2.2M209%

Comment threadlib/date/core.rb Outdated
Comment on lines +16 to +24
if day >= 1 && day <= 28
gy = month > 2 ? year : year - 1
gjd_base = (1461 * (gy + 4716)) / 4 + GJD_MONTH_OFFSET[month] + day
a = gy / 100
jd_julian = gjd_base - 1524
gjd = jd_julian + 2 - a + a / 4
obj = allocate
obj.__send__(:init_from_jd, gjd >= start ? gjd : jd_julian, start)
return obj

@rheniumrheniumMar 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although I haven't worked on date and am unfamiliar with it, I think we should be cautious about the amount of code inlining/duplication in this patch. This particular fragment is duplicated more than 10 times, whereas it appeared only once in the C implementation (c_civil_to_jd() in date_core.c, I think).

More generally, I worry that a mechanical conversion combined with this kind of micro-optimization may make the codebase less maintainable for humans.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rhenium
Thank you for your feedback. As you pointed out, we were over-optimizing by inlining. 7e80db9 has reduced inlining.

@jeremyevans

Copy link
Copy Markdown
Contributor

Replace all getbyte-based byte manipulation with StringScanner and regex patterns for improved readability and maintainability.
In exchange, the performance of Date._strptime and Date.strptime has decreased. Please give us your feedback on this.

In general, getbyte-style optimizations should be used sparingly. If we are going to convert from C to Ruby, we should be converting to idiomatic Ruby as much as possible. The decrease looks pretty substantial, though. Maybe I should do a review of the code and see how maintainable the Ruby code actually looks.

More pressing is the issue that @rhenium mentioned about duplicated code. We want to make sure the Ruby code is as well factored as the C code, or at least close. Duplicating code from a method and inlining it into every caller location is not good.

Comment threadext/date/generate-zonetab-rb Outdated
next unless offset_expr

abbr.strip!
offset_expr.strip!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to strip, as eval skips leading/trailing spaces.

Suggested change
offset_expr.strip!

Comment threadext/date/generate-zonetab-rb Outdated
entries[abbr] = offset
end

sorted = entries.sort_by { |k, _| k }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
sorted = entries.sort_by { |k, _| k }
sorted = entries.sort

Comment threadext/date/generate-zonetab-rb Outdated
Comment on lines +52 to +53
key = abbr.include?(' ') ? %Q("#{abbr}") : %Q("#{abbr}")
f.puts " %-#{max_key_len + 3}s => %d," % [key, offset]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both sides look the same expression.

Suggested change
key = abbr.include?(' ') ? %Q("#{abbr}") : %Q("#{abbr}")
f.puts " %-#{max_key_len + 3}s => %d," % [key, offset]
f.printf " %-*p => %d,\n", max_key_len + 3, abbr, offset

Comment threadext/date/generate-zonetab-rb Outdated
Comment on lines +15 to +16
File.foreach(list_path) do |line|
line.chomp!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
File.foreach(list_path) do |line|
line.chomp!
File.foreach(list_path, chomp: true) do |line|

Comment threadext/date/generate-zonetab-rb Outdated
Comment on lines +13 to +25
in_entries = false

File.foreach(list_path) do |line|
line.chomp!
if line == '%%'
if in_entries
break
else
in_entries = true
next
end
end
next unless in_entries

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
in_entries = false
File.foreach(list_path) do |line|
line.chomp!
if line == '%%'
if in_entries
break
else
in_entries = true
next
end
end
next unless in_entries
sections = 0
File.foreach(list_path) do |line|
line.chomp!
break if line == '%%' and (sections += 1) > 1
next if sections < 1

Comment threadext/date/generate-zonetab-rb Outdated
f.puts '# frozen_string_literal: true'
f.puts
f.puts '# Timezone name => UTC offset (seconds) mapping table.'
f.puts '# Auto-generated from ext/date/zonetab.list by ext/date/generate-zonetab-rb.'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
f.puts '# Auto-generated from ext/date/zonetab.list by ext/date/generate-zonetab-rb.'
f.puts "# Auto-generated from #{list_path} by ext/date/generate-zonetab-rb."

Comment threadlib/date/shared.rb Outdated
Comment on lines +9 to +10
b = s.bytes
((b[0] | 0x20) << 16) | ((b[1] | 0x20) << 8) | (b[2] | 0x20)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
b=s.bytes
((b[0] | 0x20) << 16) | ((b[1] | 0x20) << 8) | (b[2] | 0x20)
b0,b1,b2=s.unpack("C3")
((b0 | 0x20) << 16) | ((b1 | 0x20) << 8) | (b2 | 0x20)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion. s.unpack("C3") is functionally equivalent here, and I agree it expresses the "exactly 3 bytes" intent more clearly. However, compute_3key is called from the hot parsing loops in parse.rb and strptime.rb, so I benchmarked it under YJIT:

methodips
bytes (current)8.26M i/s
unpack C34.08M i/s - 2.03x slower

unpack("C3") is about 2x slower than the current s.bytes (the format-string handling has noticeable overhead), so on this hot path I'd prefer to keep s.bytes. The results are otherwise identical — I verified equivalence over the actual day/month abbreviations and all 17,576 lowercase 3-letter combinations.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you are micro-optimizing, you might want to try:

b0,b1,b2=s.getbyte(0),s.getbyte(1),s.getbyte(2)

It should save the array allocation, and also YJIT has codegen for String#getbyte.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

# frozen_string_literal: truerequire'benchmark/ips's="ABC"Benchmark.ipsdo |x|
x.report("bytes")dob=s.bytes((b[0] | 0x20) << 16) | ((b[1] | 0x20) << 8) | (b[2] | 0x20)endx.report("unpack")dob0,b1,b2=s.unpack("C3")((b0 | 0x20) << 16) | ((b1 | 0x20) << 8) | (b2 | 0x20)endx.report("getbyte")do((s.getbyte(0) | 0x20) << 16) | ((s.getbyte(1) | 0x20) << 8) | (s.getbyte(2) | 0x20)endx.compare!(order: :baseline)end
ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +YJIT +PRISM [arm64-darwin25]
Warming up --------------------------------------
bytes 1.649M i/100ms
unpack 1.189M i/100ms
getbyte 4.452M i/100ms
Calculating -------------------------------------
bytes 18.127M (± 0.6%) i/s (55.17 ns/i) - 90.679M in 5.002478s
unpack 12.623M (± 0.6%) i/s (79.22 ns/i) - 64.189M in 5.084961s
getbyte 52.338M (± 0.4%) i/s (19.11 ns/i) - 262.667M in 5.018661s
Comparison:
bytes: 18126826.3 i/s
getbyte: 52338075.0 i/s - 2.89x faster
unpack: 12623204.8 i/s - 1.44x slower

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand the usefulness of getbyte, but I avoided it because I overused it in the past, leading to poor maintainability.

@nobu
What do you think?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I intended just that the rest of the bytes would be wasted.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you're getting the byte value here anyway getbyte seems perfectly fine and the right method for this

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@byroot@eregon

After talking with nobu, I decided to use getbyte.

Comment threadlib/date/constants.rb
next if n.nil?
b = n.downcase.bytes
h[(b[0] << 16) | (b[1] << 8) | b[2]] = [i, MONTHNAMES[i].length].freeze
}.freeze

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't use Date.compute_3key consistently?

Comment threadlib/date/patterns.rb Outdated

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file looks unused.

Comment threadlib/date/constants.rb

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some constants seem to be used more locally.
For instance, HAVE_* and FL_* are used only in a method.

jinroq added 15 commits June 16, 2026 22:39
The `offset_expr` value is only consumed by `eval(offset_expr)`, and `eval` ignores leading/trailing whitespace when parsing its argument as Ruby source. Stripping it beforehand has no effect, so drop the call.
The `abbr.strip!` above is kept because `abbr` is used as a hash key, where surrounding whitespace would be significant.
Verified by regenerating lib/date/zonetab.rb (316 entries) before and after the change: the output is byte-for-byte identical.
Per review by @nobu:
ruby#155 (comment)
Replace `entries.sort_by { |k, _| k }` with `entries.sort`. Hash#sort compares `[key, value]` pairs, but since hash keys are unique the value is never reached as a tiebreaker, so the result is identical to sorting by key alone.
Verified by regenerating lib/date/zonetab.rb (316 entries) before and after the change: the output is byte-for-byte identical.
Per review by @nobu:
ruby#155 (comment)
The previous ternary `abbr.include?(' ') ? %Q("#{abbr}") : %Q("#{abbr}")` had identical branches, so the condition was dead code. Replace the whole block with a single `f.printf "%-*p"` call: `%p` inspects the key(adding the surrounding quotes) and `*` takes the field width argument, removing the redundant ternary and the intermediate `key` variable.
Using inspect via `%p` is also more robust than interpolating quotes by hand, since it would properly escape special characters if a key ever contained them.
Verified by regenerating lib/date/zonetab.rb (316 entries) before and after the change: the output is byte-for-byte identical.
Per review by @nobu:
ruby#155 (comment)
Pass `chomp: true` to `File.foreach` so each line is yielded already chomped, removing the explicit `line.chomp!` call inside the block.
zonetab.list uses LF-only line endings, so this is equivalent to the previous `chomp!` here.
Verified by regenerating lib/date/zonetab.rb (316 entries) before and after the change: the output is byte-for-byte identical.
Per review by @nobu:
ruby#155 (comment)
Replace the `in_entries` boolean and its nested `if`/`else` block with a `sections` counter. `break if line == '%%' and (sections += 1) > 1` stops at the second `%%` (end of the entries section), and `next if sections < 1` skips the declarations before the first `%%`. The first `%%` line passes that guard but is then dropped by the existing `next unless offset_expr`, since it has no comma.
The `line.chomp!` from the original suggestion is omitted here because `File.foreach` is already called with `chomp: true`.
Verified by regenerating lib/date/zonetab.rb (316 entries) before and after the change: the output is byte-for-byte identical.
Per review by @nobu:
ruby#155 (comment)
Take the list and output paths from ARGV instead of hardcoding them relative to __dir__:
```
list_path, output_path = ARGV
```
This makes the generator path-agnostic so the caller controls where it reads from and writes to. The Usage comment and the auto-generated header line are updated accordingly, the latter now interpolating the actual list_path:
```
# Auto-generated from #{list_path} by ext/date/generate-zonetab-rb.
```
The only caller, the `update-zonetab` target in ext/date/prereq.mk, is updated to pass the paths. It now runs from $(top_srcdir) and passes the repo-relative paths, so list_path resolves to exactly "ext/date/zonetab.list" and the generated header stays unchanged:
```
$(RUBY) -C $(top_srcdir) ext/date/generate-zonetab-rb \
ext/date/zonetab.list lib/date/zonetab.rb
```
Verified by regenerating lib/date/zonetab.rb (316 entries) through the updated invocation: the output, including the header comment, is byte-for-byte identical.
Per reviews by @nobu:
ruby#155 (comment)ruby#155 (comment)ruby#155 (comment)
The ABBR_DAY_3KEY / ABBR_MONTH_3KEY tables in constants.rb open-coded the 3-byte key computation that is already factored into the shared
Date.compute_3key helper. Use the helper instead, so the table keys and the runtime lookups in parse.rb / strptime.rb are produced by a single function and cannot drift apart.
To make the helper available while the tables are built at load time, require date/shared before date/constants (shared.rb references no constants, so the reorder is safe). The helper is a private class method, so it is called without an explicit receiver; its `| 0x20` case-folding also makes the previous `.downcase` unnecessary.
This runs only at load time, so there is no runtime performance impact.
Verified that ABBR_DAY_3KEY (7 entries) and ABBR_MONTH_3KEY (12 entries) are identical to the previous inline result, and the date test suite shows no new failures (0 failures; only the 3 pre-existing environment-related errors remain).
Per review by @nobu:
ruby#155 (comment)
This file defined 40 regex constants (TIME_PAT, PARSE_*, ISO8601_*, etc.) but was never required from anywhere (lib/test/ext), and none of its constants were referenced by any other file. The parsing code in parse.rb uses its own patterns (the runtime parse regexes live in constants.rb), so the file was dead code left over from an earlier stage of the port.
Confirmed unused before removal:
- no `require`/`autoload` of date/patterns anywhere
- all 40 constants have zero references outside the file
- after `require "date"`, Date::TIME_PAT and friends are undefined
- Date.parse / Date._parse / Date.rfc3339 all work without it
The date test suite is unchanged by the removal: 144 tests, 162568 assertions, 0 failures (only the 3 pre-existing environment-related errors remain).
Per review by @nobu:
ruby#155 (comment)
The FL_* (strftime flag bits) and HAVE_* (parse character-class bits) constants were defined in the shared constants.rb but are each used in only one file: FL_* by strftime.rb (across internal_strftime, fmt_year, pad_num, fmt_str, fmt_z) and HAVE_* by parse.rb (solely within _parse).
Move each group next to its sole user:
- FL_* -> lib/date/strftime.rb
- HAVE_* -> lib/date/parse.rb
They stay as private constants on Date. HAVE_* is placed directly under `class Date` (outside `class << self`) so the singleton _parse method still resolves the bare names and they remain Date::HAVE_*. Visibility and lookup paths are unchanged.
Verified:
- Date::FL_LEFT / Date::HAVE_ALPHA etc. still raise "private constant ... referenced" when referenced via scope resolution
- strftime (all flag paths) and _parse / parse behave identically
- no -w warnings
- date test suite unchanged: 144 tests, 162568 assertions, 0 failures (only the 3 pre-existing environment-related errors remain)
Per review by @nobu:
ruby#155 (comment)
Following the discussion on ruby#155, remove the C extension and ship date
as a pure Ruby library. Maintaining both a C and a Ruby implementation
that must stay behaviorally identical is a real maintenance burden; the
Ruby implementation is now the single source of truth. Performance-
sensitive users can enable YJIT, which is significantly faster on the
supported Ruby versions.
Changes:
- lib/date.rb: drop the `RUBY_VERSION >= "3.3"` gate and the
`else require 'date_core'` fallback; require the pure Ruby files
unconditionally.
- date.gemspec: bump required_ruby_version to ">= 3.3.0", drop
s.extensions, and remove the C sources from the files list.
- Remove the C extension assets: date_core.c, date_parse.c,
date_strftime.c, date_strptime.c, date_tmx.h, extconf.rb, and the
gperf-generated zonetab.h. Keep zonetab.list, generate-zonetab-rb,
update-abbr, and a trimmed prereq.mk, which still regenerate
lib/date/zonetab.rb.
- Rakefile: drop the version gate and the Rake::ExtensionTask /
zonetab.h branch; keep the pure Ruby test task (compile is a no-op).
- .github/workflows/test.yml: min_version 2.6 -> 3.3, drop the gperf
install, and run `rake test` (compile is no longer needed).
- .github/workflows/update.yml: drop gperf and the `make zonetab.h`
step; zonetab.rb regeneration is unchanged.
- strptime.rb: reword two comments that referenced the now-deleted
ext/date/*.c files, keeping the original C function names as
provenance.
required_ruby_version was chosen empirically: the suite passes on 3.0
and 3.1, the library smoke-loads on 3.2, but breaks on 2.7/2.6 (a
frozen-string incompatibility in strftime). 3.3 is kept as the floor to
match the prior gate, align with the YJIT performance rationale, and
avoid already-EOL versions.
Verified with `rake test`: 144 tests, 162595 assertions, 0 failures,
0 errors, 100% passed; no -w warnings; gemspec loads with
required_ruby_version ">= 3.3.0" and no extensions.
Date validation under a finite cutover (e.g. ITALY, ENGLAND) used the Gregorian rule unconditionally, so dates on or before the reform were mishandled. Verified against the C extension as ground truth, the pure Ruby implementation diverged on:
- Julian-only leap days, e.g. Date.new(1500,2,29,ITALY) raised instead
of returning 1500-02-29, and valid_civil? returned false.
- Reform-gap days, e.g. Date.new(1582,10,10,ITALY) silently returned
1582-10-20 (data corruption) instead of raising; ENGLAND 1752-09-03
.. 13 likewise.
- The shortened reform year, e.g. Date.ordinal(1582,356,ITALY) and
Date.commercial(1582,52,1,ITALY) returned dates in the next year
instead of raising.
Root cause: the fast paths and internal_valid_civil?/internal_valid_ordinal? chose the calendar by `sg == Float::INFINITY` only, and never rejected days skipped at the reform.
Fixes:
- civil fast path: inline civil_to_jd for days 1..28 and detect the
reform gap (gjd < start <= jjd); delegate the rest to civil_fallback.
- ordinal / commercial fast paths: bound the result by the start of the
next (commercial) year so the shortened reform year is respected.
- internal_valid_civil?: validate on the Gregorian side first, then the
Julian side (accepting Julian-only leap days and rejecting gap days
via jjd >= sg) without a round-trip.
- internal_valid_ordinal?: derive the true year length from the
difference of the two Jan 1 JDs, which already accounts for the
reform year.
All 64 calendar oracle cases (ITALY/ENGLAND/JULIAN/GREGORIAN, gap days, pre-reform leap, reform-year ordinal/commercial, negative mday/yday) now match the C extension. Official suite: 144 tests, 162595 assertions, 0 failures, 0 errors, 100%; no -w warnings.
Performance (YJIT, i/s) vs the C baseline (4.0.1_system.tsv):
method C ext pure Ruby ratio
civil 4,648,499 6,816,000 147%
ordinal 3,023,007 7,224,000 239%
commercial 2,478,941 5,209,000 210%
valid_civil? 10,749,223 4,752,000 44%
valid_ordinal? 4,233,714 12,035,000 284%
valid_commercial? 3,177,115 3,199,000 101%
valid_civil? stays below the C baseline, but it already was before this change (~47%); C's valid_civil? is exceptionally fast and the pure Ruby version has never matched it. This change moves it only ~6% (5.05M -> 4.75M) for the added correctness.
The C extension keeps the calendar-reform cutover as a double, so Date#start always returns a Float (e.g. ITALY -> 2299161.0). The pure Ruby version stored whatever was passed, so #start returned an Integer for the finite sentinels. Besides the type mismatch, this left a latent hash inconsistency: a freshly built date (Integer @start) and a date unmarshaled from C bytes (Float @start) compared eql? but hashed differently, because Date#hash mixes in @start.
Store @start as a Float at every construction point:
- init_from_jd and _init_datetime: @start = sg.to_f
- new_start (and thus gregorian/julian/italy/england): start.to_f
- step/upto/downto and initialize_copy already copy an existing
@start, so they inherit the Float.
Date#inspect formatted @start directly, which would now show "2299161.0j"; render it the way the C extension does instead:
- Float::INFINITY -> "Inf"
- -Float::INFINITY -> "-Inf"
- whole numbers -> integer ("2299161")
This also fixes a pre-existing divergence where the infinity cutovers were shown as "Infinity"/"-Infinity" instead of "Inf"/"-Inf".
Verified against the C extension: #start type/value across new, jd, ordinal, commercial, parse, gregorian, julian, italy, new_start, +, -, next, dup and marshal round-trip all match, as do the three inspect forms; a C-marshaled date and a fresh date are now eql? with equal hashes. Official suite: 144 tests, 162595 assertions, 0 failures, 0 errors, 100%; no -w warnings.
Performance (YJIT, i/s) vs the C baseline (4.0.1_system.tsv); the added to_f is a flonum with no allocation, so constructors are unaffected:
method C ext pure Ruby ratio
civil 4,648,499 7,296,000 157%
ordinal 3,023,007 6,973,000 231%
commercial 2,478,941 5,408,000 218%
- lib/date.rb: drop `require 'timeout'`; the pure Ruby implementation has no Timeout reference (the parse `limit:` is a string-length check).
- lib/date/constants.rb: remove the dead STRFTIME_DATE_DEFAULT_FMT constant; it had no references (DEFAULT_STRFTIME_FMT is the one in use).
No behavior change; the date test suite still passes 144 tests, 162595 assertions, 0 failures, 0 errors, 100%.
DateTime had no #inspect of its own and inherited Date#inspect, so its output started with "#<Date:" and always showed "0s" for the time, diverging from the C extension which shows "#<DateTime:" and the UTC seconds-into-day.
Add DateTime#inspect that reproduces the C format:
- Julian Day number and seconds are given in UTC: derive them from the
locally stored fields as (jd*86400 + h*3600 + m*60 + s - offset),
then divmod by 86400.
- sub-second is shown as nanoseconds, sec_fraction * 1e9 kept as a
Rational: an integer when whole ("123456789n"), otherwise a
parenthesized fraction ("(1000000000/3)n", "(1/2)n").
- the offset ("+Ns"/"-Ns") and start follow.
Factor the start (cutover) formatting out of Date#inspect into a shared private helper (inspect_sg) used by both classes.
Verified against the C extension across offsets, fractional and sub-nanosecond seconds, the reform boundary and negative years: Date and DateTime #inspect now match exactly. Official suite: 144 tests, 162595 assertions, 0 failures, 0 errors, 100%; no -w warnings.
Date#inspect (YJIT) stays well above the C baseline (4.0.1_system.tsv):
method C ext pure Ruby ratio
Date#inspect 548,811 1,608,000 293%
(DateTime#inspect is not benchmarked: DateTime is deprecated.)
Several long-standing bugs in the pure Ruby port, found by differential testing against the C extension (all present before this branch's recent
work):
A. DateTime#>>, #<<, next_month, prev_month, next_year and prev_year
raised "TypeError: no implicit conversion of nil into Integer".
Date#>> rebuilt the result with new_from_jd, which only initializes
the Date fields, so the inherited path produced a DateTime with nil
time-of-day ivars that blew up on the next #to_s/strftime.
Extract the month-shift JD computation into a shared private helper
(month_shifted_jd) and override DateTime#>> to rebuild via
_new_dt_from_jd_time, preserving the time of day and offset. #<< and
the next_*/prev_* helpers delegate to #>> and recover automatically.
B. DateTime#new_offset kept the wall-clock fields and only relabeled the
offset, instead of re-expressing the same instant. Shift the stored
fields by (new_offset - old_offset) and recompute jd/h/m/s.
C. strftime %y/%g/%D/%x were wrong for negative years: they used
`year.abs % 100` (e.g. -44 -> "44") instead of `year % 100`
(-44 -> "56"), diverging from the C extension. %C was already correct.
D. The day-clamp in the month shift used the Gregorian month length for
any finite cutover, so e.g. Date.new(1500,1,31,ITALY) >> 1 gave
1500-02-28 instead of 1500-02-29; month_shifted_jd now picks the
Julian/Gregorian length according to where the target month falls
relative to the reform.
Verified against the C extension: DateTime >>/<</next_*/prev_* (with time-of-day, sub-second and end-of-month clamping), new_offset across offsets and fractions, %y/%g/%D for negative years, and the reform-aware clamp all match. Official suite: 144 tests, 162595 assertions, 0 failures, 0 errors, 100%; no -w warnings.
Performance (YJIT, i/s) vs the C baseline (4.0.1_system.tsv):
method C ext pure Ruby ratio
Date#>>1 3,119,218 3,750,000 120%
Date#next_month 3,038,007 3,677,000 121%
(DateTime is deprecated and not benchmarked.)
@jinroq
jinroq requested a review from nobuJune 20, 2026 07:47
jinroq added 2 commits June 30, 2026 22:01
compute_3key built a full byte array via String#bytes just to read the first three bytes. Read them directly with String#getbyte instead: this avoids the array allocation and, as @byroot notes, YJIT has dedicated codegen for String#getbyte.
compute_3key is on the hot path of abbreviated day/month name lookup in parse.rb and strptime.rb (and builds the ABBR_*_3KEY tables at load
time), so the saved allocation matters here.
Verified equivalent to the previous implementation over all 17,576 lowercase 3-letter combinations plus the actual day/month abbreviations
(zero mismatches); ABBR_DAY_3KEY/ABBR_MONTH_3KEY are unchanged. Official suite: 144 tests, 162595 assertions, 0 failures, 0 errors, 100%; no -w warnings.
Performance (YJIT, same-shape method benchmark):
impl i/s ratio
bytes (old) 8,485,878 1.00x
getbyte (new) 24,616,601 2.90x
Per review by @byroot:
ruby#155 (comment)
@jinroq
jinroq requested review from byroot and eregonJune 30, 2026 13:03
@jeremyevans

Copy link
Copy Markdown
Contributor

I had some time tonight, so I checked lib/date/core.rb to see if the issues discussed in my previous comment (#155 (comment)) were addressed.

Use of Kernel#Integer for conversion: still present, but only after check_numeric calls, and those raise TypeError for non-numeric input. So the issue is fixed. I think it would be simpler to rename check_numeric to convert_numeric_to_int and have it use Kernel#Integer to convert the value and return that. So instead of:

check_numeric(jd,"jd")jd=Integer(jd)

You would have:

jd=convert_numeric_to_int(jd,"jd")

The following debugging methods from the C extension were removed from the Ruby code, so that issue is fixed:

  • Date.weeknum
  • Date.nth_kday
  • Date.new!
  • Date#nth_kday?
  • Date.weeknum_to_jd
  • Date.nth_kday_to_jd

Explicit call-seqs are still present in the documentation. I suppose it is fine to keep them.

Considering that with YJIT, the Ruby code is faster than the C extension, losing the lazy initialization optimization doesn't seem like a blocker. We can always add it back in a later PR.

That being said, it does seem unnecessarily wasteful for Date.new/Date.civil to not pass the year/month/day to the created instance. I recommend renaming init_from_jd to initialize. I would change it to accept year/month/day as arguments, and pass the appropriate arguments from other methods if they are available. By allowing it to take year/month/day, you can simplify initialize_copy by having it call initialize (then you don't need to worry about the instance variable order being out of sync).

Date.new still calls Date.civil instead of being an alias, same with Date#iso8601 calling Date#to_s instead of being an alias. Both of these should be fixed.

Date#initialize is no longer present. The implementation still uses allocate and init_from_jd, when it could be simplified by aliasing new to new! and making it private (assuming the rename of init_from_jd to initialize as described above).

Date#yday, Date#cweek, and Date#cwyear no longer cache, so that is fixed.

Date#day_fraction now returns integer and not rational, so that is fixed.

Date#<, Date#> and Date#== were removed, and the implementation now uses the Comparable methods, so that is fixed.

Date#deconstruct_keys now calls methods instead of inling the related code, so that is fixed. If the Ruby code only targets Ruby 3.3+, we could use implicit hash values ({year:}) to make this a bit simpler.

@sg was renamed to @start, and @df to @day_fraction, so that is fixed.

The existing C extension does support a Date instance with a fractional day component. However, it's pretty bad as the day fraction isn't even used if you convert the instance to a DateTime

date=Date.jd(2461234.5)date.jd# => 2461234date.day_fraction# => (1/2)date.to_datetime.day_fraction# => (0/1)

I think we should drop fractional day support for Date, and only support fractional days for DateTime. I would remove @day_fraction completely from the Date implementation, and have Date#day_fraction always return 0. DateTime can use @day_fraction and have DateTime#day_fraction be an attr_reader method. Existing use of @day_fraction inside Date should switch to day_fraction.

Unlike the C extension, the Ruby library doesn't split a fractional input jd into an integer jd and day fraction, so it already doesn't match the C extension behavior in this regard. I would disallow the use of fractional days for Date (only allow for DateTime), raising an exception if a fractional day is used as input.

If we do want to support fractional days for Date, then you need to mirror the C extension's handling of fractional days in input. I think dropping fractional days for Date, while backwards incompatible, is acceptable, as I'm guessing we will do a major version bump if replacing the C extension with the Ruby library.

I still think we shop drop the C extension and bump required_ruby_version to 3.3 (or the minimum Ruby version that passes the tests).

I'm not sure how @nobu, @rhenium, @byroot, and @eregon feel about dropping the C extension or how they feel about dropping support for fractional days for Date, so I'm interested in hearing their opinions.

I plan to start review of files other than lib/date/core.rb on July 15.

@rhenium

Copy link
Copy Markdown
Member

If we can get better performance without sacrificing maintainability, that's great! Thank you for the effort to make this work and to review the changes.

My main concern is maintenance since ruby/date is fairly complex, and especially since it currently doesn't have a designated maintainer. Some parts of this PR appear to be fairly direct ports from C, such as use of bitmasks in parse.rb and strftime.rb (though that's not the only example). The code is frankly rather difficult to navigate and I think some refactoring work is wanted.

Dropping support for Ruby < 3.3 seems reasonable since Ruby 3.2 reached EOL on 2026-04. I suspect the Ruby implementation could be made compatible with older versions with few changes, but that's completely optional and may not be worth the extra work.

Dropping support for fractional days on Date also makes sense to me. Date.new is another method working inconsistently by silently truncating it (though this looks like a bug - add_frac() can't work in #initialize:

add_frac();
). I don't have any insight into the original design, but hopefully no one is relying on the partial support we currently have.

Comment threadlib/date/constants.rb
Comment on lines +6 to +7
MONTHNAMES = [nil, 'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'].freeze

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These constants are documented. The rdoc comments should be kept here:

date/ext/date/date_core.c

Lines 9789 to 9792 in f61aa49

/* An array of strings of full month names in English. The first
* element is nil.
*/
rb_define_const(cDate, "MONTHNAMES", mk_ary_of_str(13, monthnames));

Comment threadlib/date/strftime.rb
s = (y < 0 ? '-' : '') + s
end
end
raise Errno::ERANGE, "strftime" if s.length >= STRFTIME_MAX_COPY_LEN

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this error exists in the C implementation.

Comment threadlib/date/core.rb
Comment on lines +1653 to +1672
def jisx0301
internal_civil unless @year
jd = @jd
m = @month
d = @day
md = "#{PAD2[m]}.#{PAD2[d]}"
if jd >= 2458605 # Reiwa (2019-05-01)
"R#{PAD2[@year - 2018]}.#{md}"
elsif jd >= 2447535 # Heisei (1989-01-08)
"H#{PAD2[@year - 1988]}.#{md}"
elsif jd >= 2424875 # Showa (1926-12-25)
"S#{PAD2[@year - 1925]}.#{md}"
elsif jd >= 2419614 # Taisho (1912-07-30)
"T#{PAD2[@year - 1911]}.#{md}"
elsif jd >= 2405160 # Meiji (1873-01-01)
"M#{PAD2[@year - 1867]}.#{md}"
else
to_s
end
end

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method returns a UTF-8 string.

Perhaps we can change the source encoding instead of repeating force_encoding?

Comment threadlib/date/core.rb
Comment on lines +1780 to +1781
# Compute [cwyear, cweek] from the Julian Day. Not cached, matching the
# C extension, which recomputes commercial fields on each access.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whether the code matches the old C implementation or not isn't particularly useful information for future readers, and such comments will likely become stale, so I suggest documenting the actual purpose instead.

@jeremyevans

Copy link
Copy Markdown
Contributor

I continued my review today. I will continue reviewing this weekend.

date.rb:

Other than requires, the code is a copy from existing date.rb, so definitely compatible. No issues.

date/version.rb:

New file, but only code is copied from previous date.rb. No issues.

date/shared.rb:

New file adding Date.compute_3key. This appears to be used for month/day name maps, I would guess as an optimization. What is the performance benefit of this optimization? This optimization is new, since the previous code used a simple array iteration in C (the equivalent of an Array#find_index approach in Ruby). The code would certainly be easier to understand if the 3 byte string was used as a hash key, so I assume there is a performance benefit, but I'm not sure how much.

date/constants.rb:

The DEFAULT_SG constant is copied from a C #define, but I don't think it makes much sense to have a private constant for this. I would replace all instances of DEFAULT_SG with ITALY and then remove DEFAULT_SG.

As mentioned above, we should consider whether the performance benefits of the compute_3key approach are worth the extra complexity. Similarly, how much performance benefit is there from using an integer key as opposed to a single character string key in STRFTIME_COMPOSITE_BYTE? Similar questions for STRFTIME_E_VALID_BYTES and STRFTIME_O_VALID_BYTES.

ASCTIME_DAYS and ASCTIME_MONS are duplicates of ABBR_DAYNAMES and ABBR_MONTHNAMES and should be removed. Additionally, the following constant aliases should be removed:

  • RFC2822_DAYS
  • STRFTIME_MONTHS_FULL
  • STRFTIME_MONTHS_ABBR
  • STRFTIME_DAYS_FULL
  • STRFTIME_DAYS_ABBR

Is the performance benefit of MONTH_DAY_SUFFIX really worth the complexity, compared to a plain array with [nil, "01", "02", ...], that you could use for both month and day lookups? Similar question:

  • ASCTIME_PREFIX
  • RFC_MON_SPACE
  • DAY_FULL_COMMA
  • MONTH_FULL_SPACE

I'm not sure it's worth having constants for all default argument values. With frozen strings, it's unlikely to provide better performance. It's more self documenting if the strings are used directly. Using a constant does ensure the value is the same if multiple methods use the same value, but I think the self documentation benefits outweigh that. This applies to:

  • JULIAN_EPOCH_DATE
  • JULIAN_EPOCH_DATETIME
  • JULIAN_EPOCH_DATETIME_RFC2822
  • JULIAN_EPOCH_DATETIME_HTTPDATE
  • DEFAULT_STRFTIME_FMT
  • YMD_FMT

I think we should reduce STRFTIME_MAX_WIDTH by a lot. 65535 width seems crazy to allow. 21 width would allow the ability for the entire 64-bit number range.

STRFTIME_MAX_COPY_LEN is used to raise an exception if the date's year, expressed as a string, is more than 1024 bytes. However, this is raised after you've already generated the resulting string, so it's not like it saves an expensive calculation. I would remove this check and constant. Especially since the limit isn't enforced in all cases, such as in strftime("%A, %B %d, %Y")

HTTPDATE_WDAY uses string keys instead of a compute_3key approach. This seems inconsistent.

The regexp constants that use month and day names or abbreviations (e.g. HTTPDATE_TYPE1_RE) may benefit from a local variable interpolation, so you aren't repeating the names in every regexp that uses them.

date/time.rb:

Time#to_date should use the standard constructor (Date.new(year, mon, mday)).

For Time#to_datetime, it's less clear cut, because the standard constructor would require: DateTime.new(year, mon, mday, hour, min, sec + subsec, utc_offset/86400r), which could have a significant negative effect on performance. However, it's worth benchmarking, because if the performance hit isn't significant, I would use the standard constructor.

date/strftime.rb:

Optimizing strftime for the no-argument case isn't worth doing, as almost all usage is with an argument. The case optimizes %Y-%m-%d but not the equivalent %F. Again, there is rarely a reason to do this, as if you want %Y-%m-%d, it's simpler to call to_s or iso8601. I would remove the fast paths. Especially since internal_strftime already does the same optimization for both %Y-%m-%d and %F in the first case statement.

internal_strftime has multiple instance_of?(Date) calls. We may want to use an approach where Date#strftime only handles Date and doesn't attempt to handle DateTime, and have DateTime#strftime to handle any time-specific formatting.

All fast paths at the top of internal_strftime (before StringScanner) are dubious and can give the impression that the code is optimized for specific benchmarks. For common format strings, there often exists an existing method that uses that format, in which case it is simpler to use that method than call strftime. Benchmarks should be provided for the non-fast paths.

In scan loop, the code scans for colons both before and after scanning for flags, and adds the number of colons together. This seems wrong, and deviates from current behavior:

d=Date.today.strftime("%:10z")# Current: "%:10z"# With PR: "+000000:00"

Additionally, behavior differs if more than 3 colons are provided with the z modifier:

d=Date.today.strftime("%::::z")# Current: "%::::z"# With PR: "+00:00"

I stopped my review at line 128 of date/strftime.rb.

@jeremyevans

Copy link
Copy Markdown
Contributor

I reviewed all remaining code that I had not previously reviewed.

date/strftime.rb:

Behavior with locale O and E modifiers can differ in certain cases:

Date.today.strftime("%10OY")# Current: "%10OY"# With PR: "%OY"Date.today.strftime("%1Em")# Current: "%1Em"# With PR: "%Em"Date.today.strftime("%:Em")# Current: "%:Em"# With PR: "%Em"

The C extension checks that the E and O modifiers are only valid for certain formats (E: cCxXyY, O: deHkIlmMSuUVwWy). The Ruby code appears to attempt to implement this check in format_spec_b, but if the spec isn't valid for the modifier, it uses only the locale modifier and format, which doesn't include length/colon modifier given in the format string.

I think the parser needs to be reworked so that it checks for valid modifiers during the parsing (instead of waiting for a later step), or it keeps a string of all modifiers so it can restore all of them.

The Ruby code appears to contain hallucinated support for both a width and a precision separated with a period. The C strftime pattern parser does not contain a '.' literal, and I can find no documentation that a precision for a width is supported. I can only guess AI halluccinated the support because strftime is somewhat similar in nature to snprintf (which supports a precision for a width), and the C extension uses precision in the source code for the strftime support, just in a different context.

Date.today.strftime("%1.2m")# Current: "%1.2m"# With PR: "7"

Padding for width modifiers is in some cases handled differently in the C extension than in the Ruby code:

Date.today.strftime("%4u")# Current: "0006"# With PR: " 6"

Flag order in modifiers is respected in the C extension but not in the Ruby code:

Date.today.strftime("%0_m")# Current: " 7"# With PR: " 7"Date.today.strftime("%_0m")# Current: "07"# With PR: " 7"

I would guess there are a lot of differences between the C extension Date#strftime and the Ruby Date#strftime. To ensure that behavior is the same, before merging the Ruby library, we should generate additional C extension strftime tests for every format specifer (50) with an assertion for every combination of modifier types (2**5 flags combinations, multiple widths {1,10}, 0-4 colons, 2 local modifiers), for both Date and DateTime. This is a lot of additional assertions (64000), but they should run quickly.

For fmt_asctime_str, could lookup the day of month string in an array instead of the d < 10 ? " #{d}" : d.to_s approach. Could also be used for the e format specifier. The w specifier could use a similar lookup array. This would be similar to PAD2, but with space padding instead of zero padding, and probably only needs to go up to 31 instead of 99.

For fast_spec, there are a lot of internal_civil unless @year calls. I would remove these and instead call the related methods (year, month, day), which would simplify a lot of this code.

The use of the byte integer instead of single character string for the case in fast_spec and format_spec_b is a bit of a smell. How much does it actually optimize the method? I'm fairly sure it's not worth using a byte integer for the locale modifier.

In which cases does fast_spec fall through to format_spec_b, and in which cases does format_spec_b use the fallback "%#{spec&.chr}"?

The + format specifier seems like it should be in STRFTIME_COMPOSITE_BYTE, so it doesn't need its own when.

In fmt_year, probably can avoid some String#+ calls if the receiver would be an empty string. Additionally, the year should always be numeric, so it looks like the final if/else is unnecessary, and the method should just return s.

In fmt_str, since the method has s = s.dup at the top, it can probably use downcase! or upcase! to save allocations.

In fmt_z, there is a lot of duplication between the lead/no_lead cases, it would be simpler to combine them.

date/strptime.rb:

For Date._strptime/Date.strptime, the fast paths for certain formats look like benchmark optimization. In general, if you have a known format that is handled by an existing method, you would use that instead of strptime. For example, %F/%Y-%m-%d are handled by Date._iso8601/Date.iso8601. I'm not sure why the %a %b %d %Y string is optimized, when the more common %a, %d %b %Y (used for httpdate, rfc822, rfc2822) is not. I searched the ruby/ruby repository for %a %b %d %Y and got no results.

Error handling for invalid types does not match the C extension:

Date._strptime(:foo, "%z")
Current: no implicit conversion of Symbol into String (TypeError)
With PR: undefined method 'to_str' for an instance of Symbol (NoMethodError)

Methods starting with internalinternal_ should probably be changed to start with internal_.

Regexp constants only used in a single place should be inlined.

Unlike the strftime handling, colon and locale modifiers appear to be appropriately restricted in strptime.

Similar to strftime, I'd like to see how much improvement the byte integer based case statement brings over the single character string approach.

Seems like strptime could also use STRFTIME_COMPOSITE_BYTE in most cases (maybe not 118/v), to DRY up some redundant when branches. If not directly, it can use a similar approach with a STRPTIME_COMPOSITE_BYTE constant.

There is a lot of code of the type that should be extracted into a method:

throw(:sp_fail)unlesssn=s.to_ithrow(:sp_fail)ifn < lower_bound || n > upper_boundhash[symbol]=n

There is redundant code for sign checks, in one of two forms, either:

ifstr_sc.scan(SP_SIGN)sign=str_sc.matched == '-' ? -1 : 1elsesign=1end

or:

sign=1ifstr_sc.skip(/-/)sign= -1end

I would standardize on an approach (the second approach looks simpler) and extract it into a method.

In some cases where sign checks are used, it does the equivalent of n = sign * n (sign being 1 or -1) and in other cases it uses n = -n if sign == -1. Again, better to standardize on an approach here.

In internal_strptime_ymd, assuming we keep the method, better to combine the STRPTIME_YMD_EXACT and STRPTIME_YMD_PREFIX code, since it's the same other than the leftover handling. The STRPTIME_YMD_GENERAL can avoid a capture group for the rest of the string by removing the (.*)\z from the regexp and using m.post_match to get the leftover. In internalinternal_strptime_ymd_to_date, code after the year/mon/mday is the same. The method should be refactored so that code is shared, assuming we want to keep the method. If we did want to keep these methods, there should be one method that handles parsing and returns or yields the year/month/day/leftover, instead of duplicating the logic. Similar issue for internal_strptime_abdy and internalinternal_strptime_abdy_to_date.

In sp_zone_to_diff, strip_word doesn't appear to need to be a closure. I would make it a constant or a method. Many cases here could probably use mutating string methods instead of string methods that return modified copies. t[$&.length..] seems like it could be $' (or switch to a less cryptic approach using match and post_match).

In sp_complete_frags, we should have a method that accepts a hash and an array of keys, and returns how many keys are present in the hash. This would allow DRYing up a lot of the code.

date/parse.rb:

Handling of non-string types does not match the C extension:

Date.rfc3339(:foo)
Current: invalid date (Date::Error)
With PR: TypeError (TypeError)
Date._rfc3339(:foo)
Current: => {}
With PR: TypeError (TypeError)
Date.rfc3339(Object.new)
Current: no implicit conversion of Object into String (TypeError)
With PR: undefined method 'to_str' for an instance of Object (NoMethodError)
Date._rfc3339(Object.new)
Current: no implicit conversion of Object into String (TypeError)
With PR: undefined method 'to_str' for an instance of Object (NoMethodError)

This appears to be true for other methods, so all should be checked and behavior updated to match the C extension. We should add a method for all of this code, which appears in multiple methods (after fixing the code to match the C extension behavior):

unlessString === stringraiseTypeErrorifstring.is_a?(Symbol)return{}ifstring.nil?string=string.to_strendreturn{}ifstring.empty?raiseArgumentError,"string length (#{string.length}) exceeds the limit #{limit}"iflimit && string.length > limit

The Ruby library has a method for the limit check in parse_check_limit, but it is never called. Similar, it has a method for converting objects to string (parse_to_str), but that is also not called.

For the underscore parsing methods (e.g. _rfc3339, _httpdate), how much performance benefit is there from having fast paths? If it isn't substantial, we may want to skip the fast paths.

In Ruby 4.1+, you can use MatchData#integer_at to speed up a lot of conversions (probably also in strptime handling as well). Not sure whether it is worth adding a method like matchdata_integer_at(matchdata, i) that uses matchdata.integer_at(i) on Ruby 4.1 and matchdata[i].to_i on older Ruby versions.

In _rfc2822, there is this code: zone = '+0000' if zone == '+0000', which I assume is unnecessary and can be deleted.

In _xmlschema and _iso8601, the fast path uses a StringScanner instead of an approach using match? and indexing into the string, which is used in internal_strptime_ymd in date/strptime.rb. These should should the same approach, whichever one is faster. There seems to be significant usage of StringScanner in multiple methods that could be handled by Regexp#match, since the StringScanner instance only has scan called on it once.

In _parse, instead of using a bitmap for cc, I would use boolean local variables. So:

if !matched && (cc & (HAVE_ALPHA | HAVE_DIGIT)) == (HAVE_ALPHA | HAVE_DIGIT)

would become:

if !matched && (have_alpha || have_digit)

I think you could avoid repeated have_digit checks by restructuring:

ifhave_digit# parse checks from parse_time to parse_yearend

It would be simpler to have _parse call a method that did the parsing from parse_eu to parse_ddd, so once a match was found, the method could return, instead of having every branch use if !matched &&.

There is a lot of code of the form:

h[:hour]=m[x].to_iifm[x]h[:min]=m[x+1].to_iifm[x+1]h[:sec]=m[x+2].to_iifm[x+2]h[:sec_fraction]=parse_sec_fraction(m[x+3])ifm[x+3]parse_zone_and_offset(m[x+4],h)ifm[x+4]

I would add a method for this, accepting h, m, and x and convert other methods to use it.

There is a significant amount of code using:

str[m.begin(0)...m.end(0)]=' ' * (m.end(0) - m.begin(0))

A slightly faster approach avoids range allocation:

e=m.end(0)b=m.begin(0)size=e - bstr[b,size]=" " * size

I would add a method for that approach and call it from the other places.

The matched local variable seems to only be used in boolean context, and a number of else; false branches could be removed if matched could contain nil instead of false.

In parse_ddd, the following lines show up in multiple sequential branches:

off_s="+#{off_s}"unlessoff_s.start_with?('+') || off_s.start_with?('-')h[:offset]=fast_zone_offset(off_s)

The code should be refactored so this occurs after the branches using if off_s, avoiding the duplication.

In s3e, ys_raw[$~.end(0)..] can be $' (or again, use Regexp#match and MatchData#post_match for less cryptic code). There is duplicate code in steps 2, 4, and 5 of the type that should probably be extracted into a method:

s2=s1.sub(/\A'?-?/,'').sub(/(?:st|nd|rd|th)\z/i,'')ifs2.length > 2 || s1.start_with?("'")

Similarly, there is also redundant code of the type for determining the comp flag that should be extracted into a method (also in s3eparse_year):

(num_s.start_with?('-') || num_s.start_with?('+') || num_s.sub(/\A[-+]/,'').length > 2)

date/zonetab.rb:

This is an autogenerated file. I only checked a few hash keys/values, but I assume the rest are correct. If we are dropping support for the C extension, we should remove ext/date/zonetab.list, move ext/date/update-abbr somewhere outside ext (possibly inline the code into the Rakefile), and have it generate lib/date/zonetab.rb directly instead of generating ext/date/zonetab.list.

date/datetime.rb:

Inline the STRFTIME_DATETIME_DEFAULT_FMT constant into the one place it is used and remove it. Same for the DATETIME_TO_S_FMT constant.

DateTime#initialize accepts non-numeric year and month, which the C extension doesn't support:

DateTime.new("2000")Current: invalidyear(not numeric)(TypeError)WithPR: =>#<DateTime: 2000-01-01T00:00:00+00:00 ((2451545j,0s,0n),+0s,2299161j)>DateTime.new(2000,"10")Current: invalidmonth(not numeric)(TypeError)WithPR: =>#<DateTime: 2000-10-01T00:00:00+00:00 ((2451819j,0s,0n),+0s,2299161j)>

It also accepts arguments after a fractional argument for day, hour, or minute, which the C extension doesn't support:

DateTime.new(2000,1,1.5,1)Current: invalidfraction(Date::Error)WithPR: =>#<DateTime: 2000-01-01T13:00:00+00:00 ((2451545j,46800s,0n),+0s,2299161j)>

Invalid offsets appear to be ignored in the C extension, but respected in the Ruby code:

DateTime.new(2000,2,3,4,5,6, -30)Current: =>#<DateTime: 2000-02-03T04:05:06+00:00 ((2451578j,14706s,0n),+0s,2299161j)>WithPR: =>#<DateTime: 2000-02-03T04:05:06-720:00 ((2451608j,14706s,0n),-2592000s,2299161j)>

Other constructors appear have these issues: DateTime.jd, DateTime.ordinal, DateTime.commercial. Some of these may have their own conversion issues, such as:

DateTime.jd(1,2,3,'6')Current: invalidsecond(not numeric)(TypeError)WithPR: =>#<DateTime: -4712-01-02T02:03:06+00:00 ((1j,7386s,0n),+0s,2299161j)>

As mentioned for Date, we should not throw away the known hour/minute/second in DateTime#initialize, we should ensure it is set correctly for the created object, so it doesn't need to recalculate it from the jd.

hour and min should be attr_readers. I recommend renaming @sec_i to @sec and @sec_frac to @sec_fraction, so sec and sec_fraction can be attr_readers as well.

@day_fraction is set to nil by _init_datetime, but it is never used. day_fraction recalculates it every time. It's probably worth caching by changing day_fraction to use @day_fraction ||=.

ajd should call offset and day_fraction instead of duplicating the logic.

+ should have the Numeric case do self + r after checking that r is Rational, instead of duplicating the logic. The logic in _from_total_sec_r can then be inlined, since that is the only caller.

- can probably treat Integer arguments the same as Rational and Float arguments.

For multiple methods, there are existing call-seq documentation issues, such as d.new_offset([offset=0]) should be d.new_offset(offset = 0) (the = makes it optional, no need for brackets). Probably fine to leave for now, but we should address at some point.

Multiple methods appear to convert arguments to integer in cases where the C extension would raise a TypeError:

DateTime.now.jisx0301("1")Current: noimplicitconversionofStringintoInteger(TypeError)WithPR: =>"R08.07.19T20:12:59.5-07:00"DateTime.now.iso8601("1")Current: noimplicitconversionofStringintoInteger(TypeError)WithPR: =>"2026-07-19T20:14:56.7-07:00"

It's a bit strange that #jisx0301 doesn't use #strftime, and that #iso8601 calculates the fractional seconds manually instead of using %#{n}N in the format string (which would result in simpler code).

Marshalling DateTime does not appear to work between the C extension and the Ruby code, looks like the offset is not set correctly on the loaded instance (happens to work if the offset is 0). The Ruby code doesn't load the offset from the C extension marshal dump, and the C extension doesn't load the offset from the Ruby code marshal dump.

# C extensiondt=DateTime.now# => #<DateTime: 2026-07-19T20:28:25-07:00 ((2461242j,12505s,882676146n),-25200s,2299161j)>s=Marshal.dumpdt# => "\x04\bU:\rDateTime[\vi\x00i\x03:\x8E%i\x02\xD90i\x04\xB2\x91\x9C4i\xFE\x90\x9Df\f2299161"dt2=dt.new_offset(0)# => #<DateTime: 2026-07-20T03:28:25+00:00 ((2461242j,12505s,882676146n),+0s,2299161j)>s2=Marshal.dumpdt2# => "\x04\bU:\rDateTime[\vi\x00i\x03:\x8E%i\x02\xD90i\x04\xB2\x91\x9C4i\x00f\f2299161"# Ruby libMarshal.load(s)# => #<DateTime: 2026-07-20T03:28:25-07:00 ((2461242j,37705s,882676146n),-25200s,2299161j)>Marshal.load(s2)# => #<DateTime: 2026-07-20T03:28:25+00:00 ((2461242j,12505s,882676146n),+0s,2299161j)>

To find issues like this, we should run Marshal.dump with Date and DateTime objects for a number of historical Ruby releases, and add tests for all unique results, that check that Marshal.load results in the expect value. Ideally, it would be best if we had tests that Marshal.dump for the same Date and DateTime resulted in the specific expected string, as that should ensure both backwards and forwards compatibility.

DateTime.new is defined to call allocate and then initialize, which seems to duplicate Class#new. I would remove the method definition.

DateTime._strptime requires an argument in the C extension but not in the Ruby code. If you call the method without the string, you get a NameError, because the default value is an undefined constant reference. The default value for the first argument should be removed. DateTime.strptime has the same issue, except that in the C extension, the method has a default value, so the default value in the Ruby code should be changed from JULIAN_EPOCH_DATETIME to Date::JULIAN_EPOCH_DATETIME (or better yet, just inline the value). Note that the reason that this constant lookup doesn't work is that constant lookup only looks in superclasses of the current namespace, not superclasses of containing namespaces. The current namespace in this case is the singleton class of DateTime, not DateTime itself, so it doesn't find the constant defined in Date.

Not sure whether we should fix this, but marshal_load returns self in the C extension and nil in the Ruby code.

In _parse_of, the Rational and Numeric branches of the case expression are the same. As Rational is a subclass of Numeric, I would remove the Rational branch.

_split_sec and _split_second have the same definition, but one is public and the other is private. At the least, one should be an alias of the other, but unless there is a good reason, everything should call the same method. Same with zone and zone_str (pick one and inline _of2str).

The private methods that just return instance variable values should be attr_reader methods or aliases of them.

Overall comments:

We should add coverage testing and ensure 100% line/branch coverage before merging.

@jinroq

Copy link
Copy Markdown
Author

@jeremyevans

Thank you for the review!

I have a couple of things I'd like to check with you.

  1. DateTime is already deprecated. Should we still address the issues you pointed out for DateTime?
  2. I'm also thinking of deprecating Date#to_datetime. What are your thoughts on this?

@jeremyevans

Copy link
Copy Markdown
Contributor

@jeremyevans

Thank you for the review!

I have a couple of things I'd like to check with you.

  1. DateTime is already deprecated. Should we still address the issues you pointed out for DateTime?

For any issues I pointed out where the behavior is not backwards compatible, the issue must be addressed before merging. I guess the other issues are not necessarily blockers, but most of those are easy to implement so it would be a good idea to address those as well. We don't need to optimize DateTime to the same extent as Date.

  1. I'm also thinking of deprecating Date#to_datetime. What are your thoughts on this?

If we want to deprecate it in the same way that DateTime is deprecated (documentation only), I think that's fine. Actual deprecation warnings should probably come at a later stage, and be consistent for all cases where a DateTime instance is created or DateTime is referenced. Possibly Object.deprecate_constant :DateTime will be sufficient for that.

@eregon

Copy link
Copy Markdown
Member

I'm not sure how @nobu, @rhenium, @byroot, and @eregon feel about dropping the C extension or how they feel about dropping support for fractional days for Date, so I'm interested in hearing their opinions.

+1 to both.
Maintaining both C and Ruby impls would be very difficult.
I don't think fractional days makes sense for Date, it's supposed to designate a given date/day.

@eregon

Copy link
Copy Markdown
Member
  1. DateTime is already deprecated. Should we still address the issues you pointed out for DateTime?

I would say it's more encouraging to use Time when possible since that's core. It's saying considered deprecated which IOW is please use Time instead where possible.
But in fact they have a significant difference: Time doesn't handle different calendars and so is imprecise when using dates far in the past or future. Time is timestamp-based (i.e. basically incorrect for anything < 1600), Date/DateTime is conceptually ajd-based so can be fully accurate.

Sign up for freeto 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.

6 participants

@jinroq@jeremyevans@nobu@eregon@rhenium@byroot