Skip to content

gh-161746: Use the Neri–Schneider algorithm for ordinal-to-date conversion in datetime - #161747

Open
mazalovmx wants to merge 4 commits into
python:mainfrom
mazalovmx:datetime-neri-schneider
Open

mazalovmx wants to merge 4 commits into
python:mainfrom
mazalovmx:datetime-neri-schneider

Conversation

@mazalovmx

@mazalovmx mazalovmx commented Nov 10, 2026 •

Copy link
Copy Markdown

gh-161746

Verification

  • Same results as main for all 3,652,059 ordinals (0001-01-01 … 9999-12-31):
    SHA-256 over every date.fromordinal() result, plus fromisocalendar() and
    date + timedelta, for both the C and the pure-Python implementations.
  • C: same results for every positive int ordinal (1 … 2**31-1), including
    the error messages past 9999-12-31; checked with gcc 13 and clang.
  • Pure Python: same results for 2.3 million other integers, including negative
    and huge ones.
  • test_datetime, test_calendar, test_strptime, test_time, test_zoneinfo,
    test_plistlib and test_xmlrpc pass. The PR adds a Hypothesis property test
    (fromordinal(d.toordinal()) == d, with explicit examples at the ends of
    the 4-, 100- and 400-year cycles) and tests for the year reported in the
    error past 9999-12-31.
  • Lib/test/support/hypothesis_helper.py now silences the typing.AnyStr
    DeprecationWarning that Hypothesis emits on import; otherwise
    test___all__ fails when it imports test.datetimetester.

Benchmarks

Measured on the first version of the patch. pyperf, x86-64 VM, gcc 13.3, -O3, no PGO; µs per 1000 operations:

Benchmark main patched Speedup
date.fromordinal, random dates 35.6 25.3 1.40x
date + timedelta(days=40) 42.9 35.7 1.20x
date.fromisocalendar 83.5 71.0 1.18x
datetime + timedelta(days=40) 37.9 34.1 1.11x
date.fromordinal, consecutive dates 26.3 23.8 1.10x
date + timedelta(days=1), 1000 steps 32.8 31.8 1.03x

After the review changes (simple form, 32-bit) I measured again: C date.fromordinal 1.16x, pure Python 1.31x (random dates).

The C function itself takes 3-4 ns per call instead of 15-16 ns on random
dates (gcc 13 and clang, -O3). The pure-Python date.fromordinal() is about
1.3x faster.

With a PGO build (--enable-optimizations, gcc 13.3) of main (e3262d9) and of
the patch, 1,000,000 operations, best of 7, builds alternated:

Operation main patched Speedup
date.fromordinal, random dates 62.6 ms 45.9 ms 1.36x
date + timedelta(days=90) 69.0 ms 60.8 ms 1.13x
date.fromisoformat('2024-W07-1') 88.6 ms 77.8 ms 1.14x
date + timedelta(days=10) 53.0 ms 52.5 ms 1.01x

pyperformance 1.14.0 (--fast, same two PGO builds, 119 benchmarks in common):
geometric mean 1.00x. No pyperformance benchmark exercises this code path, so
no change is expected there; the differences per benchmark are noise.

🤖 Generated with Claude Code

… conversion

Replace the 400/100/4/1-year cycle decomposition and the month
estimate-and-correct step in ord_to_ymd() (Modules/_datetimemodule.c)
and _ord2ymd() (Lib/_pydatetime.py) with Figure 12 of Neri and
Schneider, "Euclidean affine functions and their application to
calendar algorithms" (Softw Pract Exper. 2023;53(4):937-970), which
GCC's libstdc++, the Linux kernel, .NET and Go already use.

The C version computes the year and the month steps with one
multiplication each, and does the century step in 64 bits so that every
positive int ordinal gives the same result as before.  The pure-Python
version uses plain floor division.

