Skip to content

Hebrew calendar fast paths + shared calendar helpers#2028

Open
dra8an wants to merge 9 commits into
swiftlang:mainfrom
dra8an:port/hebrew-perf-and-dedup
Open

Hebrew calendar fast paths + shared calendar helpers#2028
dra8an wants to merge 9 commits into
swiftlang:mainfrom
dra8an:port/hebrew-perf-and-dedup

Conversation

@dra8an

@dra8an dra8an commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

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). _CalendarHebrew implements 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)
  • Time only {hour, minute, second}

Calendar_Enumerate.swift probes the fast path at init; Calendar_Recurrence.swift adds single combination, multi combination cartesian, and negative ordinal translation short circuits for RecurrenceRule.

Commit 2: Extract shared calendar helpers into _CalendarConstants + _CalendarUtility

Addresses PR #1953 review feedback (comments #6 + #7). Introduces shared static helpers for time unit constants, hash(into:), firstWeekday, minimumDaysInFirstWeek, copy(), and isDateInWeekend. 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.

Benchmark ICU (C++) Native Swift Speedup
nextThousandHanukkahs (enumerate {month,day}) 55 μs 167 ns 325×
dateComponents {year,month,day} (10K dates) 3 ns/date 1 ns/date 2–3×
roundTripDateComponents (10K dates) 6 ns/date 2 ns/date 2–3×
Calendar instantiation + date(byAdding:) 514 ns 122 ns 4.2×
Copy on write mutation 1,736 ns 31 ns 54×

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.
  • All existing tests pass (1100 tests, 59 suites).

Cross calendar safety

  • Non implementing calendars are unchanged: the protocol default returns nil, leaving all existing paths intact.
  • The RecurrenceRule short circuits probe with a sentinel _calendarNextDate call first; non Hebrew calendars bail before any expensive work.

dra8an added 2 commits June 9, 2026 13:03
  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).
@dra8an dra8an requested a review from a team as a code owner June 9, 2026 20:07
Comment thread Sources/FoundationEssentials/Calendar/Calendar_Enumerate.swift Outdated
Comment thread Sources/FoundationEssentials/Calendar/Calendar_Enumerate.swift
//===----------------------------------------------------------------------===//

/// Time-unit constants shared across calendar implementations.
internal enum _CalendarConstants {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why do we need a new scope instead of just using struct Calendar?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Moved to extension Calendar instead.

Comment thread Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift Outdated
Comment thread Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift Outdated
dra8an added a commit to dra8an/swift-foundation that referenced this pull request Jun 10, 2026
…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.
Comment thread Sources/FoundationEssentials/Calendar/Calendar.swift
Comment thread Sources/FoundationEssentials/Calendar/Calendar.swift Outdated
Comment thread Sources/FoundationEssentials/Calendar/Calendar.swift Outdated

self.usesFastPath = validates && matchingPolicy == .nextTime && repeatedTimePolicy == .first
&& calendar._supportsNextDateFastPath
&& calendar._calendarNextDate(after: start, matching: matchingComponents, direction: direction) != nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can you elaborate why this check?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread Sources/FoundationEssentials/Calendar/Calendar_Protocol.swift Outdated
Comment thread Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift Outdated
Comment thread Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift Outdated
}

/// 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(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It looks like this is only called when we're taking the fast path. Any chance we can make it obvious?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Renamed to _fastPathDateComponents

Comment thread Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift
Comment thread Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift
Comment thread Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift Outdated

// Build a base DateComponents with single valued axes, then vary only the multi valued ones.
var base = DateComponents()
var axes: [(WritableKeyPath<DateComponents, Int?>, [Int])] = []

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Replaced with direct property assignment. No more KeyPath dispatch.

