Hebrew calendar fast paths + shared calendar helpers#2028
Conversation
Adds _CalendarProtocol.nextDate(after:matching:direction:) as an
optional fast-path hook (default returns nil). _CalendarHebrew
implements it for {month, day}, {month, weekday, weekdayOrdinal},
{month, weekday, weekOfMonth}, and time-only patterns. Adds
RecurrenceRule single-combination, multi-combination cartesian, and
negative-ordinal short-circuits.
Non-implementing calendars are unchanged (the default nil leaves
existing paths intact).
Benchmarks (debug, vs ICU-backed Hebrew baseline):
- nextThousandThanksgivings: ~250x faster
- nextThousandThursdaysInTheFourthWeekOfNovember: ~107x faster
- RecurrenceRuleThanksgivings: 19x faster
- RecurrenceRuleDailyWithTimes: ~8x faster
Suite C (HebrewRecurrenceRuleParityProbe): 13 tests, 392 rule shapes
x 2088 date comparisons, 0 divergences vs Foundation's ICU-backed
Hebrew calendar.
…ility Addresses PR swiftlang#1953 review feedback (comments swiftlang#6 + swiftlang#7) by introducing shared static helpers for time-unit constants, hash(into:), firstWeekday, minimumDaysInFirstWeek, copy(), and isDateInWeekend. _CalendarHebrew adopts the helpers in this commit; _CalendarGregorian adoption follows in a subsequent PR. The structural value: single source of truth for the shared logic, and ~85 lines of boilerplate skipped per future calendar port (Islamic / Persian / Coptic / Japanese / etc.). Hebrew's adoption demonstrates the pattern. Side effect: _CalendarHebrew.isDateInWeekend now matches _CalendarGregorian.isDateInWeekend exactly (resolves a fractional- second divergence the extraction surfaced).
| //===----------------------------------------------------------------------===// | ||
|
|
||
| /// Time-unit constants shared across calendar implementations. | ||
| internal enum _CalendarConstants { |
There was a problem hiding this comment.
Why do we need a new scope instead of just using struct Calendar?
There was a problem hiding this comment.
Moved to extension Calendar instead.
…tions Back-syncs upstream commit 0127031 (PR swiftlang#2028 review feedback): - supportsNextDateFastPath Bool opt-in property on _CalendarProtocol - _CalendarConstants moved to Calendar extension with _kSecondsIn* prefix - _expandedDateComponents refactored from 8-deep loops to axis-based - Fast-path entry conditions consolidated - Verbose doc comments trimmed Two local-only corrections that do NOT exist upstream: - Per-call probe added to Calendar.enumerateDates fast-path gate. - Per-call probe added to DatesByMatching.Iterator.init usesFastPath chain. Without the probes, Hebrew opts in via the Bool but returns nil for patterns it can't fast-path (year, era, weekOfYear, dayOfYear, weekday+day, etc.). Framework commits to fast loop and emits nil to the user. With the probes, framework falls through to the generic enumerate path for unsupported patterns. Restores per-pattern fall-through that the pre-v26 framework had naturally. Hebrew extension: nextDate now handles partial time patterns (hour-only, minute-only, second-only) via new nextTimeOfDayPeriodicMatch helper. Fixes 14 metonicCycle_nineteenYears divergences from Suite B. Verified: 211/211 tests in 15 suites pass; Suite C 0 divergences. Divergence tracking: backup/LOCAL_VS_UPSTREAM_DIVERGENCE.md is authoritative — must be preserved across all future back-syncs. Snapshots: - backup/v25-frozen-pre-v26/ — pre-v26 state. - backup/v26-pr2028-review-feedback/ — post-v26 state with README.
|
|
||
| self.usesFastPath = validates && matchingPolicy == .nextTime && repeatedTimePolicy == .first | ||
| && calendar._supportsNextDateFastPath | ||
| && calendar._calendarNextDate(after: start, matching: matchingComponents, direction: direction) != nil |
There was a problem hiding this comment.
Same question: Why does usesFastPath depend on both _supportsNextDateFastPath AND _calendarNextDate(after: start, matching: matchingComponents, direction: direction) being nil? This doesn't really separate the concept of supporting-fastpath-or-not from the result of the enumeration, which basically brings us back to the previous version, right? Am I missing something?
There was a problem hiding this comment.
Same reasoning as above. The boolean avoids calling nextDate at all for non-opt-in calendars (zero cost). The probe confirms the specific pattern is handled. If the probe returns nil, usesFastPath stays false and the iterator uses _enumerateDatesStep for every call which are correct results via the slow path.
| guard let foundRange = dateInterval(of: .month, for: result) else { | ||
| throw CalendarEnumerationError.dateOutOfRange(.month, result) | ||
| // Fast-path: advance to target month directly. | ||
| if !isLeapMonthDesired || !strictMatching { |
There was a problem hiding this comment.
Can you elaborate why this check?
There was a problem hiding this comment.
The fast path advances to the target month directly via nextDate. But when isLeapMonthDesired && strictMatching, the logic below needs to distinguish between the leap and non-leap variant of the month (e.g. Adar vs Adar I in Hebrew). The fast path doesn't carry that distinction, so we skip it and let the existing month-matching logic handle it.
There was a problem hiding this comment.
The fast path doesn't carry that distinction, so we skip it and let the existing month-matching logic handle it.
How would the callsites know what the fast path supports and what not? Is this something we can fold into supportsNextDateFastPath()?
Also, it looks like elsewhere we always only use the fast path when this condition holds:
_supportsNextDateFastPath(for: matchingComponents) && matchingPolicy == .nextTime && repeatedTimePolicy == .first
Would it make sense to modify _supportsNextDateFastPath to
_supportsNextDateFastPath(for:matchingPolicy:repeatedTimePolicy:)
so we consolidate that check?
There was a problem hiding this comment.
supportsNextDateFastPath(for:) already tells callsites whether a given pattern is supported. For the policy check, the fast path only works with .nextTime + .first because it does exact matching (no approximation).
Folding that into supportsNextDateFastPath(for:matchingPolicy:repeatedTimePolicy is reasonable but would mean the helper methods need access to those parameters (they're currently not passed in).
For now the policy check lives at the top level entry points (enumerateDates, DatesByMatching.init, _enumerateDatesStep, _unadjustedDates) which are the only places policies are available. The helpers below are only reached after that check already passed.
| } | ||
|
|
||
| /// Expand `_DateComponentCombinations` into a flat array of single-valued `DateComponents`. Negative ordinals are translated to `{month, weekday, weekOfMonth}` using `anchor`'s month structure. Returns nil if the pattern can't be expanded. | ||
| fileprivate func _expandedDateComponents( |
There was a problem hiding this comment.
It looks like this is only called when we're taking the fast path. Any chance we can make it obvious?
There was a problem hiding this comment.
Renamed to _fastPathDateComponents
|
|
||
| // Build a base DateComponents with single valued axes, then vary only the multi valued ones. | ||
| var base = DateComponents() | ||
| var axes: [(WritableKeyPath<DateComponents, Int?>, [Int])] = [] |
There was a problem hiding this comment.
Keypath isn't the most performant way to do this because it involves runtime dispatch. I'd encourage trying profiling it with plain accessors to get an idea of its cost
There was a problem hiding this comment.
Replaced with direct property assignment. No more KeyPath dispatch.
| guard let foundRange = dateInterval(of: .month, for: result) else { | ||
| throw CalendarEnumerationError.dateOutOfRange(.month, result) | ||
| // Fast-path: advance to target month directly. | ||
| if !isLeapMonthDesired || !strictMatching { |
There was a problem hiding this comment.
The fast path doesn't carry that distinction, so we skip it and let the existing month-matching logic handle it.
How would the callsites know what the fast path supports and what not? Is this something we can fold into supportsNextDateFastPath()?
Also, it looks like elsewhere we always only use the fast path when this condition holds:
_supportsNextDateFastPath(for: matchingComponents) && matchingPolicy == .nextTime && repeatedTimePolicy == .first
Would it make sense to modify _supportsNextDateFastPath to
_supportsNextDateFastPath(for:matchingPolicy:repeatedTimePolicy:)
so we consolidate that check?
| } | ||
|
|
||
| /// Single-valued `DateComponents` from combinations, or nil if expansion is needed. | ||
| fileprivate func _singleCombinationDateComponents( |
There was a problem hiding this comment.
Do we really need this function? it reads to me that _singleCombinationDateComponents is just the degenerate case of _fastPathDateComponents. Can we not use that one by folding fast paths into that one? Or alternatively is there any common logic we can extract from this function and _fastPathDateComponents since they look very similar?
There was a problem hiding this comment.
Removed _singleCombinationDateComponents entirely. _fastPathDateComponents now handles total == 1. _unadjustedDates has a single call to _fastPathDateComponents
There was a problem hiding this comment.
not sure what you meant.. This function is still here?
There was a problem hiding this comment.
I checked the latest commit dra8an@76b9730 and it is showed as removed
| func nextDate(after date: Date, matching components: DateComponents, direction: Calendar.SearchDirection) -> Date? | ||
|
|
||
| /// Whether this calendar can fast path the given pattern. Default returns false; calendar implementations override for patterns they handle. | ||
| func supportsNextDateFastPath(for components: DateComponents) -> Bool |
There was a problem hiding this comment.
I wonder if it's better not to use date component as the argument for validation. Instead, we can pass in all of the fields separately
_supportsNextDateFastPath(era:year:month:day:...)
The reason is that DateComponents aren't cheap memory and performance wise.
It makes sense if we already have a DateComponent such as when we enter from the Calendar enumeration API. But in, say, Calendar_RecurrenceRule call site, we construct DateComponents solely for passing into this function, so that we can get the value out of the DateComponents. This roundtrip feels unnecessary.
There was a problem hiding this comment.
Changed to supportsNextDateFastPath(for components: Calendar.ComponentSet). ComponentSet is a UInt bitmask, zero allocation. Call sites that have a DateComponents use ._populatedComponentSet helper that know their literals like [.month] or [.weekday] directly.
7f62f46 to
47bad03
Compare
47bad03 to
76b9730
Compare
| if let dc = _singleCombinationDateComponents(combinationComponents), | ||
| _supportsNextDateFastPath(for: dc), | ||
| let fast = _calendarNextDate(after: startDate, matching: dc, direction: .forward) { | ||
| return [(fast, dc)] |
There was a problem hiding this comment.
Am I looking at the wrong commit? It doesn't look like it's fixed. I still see a one-space indentation here
| repeatedTimePolicy: RepeatedTimePolicy) throws -> [(Date, DateComponents)]? { | ||
|
|
||
| // Fast-path short-circuits. Only fires when the calendar opts in. | ||
| if matchingPolicy == .nextTime && repeatedTimePolicy == .first { |
There was a problem hiding this comment.
// Only fires when the calendar opts in.
Which part is this? It looks like this code is called without checking for fast path?
If this is stale can you go over the existing comments to make sure they're updated as you update the implementation?
There was a problem hiding this comment.
Indeed. The opt-in check (supportsNextDateFastPath) happens inside _probeAllFastPath per pattern, not at this level. Updated the comment to reflect that. Also did a pass over other comments to make sure they're current
76b9730 to
684c4b8
Compare
|
@swift-ci please test |
| return true | ||
| } | ||
| let timeInDay = TimeInterval( | ||
| (comps.hour ?? 0) * Calendar._kSecondsInHour |
There was a problem hiding this comment.
Seems inconsistent that we define a constant for seconds in hour but not for minutes in second... maybe it should just be 3600 here. I'm ok with limited magic numbers. Not blocking, I guess.
There was a problem hiding this comment.
Fixed. Now uses Calendar._secondsInMinute instead of the magic number 60.
| if hasWeekday && hasMonth && !hasWdOrd { return nil } | ||
| if !hasMonth && !hasDay && !hasWeekday { return nil } | ||
| let hasWeekOfMonth = components.weekOfMonth != nil | ||
| let timeOnly = !hasMonth && !hasDay && !hasWeekday |
There was a problem hiding this comment.
Do other fields like era or dayOfYear matter here? Maybe ignoring them is specific to the Hebrew calendar implementation...
There was a problem hiding this comment.
They're already rejected by supportsNextDateFastPath which returns false for era, dayOfYear, weekOfYear, yearForWeekOfYear. By the time nextDate is called, those are guaranteed absent.
| targetSecsInDay: secsInDay, forward: forward, tz: tz) | ||
| } | ||
|
|
||
| if hasWdOrd { |
There was a problem hiding this comment.
There's really no need to make these abbreviations for argument labels.
| } | ||
|
|
||
| /// Fast path for `{h, mi, s, ns?}` — next date with the requested time-of-day. | ||
| private func nextTimeOfDayMatch(rd: Int64, currentSecsInDay: Double, |
There was a problem hiding this comment.
Same thing here about argument labels - why rd? I'm actually not sure what it means in this context.
There was a problem hiding this comment.
Renamed to rataDie (Rata Die is the fixed day number convention from Calendrical Calculations). Internal parameter stays rd for brevity in the arithmetic
| static let _kSecondsInMinute = 60 | ||
|
|
||
| /// Sentinel used by unbounded range loops in date arithmetic. | ||
| static let _inf_ti: TimeInterval = 4398046511104.0 |
There was a problem hiding this comment.
Let's give this a sensible name while we're at it.
There was a problem hiding this comment.
Renamed to _maxDateIntervalDuration
|
|
||
| extension Calendar { | ||
| /// Time unit constants shared across calendar implementations. | ||
| static let _kSecondsInWeek = 604_800 |
There was a problem hiding this comment.
This _k prefix is very "C". It can just be secondsInWeek.
|
I removed my CR and just left some really minor style nitpicks for now. Otherwise looks good to me with @itingliu 's approval. |
Hebrew calendar fast paths + shared calendar helpers
Follow up to PR #1953 (Hebrew calendar port, merged as #2017).
Summary
Commit 1: Add Hebrew calendar fast paths + shared protocol method
Adds
_CalendarProtocol.nextDate(after:matching:direction:)as an optional fast path hook (default returns nil, existing paths unchanged for all other calendars)._CalendarHebrewimplements it for:{month, day}(annual recurrence, e.g. Hanukkah){month, weekday, weekdayOrdinal}(Nth weekday of month){month, weekday, weekOfMonth}(weekday in Nth week of month){hour, minute, second}Calendar_Enumerate.swiftprobes the fast path at init;Calendar_Recurrence.swiftadds single combination, multi combination cartesian, and negative ordinal translation short circuits forRecurrenceRule.Commit 2: Extract shared calendar helpers into
_CalendarConstants+_CalendarUtilityAddresses PR #1953 review feedback (comments #6 + #7). Introduces shared static helpers for time unit constants,
hash(into:),firstWeekday,minimumDaysInFirstWeek,copy(), andisDateInWeekend. Hebrew adopts them; Gregorian adoption deferred to a follow up PR.Performance
Measured on arm64 (M3 Max), release build,
swift-benchmark. Zero heap allocations in all cases.nextThousandHanukkahs(enumerate {month,day})dateComponents{year,month,day} (10K dates)roundTripDateComponents(10K dates)date(byAdding:)The 325× enumeration speedup comes from O(1) direct computation fast paths for common date matching patterns, bypassing the generic iterate and test framework entirely.
Testing
HebrewRecurrenceRuleParityProbe.swift: 13 tests, 392 rule shapes × 2,088 date comparisons, 0 divergences vs Foundation's ICU backed Hebrew calendar.Cross calendar safety
RecurrenceRuleshort circuits probe with a sentinel_calendarNextDatecall first; non Hebrew calendars bail before any expensive work.