|
| 1 | +// |
| 2 | +// DistancePerformanceTests.swift |
| 3 | +// CoreModel-SQLite |
| 4 | +// |
| 5 | +// Compares distance filtering/sorting via a registered SQL function (executed in |
| 6 | +// SQLite) against fetching every row and filtering/sorting in Swift. |
| 7 | +// |
| 8 | +// These are benchmarks, not correctness tests (though they assert the two paths |
| 9 | +// agree). They insert a large dataset and are skipped unless `BENCHMARK` is set in |
| 10 | +// the environment, so they don't slow the normal test matrix. Run with: |
| 11 | +// |
| 12 | +// BENCHMARK=1 swift test -c release --filter Benchmark |
| 13 | +// |
| 14 | + |
| 15 | +import Foundation |
| 16 | +import Testing |
| 17 | +import CoreModel |
| 18 | +import SQLite |
| 19 | +@testable import CoreModelSQLite |
| 20 | + |
| 21 | +/// Benchmarks are opt-in: set `BENCHMARK` in the environment to run them. |
| 22 | +private let benchmarksEnabled = ProcessInfo.processInfo.environment["BENCHMARK"] != nil |
| 23 | + |
| 24 | +private let benchmarkReferenceLatitude = 35.7796 |
| 25 | +private let benchmarkReferenceLongitude = -78.6382 |
| 26 | +private let benchmarkRowCount = 100_000 |
| 27 | +private let benchmarkIterations = 10 |
| 28 | + |
| 29 | +private func benchmarkHaversine(_ lat1: Double, _ lon1: Double, _ lat2: Double, _ lon2: Double) -> Double { |
| 30 | + let earthRadius = 6_371_000.0 |
| 31 | + let dLat = (lat2 - lat1) * .pi / 180 |
| 32 | + let dLon = (lon2 - lon1) * .pi / 180 |
| 33 | + let a = sin(dLat / 2) * sin(dLat / 2) |
| 34 | + + cos(lat1 * .pi / 180) * cos(lat2 * .pi / 180) * sin(dLon / 2) * sin(dLon / 2) |
| 35 | + return earthRadius * 2 * atan2(a.squareRoot(), (1 - a).squareRoot()) |
| 36 | +} |
| 37 | + |
| 38 | +private let benchmarkDistanceFunction = DatabaseFunction(name: "distance", argumentCount: 4) { arguments in |
| 39 | + guard case let .double(lat1) = arguments[0], case let .double(lon1) = arguments[1], |
| 40 | + case let .double(lat2) = arguments[2], case let .double(lon2) = arguments[3] else { return nil } |
| 41 | + return .double(benchmarkHaversine(lat1, lon1, lat2, lon2)) |
| 42 | +} |
| 43 | + |
| 44 | +/// A database seeded with `benchmarkRowCount` sites spread across the continental US, |
| 45 | +/// with the `distance` function registered. |
| 46 | +private func makeBenchmarkDatabase() async throws -> SQLiteDatabase { |
| 47 | + let model = Model(entities: [ |
| 48 | + EntityDescription(id: "Site", attributes: [ |
| 49 | + .init(id: "latitude", type: .double), |
| 50 | + .init(id: "longitude", type: .double) |
| 51 | + ], relationships: []) |
| 52 | + ]) |
| 53 | + let database = try SQLiteDatabase(path: temporaryDatabasePath(named: "Benchmark"), model: model) |
| 54 | + try await database.register(function: benchmarkDistanceFunction) |
| 55 | + |
| 56 | + var rng = SystemRandomNumberGenerator() |
| 57 | + var rows = [ModelData]() |
| 58 | + rows.reserveCapacity(benchmarkRowCount) |
| 59 | + for index in 0..<benchmarkRowCount { |
| 60 | + rows.append(ModelData(entity: "Site", id: ObjectID(rawValue: "s\(index)"), attributes: [ |
| 61 | + "latitude": .double(Double.random(in: 24...49, using: &rng)), |
| 62 | + "longitude": .double(Double.random(in: -125 ... -66, using: &rng)) |
| 63 | + ])) |
| 64 | + } |
| 65 | + try await database.insert(rows) |
| 66 | + return database |
| 67 | +} |
| 68 | + |
| 69 | +private func benchmarkDistanceSort() -> FetchRequest.SortDescriptor { |
| 70 | + .init(term: .function(.init(name: "distance", arguments: [ |
| 71 | + .keyPath("latitude"), .keyPath("longitude"), |
| 72 | + .attribute(.double(benchmarkReferenceLatitude)), .attribute(.double(benchmarkReferenceLongitude)) |
| 73 | + ])), ascending: true) |
| 74 | +} |
| 75 | + |
| 76 | +private func benchmarkDistanceExpression() -> FetchRequest.Predicate.Expression { |
| 77 | + .function(.init(name: "distance", arguments: [ |
| 78 | + .keyPath("latitude"), .keyPath("longitude"), |
| 79 | + .attribute(.double(benchmarkReferenceLatitude)), .attribute(.double(benchmarkReferenceLongitude)) |
| 80 | + ])) |
| 81 | +} |
| 82 | + |
| 83 | +/// Filter+sort by distance in Swift over already-fetched rows. |
| 84 | +private func benchmarkInMemory(_ rows: [ModelData], radius: Double) -> [ObjectID] { |
| 85 | + rows.compactMap { row -> (ObjectID, Double)? in |
| 86 | + guard case let .double(latitude)? = row.attributes["latitude"], |
| 87 | + case let .double(longitude)? = row.attributes["longitude"] else { return nil } |
| 88 | + let distance = benchmarkHaversine(latitude, longitude, benchmarkReferenceLatitude, benchmarkReferenceLongitude) |
| 89 | + return distance <= radius ? (row.id, distance) : nil |
| 90 | + } |
| 91 | + .sorted { $0.1 < $1.1 } |
| 92 | + .map(\.0) |
| 93 | +} |
| 94 | + |
| 95 | +@Suite(.enabled(if: benchmarksEnabled, "set BENCHMARK in the environment to run")) |
| 96 | +struct DistancePerformanceBenchmarks { |
| 97 | + |
| 98 | + /// Selective radius filter + distance sort: SQLite returns only the matches, so the |
| 99 | + /// custom-function path avoids materializing the excluded rows. |
| 100 | + @Test func benchmarkSelectiveFilterAndSort() async throws { |
| 101 | + let database = try await makeBenchmarkDatabase() |
| 102 | + let radius = 500_000.0 // 500 km |
| 103 | + let sqlRequest = FetchRequest( |
| 104 | + entity: "Site", |
| 105 | + sortDescriptors: [benchmarkDistanceSort()], |
| 106 | + predicate: .comparison(.init(left: benchmarkDistanceExpression(), right: .attribute(.double(radius)), type: .lessThanOrEqualTo)) |
| 107 | + ) |
| 108 | + let allRequest = FetchRequest(entity: "Site") |
| 109 | + |
| 110 | + #expect(try await database.fetch(sqlRequest).count == benchmarkInMemory(try await database.fetch(allRequest), radius: radius).count) |
| 111 | + |
| 112 | + let clock = ContinuousClock() |
| 113 | + var sqlMatches = 0 |
| 114 | + let sqlStart = clock.now |
| 115 | + for _ in 0..<benchmarkIterations { sqlMatches = try await database.fetch(sqlRequest).count } |
| 116 | + let sqlTime = clock.now - sqlStart |
| 117 | + |
| 118 | + var memMatches = 0 |
| 119 | + let memStart = clock.now |
| 120 | + for _ in 0..<benchmarkIterations { memMatches = benchmarkInMemory(try await database.fetch(allRequest), radius: radius).count } |
| 121 | + let memTime = clock.now - memStart |
| 122 | + |
| 123 | + print(""" |
| 124 | +
|
| 125 | + ===== Selective filter+sort: \(benchmarkRowCount) rows, \(sqlMatches) matches, \(benchmarkIterations) iterations ===== |
| 126 | + SQL (filter+sort in SQLite): avg \(sqlTime / benchmarkIterations) |
| 127 | + in-memory (fetch-all + Swift): avg \(memTime / benchmarkIterations) |
| 128 | + """) |
| 129 | + #expect(sqlMatches == memMatches) |
| 130 | + } |
| 131 | + |
| 132 | + /// Sort by distance with no filter: every row is returned, so both paths materialize |
| 133 | + /// the whole table and the SQL path gains nothing from filtering in the database. |
| 134 | + @Test func benchmarkSortNoFilter() async throws { |
| 135 | + let database = try await makeBenchmarkDatabase() |
| 136 | + let sqlRequest = FetchRequest(entity: "Site", sortDescriptors: [benchmarkDistanceSort()]) |
| 137 | + let allRequest = FetchRequest(entity: "Site") |
| 138 | + |
| 139 | + // no radius filter: `.infinity` keeps every row |
| 140 | + #expect(try await database.fetch(sqlRequest).map(\.id) == benchmarkInMemory(try await database.fetch(allRequest), radius: .infinity)) |
| 141 | + |
| 142 | + let clock = ContinuousClock() |
| 143 | + var sqlCount = 0 |
| 144 | + let sqlStart = clock.now |
| 145 | + for _ in 0..<benchmarkIterations { sqlCount = try await database.fetch(sqlRequest).count } |
| 146 | + let sqlTime = clock.now - sqlStart |
| 147 | + |
| 148 | + var memCount = 0 |
| 149 | + let memStart = clock.now |
| 150 | + for _ in 0..<benchmarkIterations { memCount = benchmarkInMemory(try await database.fetch(allRequest), radius: .infinity).count } |
| 151 | + let memTime = clock.now - memStart |
| 152 | + |
| 153 | + print(""" |
| 154 | +
|
| 155 | + ===== Sort, no filter: \(benchmarkRowCount) rows returned, \(benchmarkIterations) iterations ===== |
| 156 | + SQL (ORDER BY in SQLite): avg \(sqlTime / benchmarkIterations) |
| 157 | + in-memory (fetch-all + Swift sort): avg \(memTime / benchmarkIterations) |
| 158 | + """) |
| 159 | + #expect(sqlCount == memCount) |
| 160 | + } |
| 161 | +} |
0 commit comments