Comment thread Sources/FoundationEssentials/Calendar/Calendar.swift Outdated
Comment thread Sources/FoundationEssentials/Calendar/Calendar_Enumerate.swift Outdated
Comment thread Sources/FoundationEssentials/Calendar/Calendar_Hebrew.swift Outdated
Comment thread Sources/FoundationEssentials/Calendar/CalendarUtility.swift Outdated
Comment thread Sources/FoundationEssentials/Calendar/Calendar_Enumerate.swift
Comment thread Sources/FoundationEssentials/Calendar/Calendar_Enumerate.swift Outdated
guard let foundRange = dateInterval(of: .month, for: result) else {
throw CalendarEnumerationError.dateOutOfRange(.month, result)
// Fast-path: advance to target month directly.
if !isLeapMonthDesired || !strictMatching {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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?

Comment thread Sources/FoundationEssentials/Calendar/Calendar_Enumerate.swift
Comment thread Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift
Comment thread Sources/FoundationEssentials/Calendar/Calendar_Protocol.swift Outdated
}

/// Single-valued `DateComponents` from combinations, or nil if expansion is needed.
fileprivate func _singleCombinationDateComponents(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do we really need this function? it reads to me that _single​Combination​Date​Components 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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed _singleCombinationDateComponents entirely. _fastPathDateComponents now handles total == 1. _unadjustedDates has a single call to _fastPathDateComponents

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

not sure what you meant.. This function is still here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift Outdated
dra8an added a commit to dra8an/swift-foundation that referenced this pull request Jun 25, 2026
dra8an added a commit to dra8an/swift-foundation that referenced this pull request Jun 25, 2026
@dra8an dra8an force-pushed the port/hebrew-perf-and-dedup branch from 7f62f46 to 47bad03 Compare June 25, 2026 22:55
dra8an added a commit to dra8an/swift-foundation that referenced this pull request Jun 25, 2026
@dra8an dra8an force-pushed the port/hebrew-perf-and-dedup branch from 47bad03 to 76b9730 Compare June 25, 2026 23:02
Comment thread Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift Outdated
Comment thread Sources/FoundationEssentials/Calendar/Calendar_Recurrence.swift Outdated
if let dc = _singleCombinationDateComponents(combinationComponents),
_supportsNextDateFastPath(for: dc),
let fast = _calendarNextDate(after: startDate, matching: dc, direction: .forward) {
return [(fast, dc)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Am I looking at the wrong commit? It doesn't look like it's fixed. I still see a one-space indentation here

@itingliu itingliu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Just one more comment

repeatedTimePolicy: RepeatedTimePolicy) throws -> [(Date, DateComponents)]? {

// Fast-path short-circuits. Only fires when the calendar opts in.
if matchingPolicy == .nextTime && repeatedTimePolicy == .first {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

// 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?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

@dra8an dra8an force-pushed the port/hebrew-perf-and-dedup branch from 76b9730 to 684c4b8 Compare June 26, 2026 21:43
@itingliu

Copy link
Copy Markdown
Contributor

@swift-ci please test

@dra8an dra8an requested a review from parkera June 30, 2026 22:21
return true
}
let timeInDay = TimeInterval(
(comps.hour ?? 0) * Calendar._kSecondsInHour

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do other fields like era or dayOfYear matter here? Maybe ignoring them is specific to the Hebrew calendar implementation...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

There's really no need to make these abbreviations for argument labels.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed

}

/// Fast path for `{h, mi, s, ns?}` — next date with the requested time-of-day.
private func nextTimeOfDayMatch(rd: Int64, currentSecsInDay: Double,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Same thing here about argument labels - why rd? I'm actually not sure what it means in this context.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Let's give this a sensible name while we're at it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Renamed to _maxDateIntervalDuration


extension Calendar {
/// Time unit constants shared across calendar implementations.
static let _kSecondsInWeek = 604_800

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This _k prefix is very "C". It can just be secondsInWeek.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Renamed to lose k

@parkera parkera self-requested a review July 2, 2026 23:37
@parkera

parkera commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

I removed my CR and just left some really minor style nitpicks for now. Otherwise looks good to me with @itingliu 's approval.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants