Skip to content

Commit

Permalink
Performance improvements for Calendar.RecurrenceRule (#981)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
hristost authored Oct 22, 2024
1 parent efdf0f3 commit 8510e20
Show file tree
Hide file tree
Showing 4 changed files with 509 additions and 343 deletions.
45 changes: 41 additions & 4 deletions Benchmarks/Benchmarks/Internationalization/BenchmarkCalendar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ let benchmarks = {
let thanksgivingComponents = DateComponents(month: 11, weekday: 5, weekdayOrdinal: 4)
let cal = Calendar(identifier: .gregorian)
let currentCalendar = Calendar.current
let thanksgivingStart = Date(timeIntervalSinceReferenceDate: 496359355.795410) //2016-09-23T14:35:55-0700
let thanksgivingStart = Date(timeIntervalSince1970: 1474666555.0) //2016-09-23T14:35:55-0700

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

// Only available in Swift 6 for non-Darwin platforms, macOS 15 for Darwin
#if swift(>=6.0)
#if compiler(>=6.0)
if #available(macOS 15, *) {
Benchmark("nextThousandThanksgivingsSequence") { benchmark in
var count = 1000
Expand All @@ -66,7 +66,7 @@ let benchmarks = {
}
}

Benchmark("nextThousandThanksgivingsUsingRecurrenceRule") { benchmark in
Benchmark("RecurrenceRuleThanksgivings") { benchmark in
var rule = Calendar.RecurrenceRule(calendar: cal, frequency: .yearly, end: .afterOccurrences(1000))
rule.months = [11]
rule.weekdays = [.nth(4, .thursday)]
Expand All @@ -77,6 +77,43 @@ let benchmarks = {
}
assert(count == 1000)
}
Benchmark("RecurrenceRuleThanksgivingMeals") { benchmark in
var rule = Calendar.RecurrenceRule(calendar: cal, frequency: .yearly, end: .afterOccurrences(1000))
rule.months = [11]
rule.weekdays = [.nth(4, .thursday)]
rule.hours = [14, 18]
rule.matchingPolicy = .nextTime
for date in rule.recurrences(of: thanksgivingStart) {
Benchmark.blackHole(date)
}
}
Benchmark("RecurrenceRuleLaborDay") { benchmark in
var rule = Calendar.RecurrenceRule(calendar: cal, frequency: .yearly, end: .afterOccurrences(1000))
rule.months = [9]
rule.weekdays = [.nth(1, .monday)]
rule.matchingPolicy = .nextTime
for date in rule.recurrences(of: thanksgivingStart) {
Benchmark.blackHole(date)
}
}
Benchmark("RecurrenceRuleBikeParties") { benchmark in
var rule = Calendar.RecurrenceRule(calendar: cal, frequency: .monthly, end: .afterOccurrences(1000))
rule.weekdays = [.nth(1, .friday), .nth(-1, .friday)]
rule.matchingPolicy = .nextTime
for date in rule.recurrences(of: thanksgivingStart) {
Benchmark.blackHole(date)
}
}
Benchmark("RecurrenceRuleDailyWithTimes") { benchmark in
var rule = Calendar.RecurrenceRule(calendar: cal, frequency: .daily, end: .afterOccurrences(1000))
rule.hours = [9, 10]
rule.minutes = [0, 30]
rule.weekdays = [.every(.monday), .every(.tuesday), .every(.wednesday)]
rule.matchingPolicy = .nextTime
for date in rule.recurrences(of: thanksgivingStart) {
Benchmark.blackHole(date)
}
}
} // #available(macOS 15, *)
#endif // swift(>=6.0)

Expand All @@ -93,7 +130,7 @@ let benchmarks = {

// MARK: - Allocations

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

let allocationsConfiguration = Benchmark.Configuration(
metrics: [.cpuTotal, .mallocCountTotal, .peakMemoryResident, .throughput],
Expand Down
62 changes: 37 additions & 25 deletions Sources/FoundationEssentials/Calendar/Calendar_Enumerate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,7 @@ extension Calendar {
}

internal func _enumerateDates(startingAfter start: Date,
previouslyReturnedMatchDate: Date? = nil,
matching matchingComponents: DateComponents,
matchingPolicy: MatchingPolicy,
repeatedTimePolicy: RepeatedTimePolicy,
Expand All @@ -470,7 +471,7 @@ extension Calendar {
let STOP_EXHAUSTIVE_SEARCH_AFTER_MAX_ITERATIONS = 100

var searchingDate = start
var previouslyReturnedMatchDate: Date? = nil
var previouslyReturnedMatchDate = previouslyReturnedMatchDate
var iterations = -1

repeat {
Expand Down Expand Up @@ -511,14 +512,8 @@ extension Calendar {
matchingPolicy: MatchingPolicy,
repeatedTimePolicy: RepeatedTimePolicy,
direction: SearchDirection,
inSearchingDate: Date,
inSearchingDate searchingDate: Date,
previouslyReturnedMatchDate: Date?) throws -> SearchStepResult {
var exactMatch = true
var isLeapDay = false
var searchingDate = inSearchingDate

// NOTE: Several comments reference "isForwardDST" as a way to relate areas in forward DST handling.
var isForwardDST = false

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

Expand All @@ -539,8 +534,25 @@ extension Calendar {
// TODO: Check if returning the same searchingDate has any purpose
return SearchStepResult(result: nil, newSearchDate: searchingDate)
}

return try _adjustedDate(unadjustedMatchDate, startingAfter: start, matching: matchingComponents, adjustedMatchingComponents: compsToMatch , matchingPolicy: matchingPolicy, repeatedTimePolicy: repeatedTimePolicy, direction: direction, inSearchingDate: searchingDate, previouslyReturnedMatchDate: previouslyReturnedMatchDate)
}

internal func _adjustedDate(_ unadjustedMatchDate: Date, startingAfter start: Date,
allowStartDate: Bool = false,
matching matchingComponents: DateComponents,
adjustedMatchingComponents compsToMatch: DateComponents,
matchingPolicy: MatchingPolicy,
repeatedTimePolicy: RepeatedTimePolicy,
direction: SearchDirection,
inSearchingDate: Date,
previouslyReturnedMatchDate: Date?) throws -> SearchStepResult {
var exactMatch = true
var isLeapDay = false
var searchingDate = inSearchingDate

// 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
// NOTE: Several comments reference "isForwardDST" as a way to relate areas in forward DST handling.
var isForwardDST = false

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

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

Expand Down Expand Up @@ -1393,7 +1405,7 @@ extension Calendar {
}
}

private func dateAfterMatchingEra(startingAt startDate: Date, components: DateComponents, direction: SearchDirection, matchedEra: inout Bool) -> Date? {
internal func dateAfterMatchingEra(startingAt startDate: Date, components: DateComponents, direction: SearchDirection, matchedEra: inout Bool) -> Date? {
guard let era = components.era else {
// Nothing to do
return nil
Expand Down Expand Up @@ -1431,7 +1443,7 @@ extension Calendar {
}
}

private func dateAfterMatchingYear(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
internal func dateAfterMatchingYear(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
guard let year = components.year else {
// Nothing to do
return nil
Expand Down Expand Up @@ -1466,7 +1478,7 @@ extension Calendar {
}
}

private func dateAfterMatchingYearForWeekOfYear(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
internal func dateAfterMatchingYearForWeekOfYear(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
guard let yearForWeekOfYear = components.yearForWeekOfYear else {
// Nothing to do
return nil
Expand Down Expand Up @@ -1494,7 +1506,7 @@ extension Calendar {
}
}

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

// Get the beginning of the year we need
Expand Down Expand Up @@ -1530,7 +1542,7 @@ extension Calendar {
}
}

private func dateAfterMatchingWeekOfYear(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
internal func dateAfterMatchingWeekOfYear(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
guard let weekOfYear = components.weekOfYear else {
// Nothing to do
return nil
Expand Down Expand Up @@ -1569,7 +1581,7 @@ extension Calendar {
}

@available(FoundationPreview 0.4, *)
private func dateAfterMatchingDayOfYear(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
internal func dateAfterMatchingDayOfYear(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
guard let dayOfYear = components.dayOfYear else {
// Nothing to do
return nil
Expand Down Expand Up @@ -1606,7 +1618,7 @@ extension Calendar {
return result
}

private func dateAfterMatchingMonth(startingAt startDate: Date, components: DateComponents, direction: SearchDirection, strictMatching: Bool) throws -> Date? {
internal func dateAfterMatchingMonth(startingAt startDate: Date, components: DateComponents, direction: SearchDirection, strictMatching: Bool) throws -> Date? {
guard let month = components.month else {
// Nothing to do
return nil
Expand Down Expand Up @@ -1695,7 +1707,7 @@ extension Calendar {
return result
}

private func dateAfterMatchingWeekOfMonth(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
internal func dateAfterMatchingWeekOfMonth(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
guard let weekOfMonth = components.weekOfMonth else {
// Nothing to do
return nil
Expand Down Expand Up @@ -1784,7 +1796,7 @@ extension Calendar {
return result
}

private func dateAfterMatchingWeekdayOrdinal(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
internal func dateAfterMatchingWeekdayOrdinal(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
guard let weekdayOrdinal = components.weekdayOrdinal else {
// Nothing to do
return nil
Expand Down Expand Up @@ -1887,7 +1899,7 @@ extension Calendar {
return result
}

private func dateAfterMatchingWeekday(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
internal func dateAfterMatchingWeekday(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
guard let weekday = components.weekday else {
// Nothing to do
return nil
Expand Down Expand Up @@ -1944,7 +1956,7 @@ extension Calendar {
return result
}

private func dateAfterMatchingDay(startingAt startDate: Date, originalStartDate: Date, components comps: DateComponents, direction: SearchDirection) throws -> Date? {
internal func dateAfterMatchingDay(startingAt startDate: Date, originalStartDate: Date, components comps: DateComponents, direction: SearchDirection) throws -> Date? {
guard let day = comps.day else {
// Nothing to do
return nil
Expand Down Expand Up @@ -2045,7 +2057,7 @@ extension Calendar {
return result
}

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

private func dateAfterMatchingMinute(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
internal func dateAfterMatchingMinute(startingAt: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
guard let minute = components.minute else {
// Nothing to do
return nil
Expand Down Expand Up @@ -2211,7 +2223,7 @@ extension Calendar {
return result
}

private func dateAfterMatchingSecond(startingAt startDate: Date, originalStartDate: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
internal func dateAfterMatchingSecond(startingAt startDate: Date, originalStartDate: Date, components: DateComponents, direction: SearchDirection) throws -> Date? {
guard let second = components.second else {
// Nothing to do
return nil
Expand Down Expand Up @@ -2277,7 +2289,7 @@ extension Calendar {
return result
}

private func dateAfterMatchingNanosecond(startingAt: Date, components: DateComponents, direction: SearchDirection) -> Date? {
internal func dateAfterMatchingNanosecond(startingAt: Date, components: DateComponents, direction: SearchDirection) -> Date? {
guard let nanosecond = components.nanosecond else {
// Nothing to do
return nil
Expand Down
Loading

0 comments on commit 8510e20

Please sign in to comment.