|
import Testing |
|
|
|
@Suite("ActorRunnable Performance") |
|
struct ActorRunnablePerformanceTests { |
|
|
|
// MARK: - Test Actor |
|
|
|
/// Actor with individual accessors — each call is one suspension point from outside. |
|
actor ProfileStore: ActorRunnable { |
|
var name = "" |
|
var age = 0 |
|
var email = "" |
|
var score = 0.0 |
|
var isActive = false |
|
|
|
func setName(_ val: String) { name = val } |
|
func setAge(_ val: Int) { age = val } |
|
func setEmail(_ val: String) { email = val } |
|
func setScore(_ val: Double) { score = val } |
|
func setActive(_ val: Bool) { isActive = val } |
|
|
|
func getName() -> String { name } |
|
func getAge() -> Int { age } |
|
func getEmail() -> String { email } |
|
func getScore() -> Double { score } |
|
func getActive() -> Bool { isActive } |
|
} |
|
|
|
// MARK: - Sequential: individual awaits vs batched run |
|
|
|
@Test("batched run eliminates per-property suspension overhead") |
|
func sequentialComparison() async { |
|
let store = ProfileStore() |
|
let clock = ContinuousClock() |
|
let iterations = 10_000 |
|
|
|
// Warm up the actor executor. |
|
await store.setName("warmup") |
|
_ = await store.getName() |
|
|
|
// --- 10 suspension points per iteration --- |
|
let individualTime = await clock.measure { |
|
for idx in 0..<iterations { |
|
await store.setName("Alice") |
|
await store.setAge(idx) |
|
await store.setEmail("alice@test.com") |
|
await store.setScore(Double(idx) * 0.5) |
|
await store.setActive(idx.isMultiple(of: 2)) |
|
|
|
_ = await store.getName() |
|
_ = await store.getAge() |
|
_ = await store.getEmail() |
|
_ = await store.getScore() |
|
_ = await store.getActive() |
|
} |
|
} |
|
|
|
// --- 2 suspension points per iteration --- |
|
let batchedTime = await clock.measure { |
|
for idx in 0..<iterations { |
|
await store.run { iso in |
|
iso.name = "Alice" |
|
iso.age = idx |
|
iso.email = "alice@test.com" |
|
iso.score = Double(idx) * 0.5 |
|
iso.isActive = idx.isMultiple(of: 2) |
|
} |
|
|
|
_ = await store.run { iso in |
|
(iso.name, iso.age, iso.email, iso.score, iso.isActive) |
|
} |
|
} |
|
} |
|
|
|
let speedup = nanoseconds(individualTime) / nanoseconds(batchedTime) |
|
|
|
print(""" |
|
|
|
┌───────────────────────────────────────────────────────┐ |
|
│ Sequential · \(iterations) iterations │ |
|
├───────────────────────────────────────────────────────┤ |
|
│ Individual (10 awaits/iter): \(format(individualTime)) │ |
|
│ Batched (2 awaits/iter): \(format(batchedTime)) │ |
|
│ Speedup: \(String(format: "%.1f", speedup))× │ |
|
└───────────────────────────────────────────────────────┘ |
|
""") |
|
|
|
#expect(batchedTime < individualTime, "Batched run should be faster than individual awaits") |
|
} |
|
|
|
// MARK: - Concurrent: contention amplifies the difference |
|
|
|
@Test("batched run reduces contention under concurrent access") |
|
func concurrentComparison() async { |
|
let store = ProfileStore() |
|
let clock = ContinuousClock() |
|
let tasks = 50 |
|
let iterationsPerTask = 200 |
|
|
|
// --- 10 suspension points per iteration, N concurrent tasks --- |
|
let individualTime = await clock.measure { |
|
await withTaskGroup(of: Void.self) { group in |
|
for _ in 0..<tasks { |
|
group.addTask { |
|
for idx in 0..<iterationsPerTask { |
|
await store.setName("Alice") |
|
await store.setAge(idx) |
|
await store.setEmail("alice@test.com") |
|
await store.setScore(Double(idx) * 0.5) |
|
await store.setActive(idx.isMultiple(of: 2)) |
|
|
|
_ = await store.getName() |
|
_ = await store.getAge() |
|
_ = await store.getEmail() |
|
_ = await store.getScore() |
|
_ = await store.getActive() |
|
} |
|
} |
|
} |
|
} |
|
} |
|
|
|
// --- 2 suspension points per iteration, N concurrent tasks --- |
|
let batchedTime = await clock.measure { |
|
await withTaskGroup(of: Void.self) { group in |
|
for _ in 0..<tasks { |
|
group.addTask { |
|
for idx in 0..<iterationsPerTask { |
|
await store.run { iso in |
|
iso.name = "Alice" |
|
iso.age = idx |
|
iso.email = "alice@test.com" |
|
iso.score = Double(idx) * 0.5 |
|
iso.isActive = idx.isMultiple(of: 2) |
|
} |
|
|
|
_ = await store.run { iso in |
|
(iso.name, iso.age, iso.email, iso.score, iso.isActive) |
|
} |
|
} |
|
} |
|
} |
|
} |
|
} |
|
|
|
let speedup = nanoseconds(individualTime) / nanoseconds(batchedTime) |
|
|
|
print(""" |
|
|
|
┌───────────────────────────────────────────────────────┐ |
|
│ Concurrent · \(tasks) tasks × \(iterationsPerTask) iterations │ |
|
├───────────────────────────────────────────────────────┤ |
|
│ Individual (10 awaits/iter): \(format(individualTime)) │ |
|
│ Batched (2 awaits/iter): \(format(batchedTime)) │ |
|
│ Speedup: \(String(format: "%.1f", speedup))× │ |
|
└───────────────────────────────────────────────────────┘ |
|
""") |
|
|
|
#expect(batchedTime < individualTime, "Batched run should be faster under contention") |
|
} |
|
|
|
// MARK: - Helpers |
|
|
|
private func nanoseconds(_ duration: Duration) -> Double { |
|
let (seconds, attoseconds) = duration.components |
|
return Double(seconds) * 1_000_000_000 + Double(attoseconds) / 1_000_000_000 |
|
} |
|
|
|
private func format(_ duration: Duration) -> String { |
|
let total = nanoseconds(duration) |
|
if total >= 1_000_000_000 { |
|
return String(format: "%.2f s", total / 1_000_000_000) |
|
} else { |
|
return String(format: "%.1f ms", total / 1_000_000) |
|
} |
|
} |
|
} |