Skip to content

Commit 8510e20

Browse files
authored
Performance improvements for Calendar.RecurrenceRule (#981)
The original implementation of `Calendar.RecurrenceRule` expanded recurrences of dates using the Calendar APIs for matching date components. This would result in multiple sequences for matching date components even when just a single sequence would have sufficed, thus requiring more time and memory to complete enumeration E.g: finding the dates for Thanksgivings (fourth Thursday of each November) took ~4 times as much time using RecurrenceRule when compared to simply matching date components. This commit optimizes how we expand dates for recurrences. Instead of creating a sequence for each value of each component in the recurrence rule, we introduce a new type of sequence closely resembling Calendar.DatesByMatching, but which also allows multiple values per date component.
1 parent efdf0f3 commit 8510e20

File tree

4 files changed

+509
-343
lines changed

4 files changed

+509
-343
lines changed

Benchmarks/Benchmarks/Internationalization/BenchmarkCalendar.swift

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ let benchmarks = {
2929
let thanksgivingComponents = DateComponents(month: 11, weekday: 5, weekdayOrdinal: 4)
3030
let cal = Calendar(identifier: .gregorian)
3131
let currentCalendar = Calendar.current
32-
let thanksgivingStart = Date(timeIntervalSinceReferenceDate: 496359355.795410) //2016-09-23T14:35:55-0700
32+
let thanksgivingStart = Date(timeIntervalSince1970: 1474666555.0) //2016-09-23T14:35:55-0700
3333

3434
Benchmark("nextThousandThursdaysInTheFourthWeekOfNovember") { benchmark in
3535
// This benchmark used to be nextThousandThanksgivings, but the name was deceiving since it does not compute the next thousand thanksgivings
@@ -54,7 +54,7 @@ let benchmarks = {
5454
}
5555

5656
// Only available in Swift 6 for non-Darwin platforms, macOS 15 for Darwin
57-
#if swift(>=6.0)
57+
#if compiler(>=6.0)
5858
if #available(macOS 15, *) {
5959
Benchmark("nextThousandThanksgivingsSequence") { benchmark in
6060
var count = 1000
@@ -66,7 +66,7 @@ let benchmarks = {
6666
}
6767
}
6868

69-
Benchmark("nextThousandThanksgivingsUsingRecurrenceRule") { benchmark in
69+
Benchmark("RecurrenceRuleThanksgivings") { benchmark in
7070
var rule = Calendar.RecurrenceRule(calendar: cal, frequency: .yearly, end: .afterOccurrences(1000))
7171
rule.months = [11]
7272
rule.weekdays = [.nth(4, .thursday)]
@@ -77,6 +77,43 @@ let benchmarks = {
7777
}
7878
assert(count == 1000)
7979
}
80+
Benchmark("RecurrenceRuleThanksgivingMeals") { benchmark in
81+
var rule = Calendar.RecurrenceRule(calendar: cal, frequency: .yearly, end: .afterOccurrences(1000))
82+
rule.months = [11]
83+
rule.weekdays = [.nth(4, .thursday)]
84+
rule.hours = [14, 18]
85+
rule.matchingPolicy = .nextTime
86+
for date in rule.recurrences(of: thanksgivingStart) {
87+
Benchmark.blackHole(date)
88+
}
89+
}
90+
Benchmark("RecurrenceRuleLaborDay") { benchmark in
91+
var rule = Calendar.RecurrenceRule(calendar: cal, frequency: .yearly, end: .afterOccurrences(1000))
92+
rule.months = [9]
93+
rule.weekdays = [.nth(1, .monday)]
94+
rule.matchingPolicy = .nextTime
95+
for date in rule.recurrences(of: thanksgivingStart) {
96+
Benchmark.blackHole(date)
97+
}
98+
}
99+
Benchmark("RecurrenceRuleBikeParties") { benchmark in
100+
var rule = Calendar.RecurrenceRule(calendar: cal, frequency: .monthly, end: .afterOccurrences(1000))
101+
rule.weekdays = [.nth(1, .friday), .nth(-1, .friday)]
102+
rule.matchingPolicy = .nextTime
103+
for date in rule.recurrences(of: thanksgivingStart) {
104+
Benchmark.blackHole(date)
105+
}
106+
}
107+
Benchmark("RecurrenceRuleDailyWithTimes") { benchmark in
108+
var rule = Calendar.RecurrenceRule(calendar: cal, frequency: .daily, end: .afterOccurrences(1000))
109+
rule.hours = [9, 10]
110+
rule.minutes = [0, 30]
111+
rule.weekdays = [.every(.monday), .every(.tuesday), .every(.wednesday)]
112+
rule.matchingPolicy = .nextTime
113+
for date in rule.recurrences(of: thanksgivingStart) {
114+
Benchmark.blackHole(date)
115+
}
116+
}
80117
} // #available(macOS 15, *)
81118
#endif // swift(>=6.0)
82119

@@ -93,7 +130,7 @@ let benchmarks = {
93130

94131
// MARK: - Allocations
95132

96-
let reference = Date(timeIntervalSinceReferenceDate: 496359355.795410) //2016-09-23T14:35:55-0700
133+
let reference = Date(timeIntervalSince1970: 1474666555.0) //2016-09-23T14:35:55-0700
97134

98135
let allocationsConfiguration = Benchmark.Configuration(
99136
metrics: [.cpuTotal, .mallocCountTotal, .peakMemoryResident, .throughput],

Sources/FoundationEssentials/Calendar/Calendar_Enumerate.swift

Lines changed: 37 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,7 @@ extension Calendar {
454454
}
455455

456456
internal func _enumerateDates(startingAfter start: Date,
457+
previouslyReturnedMatchDate: Date? = nil,
457458
matching matchingComponents: DateComponents,
458459
matchingPolicy: MatchingPolicy,
459460
repeatedTimePolicy: RepeatedTimePolicy,
@@ -470,7 +471,7 @@ extension Calendar {
470471
let STOP_EXHAUSTIVE_SEARCH_AFTER_MAX_ITERATIONS = 100
471472

472473
var searchingDate = start
473-
var previouslyReturnedMatchDate: Date? = nil
474+
var previouslyReturnedMatchDate = previouslyReturnedMatchDate
474475
var iterations = -1
475476

476477
repeat {
@@ -511,14 +512,8 @@ extension Calendar {
511512
matchingPolicy: MatchingPolicy,
512513
repeatedTimePolicy: RepeatedTimePolicy,
513514
direction: SearchDirection,
514-
inSearchingDate: Date,
515+
inSearchingDate searchingDate: Date,
515516
previouslyReturnedMatchDate: Date?) throws -> SearchStepResult {
516-
var exactMatch = true
517-
var isLeapDay = false
518-
var searchingDate = inSearchingDate
519-
520-
// NOTE: Several comments reference "isForwardDST" as a way to relate areas in forward DST handling.
521-
var isForwardDST = false
522517

523518
// Step A: Call helper method that does the searching
524519

@@ -539,8 +534,25 @@ extension Calendar {
539534
// TODO: Check if returning the same searchingDate has any purpose
540535
return SearchStepResult(result: nil, newSearchDate: searchingDate)
541536
}
537+
538+
return try _adjustedDate(unadjustedMatchDate, startingAfter: start, matching: matchingComponents, adjustedMatchingComponents: compsToMatch , matchingPolicy: matchingPolicy, repeatedTimePolicy: repeatedTimePolicy, direction: direction, inSearchingDate: searchingDate, previouslyReturnedMatchDate: previouslyReturnedMatchDate)
539+
}
540+
541+
internal func _adjustedDate(_ unadjustedMatchDate: Date, startingAfter start: Date,
542+
allowStartDate: Bool = false,
543+
matching matchingComponents: DateComponents,
544+
adjustedMatchingComponents compsToMatch: DateComponents,
545+
matchingPolicy: MatchingPolicy,
546+
repeatedTimePolicy: RepeatedTimePolicy,
547+
direction: SearchDirection,
548+
inSearchingDate: Date,
549+
previouslyReturnedMatchDate: Date?) throws -> SearchStepResult {
550+
var exactMatch = true
551+
var isLeapDay = false
552+
var searchingDate = inSearchingDate
542553

543-
// Step B: Couldn't find matching date with a quick and dirty search in the current era, year, etc. Now try in the near future/past and make adjustments for leap situations and non-existent dates
554+
// NOTE: Several comments reference "isForwardDST" as a way to relate areas in forward DST handling.
555+
var isForwardDST = false
544556

545557
// matchDate may be nil, which indicates a need to keep iterating
546558
// Step C: Validate what we found and then run block. Then prepare the search date for the next round of the loop
@@ -624,7 +636,7 @@ extension Calendar {
624636
}
625637

626638
// If we get a result that is exactly the same as the start date, skip.
627-
if order == .orderedSame {
639+
if !allowStartDate, order == .orderedSame {
628640
return SearchStepResult(result: nil, newSearchDate: searchingDate)
629641
}
630642

@@ -1393,7 +1405,7 @@ extension Calendar {
13931405
}
13941406
}
13951407

1396-
private func dateAfterMatchingEra(startingAt startDate: Date, components: DateComponents, direction: SearchDirection, matchedEra: inout Bool) -> Date? {
1408+
internal func dateAfterMatchingEra(startingAt startDate: Date, components: DateComponents, direction: SearchDirection, matchedEra: inout Bool) -> Date? {
13971409
guard let era = components.era else {
13981410
// Nothing to do
13991411
return nil
@@ -1431,7 +1443,7 @@ extension Calendar {
14311443
}
14321444
}
14331445

1434-
private func dateAfterMatchingYear(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
1446+
internal func dateAfterMatchingYear(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
14351447
guard let year = components.year else {
14361448
// Nothing to do
14371449
return nil
@@ -1466,7 +1478,7 @@ extension Calendar {
14661478
}
14671479
}
14681480

1469-
private func dateAfterMatchingYearForWeekOfYear(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
1481+
internal func dateAfterMatchingYearForWeekOfYear(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
14701482
guard let yearForWeekOfYear = components.yearForWeekOfYear else {
14711483
// Nothing to do
14721484
return nil
@@ -1494,7 +1506,7 @@ extension Calendar {
14941506
}
14951507
}
14961508

1497-
private func dateAfterMatchingQuarter(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
1509+
internal func dateAfterMatchingQuarter(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
14981510
guard let quarter = components.quarter else { return nil }
14991511

15001512
// Get the beginning of the year we need
@@ -1530,7 +1542,7 @@ extension Calendar {
15301542
}
15311543
}
15321544

1533-
private func dateAfterMatchingWeekOfYear(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
1545+
internal func dateAfterMatchingWeekOfYear(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
15341546
guard let weekOfYear = components.weekOfYear else {
15351547
// Nothing to do
15361548
return nil
@@ -1569,7 +1581,7 @@ extension Calendar {
15691581
}
15701582

15711583
@available(FoundationPreview 0.4, *)
1572-
private func dateAfterMatchingDayOfYear(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
1584+
internal func dateAfterMatchingDayOfYear(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
15731585
guard let dayOfYear = components.dayOfYear else {
15741586
// Nothing to do
15751587
return nil
@@ -1606,7 +1618,7 @@ extension Calendar {
16061618
return result
16071619
}
16081620

1609-
private func dateAfterMatchingMonth(startingAt startDate: Date, components: DateComponents, direction: SearchDirection, strictMatching: Bool) throws -> Date? {
1621+
internal func dateAfterMatchingMonth(startingAt startDate: Date, components: DateComponents, direction: SearchDirection, strictMatching: Bool) throws -> Date? {
16101622
guard let month = components.month else {
16111623
// Nothing to do
16121624
return nil
@@ -1695,7 +1707,7 @@ extension Calendar {
16951707
return result
16961708
}
16971709

1698-
private func dateAfterMatchingWeekOfMonth(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
1710+
internal func dateAfterMatchingWeekOfMonth(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
16991711
guard let weekOfMonth = components.weekOfMonth else {
17001712
// Nothing to do
17011713
return nil
@@ -1784,7 +1796,7 @@ extension Calendar {
17841796
return result
17851797
}
17861798

1787-
private func dateAfterMatchingWeekdayOrdinal(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
1799+
internal func dateAfterMatchingWeekdayOrdinal(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
17881800
guard let weekdayOrdinal = components.weekdayOrdinal else {
17891801
// Nothing to do
17901802
return nil
@@ -1887,7 +1899,7 @@ extension Calendar {
18871899
return result
18881900
}
18891901

1890-
private func dateAfterMatchingWeekday(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
1902+
internal func dateAfterMatchingWeekday(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
18911903
guard let weekday = components.weekday else {
18921904
// Nothing to do
18931905
return nil
@@ -1944,7 +1956,7 @@ extension Calendar {
19441956
return result
19451957
}
19461958

1947-
private func dateAfterMatchingDay(startingAt startDate: Date, originalStartDate: Date, components comps: DateComponents, direction: SearchDirection) throws -> Date? {
1959+
internal func dateAfterMatchingDay(startingAt startDate: Date, originalStartDate: Date, components comps: DateComponents, direction: SearchDirection) throws -> Date? {
19481960
guard let day = comps.day else {
19491961
// Nothing to do
19501962
return nil
@@ -2045,7 +2057,7 @@ extension Calendar {
20452057
return result
20462058
}
20472059

2048-
private func dateAfterMatchingHour(startingAt startDate: Date, originalStartDate: Date, components: DateComponents, direction: SearchDirection, findLastMatch: Bool, isStrictMatching: Bool, matchingPolicy: MatchingPolicy) throws -> Date? {
2060+
internal func dateAfterMatchingHour(startingAt startDate: Date, originalStartDate: Date, components: DateComponents, direction: SearchDirection, findLastMatch: Bool, isStrictMatching: Bool, matchingPolicy: MatchingPolicy) throws -> Date? {
20492061
guard let hour = components.hour else {
20502062
// Nothing to do
20512063
return nil
@@ -2182,7 +2194,7 @@ extension Calendar {
21822194
return result
21832195
}
21842196

2185-
private func dateAfterMatchingMinute(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
2197+
internal func dateAfterMatchingMinute(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
21862198
guard let minute = components.minute else {
21872199
// Nothing to do
21882200
return nil
@@ -2211,7 +2223,7 @@ extension Calendar {
22112223
return result
22122224
}
22132225

2214-
private func dateAfterMatchingSecond(startingAt startDate: Date, originalStartDate: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
2226+
internal func dateAfterMatchingSecond(startingAt startDate: Date, originalStartDate: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
22152227
guard let second = components.second else {
22162228
// Nothing to do
22172229
return nil
@@ -2277,7 +2289,7 @@ extension Calendar {
22772289
return result
22782290
}
22792291

2280-
private func dateAfterMatchingNanosecond(startingAt: Date, components: DateComponents, direction: SearchDirection) -> Date? {
2292+
internal func dateAfterMatchingNanosecond(startingAt: Date, components: DateComponents, direction: SearchDirection) -> Date? {
22812293
guard let nanosecond = components.nanosecond else {
22822294
// Nothing to do
22832295
return nil

0 commit comments

Comments
 (0)