Both versions return the same results as the old code for every
ordinal from 1 to 3,652,059; the C version also for every positive int,
and the pure-Python one for 2.3 million other integers, including
negative and huge ones.  In microbenchmarks on x86-64 (gcc 13, -O3),
date.fromordinal() on random dates is about 1.4x faster, date +
timedelta across a month boundary and date.fromisocalendar() about 1.2x
faster, and the pure-Python date.fromordinal() about 1.3x faster.

Add a test for the first and last days of every month in years at the
start and the end of the 4-, 100- and 400-year cycles.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@python-cla-bot

python-cla-bot Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

All commit authors signed the Contributor License Agreement.

CLA signed

Comment thread Modules/_datetimemodule.c Outdated
* ceil(2**32 / DI4Y), so the high 32 bits are the quotient and the low
* 32 bits encode the remainder. */
const uint32_t year_scaled = 4 * day_of_century + 3;
const uint64_t year_product = (uint64_t)2939745 * year_scaled;

@StanFromIreland StanFromIreland Nov 10, 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.

I don't like these magic numbers, can you please benchmark the simpler approach (compilers are pretty smart, and should be able to deal with these), like you implemented in Python? If we accept this (I still have to think about this), they should be as close as possible for maintenance purposes.

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.

Ok. I rewrote it to follow _ord2ymd() step by step, with plain / and % by constants. gcc 14 and clang 19 turn them into multiplications anyway (no div instructions at -O3).

Results, C implementation, µs per 1000 operations (pyperf, gcc 14.2, -O3, no PGO):

Benchmark main magic numbers simple
date.fromordinal, random dates 101 60.8 (1.65x) 62.2 (1.62x)
date + timedelta(days=40) 104 82.0 (1.27x) 85.2 (1.22x)
date.fromisocalendar 184 146 (1.26x) 156 (1.18x)
datetime + timedelta(days=40) 102 78.7 (1.29x) 83.2 (1.22x)
date.fromordinal, consecutive dates 65.9 61.9 (1.06x) 59.8 (1.10x)
Geometric mean (6 benchmarks) 1.25x 1.21x

Simple vs. magic numbers: 4 of 6 benchmarks not significant, geometric mean 1.03x slower. On its own, the function takes about 6.5 ns instead of 5.5 ns (the old code: about 17 ns). That isn't worth the magic numbers, so I've pushed the simple version.

It still matches the old code for every positive int ordinal (gcc and clang) and for all dates in the supported range.

Speed up conversion of ordinals to dates in :mod:`datetime`, used by
:meth:`~datetime.date.fromordinal`, :meth:`~datetime.date.fromisocalendar`
and :class:`~datetime.timedelta` arithmetic, by switching to the
Neri–Schneider algorithm.

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.

Add a link, and a rough % speedup.

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.

Done.

Use plain division and modulo by constants instead of hand-derived
multiply-shift constants; compilers emit multiplications anyway.
Add a link to the paper and rough speedups to the NEWS entry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bedevere-app bedevere-app Bot added the type-feature A feature request or enhancement label Nov 10, 2026
@StanFromIreland

Copy link
Copy Markdown
Member

CC @cassioneri this may be of interest.

Comment thread Lib/test/datetimetester.py Outdated
self.assertEqual(d, self.theclass.fromordinal(n))
n += 1

# Check the first and last days of every month in years at the

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 might actually be a good opportunity to use a property test for the property that theclass.fromordinal(d.toordinal()) == d (handwavy)

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.

Done. I replaced my loop with a property test: fromordinal(d.toordinal()) == d for strategies.dates(). The ends of the 4-, 100- and 400-year cycles, and min/max date, are added as @example, so they run also without Hypothesis. The date is created again with self.theclass, so the test checks the class under test.

Comment thread Lib/_pydatetime.py
day_of_year = rest // 4 # day 0 is March 1
# Month lengths repeat every 5 months (153 days) from March on;
# month 3 is March, ..., month 14 is February.
month, rest = divmod(5 * day_of_year + 461, 153)

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 believe you can do divmod(4 * day_of_year + 3, 153), right?

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 think the factor must stay 5: one 153-day block has 5 months. With divmod(4 * day_of_year + 3, 153), March 1 (day_of_year == 0) gives month 0, and ordinal 1 gives (0, 8, 1).

