Skip to content

Commit 167304a

Browse files
committed
Add distance query benchmarks
Compare distance filtering/sorting via the registered SQL `distance` function (executed in SQLite) against fetching every row and filtering/sorting in Swift. Two scenarios: a selective radius filter+sort, and a full sort with no filter. Opt-in via the BENCHMARK environment variable so the 100k-row dataset doesn't slow the normal test matrix: BENCHMARK=1 swift test -c release --filter Benchmark
1 parent 5e22547 commit 167304a

1 file changed

Lines changed: 161 additions & 0 deletions

File tree

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
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

Comments
 (0)