Repository navigation
Conversation
… 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>
| * 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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
Add a link, and a rough % speedup.
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>
|
CC @cassioneri this may be of interest. |
| self.assertEqual(d, self.theclass.fromordinal(n)) | ||
| n += 1 | ||
|
|
||
| # Check the first and last days of every month in years at the |
There was a problem hiding this comment.
This might actually be a good opportunity to use a property test for the property that theclass.fromordinal(d.toordinal()) == d (handwavy)
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
I believe you can do divmod(4 * day_of_year + 3, 153), right?
There was a problem hiding this comment.
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 + 1About 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.
|
|
||
| *day = n + 1; | ||
| /* Days since 0000-03-01, the start of the computational calendar. */ | ||
| const uint64_t days = (uint64_t)ordinal + 305; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
* 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>
gh-161746
Verification
mainfor all 3,652,059 ordinals (0001-01-01 … 9999-12-31):SHA-256 over every
date.fromordinal()result, plusfromisocalendar()anddate + timedelta, for both the C and the pure-Python implementations.intordinal (1 … 2**31-1), includingthe error messages past 9999-12-31; checked with gcc 13 and clang.
and huge ones.
test_plistlib and test_xmlrpc pass. The PR adds a Hypothesis property test
(
fromordinal(d.toordinal()) == d, with explicit examples at the ends ofthe 4-, 100- and 400-year cycles) and tests for the year reported in the
error past 9999-12-31.
Lib/test/support/hypothesis_helper.pynow silences thetyping.AnyStrDeprecationWarningthat Hypothesis emits on import; otherwisetest___all__fails when it importstest.datetimetester.Benchmarks
Measured on the first version of the patch. pyperf, x86-64 VM, gcc 13.3,
-O3, no PGO; µs per 1000 operations:date.fromordinal, random datesdate + timedelta(days=40)date.fromisocalendardatetime + timedelta(days=40)date.fromordinal, consecutive datesdate + timedelta(days=1), 1000 stepsAfter the review changes (simple form, 32-bit) I measured again: C
date.fromordinal1.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-Pythondate.fromordinal()is about1.3x faster.
With a PGO build (
--enable-optimizations, gcc 13.3) of main (e3262d9) and ofthe patch, 1,000,000 operations, best of 7, builds alternated:
date.fromordinal, random datesdate + timedelta(days=90)date.fromisoformat('2024-W07-1')date + timedelta(days=10)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