461 is 3 * 153 + 2, so this is the same:

month, rest = divmod(5 * day_of_year + 2, 153)
month += 3
day = rest // 5 + 1

About the comments: I rewrote them in both versions. Now they explain each step: why the year starts on March 1, where 4*n + 3 comes from, and what 153 and 461 mean. I removed "Figure 12" (in the paper it is the multiply-shift version, not this one) and use the DOI.

Comment thread Modules/_datetimemodule.c Outdated

*day = n + 1;
/* Days since 0000-03-01, the start of the computational calendar. */
const uint64_t days = (uint64_t)ordinal + 305;

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 think we can drop the 64-bit arithmetic here entirely. As far as I can tell, the only reason for it is so that date.fromordinal() with an ordinal past MAXORDINAL still gets to the same "year must be in 1..9999, not N" error that it raises on main, because fromordinal only checks ordinal < 1 before calling ord_to_ymd. The other two callers never pass anything larger than MAXORDINAL + 2: normalize_date already range-checks the ordinal, and iso_to_ymd can reach at most 10000-01-02, the last day of ISO year 9999, which its caller then rejects.

Since the calendar repeats exactly every 400 years, fromordinal can handle the error case by shifting the ordinal into the first 400-year cycle and adding the years back afterwards, which keeps the error message identical without needing a 64-bit path in the hot function:

    if (ordinal < 1) {
        PyErr_SetString(PyExc_ValueError, "ordinal must be >= 1");
        return NULL;
    }
    if (ordinal <= MAXORDINAL) {
        ord_to_ymd(ordinal, &year, &month, &day);
    }
    else {
        /* Out of range: find the year anyway, so new_date_subclass_ex()
         * raises the usual "year must be in 1..9999, not N" error.  The
         * calendar repeats every 400 years (DI400Y days), so convert the
         * equivalent date in the first 400-year cycle and add the years
         * back. */
        int cycles = (ordinal - 1) / DI400Y;
        ord_to_ymd(ordinal - cycles * DI400Y, &year, &month, &day);
        year += 400 * cycles;
    }
    return new_date_subclass_ex(year, month, day, type);

With that change, every intermediate value in ord_to_ymd fits in a uint32_t — 4 * days + 3 tops out around 14.6 million — so the function can assert ordinal <= MAXORDINAL + 2 and the comment explaining why the century step is done in 64 bits can go away. I checked this version against the ord_to_ymd on main for every positive int, and the year, month and day all match.

I did not see a measurable speed difference on x86-64 with either gcc or clang, so this is mostly an argument for simplicity. It may help somewhat on 32-bit platforms, where the 64-bit division can turn into a library call, though I have not measured that. The pure Python version does not need an equivalent change, since it uses arbitrary-precision integers and floor division.

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.

Done, I used your version. ord_to_ymd() is now uint32_t only, with assert(ordinal <= MAXORDINAL + 2), and fromordinal() handles bigger ordinals with the 400-year shift. I checked it against main for all positive int, and the error message is the same. I also added a test that the error still has the correct year for some ordinals after MAXORDINAL.

I checked the changes twice.

mazalovmx and others added 2 commits November 10, 2026 22:01
* ord_to_ymd() uses only uint32_t; fromordinal() shifts larger
  ordinals by 400-year cycles, so the error message is unchanged.
* Replace the hand-picked loop with a Hypothesis round-trip test
  and add tests for the error year after MAXORDINAL.
* Explain each step in the comments of both implementations; cite
  the paper by DOI.
* Update the NEWS entry with new measurements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test.test___all__ imports test.datetimetester, which now imports
Hypothesis.  Hypothesis imports the deprecated typing.AnyStr, and
test___all__ fails on the unexpected DeprecationWarning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting review type-feature A feature request or enhancement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants